R14 - Ruby でのメモリ割り当ての超過 (MRI)
最終更新日 2022年07月22日(金)
Table of Contents
Ruby アプリケーションが Dyno で使用可能な量以上のメモリを使用する場合、R14 - メモリ割り当ての超過のエラーメッセージがアプリケーションのログに書き込まれます。この記事は、アプリケーションのメモリ使用の把握に役立ち、メモリエラーを起こさずにアプリケーションを実行するためのツールを提供するように意図されています。
メモリエラーが重要な理由
R14 - メモリ割り当ての超過エラーが表示された場合、アプリケーションがスワップメモリを使用していることを意味します。スワップは、RAM ではなくディスクを使用してメモリを保存します。ディスク速度は RAM よりかなり遅いので、ページアクセス時間が大幅に増加します。これにより、アプリケーションパフォーマンスが大幅に低下します。スワップしているアプリケーションは、スワップしていないアプリケーションよりもかなり遅くなります。遅いアプリケーションは誰にも望まれないので、アプリケーションで R14 メモリ割り当ての超過エラーを排除することが非常に重要になります。
問題の検出
この記事を読んでいるということは、すでに問題を見つけていることと思われます。そうでない場合は、アプリのダッシュボードで Application Metrics (アプリケーション関連のメトリクス) を使用して過去 24 時間のメモリ使用を表示できます。または、発生したエラーが時折表示されるログを確認できます。
2011-05-03T17:40:11+00:00 heroku[worker.1]: Error R14 (Memory quota exceeded)
Ruby メモリの仕組み
Ruby でのメモリの消費方法を理解して、メモリの使用量を減らすのに役立ちます。詳細は、「Ruby でのメモリの使用方法」および「Why does my App’s Memory Use Grow Over Time?」(アプリのメモリ使用が時間の経過と共に増えるのはなぜですか?) を参照してください。
Ruby 2.0 アップグレード
Ruby 2.0 から 2.1+ へのアップグレードでは、世代別ガーベジコレクションが導入されました。これは、Ruby 2.1+ アプリケーションがより高速に実行するが、より多くのメモリを使用することを意味します。最新リリースの Ruby バージョンを実行することを常にお勧めしています。最新のセキュリティ、バグフィックス、パフォーマンスパッチがあります。
メモリのわずかな増加が見られる場合は、下のテクニックを使用して、許容可能なレベルに使用量を減らすことができます。
メモリリーク
メモリリークは、時間の経過とともに増加し続けるメモリと定義されています。メモリの問題があるほとんどのアプリケーションには、「メモリリーク」があると定義されていますが、十分に長い期間これらのアプリケーションを実行させておくと、メモリ使用は横ばいになります。
アプリケーションにメモリリークがあることが確実な場合は、これを試してみることができます。まず、derailed で動的なベンチマークを実行できることを確認します。次に、ある期間にわたって RAM 使用のベンチマークを実行し、アプリにメモリリークが発生しているかどうかを判断できます。
Worker 数の超過
Puma などの現在の Ruby Web サーバーでは、並列プロセスでユーザーへのリクエストを処理できます。Puma ではこれらを「Worker」プロセスと呼んでいます。一般に、Worker が増加するとスループットが増えますが、RAM の使用も増加します。RAM の上限を超えず、アプリケーションをスワップさせずに使用している Puma Worker の数を最大にすることができます。
起動時の Worker 数の超過
アプリケーションが起動するとすぐに R14 エラーをスローし始める場合、過剰な数の Worker を設定していることが原因である可能性があります。WEB_CONCURRENCY
環境設定をより低い値に変更することで、これを修正できる場合があります。
たとえば、config/puma.rb
ファイルに次の内容がある場合、
# config/puma.rb
workers Integer(ENV['WEB_CONCURRENCY'] || 2)
Worker 数を軽減できます
$ heroku config:set WEB_CONCURRENCY=1
一部のアプリケーションでは、2 つの Puma Worker が原因で、standard-1x
dyno が提供できる量を超える RAM が使用されます。これが発生した場合でも、スレッドを使用して、スループットを増大させることができます。または、dyno サイズをアップグレードして、実行する Worker の数を増やすことができます。
時間の経過に伴う Worker 数の超過
アプリケーションのメモリ使用は、時間の経過とともに増えます。正常に開始したが、徐々に増加して RAM の上限を超えた場合、いくつかの方法を試してみることができます。すぐに上限に達する場合は、Puma Worker の合計数を減らしてみます。上限に達するまで数時間かかる場合、Puma Worker Killer と呼ばれる応急策を試してみることができます。
Puma Worker Killer を使用すると、Puma Worker の Worker の再起動の繰り返しを設定できます。この意図は、アプリケーションがどの間隔で過剰なメモリを使用し始めるのかを把握することです。続いて、アプリケーションがその間隔で Worker を再起動するようにスケジュールを設定します。プロセスを再起動すると、メモリは元の低いレベルに戻ります。メモリがまだ増大する場合でも、別の再起動をスケジュールしているさらに数時間は、問題は発生しません。
この gem を使用するには、Gemfile に次のように追加します。
gem "puma_worker_killer"
続いて $ bundle install
を実行し、config/initializers/puma_worker_killer.rb
などのイニシャライザーにこれを追加します。
PumaWorkerKiller.enable_rolling_restart
これでは、メモリ問題は実際には修正されず、その代わりに問題を隠してしまうので注意が必要です。Worker が再起動すると、数秒間リクエストに応答できないので、繰り返しの再起動がトリガーされると、エンドユーザーは、アプリケーション全体のスループットが低下したときに減速を経験することがあります。再起動すると、スループットは通常に戻ります。
メモリ問題を識別し修正できるまで、応急処置として、Puma Worker Killer を使用することを強くお勧めします。いくつかの提案を以下に取り上げます。
Puma Worker プロセスのフォーク動作
Puma は、フォークを介してその Worker プロセスを実装します。プログラムをフォークする場合、空のプロセスで始める代わりに、実行中のプログラムを新しいプロセスにコピーしてから変更します。現在のほとんどのオペレーティングシステムでは、「コピーオンライト」と呼ばれる概念で、プロセス間でメモリを共有できます。Puma は新しい Worker を起動させるときに、メモリをあまり必要とはしません。Puma がメモリを変更したりメモリに「書き込む」必要がある場合にのみ、あるプロセスから別のプロセスにメモリの位置をコピーします。現在の Ruby バージョンは、不必要にメモリに書き込まず、適宜コピーオンライトを行うように最適化されています。これは、Puma が新しい Worker を起動すると、前のものより小さくなる可能性があることを意味します。Mac の 「Activity Monitor」 (アクティビティモニター) で、または Linux の ps
を介して、この動作をローカルで監視できます。大量のメモリを消費する大きなプロセスがあり、続いてより小さなプロセスがあります。そのため、1 つの Worker を使用する Puma が 300 MB の RAM を消費していた場合、2 つの Worker を使用すると、合計で 600 MB 未満の RAM を消費する可能性があります。
起動時のメモリの超過
メモリ使用の一般的な原因は、Gemfile で要求されているライブラリによるもので、使用されているものではありません。derailed ベンチマーク gem を通じて、起動時に gem がどれだけのメモリを使用するかを確認できます。
最初に gem を Gemfile に追加します。
gem 'derailed', group: :development
次に $ bundle install
を実行すると、メモリ使用を調べる準備が整います。次のように実行できます。
$ bundle exec derailed bundle:mem
これにより、メモリに入れる必要があるときに、それぞれの gem によるメモリ使用が出力されます。
$ derailed bundle:mem
TOP: 54.1836 MiB
mail: 18.9688 MiB
mime/types: 17.4453 MiB
mail/field: 0.4023 MiB
mail/message: 0.3906 MiB
action_view/view_paths: 0.4453 MiB
action_view/base: 0.4336 MiB
使用していないライブラリを削除します。ライブラリが非常に大量のメモリを使用しているとわかった場合は、最新バージョンにアップグレードして、問題が修正されているかどうかを確認してみてください。問題が解決しない場合は、ライブラリメンテナンス機能で問題を開き、必要時のメモリを減らすために行える対処法がないかどうかを確認します。このプロセスを支援するために、$ bundle exec derailed bundle:objects
を使用できます。詳細については、derailed ベンチマークの Objects created at Require time (必要時に作成されたオブジェクト) を参照してください。
ランタイムで使用されるメモリの超過
未使用の gem を空にしていても、まだメモリ使用の超過が見られる場合は、過剰な量の Ruby オブジェクトを生成するコードが存在する可能性があります。Heroku Add-on Scout などのランタイムツールを使用できます。Scout は、ランタイムメモリ使用のデバッグに関するガイドを発行しました。
ランタイムにオブジェクト割り当てを追跡できるツールを使用しない場合、割り当てをローカルで再現することにより、derailed ベンチマークでこのメモリが増大する動作をローカルで再現してみることができます。
GC 調整
すべてのアプリケーションは動作が異なるため、推奨できる正しい GC (ガーベジコレクター) 値のセットというものはありません。
メモリの利用状況に関しては、RUBY_GC_HEAP_GROWTH_FACTOR
を設定することにより、Ruby がメモリを割り当てる速さを制御できます。Ruby のバージョンが異なると、この値は変わります。その仕組みを理解するには、最初に Ruby のメモリの使用方法を理解すると役立ちます。
Ruby は、メモリ不足になり、ガーベジコレクターを介してどのスロットも解放できない場合、さらにメモリを必要とすることをオペレーティングシステムに伝える必要があります。オペレーティングシステムにメモリを要求することは、高コスト (低速) なプロセスであり、Ruby は常に必要な分より少し多く要求しようとします。この RUBY_GC_HEAP_GROWTH_FACTOR
環境設定を設定することにより、要求するメモリの量を制御できます。たとえば、メモリが割り当てられるたびに 3% ずつ割り当てを増やすようにアプリケーションに指示する場合、次のように設定できます。
$ heroku config:set RUBY_GC_HEAP_GROWTH_FACTOR=1.03
したがって、アプリケーションのサイズが 100 MB で、機能するにはそれ以上のメモリが必要になる場合、この設定を使用すれば、余分な 3 MB の RAM を OS に要求します。これにより、Ruby が使用できるメモリの合計量が 103 MB になります。メモリが非常に短時間で増大する場合は、この値をさらに小さな数値に設定してみます。非常に低い値に設定すると、Ruby が OS にメモリを要求する時間が長くなることに注意してください。
通常、dyno のメモリ制限を超えることがめったにない場合、RUBY_GC_HEAP_GROWTH_FACTOR
の調整は R14 エラーにしか役立ちません。または、アプリケーションが数時間実行し続けた後で、非常に大量の「階段ステップ」式のメモリ割り当てが見られる場合です。個々のアプリが、独自の GC 調整設定変数の設定および保持を担っています。
マルチスレッド環境での malloc による過剰なメモリ使用
2019 年 9 月以降に作成されたアプリケーションでは、環境変数 MALLOC_ARENA_MAX=2 が設定されています。
マルチスレッド環境での malloc の動作によって、メモリ使用が大幅に増えることがあります。
このような malloc の動作を回避するために、jemalloc などの別のメモリ割り当て関数を使用して malloc を置き換えることができます。Heroku の malloc を jemalloc に置き換える場合、サードパーティ製の jemalloc buildpack を使用できます。
または、サードパーティ製の buildpack を使用しない場合は、メモリ消費を少なくするように glibc メモリ動作を調整することができます。ただし、このアプローチは、アプリケーションのパフォーマンスに影響することがあります。
dyno サイズとパフォーマンス
dyno は、共有インフラストラクチャ上で実行する Standard dyno と、ランタイムインスタンス全体を消費する Performance dyno の 2 つのタイプがあります。dyno サイズを増やすときには、消費できるメモリの量を増やします。アプリケーションが同時に 2 つ以上のリクエストに応答できない場合、リクエストキューイングに従います。理想的には、アプリケーションは、少なくとも 2 つの Puma Worker プロセスを実行できる dyno で実行している必要があります。前述のように、追加の Puma Worker プロセスは、消費する RAM が 最初のプロセスより少なくなります。より大きな dyno サイズにアップグレードし、Worker 数を倍にしながら、合計の dyno の半数だけを使用することにより、アプリケーションの消費を同じままに保つことができます。
この記事は主にメモリに関するものですが、焦点は速度にあります。そのトピックでは、Performance dyno は、「うるさい隣人」から分離されているので、さらに一貫性の高いパフォーマンスを示すことを強調しています。ほとんどのアプリケーションは、Performance dyno 上で実行するとパフォーマンスが大きく改善します。
追加リソース
- アプリのメモリ使用量が次第に増加する理由- まずは、システムのメモリ使用量が増加する原因について、こちらの概要を参照してください。Ruby によるアプリケーションレベルでのメモリの割り当てと使用の仕組みについて、理解を深めるために役立ちます。
- Complete Guide to Rails Performance (書籍) - この Nate Berkopec 氏の著書は高く評価されています。
- Ruby でメモリを使用する仕組み - メモリの “保持” と “割り当て” について詳しく説明しています。小さなスクリプトを使用して Ruby のメモリ動作を実証しています。システムの “合計最大” メモリを突破することはめったにない理由についても説明しています。
- Ruby でメモリを使用する仕組み (ビデオ) - オブジェクト割り当ての概念に詳しくない方に適した入門です (ビデオの最初のストーリーは飛ばしてもかまいません。残りはメモリに関するものです)。メモリに関する説明は 13 分あたりから始まります。
- Heroku でのメモリリークのデバッグ - おそらく、それはリークではありません。それでも、自分自身で納得して同じ結論に到達するために一読の価値があります。Heroku 以外の環境にも当てはまる内容です。多くの例でツール
derailed_benchmarks
を使用しています。 - アクティブなレコード割り当てを整頓する画期的な魔法 (ブログとビデオ) - この記事では、実際の環境でツールを使用してメモリ割り当てを追跡し、除去する方法を示しています。すべての例は Rails に提出されたパッチに関連していますが、このプロセスは、他のアプリケーションロジックに起因する割り当ての特定にも同様に有効です。
- N+1 クエリまたはメモリの問題: 両方を解決しない理由- N+1 クエリがパフォーマンスを低下させる一方で “活発な読み込み” の使用がメモリを過剰に消費していたという、2017 年に実際にあった奇妙なシナリオを振り返ります。余計なメモリ割り当てを回避しながら必要なデータを抽出するために、アプリでコードのフローを再設計する必要がありました。
- Ruby のメモリの崖から飛び降りる - メモリメトリクスの “崖” や、のこぎりの歯のようなパターンが出現することがあります。この記事では、そのような動作が存在する理由とその意味について説明しています。