K8s OOMKilled 排查:Exit Code 137 的三种来源
Exit Code 137 只说明被 SIGKILL,不说明原因。区分容器级、节点级、JVM 级 OOM,算出堆内外内存配比,附一套可直接跑的排查脚本。
凌晨两点收到告警:api 服务的 Pod 反复重启,kubectl get pod 里 RESTARTS 已经 27 次。describe 里一行关键信息:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Exit Code 137 = 128 + 9 = 被 SIGKILL 杀掉。在 k8s 里,这意味着内核 OOM Killer 出手了——但出手的原因可能来自三个完全不同的层面,修错了方向会一直在原地打转。
这篇文章按「先分清是哪一种 OOM」开始,最后给出配置公式和排查脚本。
一、先分清三种 OOM
| 类型 | 触发者 | 表现 | 修复方向 |
|---|---|---|---|
| 容器级 OOM | cgroup 内存限制 | Pod 重启,Reason: OOMKilled |
调 resources.limits.memory 或降内存占用 |
| 节点级 OOM | 内核全局内存不足 | 节点上多个 Pod 一起挂,dmesg 有记录 |
加节点内存 / 调 requests 让调度更准 |
| JVM 自己 OOM | JVM 堆内不足 | 抛 OutOfMemoryError,进程可能不退出 |
调 -Xmx / 查内存泄漏 |
关键区别:JVM 的 OutOfMemoryError 不会导致 Exit Code 137。 它抛出后进程往往还活着(只是业务报错),或者被你的异常处理逻辑退出(exit code 1)。看到 137 就是被系统杀的,跟 JVM 内部报什么错无关。
很多人一看 OOMKilled 就去调 -Xmx,结果堆调小了反而更容易 OOM——因为杀的压根不是堆。
二、确认是容器级还是节点级
# 1. 容器级:看 Pod 的 lastState
kubectl get pod api-xxx -o jsonpath='{.status.containerStatuses[*].lastState.terminated}'
# {"exitCode":137,"reason":"OOMKilled",...}
# 2. 节点级:在节点上查内核日志
dmesg -T | grep -i 'out of memory\|oom-kill' | tail -20
节点级 OOM 的内核日志长这样:
[Thu Nov 5 02:14:33 2026] Memory cgroup out of memory: Killed process 28471 (java)
total-vm:8912456kB, anon-rss:2096128kB, file-rss:14336kB
[Thu Nov 5 02:14:33 2026] oom_reaper: reaped process 28471 (java), now anon-rss:0kB
注意 Memory cgroup out of memory 这行——它仍然带 cgroup 字样,说明还是容器限制触发的。真正节点级的是 Out of memory: Kill process 后面跟全局内存统计。
判断方法:
# 容器是否触到了 limit
kubectl exec api-xxx -- cat /sys/fs/cgroup/memory.max # cgroup v2
kubectl exec api-xxx -- cat /sys/fs/cgroup/memory/memory.limit_in_bytes # cgroup v1
# 当前用量峰值(关键指标)
kubectl exec api-xxx -- cat /sys/fs/cgroup/memory.peak # v2,需要内核 5.19+
如果 memory.peak 紧贴着 memory.max,那就是容器级,答案很明确。
三、最容易被忽略的原因:堆外内存
这是本文的重点。假设你的配置是:
resources:
limits:
memory: 1Gi
env:
- name: JAVA_OPTS
value: "-Xmx1g" # ← 灾难
-Xmx1g 只限制堆,而 JVM 进程的总内存是:
总内存 = 堆(Xmx) + Metaspace + 线程栈 + 直接内存 + Code Cache + GC 结构 + JNI 本地内存
在容器里,一个 -Xmx1g 的 Java 进程实际 RSS 通常是 1.4~1.9 GB。limit 设 1Gi,必然被 OOMKilled,而且是「刚启动跑一会儿就死」——因为堆还没满,总量已经超了。
各部分的典型大小:
| 区域 | 典型占用 | 控制参数 |
|---|---|---|
| 堆 | Xmx |
-Xmx |
| Metaspace | 80~250 MB | -XX:MaxMetaspaceSize |
| 线程栈 | 线程数 × 1 MB | -Xss |
| 直接内存(Netty/NIO) | 50~500 MB | -XX:MaxDirectMemorySize |
| Code Cache | 40~240 MB | -XX:ReservedCodeCacheSize |
| GC 结构(G1 的 card table 等) | 堆的 2~5% | — |
Netty 应用尤其要注意直接内存。Spring WebFlux / WebClient / gRPC 都重度使用 DirectByteBuffer,默认 MaxDirectMemorySize 等于 Xmx——也就是说堆 1G,直接内存还能再吃 1G,总量轻松破 2G。
正确的配置公式:
Xmx ≈ limit × 0.65 ~ 0.75
1Gi 的 limit → -Xmx768m;2Gi → -Xmx1536m。留 25~35% 给堆外。
更稳妥的做法是用容器感知参数,让 JVM 自己算:
ENV JAVA_OPTS="-XX:MaxRAMPercentage=70.0 \
-XX:InitialRAMPercentage=70.0 \
-XX:MaxMetaspaceSize=256m \
-XX:MaxDirectMemorySize=256m \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dumps"
MaxRAMPercentage=70 表示「按容器 limit 的 70% 设堆」。JDK 8u191+ / JDK 10+ 才认 cgroup 限制,更老的版本会读到宿主机总内存——在 128G 的宿主机上 MaxRAMPercentage=70 会得出 89G 的堆,立刻 OOM。上线前一定要验证:
kubectl exec api-xxx -- java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage'
# 期望看到 MaxHeapSize 约等于 limit × 0.7
-XX:+ExitOnOutOfMemoryError 值得单独说。默认情况下 JVM 抛出 OutOfMemoryError 后进程可能处于半死状态——还在响应健康检查、还在接请求,但每个请求都失败。加了这个参数,直接退出让 k8s 重启,反而恢复更快。
四、排查正在发生的内存问题
OOMKilled 之后容器已经没了,看不到现场。三个办法留证据:
1. 配 HeapDump(需要挂载可写卷)
volumeMounts:
- name: dumps
mountPath: /dumps
volumes:
- name: dumps
persistentVolumeClaim: { claimName: app-dumps }
注意容器被 SIGKILL 时不会执行 dump——HeapDumpOnOutOfMemoryError 只在 JVM 自己抛 OOM 时生效。所以要尽早 dump:
# 提前抓一份,看趋势
kubectl exec api-xxx -- jcmd 1 GC.heap_dump /dumps/before-oom.hprof
2. 用 kubectl debug 挂临时容器(k8s 1.23+)
kubectl debug -it api-xxx --image=busybox --target=api -- sh
# 共享进程命名空间,可以直接看 /proc/1/status 的 VmRSS
3. 内存增长的分类判断
拿到 dump 后用 MAT 或 jmap -histo 看,问题通常落在四类:
kubectl exec api-xxx -- jcmd 1 GC.class_histogram | head -20
- 某业务对象数量异常 → 真实内存泄漏(缓存无上限、监听器未移除、
ThreadLocal未清理) byte[]/char[]占大头 → 大对象、大文件、批量查询未分页- 线程数上千 → 线程池无界队列 + 每线程 1 MB 栈,光栈就 1 GB
DirectByteBuffer多 → Netty 的池化内存配置问题
线程数是最容易被忽略的。查一下:
kubectl exec api-xxx -- jstack 1 | grep -c '^"'
如果超过 800,先看是不是 newFixedThreadPool 配了无界队列、或者每次请求都 new Thread()。
五、requests 和 limits 设成一样,还是留差距
这是配置上的核心争议。
requests 决定调度,limits 决定上限。 当 requests.memory == limits.memory 时,Pod 的 QoS 等级是 Guaranteed——节点内存压力大时最晚被驱逐。这对稳定服务是加分项。
但两者相等也意味着没有任何缓冲:业务稍有波动就撞上限被 OOMKilled。
我的实践是:
| 服务类型 | requests | limits | 说明 |
|---|---|---|---|
| 核心 API | 与 limits 相等 | 按压测定 | Guaranteed,避免被驱逐 |
| 批处理 / 定时任务 | limits × 0.6 | 按峰值 × 1.3 | Burstable,允许短时冲高 |
| 有内存泄漏风险的旧服务 | limits × 0.7 | 放宽 1.5 倍 | 给修复争取时间 |
关键前提是用压测定 limits,不要拍脑袋。压测方法:
# 施压并观察内存曲线
kubectl exec api-xxx -- cat /sys/fs/cgroup/memory.current
# 反复采样,画成曲线,取 P99 峰值 × 1.3 作为 limits
如果压测发现内存一直涨不回落,那要先修泄漏,调大 limit 只是把 OOM 时间点往后推。
六、JVM 之外的 OOM 来源
不是所有 OOMKilled 都是 Java 的。还有两种:
1. Sidecar 抢内存
一个 3 容器的 Pod,limit 是 Pod 级的总和。日志采集 sidecar、Service Mesh 的 Envoy proxy 都要吃内存(Envoy 通常 60~120 MB)。如果你的 Java 容器 limits 设 1Gi,Pod 总 limits 是 1Gi 但 Envoy 占了 100 MB,实际给 Java 的只剩 900 MB。Java 的 MaxRAMPercentage 是按容器 limit 算的,不知道自己要和 sidecar 分。
解决:Java 容器的 limit 要单独设,并且考虑 sidecar 占用。Istio 环境下经验值是给 Java 容器预留 +150 MB。
2. initContainer 峰值
initContainer 的内存峰值也算进 Pod 的 limits。如果你用一个 alpine 容器做数据库迁移,它可能瞬时吃掉几百 MB。
七、一个可以直接用的排查脚本
把上面的检查串起来,做成 Pod 内的自查脚本:
#!/bin/sh
# memcheck.sh
LIMIT=$(cat /sys/fs/cgroup/memory.max 2>/dev/null || \
cat /sys/fs/cgroup/memory/memory.limit_in_bytes)
CUR=$(cat /sys/fs/cgroup/memory.current 2>/dev/null || \
cat /sys/fs/cgroup/memory/memory.usage_in_bytes)
PEAK=$(cat /sys/fs/cgroup/memory.peak 2>/dev/null || echo n/a)
echo "limit : $((LIMIT/1024/1024)) MB"
echo "current : $((CUR/1024/1024)) MB ($((CUR*100/LIMIT))% of limit)"
echo "peak : $PEAK"
echo "--- JVM ---"
jcmd 1 VM.flags 2>/dev/null | tr ' ' '\n' | grep -E 'MaxHeapSize|MaxRAMPercentage|MaxMetaspace'
echo "threads : $(jcmd 1 Thread.print 2>/dev/null | grep -c '^\"')"
echo "heap : $(jcmd 1 GC.heap_info 2>/dev/null | head -3)"
部署时用 kubectl exec -it pod -- sh memcheck.sh 跑一遍,五个数据就能定位方向:
| 现象 | 判断 |
|---|---|
current 接近 limit,JVM 堆使用率低 |
堆外内存问题,查直接内存/线程栈 |
current 接近 limit,堆使用率也高 |
真实堆内存不足或泄漏 |
peak 是 current 的 2 倍以上 |
有间歇性峰值(批量任务/定时任务),limits 要按峰值定 |
| 线程数 > 800 | 先查线程池配置 |
堆使用率低但 current 涨得很快 |
大概率是 DirectByteBuffer 或 JNI |
八、几个真实的处理方法
场景 A:刚上线就 OOMKilled,重启后又是几分钟
典型是 -Xmx 设置过大(≥ limit)。改 MaxRAMPercentage 解决,30 秒改完。
场景 B:跑几小时才 OOMKilled
泄漏或缓存无界。查 HashMap/ConcurrentHashMap 类字段、ThreadLocal、Guava Cache 是否设了 maximumSize。临时缓解是把 limit 加倍 + 加 -XX:+ExitOnOutOfMemoryError,让它快速重启而不是半死。
场景 C:每天固定时间 OOMKilled
定时任务或批量导入。看任务时间段的内存曲线,给任务加分批处理(每批 500 条 + 手动 System.gc() 意义不大,改成流式读取)。
场景 D:只有某一台节点上的 Pod OOM
节点级问题。查这台节点的 requests 总和是否超过 allocatable,可能发生了超卖:
kubectl describe node <node> | grep -A5 'Allocated resources'
小结
Exit Code 137 只说明「被 SIGKILL 了」,不说明原因。三步定位:
- 看 cgroup:
memory.currentvsmemory.max是否贴着 - 看 JVM 实际参数:
-Xmx是不是 ≥ limit(最常见错误) - 看堆内 vs 总量差:差值就是堆外开销,超 35% 就要查直接内存和线程栈
配置上记住一个数字:堆留 70%,剩下 30% 给 Metaspace、线程栈、直接内存。1Gi 的容器配 -Xmx700m 是安全起点。想省内存就把 limit 调小并同步压测,不要靠「把 Xmx 调大一倍试试看」——那通常会让问题更糟。
正在加载评论…