订阅更新

OOM Killer 为什么总是杀错进程——oom_score / overcommit / cgroup 联合解读

Linux手册 第 19 题 约 15 分钟读完

本文目录
  1. 先说结论
  2. OOM killer 到底是怎么选人的
  3. 怎么让关键服务'免死'
  4. overcommit:Linux 为什么能'承诺不可能兑现的内存'
  5. 场景还原:那次 Redis 被误杀的完整复盘
  6. 容器/cgroup 的 OOM 是不一样的
  7. 面试高频追问
  8. 实战踩坑:用 cgroup 做'临时任务隔离'
  9. 总结

先说结论

第一条:OOM killer 的选杀目标是'oom_score 最高'的进程,不是内存占用最大的——oom_score 由内存 + oom_score_adj 调整值计算,后者可以人为调整。

第二条:关键服务必须设置 oom_score_adj = -500 到 -1000,让 OOM killer 优先杀别的;非关键服务可以设 +500 让它先被杀。

第三条:容器环境的 OOM 更复杂——cgroup 有自己独立的 OOM 触发机制,容器内 OOM 不一定影响宿主机,但 cgroup OOM 也会选杀容器内进程。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

某元宇宙社交应用出过一次事故:一台混部机器上跑着 Redis 主缓存 + 监控采集器 + 一个临时的数据导出脚本,某天凌晨机器内存飙满,OOM killer 启动——杀的不是那个突然吃 8G 内存的导出脚本,而是把生产 Redis 干掉了。Redis 一挂,缓存雪崩,下游全线超时,业务半小时。

这就是 OOM killer 最经典的'杀错人'场景。很多人以为 OOM killer 会杀'吃内存最多的',这是错的——它杀的是'oom_score 最高的',而 oom_score 的算法复杂得超乎想象。面试官问这题能迅速看出你有没有真正处理过生产 OOM。

OOM killer 到底是怎么选人的

网上 90% 的资料把 oom_score 讲得一知半解,真正的公式看内核源码 mm/oom_kill.c 的 oom_badness():

# 公式(简化):
# points = (RSS + swap_pages + PTE_pages) * 1000 / totalpages
# points += oom_score_adj * 10
# points 范围:0 - 1000
 
# 解读:
# - 基础分 = 进程占物理内存的比例 * 1000
#   占 10% 内存 = 100 分
#   占 50% 内存 = 500 分
# - oom_score_adj 直接加到分数里(每 1 分 adj 加 10 分 score)
# - adj 范围 -1000 到 +1000
# - adj = -1000 表示'绝对不杀',无论内存占用多少
# - adj = +1000 表示'优先杀'
 
# 看当前所有进程的 oom_score
for pid in $(ls /proc/ | grep -E '^[0-9]+$'); do
  if [ -r /proc/$pid/oom_score ]; then
    score=$(cat /proc/$pid/oom_score)
    adj=$(cat /proc/$pid/oom_score_adj)
    comm=$(cat /proc/$pid/comm 2>/dev/null)
    echo "$score $adj $pid $comm"
  fi
done | sort -rn | head -20
 
# 输出示例:
# 856  0     12345 redis-server    ← 吃 85% 内存,score 857
# 120  500   23456 export_script   ← 吃 12% 内存,adj=500 加 5000/10=500 → 总分 620
# 50   -500  34567 kafka-server    ← 吃 5% 内存,adj=-500 → 总分 0

关键认知颠覆:注意上面那个例子:redis-server 占 85% 内存 score 856,导出脚本 adj=500 把 score 拉到 620。按规则 OOM killer 杀分数最高的 redis——这就是'杀错人'的典型。正确做法是给 redis 设 adj=-500,把它从 856 拉到 356,让导出脚本的 620 成为最高。

怎么让关键服务'免死'

# 方法 1:直接写 /proc/<pid>/oom_score_adj
echo -500 > /proc/12345/oom_score_adj
# 临时生效,进程重启丢失
 
# 方法 2:应用启动脚本里 self-protect
# 在脚本里写:
echo -500 > /proc/$$/oom_score_adj  # 自己给自己设
exec redis-server /etc/redis.conf
 
# 方法 3(推荐):systemd unit 里 OOMScoreAdjust
# /etc/systemd/system/redis.service
# [Service]
# OOMScoreAdjust=-500
systemctl daemon-reload && systemctl restart redis
 
# 方法 4:docker/容器启动时指定
docker run --oom-score-adj=-500 redis
# 或 k8s 通过 podSpec 的 QoS 间接控制:
# Guaranteed QoS → adj=-998(几乎免死)
# Burstable QoS → adj 根据 limit/requests 计算
# BestEffort QoS → adj=1000(最优先被杀)

k8s 用户必看:k8s 的 QoS 级别直接决定 OOM 优先级。生产上关键业务一定要配 Guaranteed QoS(requests==limits),让 pod 的 oom_score_adj 拉到 -998;不重要的 cron job 可以留 BestEffort,让它们先被杀。这比运维手工去 echo 生效得多。

