凌晨两点,监控突然拉响,线上一个 Java 服务的容器被重启。冲进宿主机翻 dmesg,看到一行 oom-kill 记录:Killed process 45821 (java),但此时容器已经被 restart,进去 ps 根本找不到这个 PID。这个场景,估计每个做 Linux 服务器运维的人都经历过。OOM Killer 作为内核最后一道内存防线,平时安安静静,一旦触发就是生产事故。而我们要做的深度监控,恰恰要在这道防线开火之前和开火瞬间,把进程、cgroup、namespace 三个维度的信息全部抓全——这不只是看日志,而是要把内核的判决逻辑、容器资源账本和 PID 隔离带来的映射关系彻底搞清楚。
这篇文章我把自己这两年在生产环境里折腾 OOM 监控的完整思路写下来,包括内核触发原理、cgroup v2 的监控接口、namespace 下的还原手段,以及一套可以直接部署的脚本。全文不是教科书式的参数罗列,而是从实际踩坑出发,尽量把“为什么这么配”“为什么这里会踩坑”讲透,适合负责服务器稳定性、容器编排的运维和 SRE 参考。
1. 内核在什么情况下会开枪:OOM Killer 的触发与选人逻辑
1.1 三种 overcommit 模式决定了你的内存账本怎么算
要理解 OOM Killer,先要接受一个反直觉的事实:Linux 的 malloc 大部分时候是在“吹牛”。进程申请 1GB 内存,内核可能当场就点头,但这 1GB 物理页并不会真的分配出来,只有进程实际去读写这些地址时,内存页才会被真正占用。这种“允许超额承诺”的机制叫 overcommit,它是 Linux 多进程内存共享、写时复制等特性的基础,但也让“内存到底够不够”变成一笔糊涂账。
内核用 /proc/sys/vm/overcommit_memory 控制这笔账怎么算,取值主要是 0、1、2 三档:
| 值 | 模式 | 行为 | 生产建议 |
|---|---|---|---|
| 0 | 启发式 | 内核按经验判断单次分配是否合理,明显离谱的申请会被拒,大多数发行版默认 | 保持默认 |
| 1 | 总是允许 | 只要地址空间没到上限就批准,系统极易进入危险区域 | 不建议开启 |
| 2 | 严格模式 | 超过 CommitLimit 就拒绝,malloc 可能直接返回 NULL | 除非业务有明确诉求,否则别碰 |
严格模式下的 CommitLimit 由 vm.overcommit_ratio 决定,公式是:
code复制CommitLimit = SwapTotal + 物理内存 * vm.overcommit_ratio / 100
ratio 默认 50,也就是说系统最多承诺物理内存一半的额外超卖空间。这里有个常见的监控盲区:很多人只盯 free -h,看到还有几个 GB 就放心了,实际上在 overcommit_memory=2 的机器上,/proc/meminfo 里的 Committed_AS 才是真正的风险指标。这个值表示内核已经承诺给所有进程的地址空间总量,一旦它逼近 CommitLimit,即使 MemFree 还有剩余,新的内存申请也可能直接失败。
我遇到过一次很诡异的故障:Java 服务反复启动失败,日志里报“OutOfMemoryError:unable to create native thread”,不是进程被杀,是线程栈内存分配直接被拒。排查到最后才发现是某个人把 overcommit_memory 改成了 2,Java 这种吃地址空间的进程首当其冲。所以深度监控里必须包含这两个字段:Committed_AS 和 CommitLimit,它们能给你一个提前量。
1.2 badness 评分:为什么总杀掉你觉得"不该杀"的进程
当内核真的走到山穷水尽——kswapd 回收无效,直接回收也无效,再不放血整个系统就要卡死——它会启动 out_of_memory 流程,挑一个进程杀掉。关键问题是:它怎么挑?
内核给每个进程算一个 badness 分数,核心逻辑在 oom_badness() 这个函数里,主要看这几个因素:
- 进程占用的内存总量(RSS + swap + 页表占用的页面数),占分最大;
- 进程已经运行了多久,刚启动不久的进程分数会偏高,因为内核认为“短命鬼”杀掉代价小;
- 进程的父进程、子进程关系,以及它是不是 root 启动的关键任务;
- 是否处于不可中断的 D 状态等特殊情况。
最后的评分会被 /proc/<pid>/oom_score_adj 做线性修正,这个值范围是 -1000 到 1000,默认继承父进程,绝大多数进程是 0。写成 -1000 意味着“永远别杀我”,等于拿到免死金牌;写成正数则是主动吸引火力。最终算出来的总分写在 /proc/<pid>/oom_score 里,范围 0 到 1000。
这就解释了为什么很多时候被杀的不是你觉得“内存占用最猛”的常驻服务,而是刚启动的批处理任务。有一次我这边跑数据导出的 Python 脚本被反复 OOM kill,服务端 Java 进程反而毫发无损,原因就是那个脚本一次性加载几百万行数据,RSS 瞬间飙高,同时又刚启动没几分钟,badness 分数直接压过所有老进程。
如果要主动保护某些核心进程,可以用 systemd 的 OOMScoreAdjust= 直接调:
ini复制[Service]
ExecStart=/usr/local/bin/order-service
OOMScoreAdjust=-800
同样,给那些“宁可被杀也不能拖垮全局”的临时任务设置正分。这里要强调的是,oom_score_adj 不是越大越安全,它不是优先级队列,而是“杀掉分数最高那一个”,所以如果你把一堆进程都调成正分,那内核只是在矮子里面拔将军,保护效果会大打折扣。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 只靠 dmesg 做监控远远不够:事件之外还得有上下文
2.1 dmesg 日志不够用的三个理由
很多人查 OOM 的第一反应是翻 dmesg -T | grep -i oom,这没错,但如果你用这个做过线上复盘,很快就会发现它远远不够。
第一,内核日志是环形缓冲区,dmesg 默认大小有限,日志量大时早前的 OOM 记录可能已经被冲刷。尤其在高并发系统上,网络、磁盘、驱动都在刷日志,OOM 记录很容易被顶掉。第二,日志里只有进程的 comm 和全局 PID,没有业务上下文——那个 java 到底是订单服务还是报表服务?它在哪个容器、哪个 pod、哪个 cgroup 下?这些 dmesg 一概不给你。第三,没有“触发前的趋势”。OOM 不是瞬间发生的,内存是被一步步吃完的,但 dmesg 只给你最后判决的那一行,没有水位曲线,你就没法判断是突刺还是缓慢泄漏。
真实排查里还有一个更隐蔽的问题:comm 会被内核截断到 15 个字符。Java 的线程名、Python 脚本名稍长一点,你在日志里看到的可能只是 python3 或者 java,根本看不出是哪个业务。
2.2 把 OOM 事件、内存快照、进程树串成一条结构化记录
既然 dmesg 的信息密度不够,深度监控的做法就是“事件驱动 + 上下文快照”。也就是说,不去依赖 dmesg 里的那一行,而是等 OOM 事件发生的那一瞬间,主动去采集三组数据:
- 事件层:谁触发了 OOM(内核日志里会标明是 CONSTRAINT_MEMCG 还是全局)、杀的是哪个进程、进程的全局 PID 和 comm;
- 环境层:触发前的
/proc/meminfo关键字段、对应 cgroup 的memory.current、memory.max、swap 使用量; - 现场层:被杀进程的完整信息,包括 oom_scor
