Gunicorn のワーカーがメモリと CPU を食い尽くす原因と対策

Flask アプリを Gunicorn で運用していると、ある日突然ワーカーがメモリや CPU を異常消費してサーバーが応答不能に陥ることがある。再起動すれば直るが、数時間から数日でまた再発する。この症状には明確なメカニズムが存在し、適切な設定で防止できる。

Python プロセスのメモリ蓄積

Gunicorn のワーカーは 1 つ 1 つが独立した Python プロセスであり、リクエストを処理するたびに内部でオブジェクトが生成される。通常はガベージコレクション(GC)が不要なオブジェクトを回収するが、すべてを回収できるわけではない。モジュールレベルの変数、lru_cache の無制限キャッシュ、Flask 拡張が内部で保持する参照など、GC の対象にならないオブジェクトが少しずつ蓄積していく。

開発環境

プロセスの寿命が短く、リクエスト数も少ないため、メモリ蓄積が顕在化しない

本番環境

ワーカーが長時間稼働し続け、数千〜数万リクエストを処理するうちにメモリ消費量が膨れ上がる

Adam Johnson の分析によれば、Python の Web アプリケーションではモジュールレベル変数が無制限に膨張するパターンが典型的なメモリリークの原因であり、自前のコードだけでなくサードパーティライブラリの内部で発生することも珍しくない。リークがアプリケーションコードにあるか、ライブラリにあるか、あるいは Python の C 拡張にあるかによって対処の難度は大きく異なるが、いずれの場合もワーカープロセスを放置すれば際限なくメモリを消費し続ける。

OOM Killer による突然死

メモリ蓄積が進むと、OS のメモリ保護機構が発動する。特にコンテナ環境では cgroups によるメモリ上限が設定されていることが多く、ワーカーが上限に達した時点で SIGKILL が送られてプロセスが即死する。Gunicorn 公式 FAQ では、この現象について dmesg で確認する方法が示されている。

dmesg | grep gunicorn

出力に Memory cgroup out of memory: Kill process ... (gunicorn) のようなメッセージが含まれていれば、OOM Killer がワーカーを強制終了したことがわかる。SIGKILL はプロセス側で捕捉できないシグナルであるため、ログにも何も残らず「サイレントに死ぬ」という厄介な挙動を示す。Gunicorn のマスタープロセスは死んだワーカーを検知して新しいワーカーを fork するが、再起動のたびにリクエストが捌けない空白時間が生じ、これが外部からは「定期的にサーバーが応答しなくなる」という症状として観測されることになる。

Thundering Herd 問題

CPU バーストのもう一つの原因が Thundering Herd(驚愕する群れ)問題だ。Gunicorn は複数のワーカープロセスがソケットを共有し、新しいリクエストの到着を待ち受けている。リクエストが到着すると、スリープ中のすべてのワーカーが一斉に起き上がるが、実際にリクエストを処理できるのは 1 つだけで、残りは空振りに終わる。Gunicorn 公式 FAQ でもこの問題は認識されており、ワーカー数が多いほど顕著になるとされている。

ワーカー数を増やせばスループットが上がるという単純な話ではなく、ワーカーが多すぎると Thundering Herd によるオーバーヘッドと、プロセスごとのメモリ消費の合計が逆にパフォーマンスを悪化させる。

全ワーカーが同時に起床し、1 つ以外は無駄な CPU サイクルを消費する現象。

ワーカー数の目安は CPU コア数 × 2 + 1 とされているが、メモリ制約がある環境ではこれより少なくすべき場面もある。ワーカー数の増減はトレードオフであり、負荷テストで実測しながら調整するのが現実的なアプローチとなる。

max_requests によるワーカーの定期再起動

メモリリークの根本原因を特定・修正するのは容易ではない。tracemallocobjgraph を使ったヒープ解析には本番相当の負荷データが必要で、サードパーティライブラリや C 拡張内部のリークに至っては修正自体が困難な場合もある。そこで Gunicorn が提供しているのが --max-requests オプションだ。

gunicorn app:app \
    --workers 4 \
    --max-requests 1000 \
    --max-requests-jitter 50

