ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SSH中断恢复指南:进程诊断、tmux接管与断点续跑

SSH中断恢复指南:进程诊断、tmux接管与断点续跑

凌晨两点半,训练脚本跑到第 37 个 epoch,终端窗口突然卡住,接着弹出Connection to xxx closed by remote host。你盯着那条红字,脑子里第一个问题不是“网络怎么了”,而是“我那一千多步的迭代还在不在”。SSH 中断这件事,几乎每个真正在远程跑过代码的人都遇到过,但真正会恢复的人不多——大部分人第一反应是重新执行一遍命令,结果把还在后台活着的进程覆盖掉,或者把已经写了一半的 checkpoint 给冲了。

这篇东西聊的就是跑代码时 SSH 中断之后的恢复:怎么判断进程死没死、活着怎么接管、死了怎么把损失找回来、以及下次怎么让它断不了。内容覆盖临时救火和长期预防两条线,从一条命令都不懂的新手,到已经在写自动化托管脚本的老手,都能在这里找到能直接抄的东西。我把踩过的坑和常用的命令整理成了一套可复现的流程,你可以照着往下做。

1. 先搞清楚:SSH 断了,代码到底死没死

1.1 断连的一瞬间,系统到底做了什么

很多人以为 SSH 断开就等于“远程机器关机了”,其实完全不是。远程机器上的进程该跑还在跑,断的只是你本地终端和远端sshd之间那条 TCP 连接。真正决定进程生死的,是内核对“控制终端消失”这件事的处理方式。

链路是这样的:你本地网络一断,或者笔记本合盖休眠,TCP 连接超时,远端sshd检测到连接失效,就会向它启动的那个 shell 会话首进程发送SIGHUP(挂断信号)。shell 收到后退出,退出前如果配置了huponexit,会把这个信号继续转发给它所有的子进程。你的python train.py是 shell 的前台作业,默认行为就是收到SIGHUP后终止。

所以关键问题变成三个:你的进程有没有忽略SIGHUP、有没有脱离控制终端(setsid或者进入新会话)、有没有被某个守护进程托管。这三个条件只要满足一个,进程大概率还活着。这也是为什么nohup、tmux、systemd这三样东西在远程开发里几乎成了标配——它们做的都是同一件事:把你的进程从“终端会话”这个脆弱的东西上解绑。

还有一种更隐蔽的死法:进程活着,但它的标准输出还绑在那个已经消失的 tty 上。这时候它每次print或者写日志,内核会返回EIO错误;如果代码里没有处理写失败,轻则日志丢失,重则因为未捕获异常直接崩溃。所以有时候ps里能看到进程,但它其实已经卡死在一次write系统调用上,状态是D(不可中断睡眠)或者S但 CPU 占用为 0。

1.2 一分钟内判断进程状态的四条命令

重新连上去之后,别急着敲任何重启命令。先花一分钟做诊断,这一步做对了,后面能省掉几个小时的返工。

第一条,看机器负载和登录情况:

uptime who last -n 5

uptime里的 load average 如果还在高位,说明有东西在跑,至少在消耗 CPU;who能看到当前还有哪些会话挂着。这一步是粗筛,判断“机器有没有在干活”。

第二条,按 CPU 排序找出可疑进程:

ps -eo pid,ppid,tty,stat,etime,pcpu,pmem,cmd --sort=-pcpu | head -20

这一条的信息量很大,重点看三列。TTY列如果是?,说明这个进程没有控制终端,已经脱离会话,断连基本伤不到它;如果是pts/2这种,说明它还绑在某个终端上,需要确认那个终端是不是已经死了。STAT列里,R是运行中,S是可中断睡眠(正常,可能在等 IO),D是不可中断睡眠(通常在等磁盘或网络,很危险),T是被挂起(可能被Ctrl+Z过),Z是僵尸。ETIME是已运行时长,如果显示02:15:33,说明这个进程在断连之前就起来了,一直没死。

第三条,专门盯你的脚本:

