メモリリークはしていないのに、なぜ Rails のプロセスは日に日に太っていくのか?
Puma や Sidekiq のプロセスのメモリ使用量が、デプロイ直後から右肩上がりに増え続けるグラフを見たことはないだろうか。リークを疑って調べても、Ruby のオブジェクト数は増えていない。それでも OS から見たプロセスは太り続け、やがてコンテナのメモリ上限に当たる。犯人は Ruby ではなく、その下で動く C の malloc であることが多い。
freeしたメモリはすぐ OS に返るわけではない。malloc は空き領域を手元に抱えて再利用し、ヒープの上端に使用中の領域が残る限り下の空きは返せない。- glibc の malloc はスレッドの競合を避けるためにアリーナ(独立したメモリ置き場)を増やし、64 ビットでは既定でコア数の 8 倍まで作る。置き場が多いほど空きが分散して、穴だらけの領域が増える。
- Ruby アプリでは
MALLOC_ARENA_MAX=2でアリーナ数を絞るか、jemalloc に差し替えるのが定番の対策で、現行の Rails が生成する Dockerfile は jemalloc を読み込む設定になっている。
free しても OS にメモリが返るとは限らない
Ruby の文字列や配列の中身は、最終的に C ライブラリの malloc で確保され、GC が不要と判断すると free される。malloc はプロセス内のメモリ管理係で、OS から大きめの塊をまとめて借り、それを小さく切り分けてプログラムに渡す。Linux で標準的に使われるのは glibc の実装である。
小さな確保(glibc の既定では 128KiB 未満)は、ヒープと呼ばれる連続した領域から切り出される。メインのヒープは brk/sbrk というシステムコールで上端を伸ばして広げる仕組みなので、縮めるときも上端からしか縮められない(後述のスレッド用の置き場も、上端から使い上端から返す点は同じである)。glibc は上端に 128KiB(M_TRIM_THRESHOLD の既定)以上の空きが溜まったときに上端を切り詰めて OS に返す。
問題は真ん中の空きである。ヒープの途中にある領域を free しても、その上に 1 つでも使用中の領域が残っていれば上端は下げられない。空きは次の malloc で再利用されるが、サイズが合わなければ使われずに残る。この「空いているのに返せず、使い回しもしにくい」状態を断片化と呼ぶ。C で確保した領域はポインタで直接指されているので、malloc が中身を動かして詰め直すこともできない。
OS から見えるプロセスのメモリ量(RSS: 物理メモリに載っているページの量)は、Ruby が実際に使っている量ではなく、malloc が抱えている量に近い。だから Ruby 側のオブジェクトが増えていなくても RSS は下がらない。
スレッドが増えると置き場も増える — glibc のアリーナ
空き領域の一覧は共有データなので、複数スレッドが同時に malloc を呼ぶとロックで待たされる。glibc はこれを避けるため、ロックの競合を検出するとアリーナを追加で作る。アリーナは自前の空き領域リストとロックを持つ独立したメモリ置き場で、競合したスレッドは別のアリーナから確保するようになる。
アリーナ数には上限がある。mallopt(3) の M_ARENA_MAX は既定 0(明示的な上限なし)で、そのときはシステム構成から上限が決まり、Heroku のドキュメントによれば 64 ビット環境では CPU コア数の 8 倍になる。4 コアのコンテナなら最大 32 個の置き場ができうる。
I/O 中のスレッドが同時に malloc する T1 T2 T3 T4 … │ │ │ │ ▼ ▼ ▼ ▼ ┌──┐┌──┐┌──┐┌──┐ │a0││a1││a2││a3│ … 上限 8×コア数 └──┘└──┘└──┘└──┘ 1 つのアリーナの中(低位 → 高位) ┌────┬────┬────┬────┬────┐ │used│free│free│used│used│◀ 上端 └────┴────┴────┴────┴────┘ free は同じアリーナの malloc だけが 再利用する。上端が used なので返せない
置き場が増えると断片化が悪化する。あるアリーナに空きがあっても、別のアリーナに割り当てられたスレッドはそれを使えない。空きが 32 か所に分散すれば、1 か所にまとまっていれば再利用できたはずの領域が、それぞれのアリーナで抱え込まれたまま残る。
倉庫の在庫棚である。レジ待ちを減らすために棚と係員を 1 組から 32 組に増やすと、各棚に少しずつ空きスペースが散らばる。A 棚にも B 棚にも半端な隙間はあるのに、A 棚の係員は B 棚の隙間を使えないので、新しい荷物が来るたびに倉庫そのものを借り増すことになる。しかも棚の一番奥に荷物が 1 つ残っているだけで、その棚は大家に返せない。
GVL があるのに Ruby で効いてくる理由
Ruby(CRuby)には GVL(同時に Ruby コードを実行できるスレッドを 1 本に絞るロック)があるので、malloc の競合など起きないように思える。しかし実際は、DB や HTTP の応答を待つ I/O の間は GVL が手放され、その間に C 拡張やランタイム内部で走る確保が他のスレッドと重なる。Nate Berkopec の解説も、アリーナの影響を受けるのは「マルチスレッドで、かつ I/O をする」CRuby アプリだと述べている。Puma のスレッドや Sidekiq の concurrency で多数のスレッドを動かす構成がまさにこれに当たる。
影響の大きさは環境によるが、同記事が引用する Heroku での測定(glibc 2.19)では、アリーナ数無制限の場合はメモリが 1.73 倍、MALLOC_ARENA_MAX=2 では 0.87 倍で、応答時間の差は 1 割程度だった。Heroku は 2019 年 9 月 24 日以降に作成した Ruby アプリで MALLOC_ARENA_MAX=2 を既定にしている。
対策は 2 つ: アリーナを絞るか、malloc を差し替えるか
1 つ目は環境変数 MALLOC_ARENA_MAX でアリーナ数の上限を決めることだ。値を小さくするとメモリは減るが、ロック待ちが増えて遅くなりうる。Heroku はテスト結果から 2 を推奨し、1 では性能低下が大きい割に節約分はわずかだという報告もある。この変数は最初のメモリ確保より前に読まれるので、.env を Ruby 側で読み込むのでは遅く、プロセス起動時の環境に入れておく必要がある。
# Kubernetes の Deployment(コンテナ定義の一部)
env:
- name: MALLOC_ARENA_MAX
value: "2"
2 つ目は malloc の実装そのものを jemalloc に替えることだ。jemalloc は断片化を抑える設計の別実装で、LD_PRELOAD(指定したライブラリをプログラムより先に読み込ませ、同名の関数を差し替える仕組み)で glibc の malloc の代わりに使わせられる。現行の rails new が生成する Dockerfile は、libjemalloc2 を入れたうえで次の設定を書く。
ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so"
jemalloc を使っている場合、MALLOC_ARENA_MAX は glibc 向けの設定なので効果がない(Heroku のドキュメントもこの点を明記している)。どちらも設定しない場合は glibc の既定どおりアリーナが増え、長時間動くワーカーほど RSS が高止まりしやすい。
典型は「Sidekiq の Pod が数日おきに OOMKilled になり、再起動すると直る」という症状である。コンテナのメモリ上限は RSS を含む使用量で判定されるので、malloc が抱え込んだ空きも上限を食う。まず Dockerfile の LD_PRELOAD と環境変数 MALLOC_ARENA_MAX のどちらかが設定されているかを確認し、どちらも無ければ片方を入れて数日間の RSS の推移を比べる。それでも単調に増え続けるなら、そこで初めて本物のリーク(参照が残り続けるオブジェクト)を疑えばよい。
macOS の malloc は glibc ではないので、Linux コンテナの中で 16 スレッドが同時に malloc/free を繰り返す小さな C プログラムを動かし、glibc の malloc_stats() が表示するアリーナの数を数える。
mkdir -p /tmp/arena && cd /tmp/arena
cat > arena.c <<'EOF'
#include <malloc.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
void *work(void *arg) {
for (int i = 0; i < 100000; i++) {
char *p = malloc(1024);
memset(p, 1, 1024);
free(p);
}
return NULL;
}
int main(void) {
pthread_t t[16];
for (int i = 0; i < 16; i++) pthread_create(&t[i], NULL, work, NULL);
for (int i = 0; i < 16; i++) pthread_join(t[i], NULL);
malloc_stats();
return 0;
}
EOF
docker run --rm -v "$PWD":/w -w /w gcc:14 sh -c '
gcc -pthread arena.c -o arena
echo "default: $(./arena 2>&1 | grep -c ^Arena)"
echo "max=2: $(MALLOC_ARENA_MAX=2 ./arena 2>&1 | grep -c ^Arena)"
./arena 2>&1 | tail -4'
既定では複数のアリーナが作られ(数は Docker に割り当てたコア数とタイミングで変わる。4 コアの Linux では 15 前後になった)、MALLOC_ARENA_MAX=2 では 2 に収まるはずだ。最後の出力では、全スレッドが free し終えた後でも system bytes(OS から借りている量)が in use bytes(実際に使用中の量)よりずっと大きいことが確認できる。
- RSS が増え続けるならメモリリークだ — RSS は malloc が OS から借りて抱えている量で、Ruby が使っている量ではない。オブジェクト数が横ばいで RSS だけが増えるなら、断片化を先に疑う。
- GC を頻繁に走らせればメモリは減る — GC は Ruby のオブジェクトを解放して malloc に返すだけで、malloc がそれを OS に返すかは別の話である。上端に使用中の領域が残っていれば、GC 後も RSS はほとんど下がらない。
MALLOC_ARENA_MAX=1にすれば最も省メモリで得 — すべてのスレッドが 1 つのロックを奪い合うので性能低下が大きく、2 と比べた節約分はわずかだという報告がある。推奨値として広く使われているのは 2 である。
- malloc
- OS から借りたメモリを小さく切り分けてプログラムに渡す C ライブラリの関数。free で返された領域を再利用する。
- アリーナ
- glibc malloc が持つ独立したメモリ置き場。スレッド間のロック競合を避けるために複数作られる。
- 断片化
- 空き領域が使用中の領域の間に細切れに散らばり、再利用も OS への返却もしにくくなった状態。
- RSS
- プロセスが実際に物理メモリに載せているページの量。コンテナのメモリ上限の判定にも関わる。
- MALLOC_ARENA_MAX
- glibc malloc が作るアリーナ数の上限を決める環境変数。プロセス起動時に設定しておく必要がある。
- jemalloc
- 断片化を抑える設計の malloc 実装。LD_PRELOAD で glibc の malloc と差し替えて使う。
- Malloc Can Double Multi-threaded Ruby Program Memory Usage — スレッドがアリーナを選ぶ手順と、Ruby での測定結果を詳しく追った解説。
- Heroku Dev Center: Tuning glibc Memory Behavior — MALLOC_ARENA_MAX の既定値と推奨値、性能とのトレードオフの公式説明。
- mallopt(3) — Linux manual page — M_ARENA_MAX・M_MMAP_THRESHOLD・M_TRIM_THRESHOLD の既定値と環境変数の一次ソース。
- malloc_trim(3) — Linux manual page — 空き領域を明示的に OS へ返す関数と、glibc 2.8 以降の挙動。