はじめに — アプリケーションログは最後まで正常なのに、プロセスがいません
障害報告はたいていこう届きます。「サービスが落ちたのにアプリケーションログにはエラーが何もありません。最後の行は正常なリクエスト処理です」。そしてコンテナ環境なら終了コードが 137 と表示されています。
アプリケーションログに何もないことが重要な手がかりです。SIGKILL は捕捉できず、ハンドラも動きません。プロセスは命令を 1 つ実行している途中でそのまま蒸発します。だから痕跡はアプリケーションではなくカーネルが残します。
sudo dmesg -T | grep -iE 'killed process|out of memory' | tail -5
# [Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
この 1 行が出てくれば確定です。出てこないなら OOM ではない可能性が高いです。この記事はこの行を探すところから始めて、なぜよりによってそのプロセスだったのか、そして再発させないために何を変えるべきかまで進みます。
OOM ログの探し方と 1 行ずつの読み方
まずは探し方です。dmesg リングバッファは有限なので、時間が経つと押し出されます。再起動をまたいで照会するには journald を使います。
# 現在のブートセッションのカーネルメッセージから検索
sudo journalctl -k --since "2 hours ago" --grep "Out of memory"
# 前のブートセッション (OOM のあとに再起動した場合)
sudo journalctl -k -b -1 --grep "oom"
# カーネルログだけでなく全体から (systemd-oomd、コンテナランタイムを含む)
sudo journalctl --since "2026-07-25 03:00" --until "2026-07-25 03:30" | grep -iE 'oom|killed'
journalctl -k に何もなく dmesg にもないなら、Storage=volatile の設定で再起動時にログが消えた可能性があります。この場合は /etc/systemd/journald.conf の Storage=persistent が再発防止の第一項目になります。
ではリポート全体を読みます。カーネルが出力する OOM リポートは 4 つの部分で構成されます。
[Sat Jul 25 03:14:22 2026] java invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
[Sat Jul 25 03:14:22 2026] CPU: 3 PID: 21874 Comm: java Not tainted 6.8.0-45-generic #45-Ubuntu
[Sat Jul 25 03:14:22 2026] Call Trace:
[Sat Jul 25 03:14:22 2026] dump_stack_lvl+0x48/0x70
[Sat Jul 25 03:14:22 2026] dump_header+0x4f/0x240
[Sat Jul 25 03:14:22 2026] oom_kill_process+0x10d/0x1c0
[Sat Jul 25 03:14:22 2026] out_of_memory+0x246/0x580
[Sat Jul 25 03:14:22 2026] __alloc_pages_slowpath+0xb6c/0xf70
[Sat Jul 25 03:14:22 2026] Mem-Info:
[Sat Jul 25 03:14:22 2026] active_anon:3812044 inactive_anon:118233 isolated_anon:0
[Sat Jul 25 03:14:22 2026] active_file:214 inactive_file:302 unevictable:0
[Sat Jul 25 03:14:22 2026] slab_reclaimable:38210 slab_unreclaimable:91043
[Sat Jul 25 03:14:22 2026] free:41283 free_pcp:812 free_cma:0
[Sat Jul 25 03:14:22 2026] Node 0 Normal free:165132kB min:162044kB low:202552kB high:243060kB
[Sat Jul 25 03:14:22 2026] Tasks state (memory values in pages):
[Sat Jul 25 03:14:22 2026] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[Sat Jul 25 03:14:22 2026] [ 1042] 0 1042 28901 1204 126976 0 0 systemd-journal
[Sat Jul 25 03:14:22 2026] [ 1863] 0 1863 4521 612 73728 0 -1000 sshd
[Sat Jul 25 03:14:22 2026] [ 21874] 1000 21874 3145728 2884219 23461888 0 0 java
[Sat Jul 25 03:14:22 2026] [ 22910] 999 22910 612344 498231 4145152 0 200 postgres
[Sat Jul 25 03:14:22 2026] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/app.service,task=java,pid=21874,uid=1000
[Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
それぞれの部分から取り出すべきものは次のとおりです。
最初の行の invoked oom-killer が OOM を誘発したプロセスです。死んだプロセスとは異なる場合があります。割り当てを要求して失敗した側がトリガーで、死ぬ側はスコアで決まります。order=0 は 4KB ページ 1 枚を要求したという意味で、order の値が大きい場合 (3 以上) は物理的に連続した大きなブロックを確保できなかったということなので、総量ではなく断片化の問題かもしれません。
Mem-Info ブロックは、回収可能なメモリが本当になかったのかを示します。上の例では active_file と inactive_file がそれぞれ 214、302 ページです。ページキャッシュが 1MB 程度しか残っていなかったという意味で、これはキャッシュを捨てられるだけ捨てても足りなかった証拠です。free が min ウォーターマークの近くにあることも同じ話です。逆に active_file が数十 GB あるのに OOM が起きたなら、総量の問題ではなく特定のゾーンやノードの問題です。
Tasks state テーブルの単位はページです。4KB ページ換算で java の rss 2884219 ページは約 11.0GiB です。最終行の anon-rss:11536876kB と一致します。この表は OOM の瞬間の全体スナップショットなので、本当の犯人が死んだプロセスではないときにそれを明らかにしてくれる唯一の資料です。rss 列でソートして読んでください。
oom-kill: の行がいちばん情報量が多いのですが、よく無視されます。constraint が CONSTRAINT_NONE ならシステム全体のメモリ不足、CONSTRAINT_MEMCG なら cgroup の上限、CONSTRAINT_CPUSET や CONSTRAINT_MEMORY_POLICY なら NUMA ノードの制約です。global_oom というトークンと task_memcg のパスもここにあります。
最終行の 3 種類の RSS の区別が役に立ちます。anon-rss はヒープとスタック、file-rss はマップされたファイル、shmem-rss は共有メモリと tmpfs です。shmem-rss が大きいなら /dev/shm や tmpfs マウントを疑うべきです。tmpfs に書いたデータはファイルのように見えますが、メモリを占有しディスクへ押し出されません。
誰が死ぬかを決める計算 — badness と oom_score_adj
カーネルは候補ごとにスコアを付け、最も高いものを殺します。計算はこうです。
points = rss + swapents + (pgtables_bytes / PAGE_SIZE) # 単位: ページ
adj = oom_score_adj * (totalpages / 1000)
points = points + adj
3 つのことがすぐに導かれます。
第一に、スコアは 仮想メモリ (total_vm) ではなく実際の常駐メモリ (RSS) が基準です。64GB を mmap で予約するだけで実際には 200MB しか使わないプロセスは候補ではありません。top の VIRT を見て犯人を名指しすると、ほぼ必ず外れます。
第二に、oom_score_adj は -1000 から 1000 までで、総メモリの 1000 分の 1 を単位とする加減点です。64GiB のマシンで oom_score_adj=200 は約 12.8GiB に相当するスコアを足します。実際の RSS が 2GB のプロセスでも、この補正 1 つで 11GB の JVM を追い越せます。上のログの postgres は oom_score_adj=200 でしたが、java の RSS があまりに大きかったので java が選ばれたのです。
第三に、oom_score_adj=-1000 は特別です。スコア計算そのものから除外され、絶対に選ばれません。ディストリビューションが sshd にこの値を設定しておく理由です。ログの sshd の行で確認できます。
現在の値はこう見ます。
# プロセスごとのスコアと補正値をまとめてソート
for p in /proc/[0-9]*; do
pid=${p#/proc/}
[ -r "$p/oom_score" ] || continue
printf '%8s %7s %6s %s\n' \
"$(cat "$p/oom_score" 2>/dev/null)" \
"$(cat "$p/oom_score_adj" 2>/dev/null)" \
"$pid" \
"$(tr -d '\0' < "$p/comm" 2>/dev/null)"
done | sort -rn | head -8
# 2884219 0 21874 java
# 501431 200 22910 postgres
# 38210 0 3311 containerd
# 1204 0 1042 systemd-journal
# 0 -1000 1863 sshd
oom_score のスケールはカーネルのバージョンによって異なります。最近のカーネルはページ数ベースで、以前のカーネルは 0 から 1000 に正規化していました。絶対値をしきい値に使わず、プロセス間の相対比較にだけ使ってください。
値を変える方法は 3 つあります。
# 1) 実行中のプロセスに直接
echo -500 | sudo tee /proc/21874/oom_score_adj
# 2) systemd ユニットに (再起動時にも維持)
sudo systemctl edit myapp.service
# [Service]
# OOMScoreAdjust=-500
# 3) プロセスを起動するとき
sudo choom -n -500 -- /usr/bin/myapp
コンテナ環境ではこの値をランタイムが代わりに決めます。Kubernetes は QoS クラスに応じて次のように設定します。
| QoS クラス | oom_score_adj | 例 |
|---|---|---|
| Guaranteed | -997 | requests と limits がすべてのリソースで同一 |
| Burstable | 1000 から (1000 かける メモリ request 割る ノードメモリ) を引いた値 | 16GiB ノードに 512Mi request なら 969 |
| BestEffort | 1000 | requests も limits も何も指定しない |
Burstable の結果値は -996 と 999 の間に丸められます。ここから実務的に重要な結論が出ます。メモリ request を大きく取るほど、ノード全体の OOM で生き残る確率が上がります。request を小さく書いて実際にはたくさん使うポッドは、ノードが圧迫された瞬間に真っ先に死にます。request はスケジューラを騙すための数字ではなく、生存順位です。
コンテナ OOM とシステム全体の OOM は別の事件です
実務で最も頻繁に混同される点です。どちらも終了コード 137 を作り、どちらも dmesg に OOM ログを残しますが、原因も対応もまったく異なります。
cgroup OOM のログはこういう形です。
[Sat Jul 25 04:02:11 2026] node invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=969
[Sat Jul 25 04:02:11 2026] memory: usage 524288kB, limit 524288kB, failcnt 1842
[Sat Jul 25 04:02:11 2026] swap: usage 0kB, limit 0kB, failcnt 0
[Sat Jul 25 04:02:11 2026] Memory cgroup stats for /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cri-containerd-3f7b91ac.scope:
[Sat Jul 25 04:02:11 2026] anon 521142272
[Sat Jul 25 04:02:11 2026] file 1048576
[Sat Jul 25 04:02:11 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-3f7b91ac.scope,mems_allowed=0,oom_memcg=/kubepods.slice/...,task_memcg=/kubepods.slice/...,task=node,pid=33127,uid=1000
[Sat Jul 25 04:02:11 2026] Memory cgroup out of memory: Killed process 33127 (node) total-vm:1284736kB, anon-rss:508924kB, file-rss:36104kB, shmem-rss:0kB, UID:1000 pgtables:2048kB oom_score_adj:969
見分ける基準は 3 つあり、どれか 1 つだけでも確定します。
- 最終行が
Out of memory:なのかMemory cgroup out of memory:なのか oom-kill:行のconstraintがCONSTRAINT_NONEなのかCONSTRAINT_MEMCGなのかmemory: usage / limit / failcnt行の有無。この行は cgroup OOM にしか出ません
ログを入手できなかったなら、カウンタでも確認できます。cgroup v2 はイベントを累積しています。
# ポッドの cgroup パスを見つけて
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/memory.events
# low 0
# high 0
# max 1842
# oom 7
# oom_kill 3
max は上限にぶつかって回収が起きた回数、oom は回収の失敗で OOM 経路に入った回数、oom_kill は実際に殺した回数です。max だけが大きくて oom_kill が 0 なら、上限の近くで回収を続けながら耐えている最中だという意味です。性能は悪化していますが、まだ死んではいません。この状態を捉えることは事後の検死よりもはるかに優れています。
ここで 2 つ目のよくある誤解を指摘します。終了コード 137 は OOM の証拠ではありません。137 は 128 足す 9、つまり SIGKILL で死んだという意味でしかありません。liveness probe の失敗のあと猶予時間が過ぎて死んでも、kubectl delete で消しても、ノードの終了過程で死んでも 137 です。確定するにはコンテナ状態の reason フィールドか、上のカーネルログを見なければなりません。
kubectl get pod api-7d9f8-x2k4l -o jsonpath='{.status.containerStatuses[0].lastState.terminated}' | python3 -m json.tool
# {
# "containerID": "containerd://3f7b91ac...",
# "exitCode": 137,
# "finishedAt": "2026-07-25T04:02:11Z",
# "reason": "OOMKilled",
# "startedAt": "2026-07-25T03:11:04Z"
# }
逆方向の罠もあります。ノード全体の OOM がコンテナの中のプロセスを殺すと、そのポッドも 137 を受け取ります。このとき reason は OOMKilled と出ますが、メモリの limit はまったく超えていない場合があります。limit を上げても再発します。constraint=CONSTRAINT_NONE なら、ノード自体のメモリを増やすか、ほかのポッドを追い出すのが答えです。
overcommit_memory 0/1/2 が実際に変えるもの
3 つの値の違いは 失敗がいつ、どのような形で現れるか、という点です。メモリの総量を変えるわけではありません。
| 値 | 名前 | 割り当て時点の動作 | 失敗の形 | 使う場面 |
|---|---|---|---|---|
| 0 | ヒューリスティック | 明らかに過大な単一割り当てだけ拒否 | 主に OOM Killer | デフォルト。ほとんどのワークロード |
| 1 | 常に許可 | 検査しない | 常に OOM Killer | Redis スナップショットの fork、大きな疎マッピング |
| 2 | 厳格 | CommitLimit を超えると拒否 | malloc が ENOMEM を返す | OOM Kill 自体を禁止すべきとき |
モード 2 の限界線は次のように計算されます。
sysctl vm.overcommit_memory vm.overcommit_ratio
# vm.overcommit_memory = 0
# vm.overcommit_ratio = 50
grep -E 'CommitLimit|Committed_AS|MemTotal|SwapTotal' /proc/meminfo
# MemTotal: 65806232 kB
# SwapTotal: 0 kB
# CommitLimit: 32903116 kB
# Committed_AS: 48192044 kB
CommitLimit はスワップ全体足す物理メモリの overcommit_ratio パーセントです。上の例ではスワップがないので 64GiB の 50% である 32GiB です。Committed_AS がすでに 46GiB なので、いま vm.overcommit_memory=2 に変えると新しい割り当てがただちに失敗し始めます。スワップのないマシンでデフォルトの比率のままモード 2 を有効にするのは代表的な自滅手です。使うなら vm.overcommit_ratio を 90 以上に上げるか、vm.overcommit_kbytes で絶対値を指定する必要があります。
モード 1 が必要な代表的な事例が Redis です。BGSAVE は fork で子を作りますが、コピーオンライトなので実際の追加使用量ははるかに少ないのに、カーネルのヒューリスティックは親と同じサイズのコミットとみなすことがあります。だから Redis は起動時に vm.overcommit_memory=1 を推奨する警告を出力します。
# 永続適用
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/60-overcommit.conf
sudo sysctl --system
ここで注意すべき点です。モード 1 は OOM Killer をより頻繁に呼びます。割り当て時点で拒否しないので、実際にページを触るときに弾けます。アプリケーションが malloc の失敗を優雅に処理するよう設計されているならモード 2 のほうがよく、そうでないなら (ほとんどの場合) モード 0 を維持して cgroup の上限で隔離するほうが現実的です。
「スワップを入れれば OOM は起きない」という誤解
最も広く広まった誤解であり、半分だけ正しいです。スワップは OOM を 防ぐのではなく先送りします。そして先送りしている間にシステムが経験する状態は、しばしば即死よりも悪いです。
グローバル OOM は「メモリが 0 になったら」発動するのではなく、「回収 (reclaim) を試みたのに進展がなければ」発動します。スワップがあれば匿名ページを追い出せるので回収が進展を生み、そのため OOM が先送りされます。問題は、この区間でワーキングセットがスワップを行き来するスラッシングが起きることです。プロセスは生きていますが何もできず、ヘルスチェックはタイムアウトし、SSH 接続でさえ数分かかります。
スラッシングはこう確認します。
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 1 18 8291840 92104 1024 38912 41216 38912 42104 39204 8210 19204 2 9 4 85 0
si (スワップイン) と so (スワップアウト) が同時に毎秒数十 MB ならスラッシングです。片方だけが大きい場合は正常なページアウトかもしれないので、双方向を一緒に見る必要があります。PSI を使えばもっと明確です。
cat /proc/pressure/memory
# some avg10=94.12 avg60=88.03 avg300=61.44 total=182937461283
# full avg10=71.88 avg60=66.21 avg300=44.02 total=91827364512
full が 71% ということは、直近 10 秒のうち 7 秒以上 すべてのタスクがメモリのために止まっていた、という意味です。CPU は遊休に見えて負荷も低いかもしれませんが、システムは実質的に停止状態です。
もう 2 つ指摘します。cgroup の中では memory.swap.max が別に存在し、この値が 0 ならホストにスワップがどれだけあってもそのグループはスワップを使えず、まっすぐ OOM へ進みます。Kubernetes は長らくノードのスワップを無効化するのがデフォルトで、最近ようやく限定的に対応が入ったので、コンテナワークロードで「スワップがあるから大丈夫」という仮定はおおむね成り立ちません。
そして zram や zswap は性格が違います。ディスクではなくメモリの中で圧縮するので遅延がはるかに小さく、圧縮率のよいワークロードでは実質的なメモリ拡張効果があります。ただし圧縮そのものが CPU を使い、圧縮できないデータには効果がありません。
再発防止 — カーネルが殺す前に介入する
カーネルの OOM Killer は最後の手段であり、最後の手段は遅いです。実務の目標は、その前の段階で予測可能に介入することです。
第一に、cgroup で上限を明示します。グローバル OOM はどのプロセスが死ぬか予測しにくいですが、cgroup OOM は範囲がそのグループの中に限定されます。systemd サービスならユニットに直接書きます。
sudo systemctl edit myapp.service
# [Service]
# MemoryHigh=6G
# MemoryMax=8G
# OOMPolicy=stop
sudo systemctl daemon-reload
sudo systemctl restart myapp
systemctl show -p MemoryHigh -p MemoryMax -p OOMPolicy myapp.service
# MemoryHigh=6442450944
# MemoryMax=8589934592
# OOMPolicy=stop
MemoryHigh と MemoryMax の違いが重要です。MemoryMax (cgroup の memory.max) は超えると殺します。MemoryHigh (memory.high) は殺さずに 割り当てる側を眠らせます。超過分に比例したペナルティスリープが入るので、アプリケーションは急激に遅くなりますが生きています。2 つの値を一緒に掛けると「6GB からブレーキ、8GB で終了」という緩衝区間ができ、その区間でアラートを受けて人が介入する時間を稼げます。
第二に、圧力そのものにアラートを張ります。死んだあとに数えるカウンタよりも、死ぬ前の圧力のほうが役に立ちます。
# cgroup ごとのメモリ圧力の上位 5 件
for f in /sys/fs/cgroup/system.slice/*/memory.pressure; do
v=$(awk '/^full/ {print $3}' "$f" 2>/dev/null | cut -d= -f2)
[ -n "$v" ] && printf '%7s %s\n' "$v" "${f%/memory.pressure}"
done | sort -rn | head -5
# 18.44 /sys/fs/cgroup/system.slice/myapp.service
# 0.31 /sys/fs/cgroup/system.slice/containerd.service
Prometheus を使っているなら、node_exporter の node_vmstat_oom_kill の増分と cAdvisor の container_oom_events_total を一緒に見ます。前者はノード全体、後者はコンテナ単位なので、先ほど区別した 2 つの事件がそれぞれ別の指標で捉えられます。
第三に、ユーザー空間の OOM マネージャを検討します。カーネルは回収が完全に失敗するまで待ちますが、ユーザー空間のツールはしきい値を自分で決めます。
earlyoomはMemAvailableとSwapFreeの比率を監視し、しきい値を下回ると SIGTERM を、さらに下がると SIGKILL を送ります。設定が単純で、どのディストリビューションでも動作します。systemd-oomdは PSI ベースです。cgroup 単位でmemory.pressureを見て、一定時間以上しきい値を超えるとそのスライスの中で最も圧力の大きいものを整理します。圧力という指標を使うので、絶対容量の基準より誤検知が少ないです。
# earlyoom: 利用可能メモリ 5%、スワップ 5% を下回ったら介入
sudo systemctl edit earlyoom.service
# [Service]
# Environment=EARLYOOM_ARGS=-m 5 -s 5 --avoid '(^|/)(sshd|systemd)$' --prefer '(^|/)java$'
# systemd-oomd: スライスごとの圧力しきい値
sudo systemctl edit myapp.service
# [Service]
# ManagedOOMMemoryPressure=kill
# ManagedOOMMemoryPressureLimit=60%
systemd-oomd は /etc/systemd/oomd.conf の DefaultMemoryPressureDurationSec (デフォルト 30 秒) の間しきい値を継続的に超えないと動作しないので、瞬間的なスパイクには反応しません。
第四に、実際の使用量を測り直します。上限を上げることは原因の解決ではありません。JVM なら -XX:MaxRAMPercentage がコンテナの上限を認識しているか、Go なら GOMEMLIMIT が設定されているか、ネイティブヒープの断片化や glibc アリーナの問題ではないかを見ます。smem や /proc/PID/smaps_rollup が RSS の構成を見せてくれます。
sudo cat /proc/21874/smaps_rollup
# 7f2c00000000-7ffd1a3e5000 ---p 00000000 00:00 0 [rollup]
# Rss: 11536876 kB
# Pss: 11498210 kB
# Anonymous: 11402344 kB
# AnonHugePages: 2097152 kB
# Shared_Clean: 34120 kB
# Private_Dirty: 11402344 kB
Anonymous が RSS の大部分ならアプリケーションのヒープ、Shared_Clean が大きければ共有ライブラリなので、実際のコストは Pss のほうに近いです。
おわりに — ログの constraint 1 行が対応を分けます
1 つだけ覚えるならこれです。OOM デバッグの最初の分かれ道は グローバルか cgroup か、その答えは oom-kill: 行の constraint フィールドにすでに書かれています。
CONSTRAINT_NONEならノードのメモリが不足しています。ポッドの limit を上げても再発します。ノードを大きくするか密度を下げる必要があります。CONSTRAINT_MEMCGならそのコンテナの上限の問題です。ノードは無事です。limit を上げるか、アプリケーションの使用量を減らすのが正解です。
そして残りのルールです。
- 終了コード 137 だけを見て OOM と断定しません。
reasonフィールドかカーネルログで確認します。 - 犯人は
total_vmではなくrssで探します。仮想メモリはスコアに入りません。 - メモリ request を正直に書きます。Burstable の
oom_score_adjがその値で決まり、それがノード圧迫時の生存順位です。 MemoryMaxだけを掛けず、MemoryHighで緩衝区間を作ります。死ぬ前に遅くなるほうが診断する時間をくれます。- アラートは
oom_killカウンタではなくmemory.pressureのfullに掛けます。事後通知より事前警告が必要です。
현재 단락 (1/142)
障害報告はたいていこう届きます。「サービスが落ちたのにアプリケーションログにはエラーが何もありません。最後の行は正常なリクエスト処理です」。そしてコンテナ環境なら終了コードが 137 と表示されていま...