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 先看死因:

Pod 显示 OOMKilled + Exit Code 137

关键三行说明了一切:

  • 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)的权限——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

宿主机 dmesg 翻出 cgroup OOM 击杀记录

逐行拆解,和上篇对比看区别:

  • 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 显示内存充足

free -h 显示 available 还有 58Gi,vmstatsi/so 为 0(没有内存换进换出),宽裕得很。那为什么会被杀?因为杀死 rec-service 的不是"宿主机内存不够",而是"容器自己的内存配额不够"。 容器进程的内存被 cgroup 圈在一堵无形的墙里,墙内顶穿了,内核就在墙内杀人——不管墙外多宽敞。

注意 ps aux --sort=-%mem 里 rec-service 的 RSS 已经 4.1GB(%MEM 30.1),是这台机器上的内存大户——但它的"配额上限"不是宿主机 125Gi,而是容器自己的 4Gi。这就引出定位段的原理:cgroup 才是容器内存真正的"账本"。

memory.events:被杀的铁证

再往深一层,cgroup 自己有个"事件账本",把每次 OOM 都记下来了:

memory.events 记录 oom_kill 计数

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 定位四步命令组合

容器 OOM 定位四步命令组合

四步在同一个 ssh 会话里连续执行:

  • 第 1 步kubectl describe podReason: 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.maxmemory.currentmemory.events
  • cgroup v1(老发行版):/sys/fs/cgroup/memory/memory.limit_in_bytesmemory/memory.usage_in_bytesmemory/memory.oom_control

路径差一个层级,字段名也不同。生产上先 mount | grep cgroup 确认,再选对应命令。


定位 — cgroup 是容器内存的"隐形天花板"

触发链:容器配额顶穿 → cgroup 内 OOM → SIGKILL

上篇的全局 OOM 是"整机内存耗尽,内核在全机挑一个最肥的杀"。容器 OOM 完全不是这个逻辑,它的触发链如下:

全局 OOM 与 cgroup 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 的内存构成:

JVM 内存构成 vs 容器配额账本

区域 内容 是不是"堆"
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 查看 JVM 堆外内存账本

jcmd VM.native_memory 是 JVM 自带的本机内存(Native Memory,即堆外内存)汇总命令,能列出 Java 堆、Metaspace、线程栈、直接内存各吃了多少。看输出里的两处关键:

  • Java Heap committed 2.5GB —— 堆本身,由 -Xmx 控制。
  • Class(Metaspace)0.7GB + Thread 0.1GB + Code 0.13GB —— 堆外,经常被忽略但真实占用。

先对账:当前峰值离配额还有多远?谁占了大头? 如果 Metaspace + 线程栈 + 直接内存加起来已经逼近配额,那 -Xmx 再小也没用——堆外才是凶手。本案里堆 2.5g、堆外约 1.5g,加起来超过 4Gi 配额,撞墙被杀。

第二刀:给 JVM 和容器同时留出堆外余量

两个方向一起改:

JVM 参数 + 容器配额调整

  • -Xmx4glimits 8Gi:堆 + 堆外(约 2-3g)加起来在配额内留了缓冲。
  • -XX:MaxMetaspaceSize-XX:MaxDirectMemorySize 给堆外也设上限,防"类元数据/直接内存无界增长"。
  • 原则一句话:-Xmx + 预估堆外 ≤ 容器配额 × 80%,剩下 20% 留给系统、page cache 和 GC 高峰。

第三刀:把 oom_kill 变成告警

memory.eventsoom_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_bytesmemory.oom_controloom_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.eventsoom_kill容器 OOM 不是宿主机内存不够——是它自己的隐形天花板被顶穿了,找到天花板,再算清 JVM 的账,答案就在眼前。