Linux OOM卡死自救:手写bash脚本主动干预内存危机
2026/9/16 23:38:05 网站建设 项目流程

从一次深夜服务器失联说起:如果你在 2G 或 4G 内存的 Linux 服务器上跑过内存密集型的脚本、Java 应用或者数据处理任务,大概率经历过那种"SSH 敲进去半天不回显,控制台重启才能救回来"的绝望。问题并不总是进程被杀,而是整个系统被 OOM 前的内存回收和 swap 抖动拖到完全失联。明明内核有 OOM killer,为什么它没有及时动手?原因在于:当系统进入极端内存压力时,内核的回收机制本身会耗尽 CPU 和 IO 资源,OOM killer 想杀进程也要排队,有时根本不等它执行,SSH 已经连不上了。

所以我的思路很直接:写一个自定义 bash 脚本,在系统进入不可用状态之前,主动监控内存、提前清理缓存、识别并清理真正的内存毒瘤,把"卡死"提前变成"可用"。这篇文章就是 oom_guard.sh v1 的完整记录,包含脚本设计、完整代码、部署方式和实测中的踩坑经验,适合运维、后端开发以及对 Linux 内存管理感兴趣的读者参考。

1. 从一次深夜服务器失联说起:OOM 卡死的内核机制

1.1 卡死不是瞬间发生的,它经历了三个阶段

大多数人对 OOM 的理解停留在"内存不够,内核挑一个进程杀掉"。但实际上,真正让人头疼的"卡死"根本不是被杀,而是系统进入了一种既不能正常运行、也不能及时恢复的中间状态。我把这个过程拆成三个阶段:

第一个阶段是内存回收压力增大。当系统内存越来越少,内核的 kswapd 线程开始频繁唤醒,扫描 LRU 链表,尝试把不活跃的内存页换出到 swap,或者回收 page cache。此时系统还算能用,但响应已经开始变慢,你可能会觉得"这台机器今天有点卡"。

第二个阶段是 direct reclaim 和 swap 抖动。当 kswapd 的异步回收速度赶不上内存申请速度,进程的内存分配就会进入同步回收路径(direct reclaim),也就是每个正在申请内存的进程都亲自下场参与回收。这会让大量进程同时卡在内存分配上,CPU 被内核内存管理代码占满,同时 swap 设备被反复读写。表现出来就是 load average 飙升、IO 打满、命令响应极慢。

第三个阶段就是完全失联。当可用内存和可用 swap 都趋近于零,内核仍然在尝试回收,而每次回收都需要扫描页表、等待 IO 写回脏页。几乎所有进程都进入不可中断的 D 状态,新连接无法建立,sshd 想 fork 子进程也需要内存分配,于是连 SSH 都无法连接。到这一步,除了硬重启或者等内核 OOM killer 最终触发,基本没有别的出路。

一个比较形象的类比是:厨房(内存)本身不够大,冰箱(swap)又小又远。内存不足时,所有厨师(进程)都在抢锅和食材,还不断有人跑出去开冰箱取东西、放东西,路上来回折腾,最终所有厨师都排队等在冰箱门口,灶台上谁也做不了菜。而真正需要决策的"管事人"(内核 OOM killer)也被堵在队伍里,根本走不到指挥岗位。

1.2 为什么不能完全指望内核 OOM killer 兜底

很多人会问:Linux 不是有 OOM killer 吗?为什么还会卡死?

要回答这个问题,得先理解 OOM killer 的触发逻辑。内核并不是"内存用量超过某个百分比"就触发 OOM,而是当内存分配请求无法满足、系统尝试各种回收手段都无效时,才会进入 out_of_memory() 逻辑,然后在所有进程里选一个 badness 分数最高的杀掉。问题在于,从"内存严重不足"到"OOM killer 真正生效"之间,系统可能已经进入了第二节说的第二、三阶段。

在极端情况下,OOM killer 本身也需要在复杂的锁定环境中执行,它要遍历进程列表、计算 badness、发送信号,这都需要 CPU 和少量内存资源。如果 direct reclaim 和 swap 抖动已经把系统拖到几乎停滞,这个流程会变得非常慢,甚至要几十秒、几分钟才完成。期间你打任何命令都没反应,从使用者的角度看,就是"卡死"。

