gunicorn を長期間運用していると、メモリ使用量が徐々に増加し、最終的に OOM Killer に殺されるという報告が後を絶たない。GitHub の Issue や FastAPI のディスカッションでも繰り返し議論されている問題だ。worker クラスを変えれば解決すると思われがちだが、実際にはそう単純ではない。
メモリが解放されない根本原因
gunicorn 自体にメモリリークがあるわけではない。問題の多くは Python と glibc のメモリ管理の仕組みに起因する。
Python はオブジェクトを解放しても、そのメモリを OS に返却しない。Python のメモリプールに保持され、次の割り当てに再利用される
Linux の glibc malloc はスレッドごとに arena というメモリプールを作成する。arena のメモリも簡単には OS に返却されない
つまり、一度大きなメモリを確保すると、Python レベルでも glibc レベルでも「OS から見た使用量」は減らないのだ。
glibc arena の問題
glibc の malloc 実装はマルチスレッド環境での性能向上のため、複数の arena を作成する。64 ビット環境ではデフォルトで CPU コア数 × 8 個の arena が上限となる。
スレッドがメモリを確保しようとして既存の arena がロックされていると、新しい arena が作成される。gunicorn の gthread worker やスレッドプールを使うアプリケーションで発生しやすい。
スレッドが終了しても arena は「空き」とマークされるだけで、OS には返却されない。arena 内に 1 バイトでも使用中のメモリがあると、その arena 全体が保持され続ける。
40 コアのサーバーなら最大 320 個の arena が作成される可能性があり、それぞれが数十 MB を占有すると、それだけで数 GB のメモリが「使用中」に見えてしまう。
worker クラスを変えても解決しない理由
sync、gthread、gevent、uvicorn など worker クラスを変更しても問題が解消しないケースが多い。
sync worker でも Python 内部でスレッドが使われる
gevent/eventlet は内部で独自のメモリ管理を持つ
uvicorn worker でもイベントループ内でメモリ断片化が発生
いずれも glibc arena の影響を受ける
worker クラスの選択は並行処理モデルの違いであり、メモリ管理の根本的な解決にはならない。
現実的な対策
完全な解決は難しいが、以下の方法で影響を軽減できる。
一定リクエスト数で worker を再起動し、メモリをリセットする。jitter を付けて一斉再起動を避ける。
glibc の arena 数を制限する。Heroku は MALLOC_ARENA_MAX=2 をデフォルトにしている。
アプリケーションコードを fork 前にロードし、Copy-on-Write でメモリを共有する。ただし Python の参照カウントにより効果は限定的。
glibc malloc の代わりに jemalloc を使う。メモリ断片化が少なく、積極的に OS にメモリを返却する。
設定例
# gunicorn 起動時の設定例 gunicorn app:application \ --workers 4 \ --max-requests 10000 \ --max-requests-jitter 1000 \ --preload
環境変数で glibc の挙動を制御する場合は以下のように設定する。
# arena 数を制限 export MALLOC_ARENA_MAX=2 # または jemalloc を使用 export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
根本的には「仕様」である
リクエスト処理後にメモリが減らないのは、多くの場合バグではなく仕様だ。メモリを OS に返却しないことで、次の割り当てを高速化している。ただしコンテナ環境のように厳密なメモリ制限がある場合、この「最適化」が裏目に出る。
長期運用を前提とするなら、max-requests による定期再起動は事実上必須と考えたほうがよい。メモリリークがなくても、メモリ使用量は単調増加する傾向があるためだ。