pgrep -af python pgrep -af "train|run|main"

-a会把完整命令行打出来,你能一眼看出是不是你那个脚本、跑的是哪个配置文件。这一步比ps更聚焦,不会被执行系统的几十个进程干扰。

第四条,看日志文件还在不在长:

ls -l --time-style=full-iso train.log sleep 10 ls -l --time-style=full-iso train.log

两次时间戳如果不一样,说明进程还在往里写,基本可以确定活着且健康。这一招比看 CPU 还准,因为有些任务在等数据加载,CPU 占用很低,但日志一直在推进。

把这几条的结果对照下面这张表,基本能定位你在哪个场景里:

现象组合判断后续动作
TTY 为?,日志时间戳在变进程已脱离终端,正常运行只需接管日志,不要动进程
TTY 为pts/N,日志时间戳在变进程还活着,但输出绑在死终端上尽快用reptyr迁移,或准备重启
进程存在,STAT 为D,日志不动卡在 IO,可能被写失败阻塞用strace/py-spy看栈,评估是否强杀
找不到进程,日志停在某一时刻已被 SIGHUP 终止走断点续跑流程
进程存在但 STAT 为Z僵尸,父进程没回收检查父进程,通常要清理父进程

注意:看到进程还在,第一件要做的事是“什么都别做”。很多人下意识会kill掉重跑,结果发现自己杀掉的是一个已经跑了六个小时、只差最后两个 epoch 的任务。

2. 恢复方案选型:从救火到防火的完整谱系

2.1 三种状态对应三条完全不同的路径

恢复这件事没有万能药,因为它取决于你处在哪个状态。我把常见情况归成三类,每类的处理逻辑差别很大。

第一类是“进程活着、输出有落盘”。这是最幸运的情况,你要做的只是tail -f上去,或者用tmux重新开个窗口盯着,然后该干嘛干嘛。这种情况下唯一需要注意的是别手贱去动那个进程,尤其是在共享机器上,别人可能也在用同一个账号。

第二类是“进程活着、输出只打到终端”。这类最麻烦,因为你看不到它的进展,也没法交互。理论上它的 stdout 还指向那个已经消失的 pty,你无法从外部把这条管道“接回来”——pty 是成对的,master 端在sshd那边,sshd进程退了,master 就没了,你没法凭空造一个。可行的做法是用reptyr把进程的控制终端迁移到你现在这个 tty 上,前提是你有ptrace权限、目标进程没有被设置成不可 attach、并且它确实还挂在一个 pty 上。

第三类是“进程已经死了”。这时候不要急着原样重跑,先做两件事:从日志里找出最后完成的那一步,确认有没有可用的 checkpoint;然后复盘一下为什么会死,是不是可以用nohup或者tmux重跑一次,避免二次事故。

2.2 常见工具的横向对比

下面这张表是我这些年反复用下来总结的,覆盖了从“临时跑一次”到“长期托管”的全部场景:

方案断连后进程是否存活能否重连续屏学习成本适合场景
直接前台运行否否零只跑几十秒的短命令
cmd &看情况,多数会被 SIGHUP 带走否极低临时后台任务,不推荐
nohup cmd &是否,只能看日志文件低一次性长任务、批处理脚本
setsid cmd是否低需要完全脱离会话的场合
screen是是低老派做法,兼容性好
tmux是是,还支持分屏和滚动缓冲中日常远程开发首选
systemd-run --scope是否,但journalctl可查中需要资源限制、开机自启
supervisor是否中多进程管理、崩溃自动拉起
mosh是(连接层自动恢复)是低移动网络、频繁切换 Wi-Fi

这张表里有个容易被忽略的点:nohup和tmux解决的不是同一个问题。nohup解决的是“进程别被杀”,tmux解决的是“我还能回来看到它”。前者是保命,后者是保命加可视。所以如果你只是想让任务跑完,nohup够了;如果你还想中途看看进度、传个参数、按个Ctrl+C,那就得用tmux。