另外,OOM killer 的选人标准并不符合业务期望。它倾向于杀掉内存占用大、运行时间短的进程,这在服务器场景下往往意味着杀掉正在处理关键业务的进程,或者反过来,留下一个内存泄漏的程序继续把系统拖垮。它不会因为某个进程是 MySQL、是 Nginx 就网开一面,除非你提前设置了 oom_score_adj。然而大多数服务器根本没有设置过这个东西。

1.3 脚本的定位:不等内核动手,先自己处理

所以我的思路调整为:与其等内核在极端情况下做决定,不如用一个轻量级脚本在系统还能正常执行命令的时候主动干预。这个脚本需要做三件事:

第一,持续监控可用内存,在进入"危险区"之前给出预判。第二,当内存确实紧张时,先做无害的缓存回收,这是成本最低的一步。第三,缓存回收解决不了问题时,迅速识别并清理真正占用大量内存且不在保护名单中的进程,把系统从崩溃边缘拉回来。

这里有一个重要前提:脚本能正常执行,本身就说明系统还没彻底死透。所以监控频率不能太低,否则等你发现危险时,脚本可能也跑不动了。我默认的巡检间隔是 15 秒,实际使用中可以根据机器负载情况调到 10 到 30 秒。

2. 脚本设计思路:先判定风险,再分层干预

2.1 指标选型:MemFree 不够用,为什么看 MemAvailable

刚开始写这个脚本时,我第一反应是读取 MemFree。后来发现这很容易误判。MemFree 只是当前完全空闲的物理内存页数量,而 Linux 的内存策略是尽量利用空闲内存做 page cache 等缓冲。一台正经运行的服务器,MemFree 常年很低,但系统一点都不卡,因为大量内存被 file cache 占着,需要时随时可以回收。

真正能反映"还能不能扛住新内存分配"的指标是 MemAvailable。这个字段从内核 3.14 开始出现在 /proc/meminfo 中,内核会估算当前有多少内存可以被回收出来分配给新进程,计算时不仅包含完全空闲的内存,还包含可回收的 page cache、可回收的 slab 内存,并扣除了内存水位(watermark)保留部分。简单说,MemFree 是"账面上空的",MemAvailable 是"实际能拿出来用的"。

在脚本里我同时读取 MemTotal、MemFree、MemAvailable、SwapTotal、SwapFree,为的就是在日志里能还原现场。如果运行环境内核版本太低没有 MemAvailable,我也会做一层回退,用 MemFree 代替。虽然不太准确,但至少不会让脚本直接崩溃。

2.2 双层阈值设计:绝对值和百分比配合

阈值设计是脚本的核心。我一开始只设了百分比阈值,比如可用内存低于 5% 就触发。但对内存总量不一样的机器,百分比和绝对值会有完全不同的体验。

在一台 2G 内存的机器上,5% 也就是 100MB 左右,这个数值其实已经很危险,系统可能已经开始卡顿。在一台 32G 内存的机器上,5% 是 1.6GB,还有很大的缓冲空间,绝对够让脚本多做几次回收尝试。反过来,如果只设绝对值 512MB,32G 的机器到了 512MB 可能已经接近极限,而 2G 的机器到了 512MB 还有 25%,并不算特别危险。

所以最终采用了双条件触发:可用内存绝对值低于 LOW_ABS_KB,或者可用内存占总内存的百分比低于 LOW_PERCENT,任何一个满足就进入干预流程。默认配置是绝对值 300MB、百分比 5%,两者配合基本能覆盖小内存和大内存两种场景。swap 也单独判断:如果 swap 剩余低于 100MB,即使系统可用内存还没到阈值,也值得提前干预,因为 swap 一旦耗尽,系统离卡死就不远了。

这个双条件设计还有一个好处:日志里能清楚看到是哪一项触发的。实际调优时,如果发现脚本太灵敏,优先调高 LOW_ABS_KB 或调低 LOW_PERCENT;如果发现系统已经卡了脚本才触发,就把阈值调得更激进一些。

2.3 干预动作分级:从温和到激进

