容器 OOMKilled 排查手册:从 cgroup 计数到 JVM 堆外内存
## 一、先分清两件完全不同的事
日志里出现 `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` 占比高,决定后面查堆内、堆外还是什么都不用查
把这套流程走一遍,通常比凭经验猜要快得多。
- 点赞
- 收藏
- 关注作者
评论(0)