メモリ確保は成功したのに、なぜプロセスは後から OOM killer に殺されるのか?
Sidekiq のワーカーがエラーログも残さずに消え、終了コードは 137。カーネルログには Killed process の一行だけが残っていた。メモリが足りないなら確保の時点で失敗してくれればいいのに、なぜ Linux は「確保は成功させておいて、後から殺す」のだろうか。
mallocが返すのは仮想アドレスの「予約」で、物理メモリは初めて書き込んだページから順に割り当てられる。- Linux は既定(
vm.overcommit_memory=0)で物理メモリ以上の予約を許すため、足りなくなるのは確保時ではなく書き込み時になる。 - 書き込み時には失敗を返す相手がいないので、カーネルは OOM killer でスコアの高いプロセスを SIGKILL して物理メモリを取り戻す。
プロセスが見ているのは「番地」であって「メモリ」ではない
Linux の各プロセスは仮想メモリ(プロセスごとに用意された見かけ上のアドレス空間)の上で動いている。プログラムが読み書きするアドレスは仮想アドレスで、それを実際の物理メモリの位置へ変換する対応表をページテーブルと呼ぶ。対応付けはページ(メモリ管理の最小単位の固定長ブロック)ごとに行われる。
malloc や mmap で大きな領域を確保したとき、カーネルがやるのは「この番地の範囲はあなたのものだ」と記録することだけだ。物理メモリはまだ一切割り当てられない。プログラムがその範囲のページに初めて書き込むと、CPU は対応する物理ページが無いことに気付いてページフォールト(変換に失敗したことをカーネルに知らせる例外)を起こし、カーネルはその場で空いている物理ページを 1 枚用意してページテーブルに書き込む。この「使われた分だけ後から割り当てる」方式をデマンドページングという。
ps aux の VSZ と RSS の差はここから生まれる。VSZ は予約済みの仮想アドレス空間の大きさで、RSS は実際に物理メモリに載っている量だ。Java や Go のプロセスで VSZ が何十 GB と表示されても慌てなくてよいのは、予約しただけで触っていない範囲が大半を占めているからである。
オーバーコミット: 物理メモリより多くを約束する
予約と割り当てが分かれているなら、カーネルは「全プロセスの予約の合計」が物理メモリを超えても受け付けられる。これをオーバーコミットと呼ぶ。挙動は sysctl の vm.overcommit_memory で選べ、カーネルのドキュメントによれば値の意味は次のとおりだ。
# 0: ヒューリスティック(デフォルト) # 明らかに無茶な予約だけ拒否する # 1: 常に許可(チェックしない) # 2: 許可しない。予約の合計を # swap + 物理メモリ × overcommit_ratio% に制限 # (overcommit_ratio のデフォルトは 50) sysctl vm.overcommit_memory vm.overcommit_ratio
なぜデフォルトで約束を破りうる設計なのか。理由の一つは fork だ。10GB 使っている Ruby プロセスが fork すると、子プロセスは親のメモリをそのまま見られる状態で生まれるが、実体はコピーオンライト(書き込まれたページだけ後からコピーする方式)で共有されている。予約を厳密に数えると子の分としてさらに 10GB 分の約束が必要になり、ほとんど書き換えずにすぐ exec するだけの子プロセスのためにメモリを確保できず fork が失敗してしまう。疎な巨大配列やスレッドのスタックのように「予約は大きいが実際に触るのは一部」というパターンは多く、それを許すほうが物理メモリを有効に使える。
直感的には、メモリが足りなければ malloc が NULL を返して、アプリが自分でエラー処理すればよいように思える。しかし実際は、デフォルト設定では予約はほぼ通ってしまい、物理メモリが尽きるのはその後の何気ない代入文の瞬間になる。代入文には失敗を返す仕組みが無い。カーネルは、ページフォールトの処理を完了させるために、どこかから物理メモリを奪ってくるしかなくなる。
アプリ カーネル 物理メモリ │ malloc(1GB) │ │ ├──────────────▶│ 範囲を記録 │ │◀──────────────┤ 成功(RSS 0) │ │ p[0] = 1 │ │ ├──────────────▶│ ページフォールト │ │ ├────────────────▶│ 1枚割当 │ ...書き込みを続ける... │ │ ├────────────────▶│ 空き 0 │ │ swap も満杯 │ │ ▼ │ │ OOM killer 起動 │ │ スコア最大のプロセスに │ │ SIGKILL(自分とは限らない) │
航空会社のオーバーブッキングだ。一定数の乗客は来ないと見込んで座席数より多く予約を受け付ける(オーバーコミット)。予約の時点では誰も断られない。全員が搭乗口に来たとき初めて席が足りなくなり、航空会社は決められた基準で誰かを降ろす(OOM killer)。降ろされるのは最後に来た客とは限らず、予約時に「絶対に降ろさない」と約束された客(oom_score_adj=-1000)は対象外になる。
OOM killer は誰を殺すのか
物理メモリと swap がどちらも尽きると、カーネルは OOM killer(Out Of Memory 時に強制終了するプロセスを選ぶカーネルの機構)を起動する。各プロセスの「殺されやすさ」は /proc/<pid>/oom_score で見られる。カーネルのドキュメントでは、基本スコアは許されたメモリのうちそのプロセスがどれだけ使っているかの割合で、0(殺さない)から 1000(必ず殺す)の範囲になる。メモリを半分使っているプロセスなら 500 前後だ。
このスコアに加算される補正値が /proc/<pid>/oom_score_adj で、-1000 から +1000 の値を取る。-1000 にするとそのプロセスのスコアは常に 0 となり、OOM killer の対象から事実上外れる。逆に +1000 にすれば真っ先に狙われる。
ここで素朴な予想が裏切られる。メモリを食い潰した犯人が殺されるとは限らないのだ。選ばれるのはその瞬間に最もスコアが高いプロセスなので、暴走した小さなバッチではなく、普段から大きな共有バッファを持つ DB が犠牲になることがある。PostgreSQL のドキュメントは、親プロセス(postmaster)が OOM killer に殺されると既存接続は動き続けても新規接続を受け付けなくなり再起動が必要になると説明し、起動スクリプトで postmaster の oom_score_adj を -1000 にする方法や、専用ホストでは vm.overcommit_memory=2 で OOM の起きる確率を大きく下げる方法を挙げている。
OOM killer が送るのは SIGKILL なので、プロセスは捕捉もクリーンアップもできない。シェルやコンテナランタイムが報告する終了コード 137 は「128 + シグナル番号 9(SIGKILL)」であり、アプリのログに何も残らないのはこのためだ。
コンテナと Kubernetes ではどう変わるか
コンテナではホスト全体ではなく cgroup(プロセス群ごとに資源の上限を課すカーネル機能)単位でも同じことが起きる。docker run -m 512m のようにメモリ上限を付けると、その cgroup の使用量が上限に達した時点で、ホストに空きがあってもコンテナ内のプロセスが OOM killer に殺される。Docker のドキュメントは、--memory-swap を --memory と同じ値にするとコンテナは swap を使えなくなると説明している。
Kubernetes では kubelet が Pod の QoS クラスに応じて各コンテナの oom_score_adj を設定する。ノード全体がメモリ不足に陥ったとき、どの Pod から殺されるかはこの値でほぼ決まる。
# kubelet が設定する oom_score_adj(公式ドキュメントより)
# Guaranteed : -997
# BestEffort : 1000
# Burstable : min(max(2, 1000 - 1000
# × メモリ request ÷ ノードのメモリ容量), 999)
# request = limit なら Guaranteed になる
resources:
requests:
memory: "512Mi"
limits:
memory: "512Mi"
requests を書かない Pod は BestEffort になり、ノードのメモリが逼迫すると最初の標的になる。requests を実使用量より小さく書いた Burstable の Pod も、補正値が大きく実使用量の割合も高くなるため狙われやすい。なお kubelet 自身もしきい値に基づいて Pod を退避(eviction)させるが、それが間に合わずに物理メモリが尽きた場合にカーネルの OOM killer がこのスコアで選ぶ、という二段構えになっている。
典型は「ワーカーやコンテナがログを残さず再起動を繰り返す」症状だ。まず終了コード(137 か)と、kubectl describe pod の Last State の Reason: OOMKilled、ホストなら dmesg や journalctl -k の Killed process 行を確認する。次に「殺されたプロセスが本当に犯人か」を疑う。ノード全体の OOM なら、同居している別プロセスの膨張が原因のこともある。JVM のヒープ上限やワーカー数 × 1 プロセスの RSS がコンテナの limits.memory を超える構成になっていないかも、確保時ではなく使い始めてから落ちるという性質上、負荷試験で初めて表面化しやすい。
メモリ上限 100MB(swap 無し)のコンテナで 1GB を予約し、予約は成功するのに書き込むと殺されることを確かめる。
docker run --rm -m 100m --memory-swap 100m python:3.12-slim python -c "
import mmap
def rss():
for l in open('/proc/self/status'):
if l.startswith('VmRSS'):
return l.strip()
m = mmap.mmap(-1, 1 << 30)
print('1GB の予約に成功:', rss(), flush=True)
for i in range(0, len(m), 4096):
m[i] = 1
print('ここには来ない')
"
echo "exit code: $?"
1 行目では 1GB を予約したのに VmRSS が Python 本体の分(1GB よりはるかに小さい値)に留まり、予約が物理メモリを消費していないことが分かる。その後、ページごとに 1 バイトずつ書き込むと RSS が 100MB に達した時点で SIGKILL され、「ここには来ない」は表示されず exit code: 137 が出るはずだ。
- メモリが足りなければ malloc が失敗するはずだ — デフォルトのヒューリスティックモードでは予約はほぼ通り、不足は書き込み時に表面化する。そのとき失敗を返す口は無いので、結果は OOM killer による強制終了になる。
- OOM killer に殺されたプロセスがメモリリークの犯人だ — 選ばれるのはその瞬間のスコアが最大のプロセスで、メモリを使い込んだ張本人とは限らない。ノード全体の OOM では同居プロセスの使用量も確認する。
- VSZ が大きいプロセスはメモリを食っている — VSZ は予約済みの仮想アドレス空間の大きさで、物理メモリの消費量を表すのは RSS のほうだ。
- 仮想メモリ
- プロセスごとに用意された見かけ上のアドレス空間。ページテーブルで物理メモリに対応付けられる。
- デマンドページング
- 予約時ではなく、ページに初めて触れた時点で物理メモリを割り当てる方式。
- ページフォールト
- アクセスしたページに物理メモリが対応付けられていないとき CPU がカーネルに知らせる例外。
- オーバーコミット
- 物理メモリと swap の合計を超えるメモリ予約を許すこと。
vm.overcommit_memoryで制御する。 - OOM killer
- 物理メモリと swap が尽きたとき、スコアの高いプロセスを SIGKILL して物理メモリを回収するカーネルの機構。
- oom_score_adj
- OOM killer のスコアに加算する補正値。-1000〜+1000 で、-1000 は対象外を意味する。
- Overcommit Accounting (Linux kernel documentation) —
vm.overcommit_memoryの 3 モードとovercommit_ratioの一次情報。 - PostgreSQL: Managing Kernel Resources — 「Linux Memory Overcommit」節で、DB を OOM killer から守る具体的な設定手順が読める。
- Node-pressure Eviction (Kubernetes) — kubelet の退避と、QoS ごとの
oom_score_adjの表がある。 - Resource constraints (Docker Docs) —
--memory/--memory-swapの組み合わせとコンテナ内 OOM の扱い。