Linux OOM卡死自救:手写bash脚本主动干预内存危机 📅 发布时间:2026/9/16 23:38:07 👁 浏览次数: 从一次深夜服务器失联说起如果你在 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还有很大的缓冲空间绝对够让脚本多做几次回收尝试。反过来如果只设绝对值 512MB32G 的机器到了 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_INTERVAL15 # 内存巡检间隔单位秒 LOW_ABS_KB307200 # 可用内存绝对阈值300MB LOW_PERCENT5 # 可用内存百分比阈值5% DROP_CACHE_LEVEL1 # 触发危险后清理缓存等级0不清理 1page cache 3全部 KILL_TOP_COUNT1 # 单轮最多清理进程数 MIN_RUNTIME_SEC60 # 进程最少运行秒数避免误杀刚启动的程序 SIGKILL_WAIT_SEC5 # 发送 SIGTERM 后等待时间 LOG_FILE/var/log/oom_guard.log LOCK_FILE/var/lock/oom_guard.lock SNAPSHOT_TMP/tmp/oom_guard_snapshot.$$ # 保护名单使用扩展正则grep -E # 范围系统基础进程、远程管理进程、关键业务进程 # 小贴士这里只写进程名或命令行中的关键片段不要带路径 PROTECT_LISTsystemd|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 21; 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_RATIO0 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 三项任一命中就进入干预流程 trigger0 [ $MEM_AVAIL -lt $LOW_ABS_KB ] trigger1 [ $AVAIL_RATIO -lt $LOW_PERCENT ] trigger1 if [ $SWAP_TOTAL -gt 0 ] [ $SWAP_FREE -lt 102400 ]; then trigger1 fi if [ $trigger -eq 1 ]; then log TRIGGER: 可用内存达到危险阈值开始第一步干预清理缓存 # ---- 第一步清理 page cache ---- if [ $DROP_CACHE_LEVEL -gt 0 ]; then sync /dev/null 21 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_NEW0 [ $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 killed0 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 done3.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 不是越高越好。level1 只清 file cachelevel2 还会清 dentries 和 inodeslevel3 全清。清 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 21 。适合临时应急比如线上正在压测、内存告急你手动拉起来跑一阵子。缺点很明显没有自动重启机器重启后不会自动运行而且如果启动它的终端挂掉脚本可能受 HUP 信号影响退出nohup 能缓解。第二种crontab 配合reboot在 crontab 里加一行reboot /bin/bash /opt/scripts/oom_guard.sh /var/log/oom_guard_cron.log 21优点是可以开机自启还不需要额外配置服务。缺点是 crontab 不负责进程守护脚本运行过程中如果挂掉没有任何机制把它拉起来。对 OOM 防御脚本来说关键时刻它不在就是一场事故。第三种systemd service也是我最推荐的方式。systemd 天然支持开机自启、异常退出自动重启、日志统一管理还能通过 ProtectSystem、PrivateTmp 等参数限制脚本的权限范围安全性更好。4.2 systemd 服务配置示例创建文件/etc/systemd/system/oom-guard.service[Unit] DescriptionOOM Guard Script Aftermulti-user.target [Service] Typesimple ExecStart/bin/bash /opt/scripts/oom_guard.sh Restartalways RestartSec10 NoNewPrivilegestrue ProtectSystemfull ProtectHometrue PrivateTmptrue [Install] WantedBymulti-user.target保存后执行systemctl daemon-reload systemctl enable oom-guard.service systemctl start oom-guard.service注意几个配置的用意。Restartalways 保证脚本挂掉后 10 秒内自动重启ProtectSystemfull 会让 /usr、/boot、/etc 变成只读防止脚本出 bug 时乱写系统目录但 /etc 只读不影响我们写 /var/logProtectHometrue 禁止脚本访问 /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-linuxCentOS/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 i0 while true; do arr[$i]$(dd if/dev/zero bs1M count1 2/dev/null | base64) i$((i1)) 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 内存的云服务器上已经稳定跑了几个月大多数情况下第一板斧清缓存就能解决问题真正走到杀进程流程的次数并不多而每次触发后顺着日志里记录的命令行都顺藤摸瓜找到了真正的内存泄漏根源。脚本的本质是兜底它保证系统不会因为内存耗尽而完全失联但最终解决内存泄漏问题还得靠业务侧把代码修好。有了这一层兜底你至少能获得一个从容排查问题的机会而不是每次都面临重启了还不知道为什么死的尴尬。