我把干预动作分成两级。第一级是清理 page cache。page cache 是内核用来缓存磁盘文件内容的,本身没有业务状态,清掉之后最多是后续读盘慢一点,不会破坏任何进程的数据。执行之前先调用 sync 把脏页写回磁盘,再往 /proc/sys/vm/drop_caches 写入 1,就能释放大部分 file cache。这个动作成本低、风险小,是我最喜欢的第一板斧。

第二级是主动清理可疑进程。这个动作必须非常克制,否则容易误杀。原则是:保护名单里的进程绝对不碰,运行时间太短的不碰,一次最多清理 KILL_TOP_COUNT 个(默认 1 个),每杀完一个就重新检查内存是否恢复,恢复了立刻停下。这里用 SIGTERM 先做优雅终止,给进程 5 秒处理清理工作,如果它还不退,再升级到 SIGKILL。

这两个级别之间有一个缓冲机制:清理完 page cache 之后,不是立刻杀进程,而是再等一个巡检周期,重新读取 /proc/meminfo。如果内存已经回到安全线以上,说明内存压力主要来自缓存,直接收工。如果缓存清了还是不够,才进入进程清理流程。这个设计避免了"一触发就杀进程"的误伤。

3. oom_guard.sh v1 完整实现与代码解读

3.1 初始化、配置项与防重入

完整的 v1 脚本我放在下面,你可以直接复制保存为 /opt/scripts/oom_guard.sh,然后chmod +x并运行。所有配置集中在文件头部的"配置区",按机器实际情况修改即可。

