Skip to content

필사 모드: Kubernetes OOMKilled(137) メモリ問題の解決 — 上限を上げる前に確認すべきこと

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 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 137describe の 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 Evictedreason=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 カウンタだけです。

현재 단락 (1/230)

コンテナが繰り返し再起動するのに、ログの最後の行はごく普通の情報ログです。

작성 글자: 0원문 글자: 11,545작성 단락: 0/230