Java

堆外内存上涨把容器逼死时,先留哪些证据?

Pod 已经被 OOMKilled,Heap 却没满时,最容易做错的不是判断,而是先把现场丢了。真正该先保住的是容器事件、RSS、NMT、线程和同 Pod 进程占用,再谈根因。

  • Java
  • JVM
  • 容器
  • OOM
  • 堆外内存
14 分钟阅读

我第一次真正把“堆外把容器顶死”这件事看清,不是在白板上,而是在第二只还没死的 Pod 身上抢证据。

第一只 Pod 死得很快。

  • K8s 事件里已经写了 OOMKilled
  • Heap 监控只有 55% 左右
  • 团队第一反应是“是不是监控口径有问题”
  • 第二反应就是“先把 -Xmx 加大一点”

如果当时真这么做,现场就没了。

因为堆外问题最麻烦的地方从来不是术语,而是:容器一重启,很多真正决定方向的证据会一起被洗掉。

那次是网关聚合服务,在一版大对象响应改造后,Pod 一直在 2Gi memory limit 边缘游走。第一只挂掉以后,我们没有急着重启同配置的第二只,而是先把它摘出流量,保了 10 分钟现场。最后真正救命的,不是 Heap Dump,而是这几组按顺序留下来的证据:

  • Pod 事件与 exit code
  • 容器 RSS / working set
  • JVM 进程的 NMT 摘要
  • 线程数和线程栈
  • 同 Pod sidecar 的占用

这篇想讲清的,就是那次事故里我们到底先留了什么,为什么先怀疑堆外而不是 heap,哪些证据把别的方向排掉了,最后又是怎么证明判断没偏。

第一只 Pod 死后,我们为什么没有立刻去抓 Heap Dump

因为它根本不是一个“先查 Heap 就会更快”的现场。

第一只 Pod 挂掉时,平台侧证据已经非常明确:

Reason: OOMKilled
Exit Code: 137

但更关键的是,Heap 最高只到 780Mi,而容器 working set 已经贴到 1.96Gi / 2Gi。

这个组合有很强的指向性:

  • Java 进程总占用有大块不在 Heap 里
  • 容器边界先出手了
  • JVM 未必来得及自己抛 OutOfMemoryError

也就是说,这时候最怕的不是“不知道是不是堆外”,而是还没把 RSS、NMT 和同 Pod 占用保住,就把实例重启了。

所以那次我们没有先去补 Heap Dump,而是先把第二只 Pod 从流量里摘出来,开始按时间顺序抓外层证据。

我们真正先保住的,是外层边界怎么被撞穿的

现场里第一组最值钱的对照,是容器层数据。

1. Pod 事件先证明:外层平台确实先把进程杀了

这个步骤看起来朴素,但很重要。因为如果连 OOMKilled 都没定下来,后面大家会不断回到“是不是 JVM 自己退出了”这种无效讨论里。

那次我们固定下来的信息包括:

  • OOMKilled 发生时间
  • restart count
  • 容器 memory limit / request
  • 节点本身没有全局内存压力

这一步把“宿主机整体内存崩了”“应用自己正常退出”先排掉了。

2. 再看 working set 与 Heap 的背离

第二只 Pod 保现场时,曲线长这样:

时间Heap UsedRSS / working set容器 limit
18:11702Mi1.58Gi2Gi
18:15716Mi1.73Gi2Gi
18:18721Mi1.86Gi2Gi
18:21734Mi1.94Gi2Gi

这张表已经回答了最关键的问题:多出来的那一大块内存,不在 Heap 里。

这一刻,我们才有资格认真说“像堆外”,而不是凭感觉喊。

为什么我们先怀疑 Direct Memory,而不是线程栈或 Metaspace

堆外不是一个点,它至少可能落在好几条线:

  • 线程栈
  • Direct Buffer
  • Native 分配
  • Mapped 文件
  • 同 Pod sidecar 或 agent

那次最先把方向拉到 Direct Memory 的,是两条证据。

线程数没有同步抬高

我们先看了线程数和 jstack。线程总数一直在 220 左右,没有出现线程池爆炸,也没有大量阻塞线程堆住。

