关于我使用实验室的服务器时占用了大量的内存导致磁盘 I/O 被打满从而使得所有人的 SSH 连接断开服务器宕机这档事及解决方法
摘要
一周内,我在使用实验室的服务器微调模型的时候没有注意内存的消耗,导致分页风暴,磁盘 I/O 被打满,SSH 断开,服务器被迫重启总计 4 次,在此做出深刻检讨。
在此之前我配置了 earlyoom 用于监管内存占用,但是很可惜它没有发挥应有的作用。我发现在我下线之前内存还有 40G,但是 swap 被打满了,磁盘 I/O 出现了问题——这是 earlyoom 监控不到的情况。当然也可以配置 swap 阈值为 100% 就行了,但我还是感觉这种方法太不优雅了,而且预留内存这种方法会限制资源的使用。
于是就有了这篇文章,我想从更本质的角度分析,提出我的解决方法。
我认为最重要的就是保证 SSH 能够连上。只要 SSH 能够畅通地连接,其实占多少内存、I/O 多大、换页多频繁都没事——因为你总能连上去手动处理。于是我们可以对解决方案提出三个需求:
- 在用户层面就可以运行——不需要 sudo权限,不需要安装任何软件
- 能检测本机的 SSH 是否可以正常连接——直接验证 SSH 链路而不是间接猜测
- 能在 SSH 连接失败、资源极度匮乏、I/O 难以运转的情况下释放资源——极端条件下也能工作
基于此我编写了 ssh_guardian,一个纯 C 实现的用户态守护进程。代码可以从这里获得:https://github.com/Trisenna/ssh_guardian.git
相关工作
现有方案及其局限
目前 Linux 生态中有几个常见的 OOM 管理工具,这些都是前人的智慧,有着官方的维护,这些如果就能解决你的问题的话是最好的:
earlyoom
earlyoom 是一个用户态的 OOM 守护进程,它定期检查 /proc/meminfo,当可用内存和 swap 低于设定阈值时发送 SIGTERM/SIGKILL 杀掉占用内存最多的进程。
它的问题在于:只看内存和 swap 的数值。在我遇到的场景里,内存还剩 40GB 看起来很健康,但 swap 已经被打满了,大量的页面换入换出导致磁盘 I/O 被打满。earlyoom 看到"内存还有 40G"就认为一切正常,但系统实际上已经因为 I/O 阻塞而完全卡死了。
systemd-oomd
systemd-oomd 是 systemd 自带的 OOM 管理器,基于 cgroup v2 和 PSI(Pressure Stall Information)工作。它能感知到内存压力而不只是看数值,理论上更智能。
但问题也很明显:它依赖 systemd 和 cgroup v2,需要管理员配置,普通用户没有权限去动它。在共享的实验室服务器上,你大概率没有 root 权限,也不好意思去找管理员帮你配这些东西。
内核 OOM Killer
Linux 内核自带的 OOM Killer 是最后一道防线——当系统完全分配不出内存时,内核会选择一个进程杀掉。但它的触发时机太晚了,往往系统已经在 swap 风暴中卡了很久,所有人的 SSH 都断了好几分钟了,内核才姗姗来迟地杀掉一个进程。在我的 2GB 测试服务器上 OOM Killer 倒是很快就介入了,但在大内存服务器(比如实验室的 128GB 机器)上,系统可以在"有内存但 I/O 完全卡死"的状态下僵持很久都不触发 OOM。
这些方案的共同问题
以上方案都在试图回答同一个问题:"内存/资源是不是不够了?"
但我认为这个问题问错了。真正重要的不是"资源够不够",而是"SSH 还能不能用"。
资源占用高不一定是问题。如果你的训练吃了 120GB 内存但系统还是流畅的,那就是正常使用,不应该杀它。反过来,即使内存还剩 40GB,但由于 swap 风暴导致 I/O 阻塞,SSH 已经连不上了——这才是真正的灾难。
所以正确的问题是:"SSH 还活着吗?"
设计思路
检测方式:直接验证 SSH
最直接的方法就是让服务器自己 SSH 自己:
ssh -o BatchMode=yes -o ConnectTimeout=8 localhost /bin/true
这一条命令会走完整的 SSH 登录链路:
TCP 连接 → 协议握手 → 密钥交换 → 公钥认证 → fork 子进程 → 执行 /bin/true
链路上任何一环卡住都说明系统有问题:
- 内存不够 fork → sshd 无法创建子进程 → 连接失败
- I/O 阻塞 → 读不了密钥文件、读不了 authorized_keys → 超时
- sshd 被换出到 swap → 响应极慢 → 超时
- 系统整体负载过高 → 调度延迟导致超时
为什么用 localhost 而不是从外部探测?
因为我们要检测的故障模式是"本机资源耗尽"。在这种情况下 localhost SSH 一定也会挂,而且走 loopback 不经过外部网络,不会因为校园网抖动或者实验室交换机抽风而误判。反过来,如果 SSH 断开是因为外部网络问题,localhost SSH 正常,不触发清杀——这是正确的行为,因为杀你的训练进程也修不了外部网络。
极端条件下的生存能力
系统卡死时,最大的挑战不是"检测到问题",而是"我自己还能不能跑"。如果守护进程本身也被卡住或者被 OOM 杀了,那一切都白搭。
为此做了几个设计:
纯 C 实现,静态编译
整个程序编译后只有几十 KB,运行时 RSS 不到 1MB。不依赖 Python 解释器、不依赖动态库、不依赖任何外部程序(除了 ssh 命令本身)。在资源极度匮乏的情况下,它比任何 Python/Go 写的守护进程都更容易被内核调度到。
三级日志策略
| 系统状态 | 磁盘日志 | /dev/shm 日志 | 内存环形缓冲区 |
|---|---|---|---|
| 正常 | ✅ 写 | ✅ 写 | ✅ 写 |
| SSH 失败(紧急模式) | ❌ 停写 | ✅ 写 | ✅ 写 |
| SSH 恢复 | ✅ 补写 | ✅ 写 | ✅ 写 |
进入紧急模式后立即停止写磁盘日志——因为磁盘 I/O 可能正是问题所在,继续写只会雪上加霜。改写 /dev/shm(tmpfs,走内存不走磁盘)和内存环形缓冲区。系统恢复后再把缓冲区的内容补写到磁盘,确保完整的事件记录不丢失。
不使用 malloc / 不做额外的 I/O
紧急模式下的所有操作都在栈上完成,不申请新内存,不读额外的文件。扫描进程列表时直接 opendir("/proc") 逐个读 /proc/[pid]/stat,这些是内核虚拟文件系统,不经过磁盘。
两级清杀策略
触发条件:连续 N 次(默认 3 次)SSH 检测失败。
连续 N 次 SSH 失败
↓
Stage 1:杀 RSS 最大的 1 个候选进程
↓ 等待,重新检测
↓ SSH 恢复 → 结束
↓ SSH 仍失败 ↓
Stage 2:杀掉所有候选进程
↓ 等待,重新检测
↓ SSH 恢复 → 结束
↓ SSH 仍失败 → 停止(故障超出用户进程范围)
候选进程的定义:当前用户拥有的、进程名在 allowlist 里的(默认 python 和 python3)、RSS 超过阈值的进程。
绝不碰的进程:bash、zsh、tmux、screen、sshd 等,通过 exclude 列表硬性排除。
为什么分两级?因为可能有多个训练任务在跑,不一定都有问题。先杀最大的那个,给系统一个喘息的机会。如果一个就够了,剩下的训练可以继续跑,损失最小化。
实现
核心循环
while (running) {
sleep(interval);
if (in_cooldown()) {
log("COOLDOWN");
continue;
}
result = check_ssh(timeout); // fork+exec ssh localhost
if (result == OK) {
fail_count = 0;
continue;
}
fail_count++;
log_system_pressure(); // 记录 MemAvail, SwapFree, I/O pressure
if (fail_count < threshold)
continue;
// 进入紧急模式
enter_emergency_mode(); // 停止磁盘日志
candidates = scan_processes();
stage1_kill(candidates);
if (!ssh_recovered()) {
stage2_kill_all(candidates);
}
start_cooldown();
exit_emergency_mode(); // 恢复磁盘日志,补写缓冲区
}
SSH 检测的实现
检测通过 fork + exec 实现,而不是用 system() 或 popen()——因为后者会额外 fork 一个 shell,在资源紧张时多一次 fork 可能就是失败和成功的区别。
pid_t pid = fork();
if (pid == 0) {
// 子进程:直接 exec ssh
execlp("ssh", "ssh",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=no",
"localhost", "/bin/true", NULL);
_exit(127);
}
// 父进程:等待,超时则 kill
父进程用非阻塞方式等待子进程,如果超过设定时间还没返回,就 SIGKILL 子进程。这样即使 ssh 命令本身卡死了也不会阻塞守护进程。
进程扫描
直接遍历 /proc 目录,读取每个进程的 /proc/[pid]/stat,提取进程名和 RSS,与 allowlist 和 excludelist 比对。整个过程不依赖 ps 命令,不创建子进程,在资源极度匮乏时也能工作。
使用方法
编译
gcc -O2 -o ssh_guardian ssh_guardian.c
不需要任何第三方库。如果服务器上没有 gcc,可以在本地 gcc -static -O2 -o ssh_guardian ssh_guardian.c 静态编译后传上去。
配置 localhost 免密登录
ssh-keygen -t ed25519 # 一路回车
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
ssh -o BatchMode=yes localhost /bin/true && echo OK # 验证
这是在服务器上让服务器自己 SSH 自己,跟你从本地怎么登录服务器无关,不需要任何权限。
运行
nohup ./ssh_guardian > /dev/null 2>&1 &
默认参数适合大多数场景。如果在小内存服务器上,调低阈值:
nohup ./ssh_guardian --fail-threshold 2 --timeout 3 --interval 2 --min-rss-mb 50 > /dev/null 2>&1 &
启动训练时
ulimit -c 0 # 禁止 core dump,防止被杀时再把磁盘写炸
python3 train.py
测试
自动化单元测试
项目附带了 10 个场景、23 项检查的自动化测试套件,通过一个 fake_ssh.sh 替代真实 ssh 命令,用控制文件切换"成功/失败/卡住"三种模式,不会对系统造成任何影响:
bash test/run_tests.sh
测试覆盖:
| 测试 | 验证内容 |
|---|---|
| 1 | SSH 正常时不误杀 |
| 2 | 短暂失败(未达阈值)不触发 |
| 3 | 连续失败 → Stage 1 杀最大进程 |
| 4 | Stage 1 不够 → Stage 2 全杀 |
| 5 | --dry-run 只记录不动手 |
| 6 | 排除列表生效(bash 不被碰) |
| 7 | 恢复后环形缓冲区补写到磁盘 |
| 8 | SSH 卡住(超时)也能正确判定失败 |
| 9 | Cooldown 期间不重复清杀 |
| 10 | 日志包含所有关键字段 |
真实环境压力测试
在一台 2 核 2GB 的阿里云服务器上做了真实的端到端测试。用一个 Python 脚本模拟训练失控:逐步分配 1.2GB 内存(逐页写入随机数据确保进入 RSS),同时启动 4 个线程反复写入 832MB 的随机文件并 fsync。
日志记录了完整的过程:
[23:42:52] SSH_CHECK: FAILED (1/2) | MemAvail=14MB SwapFree=0MB IoSome10=38.7
[23:43:02] SSH_CHECK: FAILED (2/2) | MemAvail=14MB SwapFree=0MB IoSome10=72.3
[23:43:03] TRIGGER: 2 consecutive SSH failures, entering emergency mode
[23:43:09] SCAN: found 1 candidate(s):
[23:43:09] [0] pid=2850404 comm=python3 rss=356MB state=D
[23:43:09] STAGE1: killing top 1 candidate(s)
[23:43:09] KILL: pid=2850404 comm=python3 rss=356MB state=D result=OK
[23:43:25] STAGE1: SSH recovered after killing 1 process(es)
从第一次检测失败到杀掉进程:17 秒。从杀掉到 SSH 恢复:16 秒。但是我还没有在我真正的服务器上试验过,真实作用有待讨论。
局限性
- 不覆盖外部网络故障。如果 SSH 断开是因为校园网或者实验室网络的问题,localhost SSH 正常,不会触发。这是正确的——杀掉你的训练也修不了网络。
- D 状态进程。处于不可中断磁盘等待(D state)的进程,即使收到 SIGKILL 也不会立即退出,要等内核从 I/O 等待返回。但信号已经标记了,内核有机会处理时就会清理。
- 内核彻底卡死。如果系统已经严重到内核调度都跑不动了,那任何用户态程序都无能为力,只能靠管理员通过 IPMI/iDRAC 硬重启。不过在我遇到的场景里这种情况极少,大多数时候系统只是"非常慢"而不是"完全死了",ssh_guardian 的几十 KB 足够挤进去执行。
- 依赖 sshd。需要目标机上有运行中的 SSH 服务,但既然你能 SSH 连上去跑训练,这个前提就已经满足了。