你通过 SSH 在服务器上跑了个多线程的满负载测试命令。命令看着卡住不动,你不耐烦地按了 Ctrl+C,或者干脆关掉了本地终端。你以为「命令停了,服务器就消停了」。几个小时后,服务器的 CPU 一直飙满,一查,那个测试进程还在后台空转,跑了不知道多久。
现象
- 通过 SSH 启动的命令,在本地中断(Ctrl+C、关终端、断网)后,远端进程依然活着;
- 而且它在持续消耗资源——CPU 跑满、内存不释放、磁盘一直写;
- 你本地看不到任何迹象,因为连接已经断了;
- 直到有人发现服务器变慢、或者监控告警,才知道它一直在跑。
最典型的场景就是压力测试、大批量任务、死循环——这些恰恰是最烧资源、最不该失控的那类命令。
根因
根因是一句容易被忽略的事实:
SSH 连接断开,不会杀掉远端已经启动的进程。
SSH 只是个「传话」的通道——它把你的命令送到远端、把输出送回来。命令在远端是怎么执行的、要不要在连接断掉后继续,取决于命令自己,而不是取决于 SSH。
具体来说:
- 你在远端启动一个命令,远端 shell 把它跑起来,这个进程就独立存在了;
- SSH 通道关闭时,远端的 shell 可能会收到
SIGHUP(挂起信号),但如果这个进程不响应SIGHUP(很多程序默认忽略,或者你把输出重定向、用了nohup、放到了后台),它就会继续跑; - 就算它响应了
SIGHUP而退出,如果它又自己 fork 了子进程(多线程测试、后台任务常见),子进程也未必跟着死。
所以「我按了 Ctrl+C」只终止了你这一端的等待,不一定终止了远端的工作。而一个满载测试进程一旦失控地跑在后台,它不会自己停——它会一直空转,直到你把服务器彻底搞慢或者资源耗尽。
解决
别用这种方式做满载测试。 如果真需要做,用可追踪的前后台管理:
一、给命令加超时,让它在失控前自己停。
timeout 300 ./stress-test.sh
timeout 300 表示最多跑 300 秒,到点自动杀掉。这是最省事的一道保险——就算你忘了它,它也会自己停。
二、用 nohup + 日志 + 明确的进程标识。
如果确实需要长时间跑,把它变成可控的后台任务:
nohup ./long-task.sh > task.log 2>&1 &
echo $! > task.pid # 记下进程号
这样即使 SSH 断开,它也继续跑(这是你要的),但你有它的 pid 和 日志,随时能管它。
三、出事后,用 pkill 清理。
发现失控进程,按名字杀掉:
pkill -f stress-test
-f 表示匹配完整命令行,不只是进程名——对付那些名字和启动脚本不同的进程特别有用。或者按记下的 pid 杀:
kill "$(cat task.pid)"
四、起进程时就想好「怎么收尾」。 启动前先想好:它跑多久?怎么知道它跑完了?如果失控了怎么找它?把这三个问题的答案准备好,再按回车。
延伸与预防
一句话原则:
SSH 只是「传话」,它断开不等于远端进程被清理。
更通用的认知是:「连接的状态」和「工作的状态」是两回事。 你这一端能看到的,只是连接;真正在干活的是远端那个进程,它的生命周期由它自己决定。把「我看不到了」当成「它停了」,是把观测和事实划了等号。
这个思路能推广到很多地方:
- 本地终端跑长任务:同理,关掉终端窗口,后台的进程可能还在跑——用
nohup、tmux、screen来托管; - CI/CD 里起了服务:任务结束不等于子进程结束,要显式清理;
- 调用子进程的程序:主程序退出,子进程未必跟着退(「孤儿进程」就是这么来的)。
一个实用的习惯:凡是启动一个「可能跑很久、可能烧资源」的进程,都给它配好「三样东西」——超时、可追踪的标识(pid/日志)、以及清理手段。 事后补救的成本,远高于启动前多想十秒。
这条坑另外的教训是:别让最烧资源的那类命令处于「没人管」的状态。 压力测试本身就是把资源推到极限,它出问题时造成的破坏也是最大的。越是这种命令,越要有「它失控了我能立刻叫停」的手段,而不是靠「我记得去看一眼」。