Pod 被 OOMKilled,代码里一行 OOM 都没有:Kubernetes 内存账本的四笔糊涂账

2026-11-11 16 0

凌晨三点,告警群炸了:某个 Pod 重启三次,事件面板上白纸黑字写着 OOMKilled。你第一反应是翻代码:这接口没分配大数组,那条 SQL 也没把整张表捞进内存。于是加日志、dump 堆、盯一晚监控,内存曲线一路平稳上扬然后被一刀砍断,凶手始终没露面。

问题在于,被杀的那个进程可能真的一点内存都没多用。容器杀掉的是"进程",但记账的是"cgroup",而这两笔账之间,隔着一堆大多数人都没读过的账本规则。

这篇就把这几本账翻开讲清楚。


账本一:cgroup v2 里,你写进文件的那 4KB,也在吃你的配额

先看最容易被忽略的一笔。Kubernetes 默认用 cgroup v2,记账接口是 /sys/fs/cgroup/memory.stat,里面有两个关键字段:

anon       # 匿名内存:堆、栈、malloc 出来的、你没名字的那些
file       # 文件缓存:page cache,你读进来的文件、写出去的文件

memory.max 限制的是 anon + file + slab + kernel_stack + pagetables + sock 的总和。也就是说,容器里写出去的日志、emptyDir 里的临时文件、读进来的静态资源缓存,全都算在同一个额度里。

这不是 bug,是 Linux 的设计:page cache 全机器共享,隔离它有成本。但结论很硬:

一个 JVM 容器 limit 是 2Gi,若它同时往磁盘刷了 800MB 日志缓存、写了 300MB 临时文件,留给 JVM 的只剩不到 1Gi。而 JVM 读不到这个事实,它只知道自己被限制在 2Gi 以下,于是按比例把堆撑大,最后被内核连人带日志一起砍了。

区分动作很简单,看 memory.stat:如果 file 大得离谱、anon 反而很小,那你的 OOM 大概率是被磁盘 I/O 顺手带走的,跟业务代码没关系。


账本二:JVM 的"容器感知"是个半成品

JDK 10 之后 HotSpot 能读到 cgroup 的 limit,于是有了 -XX:MaxRAMPercentage,默认 25%。听起来聪明,实际有三个坑。

坑一:它只管堆,不管堆外。堆外包括 Metaspace(类元数据,默认无上限)、Thread Stack(每线程默认 1MB,500 线程就 500MB)、DirectByteBuffer(MaxDirectMemorySize 默认等于最大堆)、JNI 内存和 JIT 代码缓存(CodeCache,240MB 起步)。

limit=2Gi 的容器堆最多 512MB,剩下 1.5Gi 要同时装下这些——而堆百分比计算完全不知道这 1.5Gi 已被非堆吃掉多少。

坑二:它只看 limit,不看 request。Guaranteed 的 Pod(request == limit)可以放心用百分比;Burstable 的 Pod 则可能在流量高峰时因 memory.high 触发回收而抖动,JVM 毫无感知。

坑三:RSS 和 cgroup 的 usage 不是一个数。page cache 算在 cgroup 里却不算在 RSS 里,于是 JVM 觉得"我还有 1.2G 堆可用",cgroup 那边可能只剩 300MB。

给 JVM 容器一个体面的起手式:

java -XX:MaxRAMPercentage=70 \
     -XX:MaxDirectMemorySize=256m \
     -XX:ReservedCodeCacheSize=128m \
     -XX:MaxMetaspaceSize=192m \
     -Xss512k \
     -XX:+ExitOnOutOfMemoryError \
     -XX:+HeapDumpOnOutOfMemoryError \
     -jar app.jar

账本三:Go 这边是另一个方向的坑——它压根不看 limit

