SSH后台命令卡住?4种方法让SSH立即返回
2025-04-16 12:52:46
解决 SSH 执行后台命令不立即返回的问题
咱们在用 SSH 远程跑脚本的时候,有时会碰到一个挺烦人的情况:脚本里明明用了 &
把命令丢到后台执行了,想着 SSH 连接能马上断开,结果它非得等后台那个命令彻底跑完才肯退出。本地跑脚本明明是立刻返回提示符的。
问题:SSH 远程执行脚本,后台任务卡住了返回
举个例子,假设远程服务器 /home/mike/
目录下有个 test.sh
脚本:
#!/bin/bash
# 启动一个后台任务,2秒后输出 "done"
(
sleep 2
echo "done"
) &
# 主脚本逻辑在这里可以继续,或者直接退出
# 这个脚本本身执行是很快的
echo "Main script exiting now."
在本地服务器上直接运行这个脚本:
$ ./test.sh
Main script exiting now.
$ # (提示符立刻出现)
# (过了2秒)
done
看,提示符 $
立刻就回来了,脚本里的 echo "Main script exiting now."
也打印了。后台的 sleep 2; echo "done"
在 2 秒后自己默默输出。
但是,通过 SSH 去执行同一个脚本:
$ ssh [email protected] /home/mike/test.sh
Main script exiting now.
# (光标在这里卡住,等待...)
# (过了2秒)
done
$ # (直到 "done" 输出后,SSH 才退出,提示符出现)
这就怪了,SSH 命令死活要等到后台那个 sleep
结束,done
被打印出来之后,它才结束返回。
有人可能会说:“用 ssh -f
选项啊!” 这个选项确实能让 SSH 命令本身立刻返回,把整个远程命令(包括它的子进程)都丢到后台。但 ssh -f
有个特点:它会在本地保留一个 SSH 进程,保持连接,直到远程命令彻底结束。有时候我们不想要这种行为,比如我们就是想触发一个远程任务然后彻底断开,不希望本地还留着一个 SSH 进程。我们想要的是,远程的父脚本执行完,SSH 就该返回,别管父脚本启动的那些后台“孤儿”进程。
为什么 SSH 会等待后台命令?
要搞清楚这个问题,得稍微了解下 SSH 是怎么管理远程会话和标准输入输出(stdin, stdout, stderr)的。
当你执行 ssh user@host command
时,SSH 会在远程主机上启动一个 shell(通常是用户的默认 shell),然后在这个 shell 里执行你指定的 command
。SSH 客户端和远程服务器上的 sshd
守护进程之间会建立一个通道,用来传输命令、程序的输出以及可能的输入。
关键点来了:SSH 会话通常会保持打开状态,直到远程执行的初始命令(这里是 /home/mike/test.sh
)及其所有直接或间接打开的标准 I/O 流(stdout, stderr)都关闭为止。
在我们的 test.sh
例子里:
- SSH 在远程主机上启动 shell,运行
/home/mike/test.sh
。 test.sh
脚本执行,打印 "Main script exiting now."。test.sh
启动了一个子进程(...) &
,这个子进程继承了父脚本的标准输出和标准错误。这个子进程最终会执行echo "done"
。- 虽然
test.sh
主脚本很快就执行完了(打印完 "Main script exiting now." 就没了),但它启动的那个后台子进程还活着呢!并且,这个子进程的标准输出和标准错误还连接着 SSH 会话的通道。 - SSH 认为:“嗯,虽然主脚本退出了,但还有个跟它相关的进程(那个后台的子shell)的标准输出/错误没关掉呢,我得等等它。”
- 直到 2 秒后,后台子进程执行了
echo "done"
,输出了内容,然后彻底退出,关闭了自己的标准输出/错误。 - 这时,SSH 发现所有与初始命令相关的标准 I/O 都关闭了,于是它终于关闭连接,
ssh
命令行客户端也随之退出。
这就是为啥 SSH 会“卡住”等待。它在等所有可能产生输出到这次 SSH 连接的进程都结束。
解决方案:让 SSH "脱钩" 后台进程
明白了原因,解决思路就清晰了:我们得想办法让后台启动的进程跟当前的 SSH 会话彻底“脱钩”,尤其是断开标准输入、输出、错误的关联。这样 SSH 就不会觉得还有“遗留问题”需要等待了。
下面介绍几种常用且有效的方法:
方法一:使用 nohup
nohup
(no hang up) 是一个经典的 Unix 命令,专门用来让你启动的命令在你退出登录(hang up)后还能继续运行。它的核心作用是:
- 忽略 SIGHUP 信号: 当你关闭终端或 SSH 连接时,系统通常会向该会话启动的进程发送 SIGHUP 信号,让它们终止。
nohup
会让进程忽略这个信号。 - 重定向标准输出/错误: 如果标准输出和标准错误还是连着终端(在 SSH 场景下就是连着 SSH 通道),
nohup
会默认把它们重定向到当前目录下一个叫做nohup.out
的文件里。如果当前目录不可写,它会尝试重定向到$HOME/nohup.out
。
通过重定向 I/O,后台进程就跟 SSH 会话的 I/O 通道断开了。
操作步骤/代码:
修改 SSH 命令,在远程命令前加上 nohup
,并建议显式处理输出重定向。
# 基本用法,输出到 nohup.out
ssh [email protected] 'nohup /home/mike/test.sh &'
# 推荐:将标准输出和标准错误都重定向到 /dev/null (如果不需要日志)
ssh [email protected] 'nohup /home/mike/test.sh > /dev/null 2>&1 &'
# 或者重定向到指定日志文件
ssh [email protected] 'nohup /home/mike/test.sh > /path/to/your/logfile.log 2>&1 &'
解释:
nohup ... &
:用nohup
运行/home/mike/test.sh
,并把它放到后台。>
: 重定向标准输出 (stdout)。/dev/null
: 这是个特殊的文件,所有写入它的数据都会被丢弃(像个黑洞)。2>&1
: 把标准错误 (stderr, 文件符为 2) 重定向到标准输出 (stdout, 文件符为 1) 当前指向的地方。因为前面 stdout 已经指向了/dev/null
或日志文件,所以 stderr 也会被一并重定向过去。- 最后一个
&
:确保nohup
命令本身也在后台执行,这样 SSH 启动nohup
后就能立刻认为主命令结束了。
用了 nohup
并且正确重定向 I/O 后,SSH 启动 nohup /home/mike/test.sh ... &
这个命令,nohup
会启动 test.sh
并处理好信号和 I/O 重定向,然后 nohup
命令自身立刻退出。SSH 发现初始命令 (nohup
) 结束了,而且没有打开的 I/O 流连着它,于是 SSH 连接就立即关闭并返回了。后台的 test.sh
(以及它启动的子进程)则由 nohup
罩着继续运行。
安全建议:
- 如果输出重定向到文件(而不是
/dev/null
),确保该文件的权限设置得当,避免敏感信息泄露。 - 确保日志文件不会无限增长撑爆磁盘,考虑使用日志轮转工具(logrotate)。
进阶技巧:
nohup
命令自身会输出类似nohup: ignoring input and appending output to 'nohup.out'
的提示信息到 stderr。如果你连这个提示都不想要,可以把nohup
命令本身的 stderr 也重定向:nohup ... > /dev/null 2>&1 &
可能不够,有时需要这样写:(nohup /home/mike/test.sh > /dev/null 2>&1 &) > /dev/null 2>&1
或者nohup sh -c '/home/mike/test.sh > /dev/null 2>&1 &' > /dev/null 2>&1 &
来确保万无一失地静默。- 如果
test.sh
里面还需要更精细的后台任务管理,nohup
主要解决的是与 SSH 会话脱钩的问题。
方法二:双重 fork
这是 Unix/Linux 系统编程里一种标准的创建守护进程(daemon)的技巧。原理稍微复杂点,但效果很彻底。
原理:
- 第一次 fork: 父进程(初始的
test.sh
)创建一个子进程。 - 父进程退出: 父进程立刻退出。SSH 看到初始命令(父进程)退出了,可能会提前关闭连接(取决于具体的 SSH 实现和时机,但这是关键一步)。
- 子进程成为新会话领导: 子进程调用
setsid()
创建一个新的会话(session),并成为该会话的领导者,同时脱离原来的控制终端。 - 第二次 fork: 子进程(现在是会话领导)再创建一个孙子进程。
- 子进程退出: 子进程也立刻退出。
- 孙子进程成为孤儿: 孙子进程的父进程(那个中间的子进程)退出了,它就成了“孤儿”,会被系统的
init
进程(PID 1)或者 systemd 等进程收养。此时,孙子进程跟原来的 SSH 会话已经没有任何关系了。 - 孙子进程干活: 孙子进程关闭或重定向自己的标准输入、输出、错误(非常重要!),然后开始执行真正的后台任务(比如
sleep 2; echo "done"
)。
因为与 SSH 交互的那个初始脚本进程很快就退出了,并且通过两次 fork 和 setsid
,最终干活的进程彻底与 SSH 会话脱离了关系(包括进程组、会话和控制终端),SSH 自然就不会再等它了。
操作步骤/代码:
需要修改 test.sh
脚本来实现这个逻辑。下面是一个简化的 bash 脚本示例:
#!/bin/bash
# 后台真正要执行的任务
run_background_task() {
# 重要:重定向标准输入输出错误,彻底断开与终端的联系
exec 0</dev/null # 关闭标准输入
exec 1>/tmp/myscript.log # 标准输出重定向到日志文件
exec 2>&1 # 标准错误也重定向到日志文件
# 在这里放实际的后台命令
(
sleep 10
echo "Background task finished at $(date)"
) &
# 注意:这里内部如果还需要启动子后台进程,也可能需要小心处理
# 为了演示简单,这里直接让 run_background_task 包含了 sleep
# 或者直接在这里执行守护进程的启动命令
# 例如:/usr/local/bin/mydaemon --config /etc/mydaemon.conf
}
# 实现双重 fork 的逻辑
daemonize() {
# 第一次 fork
"$@" & # 把函数调用自身(带参数)放到后台
local parent_pid=$!
disown $parent_pid # 可选,尝试让父进程不关心这个子进程的退出状态
exit 0 # 父进程立刻退出
}
# 检查是否是第一次 fork 出来的子进程
if [ "$1" != "--child-stage-1" ]; then
# === 父进程阶段 ===
# 调用 daemonize 函数,它会 fork 并让父进程退出
daemonize "$0" --child-stage-1 # $0 是脚本自身路径
# 父进程执行到这里就会 exit 0
fi
# === 子进程阶段(第一次 fork 后) ===
# 创建新会话,脱离控制终端
setsid_exists=$(command -v setsid)
if [ -z "$setsid_exists" ]; then
echo "Error: setsid command not found. Cannot properly daemonize." >&2
exit 1
fi
setsid "$0" --child-stage-2 "$@" # setsid 会执行命令并退出,我们让它执行脚本的下一阶段
# 理论上 setsid 成功执行后,这里的代码不会被执行到,因为 setsid 启动的新进程会继续
exit 0 # 中间子进程也退出
# 检查是否是第二次 fork 出来的孙子进程 (实际上是 setsid 启动的新会话里的进程)
if [ "$1" != "--child-stage-2" ]; then
# 这段逻辑理论上不应该被执行到
exit 1
fi
# === 孙子进程阶段 ===
# 执行真正的后台任务函数
run_background_task
# 孙子进程(守护进程)开始运行,直到任务完成或被终止
exit 0 # 任务完成后退出(对于长时间运行的守护进程,这里可能不会执行)
执行 SSH 命令:
ssh [email protected] /path/to/your/double_fork_script.sh
SSH 执行这个脚本,脚本内部通过双重 fork 逻辑,使得父进程迅速退出,真正的后台任务由一个与 SSH 会话完全无关的孙子进程接管。SSH 连接会很快关闭。
安全建议:
- 守护进程必须正确处理信号(如 SIGTERM),以便能被正常停止。
- 要有日志记录机制,方便排查问题。
- 考虑使用 PID 文件来防止重复启动,并方便管理(停止、重启)。
/var/run/myapp.pid
- 确保日志文件和 PID 文件的权限安全。
进阶技巧:
- 上面的脚本是用 bash 写的,实际的守护进程可能用 C、Python、Go 等语言编写,它们有更完善的库来实现守护进程化(daemonization),包含设置工作目录、文件模式创建掩码(umask)、关闭所有非必要文件描述符等步骤。
setsid()
是关键,它能确保进程脱离控制终端。有些简单的双重 fork 实现可能没用setsid
,效果会差一些。- 可以使用现成的工具如
daemonize
(一个小巧的 C 程序) 来帮你处理双重 fork、setsid
、重定向等细节,简化脚本:
或者在你的脚本内部调用# 假设远程主机安装了 daemonize 工具 ssh [email protected] 'daemonize /path/to/your/actual_task_script.sh'
daemonize
。
方法三:使用 disown
(Bash 特有)
如果远程服务器上执行命令的 shell 恰好是 Bash,可以用 Bash 内建的 disown
命令。
原理:
Bash 会维护一个“作业列表”(job list),记录由它启动的后台任务。当你退出 Bash shell 时,它通常会向作业列表中的所有任务发送 SIGHUP 信号。disown
命令可以将一个指定的作业从这个列表中移除。一旦移除,Bash 退出时就不会再给它发 SIGHUP 信号了,这个作业就能继续在后台运行。
disown
主要解决的是 SIGHUP 信号问题,但它本身不处理 I/O 重定向。不过,如果一个进程的 I/O 没有连到终端,并且它也不会被 SIGHUP 杀死,那么 SSH 可能也会因为它不再被“负责”而提前退出。
操作步骤/代码:
需要确保远程命令在 Bash 环境下执行,并且在后台符 &
之后紧跟 disown
。
ssh [email protected] 'bash -c "/home/mike/test.sh & disown"'
或者如果 test.sh
自身就是用 Bash 写的,并且它的内容是启动一个更内部的后台任务:
# test.sh 内容示例
#!/bin/bash
(
# 真正的后台任务
sleep 10
echo "Inner task done" > /tmp/inner.log
) &
# 把刚刚启动的最后一个后台作业 (%) 从 job list 移除
disown %+
echo "Main script exiting."
然后 SSH 执行:ssh [email protected] /home/mike/test.sh
。
解释:
... &
: 启动后台作业。disown
: (无参数时) 移除最后一个后台作业。disown %1
移除第一个作业,disown -h %1
则移除的同时标记作业忽略 SIGHUP (更保险)。bash -c "..."
: 确保命令是在一个新的 Bash 实例中执行,这样&
和disown
的行为符合预期。
这种方法的成功有时也依赖于 I/O 是否被重定向。如果后台进程仍然尝试读写连接到 SSH 的 I/O,SSH 可能还是会等待。所以,即使使用 disown
,最好也配合 I/O 重定向。
ssh [email protected] 'bash -c "(/home/mike/test.sh > /dev/null 2>&1 &) ; disown"'
# 注意这里用括号包起来,确保重定向应用到 test.sh 进程
# 然后在括号外 disown
安全建议:
disown
是 Bash 特有的,如果远程用户的默认 shell 不是 Bash,或者 SSH 配置强制使用其他 shell,这个方法会失败。可靠性不如nohup
或双重 fork。- 同样需要管理好后台进程,避免资源耗尽或成为僵尸进程。
进阶技巧:
- 了解 Bash 的作业控制 (
jobs
,bg
,fg
,disown
,kill %jobid
) 对理解disown
很有帮助。 disown -a
可以移除所有作业。disown
主要解决了 SIGHUP 问题,对于 I/O 的处理不如nohup
直接。通常nohup
更省心。
方法四:使用 screen
或 tmux
screen
和 tmux
是终端多路复用器(terminal multiplexer)。它们允许你创建持久化的会话(session),在会话里可以运行多个窗口(window)和窗格(pane),并且即使你断开 SSH 连接,这些会话和在里面运行的程序还会继续在后台运行。
原理:
你在 SSH 命令里启动一个 screen
或 tmux
会话,并让它在“分离”(detached)模式下运行你的脚本。screen
或 tmux
会创建一个独立的、由它们自己管理的运行环境。你的脚本在这个环境里跑。SSH 命令的任务仅仅是启动这个分离的会话,一旦启动成功,SSH 命令本身就完成了,可以立刻返回。之后你想查看脚本状态或输出时,可以重新 SSH 登录,然后“附加”(attach)回那个 screen
或 tmux
会话。
操作步骤/代码:
使用 screen
:
# 启动一个名为 'myjob' 的 screen 会话,在其中运行脚本,然后立即分离
ssh [email protected] 'screen -dmS myjob /home/mike/test.sh'
-d
: 启动后立即分离 (detach)。-m
: 强制创建一个新会话,即使当前不在一个终端内(适合在脚本里用)。-S myjob
: 给会话命名为myjob
,方便之后查找和附加。
使用 tmux
:
# 启动一个名为 'myjob' 的 tmux 会话,在其中运行脚本,然后立即分离
ssh [email protected] 'tmux new-session -d -s myjob /home/mike/test.sh'
new-session
: 创建一个新会话。-d
: 启动后立即分离 (detach)。-s myjob
: 给会话命名为myjob
。
SSH 执行上述任一命令后,会立刻返回。远程服务器上会有一个 screen
或 tmux
会话在后台运行 /home/mike/test.sh
。
安全建议:
- 需要确保远程服务器上安装了
screen
或tmux
。 - 长期运行的后台任务可能会产生大量输出,塞满
screen
/tmux
的回滚缓冲区。脚本内部最好还是做好日志重定向。 - 管理好不再需要的会话,定期清理 (
screen -wipe
,tmux kill-session -t myjob
),避免资源浪费。 screen
/tmux
会话可能被其他登录到同一用户账号的人附加,注意权限和隔离。
进阶技巧:
- 重新附加:
screen -r myjob
或screen -x myjob
(多用户附加)tmux attach-session -t myjob
或tmux a -t myjob
- 查看会话列表:
screen -ls
tmux ls
- 可以在
screen
/tmux
命令里执行更复杂的 shell 命令,例如:tmux new-session -d -s myjob 'cd /some/dir && ./run_complex_task.sh'
screen
和tmux
功能强大,值得深入学习它们本身的用法。
方案选择考量
- 简单快捷: 如果只是想快速解决 SSH 等待问题,并且对后台任务的健壮性要求不是特别高(比如任务本身比较简单,或者能容忍偶尔因意外信号中断),
nohup ... > /dev/null 2>&1 &
是最常用也比较简单的选择。 - 健壮的守护进程: 如果要启动的是一个需要长期稳定运行、并且需要良好管理的后台服务(守护进程),实现标准的双重 fork(可以借助
daemonize
工具或语言库)是更规范、更可靠的做法。 - Bash 环境依赖:
disown
方法最轻量,但依赖于远程是 Bash shell,且对于 I/O 处理不如nohup
明确,通用性稍差。 - 交互式管理和持久化: 如果你不仅想让 SSH 立刻返回,还希望之后能方便地回去查看任务的实时输出、甚至交互(虽然启动守护进程一般不需要交互),或者任务需要一个完整的终端环境,那么
screen
或tmux
是不二之选。它们提供了一个完整的会话管理框架。
根据你的具体需求、对后台任务的要求以及对复杂度的接受程度,选择最合适的方案即可解决 SSH 执行后台命令不立即返回的问题。