- Published on
OOM Killer 杀掉进程之后 — 从解读 dmesg 日志到区分 cgroup OOM
- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 应用日志一直到最后都正常,进程却不见了
故障报告通常是这样来的。"服务挂了,但应用日志里没有任何错误。最后一行是一次正常的请求处理。"如果是容器环境,退出码还会显示成 137。
应用日志里什么都没有,这正是关键线索。SIGKILL 无法捕获,处理函数也不会运行。进程在执行某一条指令的途中就那样蒸发了。所以留下痕迹的不是应用,而是内核。
sudo dmesg -T | grep -iE 'killed process|out of memory' | tail -5
# [Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
只要这一行出现,就可以确定了。如果没有出现,那大概率不是 OOM。本文从找到这一行开始,讲到为什么偏偏是那个进程,再讲到要改什么才能不再复发。
怎么找到 OOM 日志,以及怎么逐行读它
先说怎么找。dmesg 环形缓冲区是有限的,时间一长就会被挤出去。要跨重启查询就用 journald。
# 在当前启动会话的内核消息里搜索
sudo journalctl -k --since "2 hours ago" --grep "Out of memory"
# 上一次启动会话 (如果 OOM 之后重启过)
sudo journalctl -k -b -1 --grep "oom"
# 不只是内核日志,而是全部 (包含 systemd-oomd、容器运行时)
sudo journalctl --since "2026-07-25 03:00" --until "2026-07-25 03:30" | grep -iE 'oom|killed'
如果 journalctl -k 里没有,dmesg 里也没有,那可能是 Storage=volatile 设置让日志在重启时消失了。这种情况下,/etc/systemd/journald.conf 里的 Storage=persistent 就成了防止复发清单上的第一项。
现在来读整份报告。内核打印的 OOM 报告由四个部分组成。
[Sat Jul 25 03:14:22 2026] java invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
[Sat Jul 25 03:14:22 2026] CPU: 3 PID: 21874 Comm: java Not tainted 6.8.0-45-generic #45-Ubuntu
[Sat Jul 25 03:14:22 2026] Call Trace:
[Sat Jul 25 03:14:22 2026] dump_stack_lvl+0x48/0x70
[Sat Jul 25 03:14:22 2026] dump_header+0x4f/0x240
[Sat Jul 25 03:14:22 2026] oom_kill_process+0x10d/0x1c0
[Sat Jul 25 03:14:22 2026] out_of_memory+0x246/0x580
[Sat Jul 25 03:14:22 2026] __alloc_pages_slowpath+0xb6c/0xf70
[Sat Jul 25 03:14:22 2026] Mem-Info:
[Sat Jul 25 03:14:22 2026] active_anon:3812044 inactive_anon:118233 isolated_anon:0
[Sat Jul 25 03:14:22 2026] active_file:214 inactive_file:302 unevictable:0
[Sat Jul 25 03:14:22 2026] slab_reclaimable:38210 slab_unreclaimable:91043
[Sat Jul 25 03:14:22 2026] free:41283 free_pcp:812 free_cma:0
[Sat Jul 25 03:14:22 2026] Node 0 Normal free:165132kB min:162044kB low:202552kB high:243060kB
[Sat Jul 25 03:14:22 2026] Tasks state (memory values in pages):
[Sat Jul 25 03:14:22 2026] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[Sat Jul 25 03:14:22 2026] [ 1042] 0 1042 28901 1204 126976 0 0 systemd-journal
[Sat Jul 25 03:14:22 2026] [ 1863] 0 1863 4521 612 73728 0 -1000 sshd
[Sat Jul 25 03:14:22 2026] [ 21874] 1000 21874 3145728 2884219 23461888 0 0 java
[Sat Jul 25 03:14:22 2026] [ 22910] 999 22910 612344 498231 4145152 0 200 postgres
[Sat Jul 25 03:14:22 2026] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/app.service,task=java,pid=21874,uid=1000
[Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
每个部分里要提取的东西如下。
第一行的 invoked oom-killer 指的是触发 OOM 的进程。它可能和被杀掉的进程不是同一个。请求分配却失败的那一方是触发者,被杀的那一方由分数决定。order=0 表示请求的是一张 4KB 页面,而 order 值很大 (3 以上) 说明没能拿到物理上连续的大块,所以问题可能是碎片化而不是总量。
Mem-Info 块显示可回收内存是不是真的已经没有了。上面的例子里 active_file 和 inactive_file 分别是 214、302 页。这意味着页缓存只剩下 1MB 左右,也就是说缓存已经能丢的都丢了却还是不够。free 贴近 min 水位线说的是同一件事。反过来,如果 active_file 有几十 GB 却还是发生了 OOM,那就不是总量问题,而是某个特定 zone 或节点的问题。
Tasks state 表的单位是页。按 4KB 页面算,java 的 rss 2884219 页大约是 11.0GiB。这和最后一行的 anon-rss:11536876kB 是吻合的。这张表是 OOM 那一刻的整体快照,所以当真正的元凶不是被杀掉的那个进程时,它是唯一能揭示真相的材料。请按 rss 列排序来读。
oom-kill: 这一行信息量最大,却最常被忽略。constraint 是 CONSTRAINT_NONE 就是系统整体内存不足,是 CONSTRAINT_MEMCG 就是 cgroup 上限,是 CONSTRAINT_CPUSET 或 CONSTRAINT_MEMORY_POLICY 就是 NUMA 节点约束。global_oom 这个 token 和 task_memcg 路径也在这里。
最后一行里三种 RSS 的区分很有用。anon-rss 是堆和栈,file-rss 是映射的文件,shmem-rss 是共享内存和 tmpfs。如果 shmem-rss 很大,就要怀疑 /dev/shm 或者 tmpfs 挂载。写进 tmpfs 的数据看起来像文件,但它占用内存,而且不会被换出到磁盘。
决定谁被杀的计算 — badness 和 oom_score_adj
内核给每个候选打分,杀掉分数最高的那个。计算是这样的。
points = rss + swapents + (pgtables_bytes / PAGE_SIZE) # 单位: 页
adj = oom_score_adj * (totalpages / 1000)
points = points + adj
马上能推出三件事。
第一,分数的基准是 实际驻留内存 RSS,而不是虚拟内存 total_vm。用 mmap 只预留 64GB、实际只用 200MB 的进程根本不是候选。看着 top 里的 VIRT 去指认元凶,几乎总是错的。
第二,oom_score_adj 的范围是 -1000 到 1000,它是 以总内存的千分之一为单位的加减分。在一台 64GiB 的机器上,oom_score_adj=200 会加上大约相当于 12.8GiB 的分数。哪怕实际 RSS 只有 2GB 的进程,也能靠这一项修正超过一个 11GB 的 JVM。上面日志里的 postgres 虽然是 oom_score_adj=200,但 java 的 RSS 实在太大,所以选中的是 java。
第三,oom_score_adj=-1000 很特殊。它会被完全排除在分数计算之外,永远不会被选中。这就是发行版给 sshd 设这个值的原因。你可以在日志的 sshd 那一行确认。
当前的值这样查看。
# 把每个进程的分数和修正值一起排序
for p in /proc/[0-9]*; do
pid=${p#/proc/}
[ -r "$p/oom_score" ] || continue
printf '%8s %7s %6s %s\n' \
"$(cat "$p/oom_score" 2>/dev/null)" \
"$(cat "$p/oom_score_adj" 2>/dev/null)" \
"$pid" \
"$(tr -d '\0' < "$p/comm" 2>/dev/null)"
done | sort -rn | head -8
# 2884219 0 21874 java
# 501431 200 22910 postgres
# 38210 0 3311 containerd
# 1204 0 1042 systemd-journal
# 0 -1000 1863 sshd
oom_score 的量纲随内核版本而不同。较新的内核是基于页数的,老内核则归一化到 0 到 1000。不要拿绝对值当阈值,只把它用于进程之间的相对比较。
改这个值有三种办法。
# 1) 直接改运行中的进程
echo -500 | sudo tee /proc/21874/oom_score_adj
# 2) 写进 systemd 单元 (重启后也保持)
sudo systemctl edit myapp.service
# [Service]
# OOMScoreAdjust=-500
# 3) 在启动进程的时候
sudo choom -n -500 -- /usr/bin/myapp
在容器环境里,这个值由运行时替你决定。Kubernetes 按 QoS 类别做如下设置。
| QoS 类别 | oom_score_adj | 示例 |
|---|---|---|
| Guaranteed | -997 | requests 和 limits 在所有资源上都相同 |
| Burstable | 1000 减去 (1000 乘以 内存 request 除以 节点内存) | 16GiB 节点上 512Mi request 就是 969 |
| BestEffort | 1000 | requests 和 limits 什么都不指定 |
Burstable 的结果值会被截断在 -996 和 999 之间。从这里能得出一个实务上很重要的结论。内存 request 设得越大,在节点全局 OOM 里存活下来的概率就越高。request 写得小、实际用得多的 Pod,在节点承压的那一刻会第一个死。request 不是用来糊弄调度器的数字,而是生存排位。
容器 OOM 和系统全局 OOM 是两件不同的事
这是实务中最常被混淆的地方。两者都会产生退出码 137,都会在 dmesg 里留下 OOM 日志,但原因和应对完全不同。
cgroup OOM 的日志长这样。
[Sat Jul 25 04:02:11 2026] node invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=969
[Sat Jul 25 04:02:11 2026] memory: usage 524288kB, limit 524288kB, failcnt 1842
[Sat Jul 25 04:02:11 2026] swap: usage 0kB, limit 0kB, failcnt 0
[Sat Jul 25 04:02:11 2026] Memory cgroup stats for /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cri-containerd-3f7b91ac.scope:
[Sat Jul 25 04:02:11 2026] anon 521142272
[Sat Jul 25 04:02:11 2026] file 1048576
[Sat Jul 25 04:02:11 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-3f7b91ac.scope,mems_allowed=0,oom_memcg=/kubepods.slice/...,task_memcg=/kubepods.slice/...,task=node,pid=33127,uid=1000
[Sat Jul 25 04:02:11 2026] Memory cgroup out of memory: Killed process 33127 (node) total-vm:1284736kB, anon-rss:508924kB, file-rss:36104kB, shmem-rss:0kB, UID:1000 pgtables:2048kB oom_score_adj:969
区分标准有三个,任何一个单独成立都足以确定。
- 最后一行是
Out of memory:还是Memory cgroup out of memory: oom-kill:行的constraint是CONSTRAINT_NONE还是CONSTRAINT_MEMCGmemory: usage / limit / failcnt这一行在不在。这一行只在 cgroup OOM 时出现
如果拿不到日志,用计数器也能确认。cgroup v2 会把事件累加起来。
# 先找到 Pod 的 cgroup 路径
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/memory.events
# low 0
# high 0
# max 1842
# oom 7
# oom_kill 3
max 是撞到上限并触发回收的次数,oom 是回收失败进入 OOM 路径的次数,oom_kill 是实际杀掉的次数。如果只有 max 很大而 oom_kill 是 0,就说明它正在上限附近靠不断回收硬撑。性能已经在恶化,但还没死。抓住这个状态,比事后验尸要好得多。
这里要指出第二个常见误解。退出码 137 并不是 OOM 的证据。137 是 128 加 9,只表示进程是被 SIGKILL 杀掉的。liveness probe 失败后过了宽限期被杀是 137,用 kubectl delete 删掉是 137,在节点关机过程中被杀也是 137。要确定,就得看容器状态的 reason 字段,或者上面那段内核日志。
kubectl get pod api-7d9f8-x2k4l -o jsonpath='{.status.containerStatuses[0].lastState.terminated}' | python3 -m json.tool
# {
# "containerID": "containerd://3f7b91ac...",
# "exitCode": 137,
# "finishedAt": "2026-07-25T04:02:11Z",
# "reason": "OOMKilled",
# "startedAt": "2026-07-25T03:11:04Z"
# }
反方向也有陷阱。节点全局 OOM 杀掉容器里的进程时,那个 Pod 同样会拿到 137。这时 reason 显示为 OOMKilled,但内存 limit 可能压根没有超。把 limit 调大也会复发。如果是 constraint=CONSTRAINT_NONE,答案是加大节点本身的内存,或者把别的 Pod 赶走。
overcommit_memory 0/1/2 实际改变了什么
三个值的差别在于 失败在什么时候、以什么形式出现。它们并不改变内存的总量。
| 值 | 名称 | 分配时的行为 | 失败形式 | 使用场景 |
|---|---|---|---|---|
| 0 | 启发式 | 只拒绝明显过大的单次分配 | 主要是 OOM Killer | 默认值。大多数工作负载 |
| 1 | 总是允许 | 不做检查 | 总是 OOM Killer | Redis 快照 fork、大型稀疏映射 |
| 2 | 严格 | 超过 CommitLimit 就拒绝 | malloc 返回 ENOMEM | 必须禁止 OOM Kill 本身的时候 |
模式 2 的上限线是这样算出来的。
sysctl vm.overcommit_memory vm.overcommit_ratio
# vm.overcommit_memory = 0
# vm.overcommit_ratio = 50
grep -E 'CommitLimit|Committed_AS|MemTotal|SwapTotal' /proc/meminfo
# MemTotal: 65806232 kB
# SwapTotal: 0 kB
# CommitLimit: 32903116 kB
# Committed_AS: 48192044 kB
CommitLimit 是 swap 全部加上物理内存的 overcommit_ratio 百分比。上面的例子里没有 swap,所以是 64GiB 的 50%,也就是 32GiB。Committed_AS 已经是 46GiB 了,所以现在改成 vm.overcommit_memory=2,新的分配会立刻开始失败。在没有 swap 的机器上照着默认比例打开模式 2,是典型的自伤。要用的话,得把 vm.overcommit_ratio 提到 90 以上,或者用 vm.overcommit_kbytes 指定绝对值。
需要模式 1 的代表性案例是 Redis。BGSAVE 用 fork 创建子进程,由于写时复制,实际多用的内存要少得多,但内核的启发式可能把它看成和父进程一样大的提交。所以 Redis 启动时会打印一条建议 vm.overcommit_memory=1 的警告。
# 永久生效
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/60-overcommit.conf
sudo sysctl --system
这里要注意一点。模式 1 会 更频繁地把 OOM Killer 叫出来。它在分配的时候不拒绝,所以真正去碰页面的时候才爆。如果应用被设计成能优雅处理 malloc 失败,那模式 2 更好;如果不是 (大多数情况如此),保持模式 0 再用 cgroup 上限来隔离更现实。
"加了 swap 就不会 OOM"这个误解
这是流传最广的误解,而且只对了一半。swap 不是阻止 OOM,而是推迟 OOM。而且在推迟的这段时间里,系统所处的状态常常比当场死掉更糟。
全局 OOM 不是"内存变成 0 就"触发的,而是"尝试回收 (reclaim) 却没有任何进展就"触发的。有 swap 就可以把匿名页换出去,于是回收产生了进展,OOM 也就被推后了。问题是在这个区间里,工作集会在 swap 之间来回搬运,也就是抖动 (thrashing)。进程还活着,却什么都干不成,健康检查超时,连 SSH 登录都要好几分钟。
抖动这样确认。
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 1 18 8291840 92104 1024 38912 41216 38912 42104 39204 8210 19204 2 9 4 85 0
si (换入) 和 so (换出) 同时达到每秒几十 MB,就是抖动。只有一边大可能是正常的页面换出,所以要把两个方向放在一起看。用 PSI 会更清楚。
cat /proc/pressure/memory
# some avg10=94.12 avg60=88.03 avg300=61.44 total=182937461283
# full avg10=71.88 avg60=66.21 avg300=44.02 total=91827364512
full 是 71%,意思是最近 10 秒里有 7 秒以上 所有任务都因为内存而停住了。CPU 看上去是空闲的,负载可能也不高,但系统实际上处于停摆状态。
再补两点。在 cgroup 里 memory.swap.max 是单独存在的,这个值如果是 0,那么无论宿主机上有多少 swap,这个组都用不上 swap,会直接走向 OOM。Kubernetes 长期以来默认禁用节点 swap,最近才加进有限的支持,所以在容器工作负载上,"有 swap 所以没事"这个假设大体上是不成立的。
另外 zram 和 zswap 性质不同。它们是在内存里压缩而不是写到磁盘,所以延迟小得多,在压缩率好的工作负载上有实质性的内存扩展效果。不过压缩本身要吃 CPU,对压不动的数据也没有效果。
防止复发 — 在内核动手之前介入
内核 OOM Killer 是最后的手段,而最后的手段总是来得太晚。实务上的目标,是在那之前的阶段可预测地介入。
第一,用 cgroup 明确写出上限。全局 OOM 很难预测哪个进程会死,而 cgroup OOM 的范围被限定在那个组里。如果是 systemd 服务,就直接写进单元文件。
sudo systemctl edit myapp.service
# [Service]
# MemoryHigh=6G
# MemoryMax=8G
# OOMPolicy=stop
sudo systemctl daemon-reload
sudo systemctl restart myapp
systemctl show -p MemoryHigh -p MemoryMax -p OOMPolicy myapp.service
# MemoryHigh=6442450944
# MemoryMax=8589934592
# OOMPolicy=stop
MemoryHigh 和 MemoryMax 的区别很重要。MemoryMax (cgroup 的 memory.max) 一超过就杀。MemoryHigh (memory.high) 不杀,而是 让分配的一方睡眠。惩罚性睡眠会按超出量成比例地施加,所以应用会急剧变慢,但还活着。两个值一起设,就形成了"6GB 开始刹车、8GB 终止"的缓冲区间,在这个区间里收到告警就能为人工介入争取时间。
第二,直接对压力本身设告警。比起死后才去数的计数器,死之前的压力更有用。
# 按 cgroup 统计内存压力前 5 名
for f in /sys/fs/cgroup/system.slice/*/memory.pressure; do
v=$(awk '/^full/ {print $3}' "$f" 2>/dev/null | cut -d= -f2)
[ -n "$v" ] && printf '%7s %s\n' "$v" "${f%/memory.pressure}"
done | sort -rn | head -5
# 18.44 /sys/fs/cgroup/system.slice/myapp.service
# 0.31 /sys/fs/cgroup/system.slice/containerd.service
如果用 Prometheus,就把 node_exporter 的 node_vmstat_oom_kill 增量和 cAdvisor 的 container_oom_events_total 放在一起看。前者是节点全局的,后者是容器粒度的,所以前面区分过的两类事件会分别落在两个不同的指标上。
第三,考虑用户空间的 OOM 管理器。内核会一直等到回收彻底失败,而用户空间工具自己定阈值。
earlyoom监视MemAvailable和SwapFree的比例,掉到阈值以下就发 SIGTERM,再掉就发 SIGKILL。配置简单,在任何发行版上都能跑。systemd-oomd基于 PSI。它按 cgroup 粒度看memory.pressure,超过阈值持续一段时间,就清理那个 slice 里压力最大的那个。因为用的是压力这个指标,所以比按绝对容量判断的误报要少。
# earlyoom: 可用内存 5%、swap 5% 以下时介入
sudo systemctl edit earlyoom.service
# [Service]
# Environment=EARLYOOM_ARGS=-m 5 -s 5 --avoid '(^|/)(sshd|systemd)$' --prefer '(^|/)java$'
# systemd-oomd: 按 slice 设置压力阈值
sudo systemctl edit myapp.service
# [Service]
# ManagedOOMMemoryPressure=kill
# ManagedOOMMemoryPressureLimit=60%
systemd-oomd 必须在 /etc/systemd/oomd.conf 的 DefaultMemoryPressureDurationSec (默认 30 秒) 期间持续超过阈值才会动作,所以它不会对瞬时尖峰做出反应。
第四,重新测量实际使用量。把上限调大不是根因解决。如果是 JVM,看看 -XX:MaxRAMPercentage 有没有识别到容器上限;如果是 Go,看看 GOMEMLIMIT 有没有设置;再看看是不是原生堆碎片化或者 glibc arena 的问题。smem 或 /proc/PID/smaps_rollup 会告诉你 RSS 的构成。
sudo cat /proc/21874/smaps_rollup
# 7f2c00000000-7ffd1a3e5000 ---p 00000000 00:00 0 [rollup]
# Rss: 11536876 kB
# Pss: 11498210 kB
# Anonymous: 11402344 kB
# AnonHugePages: 2097152 kB
# Shared_Clean: 34120 kB
# Private_Dirty: 11402344 kB
Anonymous 占了 RSS 的大部分就是应用堆,Shared_Clean 很大就是共享库,那么实际成本更接近 Pss 那一侧。
结语 — 日志里 constraint 那一行决定了应对方式
如果只记一件事,就记这个。OOM 调试的第一个分岔口是 全局还是 cgroup,答案已经写在 oom-kill: 行的 constraint 字段里了。
- 是
CONSTRAINT_NONE就说明节点内存不够。把 Pod 的 limit 调大也会复发。要么加大节点,要么降低密度。 - 是
CONSTRAINT_MEMCG就说明是那个容器的上限问题。节点是好的。调大 limit 或者减少应用的用量才是对的。
然后是剩下的规则。
- 不要只看退出码 137 就断定是 OOM。用
reason字段或者内核日志来确认。 - 元凶要用
rss找,而不是total_vm。虚拟内存不进分数。 - 老老实实写内存 request。Burstable 的
oom_score_adj由这个值决定,而它就是节点承压时的生存排位。 - 不要只设
MemoryMax,用MemoryHigh造出一个缓冲区间。死之前先变慢,才能给你诊断的时间。 - 告警设在
memory.pressure的full上,而不是oom_kill计数器上。需要的是事前预警,而不是事后通知。