Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyのメモリリークを見つけて修正する方法:memory_profilerとderailed_benchmarks徹底活用ガイド

本記事は、メモリリークに関する2部構成シリーズの後編です。前編では、Rubyがどのようにメモリを管理し、ガベージコレクション(GC)がどのように動作するのかを解説しました。

大容量メモリを搭載した高性能なマシンを用意することはできるかもしれませんし、アプリを頻繁に再起動すればユーザーが気づかないかもしれません。それでも、メモリ使用量は重要です。

メモリの確保(アロケーション)とガベージコレクションは無料ではありません。メモリリークがあると、本来アプリにさせたい処理ではなく、ガベージコレクションに費やす時間がどんどん増えていきます。

この記事では、メモリリークの発見と診断に役立つツールについて、さらに深く掘り下げていきます。

それでは始めましょう!

Rubyでメモリリークを発見する

リークを「検知」すること自体はそれほど難しくありません。GCモジュールやObjectSpaceモジュール、APMツールのRSS(常駐メモリサイズ)グラフを使えば、メモリ使用量の増加を監視できます。しかし、リークの存在を知るだけでは問題を解決できません。リークがどこから発生しているのかを突き止める必要があります。生の数値だけではその情報は得られません。

幸い、Rubyのエコシステムには、これらの数値に文脈を持たせてくれる優れたツールが存在します。その代表格がmemory_profilerderailed_benchmarksです。

Rubyにおけるmemory_profiler

memory_profiler gemは、非常にシンプルなAPIを提供し、確保(allocated)されたメモリと保持(retained)されたメモリに関する詳細なレポートを出力します。レポートには、生成されたオブジェクトのクラス、サイズ、そしてどこで確保されたかという情報まで含まれています(情報量が多くやや圧倒されることもありますが)。リークのあるプログラムへの組み込みも簡単です。


実行すると、次のようなレポートが出力されます。


レポートには大量の情報が含まれていますが、リークを探す際には特にallocated objects by location(ロケーション別の確保オブジェクト)とretained objects by location(ロケーション別の保持オブジェクト)のセクションが役立ちます。これらは、オブジェクトを確保したファイル上の場所を、確保数順に並べたものです。

  • allocated(確保)オブジェクトreportブロック内で確保(生成)されたすべてのオブジェクトを指します。
  • retained(保持)オブジェクトreportブロック終了時点でまだガベージコレクションされていないオブジェクトを指します。リークしているオブジェクトをより明確に確認できるよう、ブロックの終了前に強制的にGCを実行させています。

ただし、retainedオブジェクトの数値を鵜呑みにするのは危険です。この数値は、リークしているコードのどの部分がreportブロック内に入っているかに大きく依存します。

例えば、an_arrayの宣言をreportブロックの中に移動すると、「このコードにはリークがない」と誤認してしまう可能性があります。


実際に出力されるレポートの上部には、保持されているオブジェクトはほとんど表示されません(レポート自体のみ)。


Rubyにおけるderailed_benchmarks

derailed_benchmarks gemは、さまざまなパフォーマンス作業に役立つツール群であり、主にRailsアプリ向けに設計されています。メモリリークの調査では、perf:mem_over_timeperf:objectsperf:heap_diffの3つのタスクが特に有用です。

これらのタスクは、稼働中のアプリに対してcurlリクエストを送信することで動作するため、小さなスクリプトには組み込めません。代わりに、リクエストごとにメモリをリークするエンドポイントを持つ小さなRailsアプリを用意し、そこにderailed_benchmarksをインストールする必要があります。



これでbin/rails sでアプリを起動できるようになります。リクエストのたびにメモリをリークするエンドポイントに対してcurlを実行できます。


準備が整ったので、derailed_benchmarksを使ってリークの様子を観察してみましょう。

perf:mem_over_time

このタスクは、時間経過に伴うメモリ使用量を表示します(前編でwatchpsを使ってリークするスクリプトのメモリ増加を観察したのと同じ要領です)。

Derailedは本番モードでアプリを起動し、指定のエンドポイント(デフォルトは/)に繰り返しアクセスしてメモリ使用量をレポートします。メモリ使用量が増え続けて止まらなければ、それはメモリリークです!


注意:Derailedはテスト実行時にRailsアプリを本番モードで起動します。デフォルトでは最初にrequire rails/allも実行されます。このサンプルアプリにはデータベースがないため、環境変数DERAILED_SKIP_ACTIVE_RECORD=trueを設定してこの挙動を無効化する必要があります。

このベンチマークを異なるエンドポイントに対して実行すれば、どのエンドポイントが(あるいはどれも)リークしているのかを特定できます。

perf:objects

perf:objectsタスクは内部でmemory_profilerを使用しているため、出力されるレポートは見覚えのあるものになるでしょう。