#!/usr/bin/env bash # oom_guard.sh v1 # 用于低内存 Linux 环境中预防 OOM 卡死 # 建议以 root 运行,否则无法 drop_caches 和 kill set -u # ================= 配置区 ================= CHECK_INTERVAL=15 # 内存巡检间隔,单位秒 LOW_ABS_KB=307200 # 可用内存绝对阈值:300MB LOW_PERCENT=5 # 可用内存百分比阈值:5% DROP_CACHE_LEVEL=1 # 触发危险后清理缓存等级,0=不清理 1=page cache 3=全部 KILL_TOP_COUNT=1 # 单轮最多清理进程数 MIN_RUNTIME_SEC=60 # 进程最少运行秒数,避免误杀刚启动的程序 SIGKILL_WAIT_SEC=5 # 发送 SIGTERM 后等待时间 LOG_FILE="/var/log/oom_guard.log" LOCK_FILE="/var/lock/oom_guard.lock" SNAPSHOT_TMP="/tmp/oom_guard_snapshot.$$" # 保护名单,使用扩展正则(grep -E) # 范围:系统基础进程、远程管理进程、关键业务进程 # 小贴士:这里只写进程名或命令行中的关键片段,不要带路径 PROTECT_LIST="systemd|sshd|bash|oom_guard|mysqld|mariadbd|postgres|nginx|redis-server|docker|containerd|kubelet|cron|rsyslog|dbus-daemon|systemd-journal|polkitd|sssd|auditd|NetworkManager|tuned" # ================= 工具函数 ================= log() { echo "$(date '+%F %T') $*" >> "$LOG_FILE" } get_meminfo_kb() { awk -v key="$1" '$0 ~ key {print $2}' /proc/meminfo } current_available_kb() { get_meminfo_kb '^MemAvailable' } # ================= 防重入 ================= if command -v flock >/dev/null 2>&1; then exec 9>"$LOCK_FILE" if ! flock -n 9; then echo "另一个 oom_guard 实例已在运行,退出" >&2 exit 1 fi fi # ================= 主循环 ================= while true; do MEM_TOTAL=$(get_meminfo_kb '^MemTotal') MEM_AVAIL=$(get_meminfo_kb '^MemAvailable') MEM_FREE=$(get_meminfo_kb '^MemFree') SWAP_FREE=$(get_meminfo_kb '^SwapFree') SWAP_TOTAL=$(get_meminfo_kb '^SwapTotal') # 空安全:内核版本过低时没有 MemAvailable,就退回 MemFree if [ -z "$MEM_AVAIL" ]; then MEM_AVAIL=$MEM_FREE fi AVAIL_RATIO=0 if [ "$MEM_TOTAL" -gt 0 ] 2>/dev/null; then AVAIL_RATIO=$(( MEM_AVAIL * 100 / MEM_TOTAL )) fi log "CHECK: total=${MEM_TOTAL}KB avail=${MEM_AVAIL}KB free=${MEM_FREE}KB ratio=${AVAIL_RATIO}% swap_free=${SWAP_FREE}KB" # 危险判定:绝对值、百分比、swap 三项任一命中就进入干预流程 trigger=0 [ "$MEM_AVAIL" -lt "$LOW_ABS_KB" ] && trigger=1 [ "$AVAIL_RATIO" -lt "$LOW_PERCENT" ] && trigger=1 if [ "$SWAP_TOTAL" -gt 0 ] && [ "$SWAP_FREE" -lt 102400 ]; then trigger=1 fi if [ "$trigger" -eq 1 ]; then log "TRIGGER: 可用内存达到危险阈值,开始第一步干预(清理缓存)" # ---- 第一步:清理 page cache ---- if [ "$DROP_CACHE_LEVEL" -gt 0 ]; then sync >/dev/null 2>&1 echo "$DROP_CACHE_LEVEL" > /proc/sys/vm/drop_caches 2>/dev/null && \ log "DROP_CACHES: 已执行 level=$DROP_CACHE_LEVEL" fi # 清理完再等一个周期,让系统消化,并再次读内存数据 sleep "$CHECK_INTERVAL" MEM_AVAIL_NEW=$(current_available_kb) [ -z "$MEM_AVAIL_NEW" ] && MEM_AVAIL_NEW=$MEM_AVAIL AVAIL_RATIO_NEW=0 [ "$MEM_TOTAL" -gt 0 ] 2>/dev/null && AVAIL_RATIO_NEW=$(( MEM_AVAIL_NEW * 100 / MEM_TOTAL )) # 如果恢复,不杀进程 if [ "$MEM_AVAIL_NEW" -ge "$LOW_ABS_KB" ] && [ "$AVAIL_RATIO_NEW" -ge "$LOW_PERCENT" ]; then log "RECOVERED: 缓存清理后可用内存回到 ${MEM_AVAIL_NEW}KB,无需杀进程" sleep "$CHECK_INTERVAL" continue fi # ---- 第二步:清理危险进程 ---- log "STILL_LOW: 缓存清理后仍未恢复,进入进程清理流程" ps -eo pid=,rss=,etimes=,comm=,args= --sort=-rss --no-headers > "$SNAPSHOT_TMP" killed=0 while read -r pid rss etimes comm args; do # 跳过基础保护 [ -z "$pid" ] && continue [ "$pid" -le 1 ] && continue # 去掉进程名中的内核标记方括号 clean_comm=$(printf '%s' "$comm" | tr -d '[]') # 保护名单匹配:用 clean_comm + 完整命令行一起匹配 if echo "$clean_comm $args" | grep -qiE "$PROTECT_LIST"; then continue fi # 运行时间过滤 [ -z "$etimes" ] && continue if ! [ "$etimes" -ge "$MIN_RUNTIME_SEC" ] 2>/dev/null; then continue fi log "KILL: pid=$pid rss=${rss}KB runtime=${etimes}s comm=$clean_comm args=$(echo "$args" | cut -c1-200)" kill -15 "$pid" 2>/dev/null sleep "$SIGKILL_WAIT_SEC" if kill -0 "$pid" 2>/dev/null; then log "KILL9: pid=$pid 未响应 SIGTERM,升级为 SIGKILL" kill -9 "$pid" 2>/dev/null fi killed=$(( killed + 1 )) # 每次杀完都重新检查,恢复了就停止 avail_now=$(current_available_kb) [ -z "$avail_now" ] && avail_now=$MEM_AVAIL_NEW ratio_now=$(( avail_now * 100 / MEM_TOTAL )) if [ "$avail_now" -ge "$LOW_ABS_KB" ] && [ "$ratio_now" -ge "$LOW_PERCENT" ]; then log "RECOVERED: 杀进程后可用内存回到 ${avail_now}KB,停止本轮清理" break fi if [ "$killed" -ge "$KILL_TOP_COUNT" ]; then break fi done < "$SNAPSHOT_TMP" rm -f "$SNAPSHOT_TMP" fi sleep "$CHECK_INTERVAL" done

