Skip to content

필사 모드: OOM Killer 杀掉进程之后 — 从解读 dmesg 日志到区分 cgroup OOM

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 应用日志一直到最后都正常,进程却不见了

故障报告通常是这样来的。"服务挂了,但应用日志里没有任何错误。最后一行是一次正常的请求处理。"如果是容器环境,退出码还会显示成 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_fileinactive_file 分别是 214、302 页。这意味着页缓存只剩下 1MB 左右,也就是说缓存已经能丢的都丢了却还是不够。free 贴近 min 水位线说的是同一件事。反过来,如果 active_file 有几十 GB 却还是发生了 OOM,那就不是总量问题,而是某个特定 zone 或节点的问题。

Tasks state 表的单位是页。按 4KB 页面算,javarss 2884219 页大约是 11.0GiB。这和最后一行的 anon-rss:11536876kB 是吻合的。这张表是 OOM 那一刻的整体快照,所以当真正的元凶不是被杀掉的那个进程时,它是唯一能揭示真相的材料。请按 rss 列排序来读。

oom-kill: 这一行信息量最大,却最常被忽略constraintCONSTRAINT_NONE 就是系统整体内存不足,是 CONSTRAINT_MEMCG 就是 cgroup 上限,是 CONSTRAINT_CPUSETCONSTRAINT_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-997requests 和 limits 在所有资源上都相同
Burstable1000 减去 (1000 乘以 内存 request 除以 节点内存)16GiB 节点上 512Mi request 就是 969
BestEffort1000requests 和 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: 行的 constraintCONSTRAINT_NONE 还是 CONSTRAINT_MEMCG
  • memory: 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 KillerRedis 快照 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。BGSAVEfork 创建子进程,由于写时复制,实际多用的内存要少得多,但内核的启发式可能把它看成和父进程一样大的提交。所以 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

MemoryHighMemoryMax 的区别很重要。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 监视 MemAvailableSwapFree 的比例,掉到阈值以下就发 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.confDefaultMemoryPressureDurationSec (默认 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 或者减少应用的用量才是对的。

然后是剩下的规则。

  1. 不要只看退出码 137 就断定是 OOM。用 reason 字段或者内核日志来确认。
  2. 元凶要用 rss 找,而不是 total_vm。虚拟内存不进分数。
  3. 老老实实写内存 request。Burstable 的 oom_score_adj 由这个值决定,而它就是节点承压时的生存排位。
  4. 不要只设 MemoryMax,用 MemoryHigh 造出一个缓冲区间。死之前先变慢,才能给你诊断的时间。
  5. 告警设在 memory.pressurefull 上,而不是 oom_kill 计数器上。需要的是事前预警,而不是事后通知。

현재 단락 (1/142)

故障报告通常是这样来的。"服务挂了,但应用日志里没有任何错误。最后一行是一次正常的请求处理。"如果是容器环境,退出码还会显示成 137。

작성 글자: 0원문 글자: 11,987작성 단락: 0/142