容器 OOMKilled 排查手册:从 cgroup 计数到 JVM 堆外内存

举报
yd_282015185 发表于 2026/11/02 08:11:25 2026/11/02
【摘要】 容器反复重启、日志里却没有异常堆栈?那是内核杀的,不是 JVM。本文按 exit code 137、cgroup 内存构成、堆外泄漏三段递进,给出一份十分钟内能定位的排查清单和一份决策表。

## 一、先分清两件完全不同的事


日志里出现 `OutOfMemoryError`,和容器被 `OOMKilled`,是两条完全不同的故障路径,修法也完全不同。


- **JVM 自己抛 `OutOfMemoryError`**:堆或元空间到达 JVM 参数上限,JVM 还活着,能打出堆栈、能 dump。属于应用内部问题。

- **容器被内核杀掉(OOMKilled / exit code 137)**:进程的 RSS 触到 cgroup 的 `memory.max`,内核的 OOM killer 直接发 SIGKILL,进程没有任何机会写日志。属于资源配置与内存核算问题。


最典型的踩坑现象:容器反复重启,`kubectl logs` 里最后一行是正常的业务日志,没有任何异常堆栈,`describe pod` 显示 `Last State: Terminated, Reason: OOMKilled, Exit Code: 137`。这时候去翻应用日志是白费时间,因为进程是被 SIGKILL 带走的,根本没机会记录。


判断只需要一条命令:


```

kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[*].lastState.terminated.reason}'

```


返回 `OOMKilled` 就走上文第二条路,返回空或 `Error` 再去看应用日志。


## 二、137 不等于一定是 OOM


`Exit Code: 137` 的准确含义是「进程被信号 9 终止」,信号 9 的来源不止 OOM killer 一个。下面几种情况同样会是 137:


- 内核 OOM killer 命中

- 有人执行了 `kill -9`(包括编排系统主动清理)

- liveness probe 连续失败后被 kubelet 强杀

- 节点级驱逐(node pressure eviction)


区分办法是看内核日志:


```bash

dmesg -T | grep -i -E "out of memory|oom-kill|killed process"

# 或

journalctl -k | grep -i "killed process"

```


会打出类似这样的行:


```

kernel: Memory cgroup out of memory: Killed process 23871 (java) total-vm:8321456kB, anon-rss:1962140kB, file-rss:15360kB, shmem-rss:0kB

```


注意 `anon-rss` 这个数值——它才是真正被算进 cgroup 限额的部分。`total-vm` 是虚拟地址空间,通常远大于限额,看它没意义。


如果 dmesg 里查不到,但 `lastState.reason` 是 `OOMKilled`,那基本可以确定是 cgroup 级 OOM(节点级 OOM 会同时影响多个 Pod,且事件里会带 `evicted`)。


## 三、cgroup 的内存计数到底算了什么


这是最容易算错的地方。很多人以为「容器内存 = JVM 堆」,于是一看堆设了 2G、limit 设了 2G,觉得刚好。实际上 cgroup 记账包含:


| 计入 memory.current 的部分 | 是否计入 |

|---|---|

| 匿名页(JVM 堆、线程栈、DirectBuffer) | 计入 |

| 页缓存 page cache(读文件产生的缓存) | **计入** |

| tmpfs / shmem(/dev/shm、部分日志缓冲) | **计入** |

| 内核态内存(slab、socket buffer) | 计入(v1 的 kernel 子项 / v2 统一计入) |


也就是说,一个 Java 应用读了 500MB 文件做解析,页缓存会被算进这个容器。页缓存理论上是可回收的,但在内存紧张时,内核回收不及时、或者匿名页占比太高无法回收,就会直接触发 OOM。


查看当前真实用量:


```bash

# cgroup v2(新内核,K8s 1.25+ 常见)

cat /sys/fs/cgroup/memory.current

cat /sys/fs/cgroup/memory.max

cat /sys/fs/cgroup/memory.stat # 看 anon / file / slab 三段


# cgroup v1(老集群)

cat /sys/fs/cgroup/memory/memory.usage_in_bytes

cat /sys/fs/cgroup/memory/memory.limit_in_bytes

cat /sys/fs/cgroup/memory/memory.stat

```


从宿主机外部看,更直接:


```bash

# 找到容器对应的 cgroup 路径

cg=$(docker inspect -f '{{.HostConfig.CgroupParent}}' <container> 2>/dev/null)

# 或直接看 kubelet 侧

cat /sys/fs/cgroup/memory/kubepods/burstable/pod<uid>/<container-id>/memory.current

```


`memory.stat` 里重点看三行:


```

anon 1962140000 # 匿名页,主要是堆

file 512000000 # 页缓存

slab 42000000 # 内核对象

```


如果 `file` 很大而 `anon` 不大,说明是文件缓存把限额顶满了,处理方向是加 limit 或调整 IO 模式;如果 `anon` 接近限额,那就是真正的内存泄漏或不合理的堆配置。


## 四、调度看的是 working set,不是 usage


一个容易被忽略的事实:K8s 驱逐 Pod 时参考的指标是 `container_memory_working_set_bytes`,它的定义是 `usage - inactive_file`,也就是**排除了可回收的非活跃页缓存**。


这意味着两种数值可能差异很大:


- `memory.current` 可能因为大量文件缓存而很高

- `working_set` 可能不高,Pod 不会被驱逐


反过来也可能出现:`working_set` 接近 limit 但 `current` 未超,此时还没被杀,但已经在边缘。


查 working set:


```bash

kubectl top pod <pod> --containers

# 或从指标系统

container_memory_working_set_bytes{container="app"}

```


**这里有个实用结论**:如果 `current` 长期贴着 limit、但 `working_set` 只有 limit 的 60%,说明大部分是页缓存,属于健康状态,不必急着扩容。只有当 `working_set/limit > 0.9` 持续存在,才是真的要加内存。


## 五、limits 到底该设多少


以 Java 应用为例,一个常见的错误配置是:堆设 `-Xmx2g`,容器 limit 设 `2Gi`。这一定会在压力下 OOMKilled,因为堆之外还有不少固定开销:


- Metaspace:默认无上限,通常 100–300MB

- 线程栈:每线程 1MB,200 线程就是 200MB

- DirectByteBuffer / Netty:按需分配,常见 100–500MB

- JVM 自身(code cache、GC 结构、符号表):100–200MB

- 页缓存与内核开销


所以经验公式是:


```

limit ≈ 堆上限 × 1.3 ~ 1.5

```


或者反过来,从 limit 推堆:


```

堆上限 ≈ limit × 0.7

```


对于 4Gi limit 的容器,堆给到 `-Xmx2800m` 比较稳;给到 3500m 就是赌博。


更严谨的做法是把非堆部分用参数锁死,让内存占用可预测:


```

-XX:MaxMetaspaceSize=256m

-XX:MaxDirectMemorySize=256m

-Xss512k

-XX:ReservedCodeCacheSize=128m

```


锁死之后,最坏情况下的总内存是可计算的,再留 20% 余量设 limit,就能长期稳定。


## 六、JVM 认不到容器限制的三个历史坑


JDK 8 早期版本在容器里有著名的「看不见内存限制」问题,读的是宿主机内存。三个关键点:


**1. `UseContainerSupport`**


JDK 8u191 之后默认开启,之前的版本需要显式加 `-XX:+UseContainerSupport`。检查方法:


```bash

java -XX:+PrintFlagsFinal -version | grep -i UseContainerSupport

```


**2. `MaxRAMPercentage` 而不是 `-Xmx`**


如果用了百分比方式,务必确认它真的生效:


```bash

java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|MaxRAMPercentage"

```


`MaxHeapSize` 显示的就是最终堆上限。如果它等于宿主机内存的 1/4,说明容器识别失败,后面怎么调 limit 都没用。


**3. cgroup v2 支持**


JDK 8 与 11 的早期版本不认识 cgroup v2 的 `memory.max` 文件,会退化成读宿主机内存。K8s 1.25 之后默认 cgroup v2,这个坑在老旧镜像上非常常见。


判断方法:


```bash

stat -fc %T /sys/fs/cgroup/

# 输出 cgroup2fs 表示 v2

```


如果集群是 v2 而 JDK 是 8u202 以下,最稳的做法是显式指定 `-Xmx` 绝对值,不要依赖百分比自动推导。


## 七、堆外内存到底去哪了


排除堆配置问题后,如果 `anon` 仍然持续上涨,就要查堆外。四个主要方向:


**Metaspace 泄漏**——常见于反复热部署、大量动态代理、Groovy/JS 脚本引擎。表现为 `jstat -gc <pid>` 中 `M`(Metaspace)列只涨不跌。加 `-XX:MaxMetaspaceSize` 能把它变成可观测的 `OutOfMemoryError: Metaspace`,比静默被杀好定位得多。


**线程数暴涨**——每个线程占 1MB 栈(虚拟地址),实际 RSS 视触碰页而定,但线程多了总量很可观。查:


```bash

jstack <pid> | grep -c "^\""

# 按线程名前缀统计,找出是谁在堆线程

jstack <pid> | grep "^\\\"" | sed 's/".*//' | sort | uniq -c | sort -rn | head

```


