ssh 远程执行后台任务后命令挂起不返回 —— nohup 后台进程继承 ssh 管道 fd 导致会话无法关闭(三件套重定向 / setsid 修复)
日期:2026-09-11 08:00 | 第 47 题 | 分支
2026-09-11-0800技术栈关键词:ssh 挂起ssh 不返回ssh 卡住nohupsetsidsshdOpenSSHpipe fd后台任务CI job 卡住部署脚本AlpineDockerdeploy.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 |
六、经验与避坑
nohup≠ 断开 fd。 nohup 只忽略 SIGHUP,不管 stdout/stderr 指向哪。 「nohup + &」后面接>log 2>&1 </dev/null才是完整写法。- 排障神器是
/proc/<pid>/fd,不是日志。ls -l /proc/<pid>/fd看到1 -> pipe:[...]就实锤了。 Alpine 上lsof还要额外装,直接读/proc更快。 - 看 PPID 判断"谁还活着"。 孤儿进程 PPID=1 但
sshd: root@notty仍在, 就说明问题在 fd 引用而不是"脚本没跑完"。 - 别用
ssh -t当修复。 CI / 命令替换 / 管道场景 pty 分不到, 报Pseudo-terminal will not be allocated because stdin is not a terminal.后原样挂住。 - 修复后一定要验证后台任务真的完成了。
只验证「ssh 立即返回」是不够的 —— 可能把任务一起弄死了。
检查
pgrep存活 + 等一轮看日志内容。 - CI 里加超时兜底。
timeout 60 ssh root@target "/root/scripts/deploy.sh"
万一有别的进程握着通道,至少不会卡到 job 全局超时。 - Alpine 的 busybox
sh没有time。 计时用S=$(date +%s); E=$(date +%s); echo $((E-S)), 或显式bash -c 'time ...'。 - 反复实验会堆积孤儿进程。 对比 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。
