OOMKilled 排查实战:容器内存限制与 JVM 堆外

线上 Pod 每隔几分钟重启一次,kubectl logs 只看到最后一行进程被信号杀死,kubectl describe 里赫然写着 OOMKilled。很多人的第一反应是”Java 堆溢出了”,于是无脑调大 -Xmx,结果照样被杀。容器内存超限 OOMKilled 真正的元凶常常是 cgroup 内存上限,而 JVM 堆外内存、glibc 内存池、文件 mmap 都不归 -Xmx 管——本文讲清从现象到根治的完整排查链路。

一、现象:Pod 反复重启,日志只留下 137

最典型的症状:服务监控里 Pod 重启次数一路涨,应用日志没有任何异常堆栈,反而是编排层报”退出码 137″。如果你用 Docker 直接跑,会看到容器状态是 Exited (137);在 Kubernetes 里则是 Reason: OOMKilledLast State: Terminated, Exit Code: 137。先记住一个铁律:137 = 128 + 9,即进程收到了 SIGKILL(信号 9),而 SIGKILL 来自内存子系统,不是你的代码抛了异常。

这一步最关键的是别急着改代码。OOMKilled 几乎总是”配置/认知”问题,而不是业务逻辑 bug。下面三节帮你把”谁杀的、杀的谁、为什么”一次定位清楚。

二、先搞懂 OOMKilled 是谁干的:cgroup 而非内核 OOM Killer

2.1 cgroup v1 / v2 的内存上限在哪

容器不是一台”小虚拟机”,而是被 cgroup 限制资源的一组进程。内存上限由 cgroup 控制,和宿主机物理内存是两回事。即使宿主机还剩 16G,只要你的 cgroup memory.max 设了 512M,用到 512M 就会被掐。cgroup v2 用 memory.max,cgroup v1 用 memory.limit_in_bytes

Kubernetes 里的 resources.limits.memory 最终就映射成这个文件。所以”OOMKilled”准确说是cgroup 内存上限触发了内核 memory cgroup 的 OOM 回收,它和宿主机整体内存不足的”传统 OOM Killer”是两套机制——这也是为什么很多文章混着讲会让人越看越晕。

2.2 exit code 137 与 OOMKilled 的关系

当 cgroup 内存使用超过上限且无法回收(比如 inactive 文件缓存也压不动了),内核会向 cgroup 内所有进程发 SIGKILL,容器运行时捕获后把退出码记为 137。看到 137,基本可以锁定”内存超限”这条路,不用再怀疑是被 liveness 探针或外部 kill。

三、第一步定位:kubectl 看事件与实时用量

先确认是不是 OOMKilled,并看当前实际用量离上限有多近。三板斧:

# 1) 看 Pod 事件与上次终止原因
kubectl describe pod <pod-name> | grep -A5 -i "last state\|omm\|restart"

# 2) 看实时内存用量(需 metrics-server)
kubectl top pod <pod-name> --containers

# 3) 看重启前最后的日志(注意加 --previous)
kubectl logs <pod-name> --previous

如果 describe 里出现 OOMKilledkubectl top 显示用量紧贴 limit,那”超限”实锤。但光知道超限还不够——你得知道是哪一块内存涨上去了,否则调 limit 只是拆东墙补西墙。

四、第二步量化:limit 设了多少,实际吃了多少

进到容器里直接读 cgroup 计数器,比猜靠谱。下面两条能告诉你当前用量和硬上限:

# cgroup v2:当前用量与上限
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max

# cgroup v1:当前用量与上限
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/memory.limit_in_bytes

注意 memory.current 包含两部分:匿名内存 + 文件缓存(page cache)。有时候你看到用量贴着 limit,其实是 page cache 占的多,内核本可以在压力下回收——这种情况下真正”不可回收”的匿名内存并没到顶,属于被误杀的边缘情况。要判断这一点,得看 memory.stat 里的 anonfile 分项。这正是下一节 JVM 场景最容易出现误解的地方。

五、最隐蔽的坑:JVM 堆外内存不归 -Xmx 管

5.1 堆外都去哪了

Java 开发者最容易踩的坑:把 -Xmx 设成和容器 limit 一样大。但 JVM 进程总内存 = 堆(-Xmx)+ 堆外。堆外至少包含:Metaspace(类元数据)、线程栈(默认每线程 1M)、Code Cache(JIT 编译产物)、DirectByteBuffer(NIO 直接内存)、mmap 的文件映射、glibc 的 arena 内存池。这些全在 -Xmx 之外,却都算进 cgroup 的匿名内存。

5.2 经典翻车:-Xmx 等于 limit,没留余量

