SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

SSH断开后如何让命令继续运行?

SSH断开时内核会发送SIGHUP,任务可能随即终止。本文对比nohup、disown、tmux和systemd-run,说明如何处理标准输出,以及每种方案适合的任务类型。

SSH 断开后命令为何会终止

要让命令在 SSH 断开后继续运行,必须使它进入挂断信号无法到达的位置。下面的每种方法都通过不同方式实现这一点,因此应先了解其工作机制。

您的登录会话运行在 pty(伪终端)上。这是 sshd 为您的会话在服务器上创建的虚拟终端设备。它是您 shell 以及从该 shell 启动的所有命令的控制终端。如果要了解其余过程,请参阅登录时 SSH 设置的内容。TCP 连接断开后,sshd 会关闭自己的连接端,pty 也会被销毁。内核将此视为终端挂断,因此会向该终端的前台进程组和会话领导进程发送 SIGHUP;会话领导进程就是您的 shell。SIGHUP 的默认操作是终止进程。您的命令属于前台进程组,因此会终止。

后台任务也不安全。使用 & 启动的任务位于自己的进程组中,因此内核不会直接向它发送信号。Bash 会处理此事。交互式 bash 收到 SIGHUP 后,会在退出前向其任务表中的每个任务重新发送 SIGHUP。从您的角度看,结果完全相同:任务消失,日志文件在一行中间停止写入。

这里存在一个容易混淆的不对称性。输入 exit 不会挂断后台任务,因为 bash 只有在设置了 huponexit 选项时才会执行此操作,而该选项默认关闭。连接断开则会挂断这些任务。您正常关闭终端时仍然存活的任务,在 Wi-Fi 断开后仍可能终止。

由此可以得到两个结论,这也是整个问题的核心。忽略 SIGHUP 的进程,或完全没有控制终端的进程,不会被挂断。如果进程的标准输出仍然指向已销毁的 pty,它就无处写入:写操作会因 EIO(输入/输出错误)而失败,大多数程序会在此时退出。您必须同时解决这两个问题。许多操作只解决了第一个问题,因此有人会说“nohup 不起作用”。

如果您的连接每天断开多次,也应解决这个问题。~/.ssh/config 中的 ServerAliveInterval 60 可防止路径中的 NAT(网络地址转换)因超时而丢弃空闲会话。完全无法建立的会话属于另一类故障,原因也不同;此时应了解connection refused 与 connection timed out 的区别

哪种方法可以让命令在 SSH 断开后继续运行?

以下列出 4 种方法,按任务的重要程度排序。

  • nohupsetsid:适用于现在启动、之后再查看日志的一次性任务。您需要自行重定向输出。
  • disown:适用于已经启动但忘记保护的任务。它可以挽救进程,但无法恢复进程输出。
  • tmuxscreen:适用于需要在数天内持续查看、暂停并返回处理的任务。
  • systemd-run 或正式的 unit 文件:适用于必须在您退出登录后继续运行的任务,例如运行 6 小时的 rsync 或隔夜数据库导入任务。

请记住这条规则:如果忘记任务会造成问题,就应将任务交给 systemd,而不是 tmux。tmux 窗口需要由人员记住和管理。unit 具有名称、状态、日志和重启策略,下一个接手的人无需额外说明即可找到这些信息。

nohup 和 setsid:启动程序后退出

nohup ./import.sh > ~/import.log 2>&1 &
echo $! > ~/import.pid

nohup 会将 SIGHUP 的处理方式设为忽略,然后运行您的命令。因此,内核发送挂断信号时不会产生任何效果。重定向目标需要由您指定。如果标准输出仍指向终端,nohup 会将其重定向到当前目录中的 nohup.out;如果无法这样做,则回退到 $HOME/nohup.out,并输出:

nohup: ignoring input and appending output to 'nohup.out'

该文件不易追踪,因此请自行指定文件名。$! 保存最后一个后台作业的 PID(进程标识符)。保存该值后,您重新登录时就可以检查该作业的状态。

setsid 从另一个角度解决相同问题。它会在没有控制终端的新会话中运行命令,因此不存在可以挂断该进程的终端。

setsid --fork ./import.sh > ~/import.log 2>&1

使用 --fork。如果不使用它,当进程还不是进程组组长时,setsid 会原地调用 setsid()。在 shell 脚本中通常会出现这种情况,导致脚本一直阻塞。使用 --fork 后,脚本中的行为与在提示符下执行时相同。

