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

难度 ★★★★☆ · 前置:页缓存:为什么 free 少不一定是坏事 | 一句话结论:内存不够时内核杀掉 score 最高的进程,发出的其实是 SIGKILL(但不能捕获,也来不及清理);容器里 /proc/meminfo 永远是宿主机的,所以应用得读 cgroup 的 memory.max,否则「limit 2Gi 却按 64GB 自适应」这类事故一定会来。

一点五个 G 撞上了两个 G 的天花板

周三凌晨两点,值班手机响了,我摸过来看第一眼是某个 API 服务的实例挂了,第二眼是重启次数,第三次重试的时候人已经坐起来了,脑子里过的第一个念头不是去查业务代码,是这台机器还剩多少内存,因为那半年里凡是凌晨两点响的内存告警,九成都跟业务逻辑没关系,全是配置和默认值在打架,我上去敲的第一条命令是 kubectl get pod,状态里那几个字比一千行日志都管用,日志会骗人,它只记录进程自己愿意说的,而状态至少能告诉你这进程是被自己写崩的还是被外面弄死的,这两条排查路线差得非常远,走错了就是一晚上白干,而这种白干在我入职的头半年里发生过不止一次。

(现在回头看那批工单,没有一次是代码写错,全是默认值没人管。)

1
kubectl get pod
1
2
NAME                        READY   STATUS      RESTARTS   AGE
api-server-7d9f8b6c4-x2k9p 0/1 OOMKilled 3 2h

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
2
3
4
Limits:
memory: 2Gi
Requests:
memory: 512Mi

而容器里的 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
2
PID=$(pgrep -n mysqld)      # 换成你要看的进程名
cat /proc/$PID/oom_score
1
683

范围 0 到 1000,值越大越危险,683 意味着这个进程在当时那一刻相当危险。

计算方式简化成这样,看一眼就能明白内核的偏好。

1
2
oom_score ≈ 进程的物理内存占用(RSS + swap + 页表)× 1000 / 总内存
+ oom_score_adj

第二行那个 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
2
3
pgrep -a mysqld                              # 先确认只有一个、且是你要保护的那个
MPID=$(pgrep -o mysqld)
echo -500 | sudo tee /proc/$MPID/oom_score_adj

要落成配置就写进 systemd unit,机器重启也还在。

1
2
[Service]
OOMScoreAdjust=-900

常见的生产配置大概是这么分的,照抄可以,但要知道每一行为什么。

服务 建议值 理由
数据库(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
2
some avg10=12.34 avg60=8.71 avg300=3.02 total=2841932
full avg10=5.11 avg60=3.44 avg300=1.02 total=1123456

两个数的含义不一样,这是最容易被看错的地方。

  • 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
2
# 容器内
cat /proc/meminfo | head -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() 的实际流程比想象中啰嗦,杀一个不够还要再来一遍。

注意这是一个循环,杀一个不够,继续杀下一个,极端情况下 OOM Killer 会连续杀掉半台机器上的进程,你在 dmesg 里会看到连着好几行 Killed。

dmesg 里会留下记录,老内核(5.0 及以前)是两行,第一行带分数。

1
2
[  421.382] Out of memory: Kill process 1823 (java) score 812 or sacrifice child
[ 421.383] Killed process 1823 (java) total-vm:24563168kB, anon-rss:15234432kB, file-rss:0kB, shmem-rss:0kB

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
2
3
4
5
low 0
high 0
max 142
oom 3
oom_kill 3

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
2
char *p = malloc(8UL << 30);   // 8GB,可能成功
memset(p, 0, 8UL << 30); // 这里才真的分配物理页 → OOM

这是 Linux 上内存问题难排查的根本原因之一,错误发生在离成因很远的地方,栈上看到的是 memset 那一行,真正的成因可能是一小时前那次 malloc。

vm.overcommit_memory = 2 能让 malloc 阶段就失败,但需要仔细配 overcommit_ratio,配错会让 SSH 都登不上,生产上一般保持 0,接受这个赌注,把监控补齐。

swap 有和没有,是两种死法

1
2
swapon --show
free -h

有 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
2
523
0

具体数字取决于当时系统里有多少进程、各占多少内存,同一个进程跑两次都可能不一样。

主动制造一次主机 OOM(只在测试机上)

不要在生产上做这件事,也不要在有人用的共享机器上做,后果是别人跟着你一起断。

1
2
3
4
5
6
7
python3 -c "
import sys
chunks = []
while True:
chunks.append(bytearray(100 * 1024 * 1024))
print(f'{len(chunks) * 100} MB', flush=True)
" &

跑着跑着会被 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
2
echo -1000 | sudo tee /proc/$$/oom_score_adj
echo -1000 | sudo tee /proc/$(pgrep -n sshd)/oom_score_adj

然后再去制造内存压力,你的 ssh 会话大概率能活下来(虽然不保证)。我第一次做这个是在一台只有 2GB 的测试机上,信心满满地设了 -1000 去吃内存,结果 ssh 还是断了,因为整机只剩它一个能杀的候选,豁免解决不了没有候选这件事。

用完复原,这一步经常被人忘掉,然后这台测试机在半年里行为都很怪。

1
2
echo 0 | sudo tee /proc/$$/oom_score_adj
echo 0 | sudo tee /proc/$(pgrep -n sshd)/oom_score_adj

在 cgroup 里做,更安全

关键是别把你自己那个终端关进笼子,用 bash -c 起一个子进程进去,它死了你的 shell 还在。

1
2
3
4
5
sudo mkdir -p /sys/fs/cgroup/oomtest
echo 200M | sudo tee /sys/fs/cgroup/oomtest/memory.max

# 在子 shell 里进笼,只影响这个子进程
sudo bash -c 'echo $$ > /sys/fs/cgroup/oomtest/cgroup.procs; exec python3 -c "x = bytearray(300 * 1024 * 1024)"'
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
2
3
max 1
oom 1
oom_kill 1

清理,2>/dev/null || true 是为了在目录已经不在时不报错。

1
sudo rmdir /sys/fs/cgroup/oomtest 2>/dev/null || true

观察容器里的错位

1
2
docker run --rm -m 256m alpine sh -c \
'echo "容器看到的 MemTotal:"; head -1 /proc/meminfo; echo "cgroup 上限:"; cat /sys/fs/cgroup/memory.max 2>/dev/null'
1
2
3
4
容器看到的 MemTotal:
MemTotal: 16265472 kB
cgroup 上限:
268435456

/proc/meminfo 说 15GB,cgroup 说 256MB,两个数字差了 60 倍,程序该信哪个,信 cgroup 的。

OOM 之后去哪看现场

三条命令覆盖三个场景,主机、k8s、docker。

1
2
3
4
5
6
7
8
# 主机
sudo dmesg -T | grep -iE 'out of memory|killed process' | tail -10

# 容器(k8s)
kubectl describe pod <pod> | grep -A3 'Last State'

# 容器(docker)
docker inspect <container> --format '{{.State.OOMKilled}}'

被问得最多的那几句

被 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 的文件,内存为什么不涨。