内存不够时先发生什么:OOM 与 cgroup

内存不够时先发生什么:OOM 与 cgroup
隔壁老康难度 ★★★★☆ · 前置:页缓存:为什么 free 少不一定是坏事 | 一句话结论:内存不够时内核杀掉 score 最高的进程,发出的其实是
SIGKILL(但不能捕获,也来不及清理);容器里/proc/meminfo永远是宿主机的,所以应用得读 cgroup 的memory.max,否则「limit 2Gi 却按 64GB 自适应」这类事故一定会来。
一点五个 G 撞上了两个 G 的天花板
周三凌晨两点,值班手机响了,我摸过来看第一眼是某个 API 服务的实例挂了,第二眼是重启次数,第三次重试的时候人已经坐起来了,脑子里过的第一个念头不是去查业务代码,是这台机器还剩多少内存,因为那半年里凡是凌晨两点响的内存告警,九成都跟业务逻辑没关系,全是配置和默认值在打架,我上去敲的第一条命令是 kubectl get pod,状态里那几个字比一千行日志都管用,日志会骗人,它只记录进程自己愿意说的,而状态至少能告诉你这进程是被自己写崩的还是被外面弄死的,这两条排查路线差得非常远,走错了就是一晚上白干,而这种白干在我入职的头半年里发生过不止一次。
(现在回头看那批工单,没有一次是代码写错,全是默认值没人管。)
1 | kubectl get pod |
1 | NAME READY STATUS RESTARTS AGE |
OOMKilled,重启过 3 次。
看日志,最后一行是正常的请求日志,没有任何异常堆栈,没有任何错误,进程像是凭空消失。我原以为至少能看到一个 RangeError,或者一段 GC 时间暴涨的记录,结果干净得像是我自己把服务正常停掉了,这一下把常规的排查路线全堵死了,只能换个方向去翻内核留下的东西,因为业务日志是进程自己写的,它死了就不写了,而内核还在,它才是那个下手的,那一晚我在日志里翻了大概四十分钟,把重启前那几百行请求逐条看了一遍,状态码全是 200,响应时间也都在几十毫秒,看起来一切正常,直到那时我才意识到判断的依据找错了对象,一个被外壳干掉的进程当然不会在遗书里写自己怎么死的。
1 | sudo dmesg -T | grep -i 'killed process' # 普通用户常被 dmesg_restrict 挡住,加 sudo |
1 | [Fri Nov 13 02:14:08 2026] Out of memory: Killed process 14382 (api-server) total-vm:2412840kB, anon-rss:1567232kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:3148kB oom_score_adj:0 |
anon-rss 是 1.5GB,可 resources.limits.memory 写的是 2Gi,1.5GB 没到 2Gi,为什么被杀。这一句话我在工单里重复写了三遍,因为我不信,反复对 yaml 里的值和 describe 里的值,确认是不是改错了命名空间,最后才想起来去看 requests,两个数字差着一个数量级。
1 | Limits: |
而容器里的 Node.js 因为 --max-old-space-size 没设,V8 是照着宿主机的 64GB 内存在管理堆,它觉得自己可以随便用,一路涨到 cgroup 的 2Gi 天花板,然后被 cgroup 干掉,全程没有一行报错,因为它一直以为自己还有很多余量。
Node.js 只看到了我还有很多内存,看不到 cgroup 的上限。
两种 OOM,一个共同点
| 名字 | 触发者 | 范围 | 表现 |
|---|---|---|---|
| 主机 OOM | 内核 oom_kill_process() |
整台机器 | 按分数杀一个进程,dmesg 有记录 |
| cgroup OOM | memcg 的内存控制器 | 一个 cgroup | 杀该组内的进程,退出码 137 |
两种 OOM 的触发者不一样,一个是整台机器的内核,一个是某个 cgroup 的内存控制器,范围也不一样,一个管全机,一个只管那一组,可它们下手的方式完全相同,用的都是 SIGKILL,内核 oom_kill_process() 里就是 do_send_sig_info(SIGKILL, SEND_SIG_PRIV, victim, PIDTYPE_TGID) 这一行,而它跟你在终端里敲 kill -9 又不是一回事,进程既捕获不了,也来不及清理,内核只是把它标成 OOM victim 之后收尾,你注册过的那几个清理钩子根本没有执行的机会,所以想在信号处理里写日志、存状态、发告警,这条路从头到尾就是堵死的,内存限制这件事在主机和容器里本来就是两套机制,而应用往往只认识其中一套,并且恰好是错的那一套,这种错位不会在编译时提醒你,测试环境里也看不出来,只会等到某个凌晨两点以 137 的形式还给你。
主机 OOM:谁占得多谁死
内存不够时,回收顺序走到底之后,内核必须腾出内存,它会挑一个进程杀掉,挑谁不由你决定,由 oom_score 决定,这个分数是内核在遍历进程的时候现算的,不是谁提前登记的,你在 top 里看不到它,只能从 /proc 里读。(这条我入职第一年完全不知道,那次主机 OOM 之后我第一反应是查日志找报错,找了半小时才有人告诉我去看 oom_score,而那个进程其实早就死了,日志里什么都没留,因为它是被信号带走的,没机会写日志。)
1 | PID=$(pgrep -n mysqld) # 换成你要看的进程名 |
1 | 683 |
范围 0 到 1000,值越大越危险,683 意味着这个进程在当时那一刻相当危险。
计算方式简化成这样,看一眼就能明白内核的偏好。
1 | oom_score ≈ 进程的物理内存占用(RSS + swap + 页表)× 1000 / 总内存 |
第二行那个 oom_score_adj 是直接加在 0 到 1000 这个分数上的偏移,不是按比例缩放,所以它能一下子把进程推到顶格或者踢到最后。核心逻辑是,谁占的内存多,杀谁,因为杀一个 5GB 的进程能救活最多的人,杀 100 个 50MB 的进程收益小得多。
这个逻辑在实际中非常好用,也非常冷酷,一台机器 64GB,一个 Java 进程吃了 30GB,OOM 的时候它基本必死,你不喜欢你正在跑的数据库也一样,它不会因为你标注了重要两个字而幸免,内核不读注释,也不看工单优先级,它只看那个除法,所以那种这个服务很重要应该不会被杀吧的期待,实际上是在赌有没有一个更胖的进程排在你前面,而赌注从来不在你手上,机器上跑着什么、谁最近涨得快、谁在做批量导入,全都由别人的部署节奏决定,你能做的只是提前把那个除法往自己这边挪一点。
oom_score_adj 是唯一的干预手段
1 | cat /proc/$PID/oom_score_adj |
1 | 0 |
范围 -1000 到 1000,四档值得记住。
-1000:永远不杀(oom_badness()见到它直接返回LONG_MIN,选择时直接跳过);-999:几乎不杀;0:默认;1000:最先杀。
名字历史上叫 oom_adj(合法范围 -16 到 15,另有特殊值 -17 表示完全禁用),现在的接口是 oom_score_adj,oom_adj 从 2.6.36 起弃用,Linux 3.7 里已经被移除,新内核上根本没有这个文件,你照着一篇老博客去改会发现路径不存在,然后开始怀疑自己是不是敲错了。
临时用一次是这样,进程重启就没了。
1 | pgrep -a mysqld # 先确认只有一个、且是你要保护的那个 |
要落成配置就写进 systemd unit,机器重启也还在。
1 | [Service] |
常见的生产配置大概是这么分的,照抄可以,但要知道每一行为什么。
| 服务 | 建议值 | 理由 |
|---|---|---|
| 数据库(MySQL/PostgreSQL) | -900 |
被杀一次要恢复很久 |
| SSH / systemd / 关键守护 | -1000 |
不然你连不上机器 |
| 业务应用 | 0 ~ 200 |
默认即可 |
| 批处理任务 | 500 |
它不那么急,且常常是吃内存的元凶 |
| 压测脚本 | 1000 |
故意的 |
oom_score_adj 只是建议,不是绝对保证,但 -1000 是例外,oom_badness() 看到 -1000 直接返回 LONG_MIN,选择循环里直接跳过它。
我当初以为给自己设了 -1000 就等于上了保险,还专门在测试机上试了一次,把 shell 和 sshd 都设成 -1000 再去吃内存,结果整机只剩这两个候选的时候,内核不是退而杀它们,而是打一行 Out of memory and no killable processes... 然后 panic("System is deadlocked on memory"),机器直接锁死,我那次是从控制台重启回来的,教训是豁免只能解决优先级问题,解决不了没有候选这件事,也就是说它调的不是谁先谁后的顺序,而是那个循环里还剩不剩下人,一个都没有的时候,内核连选的动作都做不了,只能认输,而认输的方式不是退出,是把整台机器停在那儿。
容器是另一堵墙
cgroup 的 memory 控制器给一组进程设上限,超了就在组内触发 OOM,不去动组外的进程,v2 是主流了,路径是 /sys/fs/cgroup/<path>/memory.max,v1 老路径是 /sys/fs/cgroup/memory/<path>/memory.limit_in_bytes,两种都还能见到,看 /sys/fs/cgroup 下有没有 cgroup.controllers 这个文件就知道是哪一版。
关键文件(v2)一共五个,记住它们就够日常排查用了。
| 文件 | 意思 |
|---|---|
memory.max |
硬上限,超了就 OOM |
memory.high |
软上限,超了会被限速但不杀 |
memory.current |
当前用量 |
memory.events |
计数器:max、oom、oom_kill |
memory.swap.max |
swap 上限 |
memory.high 看起来很友好,超了不杀,只是让进程变慢,给它自己退出的机会,我一开始也觉得这是个更温柔的选择,直到有次把它开在一个线上服务上,很多场景下 throttle 比 OOM 更折磨人,因为进程没死,一直在慢,监控上看不到 5xx,看不到重启,只看到 P99 一点点往上爬,而你还在别的地方找原因,节流怎么实现的,cgroup v2 在分配页的那条路径上插了个检查点(mem_cgroup_high_events + try_charge),一旦 memory.current 超过 memory.high,分配页的进程不报错、也不失败,而是被塞进一个回收等待队列,先同步回收这个 cgroup 自己的一些页,回收不出来就睡一小会儿再试。
表现出来就是 malloc 变慢,而不是 malloc 失败,所以内存超了但进程还在跑、CPU 也没满、就是速度掉了十倍,八成是撞上 memory.high,这时候去看 memory.events 里的 high 计数,比看什么指标都快。
比阈值更早的信号:PSI
不管是 memory.max 还是 memory.high,都是已经出事之后的边界,想要提前知道这个 cgroup 快撑不住了,看 PSI(Pressure Stall Information,内核 4.20 起)。
1 | cat /sys/fs/cgroup/myapp/memory.pressure |
1 | some avg10=12.34 avg60=8.71 avg300=3.02 total=2841932 |
两个数的含义不一样,这是最容易被看错的地方。
some:至少有一个任务因为等内存而停住的时间比例。哪怕只有 1 个线程在等,也算。full:所有任务都停住的时间比例。这个数非零,说明整组进程真的停了。
avg10/avg60/avg300 是三个滑动窗口的百分比,total 是累计微秒数,生产上盯 full avg60 比盯 memory.current 有用得多,用量 90% 不代表有问题,full 的 60 秒均值涨到 20% 才说明确实在等。
(我第一次看到这个数字的时候以为它是内存使用率,还跟同事说这机器的内存才用了 12%,被笑了很久。)
PSI 还有 cpu.pressure 和 io.pressure,同一个格式,io.pressure 配合 full 能直接证明慢不是因为 CPU,这个数最实用的地方在于它能区分机器忙和机器在等,load average 分不清这两件事,PSI 能,一个把 CPU 跑满的编译任务和一个卡在磁盘上的进程,前者 some 高 full 低,后者两个都高。
容器里的 free 一定是错的
容器没有自己的 /proc/meminfo,它看到的是宿主机的,这就是开篇那个 Node.js 的根因。
1 | # 容器内 |
1 | MemTotal: 65843724 kB # 宿主机 64GB |
但 cgroup 的限制在另一个文件里,两个数字摆在一起就很吓人。
1 | cat /sys/fs/cgroup/memory.max |
1 | 2147483648 |
2Gi,所以任何按 /proc/meminfo 自适应的程序在容器里都会算错。JVM 从 JDK 10 起会读 cgroup(-XX:+UseContainerSupport,10 起默认开),Go 从 1.19 起读 GOMEMLIMIT(需要显式设),Node.js 要手动设 --max-old-space-size,三种语言的成熟度不一样,最不省心的那个恰好最流行。
挑选是个循环
oom_kill_process() 的实际流程比想象中啰嗦,杀一个不够还要再来一遍。
graph TD
A["内存分配失败<br/>且回收失败"] --> B["遍历进程<br/>计算 oom_score"]
B --> C{"有 oom_score_adj = -1000<br/>且不是唯一候选?"}
C -->|有| B
C -->|没有| D["选分数最高的"]
D --> E["杀了它,回收它的内存"]
E --> F{"内存够了吗?"}
F -->|不够| B
F -->|够了| G["回到正常"]
注意这是一个循环,杀一个不够,继续杀下一个,极端情况下 OOM Killer 会连续杀掉半台机器上的进程,你在 dmesg 里会看到连着好几行 Killed。
dmesg 里会留下记录,老内核(5.0 及以前)是两行,第一行带分数。
1 | [ 421.382] Out of memory: Kill process 1823 (java) score 812 or sacrifice child |
5.1 起第一行被删掉了,分数不再打进 dmesg,只剩判决这一行,所以你在新内核上永远看不到那个 812。
1 | [ 421.383] Out of memory: Killed process 1823 (java) total-vm:24563168kB, anon-rss:15234432kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:3148kB oom_score_adj:0 |
or sacrifice child 指的是,如果父进程被选中的话,可以选它的某个子进程代替,OOM Killer 倾向于杀叶子进程,因为杀父进程会让整个子树都挂掉。
cgroup 的 OOM 更简单,谁撞墙谁死,但墙内的所有进程都是候选,memory.max 被突破的那一刻,内核先尝试回收同一 cgroup 内的文件页,回收不够就调用 mem_cgroup_out_of_memory(),在该 cgroup 内按同样的 oom_score 逻辑挑一个杀掉,如果还不够就继续,所以容器 OOM 的关键特征是被杀的是撞墙的那一组里最大的那个,而不是整机上最大的那个,这一点非常容易看错,尤其是同一台机器上还跑着一个更大的服务的时候,你盯着监控上那个 40GB 的进程,怀疑了半天,其实动手的内核从头到尾都没看过它一眼。
memory.events 会记录这一切,它比 Pod 的状态更早。
1 | cat /sys/fs/cgroup/memory.events |
1 | low 0 |
oom_kill 3 就是这一组被杀过 3 个进程。
这个计数器非常适合做监控告警,比看 OOMKilled 状态更早,因为状态要等容器真的退出才会有,而计数器在内存撞墙的那一秒就动了。
137 这个数字
137 = 128 + 9,即被 SIGKILL 杀死,OOM Killer 杀进程用的就是 SIGKILL 的机制(内核 oom_kill_process() 里直接 do_send_sig_info(SIGKILL, ...)),所以父进程看到的 wait status 是被信号 9 终止,shell 换算成 128 + 9 = 137,这个换算是 shell 干的,不是内核。
| 退出码 | 含义 | 典型原因 |
|---|---|---|
137 |
128 + 9,被 SIGKILL 杀 |
OOM、docker kill、kubectl delete --force |
143 |
128 + 15,被 SIGTERM 杀 |
正常的优雅停止 |
139 |
128 + 11,段错误 |
空指针、野指针、栈溢出 |
134 |
128 + 6,abort |
assert 失败、C++ 未捕获异常 |
1 |
应用自己退出 | 配置错误、初始化失败 |
看到 137 就要怀疑 OOM,但也可能是别的原因杀了它,区分方法就这么几条。
- 主机上:
dmesg | grep -i 'killed process' - 容器里:
kubectl describe pod看Last State: Terminated, Reason: OOMKilled,或者docker inspect看OOMKilled: true
143 和 137 的区别很重要。143 说明进程是被 SIGTERM 结束的,也就是优雅停止走通了,真被 SIGKILL 补刀的话,退出码会变成 137,所以 143 不是 OOM,这一点很容易看反,我见过有人在 143 的工单里翻了半天内存曲线,翻到最后发现人家是正常下线的。
为什么 Linux 敢超卖
1 | cat /proc/sys/vm/overcommit_memory |
1 | 0 |
三个值,默认是中间那个。
| 值 | 策略 |
|---|---|
0 |
启发式(默认):明显的过度承诺才拒绝 |
1 |
永远说 yes,绝不拒绝 malloc |
2 |
严格:CommitLimit = swap + RAM × overcommit_ratio,超了就拒 |
默认的 0 意味着 malloc(10GB) 在一台 8GB 的机器上可能成功,因为内核按启发式判断你大概不会真用,这个赌注在大多数程序身上都是对的,代价是申请的时候没事,用的时候才炸。
1 | char *p = malloc(8UL << 30); // 8GB,可能成功 |
这是 Linux 上内存问题难排查的根本原因之一,错误发生在离成因很远的地方,栈上看到的是 memset 那一行,真正的成因可能是一小时前那次 malloc。
vm.overcommit_memory = 2 能让 malloc 阶段就失败,但需要仔细配 overcommit_ratio,配错会让 SSH 都登不上,生产上一般保持 0,接受这个赌注,把监控补齐。
swap 有和没有,是两种死法
1 | swapon --show |
有 swap,匿名页可以被换出去,OOM 会晚很多,但可能进入 swap 抖动,系统还在跑,但慢到无法使用(si、so 持续很高)。
没 swap 这通常是更好的选择,快速失败,而不是慢慢卡死,Kubernetes 从 1.22 起才支持 swap(默认关闭),因为节点级别的 swap 和 pod 的内存隔离在语义上冲突,一个 pod 换出去的页,会跟别的 pod 抢同一块磁盘。
先给自己打个分,再撞一次墙
看自己的分数
1 | cat /proc/self/oom_score /proc/self/oom_score_adj |
1 | 523 |
具体数字取决于当时系统里有多少进程、各占多少内存,同一个进程跑两次都可能不一样。
主动制造一次主机 OOM(只在测试机上)
不要在生产上做这件事,也不要在有人用的共享机器上做,后果是别人跟着你一起断。
1 | python3 -c " |
跑着跑着会被 Killed,然后去看现场。
1 | sudo dmesg -T | tail -5 |
1 | [Fri Nov 13 02:31:44 2026] Out of memory: Killed process 21044 (python3) total-vm:8231440kB, anon-rss:8031420kB, ... |
你会看到它一次涨到接近整机内存才被杀,这就是 malloc 成功、用的时候才死的直观演示,前面每一行打印都说明申请成功了。
保护一个进程
先给 shell 和 sshd 设好豁免,只在测试机上做,做完记得改回 0。
1 | echo -1000 | sudo tee /proc/$$/oom_score_adj |
然后再去制造内存压力,你的 ssh 会话大概率能活下来(虽然不保证)。我第一次做这个是在一台只有 2GB 的测试机上,信心满满地设了 -1000 去吃内存,结果 ssh 还是断了,因为整机只剩它一个能杀的候选,豁免解决不了没有候选这件事。
用完复原,这一步经常被人忘掉,然后这台测试机在半年里行为都很怪。
1 | echo 0 | sudo tee /proc/$$/oom_score_adj |
在 cgroup 里做,更安全
关键是别把你自己那个终端关进笼子,用 bash -c 起一个子进程进去,它死了你的 shell 还在。
1 | sudo mkdir -p /sys/fs/cgroup/oomtest |
1 | Killed |
只有那个子进程死了,机器和你的终端都没事,这就是 cgroup 的价值,一个可以随手弄坏又不心疼的沙盒。
如果你手快把 echo $$ | sudo tee .../cgroup.procs 直接敲进了当前终端($$ 就是你自己的 shell),后果是这个终端被锁在 200MB 里,之后每条命令都受限制,而且 rmdir 会因为还有进程在里面而报 Device or resource busy,清理不掉,真踩了的话退出这个 shell 重开一个即可,别在那儿跟 rmdir 较劲。
看计数器,确认它真的记了一笔。
1 | cat /sys/fs/cgroup/oomtest/memory.events |
1 | max 1 |
清理,2>/dev/null || true 是为了在目录已经不在时不报错。
1 | sudo rmdir /sys/fs/cgroup/oomtest 2>/dev/null || true |
观察容器里的错位
1 | docker run --rm -m 256m alpine sh -c \ |
1 | 容器看到的 MemTotal: |
/proc/meminfo 说 15GB,cgroup 说 256MB,两个数字差了 60 倍,程序该信哪个,信 cgroup 的。
OOM 之后去哪看现场
三条命令覆盖三个场景,主机、k8s、docker。
1 | # 主机 |
被问得最多的那几句
被 OOM 杀的进程会收到信号,会,内核发的就是 SIGKILL,只不过没有普通 SIGKILL 的语义,进程捕获不到,也来不及清理,所以想在 OOM 前清理资源这种想法不成立,只能在逃不掉之前自己监控 memory.current 主动退出。resources.limits.memory 设了 2Gi 进程就不会超过 2Gi,会超过的,cgroup 是事后拦截,进程已经申请到 2.1GB 才被杀,而且页缓存也算在内(memory.current 含 file 部分),你以为的纯业务内存往往只是它的一部分。(这四条我一开始全答错了,前两条是概念不清,后两条是压根没往上想过,问的人比我早两年入职。)
容器里的 /proc/meminfo 是容器的,不是,是宿主机的,所有按它自适应的程序在容器里都会算错,包括那个你最信任的 JVM。oom_score_adj = -1000 就绝对安全,只要整机还有别的可杀进程就安全,真的一个都杀不了的时候内核会 panic("System is deadlocked on memory"),前面我踩过这个坑。
OOMKilled 一定是内存泄漏,也可能是 limits 设太小,或者应用按宿主机内存自适应(开篇那个 Node.js),或者一次性的批量操作峰值,三者里只有第三个是真的泄漏。
退出码 137 一定是 OOM,也可能是 docker kill、kubectl delete --force、或者节点驱逐,要查 dmesg 和 OOMKilled 字段确认,光看一个数字不够。
加了 swap 就不会 OOM,swap 只是推迟,而且可能让你从快速失败变成五小时卡死最后 OOM,后者更难排查,因为那五个小时里所有指标都是正常的。
memory.high 比 memory.max 安全,超了不杀,但会被节流,开启后常见现象是进程活着但响应时间涨了 10 倍,比干脆的死更难发现。
两条路,一个机制
主机 OOM 按 oom_score(主要看 RSS)挑最贵的杀,杀完不够继续杀,oom_score_adj 是唯一的干预手段,范围 -1000 到 1000,只是建议不是保证。
cgroup 的 OOM 是撞墙就在墙内挑一个,机制相同,范围局限在组里,容器里的 /proc/meminfo 是宿主机内存,任何按它自适应的程序都会算错。退出码 137 是 128 + 9,看到先查 dmesg 和 OOMKilled,别急着改代码。
Linux 默认不预留内存(overcommit_memory = 0),malloc 成功不代表能写,memory.events 里的 oom_kill 计数器比 Pod 状态更早发现问题,适合做告警。
内存这几篇都在处理看不见的东西,而这一片最难的地方不在于看懂某个机制,而在于接受数字不等于真相,free 少不是坏事,VIRT 大不是问题,RSS 涨不一定泄漏,被 OOM 杀掉的进程可能完全无辜。
内存这条线走到这里。接下来的问题是两个进程之间怎么传数据,而且第一问很朴素,cat bigfile | wc -c 过一遍管道,2GB 的文件,内存为什么不涨。