检查实际获得的结果:

ps -o pid,ppid,sid,tty,stat,cmd -p "$(cat ~/import.pid)"

如果 TTY 列显示 ?,表示该进程没有控制终端,因此不会被挂断。在仍保持连接时,nohup 下的 TTY 列仍会显示类似 pts/0 的内容;pty 被销毁后则会变为 ?。这两种结果都表示状态正常。该作业已成功保留。

disown:挽救已经启动的作业

您在前台启动了一个需要运行两小时的作业,随后才想起这个问题。不要终止它再重新启动。

# press Ctrl-Z to suspend the job first
bg
jobs -l
disown -h %1

Ctrl-Z会暂停作业,bg会在后台恢复作业,jobs -l会在 PID 旁显示作业编号。disown -h %1会标记该作业,使 bash 不再向其发送 SIGHUP。直接使用 disown %1会将作业从 bash 的作业表中完全删除,这对挂起信号有相同效果,但之后 jobs将不再列出该作业。

disown无法做的是迁移输出。进程仍将 pty 作为标准输出,pty 消失后,下一次写入会返回 EIO。因此,disown可以可靠地保护安静的作业,例如将输出写入文件的编译任务,但通常无法保护输出频繁的作业。作业可能在没有输出目标的情况下继续运行,也可能在下一次输出时终止。

还有一种用于处理文件描述符的挽救工具。reptyr可以将运行中的进程迁移到当前终端:使用 sudo apt install -y reptyr安装它,然后在 tmux 窗口中运行 reptyr <pid>。它通过 ptrace工作;Ubuntu 提供 kernel.yama.ptrace_scope = 1,该工具只允许跟踪您自己的子进程,因此对于继承来的进程,需要使用 sudo reptyr <pid>。请将其视为应急工具,不要以此构建常规操作流程。

tmux:需要监控并稍后返回的工作

tmux(终端复用器)从另一个层面解决了这个问题。它不会保护进程免受 pty 影响,而是为进程提供一个不属于 SSH 会话的 pty。tmux服务器在该会话之外运行,并拥有其中所有内容的终端。SSH 连接只是附加到它的查看器。断开连接时,服务器不会察觉。

sudo apt update && sudo apt install -y tmux
tmux new -s import

在该窗口中启动任务,然后按 Ctrl-b,再按 d 解除连接。稍后重新登录并恢复任务:

tmux ls
tmux attach -t import

tmux ls 应输出以 import: 1 windows 开头的一行。如果输出 no server running on /tmp/tmux-1000/default,则没有可附加的会话,因为会话可能从未创建,或者某个进程终止了服务器。

screen 也能完成相同的工作,但使用不同的按键。screen -S import 创建会话,然后按 Ctrl-a,再按 d 与其解除连接。screen -ls 列出当前存在的会话,screen -r import 恢复其中一个会话。在这里使用任一工具都可以。人们最容易忘记的是解除连接所用的按键。

对于必须在连接意外断开后继续运行的交互式任务,终端复用器同样是合适的运行环境。因此,在 VPS 的 tmux 中运行 Claude Code 是常见配置;在每隔几分钟就重新连接一次的移动网络上,通过手机控制服务器会话 也因此变得可用。

systemd-run:将任务交给 PID 1

对于完全不应依赖您的任务,请将其交给 init 系统。

sudo systemd-run --unit=bigsync --collect /usr/bin/rsync -aH --stats /srv/data/ /mnt/backup/

这会创建名为 bigsync.service 的临时服务单元。它拥有独立的 cgroup,没有控制终端,也不依赖您的登录会话。命令会立即返回,并输出 Running as unit: bigsync.service。您可以使用以下任一命令监控它:

systemctl status bigsync
journalctl -u bigsync -f

--collect 会让 systemd 在单元完成后将其删除,即使任务失败也一样。如果不使用该选项,失败的临时单元会保持加载状态,其名称也会继续被占用,因此下次运行会失败,并提示该单元已存在。输出会写入 journal,每行都带有时间戳。只有在存在 /var/log/journal 时,journal 条目才会在重启后保留;如需此功能,请运行 sudo mkdir -p /var/log/journal,然后重启 systemd-journald

以普通用户身份调用 systemd-run 而不使用 sudo 时,会请求 polkit 授权,并输出 ==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===。系统单元请使用 sudo