overcommit:Linux 为什么能'承诺不可能兑现的内存'

Linux 默认允许进程 malloc 比物理内存还多的内存——这叫 overcommit。它是一个'聪明'的优化:大多数进程 malloc 了就不一定真用,所以内核乐观地批准。但这也意味着'发生 OOM 时'这件事就变得几乎不可预测。

# overcommit_memory 三种模式
cat /proc/sys/vm/overcommit_memory
 
# 0 = 启发式(默认):小分配随便给,大分配粗略判断
# 1 = 总是允许:malloc 100G 都给,OOM 时再杀人
#     这是 Redis/MongoDB 官方推荐
#     原因:fork() 做 BGSAVE 时 COW 需要全量地址空间承诺
# 2 = 严格:total_committed <= swap + RAM * overcommit_ratio
#     不允许超卖,malloc 直接失败
#     缺点:fork 大进程会失败('fork failed: Cannot allocate memory')
 
# 看当前 committed
grep -E 'Committed|Commit' /proc/meminfo
# CommitLimit:     24576000 kB
# Committed_AS:    13456000 kB   ← 当前已承诺给进程的总额
 
# 何时调 overcommit_memory=1?
# - Redis 机器(官方建议)
# - 需要 fork 大进程的场景(MongoDB、某些 Python worker)
# - 希望 OOM killer 策略明确的场景
 
# 何时调 overcommit_memory=2?
# - 关键基础服务,想让应用在申请时就失败而不是事后 OOM
# - 内存严格受控的场景(但现代容器已经不太需要)

Redis 的 fork 坑:Redis BGSAVE/AOF rewrite 会 fork 一个子进程。fork 时 Linux 用 COW(写时复制),但 overcommit_memory=0 启发式模式下,内核可能会拒绝 fork,因为它看到'父进程虚拟内存很大,万一全部写怎么办'。这就是为什么 Redis 启动时会警告 WARNING overcommit_memory is set to 0! Background save may fail under low memory condition。解决就是改成 1。

场景还原:那次 Redis 被误杀的完整复盘

某元宇宙社交应用那台机器的复盘:

# 事后 dmesg 看 OOM 记录
dmesg -T | grep -A 20 'oom-kill'
# [Sat Jun 10 02:15:33 2023] oom-kill: invoked kill
# [Sat Jun 10 02:15:33 2023] [  PID]  uid tgid total_vm  rss  oom_score_adj
# [Sat Jun 10 02:15:33 2023] [12345]    0 12345  5242880  4190000  0   ← Redis, score 高
# [Sat Jun 10 02:15:33 2023] [23456]    0 23456  2097152   90000  0   ← 导出脚本
# [Sat Jun 10 02:15:33 2023] Out of memory: Killed process 12345 (redis-server)
#                                                              ↑ 生产 Redis 被杀
 
# 复盘:
# - 导出脚本是临时部署,adj 没设,用默认 0
# - Redis 是用 systemd 启动但 unit 没写 OOMScoreAdjust,adj 也是 0
# - 两者 adj 相同,OOM killer 纯按 RSS 选——Redis RSS 4G,导出脚本 RSS 90M
# - Redis 毫无悬念被选中
 
# 修复 1:Redis unit 加 OOMScoreAdjust
mkdir -p /etc/systemd/system/redis.service.d/
cat > /etc/systemd/system/redis.service.d/oom.conf <<EOF
[Service]
OOMScoreAdjust=-900
EOF
systemctl daemon-reload && systemctl restart redis
 
# 修复 2:临时脚本统一用 systemd-run 包装
systemd-run --unit=export-job --property=OOMScoreAdjust=500 \
  --property=MemoryMax=2G /data/scripts/export.sh
# OOMScoreAdjust=500 让它成为 OOM 首选目标
# MemoryMax=2G 用 cgroup 硬性限制,超过直接 kill
 
# 修复 3:彻底不再混部
# 关键 Redis 迁出共享机器,独占物理机或独占容器

关键教训:'临时任务'是生产杀手。那次事故后我们立了一条规则:任何临时部署的脚本必须用 systemd-run 包装,必带 MemoryMax 和 OOMScoreAdjust=500。这样哪怕它吃爆内存,也只会杀自己不会连累生产。规则上线一年,类似事故 0 起。

容器/cgroup 的 OOM 是不一样的

容器环境的 OOM 有两层:cgroup OOM 和 host OOM。它们的触发和处理逻辑不一样:

类型触发条件影响范围dmesg 特征
host OOM物理内存+swap 全满宿主机所有进程候选Out of memory: Killed process X
cgroup OOM超出 memory.limit仅 cgroup 内进程Memory cgroup out of memory
k8s OOMKilledcontainer memory limitPod 内 containerkubectl get pod 显示 OOMKilled
swap OOMswap 耗尽但 RAM 有当前申请进程page allocation failure

bash - 容器 OOM 排查

