堆外内存上涨把容器逼死时,先留哪些证据?
Pod 已经被 OOMKilled,Heap 却没满时,最容易做错的不是判断,而是先把现场丢了。真正该先保住的是容器事件、RSS、NMT、线程和同 Pod 进程占用,再谈根因。
我第一次真正把“堆外把容器顶死”这件事看清,不是在白板上,而是在第二只还没死的 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 Used | RSS / working set | 容器 limit |
|---|---|---|---|
| 18:11 | 702Mi | 1.58Gi | 2Gi |
| 18:15 | 716Mi | 1.73Gi | 2Gi |
| 18:18 | 721Mi | 1.86Gi | 2Gi |
| 18:21 | 734Mi | 1.94Gi | 2Gi |
这张表已经回答了最关键的问题:多出来的那一大块内存,不在 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 指标也在抬:
| 时间 | usedDirectMemory | Heap Used |
|---|---|---|
| 18:11 | 412Mi | 702Mi |
| 18:15 | 638Mi | 716Mi |
| 18:18 | 901Mi | 721Mi |
| 18:21 | 1.17Gi | 734Mi |
到这里,线程栈不是主角,Metaspace 也不是主角,方向已经明显收紧到 Direct Memory / Netty 这条线上了。
还有一条经常被漏掉的证据:同 Pod 其他进程有没有一起吃边界
堆外事故很容易有一个额外坑:你只盯 Java 进程,最后把整个 Pod 的预算看窄了。
那次我们专门补了:
kubectl top pod gateway-7c9d --containers
发现 sidecar 常驻也有 180Mi 到 220Mi 的占用。它不是主因,但它决定了容器真正能留给 Java 进程的余量比大家想象的小得多。
这一步的价值在于:
- 它不能证明 Direct Memory 泄漏
- 但它能解释为什么“Heap 才 700 多 Mi,容器却已经快死了”
也就是说,排查堆外时看的是 Pod 总账,不是只看 JVM 小账。
最终把问题钉住的,不是某个概念,而是证据顺序
真正把根因拉出来的,是把这些证据按时间顺序放在一起:
- Pod 先
OOMKilled - Heap 长期稳定在 700 多 Mi
- RSS / working set 一路贴着 2Gi 上限
- 线程数稳定,没有线程栈暴涨
- NMT 与 Netty 指标显示 Direct Memory 持续抬升
- sidecar 进一步压缩了 Java 可用余量
- 发布 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。
顺序错了,方向再对也容易白费。
如果你站在现场里只能先做一轮动作,我会按这个先后:
- 先确认是不是
OOMKilled - 先把 Heap 和 RSS 对齐,看差值是不是解释不通
- 还活着的实例上立刻补 NMT、线程和同 Pod 占用
- 再去看 Direct、线程栈、native 分类各自谁在涨
只有这几步做完,你后面再去讨论“是不是堆外”,才不是猜。
如果你现在还没分清是容器先杀进程还是 JVM 先报错,建议先回 容器 OOMKilled 和 Java heap OOM,先怎么区分?;如果已经确定 Pod 是被总占用顶穿,现场值钱的第一件事从来不是调 -Xmx,而是先把这条证据链保下来。