JVM 的问题是"知道了但管不全",Go 的问题是"根本不知道"。Go 默认不感知容器,GC 策略是 GOGC=100:堆涨到活对象的两倍才回收。活对象 400MB、limit 1Gi,Go 觉得 800MB 完全正常;额度快满了它还想涨上去——于是被内核 OOM Kill。日志里连一句 "out of memory" 都没有,因为 Go 侧真没觉得自己内存不够。

解法在 Go 1.19 之后:GOMEMLIMIT。

docker run -e GOMEMLIMIT=800MiB -e GOGC=100 your-go-app

它不是硬上限,而是"软内存上限":GC 会在总堆逼近该值时提前启动回收,把 GOGC 那种"翻倍就等"的节奏改成"逼近上限就干活"。运行时策略从"我不管你"变成"我听着你的约束干活"。

配合 debug.SetMemoryLimit 动态调整,无损缩容也有了抓手:收到缩容信号时把上限调到当前用量附近,GC 主动把内存还给 cgroup,Pod 就不会因"内存没还干净"被强杀。


账本四:你的监控面板在骗你,而且骗得很有道理

OOM 时的内存曲线是"缓慢爬升然后断崖",没有尖峰。这形状本身就是最大的误导:让人以为"内存平滑增长",矛头自然指向"对象在泄漏"。但真实原因可能是 cgroup 里 file 那一坨在慢慢长大(日志量线性增长、缓存文件被追加),而 anon 部分一直很稳。这两件事在 JVM 或 Go 看来不是一回事,在 cgroup 看来却是一笔账。

排查 OOM 的正确顺序是先分清"哪一笔账",再找代码:

cat /sys/fs/cgroup/memory.current            # 实际用量
cat /sys/fs/cgroup/memory.max                # limit
grep -E "^(anon|file|slab) " /sys/fs/cgroup/memory.stat
cat /sys/fs/cgroup/memory.events             # oom / oom_kill

关键区分点是 memory.events 里的 oom 与 oom_kill:前者递增说明分配失败被救回,后者递增就是真有人被杀了。

还有一层容易忽略:memory.events.local 是本 cgroup 的,memory.events 是整层级聚合的。父 cgroup(节点)触发的回收压力只体现在父层,容器里什么都看不到——这就是"节点内存明明很多,容器却被杀"的常见解释。


防御清单:把这些做完,OOM 会从"事故"变成"日志"

  • 别拿 limit 和 request 的差额去赌。至少留 20% headroom 给 page cache 和临时文件。
  • 给日志加上限。要么走 stdout 由 runtime 收集(记得配 size 限制),要么用 logrotate,别让日志无限增长。
  • JVM 显式配置 MaxRAMPercentage、DirectMemorySize、MetaspaceSize、线程栈。别用默认值赌规格。
  • Go 服务设置 GOMEMLIMIT。最容易被忽略、也最容易在版本升级后悄悄失效的一条。
  • 监控 working set 与 rss 两条线,外加 oom_kill 计数。只盯"内存使用率"一个指标等于失明。
  • 让容器优雅退出。Spring Boot 的 server.shutdown=graceful、Go 的 signal handler、Node 的 preStop hook——别让 Pod 在内存没还干净时挨枪。

最后

内存问题最让人挫败的地方在于,它不报错。Go 那边一片安静,JVM 那边只留一行 Exit code 137——而 137 的含义是"SIGKILL,来自 cgroup OOM killer",不是"你的代码崩了"。

下次再看到 OOMKilled,别急着翻代码。先翻那几本账:memory.stat 看是哪一笔在涨,MaxRAMPercentage / GOMEMLIMIT 看运行时配得对不对,memory.events 看杀人的是自己还是父级。九成的"代码没毛病但就是被杀",答案都在这几行命令里。

相关文章

你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相
你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言
你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天
扩容一次,数据搬家一半:一致性哈希、虚拟节点,以及我踩过的那个三倍流量坑
「数据过千万就得分表」——这句话害了多少人?分库分表的四个坑与正解

发布评论