假设容器 limit 512M,你设 -Xmx512m,堆吃满 512M 的同时,线程栈和 Direct Buffer 再来 80M,cgroup 总量 592M 超过 512M 上限,直接 OOMKilled——即使堆本身没溢出。这就是为什么”日志里没有 OutOfMemoryError 却一直被 137 杀”在 Java 服务里如此常见。

正确做法是给堆外留预算,并显式限制可控的堆外项。从 JDK 8u191 / 10 起,JVM 默认开启 -XX:+UseContainerSupport,会读 cgroup 上限来算默认堆;但默认只按”上限”算,仍可能贴边。建议显式收口:

# 容器 limit 512M 时的稳妥写法
# 堆最多 300M,给堆外留约 200M 余量
java -XX:MaxRAMPercentage=75.0 \
     -XX:InitialRAMPercentage=50.0 \
     -XX:MaxMetaspaceSize=96m \
     -XX:MaxDirectMemorySize=64m \
     -XX:+UseContainerSupport \
     -jar app.jar

MaxRAMPercentage 让 JVM 按 cgroup 上限的百分比算堆,比写死 -Xmx 更贴合容器;再叠 MaxMetaspaceSizeMaxDirectMemorySize 把最容易爆的两块堆外显式封顶,意外率会大幅下降。想看堆外具体构成,用 jcmd <pid> VM.native_memory summary 能看到 metaspace、Thread、Internal 各占多少。

六、其他语言同样中招:glibc 内存池与 mmap

别以为只有 Java 有这问题。C/C++ 服务用 glibc 的 malloc,默认会为每个线程维护一个 arena 内存池,64 位下最多 8 × CPU 核数个 arena,每个 arena 默认 64M——一台 32 核机器上,光 arena 理论上限就能到 16G,而且释放后不会归还操作系统,只是留在进程里待用,cgroup 照样算你用了。典型现象:RSS 居高不下但 valgrind 查不出泄漏。

缓解办法:设 MALLOC_ARENA_MAX=2 限制 arena 数量;或换用 jemalloc/tcmalloc 这类更省、更可控的分配器。另外大量读文件的程序用 mmap,映射区也算进 cgroup 内存记账,处理大文件时批量分片、及时 munmap 才能压住峰值。

七、根治清单:limit 怎么设才不误杀

定位到是哪块内存涨,就能按”总预算 = 各组件上限之和 + 余量”反向设 limit。下面是一份 Java 服务的预算参考:

内存组成建议上限说明
Java 堆(-Xmx)≤ limit 的 70%留 30% 给堆外与余量
Metaspace96–128M用 MaxMetaspaceSize 封顶
Direct Buffer64–128M用 MaxDirectMemorySize 封顶
线程栈线程数 × 512K高并发服务重点核算
glibc / jemalloc设上限或换分配器防 arena 膨胀
总余量+10–15%应对 page cache 与峰值

设 limit 时遵循两条原则:一是 request 与 limit 别差太远,否则节点超卖时你的 Pod 最容易被牺牲;二是 limit 要给真实峰值留 15% 以上余量,别按平均用量卡线设。压测时把内存打满看 RSS 曲线,比拍脑袋定 limit 可靠得多。

八、CI / 本地怎么提前发现

这类问题最好在上线前暴露,而不是等凌晨告警。两招:其一,在 GitHub Actions 流水线 里加一个内存压测 job,用接近生产的 limit 跑一轮负载,RSS 贴边就失败;其二,容器镜像里把 JVM 参数和 limit 一并固化,结合 Docker Compose 多环境编排 在本地就能复现 cgroup 上限行为,不用等上 Kubernetes。

如果服务已经在 K8s 上跑,先把 Kubernetes 集群部署实战 里的资源配额和 Ingress 与 TLS 配置 理顺,再叠加本文的内存预算法,稳定性会明显提升。顺带一提,容器相关的”诡异故障”不止内存一种,之前我们复盘过 服务器时钟漂移导致的线上故障,思路和本文一脉相承:先确认是配置/环境问题,再谈改代码。

九、一句话总结

OOMKilled 不是”内存泄漏”,而是cgroup 上限与进程真实用量之间的认知差。排查顺序记住:看 137 确认真相 → 读 cgroup 计数器量化用量 → 拆堆/堆外/缓存找到涨的那块 → 按预算反设 limit 并显式封顶堆外。把 -Xmx = limit 这个习惯改掉,Java 服务半夜被 SIGKILL 的概率能降一大半。

上一篇 pgvector 实战:用 PostgreSQL 搞定向量检索与 RAG
下一篇 生成式引擎优化 GEO:AI 搜索时代的 SEO 新战法