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

Rails: Unicorn::WorkerKillerのメモリ管理とレジリエンスを再考する(翻訳)

概要

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

日本語タイトルは内容に即したものにしました。

Rails: Unicorn::WorkerKillerのメモリ管理とレジリエンスを再考する(翻訳)

Shopify/pitchfork - GitHub

私がやりたいのは、Pitchforkに関する記事を書いて、これがどんな理由でできたのか、なぜ現在のような形になったのか、そして今後どうなるのかについて説明することです。しかしその前に解説しておく必要があることがいくつかあります。今回は「レジリエンス(resilience: 回復力)」についてです。

数年前、unicorn-worker-killer gemには重大なコードの臭い(code smell)があると指摘しているツイートを見かけました。誰がいつツイートしたかはあまり思い出せませんが、それは問題ではありません。しかしツイートの指摘にはいたく興味を惹かれました。このgemの使い方次第では、そのツイートに強くうなずける場合もあれば同意できない場合もあるからです。

kzk/unicorn-worker-killer - GitHub

🔗 unicorn-worker-killerは何を行うか

unicorn-worker-killerは、Unicorn Webサーバーに2つの付加機能を提供します。

  • 設定1: Unicornワーカーをリサイクルするまでのリクエスト数を設定できます。
  • 設定2: Unicornワーカーをリサイクルするまでのメモリ使用量のしきい値を設定できます。

私見では、設定1は確かにコードの臭いがひどく、これを使う正当な理由はどこにもありません。

しかし設定2は絶対に必要不可欠です。私に言わせれば、これと同じようなことを行わずにコードをproduction環境にデプロイするのは無責任すれすれです。

どちらの機能も、メモリリークやメモリの肥大化に対処するための手段なので、一見非常によく似ている2つの機能に対して私が述べている意見が根本的に異なることが不思議に思われるかもしれません。

その理由は、前者はメモリリークを隠蔽するのに対し、後者はメモリリークを適切に処理するからです。

🔗 MaxRequests設定がよくない理由

メモリリークというバグにRubyアプリケーションで対処するのがつらい理由の1つは、メモリリークがよほど極端でない限り、システムのOOM(out-of-memory)killerがトリガーされるほどプロセスが肥大化するまでに数時間から数日かかることが多いためです。そのため、ほとんどのバグとは対照的に、メモリリークをアプリケーションのデプロイと結びつけるのは簡単ではありません。

1日に数回デプロイすると仮定すると、週末にデプロイが1〜2日ほど途絶えたときに初めて何か問題が発生したことに気付くかもしれません。場合によっては、数か月経過してチームのほとんどが夏休みや冬休みで不在になり、デプロイが1週間行われなくなってから初めて発覚することもあります。

Unicorn::WorkerKiller::MaxRequestsによる「解決」は、本質的にアプリケーションを定期的に再起動する形で行われるため、メモリリークが発生していることにすら気づけなくなってしまいます。

また、問題を隠蔽するだけでなく、アプリケーションのパフォーマンスにも悪影響を及ぼします。
Unicornワーカーの再起動は比較的低コストで済みますが、Rubyプロセスにはウォームアップ時間が必要です。JITのウォームアップを無視したとしても、通常のインタープリタには大量のインラインキャッシュがあるため、初めて実行するコードパスでは追加の作業が必要になり、それによってCopy-on-Writeが無効化されてしまい、レイテンシがさらに悪化する傾向があります。またVM自体以外にも、(これは悪いパターンですが)Rubyアプリケーションで初期化の一部が遅延した状態になることが非常によくあります。

だからこそ、Unicorn::WorkerKiller::MaxRequestsのコードの臭いは重大だと思うのです。これは、メモリリークが存在することをチームが知っていながら修正を諦めたことを示しています。

このバグを認識している人であっても、自分のユースケースではトレードオフとして許容できるから別に構わないと思うかもしれませんが、メモリリークが1個どころか2〜10個発生する問題に直面して、max_requests設定を下げ続けるしかなくなるのは時間の問題でしょう。

🔗 MaxMemoryが良い理由

逆に、特定のメモリしきい値に達したときにワーカーを正常に再起動するmax_memory設定は、レジリエンスを高めるうえで不可欠な機能です。

アプリケーションが最大メモリポリシーを自らに強制しない場合は、OSが代わりにポリシー強制を実行することになりますが、残念な結果になるでしょう。使用可能メモリが枯渇したときの振る舞いはOSとその構成によって異なりますが、Rubyプログラムのコンテキストでは、Linux OOM killerに遭遇する可能性が最も高いでしょう。

要するに、Linuxはプログラムにメモリを渡せなくなると、マシン上で実行中のすべてのプロセスを調べ、いくつかのヒューリスティック(網羅的でない場当たりの方法)を用いて、メモリを解放するために強制終了するプロセスを見つけます。強制終了されるプロセスがメモリリークを起こしているプロセスである保証はなく、プロセスがSIGKILLを受信したことで正常にシャットダウンされなくなり、大きな問題を引き起こす可能性があります。

ここでちょっとした小話を。10年以上前の私は、PHPとBackbone.jsアプリケーションを動かしているスタートアップ企業で働いていました。MySQLサーバーを含むすべてが1台のマシン上で実行されていました。ある日トラフィックのスパイクが発生し、PHPプロセス数が原因でLinuxのOOM killerがトリガーされました。OOM killerのヒューリスティックは、数百個もの30MBのPHPプロセスよりも巨大なmysqldプロセスに目を奪われてしまい、mysqldSIGKILLしてWebサイト全体をダウンさせることを決定したのでした。

このように、アプリケーションが最大メモリーのポリシーを自らに強制しなければ、OSのメモリ上限が適用されて悲しい結果に終わります。

