关于我使用实验室的服务器时占用了大量的内存导致磁盘 I/O 被打满从而使得所有人的 SSH 连接断开服务器宕机这档事及解决方法

摘要

一周内,我在使用实验室的服务器微调模型的时候没有注意内存的消耗,导致分页风暴,磁盘 I/O 被打满,SSH 断开,服务器被迫重启总计 4 次,在此做出深刻检讨。

在此之前我配置了 earlyoom 用于监管内存占用,但是很可惜它没有发挥应有的作用。我发现在我下线之前内存还有 40G,但是 swap 被打满了,磁盘 I/O 出现了问题——这是 earlyoom 监控不到的情况。当然也可以配置 swap 阈值为 100% 就行了,但我还是感觉这种方法太不优雅了,而且预留内存这种方法会限制资源的使用。

于是就有了这篇文章,我想从更本质的角度分析,提出我的解决方法。

我认为最重要的就是保证 SSH 能够连上。只要 SSH 能够畅通地连接,其实占多少内存、I/O 多大、换页多频繁都没事——因为你总能连上去手动处理。于是我们可以对解决方案提出三个需求:

  1. 在用户层面就可以运行——不需要 sudo权限,不需要安装任何软件
  2. 能检测本机的 SSH 是否可以正常连接——直接验证 SSH 链路而不是间接猜测
  3. 能在 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 里的(默认 pythonpython3)、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 秒。但是我还没有在我真正的服务器上试验过,真实作用有待讨论。


局限性

  1. 不覆盖外部网络故障。如果 SSH 断开是因为校园网或者实验室网络的问题,localhost SSH 正常,不会触发。这是正确的——杀掉你的训练也修不了网络。
  2. D 状态进程。处于不可中断磁盘等待(D state)的进程,即使收到 SIGKILL 也不会立即退出,要等内核从 I/O 等待返回。但信号已经标记了,内核有机会处理时就会清理。
  3. 内核彻底卡死。如果系统已经严重到内核调度都跑不动了,那任何用户态程序都无能为力,只能靠管理员通过 IPMI/iDRAC 硬重启。不过在我遇到的场景里这种情况极少,大多数时候系统只是"非常慢"而不是"完全死了",ssh_guardian 的几十 KB 足够挤进去执行。
  4. 依赖 sshd。需要目标机上有运行中的 SSH 服务,但既然你能 SSH 连上去跑训练,这个前提就已经满足了。