3.2 内存巡检与危险判定逻辑拆解

脚本的主循环并不复杂,真正需要注意的是一些细节。比如,我读取 /proc/meminfo 用的是 awk 匹配字段名,而不是用 grep 再 awk,因为 /proc/meminfo 里 MemAvailable 和 MemFree 的行格式很规整,直接按 key 取第 2 列最稳定。

危险判定采用三条件任意命中机制:可用内存绝对值和百分比都低于阈值,会触发;swap 剩余低于 100MB,也会触发。为什么要加 swap 判断?因为 swap 相当于系统的最后缓冲垫。即使可用内存还有几百 MB,如果 swap 已经见底,说明系统一直在靠 swap 硬撑,任何一个突发的内存申请都可能压垮系统。提前介入可以避免这种突发场景。

日志里每一轮都会输出 total、avail、free、ratio、swap_free 这五个值。不要小看这些日志,它们是事后排查系统内存趋势、判断脚本阈值是否合理的第一手资料。我在实际运行中靠这些日志发现过一次 PHP-FPM 的慢速内存泄漏,趋势线非常明显,这在调试阶段价值极高。

3.3 第一板斧怎么用:drop_caches 的边界

清理 page cache 是最安全的干预手段,因为它清理的只是文件缓存,不会影响任何进程正在使用的内存。但有两个边界必须说清楚。

第一,drop_caches 只能清理可回收的缓存页,包括 file cache、dentries、inodes,但它清理不了进程的匿名内存页(比如进程堆、栈、mmap 的私有匿名映射)。如果内存大头是某个进程的堆内存,drop_caches 几乎是无效的。这正是脚本在执行完第一板斧后必须重新检查内存、而不是直接杀进程的原因。

第二,drop_caches 的 level 不是越高越好。level=1 只清 file cache,level=2 还会清 dentries 和 inodes,level=3 全清。清 dentries 和 inodes 意味着文件系统元数据缓存也被丢掉了,后续访问目录、读取文件元数据都要重新走磁盘,短时间内 IO 负载会明显上升,反而可能加剧系统卡顿。所以我的默认值是 1,而且绝不建议把它做成定时任务反复执行。我在踩坑部分会再说一次这个问题。

3.4 候选进程筛选:保护名单、运行时间、命令行匹配

进程清理是脚本里风险最高的部分,筛选逻辑的每一步都是为了减少误杀。

首先是保护名单。我用扩展正则把系统基础进程、远程管理进程、常见数据库服务、容器服务全部列入。值得强调的是,匹配时使用的是"进程名 + 完整命令行"拼接后的字符串,这样既能匹配 mysqld、redis-server 这类进程名,也能匹配像 supervisor 管理的特殊进程等带有明显命令行特征的进程。

其次是运行时间过滤。刚启动的进程内存占用可能还没稳定,甚至正在初始化,这时候杀掉很容易误伤。MIN_RUNTIME_SEC 默认 60 秒,实际上在内存压力极大的机器上,一个进程如果能撑过 60 秒还保持超高内存占用,是内存毒瘤的概率就很高了。

还有一个细节:内核线程的进程名通常带方括号,比如 [kthreadd],方括号在正则表达式里有特殊含义,直接拿去 grep 可能匹配不上或误匹配。所以我在匹配前用tr -d '[]'把方括号去掉,命令行的其他部分保持不变。

至于为什么用 ps 的 RSS 而不是 VSZ,是因为 RSS 是实际驻留物理内存的大小,VSZ 只是虚拟内存大小,两者差距可能几十倍。一个进程 VSZ 很大但 RSS 很小,并不会压垮物理内存。

3.5 杀进程的顺序和止损逻辑

整个清理流程的核心是"杀一个,看一次,恢复就停"。脚本每次找到候选者后,先发送 SIGTERM,等待 5 秒,如果进程还没退出再发 SIGKILL。发完一个信号后立即重新读取 MemAvailable,只要回到安全线以上,就立刻终止本轮清理,不再继续杀下一个。

