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 了」,不说明原因。三步定位:

  1. 看 cgroup:memory.current vs memory.max 是否贴着
  2. 看 JVM 实际参数:-Xmx 是不是 ≥ limit(最常见错误)
  3. 看堆内 vs 总量差:差值就是堆外开销,超 35% 就要查直接内存和线程栈

配置上记住一个数字:堆留 70%,剩下 30% 给 Metaspace、线程栈、直接内存。1Gi 的容器配 -Xmx700m 是安全起点。想省内存就把 limit 调小并同步压测,不要靠「把 Xmx 调大一倍试试看」——那通常会让问题更糟。

查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

K8s OOMKilled 排查:Exit Code 137 的三种来源 - 起航社区