Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

新規Rails 7アプリがHerokuのメモリクォータを超える問題(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

新規Rails 7アプリがHerokuのメモリクォータを超える問題(翻訳)

原文更新情報: このコミット(06d614a)と現時点のmainブランチから判断すると、この問題はRails 8で修正されるはずです。なお、これについてはissue #50450に素晴らしいプルリクスレッドがあります。

訳注

その後、上のコミットは#50669としてRails 7.2にマージされ(ウォッチ20240206)、他にもpuma.rb.ttでいくつか更新が行われました。

Ruby on Railsの歴史において、メモリ使用量を抑えるための健全なプレッシャーのひとつは、「Railsアプリを最も手軽にホスティング開始できる場所」としてHerokuが果たしてきた特別な役割です。RailsコアチームとHerokuのスタッフは全般的に、フレームワークを進化させつつ、新規RailsアプリがHerokuの無料の(今なら安価な)dynoサーバー上で快適に動くことを保証するという永遠の緊張関係を、17年(!)にもわたって絶妙なバランスを保ってきました。アプリをサーバーにgit pushすればすべてが「問題なく動く」ことは、Railsを採用する大きな原動力となってきましたし、開発者が最終的にHeroku以外の場所にアプリを移動したとしても、このように摩擦の小さいデプロイ体験を今後も維持することは、関係者一同にとって明らかに重要です。つまり、リソースの消費を技術スタックのあらゆるレベルで理解しておくということです。

世にトラブルの種は尽きませんが、私はちょうどそれを踏んでしまったのだと思います。

私が素のRails 7.1.3アプリの基本部分をHerokuにプッシュしたところ、ログのあちこちにおなじみのエラーが表示されました。

Error R14 (Memory quota exceeded)

このエラーは、アプリをデプロイして文字通り数分後に起きました。アプリのメモリ消費量は520MBに達していました。「これは一体?」しかも私は、Railsで最もメモリ食いなコンポーネントであるAction Mailboxの読み込みを回避しておくのを忘れていなかったのに!

ローカル環境でのメモリ消費をベンチマークするのに30秒かかりましたが、私がこの種の問題に対処したときの経験から、「オッカムの剃刀」に沿ってサーバーのデフォルト設定をまず調べる必要があることに気づきました。

私はHerokuの新しい低価格サーバーを構成して、Webアプリ実行用の「Basic」dynoとSolid Queue実行用に別のdynoを準備しました。

このときは、このtierのプロセッサは1コアか2コアになるだろうと予想していましたが、Herokuのドキュメントには利用可能なプロセッサ数やコア数ははっきりとは記載されていませんでした。

そこで、環境を調べるために以下のシェルを実行して使い捨てのBasic dynoを起動してみました。

# ヒント: 実はHerokuのdynoでシェルを実行できます!
$ heroku run bash
Running bash on ⬢ app... up, run.9833 (Basic)
# プロセッサ数をチェック:
$ nproc
8

プロセッサ数が8とは気前がいいですね。ではコア数はどうでしょう?

$ cat /proc/cpuinfo | grep "cpu cores"
cpu cores   : 4
cpu cores   : 4
cpu cores   : 4
cpu cores   : 4
cpu cores   : 4
cpu cores   : 4
cpu cores   : 4
cpu cores   : 4

8プロセッサで、しかもプロセッサごとに4コアを消費しているとは...まあ1時間あたり1ドルなら悪くありませんね。これで期待通りに動かなかった理由もすぐに説明がつきました。

「プロセッサ設定が大盤振る舞いになっていると何が問題なの?」と思うかもしれません。

問題は、Railsサーバーとして最も名の知られたPumaのデフォルト設定にあります。生成されたconfig/puma.rbファイルを見てみましょう。

# productionではワーカー数がプロセッサ数と一致するよう指定する
if ENV["RAILS_ENV"] == "production"
  require "concurrent-ruby"
  worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
  workers worker_count if worker_count > 1
end

つまり、production環境で使いたいプロセッサ数を指定するWEB_CONCURRENCY環境変数が「たまたま」未設定になっていると、Pumaはデフォルトでシステム上の全物理プロセッサごとに1回ずつforkします。forkした各プロセスには、そのプロセスに割り当てられたメモリもコピーされるので、HerokuのBasic dynoでそうなったように、「CPU数が多いがメモリ不足の環境」ではPumaのデフォルト設定が効かなくなります

上と同じbashセッションでirbを起動し、以下を調べてみることで、正確な物理プロセッサ数がわかります。

~ $ irb
irb(main):001> require "concurrent-ruby"
=> true
irb(main):002> Concurrent.physical_processor_count
=> 4

すなわち、nprocコマンドでは8個だったのに、Concurrent.physical_processor_countではなぜか4個になってしまっています。実際には、空のRails 7.1アプリ(Ruby 3.3)であれば、消費メモリ容量はすぐ125MBに達するので問題ではありません。125MBを4倍するとHerokuの500MBクォータに達してR14エラーが吐き出されます。

🔗 解決方法

おそらくこの場合、プロセス数ではなくスレッド数を増やせば、必要なHTTPスループットをすべて得られるでしょう(スレッド数を増やしてもアプリのメモリ使用量はそれほど急に増加しません)。この場合ワーカーを2個実行する余裕があるのは明らかなので、このようにしてみました。コードやデフォルト設定を変更しなくても、私が行ったようにWEB_CONCURRENCY環境変数で設定すればプロセス数を動的に指定できます。

$ heroku config:set WEB_CONCURRENCY=2
Setting WEB_CONCURRENCY and restarting ⬢ app... done, v28
WEB_CONCURRENCY: 2

これはもちろん「解決方法」ですが、可能な設定方法について豊富な経験や体系的な知識を持ち合わせていないユーザーにとっては、あまりよい解決方法ではありません。今後、新人開発者がRailsやHerokuで優秀な即戦力となれるよう、HerokuやRailsコアチームやPumaメンテナーが一致協力してこの種の障害を軽減する方法を見つけてくれることを期待しています。

お知らせ: 最新情報をチェックしたい方へ

ラッキーなことに、このWebサイトはRSSでもMastodonでも購読可能です。他の記事も読みたい方は、私のニュースレターにお申し込みいただければ、いい感じのエッセイを月イチで配信いたします。もちろん私単独でのポッドキャストも運営しています。

関連記事

Rails 7でSMTPメールをAWS SESで送信する正しい方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。