返回

SSH后台命令卡住?4种方法让SSH立即返回

Linux

解决 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 例子里:

  1. SSH 在远程主机上启动 shell,运行 /home/mike/test.sh
  2. test.sh 脚本执行,打印 "Main script exiting now."。
  3. test.sh 启动了一个子进程 (...) &,这个子进程继承了父脚本的标准输出和标准错误。这个子进程最终会执行 echo "done"
  4. 虽然 test.sh 主脚本很快就执行完了(打印完 "Main script exiting now." 就没了),但它启动的那个后台子进程还活着呢!并且,这个子进程的标准输出和标准错误还连接着 SSH 会话的通道。
  5. SSH 认为:“嗯,虽然主脚本退出了,但还有个跟它相关的进程(那个后台的子shell)的标准输出/错误没关掉呢,我得等等它。”
  6. 直到 2 秒后,后台子进程执行了 echo "done",输出了内容,然后彻底退出,关闭了自己的标准输出/错误。
  7. 这时,SSH 发现所有与初始命令相关的标准 I/O 都关闭了,于是它终于关闭连接,ssh 命令行客户端也随之退出。

这就是为啥 SSH 会“卡住”等待。它在等所有可能产生输出到这次 SSH 连接的进程都结束。

解决方案:让 SSH "脱钩" 后台进程

明白了原因,解决思路就清晰了:我们得想办法让后台启动的进程跟当前的 SSH 会话彻底“脱钩”,尤其是断开标准输入、输出、错误的关联。这样 SSH 就不会觉得还有“遗留问题”需要等待了。

下面介绍几种常用且有效的方法:

方法一:使用 nohup

nohup (no hang up) 是一个经典的 Unix 命令,专门用来让你启动的命令在你退出登录(hang up)后还能继续运行。它的核心作用是:

  1. 忽略 SIGHUP 信号: 当你关闭终端或 SSH 连接时,系统通常会向该会话启动的进程发送 SIGHUP 信号,让它们终止。nohup 会让进程忽略这个信号。
  2. 重定向标准输出/错误: 如果标准输出和标准错误还是连着终端(在 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)的技巧。原理稍微复杂点,但效果很彻底。

原理:

  1. 第一次 fork: 父进程(初始的 test.sh)创建一个子进程。
  2. 父进程退出: 父进程立刻退出。SSH 看到初始命令(父进程)退出了,可能会提前关闭连接(取决于具体的 SSH 实现和时机,但这是关键一步)。
  3. 子进程成为新会话领导: 子进程调用 setsid() 创建一个新的会话(session),并成为该会话的领导者,同时脱离原来的控制终端。
  4. 第二次 fork: 子进程(现在是会话领导)再创建一个孙子进程。
  5. 子进程退出: 子进程也立刻退出。
  6. 孙子进程成为孤儿: 孙子进程的父进程(那个中间的子进程)退出了,它就成了“孤儿”,会被系统的 init 进程(PID 1)或者 systemd 等进程收养。此时,孙子进程跟原来的 SSH 会话已经没有任何关系了。
  7. 孙子进程干活: 孙子进程关闭或重定向自己的标准输入、输出、错误(非常重要!),然后开始执行真正的后台任务(比如 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 更省心。

方法四:使用 screentmux

screentmux 是终端多路复用器(terminal multiplexer)。它们允许你创建持久化的会话(session),在会话里可以运行多个窗口(window)和窗格(pane),并且即使你断开 SSH 连接,这些会话和在里面运行的程序还会继续在后台运行。

原理:

你在 SSH 命令里启动一个 screentmux 会话,并让它在“分离”(detached)模式下运行你的脚本。screentmux 会创建一个独立的、由它们自己管理的运行环境。你的脚本在这个环境里跑。SSH 命令的任务仅仅是启动这个分离的会话,一旦启动成功,SSH 命令本身就完成了,可以立刻返回。之后你想查看脚本状态或输出时,可以重新 SSH 登录,然后“附加”(attach)回那个 screentmux 会话。

操作步骤/代码:

使用 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 执行上述任一命令后,会立刻返回。远程服务器上会有一个 screentmux 会话在后台运行 /home/mike/test.sh

安全建议:

  • 需要确保远程服务器上安装了 screentmux
  • 长期运行的后台任务可能会产生大量输出,塞满 screen/tmux 的回滚缓冲区。脚本内部最好还是做好日志重定向。
  • 管理好不再需要的会话,定期清理 (screen -wipe, tmux kill-session -t myjob),避免资源浪费。
  • screen/tmux 会话可能被其他登录到同一用户账号的人附加,注意权限和隔离。

进阶技巧:

  • 重新附加:
    • screen -r myjobscreen -x myjob (多用户附加)
    • tmux attach-session -t myjobtmux a -t myjob
  • 查看会话列表:
    • screen -ls
    • tmux ls
  • 可以在 screen/tmux 命令里执行更复杂的 shell 命令,例如:tmux new-session -d -s myjob 'cd /some/dir && ./run_complex_task.sh'
  • screentmux 功能强大,值得深入学习它们本身的用法。

方案选择考量

  • 简单快捷: 如果只是想快速解决 SSH 等待问题,并且对后台任务的健壮性要求不是特别高(比如任务本身比较简单,或者能容忍偶尔因意外信号中断),nohup ... > /dev/null 2>&1 & 是最常用也比较简单的选择。
  • 健壮的守护进程: 如果要启动的是一个需要长期稳定运行、并且需要良好管理的后台服务(守护进程),实现标准的双重 fork(可以借助 daemonize 工具或语言库)是更规范、更可靠的做法。
  • Bash 环境依赖: disown 方法最轻量,但依赖于远程是 Bash shell,且对于 I/O 处理不如 nohup 明确,通用性稍差。
  • 交互式管理和持久化: 如果你不仅想让 SSH 立刻返回,还希望之后能方便地回去查看任务的实时输出、甚至交互(虽然启动守护进程一般不需要交互),或者任务需要一个完整的终端环境,那么 screentmux 是不二之选。它们提供了一个完整的会话管理框架。

根据你的具体需求、对后台任务的要求以及对复杂度的接受程度,选择最合适的方案即可解决 SSH 执行后台命令不立即返回的问题。