线程数过千基本可以确定是线程池没复用,或者定时任务重复创建。


**DirectByteBuffer / Netty**——`-XX:MaxDirectMemorySize` 不设的话,上限约等于堆大小。Netty 的池化分配器在高并发短连接场景下容易堆高。看方式:


```bash

jcmd <pid> VM.native_memory summary # 需启动时加 -XX:NativeMemoryTracking=summary

```


这是最有效的手段,能直接看到 `Java Heap`、`Class`、`Thread`、`Code`、`GC`、`Internal` 各段占用。生产环境建议常开 `summary` 级别,开销约 3%。


**glibc 的 arena 碎片**——多线程 malloc 时 glibc 会创建多个 arena,每个 arena 保留已释放的内存不归还系统。表现为 RSS 远大于实际使用量,`malloc_trim` 后能降下来。规避方式:


```bash

export MALLOC_ARENA_MAX=2

```


这一条在高并发 Java 服务上收益明显,能显著压低 RSS 峰值。


## 八、一份可直接抄的排查清单


按顺序执行,基本能在十分钟内定位:


```bash

# 1. 确认是不是 OOMKilled

kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'


# 2. 内核侧证据

dmesg -T | grep -i "killed process" | tail -5


# 3. 当前 cgroup 用量与构成

cat /sys/fs/cgroup/memory.current

cat /sys/fs/cgroup/memory.max

grep -E "^(anon|file|slab|pgfault|pgmajfault) " /sys/fs/cgroup/memory.stat


# 4. JVM 实际堆上限(验证是否识别容器)

java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|MaxMetaspaceSize|MaxDirectMemorySize"


# 5. 堆与元空间趋势

jstat -gc <pid> 1000 10


# 6. 线程数

jstack <pid> | grep -c '^"'


# 7. 原生内存分布

jcmd <pid> VM.native_memory summary


# 8. 堆转储(如果是堆内问题)

jcmd <pid> GC.heap_dump /tmp/heap.hprof

```


特别注意第 3 步和第 4 步的对照:如果 `MaxHeapSize` 接近 `memory.max`,不用再查了,直接改配置。


## 九、如何稳定复现


改配置之前,最好先能复现,否则改完不知道有没有效。三种手段:


**压测叠加**——用 `wrk` 或 `jmeter` 打到平时峰值的 3 倍,同时用 `watch -n1 cat /sys/fs/cgroup/memory.current` 观察曲线。如果曲线锯齿状上升且不回落,是泄漏;如果随并发线性上升后平稳,是容量不足。


**限制内存做灰度**——临时把 limit 调到当前的 70%,让问题提前暴露在小流量环境。这比在生产上等一周要高效得多。


**主动触发一次 Full GC**——`jcmd <pid> GC.run`,观察 RSS 是否回落。如果 Full GC 后 `anon` 明显下降,说明是堆内问题(对象堆积);如果几乎不动,问题在堆外。


这个对比非常有用,能把排查范围一次性砍掉一半。


## 十、决策表


| 现象 | 判断 | 处理 |

|---|---|---|

| `MaxHeapSize` ≈ 宿主机内存/4 | JVM 未识别容器 | 显式 `-Xmx` 或开启 `UseContainerSupport` |

| `anon` 贴 limit,Full GC 后不降 | 堆外泄漏或线程膨胀 | NMT 分析,锁 Metaspace / DirectMemory / 线程池 |

| `anon` 贴 limit,Full GC 后明显降 | 堆内对象堆积 | dump 分析,查缓存与集合 |

| `file` 占大头,`working_set` 正常 | 页缓存顶满 | 一般无需处理,或加 limit |

| `working_set/limit > 0.9` 持续 | 容量确实不足 | 提 limit,按 堆 = limit×0.7 重设 |

| 重启后 RSS 缓慢爬升数天 | 典型泄漏 | NMT + 定期 heap dump 对比 |

| 线程数 > 1000 且持续涨 | 线程池未复用 | 查定时任务与异步创建点 |


## 收尾


OOMKilled 的排查难点不在技术,而在**一开始走错方向**——去翻应用日志、去怀疑代码 bug,而真正的原因在资源配置和内存核算。


记住三条主线就够了:


1. `exit code 137` + 无异常堆栈 = 内核杀的,别翻日志

2. 先验证 `MaxHeapSize` 是否识别到容器限制,这一步能解决一半问题

3. `anon` 还是 `file` 占比高,决定后面查堆内、堆外还是什么都不用查


把这套流程走一遍,通常比凭经验猜要快得多。


【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。