dmesg 里没 OOM,容器 Java 却死了?凶手藏在 cgroup 里
场景:容器 Java 被 killed,应用日志干净、宿主机 free 充足、dmesg 没有 Out of memory——它到底怎么死的 路径:ExitCode 137 → 容器内 dmesg 落空 → 宿主机翻出 CONSTRAINT_MEMCG → memory.events 数出 oom_kill → cgroup 隐形天花板 → 处方
症状识别
同样的 dmesg,这次却什么都搜不到
上篇讲了整机内存耗尽时,dmesg 里会留下一行 Out of memory: Killed process。这篇我们把场景搬进容器——进程同样是被杀的,dmesg 却安静得像什么都没发生。
某天早晨,K8s 集群告警:推荐服务 rec-service 的 Pod 不断重启。不是 RT 飙高,不是 5xx,是容器直接被杀、被 ReplicaSet 拉起来、再被杀,一个晚上重启了 6 次。应用日志最后一行停在一条正常请求,JVM 没有留下任何堆栈。
表象反差:宿主机
free显示内存充足得很,dmesg | grep "out of memory"一条记录都没有——但容器里 Java 确实死得干干净净,连句遗言都没留。
登录节点 kubectl describe pod 先看死因:

关键三行说明了一切:
Reason: OOMKilled—— 容器是被内存 OOM 杀掉的,不是崩溃,不是被删除。Exit Code: 137—— 137 = 128 + 9,即进程收到 SIGKILL(信号 9)。内核杀进程用 SIGKILL,因为信号 9 不可捕获、不可忽略,进程来不及做任何清理——这就是日志停在正常请求的原因。Restart Count: 6—— 被杀后 ReplicaSet 按策略拉起,反复送死。
Reason: OOMKilled 已经承认了"被 OOM",可诡异的是:宿主机 dmesg 里却没有上篇那种 Out of memory: Killed process 记录。同样的排查套路,这次第一步就落空了。
顺手验证一下在容器里能不能看到内核日志,结果同样落空:

容器内的进程没有读取内核环形缓冲区(dmesg)的权限——dmesg: read kernel buffer failed: Operation not permitted,非 privileged 容器默认看不到宿主机内核日志。你以为"什么都没",其实是权限把日志挡住了——真相都在宿主机那边,只是你没上宿主机看。
诊察
宿主机 dmesg:换一个关键字搜
权限问题解决,上宿主机。但就算上了宿主机,用上篇的关键字依然可能搜不到——dmesg -T | grep -i "out of memory" 返回空。不是没记录——是关键字不对。容器 OOM 的击杀记录,和整机 OOM 的长得不一样。全局 OOM 走的是 Out of memory: Killed process,而 cgroup 触发的 OOM 走的是另一条路,日志里写的是 CONSTRAINT_MEMCG:

逐行拆解,和上篇对比看区别:
constraint=CONSTRAINT_MEMCG—— 关键标识。上篇整机 OOM 是CONSTRAINT_NONE(全局内存耗尽),这次是MEMCG,说明击杀原因是某个 memory cgroup(容器)的内存上限被顶穿了。MEMCG 是 Memory Control Group 的缩写,即容器所在的 cgroup。cpuset=/docker/4f2a...—— 被击杀进程所属的 cgroup 路径。/docker/前缀表明是 Docker 容器,后面是容器 ID 开头。Memory cgroup out of memory—— 注意和上篇的Out of memory只差一个单词,但含义完全不同:是某一个 cgroup 内部内存超限,不是整机耗尽。anon-rss:4102123kB—— 进程匿名物理内存约 3.9Gi,后面定位段会用它做数学题。oom_reaper: reaped—— 内核的收割线程(oom_reaper,负责强制回收被杀进程内存的内核线程)已回收干净,物理内存释放,进程消失。
关键点:
dmesg | grep "out of memory"只匹配全局 OOM 的措辞,CONSTRAINT_MEMCG这条记录里根本不含 "out of memory" 字样——所以同样一条命令,全局 OOM 搜得到、容器 OOM 搜不到。这不是日志丢了,是搜的姿势不对。
free:宿主机内存充足,为什么还杀?
顺手确认一个反直觉的现象——宿主机内存明明够:

free -h 显示 available 还有 58Gi,vmstat 里 si/so 为 0(没有内存换进换出),宽裕得很。那为什么会被杀?因为杀死 rec-service 的不是"宿主机内存不够",而是"容器自己的内存配额不够"。 容器进程的内存被 cgroup 圈在一堵无形的墙里,墙内顶穿了,内核就在墙内杀人——不管墙外多宽敞。
注意 ps aux --sort=-%mem 里 rec-service 的 RSS 已经 4.1GB(%MEM 30.1),是这台机器上的内存大户——但它的"配额上限"不是宿主机 125Gi,而是容器自己的 4Gi。这就引出定位段的原理:cgroup 才是容器内存真正的"账本"。
memory.events:被杀的铁证
再往深一层,cgroup 自己有个"事件账本",把每次 OOM 都记下来了:

cat /sys/fs/cgroup/memory.events 输出六行计数,其中两行是本案关键。注意执行位置:容器内是 /sys/fs/cgroup/memory.events(自己所在 cgroup 的账本),宿主机则要拼上完整路径 kubepods.slice/.../docker-<id>.scope/memory.events(截图所示)——两种路径读的是同一个文件,容器内路径更短更方便。
oom_kill 1—— 这个 cgroup 内已经发生1 次 OOM 击杀,铁证。oom是"内存超限触发了 OOM 处理流程"的次数,oom_kill是"真的杀掉了进程"的次数——它俩基本同时出现,但如果配置了memory.oom.group之类策略会有差别,本文不展开。max 0—— 触达memory.max上限但没被 kill 的次数(比如刚超限又降回来)。这里是 0,因为 rec-service 一顶穿就被杀了。
再对账三个数值,把"顶穿"钉死:
| 文件 | 值 | 含义 |
|---|---|---|
memory.max |
4294967296(4Gi) | 容器内存配额,即 K8s 的 limits.memory |
memory.current |
4182110208(约 3.9Gi) | 当前实际用量,已逼近配额 |
memory.peak |
4261412864(约 3.97Gi) | 历史峰值,离顶穿 4Gi 只差一步 |
memory.swap.max |
0 | 容器被禁用 swap,顶穿没有任何缓冲 |
memory.high |
max | 未设 soft 限制,只有 hard 上限 memory.max |
memory.peak 是 cgroup 自启动以来的内存峰值(内核 5.19+ 提供),比 current 更能说明问题:进程生前顶到过 3.97Gi,配合 dmesg 里 anon-rss:4102123kB(约 3.9Gi 的匿名内存),两条证据都指向"在配额边缘波动"。而 memory.swap.max=0 是压垮的最后一根稻草——容器被禁用了 swap,内存一超限没有磁盘可缓冲,内核只能立刻杀人。
诊断闭环:
kubectl describe说 OOMKilled(现象)→ 宿主机 dmesg 说 CONSTRAINT_MEMCG(机制)→memory.events说 oom_kill=1(铁证)。三条证据对上了,cgroup 击杀坐实。
路径 — 下次遇到直接跑
容器 OOM 定位四步命令组合

四步在同一个 ssh 会话里连续执行:
- 第 1 步:
kubectl describe pod看Reason: OOMKilled+Exit Code: 137,先确认是"被内存杀"。 - 第 2 步:宿主机
grep CONSTRAINT_MEMCG,找到击杀记录,确认是 cgroup 触发。容器内 dmesg 权限不足,必须上宿主机。 - 第 3 步:
memory.events是 cgroup 的"事件账本",oom_kill字段记录了这个 cgroup 内被杀的进程次数——非 0 就是铁证。 - 第 4 步:
memory.max(cgroup v2 的内存上限)和memory.current(当前用量)对账,看是不是真的顶穿了。
术语说明:cgroup 是 Linux 内核的资源隔离机制,memory cgroup 负责给进程圈一块内存配额;
/sys/fs/cgroup/是 cgroup 的控制文件系统。K8s 里给容器配的resources.limits.memory最终就写到 cgroup 的memory.max里。
验证 cgroup 版本:v1 还是 v2
第 4 步的文件路径依赖内核版本,先花一秒确认用哪套:
$ mount | grep cgroup
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,...) # v2:用 memory.max
# cgroup on /sys/fs/cgroup/memory type cgroup (rw) # v1:用 memory/memory.limit_in_bytes
- cgroup v2(现代发行版默认):
/sys/fs/cgroup/memory.max、memory.current、memory.events。 - cgroup v1(老发行版):
/sys/fs/cgroup/memory/memory.limit_in_bytes、memory/memory.usage_in_bytes、memory/memory.oom_control。
路径差一个层级,字段名也不同。生产上先 mount | grep cgroup 确认,再选对应命令。
定位 — cgroup 是容器内存的"隐形天花板"
触发链:容器配额顶穿 → cgroup 内 OOM → SIGKILL
上篇的全局 OOM 是"整机内存耗尽,内核在全机挑一个最肥的杀"。容器 OOM 完全不是这个逻辑,它的触发链如下:

| 维度 | 全局 OOM(上篇) | cgroup OOM(本篇) |
|---|---|---|
| 触发条件 | 宿主机物理内存耗尽 | 容器 cgroup 配额顶穿 |
| 选择范围 | 全机所有进程 | 仅本 cgroup 内的进程 |
| 决策依据 | oom_score 全局评分 | cgroup 内评分 |
| dmesg 标识 | constraint=CONSTRAINT_NONE |
constraint=CONSTRAINT_MEMCG |
| 杀谁 | oom_score 最高者 | cgroup 内被顶穿时的分配者/高分者 |
| 内存是否真不足 | 是,整机真不够 | 否,宿主机内存充足 |
为什么 dmesg 里没有 Out of memory:因为全局 OOM 才会走"全机挑最肥"那套完整流程和日志。cgroup OOM 是局部判决——内核发现某个 cgroup 触顶,直接在墙内执行击杀,记录里写的是 CONSTRAINT_MEMCG。这就是为什么你按上篇的关键字去搜,永远搜不到。
为什么宿主机 free 充足还是被杀:容器进程能吃到的内存上限 = memory.max,不是宿主机总量。K8s 的 limits.memory 就是写进这里的配额。配额 4G,进程用到 4G 顶穿,内核判死刑——墙外再有 100G 空闲也不关它的事。
数学题:4G 容器,Java 为什么会顶穿
回到 dmesg 那条记录:anon-rss:4102123kB ≈ 3.9Gi。而 K8s 给 rec-service 的 limits.memory 是 4Gi。匿名内存已经占了 3.9Gi——注意 dmesg 里的 anon-rss 只是匿名内存(堆、栈等),cgroup 统计的 memory.current 还要加上 page cache、页表、内核 slab 等,实际逼近甚至瞬时超过 4Gi,一波动就被就地正法。
Java 进程的内存从来不是只有堆。一个 JVM 的内存构成:

| 区域 | 内容 | 是不是"堆" |
|---|---|---|
| Java 堆 | -Xmx 控制的对象存储区 |
✅ 是 |
| Metaspace | 类元数据(类、方法、常量池) | ❌ 不是 |
| 线程栈 | 每个线程约 512KB-1MB | ❌ 不是 |
| 直接内存 | DirectBuffer(Netty/NIO) | ❌ 不是 |
| JIT 编译产物 | 编译后的本地代码 | ❌ 不是 |
容器 OOM 最常见的根因不是堆设得不够,而是堆 + 各种堆外把容器配额顶穿了。 很多团队只盯着 -Xmx,堆给了 2.5g,剩 1.5g 给堆外——线程一多、类一多、直接内存一涨,1.5g 根本不够,撞墙被杀。
这是 C2 的答案:内核的行为不是"看宿主机内存不够",而是"看容器 cgroup 配额顶穿"。容器技术用 cgroup 给进程画了个"隐形天花板",Java 这类吃内存大户最容易被天花板撞死,而且因为 dmesg 措辞不同,让"有经验的 OOM 排查"也容易走错方向。
这不是应用层的问题
这个案例属于系统层还是应用层? 分两面看:
- 触发机制是系统层:cgroup 配额顶穿 → cgroup OOM → SIGKILL,这一整套是内核 + 容器运行时干的,应用代码没 bug,日志干净得能当证据。
- 根因往往是应用层/配置层:JVM 参数(
-Xmx、Metaspace、线程数)决定了它吃多少;容器配额(limits.memory)决定了它能吃多少。两者不匹配,才顶穿。
所以判断链条是:先确认"系统层怎么杀的"(OOMKilled + CONSTRAINT_MEMCG),再定位"应用层怎么撑爆的"(JVM 内存账本 vs 容器配额)。 不能只杀进程不查食量,否则今晚还会再死一次。
处方
第一刀:对账 JVM 内存账本与容器配额
进容器看 JVM 实际怎么分配:

jcmd VM.native_memory 是 JVM 自带的本机内存(Native Memory,即堆外内存)汇总命令,能列出 Java 堆、Metaspace、线程栈、直接内存各吃了多少。看输出里的两处关键:
Java Heapcommitted 2.5GB —— 堆本身,由-Xmx控制。Class(Metaspace)0.7GB +Thread0.1GB +Code0.13GB —— 堆外,经常被忽略但真实占用。
先对账:当前峰值离配额还有多远?谁占了大头? 如果 Metaspace + 线程栈 + 直接内存加起来已经逼近配额,那 -Xmx 再小也没用——堆外才是凶手。本案里堆 2.5g、堆外约 1.5g,加起来超过 4Gi 配额,撞墙被杀。
第二刀:给 JVM 和容器同时留出堆外余量
两个方向一起改:

-Xmx4g配limits 8Gi:堆 + 堆外(约 2-3g)加起来在配额内留了缓冲。-XX:MaxMetaspaceSize和-XX:MaxDirectMemorySize给堆外也设上限,防"类元数据/直接内存无界增长"。- 原则一句话:
-Xmx+ 预估堆外 ≤ 容器配额 × 80%,剩下 20% 留给系统、page cache 和 GC 高峰。
第三刀:把 oom_kill 变成告警
memory.events 里 oom_kill 非 0,说明已经死过一次了——别等 ReplicaSet 再拉起送死,把它做成监控:
- 指标:
container_memory_working_set_bytes(容器实际工作集)对比limits.memory,持续 5 分钟 > 80% → 预警,> 95% → 严重。 - 事件:监听
oom_kill计数变化,一增长立刻拉上面的四步命令组合,看是谁顶穿的。 - 预防:对账过后如果发现是堆外无界,优先定位(Metaspace 泄漏 / DirectBuffer 泄漏),而不是简单加配额——加配额只是延迟撞墙。
记住这个命令组合
$ kubectl describe pod <pod> | grep -A3 "Last State" # 容器死因:OOMKilled + Exit 137
$ dmesg -T | grep -i "CONSTRAINT_MEMCG" # 宿主机击杀记录:cgroup 触发
$ cat /sys/fs/cgroup/memory.events # oom_kill 计数:非 0 即铁证
$ cat /sys/fs/cgroup/memory.max && cat /sys/fs/cgroup/memory.current # 对账配额 vs 用量
附:完整命令清单
kubectl describe pod <pod> | grep -A3 "Last State" # 死因:OOMKilled + Exit Code 137
dmesg -T | grep -iE "CONSTRAINT_MEMCG" # 宿主机 cgroup OOM 击杀记录
dmesg -T | grep -iE "Memory cgroup out of memory" # 同一条记录的另一措辞
cat /sys/fs/cgroup/memory.events # oom_kill 计数(cgroup v2)
cat /sys/fs/cgroup/memory.max # 容器内存配额(cgroup v2)
cat /sys/fs/cgroup/memory.current # 当前用量(cgroup v2)
cat /sys/fs/cgroup/memory/memory.oom_control # cgroup v1 版本
jcmd $(pgrep java) VM.native_memory summary # JVM 堆外内存账本
mount | grep cgroup # 先确认 cgroup v1/v2
内核版本说明:cgroup v2 自内核 4.5 引入,5.x 起成为主流发行版默认(
memory.max/memory.current/memory.events)。cgroup v1 老环境用/sys/fs/cgroup/memory/memory.limit_in_bytes和memory.oom_control。oom_reaper收割线程自内核 4.6 起存在。K8s 从 1.22 起默认使用 cgroup v2。
阅读路线
内存类排查共 9 篇。上篇讲了整机 OOM 在 dmesg 找 Out of memory,这篇补上了容器场景:dmesg 里没有 OOM 记录,不代表没被杀——CONSTRAINT_MEMCG 才是容器 OOM 的关键字。 下篇我们聊 Swap 使用量飙升对 Java GC 的影响分析:当内存压力让内核把匿名页换到磁盘,JVM 的 GC 会怎样被拖慢、为什么 swap 一涨 GC 停顿就失控。
记住一个心法:Linux 不会在容器里乱杀进程——cgroup 只是替你在隐形天花板前执行了配额判决。 排查容器 Java 被杀,第一步别在容器里找 dmesg,上宿主机,搜 CONSTRAINT_MEMCG,再数 memory.events 的 oom_kill。容器 OOM 不是宿主机内存不够——是它自己的隐形天花板被顶穿了,找到天花板,再算清 JVM 的账,答案就在眼前。