2.3 为什么我把 tmux 放在第一位

选型上我有个很朴素的判断标准:出问题的时候,我需要敲几条命令才能恢复到工作状态。nohup的答案是“一条tail -f,但看不到历史输出,也没法交互”;tmux的答案是“一条tmux attach,所有窗口、滚动缓冲、当前状态原样还原”。

tmux还有一个隐性的好处:它是一个独立于sshd的进程,会话由tmux server托管。你的网络断了,tmux server不受影响;你重连之后attach上去,看到的还是那个 pane,连光标位置都在。这种“时间被冻结”的体验,在调试长任务的时候价值极高——你不用重新cd到目录、重新激活环境、重新翻历史命令。

至于screen,它当然也能干这事,功能上也够用。只是tmux的配置更灵活、分屏更顺手、插件生态更完整,而且默认的滚动和复制模式更符合现代终端习惯。两者不冲突,机器上装哪个用哪个,但如果是从零开始,我建议直接上tmux。

3. 实操过程:四套恢复动作的具体命令

3.1 场景一:进程还活着,先把它“锁住”

假设你已经确认进程活着、日志在长。这时候第一件事不是庆祝,是防止它被二次伤害。共享机器上最常见的事故是:你重连之后发现有个同名进程,以为是残留,kill掉了,结果那是队友在跑的任务。

先给它打个标记,确认归属:

ps -o pid,ppid,user,lstart,etime,cmd -p <PID>

lstart会打出精确的启动时间,user确认是谁起的。确认是自己的之后,把这个 PID 记下来,写在一个临时文件里,避免后面忘了:

echo "<PID>" >> ~/running_tasks.txt

接下来接管输出。如果原来就重定向到了文件,直接跟就行:

tail -f -n 200 train.log

-n 200是为了先看到最近 200 行,快速判断进度到哪了。如果日志特别大,用tail -f的时候注意别用cat,那会把几个 G 的内容全刷到终端,把tmux的缓冲打爆。

如果日志没落盘,但进程还挂在 pty 上,可以试着看它的文件描述符指向哪里:

ls -l /proc/<PID>/fd/

输出里1 -> /dev/pts/2就是它的标准输出。你可以顺着这个路径判断那个 pty 还在不在(ls /dev/pts/)。如果那个 pty 已经不存在了,说明进程每次写都会失败,这时候要尽快决定是迁移还是重启。

3.2 场景二:把运行中的进程迁到新终端

reptyr是这类需求里最实用的工具,它通过ptrace把目标进程的 controlling terminal 改到你当前的 tty 上。装法很简单:

sudo apt install reptyr # 或者 sudo yum install reptyr

用法是先在tmux里开一个新窗口,然后:

reptyr <PID>

成功的话,目标进程的输出会直接出现在你当前终端里,你可以像正常前台进程一样看到它的打印,甚至按Ctrl+C中断它。这对调试特别有用——你原本以为丢了的输出,突然全回来了。

不过reptyr有几个硬性前提,踩过一次就记住了。一是需要ptrace权限,很多发行版默认的kernel.yama.ptrace_scope是 1,只允许 attach 子进程,你需要临时调成 0:

sudo sysctl -w kernel.yama.ptrace_scope=0

二是目标进程不能是多线程的复杂程序,某些运行时(比如带 GC 的虚拟机)在被 attach 后可能行为异常,甚至直接挂掉。三是如果进程正在做阻塞式 IO,迁移过程中可能短暂卡顿。所以我的建议是:reptyr适合“输出还想看,但任务不算特别关键”的场合;如果任务已经跑了很久、价值很高,宁可让它继续跑,你去tail日志,别冒这个险。

3.3 场景三:进程已死,怎么把损失降到最低

进程没了,先别慌着重跑。第一步是从日志里挖出最后状态:

tail -n 100 train.log grep -n "epoch\|step\|iteration" train.log | tail -n 20