您也可以在自己的用户管理器下运行任务:

systemd-run --user --unit=bigsync --collect /usr/bin/rsync -aH /srv/data/ /mnt/backup/

这里有一个陷阱。您的每用户管理器 user@1000.service 通常会在最后一个会话结束时停止,并同时停止所有用户单元。请启用 lingering:

loginctl enable-linger "$USER"
loginctl show-user "$USER" --property=Linger

第二条命令应输出 Linger=yes。启用 lingering 后,您的用户管理器会在启动时启动,无论您是否登录都会持续运行。未启用时,systemd-run --usernohup 相比没有任何额外作用。

systemd-run --scope 是另一回事。它会在前台运行命令,并将命令附加到您的终端,因此不适用于这里。

对于需要多次运行的任务,应将单元写入文件,而不是每次都输入临时单元。

需要重复运行的任务的永久单元
[Unit]
Description=Nightly data sync
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/srv/data
ExecStart=/usr/local/bin/nightly-sync.sh

将其保存为 /etc/systemd/system/nightly-sync.service,运行 sudo systemctl daemon-reload,然后使用 sudo systemctl start nightly-sync 启动,并使用 journalctl -u nightly-sync 读取。若任务应按计划运行而不是按需运行,请添加匹配的 .timer 文件。

编写 systemd 服务单元及其计时器完整介绍文件格式和计划语法。

输出到哪里,以及为什么会消失

重定向的顺序很重要。> file 2>&1先将标准输出指向文件,然后将标准错误也指向同一位置。2>&1 > file的顺序相反:标准错误仍输出到终端,而终端即将消失。Bash 还接受 &> file,用它可同时重定向两个流。

另一个容易忽略的问题是缓冲。当标准输出连接到终端时,C 库会逐行刷新。当标准输出连接到文件时,它会改用几 KB 的块缓冲,因此 tail -f ~/import.log 可能几分钟都没有输出,看起来像是任务已停止。使用 stdbuf -oL ./import.sh > ~/import.log 2>&1 强制启用行缓冲,或使用程序自身的选项,例如 python3 -ugrep --line-buffered

避免使用以下模式:

nohup ./import.sh 2>&1 | tee ~/import.log &

nohup 只保护 import.sh,不会保护其他内容。tee 是同一管道中的独立进程,仍会因挂断而退出。随后,import.sh 会向没有读取端的管道写入数据,因此接收 SIGPIPE 并停止。将整个管道放入 setsid bash -c '...' 中,或者直接写入文件,重新连接后再对该文件运行 tail -f

对于 rsync,还需注意一点。--info=progress2 会输出一串回车符,在终端上显示正常,但在日志文件或 journal 中会变成一行非常长的内容。无人值守运行时,应去掉它,改用 --stats

为什么在 shell 中可运行的作业在 systemd 或 cron 下会失败

交互式 shell 会读取 /etc/profile~/.profile~/.bashrc,因此包含您的 PATH、版本管理器 shim 以及已导出的变量。systemd 单元不会读取其中任何内容。cron 也不会读取:在 Debian 和 Ubuntu 上,cron 会使用 SHELL=/bin/shPATH=/usr/bin:/bin 运行作业。

在 systemd 下,常见症状是 systemctl status 报告 (code=exited, status=203/EXEC)。这表示 systemd 根本无法执行该文件,原因可能是路径错误,或文件未标记为可执行。在 cron 下,通常是 command not found,由本地邮件系统发送;如果未安装邮件系统,则可能完全不会送达。

在花费一小时猜测之前,先确认作业实际使用的环境:

sudo systemd-run --collect --wait --unit=envtest /usr/bin/env
journalctl -u envtest --no-pager

这会打印作业运行时使用的确切环境。然后修复环境差异。您自己的文件应使用绝对路径,因为 systemd 会根据固定的系统路径列表解析裸 rsync,不会使用 shell 的 PATH。在命令行中使用 -p Environment="KEY=value" 传递所需变量,或在单元文件中使用 EnvironmentFile=/etc/default/myjob。如果作业确实需要您的登录环境,请使用 /bin/bash -lc 'my-command' 运行,但要接受该作业现在依赖您的 dotfiles。