这一步很重要,因为如果线程数从 200 飙到 700,那优先怀疑线就会先落在线程栈,而不是 Direct Buffer。

NMT 里 native 大头确实在变

我们在第二只 Pod 上补了:

jcmd <pid> VM.native_memory summary

NMT 摘要里最明显的变化,不是 Java Heap,也不是 Class,而是与 Direct / Internal 相关的区域在一路往上长。与此同时,Netty 暴露的 usedDirectMemory 指标也在抬:

时间usedDirectMemoryHeap Used
18:11412Mi702Mi
18:15638Mi716Mi
18:18901Mi721Mi
18:211.17Gi734Mi

到这里,线程栈不是主角,Metaspace 也不是主角,方向已经明显收紧到 Direct Memory / Netty 这条线上了。

还有一条经常被漏掉的证据:同 Pod 其他进程有没有一起吃边界

堆外事故很容易有一个额外坑:你只盯 Java 进程,最后把整个 Pod 的预算看窄了。

那次我们专门补了:

kubectl top pod gateway-7c9d --containers

发现 sidecar 常驻也有 180Mi 到 220Mi 的占用。它不是主因,但它决定了容器真正能留给 Java 进程的余量比大家想象的小得多。

这一步的价值在于:

  • 它不能证明 Direct Memory 泄漏
  • 但它能解释为什么“Heap 才 700 多 Mi,容器却已经快死了”

也就是说,排查堆外时看的是 Pod 总账,不是只看 JVM 小账。

最终把问题钉住的,不是某个概念,而是证据顺序

真正把根因拉出来的,是把这些证据按时间顺序放在一起:

  1. Pod 先 OOMKilled
  2. Heap 长期稳定在 700 多 Mi
  3. RSS / working set 一路贴着 2Gi 上限
  4. 线程数稳定,没有线程栈暴涨
  5. NMT 与 Netty 指标显示 Direct Memory 持续抬升
  6. sidecar 进一步压缩了 Java 可用余量
  7. 发布 diff 里刚好上了一段“大响应聚合后统一拼装再回写”的逻辑

继续看线程栈和代码,最后发现是一个 Netty handler 在异常路径上没有及时释放 ByteBuf,再叠加大响应统一聚合,Direct Buffer 被持续持有。

这条链能成立,不是因为“Netty 容易泄漏”这个结论,而是因为现场里每一条证据都在往同一个方向收。

动作以后,怎样验证真的就是这条线

我们做了两层处理:

  • 先把大对象响应从“整包聚合”改回流式透传
  • 再修掉异常路径上漏掉的 ByteBuf.release()

验证不是只看 Pod 不重启,而是看下面这些东西有没有一起回归:

  • usedDirectMemory 从事故时的 1Gi+ 回到 300Mi 左右
  • RSS 不再贴着容器 limit 跑,稳定留出 500Mi 以上余量
  • Heap 曲线变化不大,说明不是碰巧把 heap 压下去了
  • Pod 不再出现 OOMKilled / exit 137

如果动作后只是“暂时不死”,但 RSS 还继续和 working set 一起阶梯式上升,那说明你修到的是放大器,不是源头。

所以,堆外把容器逼死时,先留什么才真的值钱

那次事故之后,我自己会记这句:

先留 Pod 事件和 RSS,再留 JVM 进程级证据,最后才谈 heap。

顺序错了,方向再对也容易白费。

如果你站在现场里只能先做一轮动作,我会按这个先后:

  1. 先确认是不是 OOMKilled
  2. 先把 Heap 和 RSS 对齐,看差值是不是解释不通
  3. 还活着的实例上立刻补 NMT、线程和同 Pod 占用
  4. 再去看 Direct、线程栈、native 分类各自谁在涨

只有这几步做完,你后面再去讨论“是不是堆外”,才不是猜。

如果你现在还没分清是容器先杀进程还是 JVM 先报错,建议先回 容器 OOMKilled 和 Java heap OOM,先怎么区分?;如果已经确定 Pod 是被总占用顶穿,现场值钱的第一件事从来不是调 -Xmx,而是先把这条证据链保下来。