这个设计非常重要。在内存紧张时,杀掉一个进程释放的内存可能就足够系统恢复,如果脚本接着往下杀第二个、第三个,就是过度清理,可能把本不该杀的业务进程也带走。

KILL_TOP_COUNT 参数控制的是"单轮最多清理几个进程"。我默认是 1,宁可多跑几轮循环慢慢恢复,也不愿意一轮杀太多导致业务大面积不可用。如果你确认机器上没有重要进程,想快速恢复,可以把这个值调大到 2 或 3,但不建议超过 3。

4. 部署与守护:三种方式对比与推荐配置

4.1 三种部署方式:nohup、crontab、systemd

脚本写好了,接下来是让它开机自启并稳定运行。我实际试过三种方式,各有优劣。

第一种最简单,nohup bash /opt/scripts/oom_guard.sh >/dev/null 2>&1 &。适合临时应急,比如线上正在压测、内存告急,你手动拉起来跑一阵子。缺点很明显:没有自动重启,机器重启后不会自动运行,而且如果启动它的终端挂掉,脚本可能受 HUP 信号影响退出(nohup 能缓解)。

第二种,crontab 配合@reboot,在 crontab 里加一行:

@reboot /bin/bash /opt/scripts/oom_guard.sh >> /var/log/oom_guard_cron.log 2>&1

优点是可以开机自启,还不需要额外配置服务。缺点是 crontab 不负责进程守护,脚本运行过程中如果挂掉没有任何机制把它拉起来。对 OOM 防御脚本来说,关键时刻它不在,就是一场事故。

第三种,systemd service,也是我最推荐的方式。systemd 天然支持开机自启、异常退出自动重启、日志统一管理,还能通过 ProtectSystem、PrivateTmp 等参数限制脚本的权限范围,安全性更好。

4.2 systemd 服务配置示例

创建文件/etc/systemd/system/oom-guard.service

[Unit] Description=OOM Guard Script After=multi-user.target [Service] Type=simple ExecStart=/bin/bash /opt/scripts/oom_guard.sh Restart=always RestartSec=10 NoNewPrivileges=true ProtectSystem=full ProtectHome=true PrivateTmp=true [Install] WantedBy=multi-user.target

保存后执行:

systemctl daemon-reload systemctl enable oom-guard.service systemctl start oom-guard.service

注意几个配置的用意。Restart=always 保证脚本挂掉后 10 秒内自动重启;ProtectSystem=full 会让 /usr、/boot、/etc 变成只读,防止脚本出 bug 时乱写系统目录,但 /etc 只读不影响我们写 /var/log;ProtectHome=true 禁止脚本访问 /home 目录,进一步降低风险。

启动后可以用systemctl status oom-guard确认状态,脚本日志则通过journalctl -u oom-guard -f实时查看。如果你还是习惯看普通文件日志,脚本里已经写入了 /var/log/oom_guard.log,两种方式可以并存。

4.3 防重入与锁文件

脚本里我用了flock实现防重入,它的原理是:在文件描述符 9 上持有排他锁,如果另一个实例尝试启动,flock -n会立刻返回失败,脚本直接退出并提示"另一个实例已在运行"。这比用 PID 文件可靠得多,因为 PID 文件存在读写竞争和 PID 复用两个坑。

如果你的系统提示找不到 flock 命令,通常是 util-linux 没装,Debian/Ubuntu 系可以apt install util-linux,CentOS/RHEL 系通常是自带的。锁文件本身我放在 /var/lock 下,这个目录用于保证原子锁操作,不会被普通用户随意篡改。

5. 压力和实测:怎么验证脚本真的有用

5.1 用 stress-ng 模拟内存压力

写完脚本最要紧的是验证。我建议不要直接在生产环境等 OOM 出现,而是先在测试机或低峰期用压力工具模拟。

以 stress-ng 为例,它比老牌的 stress 支持更多压测模式,也更贴近真实场景。我常用的命令是:

stress-ng --vm 2 --vm-bytes 80% --vm-hang 30 --timeout 300