大部分训练脚本会在日志里打步数,找到最后一个完整记录的步,就是你的恢复起点。如果脚本有 checkpoint 机制,去保存目录看看最新的文件:

ls -lt --time-style=full-iso checkpoints/ | head

注意看文件大小和时间戳。如果最新那个文件大小明显小于之前的,很可能是写到一半被中断了,这种文件加载时会报unexpected end of file或者权重形状不匹配。这时候要退回到上一个完整的 checkpoint。

判断 checkpoint 是否完整,有个小技巧:如果保存时用的是“先写临时文件再原子重命名”的方式,那目录里不该出现.tmp后缀的残留;如果出现了,说明那次保存被中断,直接删掉别用。加载前可以先做个快速校验:

python -c " import torch ckpt = torch.load('checkpoints/latest.pt', map_location='cpu') print(ckpt.keys()) print(ckpt.get('step'), ckpt.get('epoch')) "

能正常打出 key 和步数,基本就是好的。这一步花三十秒,能避免你重启之后跑了两小时才发现加载的是坏文件。

3.4 场景四:用 nohup 重跑,但这次别再犯同样的错

确认要重跑之后,命令的写法决定了你下次还会不会遇到同样的问题。最朴素的写法是这样:

nohup python -u train.py --config cfg.yaml > logs/train_$(date +%m%d_%H%M).log 2>&1 &

这里面每个部分都有讲究。nohup让进程忽略SIGHUP;-u强制 Python 不缓冲标准输出,不然你会发现日志文件十分钟都不动一下,其实是全卡在缓冲区里;2>&1把错误输出合并进同一个文件,不然报错信息会丢;日志文件名带上时间戳,避免重跑时覆盖上一次的记录——这一点非常重要,我曾经因为用了固定的train.log,第二次跑直接覆盖了第一次的崩溃现场,事后完全没法复盘。

还有一个常见误区是只写&不写nohup。&只是把进程放到后台,它仍然是当前 shell 的作业,shell 退出时照样可能收到SIGHUP。有人会说“我用了&也没死啊”,那是因为你的 shell 没有开启huponexit,或者退出方式比较温和,这属于运气,不是机制。

如果你不想用nohup,setsid是另一条路:

setsid python -u train.py > logs/train.log 2>&1 < /dev/null &

setsid会创建一个新会话,进程成为会话首进程,彻底与控制终端解绑,连SIGHUP都收不到。< /dev/null那一段是把标准输入也断开,防止进程在某些情况下等待终端输入而挂住。

跑起来之后,用disown把它从当前 shell 的作业表里摘掉,双保险:

disown -h %1

4. tmux 实战:把会话变成抗中断的工作台

4.1 安装与一份够用的配置

大部分发行版的仓库里都有:

sudo apt install tmux # Debian 系 sudo yum install tmux # RedHat 系 conda install -c conda-forge tmux # 没有 root 权限时

装完先别急着用,写一份~/.tmux.conf,能省掉后面无数次的摸索:

# 扩大滚动缓冲,默认 2000 行看长日志不够 set -g history-limit 50000 # 打开鼠标支持,可以拖动选择、滚轮翻页 set -g mouse on # 窗口编号从 1 开始,跟键盘位置对应 set -g base-index 1 setw -g pane-base-index 1 # 状态栏显示更实用的信息 set -g status-right "#{?client_prefix,#[fg=red]PREFIX ,}%H:%M %d-%b" # 减少 ESC 延迟,vim 用户会感谢这一行 set -sg escape-time 10

history-limit这一项值得单独说。默认 2000 行,跑训练的时候一屏日志就几百行,很快就被冲掉了。调到 50000 行,代价是每个 pane 多占几 MB 内存,对现代机器来说完全无所谓,但换来的是你能往回翻很久的输出,排查问题时特别值。

4.2 日常操作的六个动作

tmux的键位都是“前缀键 + 命令”,默认前缀是Ctrl+b。下面这六个动作覆盖了 95% 的日常使用:

动作命令说明
新建命名会话tmux new -s train一定起名字,别用默认数字
分离Ctrl+b然后d断开但不结束,进程继续跑
列出会话tmux ls看有哪些会话还活着
重新连接tmux attach -t train回到原来的现场
新建窗口Ctrl+b然后c一个会话里开多个窗口
垂直分屏Ctrl+b然后%一边跑任务一边看日志

我自己的习惯是每个任务一个独立会话,命名规则是“项目名-用途”,比如seg-train、seg-eval、seg-debug。这样tmux ls一眼就能看出哪个会话在干什么,不用挨个 attach 进去看。

4.3 让 pane 输出自动落盘

tmux的滚动缓冲只在内存里,服务器一重启就全没了。想要持久化,用pipe-pane把 pane 的所有输出同步写到一个文件:

tmux pipe-pane -o -t train:0.0 'cat >> ~/logs/train_pane.log'

-o表示 toggle,重复执行会关掉。-t后面是会话:窗口.窗格的定位方式。这条命令的好处是,即使你后来忘了nohup重定向,tmux层面也帮你留了一份完整记录。缺点是文件会一直增长,长任务跑几天可能几个 G,建议配合logrotate或者干脆定期truncate。

4.4 tmux 用起来的几个真实的坑

第一个坑:服务器重启后会话全没。tmux的会话是存在内存里的,机器一重启就清空。要解决这个,得装tmux-resurrect这类插件,它会定期把会话布局、工作目录、甚至 pane 里的命令历史序列化到磁盘,重启后一键恢复。装与不装,差别就是“重启后五分钟重建环境”和“重启后一切照旧”。

第二个坑:大输出把 tmux 卡住。如果某个进程疯狂打印(比如进度条每一帧都刷一行),tmux需要处理这些字符,CPU 会飙升,甚至让整个会话变得迟钝。解决办法是把进度条输出重定向到文件,终端里只看关键节点。用tqdm的话可以设disable=True,或者在tmux外跑。

第三个坑:Ctrl+b和编辑器冲突。emacs用户会发现Ctrl+b是“光标左移”,在tmux里会变成前缀键。改法是在~/.tmux.conf里换前缀,比如set -g prefix C-a,同时记得bind C-a send-prefix,否则连按两次Ctrl+a传不到内层程序。

提示:判断一个进程是否在tmux会话里,可以看环境变量TMUX,echo $TMUX有输出就说明在会话中。

5. 常见问题与排查技巧实录

5.1 高频现象速查表

下面这些是我和身边人真实遇到过的问题,按“现象—原因—处理”整理,遇到时可以直接对号入座:

现象常见原因处理方式
Write failed: Broken pipe客户端写入时连接已断属于结果不是原因,重点查网络与服务端超时
Connection reset by peer服务端或中间设备主动断开查sshd日志、查链路中间设备的会话老化时间
闲置几分钟就断服务端ClientAliveInterval或中间设备 NAT 表项超时客户端配ServerAliveInterval,服务端调保活参数
重连提示Too many authentication failures本地加载了太多密钥,逐个尝试被拒客户端配置IdentitiesOnly yes并指定密钥
提示主机密钥变更服务器重装或重建,密钥对换了确认无误后删除本地known_hosts对应条目
进程在但日志不更新输出被缓冲,或写 tty 失败被阻塞加-u、PYTHONUNBUFFERED=1,或检查 fd 指向
重连后tmux ls为空机器重启过,或用了不同的用户登录检查是否同一账号,检查系统是否重启
加载 checkpoint 报形状不匹配保存被中断,文件不完整退回上一个完整文件,并改用原子写入

关于保活配置,客户端这边可以在~/.ssh/config里加:

Host * ServerAliveInterval 60 ServerAliveCountMax 3 TCPKeepAlive yes

意思是每 60 秒发一次探测,连续 3 次没回应才判定断开。这样即使中间链路有闲置超时,连接也能被这些轻量报文“喂”着,不会莫名其妙断掉。这属于 SSH 客户端的标准配置,跟具体业务无关,建议所有远程开发机都加上。