このレポートは、リークしたメモリがどこで確保されているのかを絞り込むのに役立ちます。今回の例では、レポートの最後のセクションであるRetained String Reportが、問題の核心を正確に教えてくれます。


LeaksControllerの3行目から、"ABC"を含む文字列が10,000個リークしていることがわかりました。実際的な規模のアプリでは、このレポートは大幅に大きくなり、意図的に保持したい文字列(クエリキャッシュなど)も含まれるでしょう。しかし、このセクションやその他の「ロケーション別」セクションは、リーク箇所の絞り込みに必ず役立つはずです。

perf:heap_diff

perf:objectsの出力が複雑すぎてリーク元を特定できない場合に、perf:heap_diffベンチマークが役立ちます。

名前の通り、perf:heap_diffは3つのヒープダンプを作成し、その差分を計算します。ダンプ間で保持されていたオブジェクトの型と、それらを確保した場所を含むレポートを生成してくれます。


さらに詳しい仕組みについては、「Tracking a Ruby memory leak in 2021」を読んで理解を深めるのもおすすめです。

このレポートのおかげで、リークしているサンプルアプリの問題箇所へまっすぐたどり着けます。diffの先頭には、LeaksControllerの3行目で確保され、保持され続けている999,991個の文字列オブジェクトが表示されています。

実際のRuby / Railsアプリで起こるメモリリーク

ここまで使ってきた例のようなコードが、現実のアプリに紛れ込んでいないことを願います。わざわざメモリをリークさせたい人はいないでしょうから!

実際の規模のアプリでは、メモリリークの追跡ははるかに困難です。保持されたオブジェクトが常に悪いとは限りません。アイテムがガベージコレクションされてしまうキャッシュでは、ほとんど役に立ちませんよね。

しかし、すべてのリークには共通点があります。どこかの時点で、ルートレベルのオブジェクト(クラスやグローバル変数など)が、何らかのオブジェクトへの参照を保持し続けているのです。

典型的な例の一つが、上限も退避ポリシー(eviction policy)も持たないキャッシュです。定義上、これは必ずメモリリークになります。キャッシュに投入されたすべてのオブジェクトが永遠に残り続けるからです。時間が経つにつれて、このキャッシュはアプリのメモリをどんどん占有していき、実際に使われている割合はどんどん低下していきます。

次のコードは、ゲームのハイスコアを取得する例です。過去に筆者が実際に見かけたものと似ています。このリクエストはコストが高いためキャッシュしたいところですが、ハイスコアが更新されたときに簡単にキャッシュを無効化できます。


@scoresハッシュは完全に無制御です。全ユーザーの全ハイスコアを保持するまで成長し続けます。ユーザー数やスコア数が多い場合、これは理想的とは言えません。

Railsアプリであれば、代わりに適切な有効期限を設定したRails.cacheを使うべきでしょう(Redisでのメモリリークも、やはりメモリリークです!)。

Rails以外のアプリでは、ハッシュのサイズに上限を設け、最も古いアイテムや最も長く使われていないアイテムを退避させるのがよいでしょう。LruReduxはその良い実装です。

この種のリークには、より巧妙なバージョンもあります。それは「上限はあるものの、キーのサイズが不定」なキャッシュです。キー自体が大きくなれば、キャッシュも大きくなります。通常はこの問題に遭遇することはありません。しかし、オブジェクトをJSONとしてシリアライズし、それをキーとして使う場合は注意が必要です。ユーザーの既読メッセージ一覧のように、利用とともに成長していくデータをシリアライズしていないか、必ず確認してください。

循環参照

循環参照はガベージコレクション可能です。Rubyのガベージコレクションは「Mark & Sweep(マーク&スイープ)」アルゴリズムを使用しています。可変幅アロケーション(Variable Width Allocation)を紹介したプレゼンテーションの中で、Peter Zhu氏とMatt Valentine-House氏が、このアルゴリズムの仕組みについて素晴らしい説明をしていました。

基本的に、アルゴリズムには「マーク」と「スイープ」という2つのフェーズがあります。

  • マークフェーズでは、ガベージコレクタがルートオブジェクト(クラス、グローバル変数など)から開始し、それらにマークを付けてから、参照先のオブジェクトを調べます。

    次に、参照されているすべてのオブジェクトにマークを付けます。すでにマーク済みのオブジェクトは再度調べられません。これを、調べる対象がなくなる――つまり、参照されるすべてのオブジェクトにマークが付くまで――繰り返します。

  • その後、ガベージコレクタはスイープフェーズに移行します。マークされていないオブジェクトはすべて回収(クリーンアップ)されます。

したがって、有効な参照を持つオブジェクトでも回収され得ます。ルートオブジェクトから最終的にそのオブジェクトへ到達できない限り、回収の対象となるのです。この仕組みにより、循環参照を含むオブジェクトのクラスタでも、ガベージコレクションによって回収できます。

