Skip to content
Published on

Kubernetes OOMKilled(137) 内存问题排查 — 在调高上限之前要确认的事

分享
Authors

开篇 — 以 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 shell 和容器运行时会把被信号终止的进程的退出码报告为 128 加信号编号。

这里有一点必须准确区分。137 只说明"有人强行杀死了这个进程"这一事实。它不会告诉你原因是不是内存。发送 SIGKILL 的主体有好几个。

  • 内核的 cgroup OOM killer — 容器超过内存上限时
  • 内核的全局 OOM killer — 整个节点内存耗尽时
  • 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 的每线程内存 arena。线程一多 arena 就会增加,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 killer 杀的不是容器,而是 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 计数器。