OOM killer 是什么?进程被杀怎么排查与预防
OOM killer 是 Linux 内核在内存彻底耗尽、怎么回收都腾不出空间时的最后手段:给每个进程打分,向分数最高的那个发 SIGKILL,让系统活下来。进程莫名消失,先用 dmesg 查有没有 Killed process;确认是 OOM 后,短期用 oom_score_adj 保护关键进程,长期要把各服务的内存上限加起来算清楚,再配适量 swap。
OOM killer(Out Of Memory killer)是 Linux 内核的内存耗尽处理机制:物理内存和 swap 都用完、内核回收缓存和换页之后仍然分配不出内存时,它给每个进程算一个分数,杀掉分数最高的那个,释放内存让系统继续运行。被选中的进程收到的是 SIGKILL,不能捕获也不能拒绝,表现就是“进程突然没了”,程序自己的日志里没有任何退出信息。所以服务莫名其妙地消失时,第一件事是看内核日志里有没有 Out of memory 和 Killed process;有,再弄清楚是谁把内存吃光的、为什么偏偏杀了它。
OOM killer 什么时候会触发
Linux 默认允许程序申请超过实际可用量的内存(overcommit),申请时只登记虚拟地址,真正写入时才分配物理页。所以 OOM 不是发生在 malloc 那一刻,而是发生在某个进程触碰新内存、内核拿不出空闲页的时候:内核先回收页缓存、把匿名页换到 swap、整理内存碎片,这些都失败后才调用 OOM killer。按触发范围分四种:
| 触发场景 | 条件 | 在哪个范围里挑进程 | 日志特征 |
|---|---|---|---|
| 整机内存耗尽 | 物理内存和 swap 都用完,回收不出可用页 | 全系统 | oom-kill: 行带 global_oom,constraint 为 CONSTRAINT_NONE |
| cgroup 内存上限 | 容器或 systemd 服务的 memory.max(cgroup v1 为 memory.limit_in_bytes)到顶 | 只在该 cgroup 内 | Memory cgroup out of memory,constraint 为 CONSTRAINT_MEMCG |
| NUMA 节点或 cpuset 限制 | 进程被限定只能用部分内存节点,这些节点耗尽 | 受限的进程 | constraint 为 CONSTRAINT_CPUSET 或 CONSTRAINT_MEMORY_POLICY |
| 手动触发 | 通过 SysRq 的 f 键 | 全系统 | 与整机 OOM 类似,invoked oom-killer 行里 order=-1 |
下面两种情况看起来像 OOM,其实不是 OOM killer 干的,排查时要分开:
- Java 报
java.lang.OutOfMemoryError:这是 JVM 的堆或元空间到了 -Xmx 等参数设定的上限,由 JVM 自己抛出,进程不一定退出,内核日志里也没有记录。 - 程序报
Cannot allocate memory:申请内存当场失败,常见于vm.overcommit_memory=2(严格模式)或进程触及 ulimit 限制,内核没有杀任何进程。
三个相关的内核参数,先看当前值:
sysctl vm.overcommit_memory vm.panic_on_oom vm.oom_kill_allocating_task
| 参数 | 默认 | 含义 |
|---|---|---|
| vm.overcommit_memory | 0 | 0 按启发式允许超额申请;1 总是允许;2 严格限制,承诺总量不超过 CommitLimit(swap + 内存 × overcommit_ratio%,ratio 默认 50) |
| vm.panic_on_oom | 0 | 0 杀进程;1 整机 OOM 时内核崩溃,受 cpuset、内存策略或 cgroup 限制的 OOM 仍只杀进程;2 任何 OOM 都崩溃 |
| vm.oom_kill_allocating_task | 0 | 0 按分数挑;非 0 直接杀掉正在申请内存的那个进程,省去扫描 |
OOM killer 怎么挑进程:打分规则与算例
内核给每个进程算的“坏分”(badness)由两部分组成:
- 内存占用:常驻内存页(匿名页、文件映射页、共享内存页)+ 已被换到 swap 的页 + 页表占用的页,单位是页。
- 人为调整:
oom_score_adj× 可用总页数 ÷ 1000。oom_score_adj 取值 -1000 到 1000,相当于按总内存的千分之几加分或减分。
整机 OOM 时“可用总页数”是物理内存加 swap。几类进程不参与评选:oom_score_adj 为 -1000 的、内核线程和 init(PID 1)。如果上一个被选中的进程还在退出、没释放完内存,内核会先等它,而不是马上再杀一个。
用一个算例看 oom_score_adj 的分量。假设一台服务器有 16 GiB 内存和 2 GiB swap,总共 18 GiB,按 4 KiB 一页是 4,718,592 页:
| 进程 | 内存占用 | oom_score_adj | 调整分 | 总分 |
|---|---|---|---|---|
| mysqld | 9 GiB = 2,359,296 页 | 0 | 0 | 2,359,296 |
| 报表任务(java) | 3 GiB = 786,432 页 | 500 | 500 × 4,718,592 ÷ 1000 = 2,359,296 | 3,145,728 |
| php-fpm 单个子进程 | 60 MiB = 15,360 页 | 0 | 0 | 15,360 |
报表任务只占 3 GiB,却因为 +500 的调整排在 mysqld 前面先被杀掉。反过来,如果什么都不调,被杀的就是占用最大的 mysqld。由此有两个结论:
- 分数只看占用,不看谁在泄漏。 一个小进程慢慢泄漏把内存挤满,最后被杀的往往是最大、最无辜的数据库。日志第一行
invoked oom-killer前面的进程名,是触发那一刻正在申请内存的进程,也未必是罪魁祸首。 - 每个进程的当前分数能直接看。
/proc/<pid>/oom_score是内核按总内存折算后的分数,数字越大越先被杀,只适合在同一台机器上横向比较。
# 列出 oom_score 最高的 10 个进程:分数、oom_score_adj、进程名
for p in /proc/[0-9]*; do
printf '%s %s %s\n' "$(cat $p/oom_score 2>/dev/null)" "$(cat $p/oom_score_adj 2>/dev/null)" "$(cat $p/comm 2>/dev/null)"
done | sort -rn | head -10
进程被杀了,怎么确认是不是 OOM
# 本次启动以来的内核日志,-T 显示可读时间
dmesg -T | grep -E 'invoked oom-killer|Out of memory|Killed process|oom-kill:'
# 机器已经重启过:查上一次启动的内核日志(需要 journald 持久化存储)
journalctl -k -b -1 | grep -iE 'oom|killed process'
# 传统日志文件,RHEL 系在 messages,Debian/Ubuntu 在 kern.log
grep -iE 'out of memory|killed process' /var/log/messages /var/log/kern.log 2>/dev/null
一次整机 OOM 在日志里大致是这样(示例,已删去时间戳和中间的内存明细):
php-fpm invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0, oom_score_adj=0
Mem-Info:
active_anon:3612340 inactive_anon:210422 ... free:33711 ...
Tasks state (memory values in pages):
[ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[ 1123] 27 1123 2852610 2401230 20512768 0 0 mysqld
[ 2210] 33 2210 98213 15520 417792 0 0 php-fpm
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/mysqld.service,task=mysqld,pid=1123,uid=27
Out of memory: Killed process 1123 (mysqld) total-vm:11410440kB, anon-rss:9604920kB, file-rss:0kB, shmem-rss:0kB, UID:27 pgtables:20032kB oom_score_adj:0
逐行读:
| 日志位置 | 看什么 |
|---|---|
invoked oom-killer 行 |
触发时正在申请内存的进程;order=0 是申请单个页,order 较大而 free 并不低时,问题多在内存碎片而不是总量 |
Mem-Info |
active_anon、inactive_anon 是进程的匿名内存,单位是页;free 很低、anon 占了绝大部分,说明是进程把内存吃光,而不是缓存 |
Tasks state 表 |
当时每个进程的占用,rss 单位是页(4 KiB):2,401,230 页约 9.2 GiB。把这张表按 rss 排序,能看出除了被杀的进程还有谁在占内存 |
oom-kill: 行 |
global_oom 表示整机 OOM,oom_memcg=... 表示某个 cgroup 的上限到了;task_memcg 能看出进程属于哪个服务或容器 |
Killed process 行 |
被杀的进程和它当时的匿名内存 anon-rss;老内核这里写作 Out of memory: Kill process 1123 (mysqld) score 912 or sacrifice child |
内核日志里找不到记录,进程却确实被杀了,按下表继续查:
| 现象 | 可能的来源 | 怎么确认 |
|---|---|---|
| 整组进程或整个用户会话被杀,dmesg 无记录 | systemd-oomd 等用户态 OOM 守护程序,它在内核 OOM 之前按内存压力杀掉整个 cgroup | journalctl -u systemd-oomd |
| 容器退出码 137 | 137 = 128 + 9,即收到 SIGKILL | docker inspect -f '{{.State.OOMKilled}}' 容器名;Kubernetes 看 kubectl describe pod 里的 Reason: OOMKilled |
| systemd 服务失败 | 服务所在 cgroup 触发了 OOM | systemctl status 服务名,较新版本会显示 Failed with result 'oom-kill' |
| 退出码 137,以上都没有 | 有人或某个监控脚本执行了 kill -9 | 查审计日志、定时任务和看门狗脚本 |
用 oom_score_adj 保护关键进程
oom_score_adj 写在 /proc/<pid>/oom_score_adj 里,直接改是临时的,进程重启后失效:
cat /proc/$(pidof -s sshd)/oom_score_adj # 查看当前值
echo -500 > /proc/1123/oom_score_adj # 临时调整
choom -p 1123 -n -500 # util-linux 自带的工具,效果相同
降低分值(往负数方向调)需要 root 或 CAP_SYS_RESOURCE 权限,普通用户只能往高调。要持久生效,写进 systemd 服务的覆盖配置。下例的服务名以实际为准(RHEL 系的 MySQL 服务叫 mysqld,Debian/Ubuntu 叫 mysql):
mkdir -p /etc/systemd/system/mysqld.service.d
printf '[Service]\nOOMScoreAdjust=-600\n' > /etc/systemd/system/mysqld.service.d/oom.conf
systemctl daemon-reload && systemctl restart mysqld
cat /proc/$(pidof -s mysqld)/oom_score_adj # 确认已生效
调多少,按进程的角色定(下表数值是经验取值,不是标准):
| 进程类型 | 建议取值 | 理由 |
|---|---|---|
| sshd、监控与运维 agent | -900 到 -1000 | 占内存很少,保住它们才能登录进去处理问题 |
| 数据库等主业务 | -500 到 -800 | 降低被选中的概率,但不要 -1000 |
| 报表、批处理、可重跑的任务 | 300 到 1000 | 让它们先被牺牲 |
两个坑要避开:
- 不要给最大的内存用户设 -1000。 它被完全豁免后,内核只能去杀其他进程;如果泄漏的正是它,其他进程杀光了内存还是不够,最后会因为找不到可杀的进程而整机崩溃,日志是
Out of memory and no killable processes。 - 子进程会继承 oom_score_adj。 给 sshd 设了 -1000,从 SSH 登录后启动的程序也可能带着 -1000,跑飞了反而杀不掉。设置后登录进去执行
cat /proc/self/oom_score_adj检查,不是 0 的话,在登录脚本里执行echo 0 > /proc/self/oom_score_adj调回来,往高调不需要特权。
根本解决:先算清内存账,再配 swap
oom_score_adj 只决定“杀谁”,不决定“杀不杀”。根治靠两件事:让各服务的内存上限加起来小于物理内存,以及给突发情况留缓冲。
先把每个服务能用到的内存上限列出来。以一台 16 GB 内存的网站服务器为例(配置值和单进程占用都是假设):
| 组件 | 配置 | 估算上限 |
|---|---|---|
| 系统与常驻服务 | sshd、systemd、监控 agent | 约 1 GB |
| MySQL 缓冲池 | innodb_buffer_pool_size = 8G | 8 GB,实际占用还会再多一些 |
| MySQL 连接 | 300 个连接 × 峰值每连接约 4 MB | 约 1.2 GB |
| PHP-FPM | pm.max_children = 60 × 每进程约 50 MB | 约 3 GB |
| Redis | maxmemory 2gb | 2 GB,后台持久化 fork 期间写入多时还会额外增加 |
| 合计 | — | 约 15.2 GB,只剩不到 1 GB 给页缓存和突发 |
这个配置平时跑得好好的,高峰期 PHP 进程开满、Redis 又正好在做持久化,就会触发 OOM。把缓冲池调到 6G、max_children 调到 40 后,合计约 12.2 GB,留出约 3.8 GB 给文件缓存和突发。单个 PHP-FPM 进程的占用可以这样粗估(进程名以实际为准,有的发行版叫 php-fpm8.1 之类),它用的是 RSS,会把共享内存重复计入,所以算出来偏保守:
ps -C php-fpm -o rss= | awk '{s+=$1; n++} END {printf "%d 个进程,平均 %.0f MB\n", n, s/n/1024}'
再配 swap。swap 的作用是把长期不用的内存页换出去,并在内存突然吃紧时争取时间,让告警先于 OOM 到达;它不能替内存泄漏续命,配得过大,机器会在大量换页中变得极慢,比直接 OOM 更难处理。业务服务器配几 GB 作为缓冲是常见做法。没有 swap 分区时用 swap 文件:
fallocate -l 4G /swapfile # ext4、XFS 可用;不支持时改用 dd if=/dev/zero of=/swapfile bs=1M count=4096
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap defaults 0 0' >> /etc/fstab
swapon --show
最后,给可能失控的服务设上限,把 OOM 限制在它自己的 cgroup 里:systemctl set-property 服务名 MemoryHigh=6G MemoryMax=8G。MemoryHigh 是软上限,超过后内核会加紧回收、拖慢这个服务;MemoryMax 是硬上限,超过就只在该服务内部触发 OOM,不连累其他进程。要提前发现内存压力,内核 4.20 起可以读 /proc/pressure/memory,some 和 full 的 avg10 持续上升说明进程已经在等内存了;部分发行版需在内核启动参数中加 psi=1 才会开启。
在管理系统里怎么落地
OOM 发生时,被杀的有时恰好是 sshd,或者系统在大量换页中卡住,SSH 怎么也连不上。这时要走不经过操作系统的 BMC 通道:Toplink DCIM 的 IPMI 远程管理提供浏览器里的 VNC 控制台,内核打印到控制台的 OOM 信息在屏幕上直接可见,也能从本地终端登录排查;控制台也卡死时,可以在同一页面远程重启,每次会话都会录像,事后能回放当时做了什么。出租给客户的服务器,客户可以在客户自助端自己打开控制台、重启;如果是某个服务一开机就把内存吃光、反复触发 OOM,还可以进入救援模式修改配置,不动硬盘数据,救援这类高危操作需要输入 OTP 动态口令确认。
常见问题
OOM killer 可以关掉吗?
不能真正关掉,只能换一种失败方式:vm.panic_on_oom=1 让内存耗尽时整机内核崩溃,适合有集群自动切换的场景;vm.overcommit_memory=2 让程序在申请内存时直接收到失败,而不是事后被杀。对单台业务服务器,这两种结果通常都比杀掉一个进程更糟。
被 OOM 杀掉的进程会自动重启吗?
内核不负责重启。由 systemd 管理、设置了 Restart=on-failure 或 Restart=always 的服务会被重新拉起,容器取决于重启策略,手工启动的进程就停在那里。自动重启只能止血,根因不解决会反复被杀。
发生 OOM 之后要重启服务器吗?
一般不用。被杀的进程已经把内存还回来了,系统通常会恢复正常,重新拉起被杀的服务、查清原因即可。只有机器仍在大量换页、卡到登录不进去,或者关键服务反复被杀起不来时才考虑重启;重启前先把 dmesg 输出保存下来,没有持久化的内核日志会随重启丢失。