APM活用:イベントタイムラインとアロケーショングラフ

シリーズ前編でも述べたように、本番レベルのアプリには何らかの形でアプリケーションパフォーマンスモニタリング(APM)を導入すべきです。

選択肢は多数あります。自社開発という道もありますが(大きなチームでない限り非推奨)、APMから最低限得たい機能の一つが、アクション(またはバックグラウンドジョブ)が実行するアロケーション数を確認できることです。優れたAPMツールなら、これをさらに分解し、アロケーションがコントローラ由来なのかビュー由来なのかといった内訳まで提供してくれます。

これは一般に「イベントタイムライン」と呼ばれます。タイムラインをさらに細分化するカスタム計測コードを書けるAPMなら、なお良いですね。

次のRailsコントローラのコードを見てみましょう。


APMで計測すると、イベントタイムラインはAppSignalのスクリーンショットのように表示されます。

Rubyのメモリリークを見つけて修正する方法:memory_profilerとderailed_benchmarks徹底活用ガイド

コードに計測ポイントを仕込めば、タイムライン上のどの部分のコードがアロケーションを行っているのかを確認できます。実際のアプリでは、コードを見てもここまで明白ではないでしょうけどね😅


こちらもAppSignalの、計測済みイベントタイムラインの例です。

Rubyのメモリリークを見つけて修正する方法:memory_profilerとderailed_benchmarks徹底活用ガイド

どこに計測ポイントを仕込むべきかの判断は、意外と難しいものです。自分のアプリのコードを深く理解することに代わるものはありませんが、判断のヒントとなる「コードの臭い(smell)」はいくつか存在します。

APMがGC実行回数やアロケーション数の推移を表示している場合は、急激なスパイクに注目しましょう。それが特定のエンドポイントへのアクセスや、特定のバックグラウンドジョブの実行と一致するかどうかを確認できます。以下はAppSignalのRuby VMマジックダッシュボードからの別の例です。

Rubyのメモリリークを見つけて修正する方法:memory_profilerとderailed_benchmarks徹底活用ガイド

このようにアロケーションを観察することで、メモリ問題を調査する際の検索範囲を狭められます。結果として、memory_profilerderailed_benchmarksといったツールを効率的に使いやすくなります。

アロケーション統計やGC統計トラッキングなど、AppSignal Ruby gemの最新機能についてもぜひ読んでみてください。

まとめ

本記事では、メモリリークの発見と修正に役立つさまざまなツールを掘り下げました。具体的には、memory_profilerderailed_benchmarksperf:mem_over_timeperf:objectsperf:heap_diff、そしてAppSignalのイベントタイムラインとアロケーショングラフです。

本記事と前編が、皆さんのRubyアプリのメモリリーク診断と解決の一助となれば幸いです。

本記事で使用したツールの詳細はこちら:

  • memory_profiler
  • derailed_benchmarks
  • リークするRailsアプリのサンプル

さらに詳しく読みたい方向けの資料:

  • GCモジュールドキュメント
  • ObjectSpaceモジュールドキュメント
  • Garbage Collection Deep Dive
  • Variable Width Allocation

それでは、Happy coding!

P.S. 「Ruby Magic」の新着記事をいち早くお読みになりたい方は、Ruby Magicニュースレターをご購読ください。記事を見逃すことはありません!

  1. Minitestの仕組みをソースコードで徹底解説!Rubyテストフレームワークの内部動作

    Minitestとは? MinitestはRubyのテストライブラリで、TDD(テスト駆動開発)スタイルでコードのテストを書くためのツールです。 Railsのデフォルトのテストフレームワークであり、Railsの生みの親であるDHH(David Heinemeier Hansson)のお気に入りでもあります。 主な競合であるRSpecと比較して、シンプルでコード量が少ない点を評価してMinitestを選ぶ開発者も多くいます。 下の画像をご覧ください: ただし、この記事の目的は「どちらを選ぶべきか」「どちらが優れているか」を語ることではありません。 この記事で扱うのは、Minitestが実際にど

  2. シングルページアプリを捨ててTurbolinksを使う?Rails開発者のためのPJAX活用ガイド

    Turbolinks――おそらくRails界で最も忌み嫌われている言葉のひとつでしょう。 試したことがある方も多いはずです。新規プロジェクトや既存アプリにTurbolinksを組み込んだ途端、アプリは奇妙で不可解なバグを連発するようになりました。幸い、解決策は簡単でした。Turbolinksをオフにすればよかったのです。 ……しかし、上手に使いこなしている企業も存在します。Honeybadgerでも実際に使いこなしています。そして、私たちは天才ではありません。 その答えはあまりにシンプルなので、あえて取り上げるのを躊躇するほどです。とはいえ、Ruby NationやMadison+Rubyでこ