朕喜技术(ZhenxiJS)
2026.09.11
1 分钟阅读

ssh 远程执行后台任务后命令挂起不返回 —— nohup 后台进程继承 ssh 管道 fd 导致会话无法关闭(三件套重定向 / setsid 修复)

日期:2026-09-11 08:00 | 第 47 题 | 分支 2026-09-11-0800 技术栈关键词:ssh 挂起 ssh 不返回 ssh 卡住 nohup setsid sshd OpenSSH pipe fd 后台任务 CI job 卡住 部署脚本 Alpine Docker deploy.sh 不结束 ssh -t 正常

一、问题背景

在本地开发机或 CI Runner 上用 ssh 到远程服务器执行部署脚本:

ssh root@target "/root/scripts/deploy.sh"

脚本很普通:后台起一个耗时任务,然后脚本立刻返回。

nohup bash -c 'echo "开始构建..."; sleep 30; echo "构建完成"' &
echo "已提交后台任务 pid=$!"

现象很诡异:

  • 远端脚本的 echo 都打出来了,脚本进程也退出了;
  • 但本地这条 ssh 命令不返回,终端一直挂着;
  • CI 里表现为 job 卡到超时被杀,本地表现为必须 Ctrl+C;
  • 更迷惑:ssh -t 加伪终端就正常了,或者加 > /dev/null 2>&1 也正常了。

「远端脚本早就跑完了,为什么 ssh 还挂在那里?」

二、复现步骤

一键 docker 环境(两个容器:target 跑 sshd,client 跑 ssh 客户端):

cd scenario/2026-09-11-0800
docker compose up -d --build

# 一键复现 + 对比验证
docker compose exec client sh /repro/repro.sh

docker compose down

实测关键输出:

场景:错误写法:nohup ... &(不重定向)
ssh 退出码:255    耗时:15s
结果:✅ 复现成功 —— ssh 挂住约 15s(rc=255),直到超时才返回
----------------------------------------------
场景:修复 1:nohup + 三件套重定向
ssh 退出码:0    耗时:1s
结果:✅ 正常 —— ssh 立即返回(rc=0, 1s)
----------------------------------------------
场景:修复 2:setsid -f + 重定向
ssh 退出码:0    耗时:0s
结果:✅ 正常 —— ssh 立即返回(rc=0, 0s)
通过:3    失败:0

手工复现:

docker compose exec client sh
SSH='ssh -i /keys/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'

time $SSH root@target "/root/scripts/deploy_broken.sh"   # ❌ 挂满 15s
time $SSH root@target "/root/scripts/deploy_fixed.sh"    # ✅ 1s
time $SSH root@target "/root/scripts/deploy_setsid.sh"   # ✅ 0s

三、根本原因

nohup 只做两件事:让进程忽略 SIGHUP;当 stdout 是终端时才重定向到 nohup.out。

通过 ssh 非交互执行时 stdout 是管道不是终端,所以 nohup 的重定向根本不触发 —— 后台进程原封不动继承了 ssh 会话的 stdout/stderr 管道写端。

决定性证据 —— 后台静默发起 ssh 让它挂着,然后看远端孤儿进程的 fd:

docker compose exec -d client sh -c \
  'ssh -i /keys/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \
       -o UserKnownHostsFile=/dev/null root@target /root/scripts/deploy_broken.sh'
sleep 5
docker compose exec target bash -c 'ls -l /proc/$(pgrep -f "sleep 30"|head -1)/fd'

实测输出:

0 -> /dev/null
1 -> pipe:[864689756]      <-- ssh 通道管道!还在写端
2 -> pipe:[864689757]      <-- ssh 通道管道!还在写端

同时 ps -ef 显示:发起脚本的 shell 早已退出(孤儿进程 PPID=1),但 sshd: root@notty 会话进程仍然活着。

sshd 关闭 session channel 是引用计数的:只要还有活进程持有该 channel 的 fd(尤其写端), channel 就不关闭,客户端 ssh 就一直等 EOF —— 这就是「远端脚本早跑完却挂住」的全部原因。

对照组(修复后):

0 -> /dev/null
1 -> /tmp/deploy.log       <-- 指向日志文件,不是管道
2 -> /tmp/deploy.log

此时 sshd 立刻关会话,ssh rc=0、0s 返回。

为什么 ssh -t 看起来没问题(重要陷阱)

分配 pty 后,标准 fd 指向终端设备而非管道:

docker compose exec target bash -c 'script -qc "ls -l /proc/self/fd/1" /dev/null'
# /proc/self/fd/1 -> /dev/pts/0

sshd 关闭 pty master 时,slave 端收到 SIGHUP/EOF,孤儿进程持有的 fd 不再算「活的 channel 写端」, channel 立刻关闭,ssh 就返回了。

但这是假象:在 CI、$(...) 命令替换、管道接收等 stdout 不是终端的环境里,ssh 会报

Pseudo-terminal will not be allocated because stdin is not a terminal.

pty 分不到,问题原样复现。绝不能把 ssh -t 当修复手段。

实测同一 broken 脚本:

命令耗时结果
ssh root@target deploy_broken.sh(无 pty)15s 超时❌ 挂住
ssh -tt root@target deploy_broken.sh(有 pty)0s⚠️ 假性正常

四、解决办法

方案 A(推荐):三件套重定向

nohup bash -c '
  echo "[$(date +%T)] 开始构建..."
  sleep 30
  echo "[$(date +%T)] 构建完成"
' >"$LOG" 2>&1 </dev/null &

三个重定向缺一不可:

  • >log:stdout 不再指向 ssh 管道(最关键)
  • 2>&1:stderr 跟上,否则报错输出仍占住通道
  • </dev/null:stdin 断开,避免后续读到 EOF 异常退出

方案 B:setsid(守护型长驻进程推荐)

setsid -f bash -c '...' >"$LOG" 2>&1 </dev/null

setsid -f 直接 fork 到新会话,脱离控制终端。验证已独立成会话:

docker compose exec target bash -c 'ps -o pid,sid,cmd -p $(pgrep -f "sleep 30"|head -1)'
# PID 21, SID 21    <-- SID == PID,新会话首进程

方案 C:想实时看日志

ssh root@target 'nohup ./build.sh >/tmp/build.log 2>&1 </dev/null & echo started'
ssh root@target 'tail -f /tmp/build.log'     # 单独连接看进度

方案 D:不改远端,改 ssh 侧(治标)

ssh root@target '/root/scripts/deploy_broken.sh' >/dev/null 2>&1 </dev/null

本地不再等,但远端孤儿进程仍握着管道,不干净。

选型

  • 一次性后台任务(部署/构建)→ 方案 A
  • 常驻服务/守护进程 → 方案 B,或直接 systemd / supervisord
  • CI 里看进度 → 方案 C

五、验证

修复不能只验证「ssh 不挂了」,后台任务还必须真的跑完:

docker compose exec client sh -c "$SSH root@target /root/scripts/deploy_fixed.sh"
# 已提交后台任务 pid=20
# (脚本返回后 ssh 立即结束)      <-- 立即返回

sleep 2
docker compose exec target bash -c 'pgrep -af "sleep 30" >/dev/null && echo "后台任务存活 ✅"'
# 后台任务存活 ✅

sleep 32
docker compose exec target bash -c 'cat /tmp/deploy.log'
# [00:14:54] 开始构建...
# [00:15:24] 构建完成            <-- 30s 后自己跑完了 ✅

docker compose exec target bash -c 'ps -ef | grep "sshd: root" | grep -v grep || echo "(无会话进程)"'
# (无会话进程)                   <-- 会话已干净关闭 ✅

最终验收:

验收项结果
稳定复现 broken 挂住 15s✅
fd 证据定位到孤儿进程持有 ssh 管道写端✅ 1 -> pipe:[...]
方案 A 三件套重定向✅ rc=0, 0s
方案 B setsid(新 SID)✅ rc=0, 0s
修复后后台任务存活并完成,日志正确✅
解释 ssh -t 假性正常✅ pty → /dev/pts/N
官方 repro.sh 三场景✅ 通过 3 / 失败 0

六、经验与避坑

  1. nohup ≠ 断开 fd。 nohup 只忽略 SIGHUP,不管 stdout/stderr 指向哪。 「nohup + &」后面接 >log 2>&1 </dev/null 才是完整写法。
  2. 排障神器是 /proc/<pid>/fd,不是日志。ls -l /proc/<pid>/fd 看到 1 -> pipe:[...] 就实锤了。 Alpine 上 lsof 还要额外装,直接读 /proc 更快。
  3. 看 PPID 判断"谁还活着"。 孤儿进程 PPID=1 但 sshd: root@notty 仍在, 就说明问题在 fd 引用而不是"脚本没跑完"。
  4. 别用 ssh -t 当修复。 CI / 命令替换 / 管道场景 pty 分不到, 报 Pseudo-terminal will not be allocated because stdin is not a terminal. 后原样挂住。
  5. 修复后一定要验证后台任务真的完成了。 只验证「ssh 立即返回」是不够的 —— 可能把任务一起弄死了。 检查 pgrep 存活 + 等一轮看日志内容。
  6. CI 里加超时兜底。
    timeout 60 ssh root@target "/root/scripts/deploy.sh"
    

    万一有别的进程握着通道,至少不会卡到 job 全局超时。
  7. Alpine 的 busybox sh 没有 time。 计时用 S=$(date +%s); E=$(date +%s); echo $((E-S)), 或显式 bash -c 'time ...'。
  8. 反复实验会堆积孤儿进程。 对比 fd 前先 docker compose restart, 否则 pgrep -f "sleep 30" 会命中一堆历史进程。

七、一句话总结

ssh 挂起不是网络问题,是 fd 泄漏问题:nohup cmd & 让后台进程继续持有 ssh channel 的 stdout/stderr 管道写端,sshd 等写端全部关闭才关会话。修复公式:nohup cmd >"$LOG" 2>&1 </dev/null &,长驻进程用 setsid -f cmd >"$LOG" 2>&1 </dev/null。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-11-0800-ssh-nohup

分享这篇文章