Gunicorn の max-requests と max-requests-jitter - ワーカーの寿命管理

Gunicorn で Flask アプリを動かしていると、数時間から数日でサーバーのメモリや CPU が異常に跳ね上がり、応答不能になることがある。再起動すれば直るが、しばらくするとまた再発する。この症状の根本にあるのは Python プロセスのメモリ蓄積であり、--max-requests--max-requests-jitter という 2 つのオプションで防止できる。

なぜワーカーのメモリは膨れ上がるのか

Gunicorn のワーカーは 1 つ 1 つが独立した Python プロセスだ。リクエストを処理するたびにオブジェクトが生成され、通常はガベージコレクションが不要なオブジェクトを回収する。しかし、すべてが回収されるわけではない。

lru_cache の無制限キャッシュ
モジュールレベルで保持されるグローバル変数
Flask 拡張が内部に持つ参照
サードパーティライブラリの C 拡張が確保したメモリ

これらは GC の対象にならず、リクエストを処理するたびに少しずつ蓄積していく。開発環境ではプロセスの寿命が短いため気づかないが、本番環境でワーカーが数万リクエストを処理すると目に見える量になる。Adam Johnson の分析でも、Python の Web アプリケーションにおけるメモリリークはモジュールレベル変数の無制限な膨張が典型であり、自前のコードだけでなくサードパーティ内部で起きることも珍しくないと指摘されている。

メモリが蓄積し続けると、やがて OS のメモリ保護機構(OOM Killer)が SIGKILL を送ってワーカーを強制終了する。SIGKILL はプロセス側で捕捉できないため、ログにも何も残らず静かに死ぬ。Gunicorn のマスタープロセスが新しいワーカーを fork するまでの空白時間に、外部からは「サーバーが応答しない」と見える。

max-requests の仕組み

--max-requests は、ワーカーが指定した回数のリクエストを処理した後に自動で再起動するオプションだ。

gunicorn app:app --workers 3 --max-requests 2000

この設定では、各ワーカーが 2000 リクエストを処理した時点で Gunicorn が graceful にワーカーを終了し、新しいプロセスを fork する。プロセスが終了すれば、そのプロセスが確保していたメモリはすべて OS に返却される。GC が回収できなかったオブジェクトも、C 拡張が確保した領域も、プロセスの終了とともにすべて消える。

max-requests なし

ワーカーは起動してから永遠に生き続け、メモリを蓄積し続ける。最終的に OOM Killer に殺されるか、サーバー全体が不安定になる。

max-requests あり

ワーカーは一定回数で自動再起動する。蓄積したメモリが定期的にリセットされ、消費量が一定の範囲内に収まる。

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

全ワーカー同時再起動の問題

ここで 1 つ問題がある。--max-requests 2000 だけを指定すると、すべてのワーカーがほぼ同じタイミングで再起動する可能性が高い。

ワーカーが 3 つあり、リクエストがラウンドロビンで均等に振り分けられている場合を考えてみる。サーバーに 6000 リクエストが来た時点で、3 つのワーカーはそれぞれちょうど 2000 リクエストを処理したことになる。この瞬間、3 つ全員が同時に再起動に入る。

全ワーカーが同時に 2000 リクエスト到達

全ワーカーが同時に再起動開始

リクエストを受けるプロセスがゼロになる

その間のリクエストがタイムアウトする

再起動自体は数秒で完了するが、その数秒間にサービスが完全に停止する。トラフィックが多い環境ではこの瞬断が致命的になりうる。

max-requests-jitter で再起動をずらす

--max-requests-jitter は、各ワーカーの再起動タイミングにランダムな揺らぎを加えるオプションだ。

gunicorn app:app --workers 3 --max-requests 2000 --max-requests-jitter 100

この設定では、各ワーカーの実際の再起動タイミングが 2000〜2100 の範囲でランダムに決まる。ワーカーごとに異なる値が割り当てられるため、同時再起動は起きない。

ワーカー A

2047 リクエスト目で再起動。残りの B と C がリクエストを処理し続ける。

ワーカー B

2003 リクエスト目で再起動。A はすでに復帰済みで、C も稼働中。

ワーカー C

2091 リクエスト目で再起動。A と B が稼働中なのでサービスは止まらない。

こうして常に少なくとも 1 つのワーカーがリクエストを受けられる状態を維持できる。jitter の値は max-requests の 5〜10% 程度が目安で、max-requests=2000 なら jitter=100 から 200 が妥当な範囲となる。

値の決め方

max-requests を小さくしすぎると再起動が頻発してレイテンシに悪影響が出る。大きくしすぎるとメモリ蓄積が進んで OOM に至る。あるプロダクション環境での計測では、max-requests=250 で最大メモリ消費が 468MB、max-requests=3000 で 600MB まで上昇したと報告されている。OOM の閾値が 700MB の環境では後者だとギリギリの水準であり、アプリケーションの特性とメモリ制限によって最適な値は変わってくる。

実際の運用では、まず max-requests=1000 程度から始めて、ワーカーの RSS(Resident Set Size)を監視する方法が確実だ。

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

このコマンドでワーカーの PID とメモリ使用量を確認できる。再起動直後と一定時間経過後の RSS を比較すれば、1 リクエストあたりどの程度メモリが蓄積するかの見当がつく。その増加速度とメモリ上限から逆算して max-requests の値を決めればよい。

systemd サービスファイルでの設定

systemd でサービスとして動かしている場合、--max-requests--max-requests-jitter は ExecStart に直接書ける。

# /etc/systemd/system/myapp.service

[Unit]
Description=Gunicorn Flask Application
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/var/www/myapp/venv/bin/gunicorn \
    --bind 0.0.0.0:8000 \
    --workers 3 \
    --timeout 30 \
    --max-requests 2000 \
    --max-requests-jitter 100 \
    --access-logfile - \
    --error-logfile - \
    wsgi:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Restart=on-failure を入れておくことで、万が一 Gunicorn のマスタープロセスごと OOM Killer に殺された場合でも、systemd が 5 秒後に自動再起動してくれる。

設定を変更したら systemctl daemon-reloadsystemctl restart myapp を忘れないこと。

sudo systemctl daemon-reload
sudo systemctl restart myapp

Python の conf ファイルで管理する方法

オプションが増えてきた場合や、環境ごとに設定を切り替えたい場合は、Python の設定ファイルに分離することもできる。

# gunicorn.conf.py

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

max_requests = 2000
max_requests_jitter = 100

accesslog = "-"
errorlog = "-"
loglevel = "info"

systemd 側は --config でこのファイルを指定するだけになる。

ExecStart=/var/www/myapp/venv/bin/gunicorn --config gunicorn.conf.py wsgi:app
systemd 直書き

設定が一箇所にまとまって把握しやすい。オプションが少ない小規模構成向き。

conf ファイル分離

環境変数や条件分岐を Python で書ける。オプションが多い場合や複数環境で設定を切り替えたい場合に向いている。

どちらの方式を選んでも max-requestsmax-requests-jitter の効果は同じだ。1 台のサーバーでシンプルに運用するなら直書きで十分であり、conf ファイルを使うかどうかはプロジェクトの規模と好みで決めればよい。