メモリが余っているのに OOM で死ぬ理由 — オーバーコミット、cgroup、OOM キラー
監視グラフにはメモリの余裕があるのにプロセスが死んでいて、dmesg には OOM キラーの痕跡が残っています。「メモリが余っているのになぜ OOM?」は Linux 運用で最もよく出会うパラドックスで、答えは 3 層あります。Linux はメモリを約束だけして後から支給し(オーバーコミット)、死の基準はシステム全体ではなく cgroup の上限かもしれず、グラフの「残りメモリ」はそもそも読み方を間違えた数字かもしれません。 ひとつずつ解いていきます。
前提 — malloc はメモリをくれません #
最初の鍵はオーバーコミット(overcommit)です。プロセスが malloc で 1GB を要求しても、カーネルは物理メモリを確保せず、仮想アドレス空間を予約するだけです。実際の物理ページは、プロセスがそのアドレスに初めて書き込んだ瞬間に割り当てられます。だから Linux は物理メモリより多い量を平然と約束でき、ほとんどの場合これはうまく回ります。プロセスが要求した分を全部使うことはまれだからです。fork が copy-on-write で動くことと同じ思想です。
問題は、約束が一斉に履行を求められるときです。複数のプロセスが約束されたメモリに実際に書き込み始め、物理メモリが本当に尽きると、カーネルには選択肢がありません。約束済みのメモリなので断れず、かといってシステム全体を止めるわけにもいきません。そこで登場するのが OOM キラーです。誰かを殺してメモリを回収する、カーネルの最終手段です。
vm.overcommit_memory の設定でこの動作は変えられますが(2 で厳格モード)、デスクトップや一般的なサーバーのデフォルトはヒューリスティックなオーバーコミットで、ほとんどの環境はこの前提の上で運用されています。
free の「残りメモリ」は何が残っているという意味か #
2 つ目の鍵は指標の読み方です。free の出力で見るべきは free カラムではなく available カラムです。
free: 本当に何の用途にも使われていないメモリ。Linux は余ったメモリをページキャッシュ(ディスク内容のキャッシュ)に使うので、健全なシステムほどこの値は小さくなります。available: ページキャッシュのうち回収可能な分まで含めた、「新しいプロセスが実際に使える」推定値。
「free が小さい」のは正常で、「available が底をつく」のが危険信号です。ところが、available に余裕があるように見えても死ぬケースがあります。それが 3 つ目の鍵です。
コンテナ時代の OOM — 基準はノードではなく cgroup です #
現代の環境で出会う OOM の多くは、システム全体のメモリ枯渇ではなく cgroup の上限超過です。コンテナ(Docker、Kubernetes)は cgroup でメモリの上限を掛けており、コンテナ内のプロセスの使用量がその上限に達すると、ノードにどれだけメモリが残っていても cgroup 範囲の OOM キルが発生します。
これが「ノードのグラフには余裕があるのにポッドが OOMKilled」の正体です。確認する場所も違います。
# システム全体の OOM: カーネルログ
dmesg -T | grep -i "out of memory"
journalctl -k | grep -i oom
# Kubernetes: 終了コード 137、理由 OOMKilled
kubectl describe pod mypod # Last State: Terminated, Reason: OOMKilled終了コード 137(128 + SIGKILL の 9)は OOM の代表的なシグネチャです。カーネルログにはどの cgroup で、どのプロセスが、当時のメモリ状態がどうだったかが一緒に記録されるので、OOM 調査の最初のコマンドは常に dmesg です。
JVM とコンテナの組み合わせの古典的事故もここから生まれます。ヒープの最大値をコンテナの上限と同じにすると、ヒープ外のメモリ(メタスペース、スレッドスタック、ネイティブ)が乗った瞬間に上限を超えて死にます。コンテナの上限はプロセスの総メモリ使用量が基準であり、アプリケーション設定のヒープ上限より余裕を持たせる必要があります。
犠牲者はどう選ばれるのか — oom_score #
OOM キラーは無作為に殺しません。プロセスごとに oom_score(おおよそメモリ使用比率ベース)を計算し、最も高いものを殺します。ここに oom_score_adj(-1000〜1000)で重み付けができます。
-1000は免除です。sshd のような必須デーモンが低い値を持つ理由で、逆に死んでも構わないキャッシュ系プロセスに正の値を与えれば、犠牲の順位を譲らせることができます。- Kubernetes はこの仕組みを QoS クラスとして使います。requests と limits が同じポッド(Guaranteed)は保護され、requests なしで立ち上がったポッド(BestEffort)が先に死にます。「重要なサービスなのに先に死ぬ」なら、requests・limits の設定、つまり QoS クラスから確認すべきです。
対応 — 順序があります #
- 死因の確認: dmesg・kubectl で、システム OOM か cgroup OOM か、誰が死に、当時誰がメモリを持っていたかを確認します。ここを飛ばして「メモリ増設」に行くと、同じ事故が繰り返されます。
- 上限の問題か、使用量の問題か: 通常の負荷で必要なメモリが上限より大きいなら上限(limits)を上げるのが正しく、使用量が右肩上がりを続けるならメモリリークの調査が正解です。Python なら py-spy のようなプロファイリングが道具になります。
- スワップと圧迫指標: スワップのないノードは、メモリ圧迫がそのまま OOM に直行します。死ぬ前にシグナルを見るには、PSI(pressure stall information)や working set の推移を監視に入れるのが順序です。システムが遅くなる他の原因は Load Average の正体で扱いました。
まとめ #
- Linux はオーバーコミットでメモリを約束だけし、物理ページは書き込んだ瞬間に割り当てます。約束が一斉に履行を求められると OOM キラーが出動します。
- free ではなく available を見ます。ページキャッシュのせいで free が小さいのは正常です。
- 現代の OOM の多くはノードではなく cgroup の上限超過です。ノードにメモリが残っていてもコンテナは死にます。終了コード 137 と dmesg が確認手段です。
- 犠牲者は oom_score で選ばれ、Kubernetes では requests・limits が作る QoS クラスがその順位を決めます。
- 対応の順序は、死因の確認 → 上限の問題とリークの問題の切り分け → 圧迫指標の監視追加です。確認なしの「メモリ増設」は再発の予約です。