だからこそ、Unicorn::WorkerKiller::Oomはレジリエンスを高める重要な機能だと私は信じています。正しく使う必要のある要注意な機能であるにもかかわらず、です。
あらゆるOOMイベントが通知され、頻繁に発生したら警告が発せられることが重要です。OOMイベントが突然急上昇しても、それに誰も気づかなければmax_requestと変わりません。

それだけではありません。私はShopifyのモノリスで実装したのは、ワーカーがリサイクルされる前のOOMイベントのサンプルについて、ヒープを最初にObjectSpace.dump_allでダンプしてオブジェクトストアにアップロードする機能です。これを用いて根本原因を調査し、リークがあれば特定できます。

これは両方の長所を兼ね備えています。OOMイベントは適切に処理され、イベントの大幅な増加も報告されるので、デバッグしやすくなります。

アプリケーションのメモリ使用量を増やしてしきい値を上げるという正攻法を採用すれば済む話かもしれませんが、夜間に診断の難しいメモリリークが発生したせいでページングが発生してひどい目に遭うよりも、時々ポリシーを確認する方が望ましいでしょう。

ついでに書くと、unicorn-worker-killerが提供している実装そのものがどうも好きになれません。理由は、この実装がメモリのメトリクスとしてRSS(resilient set size)を使うからです。私は、事前にforkするアプリケーションにはPSS(proportional set size) の方がメモリのメトリクスとしてずっと優れていると信じています1

いずれにしろ、ワーカーをリクエストサイクルの外で、独自の条件でクリーンにシャットダウンする方が、OSがプロセスにSIGKILLを気まぐれに送信するのを容認するよりもずっと望ましいと言えます。

🔗 認めようじゃないか、バグは起こるのだと

ここではメモリを例に取ったのは、どこで線引するかを示す良い例だと思ったからでした。しかし私はもっと普遍的なことを話しているのです。

別の例として、Unicorn組み込みのリクエストタイムアウト設定があります。リクエストがワーカーに割り当てられるたびに、処理が完了するまでの時間が固定で割り当てられ、処理が時間内に完了しない場合はワーカーがシャットダウンして置き換わります。

「これはバグを回避している悪い設計だ」と思うかもしれませんが、これこそがレジリエンスのあるシステムを構築するための鍵となるのです。バグや壊滅的なイベントは最終的にいつか必ず発生することを受け入れなければなりません。すなわち、システムはそのような事態の影響を一定の範囲に食い止めるように設計する必要があります。

これは、バグを容認しても構わないという意味ではありません。テストやコードレビューなどを通じて、事前に可能な限り多くのバグを防ぐよう努めるべきです。システムにレジリエンスが備わっているからといって、それに甘んじて粗悪なソフトウェアをリリースしてよい理由には決してなりません。

かといって、能力や処理内容だけですべてのバグを防止できると考えるのも幻想です。

「自分は運転が上手いからシートベルトなんかいらない」と言う人がいたら、私なら全速力で反対方向に逃げます。

このような取り組み姿勢は、小規模ならしばらく持つかもしれませんが、プロジェクトとチームが拡大して変化が加速してくれば持ちこたえられません。

🔗 「シェアナッシング」アーキテクチャにはレジリエンスがある

私がインフラストラクチャエンジニアとして「シェアナッシング」アーキテクチャのファンである理由は、ここにあります。このアーキテクチャには、レジリエンスという強みが本質的に備わっています。

たとえばPHPを見てみましょう。プログラミング言語としては好きではありませんが、その古典的な実行モデルは本当に優れていると認めざるを得ません。すべてのリクエストは新しいプロセスで開始されるため、リクエスト間でステートが漏洩することはほぼ不可能です。

このモデルは、Rubyでは前述のウォームアップが必要なためにうまく機能しませんが、Unicornのモデルはある意味で両者の中間的なものです。Unicornのワーカーは複数のリクエストを処理しますが、処理がコンカレントになることはないため、非常に高いレベルの分離というメリットを引き続き得られます。何か重大な問題が発生しても、ワーカーを強制終了するという選択肢が常に存在し、リソースはOSが回収してくれます。

一方、スレッドをきれいに中断することは基本的に不可能です。RubyのTimeoutモジュールは疫病のように避けるべきという話をおそらく皆さんも聞いたことがあるでしょう。Timeoutはスレッドの中断を試み、そしてまさにそのために問題が発生する可能性があるからです。

Unicornのリクエストタイムアウトに相当するPumaのrack-timeoutを見ると、リクエストをタイムアウトする必要があるときは、単一のスレッドをきれいにシャットダウンする方法がないため、常にPumaワーカー全体がシャットダウンされることがわかります。

zombocom/rack-timeout - GitHub

🔗 まとめ

これまで過去記事で説明を試みてきたように、RubyにGVLがなかったとしてもforkそのものに多くの問題があることを考慮したとしても、私はこの実行モデルには実に魅力的な特性がいくつもあり、安易に置き換えるわけにはいかないと強く信じています。

ご多分に漏れず、このモデルにもトレードオフはあり、得るものもあれば失うものもあります。しかしだからといって、出来のよくないアプリケーションで必要悪として使われる非推奨モデルとして安易に捨て去るものではありません。

このモデルには確かにいろんなメリットがあり、レジリエンスもその1つにすぎないのです。

関連記事

「RailsアプリはIO-boundである」という神話について考える(翻訳)

RubyのGVLを消し去りたいあなたへ(翻訳)

Ruby: fork(2)がみんなに嫌われる理由(翻訳)


  1. 原注: RSSとPSSの違いについてはShopifyのブログ記事『To Thread or Not to Thread: An In-Depth Look at Ruby’s Execution Models』に書きました。 

CONTACT

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