max_requests はワーカーが指定回数のリクエストを処理した後に自動で再起動する仕組みで、プロセスの再起動によって蓄積したメモリが OS に返却される。max_requests_jitter はそこにランダムな揺らぎを加えるオプションで、全ワーカーが同時に再起動してサービスが一時的に停止する事態を防ぐ。

Gunicorn の GitHub ディスカッションでは、max_requests の本来の目的はメモリリークへの一時的な回避策だと Gunicorn メンテナ自身が述べている。しかし実際には多くの本番環境で恒常的に使われており、複雑なアプリケーションはある程度のリークを抱えるのが常態だという現場の声も寄せられている。

max_requests の値の選び方

小さすぎると再起動が頻発してレイテンシが悪化し、大きすぎるとメモリ蓄積が進んで OOM に至る。1000〜10000 の範囲で、メモリ消費の推移を監視しながら調整するのが一般的。

jitter の設定

max_requests_jittermax_requests の 5〜10% 程度が目安となる。たとえば max_requests=1000 なら max_requests_jitter=50 から 100 の範囲で設定するとよい。

あるプロダクション環境での計測では、max_requests=250, jitter=15 の設定で最大メモリ消費が 468MB に抑えられた一方、max_requests=3000, jitter=50 では 600MB まで上昇したと報告されている。この環境では OOM の閾値が 700MB であったため、後者でもギリギリ許容範囲に収まったが、余裕は少ない。値の最適解はアプリケーションの特性とインフラの制約に依存するため、一律の正解は存在しない。

ワーカータイプの選択

Gunicorn はデフォルトの sync ワーカーのほかに、gevent や eventlet といった非同期ワーカーを選択できる。I/O バウンドなアプリケーションでは非同期ワーカーのほうがスループットが高くなりそうに思えるが、落とし穴がある。

Jefferson Heard の事例報告では、Flask + gevent ワーカーの構成で数か月にわたって断続的な I/O スパイクとメモリ異常に悩まされた結果、sync ワーカー + HAProxy のキューイングに切り替えたところ問題がすべて解消し、さらに 33% 多いユーザーを捌けるようになったと述べられている。gevent はモンキーパッチで I/O を協調的マルチスレッドに変換する仕組みのため、モンキーパッチと相性の悪いライブラリが混在すると予測困難な不具合を引き起こす。

sync ワーカーで問題がないか確認

I/O バウンドが明確なら gevent を検討

問題が出たら sync に戻す

同記事の結論は「アプリケーションの種類に関係なく、まず sync ワーカーから始めよ」というものであり、これは多くの Flask アプリケーションにとって妥当な指針といえる。

実践的な設定例

以上をふまえ、Flask アプリを Gunicorn で安定運用するための設定ファイルの例を示す。

# gunicorn.conf.py

bind = "0.0.0.0:8000"
workers = 3
worker_class = "sync"
timeout = 30

# メモリリーク対策
max_requests = 2000
max_requests_jitter = 100

# ログ
accesslog = "-"
errorlog = "-"
loglevel = "info"

workers の数はサーバーの CPU コア数とメモリ容量に応じて調整する。コンテナ環境であれば、1 ワーカーあたりのメモリ上限を見積もったうえで、コンテナ全体のメモリ制限を超えないワーカー数に抑えるべきだ。

監視の観点では、各ワーカーの RSS(Resident Set Size)を定期的に記録しておくと、メモリ蓄積の速度とリーク量を把握できる。ps コマンドで簡易的に確認する方法は以下のとおり。

ps aux | grep gunicorn | grep -v grep | awk '{print $2, $6 " KB", $11}'

ワーカーの RSS が再起動直後から時間経過とともに一方的に増加していくようであれば、アプリケーション内にメモリリークが存在する証拠となる。max_requests で症状を抑えつつ、tracemalloc を使って原因箇所を特定するのが理想的な対処フローだ。

まとめ

Gunicorn のメモリ・CPU バーストは単一の原因ではなく、Python プロセスのメモリ蓄積、OOM Killer の介入、Thundering Herd、ワーカータイプの不適合といった複数の要因が絡み合って発生する。即効性のある対策としては max_requestsmax_requests_jitter の設定が最も有効であり、本番環境では最初から設定しておくことが推奨される。サーバーが不安定になってから慌てるのではなく、デプロイ時点でワーカーの寿命管理を組み込んでおくことが安定運用の鍵となる。