5.2 几个不太有人讲但很救命的技巧

技巧一:断连前先看dmesg。有些断连不是网络问题,是机器内存耗尽触发了 OOM Killer,把sshd或者你的进程杀了。重连之后先跑一句dmesg -T | tail -50,如果看到Out of memory: Killed process,那就别怀疑网络了,去查内存泄漏或者调小 batch size。

技巧二:用lsof确认进程还占着哪些文件。如果进程活着但你不确定它是不是卡住了,看它打开的文件描述符:

lsof -p <PID> | grep -E "log|ckpt|data"

如果能看到日志文件还在打开状态,说明进程没退出;如果data目录下的某个文件一直处于打开但没有任何读写进展,那可能卡在数据加载上了。

技巧三:py-spy是排查 Python 进程卡死的利器。它可以在不重启、不改代码的情况下 dump 出运行中的调用栈:

pip install py-spy py-spy dump --pid <PID>

输出会告诉你每个线程当前停在哪一行。我遇到过一次任务“看起来在跑”但其实死锁的情况,py-spy一 dump 就发现主线程卡在队列的get()上,工作线程全在等锁,问题是数据加载器提前退出了。这种问题光看日志永远看不出来。

技巧四:strace看系统调用,判断是不是卡在 IO。当进程状态是D或者 CPU 占用为 0 但日志不动时:

strace -p <PID> -e trace=write,read,openat -T

-T会显示每个调用耗时。如果看到某个write卡了几十秒,基本可以确定是磁盘满了或者网络存储挂了。

技巧五:磁盘满了会伪装成各种故障。日志写不进去、checkpoint 保存失败、甚至进程莫名崩溃,都可能是No space left on device。养成习惯,出问题先跑一句:

df -h du -sh ~/* 2>/dev/null | sort -h | tail

我见过最离谱的一次是某同学的任务跑了六小时,最后保存模型时失败,一查是日志文件把磁盘写满了——因为他开了DEBUG级别并且每步都打印张量。

6. 长任务工程化:把恢复变成不必要的事

6.1 checkpoint 设计的四个细节

恢复能力的上限,取决于你的 checkpoint 设计得好不好。很多人以为“每隔几百步torch.save一下”就够了,实际用起来会发现一堆问题。

第一个细节是保存哪些状态。只存模型权重是不够的,恢复后学习率会从头开始,优化器的动量状态也丢了,效果会明显变差。完整的做法是把模型、优化器、学习率调度器、当前步数、随机种子一起打包:

ckpt = { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "step": global_step, "epoch": epoch, "seed": args.seed, "rng_state": torch.get_rng_state(), }

随机状态这一项容易被忽略,但对结果复现很重要。不加的话,恢复后的数据打乱顺序会变,两次实验没法严格对比。

第二个细节是原子写入。直接torch.save(ckpt, "latest.pt")有个致命问题:如果写到一半进程被杀,latest.pt就是一个损坏文件,而你正好只有这一个。正确做法是先写临时文件再重命名:

tmp = f"{path}.tmp" torch.save(ckpt, tmp) os.replace(tmp, path) # 同一文件系统内是原子操作

os.replace在 POSIX 系统上是原子的,要么是旧文件,要么是新文件,不会出现半个。这一条改起来只有两行代码,但能避免最糟糕的那种事故。

第三个细节是保留多个历史版本。只留latest.pt的话,万一最新那个恰好是坏的,你就只能从头再来。我的习惯是保留最近 3 个,命名带上步数:

ckpt_step_001200.pt ckpt_step_001400.pt ckpt_step_001600.pt latest.pt -> ckpt_step_001600.pt

用软链接指最新,恢复时直接读latest.pt,出问题就往前翻一个。

第四个细节是保存频率要和任务时长匹配。如果单步耗时 2 秒,每 500 步存一次,就是每 16 分钟存一次,中断最多损失 16 分钟。但如果 checkpoint 文件有 2 GB,保存本身要 30 秒,那就不能太频繁,否则 IO 会成为瓶颈。经验值是保存耗时控制在总时长的 1% 以内。

6.2 用一个启动脚本把所有约定固化下来

零散的命令记不住,我一般给每个项目写一个run.sh,把环境激活、日志命名、后台运行、PID 记录全部封进去:

#!/usr/bin/env bash set -euo pipefail TASK_NAME="${1:-train}" TS="$(date +%m%d_%H%M%S)" LOG_DIR="./logs" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/${TASK_NAME}_${TS}.log" PID_FILE="$LOG_DIR/${TASK_NAME}.pid" echo "[$(date)] start task=$TASK_NAME log=$LOG_FILE" nohup python -u train.py \ --config configs/default.yaml \ --resume auto \ > "$LOG_FILE" 2>&1 < /dev/null & echo $! > "$PID_FILE" echo "[$(date)] pid=$(cat "$PID_FILE")" echo "follow: tail -f $LOG_FILE"

这个脚本解决了几件事:日志名带时间戳不会覆盖、PID 写文件方便后续kill、< /dev/null防挂起、set -euo pipefail让脚本自己在出错时停下来而不是继续往下跑。用的时候bash run.sh train就完事了,不用每次回忆那一长串参数。

--resume auto是我习惯的一个约定:脚本自己去checkpoints/找最新的完整文件,有就从那里续,没有就从头开始。这样无论是手动重启还是被托管程序拉起,行为都是一致的,不需要人介入判断。

6.3 交给 systemd 托管,彻底不用管

如果任务要跑好几天,连tmux都懒得维护,那就交给systemd的用户实例。写一个 unit 文件放到~/.config/systemd/user/train.service:

[Unit] Description=Long running training task After=network.target [Service] Type=simple WorkingDirectory=%h/projects/demo ExecStart=/usr/bin/bash -lc 'python -u train.py --config configs/default.yaml --resume auto' Restart=on-failure RestartSec=30 StandardOutput=append:%h/logs/train.log StandardError=append:%h/logs/train.err [Install] WantedBy=default.target

然后用这几条命令管理:

systemctl --user daemon-reload systemctl --user start train systemctl --user status train journalctl --user -u train -f

关键在于Restart=on-failure,进程意外挂掉会在 30 秒后自动拉起,配合--resume auto就是一套完整的自愈机制。断连、崩溃、甚至机器重启(需要开启 lingering)都能扛住。

开启 lingering 的命令是:

loginctl enable-linger $USER

不开的话,你 SSH 一退出,用户实例会被systemd回收,服务跟着停。这一条坑过很多人——服务明明start成功了,一断开连接就没了,原因就在这里。

6.4 关于 mosh 的一点补充

如果你的断连主要是本地网络切换造成的(比如从办公室走到会议室、笔记本合盖再打开),除了在 SSH 层做保活,还可以考虑用mosh作为连接层。它在 UDP 上做状态同步,本地网络变化后会自动重连,界面不闪、会话不断,体验上像是“从来没断过”。用法很简单,服务端装好之后本地直接mosh user@host就行。

不过mosh是替代连接方式的,不是替代tmux的。它解决的是“连接抖动”,不解决“我主动关掉终端”。两者叠起来用是最舒服的组合:mosh保证连接不中断,tmux保证会话可回溯。

我个人在实际操作中的体会是,恢复能力这件事,七分靠事前设计,三分靠临场反应。事前那七分,无非就是把 checkpoint 写对、把启动方式固定成脚本、把长任务丢进tmux或者systemd;临场那三分,就是重连之后先别动手,花一分钟看清楚进程状态,再决定是接管还是重启。真正让人后悔的从来不是“断连”本身,而是断连之后那几秒钟的慌乱操作——我见过太多次,一个本来还能救的任务,被随手一条pkill -f python彻底送走。下次再遇到终端卡住,先深呼吸,敲ps,别敲kill。

返回列表