- 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 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 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 计数器。