仍会终止分离任务的因素

  • 重启。tmux 不会跨重启保留任何状态,因为服务器是普通进程,会话是其内存中的状态。内核更新意味着需要重启,因此无法轻易重启的任务应放入可以由 systemctl enable 管理的单元中。
  • Out-of-memory killer。dmesg -T | grep -i 'killed process' 可以显示相关信息,包括它选择终止的进程名称。在小型 VPS 上执行大型导入时,经常会成为目标。
  • logind 清理。如果 /etc/systemd/logind.conf 设置了 KillUserProcesses=yes,最后一个会话结束时,残留进程会被终止,其中也包括 tmux 服务器。使用 loginctl show --property=KillUserProcesses 检查当前设置,并使用 loginctl enable-linger "$USER" 将您的用户排除在外。
  • 磁盘已满。任务停止是因为重定向的日志填满了文件系统,而不是因为您退出了会话。在判断信号原因前,先运行 df -h

通过 SSH 启动作业而不保持连接

ssh vps 'sudo systemd-run --unit=import --collect /usr/local/bin/import.sh'

systemd-run会在单元启动后立即返回,因此 ssh 命令也会返回,作业不会与启动它的会话建立连接。这是干净的做法。

nohup版本需要更加谨慎:

ssh vps 'nohup ./import.sh > ~/import.log 2>&1 < /dev/null &'

如果不进行重定向,该命令看起来会一直挂起。只要仍有进程持有远程命令的标准输出或标准错误,sshd 就会保持通道打开,而后台作业会继承这两个文件描述符。单独使用 nohup 无法解决问题,因为 nohup 仅在输出目标是终端时才会重定向输出,而这里的输出通过管道返回到客户端。添加 < /dev/null 后,输入端也会关闭。ssh -n 在客户端一端执行相同的操作。

FAQ

我的命令为什么会在 SSH 连接断开后停止?

会话使用的 pty(伪终端)被销毁后,内核会向该终端上的前台进程组发送 SIGHUPSIGHUP 的默认操作是终止进程。后台作业也会终止,因为 bash 退出前会向其作业表中的每个作业重新发送 SIGHUP。忽略 SIGHUP 的命令(例如使用 nohup 启动的命令),或从未共享该会话的命令(例如 systemd 单元),不受影响。

对于运行六小时的 rsync,tmux 还是 systemd-run 更好?

systemd-run。tmux 会话依赖于由您启动的服务器进程,因此会在下一次重启时结束;不知道运行 tmux ls 的人也无法看到该会话。运行 sudo systemd-run --unit=bigsync --collect /usr/bin/rsync -aH --stats /srv/data/ /mnt/backup/ 会为状态提供 systemctl status bigsync,为输出提供 journalctl -u bigsync;下一位管理员无需额外说明即可找到这两者。需要查看屏幕并向其中输入内容时,使用 tmux。

如何查看忘记重定向的作业输出?

通常无法查看,因为这些输出已写入不存在的终端。进程仍在运行时,可以使用 sudo ls -l /proc/<pid>/fd 检查其打开的文件,或使用 sudo strace -p <pid> 监控其系统调用,但已经写出的文本无法恢复。reptyr <pid> 可以将进程迁移到新的终端;在 Ubuntu 中,对于不属于您的子进程,需要使用 kernel.yama.ptrace_scope = 1sudo。避免这些问题的做法是,在启动时就将输出重定向到文件,并 tail -f 该文件。

分离的 tmux 会话能否在重启后继续存在?

不能。tmux 服务器是普通进程,会话是其内存中的状态,因此重启会同时结束服务器和会话。当 /etc/systemd/logind.conf 设置 KillUserProcesses=yes,并且您退出最后一个会话时,tmux 服务器也会终止;loginctl enable-linger "$USER" 可以防止这种情况。需要在重启后自动恢复的工作,应编写 systemd 单元并 systemctl enable 它。

我的脚本在 shell 中可以运行,但作为 systemd 单元却失败,为什么?

单元不会读取 /etc/profile~/.bashrc,因此既没有您的 PATH 添加项,也没有您导出的变量。systemctl status 显示 (code=exited, status=203/EXEC),表示 systemd 根本无法执行该文件,因此应使用绝对路径并检查可执行位。运行 sudo systemd-run --collect --wait --unit=envtest /usr/bin/env,再使用 journalctl -u envtest 读回它,即可得到作业实际使用的完整环境。使用 Environment=EnvironmentFile= 提供缺少的内容。

#SSH#tmux#nohup#systemd#long-running-jobs