意思是启动 2 个虚拟内存压测进程,每个申请总内存的 80%,并保持 30 秒不释放。这个参数组合能很快把系统内存推到危险区,又因为在 30 秒后会自动释放,不会真的把机器打到无法恢复,适合做触发测试。

如果你的系统没有 stress-ng,也可以用一个简单的 bash 脚本模拟内存泄漏:

#!/bin/bash # mem_fill.sh 模拟大量内存占用 declare -a arr i=0 while true; do arr[$i]=$(dd if=/dev/zero bs=1M count=1 2>/dev/null | base64) i=$((i+1)) sleep 0.1 done

这个脚本会不断往内存里塞数据,几分钟后就能吃掉几百 MB。测试完记得 kill 掉它。

5.2 观察日志,验证介入时机

压测开始后,我把 oom_guard.log 放到另一个终端实时监控。正常情况下,你会看到一连串 CHECK 行,ratio 不断下降。当 ratio 跌破 5% 或可用内存低于 300MB 时,出现 TRIGGER 行,然后脚本执行 sync 和 drop_caches,输出 DROP_CACHES。

让我印象很深的一次测试结果是:在 4G 内存、有 1G swap 的机器上,压测把内存打到 200MB 以下后,脚本先清缓存,MemAvailable 回升到 1.2GB,日志出现 RECOVERED,整个过程没有杀任何进程。这说明在这次场景里,内存压力主要来自 file cache,第一板斧就解决问题了。

另一次测试中,我用 mem_fill.sh 这种真的在申请匿名内存的进程去压,drop_caches 之后 MemAvailable 几乎没有变化。脚本随即进入进程清理流程,日志里出现了 KILL 行,可以看到目标进程的 PID、RSS、运行时间和命令行。杀掉进程后,可用内存在几秒内恢复到安全线,日志输出 RECOVERED。

5.3 误杀风险测试与保护名单调整

验证完"能触发"之后,还要验证"不会误杀"。我在测试机上跑了 MySQL、Nginx 和一个普通的 ssh 会话,然后用压力工具触发 OOM 干预。第一次测试发现脚本把 Nginx 的 worker 进程当成了可疑进程准备清理,虽然因为保护名单里有 nginx 而放过了,但这也说明保护名单的正则匹配确实生效了。

更值得注意的一个场景是:如果一个服务是用 bash 脚本拉起来的,它的 comm 字段可能是 bash,命令行里也没有服务名。这种情况下,如果保护名单里不加 bash,脚本很可能会把这个服务的父进程或封装脚本杀掉。所以我默认把 bash 也放进了保护名单。代价是:如果一个用 bash 跑的内存密集型脚本真的出了问题,它会被保护起来,无法被自动清理。这是权衡后的选择,宁可多留一个风险进程,也不能误杀掉用户的交互 shell。你如果确认机器上没有重要的交互 shell,可以把 bash 从保护名单里去掉。

6. 踩坑记录与调优建议

6.1 踩过的坑:drop_caches 不是万能的

我最初使用这个脚本时,一直以为 drop_caches 是救命稻草,直到一次实测让我彻底改变看法。当时服务器上跑着一个 Java 应用,内存被堆内存撑到只剩 100MB,我执行echo 1 > /proc/sys/vm/drop_caches,以为能释放一大块内存,结果 MemAvailable 纹丝不动。原因就是我把问题想简单了:drop_caches 清的是文件缓存,而 Java 的堆内存是匿名页,根本不归它管。

此后我加上了"清理缓存后重新检查"的逻辑。如果清理完缓存内存还没恢复,就必须走进程清理路线。这个设计很关键,它让脚本不会在无效动作上浪费时间。

6.2 另一个常见的坑:频繁清理缓存导致 IO 雪上加霜

我曾经见过有人用 crontab 每 5 分钟执行一次echo 3 > /proc/sys/vm/drop_caches,理由是"这样能让内存一直很空"。这是典型的反向优化。page cache 存在的意义就是加速磁盘读写,把它全部清掉等于每次读文件都要重新走磁盘,系统负载反而会升高。

