- Published on
Kubernetes OOMKilled(137) メモリ問題の解決 — 上限を上げる前に確認すべきこと
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — 137 で停止したのにログには例外がありません
コンテナが繰り返し再起動するのに、ログの最後の行はごく普通の情報ログです。
kubectl get pod -n search
NAME READY STATUS RESTARTS AGE
indexer-6c4d9f7b8-h2klp 0/1 CrashLoopBackOff 4 (94s ago) 22m
kubectl logs indexer-6c4d9f7b8-h2klp -n search --previous | tail -3
2026-07-26T00:38:41.220Z INFO segment merge started shard=7 docs=1284000
2026-07-26T00:38:44.902Z INFO segment merge progress shard=7 pct=61
例外も、終了メッセージもありません。プロセスが何かを言う機会を得られないまま消えたという意味であり、それは SIGKILL の特徴です。
kubectl describe pod indexer-6c4d9f7b8-h2klp -n search | grep -A6 "Last State"
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Sun, 26 Jul 2026 09:36:02 +0900
Finished: Sun, 26 Jul 2026 09:38:45 +0900
Restart Count: 4
137 が伝えることと伝えないこと
137 は 128 に 9 を足した値であり、9 番のシグナルは SIGKILL です。POSIX シェルとコンテナランタイムは、シグナルで終了したプロセスの終了コードを 128 足すシグナル番号として報告します。
ここで正確に区別すべきことがあります。137 は「誰かがこのプロセスを強制的に終了させた」という事実だけを伝えます。メモリが原因なのかどうかは教えてくれません。SIGKILL を送る主体は複数あります。
- カーネルの cgroup OOM キラー — コンテナがメモリ上限を超えたとき
- カーネルの全体 OOM キラー — ノード全体のメモリが枯渇したとき
- kubelet — SIGTERM のあと猶予時間内に終了しないコンテナに対して
- ノードの別の管理ツール、あるいは人
メモリが原因かどうかを決めるのは終了コードではなく Reason フィールド です。Reason が OOMKilled ならメモリであり、Error なのに 137 なら猶予時間切れによる強制終了です。後者にメモリを足しても何の効果もありません。
kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,REASON:.status.containerStatuses[*].lastState.terminated.reason,CODE:.status.containerStatuses[*].lastState.terminated.exitCode' \
| grep -v '<none>' | head -5
NS POD REASON CODE
search indexer-6c4d9f7b8-h2klp OOMKilled 137
payments ledger-5f8b7c6d9-nm3xt Error 137
media transcode-79c5d6f8b-vn4pq Error 143
二番目の Pod は 137 ですが OOM ではありません。終了シグナルを無視したまま停止したのであり、解決策はグレースフルシャットダウンの側にあります。
最も確実な証拠は、cgroup が自分で数えているカウンタです。
kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- cat /sys/fs/cgroup/memory.events
low 0
high 0
max 3421
oom 2
oom_kill 2
oom_kill が 0 より大きければ、この cgroup の中で実際にプロセスが OOM で停止したという意味です。max カウンタも重要です。この値は使用量が上限に触れてカーネルが回収を試みた回数なので、まだ停止していないものの非常に際どいコンテナを先回りして見つけるのに使います。
コンテナの OOM か、ノード全体の OOM か
症状は似て見えますが、対応はまったく異なります。
コンテナの cgroup OOM は、そのコンテナだけが停止します。上限を超えたのはこのコンテナの責任であり、ノードの他の Pod は無事です。
ノード全体の OOM は、ノード全体のメモリが枯渇してカーネルが犠牲者を選ぶ状況です。停止するプロセスが原因を作った側とは限らず、複数の Pod が同時に停止します。
kubelet の退避(エビクション) は、その前の段階です。kubelet はノードメモリが退避のしきい値を下回ると Pod を選んで終了させ、このときの状態は OOMKilled ではなく Evicted になります。
ノード上のカーネルメッセージ一行で判別できます。
kubectl debug node/ip-10-0-1-14 -it --image=busybox -- sh
/ # dmesg -T | grep -i "out of memory" | tail -3
[Sun Jul 26 09:38:45 2026] Memory cgroup out of memory: Killed process 21847 (java) total-vm:9421884kB, anon-rss:2088412kB, file-rss:2104kB, shmem-rss:0kB, UID:1000 pgtables:5120kB oom_score_adj:939
Memory cgroup out of memory という接頭辞が決定的です。この文言があればコンテナの上限超過であり、それがなく Out of memory: Killed process だけが出るならノード全体の OOM です。
退避はイベントで確認します。
kubectl get events -A --field-selector reason=Evicted --sort-by=.lastTimestamp | tail -3
NAMESPACE LAST SEEN TYPE REASON OBJECT MESSAGE
media 4m12s Warning Evicted pod/transcode-79c5d6f8b-9klm The node was low on resource: memory. Threshold quantity: 100Mi, available: 84Mi. Container transcode was using 3187Mi, request is 512Mi.
メッセージには使用量と要求量が一緒に出ます。3187Mi を使いながら 512Mi しか要求していない Pod がノードを危険にさらしたという意味であり、この場合に直すべきなのは上限ではなく 要求量の正直さ です。
全体 OOM で誰が先に停止するかは QoS クラスが決めます。kubelet は QoS に応じてコンテナの oom_score_adj を変えて設定します。
- Guaranteed — すべてのコンテナの requests と limits が同じ。oom_score_adj は -997 で、事実上最後に停止します
- BestEffort — requests も limits もなし。oom_score_adj は 1000 で、最初に停止します
- Burstable — その中間。メモリ要求量がノード容量に対して大きいほどスコアが下がり、生き残る確率が高くなります
kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,QOS:.status.qosClass' | grep BestEffort | head -3
NS POD QOS
default debug-shell BestEffort
media thumbnailer-5c8d7f9b6-p4wnt BestEffort
ランタイムがコンテナの上限を誤解するとき
コンテナに 2Gi を与えたのに、アプリケーションはノードの 64Gi を自分の取り分だと勘違いすることがよくあります。
JVM
最新の JVM はコンテナ認識をデフォルトで有効にするので、上限を超えてヒープを確保することはまれです。問題は逆方向にあります。デフォルトの MaxRAMPercentage が 25 パーセントなので、2Gi のコンテナではヒープ上限が 512Mi になります。
kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- \
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "MaxHeapSize|MaxRAMPercentage"
size_t MaxHeapSize = 536870912 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
ここで 137 が出たなら原因はヒープではありません。ヒープ上限が 512Mi なのにコンテナが 2Gi で停止したのなら、残りの 1.5Gi を ヒープの外 が使っているという意味です。メタスペース、スレッドスタック、コードキャッシュ、ダイレクトバイトバッファ、GC のデータ構造、ネイティブライブラリがここに当たります。
kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- jcmd 1 VM.native_memory summary
Total: reserved=4183MB, committed=1971MB
- Java Heap (reserved=512MB, committed=512MB)
- Class (reserved=1084MB, committed=68MB)
- Thread (reserved=418MB, committed=418MB)
- Code (reserved=250MB, committed=42MB)
- GC (reserved=76MB, committed=76MB)
- Internal (reserved=812MB, committed=804MB)
スレッドが 418MB を使っています。デフォルトのスタック 1MB でスレッドを 400 個作ったということです。ここでヒープを増やすと状況はさらに悪くなります。
よくある誤答:137 を見てヒープサイズを上げること。ヒープを増やすと GC の頻度が下がって実際の常駐メモリが増え、オフヒープが原因だったなら停止する時点を早めるだけです。まずどこが大きいのかから見るべきです。
推奨する設定は絶対値ではなく、比率と上限の組み合わせです。
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:MaxRAMPercentage=70.0
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps
resources:
requests:
memory: 2Gi
limits:
memory: 2Gi
-XX:+ExitOnOutOfMemoryError が重要です。このオプションがないと、JVM はヒープ不足の状況でも停止せず、GC に時間をすべて使いながら生き続けます。見た目は Running なのに応答しない、診断が最も難しい状態になります。いっそきれいに停止して再起動するほうがましです。
Node.js
V8 の古い世代のヒープ上限はプロセスが認識したシステムメモリを基準に決まりますが、コンテナの上限を正確に反映できない組み合わせが依然として存在します。明示するほうが安全です。
kubectl exec -it api-gateway-7d8f9c6b5-x2mnp -n edge -- \
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1048576)"
4144.0
コンテナの上限は 1Gi なのに、V8 は 4GB まで増やせると信じています。この状態では V8 が GC を急がないまま、コンテナのほうが先に停止します。JavaScript のヒープ不足例外すら出ず、ただの 137 です。
env:
- name: NODE_OPTIONS
value: '--max-old-space-size=768'
resources:
requests:
memory: 1Gi
limits:
memory: 1Gi
上限の 75 パーセント程度をヒープに与え、残りをバッファ、ネイティブアドオン、スタックに残します。Buffer を多用するアプリケーションならもっと下げる必要があります。Buffer はヒープの外に確保されるので、max-old-space-size には含まれません。
Python
Python にはヒープ上限という概念がありません。代わりに二つのことがよく問題を作ります。
一つ目は、glibc のスレッドごとのメモリアリーナです。スレッドが多いとアリーナが増え、RSS が実使用量よりはるかに大きくなります。
env:
- name: MALLOC_ARENA_MAX
value: '2'
二つ目は、ワーカープロセスがリークを蓄積することです。根本修正までは、定期的なリサイクルが実用的な緩衝装置になります。
command:
- gunicorn
- --workers=4
- --max-requests=2000
- --max-requests-jitter=200
- app:application
--max-requests-jitter がないと、ワーカーが同時に再起動してリクエストが一斉に途切れます。
コンテナは生きているのに機能が停止する場合
cgroup の OOM キラーはコンテナを停止させるのではなく、cgroup の中で最もスコアが高いプロセス を終了させます。PID 1 ではない子ワーカーが犠牲になると、コンテナは Running のまま見え、RESTARTS も増えず、機能の一部だけが静かに止まります。
kubectl exec -it api-gateway-7d8f9c6b5-x2mnp -n edge -- cat /sys/fs/cgroup/memory.events | grep oom_kill
oom_kill 3
RESTARTS は 0 なのに oom_kill が 3 です。この組み合わせが見えたら、子プロセスが停止しているという意味です。解決は、監督プロセスが子の死を検知して自分から終了するようにすることです。そうしてはじめて Kubernetes が介入できます。
working set と RSS、そしてページキャッシュ
メモリ指標は名前が似ているのに意味が違います。間違った指標にアラートを仕掛けると、毎日誤検知に悩まされます。
- memory.current(cgroup v2)— この cgroup が占めた全体。匿名メモリとページキャッシュの両方を含みます。上限判定の基準になる値です
- working set — memory.current から非アクティブなファイルキャッシュを引いた値。
kubectl topが見せる値であり、kubelet の退避判定の基準です - RSS — プロセスが実際に物理メモリに載せたページ。共有ライブラリが重複して数えられ、ページキャッシュは外れます
ここで落とし穴が出てきます。ファイルを多く読むアプリケーションはページキャッシュが積み上がり、memory.current が上限に張り付きます。グラフだけ見るとすぐにも停止しそうですが、キャッシュは回収可能なので実際には停止しません。
kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- sh -c \
'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/memory.current; grep -E "^(anon|file|slab) " /sys/fs/cgroup/memory.stat'
2147483648
2091483136
anon 1183842304
file 874512384
slab 33124352
全体 2091MB のうち匿名メモリは 1183MB、ファイルキャッシュが 874MB です。匿名メモリだけ見れば余裕があります。
kubectl top pod indexer-6c4d9f7b8-h2klp -n search
NAME CPU(cores) MEMORY(bytes)
indexer-6c4d9f7b8-h2klp 410m 1216Mi
kubectl top が 1216Mi を報告する理由がこれです。非アクティブなファイルキャッシュを引いた値です。
よくある誤答:アラートを container_memory_usage_bytes に仕掛けること。この指標はページキャッシュを含むので、正常なファイル I/O でも上限の近くまで上がります。アラートは container_memory_working_set_bytes で仕掛けるべきです。
ただし一つ但し書きがあります。キャッシュが回収されない種類のとき、たとえば tmpfs や共有メモリに載せたデータは回収対象ではなく、そのまま上限を押し上げます。emptyDir の medium を Memory にしたボリュームが代表例です。このボリュームに書いたデータはすべてコンテナのメモリ上限に計算されます。
volumes:
- name: scratch
emptyDir:
medium: Memory
sizeLimit: 256Mi
sizeLimit を必ず指定してください。なければノードメモリの半分まで使えてしまいます。
requests と limits をどう決めるか
| 原因 | 代表的な兆候 | 診断 | 解決 |
|---|---|---|---|
| コンテナ上限の超過 | Reason OOMKilled、Exit Code 137 | describe の Last State、memory.events の oom_kill | 上限の引き上げまたは使用量の縮小 |
| 子プロセスだけが停止 | RESTARTS は 0 なのに機能の一部が停止 | oom_kill カウンタと RESTARTS の比較 | 監督プロセスが子の死で終了 |
| JVM のオフヒープ超過 | ヒープは余裕、コンテナは上限 | jcmd VM.native_memory summary | メタスペースとダイレクトの上限を指定 |
| Node.js のヒープ上限が未設定 | heap_size_limit がコンテナ上限より大きい | v8 のヒープ統計を照会 | max-old-space-size を明示 |
| ページキャッシュの蓄積 | usage は上限の近く、working set は余裕 | memory.stat の file 項目 | アラート指標を working set に交換 |
| メモリベースの emptyDir | ボリューム書き込みとともに使用量が増加 | ボリューム仕様の medium を確認 | sizeLimit の指定またはディスクの使用 |
| ノード全体の OOM | 複数の Pod が同時に停止、dmesg に cgroup の文言なし | ノードの dmesg | オーバーコミットの縮小、requests の整合性 |
| kubelet の退避 | STATUS Evicted | reason=Evicted のイベント | 要求量の引き上げ、退避しきい値の再検討 |
| メモリリーク | working set がのこぎり歯なしに単調増加 | 時系列グラフ、プロファイリング | コード修正、ワーカーのリサイクル |
メモリに関しては requests と limits を同じにするのがおおむね正しいです。理由は、メモリが 圧縮できない資源 だからです。CPU は不足すると遅くなるだけですが、メモリは不足すると停止します。requests より多く使う Pod はスケジューラの計算を裏切っており、その代償は同じノードの他の Pod が払います。
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
memory: 2Gi
ここでよくある論争があります。requests と limits を全部同じに合わせて Guaranteed QoS を得るべきかどうかです。整理するとこうなります。
- メモリは同じに — ノード全体の圧迫で最後に犠牲になり、オーバーコミットによる連鎖事故を防ぎます
- CPU requests は指定、CPU limits は慎重に — CPU の上限は cgroup の帯域スロットリングを引き起こします。余裕のあるノードでも上限に引っかかって遅延が跳ねる現象が出るので、遅延に敏感なサービスでは CPU limits を省略し、requests で取り分だけ確保する選択がよく採られます
- ただし CPU limits を省略すると Guaranteed QoS は得られません。ノード全体の OOM 防御と遅延の安定性のどちらがより重要かという問題であり、どちらかが常に正しいわけではありません
よくある誤答:137 が出るたびに上限を二倍に上げること。リークなら停止する周期が延びるだけで、その間にノードが一つ余分に必要になります。まず時系列を見てください。のこぎり歯の形で上下するなら正常な GC のパターンであり、上限が本当に足りていません。単調増加してから崖のように落ちるならリークです。
再発防止 — 根拠を作り、先回りして捕まえます
VPA で実際の使用量に根拠を置きます
推奨モードだけで回しても十分な価値があります。自動適用は Pod の再生成を伴うので慎重であるべきです。
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: indexer
namespace: search
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: indexer
updatePolicy:
updateMode: 'Off'
kubectl get vpa indexer -n search \
-o jsonpath='{.status.recommendation.containerRecommendations[0]}'
{"containerName":"indexer","lowerBound":{"cpu":"320m","memory":"1300Mi"},"target":{"cpu":"480m","memory":"1730Mi"},"uncappedTarget":{"cpu":"480m","memory":"1730Mi"},"upperBound":{"cpu":"1100m","memory":"2600Mi"}}
target は観測された使用量の分布に基づく推奨値であり、upperBound は観測期間が短いほど余裕を持った値になります。最低でも一週間、可能ならトラフィックのピークを含む期間の値を見てはじめて意味があります。
停止する前にアラートを受け取ります
停止したあとに来るアラートは事後通知です。上限に近づく段階で捕まえるべきです。
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: memory-alerts
namespace: monitoring
spec:
groups:
- name: memory
rules:
- alert: ContainerOOMKilled
expr: increase(kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}[10m]) > 0
labels:
severity: critical
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} was OOMKilled"
- alert: ContainerMemoryNearLimit
expr: |
container_memory_working_set_bytes{container!="",container!="POD"}
/ on(namespace,pod,container) container_spec_memory_limit_bytes{container!=""} > 0.9
for: 15m
labels:
severity: warning
- alert: ContainerMemoryGrowsMonotonically
expr: |
deriv(container_memory_working_set_bytes{container!="",container!="POD"}[6h]) > 0
and increase(container_memory_working_set_bytes{container!="",container!="POD"}[6h]) > 200e6
for: 1h
labels:
severity: warning
三番目のルールがリークの早期警報です。6h のあいだ単調増加しながら 200MB 以上増えたのなら、正常なキャッシュのウォームアップとは見なしにくいです。
事後分析の材料をあらかじめ確保します
OOM で停止したコンテナは何も残せません。停止する前にダンプが出るようにボリュームを付けておきます。
volumeMounts:
- name: dumps
mountPath: /dumps
volumes:
- name: dumps
persistentVolumeClaim:
claimName: heap-dumps
JVM は先に見た HeapDumpOnOutOfMemoryError で、Node.js はシグナルベースのスナップショットで、Python は memray や tracemalloc で確保します。ただし、ヒープダンプを書いているあいだにメモリが追加で必要になるので、上限に余裕がないとダンプ自体が失敗しうるという点は織り込んでおくべきです。
おわりに — Reason を先に見て、上限は最後に上げます
OOMKilled への対応の順序は決まっています。Reason が本当に OOMKilled なのかを確認し、cgroup の oom_kill カウンタで裏づけ、dmesg でコンテナの OOM かノード全体の OOM かを分け、ランタイムが上限をきちんと認識しているかを検査し、working set の時系列がのこぎり歯なのか単調増加なのかを見ます。ここまで来てはじめて、上限の調整が根拠のある判断になります。
覚えておくべき一文はこれです。137 は診断ではなく死因不明であり、メモリだと確定してくれるのは Reason フィールドと oom_kill カウンタだけです。