# k8s Pod 经常看到 OOMKilled,排查链路:
kubectl describe pod <pod> | grep -i oom
# Last State: Terminated   Reason: OOMKilled
# Exit Code: 137   ← 137 = 128 + 9 = SIGKILL
 
# 进入节点查 cgroup 统计(比 pod 描述更细)
cat /sys/fs/cgroup/memory/kubepods/burstable/pod<uid>/memory.max_usage_in_bytes
cat /sys/fs/cgroup/memory/kubepods/burstable/pod<uid>/memory.oom_control
# oom_kill 1   ← 发生过 OOM
 
# 比 memory limit 还大是常见现象,因为 OOM 是瞬时峰值
# 解决:提高 limit 或者修复应用内存泄漏

面试高频追问

追问一:dmesg 看到 OOM 时怎么快速定位谁触发的?

dmesg -T | grep -A 30 'oom-kill' 会打印 OOM 触发那一刻的完整进程表——每个进程的 pid、tgid、virt/rss/oom_score_adj。第一步看 Out of memory: Killed process X 被杀的是谁;第二步往上翻,看触发者 oom-kill: invoked 后面的 gfp_mask 和 constraint,判断是 host 还是 cgroup;第三步看进程表里 oom_score_adj 分布,找出 adj 明显偏大的进程(可能是该杀的没设、该保护的没保护)。整个过程 5 分钟能讲清事故全貌。

追问二:如何避免生产服务被 OOM killer 杀?

四层防护:第一层是容量规划,物理内存留 20% buffer,不要把机器跑到 95%+;第二层是 sysctl 限制 overcommit + swappiness=1,让问题在可控时暴露;第三层是关键服务 systemd unit 写 OOMScoreAdjust=-500 到 -900;第四层是混部隔离,用 cgroup memory.max 给非关键进程硬性配额,超过直接 cgroup OOM 杀自己。这四层都做到,生产 OOM 事故基本可以消灭。

追问三:OOM killer 一定会触发吗?有没有情况是内存耗尽但没触发 OOM?

有两种。一是 overcommit_memory=2 时内存分配会直接失败(ENOMEM),malloc 返回 NULL,进程自己决定崩不崩——可能是 core dump 也可能是优雅退出,根本走不到 OOM killer。二是'越界慢死',内存没到物理极限但 GC 或 Allocator 自己卡住,进程响应超时被上层杀掉(k8s liveness probe failed、Flink task manager heartbeat lost),这种不是 OOM 但结果像 OOM。面试时能说出这两种情况,面试官就知道你真的看过各种内存相关事故。

实战踩坑:用 cgroup 做'临时任务隔离'

某元宇宙社交应用 Redis 事故后的长期方案是给所有临时脚本做 cgroup 隔离。我写了一个包装器,所有运维临时跑的脚本都必须通过它启动:

bash - safe-run 包装器

#!/bin/bash
# /usr/local/bin/safe-run
# 用法:safe-run <name> <memory_limit> <cmd...>
set -euo pipefail
 
NAME=$1; shift
MEM=$1; shift
 
# systemd-run 创建一个瞬时单元,跑完自动清理
systemd-run --unit="safe-$NAME-$$" \
  --property="MemoryMax=$MEM" \
  --property="MemorySwapMax=0" \
  --property="OOMScoreAdjust=500" \
  --property="CPUQuota=200%" \
  --property="Nice=10" \
  --collect \
  --wait \
  --pty \
  "$@"
 
# 用法示例:
# safe-run export 2G /data/scripts/export_data.sh
# safe-run analyze 4G python /data/scripts/analyze.py
 
# 效果:
# - MemoryMax 硬限制,超过立即 cgroup OOM 杀自己
# - MemorySwapMax=0 禁用 swap,避免拖慢整机
# - OOMScoreAdjust=500 host 级 OOM 时也优先杀自己
# - CPUQuota=200% 最多占 2 核
# - Nice=10 CPU 调度优先级降低
# - --collect 跑完自动清理 transient unit

长期收益:这个 safe-run 工具上线一年后,运维组因为临时任务导致的生产事故从每月 1-2 次降到 0。更重要的是'临时任务'这件事变得安全——新同事可以放心地在生产机上跑脚本,因为 cgroup 会兜底。这就是'基础设施即文化'的一个真实例子。

总结

OOM killer 不是随机杀人的暴徒,它有一套严格的打分机制——oom_score = RSS 比例 + oom_score_adj * 10。想让关键服务不被误杀,你必须主动设置 adj,用 systemd 的 OOMScoreAdjust 或容器的 --oom-score-adj。配合 cgroup memory.max 隔离临时任务,生产 OOM 事故才能真正可控。面试时把 oom_score 算法、systemd 配置、cgroup 隔离这三件事串起来讲,就证明你踩过、也解决过真实的 OOM 问题。

下一篇预告:第 20 篇是系列总结——20 道题的回顾、难度分级、三板斧提炼、面试通关路线图。

你被 OOM killer 杀过最尴尬的一次是什么服务?评论区聊聊。

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器