所以脚本里的 DROP_CACHE_LEVEL 默认是 1,也就是只清理纯文件缓存,而且只会在内存到达危险阈值时执行一次,之后要等下一个巡检周期才会再次触发。如果你确实需要更激进地清理 slab,可以改成 3,但一定要观察后续的 IO 负载和系统响应,发现变慢就改回来。

6.3 PID 复用问题:kill -0 也救不了你

还有一个很容易被忽视的细节是 PID 复用。脚本在发送 SIGTERM 前用kill -0检查进程是否存在,但kill -0只能确认"当前有一个进程叫这个 PID",不能确认它还是不是刚才那个进程。在高并发环境下,一个进程退出后,它的 PID 可能立刻被新进程复用。如果脚本杀掉的是新进程,就是误杀。

解决思路有两个:一是用进程启动时间验证,对比记录的 etimes 和当前 etimes 是否一致;二是更稳妥的做法,在发送信号前重新读取 /proc/${pid}/comm 或 /proc/${pid}/cmdline,确认进程身份没有变化。v1 脚本里我保留了kill -0的基础检查,这个坑留给 v2 去完善。

6.4 关于早期介入的取舍:earlyoom 与 systemd-oomd

你可能想问,市面上明明有 earlyoom 和 systemd-oomd,为什么还要写 bash 脚本?我实际对比过这三者。

earlyoom 是 C 写的专用守护进程,性能好,逻辑也成熟,但它默认的杀进程策略相对简单,保护名单配置灵活度不如 bash 脚本高,而且很多发行版需要额外安装。

systemd-oomd 是 systemd 自带的方案,在 cgroup v2 环境下能监控 memory pressure,但它的设计重心是"用户会话"和"切片"级内存压力,对"整机内存即将耗尽"这种全局场景的介入并不是它的长项,配置起来也比较绕。

自定义 bash 脚本的价值在于:逻辑完全透明,保护名单和阈值可以按业务灵活调整;不依赖 systemd 版本;日志可以直接对接自己的监控体系。缺点是 bash 本身的性能开销和潜在 bug 风险,而且脚本执行依赖系统仍能响应命令。对这个问题我的观点是:脚本不是 earlyoom 的替代品,而是一个补充。如果你生产环境团队能维护好 C 程序,直接上 earlyoom 是更省心的选择;如果你像我一样希望一切尽在掌握,或者需要在容器等最小化环境中用,bash 脚本反而更顺手。

6.5 v2 可以怎么升级

说几个我列在 todo 里的升级方向,供想改进脚本的读者参考。

第一,按进程名做 RSS 聚合。现在的脚本按单个进程的 RSS 排序,但像 Java 应用这种会 fork 出多个同命令子进程的场景,单个进程的 RSS 不大,全部加起来却可能超过几个 G。v2 应该先按 comm 聚合 RSS,再决定清理目标。

第二,增加内存增长速率检测。连续两次巡检之间,如果某个进程的内存占用持续快速增长,说明它在泄漏,即使当前占用不是第一,也应该优先处理。这个逻辑比只看瞬时 RSS 更能提前发现问题。

第三,接入告警。可以在日志写完后触发一个 curl 请求,把 OOM 触发信息推到钉钉、企业微信或 Slack。这样在凌晨机器出问题时,你不需要等到第二天翻日志才知道。

第四,完善 PID 复用校验。把进程启动时间纳入校验,避免因 PID 复用导致误杀。

第五,可以考虑把脚本纳入 cgroup 限制,利用 memory.limit 做更细粒度的业务级内存保护,但这已经超出 bash 脚本的范畴了,更适合写成一个独立的服务。

根据我自己的实际使用体会,这个脚本在 2G 到 8G 内存的云服务器上已经稳定跑了几个月,大多数情况下第一板斧(清缓存)就能解决问题,真正走到杀进程流程的次数并不多,而每次触发后顺着日志里记录的命令行,都顺藤摸瓜找到了真正的内存泄漏根源。脚本的本质是兜底,它保证系统不会因为内存耗尽而完全失联,但最终解决内存泄漏问题还得靠业务侧把代码修好。有了这一层兜底,你至少能获得一个从容排查问题的机会,而不是每次都面临"重启了还不知道为什么死"的尴尬。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询