Linux 中 nohup 为什么无法阻止进程退出?从 Docker、SSH、SIGHUP 到 setsid 的完整排查 SSH断开后后台进程为何退出?
Linux 中 nohup 为什么无法阻止进程退出?从 Docker、SSH、SIGHUP 到 setsid 的完整排查

一、问题背景
在 Linux 服务器上运行 Web 服务、模型推理程序或其他长时间任务时,经常需要通过 SSH 远程启动进程,并希望关闭终端后程序能够继续运行。
通常会使用下面的命令:
nohup command > app.log 2>&1 &
但实际使用过程中发现,即使添加了 nohup,SSH 连接断开后,后台进程依然可能退出。
本文以 Docker 容器中的两个实际案例为例:
- 使用
npx启动 DeepSeek Harness Web 服务。 - 使用
socat进行 TCP 端口转发。
经过多轮对照实验、Linux 进程状态分析和源码检查,发现问题并不是 nohup 本身失效,而是应用程序的信号处理机制与 Bash 的作业控制机制共同作用,使得进程最终仍然可能因为 SIGHUP 退出。
1.1 运行环境
实验环境如下:
| 项目 | 环境 |
|---|---|
| 操作系统 | Ubuntu 22.04 LTS |
| 运行环境 | Docker 容器 |
| 连接方式 | SSH 登录容器 |
| Shell | Bash |
| 容器 PID 1 | bash |
| 进程管理 | 容器内未运行 systemd |
| 测试程序 | DeepSeek Harness、socat |
需要强调:这里是通过 SSH 登录到容器内部,而不是使用 docker exec 进入容器。
因此,不能直接将故障归因于 docker exec 会话结束后的进程清理。
1.2 出现问题的命令
首先使用 npx 启动 Web 服务:
nohup npx @deepseek-ai/dsh web --port 8080 \
> dsh.log 2>&1 &
然后使用 socat 将监听在本地回环地址的 8080 端口转发到容器的 8081 端口:
nohup socat \
TCP-LISTEN:8081,fork,reuseaddr,bind=0.0.0.0 \
TCP:127.0.0.1:8080 \
> socat.log 2>&1 &
刚执行时,服务均能正常运行;但关闭 SSH 终端后,后台进程退出。
将启动方式改为:
setsid npx @deepseek-ai/dsh web --port 8080 \
> dsh.log 2>&1 < /dev/null &
setsid socat \
TCP-LISTEN:8081,fork,reuseaddr,bind=0.0.0.0 \
TCP:127.0.0.1:8080 \
> socat.log 2>&1 < /dev/null &
问题得到解决。
但为什么同样是后台运行,nohup 和 setsid 的效果不同?
要解释这个问题,需要先理解 Linux 的信号与会话机制。
二、基础原理:什么是 Linux 信号(Signal)?
2.1 什么是信号?
在 Linux 中,信号(Signal)是一种通知进程发生了某个事件的机制。
可以把进程理解为正在工作的员工,信号就是系统发送给员工的通知。
例如:
- 用户按下
Ctrl+C,通知程序中断运行。 - 用户关闭终端,可能触发挂断信号。
- 系统要求程序结束,可以发送终止信号。
- 操作系统检测到非法内存访问,可以向程序发送异常信号。
这些通知都是通过 Linux 信号机制实现的。
常见信号如下:
| 信号 | 编号(Linux) | 作用 |
|---|---|---|
SIGHUP | 1 | 挂断信号,通常与终端断开有关 |
SIGINT | 2 | 中断信号,常见于 Ctrl+C |
SIGKILL | 9 | 强制终止进程,不可捕获或忽略 |
SIGTERM | 15 | 请求程序终止,允许程序执行清理 |
SIGCHLD | 17 | 子进程状态改变时通知父进程 |
其中,本文最重要的是 SIGHUP。
2.2 什么是 SIGHUP?
SIGHUP 是 Signal Hang Up 的缩写。
SIG:Signal,信号。HUP:Hang Up,挂断。
它最初用于表示终端通信线路断开。
在 SSH 交互式登录场景中,终端挂断可能导致 Shell 收到 SIGHUP。Bash 在收到该信号并准备退出时,还会向自己管理的作业转发 SIGHUP。
一个典型的执行过程是:
SSH 客户端
|
| 关闭终端
v
SSH 连接断开
|
v
Bash 收到 SIGHUP
|
| 向其管理的后台作业转发 SIGHUP
v
后台程序收到 SIGHUP
|
v
执行程序自己的信号处理逻辑
注意:这是一种典型的挂断处理路径,并不意味着所有 SSH 断开场景都会发生完全相同的信号转发。
2.3 进程收到信号后会发生什么?
一个进程接收到某个信号后,通常存在三类处理方式。
第一种:使用默认行为(SIG_DFL)
例如,SIGHUP 的默认行为是终止进程。
收到 SIGHUP
↓
执行默认行为
↓
进程退出
第二种:忽略信号(SIG_IGN)
收到 SIGHUP
↓
忽略该信号
↓
继续运行
这正是 nohup 所依赖的机制。
第三种:捕获信号并执行自定义处理函数
程序可以使用 sigaction() 注册一个信号处理函数。
例如:
void handle_signal(int sig)
{
printf("Received signal: %d\n", sig);
exit(128 + sig);
}
如果程序将这个函数注册为 SIGHUP 处理函数,那么收到 SIGHUP 时,就会执行自定义逻辑,而不是简单地忽略信号。
这也是本文两次故障的重要技术基础:程序可以主动改变原有的信号处理方式。
三、nohup、&、disown 和 setsid 分别是什么?
这四个工具经常同时出现,但实际上解决的是不同层面的问题。
3.1 &:把任务放到后台
例如:
python app.py &
末尾的 & 告诉 Shell:异步执行这个命令,不要等待程序结束。
这样终端可以继续输入其他命令。
但程序仍然由当前 Shell 启动和管理。后台运行不等于脱离终端,也不等于忽略 SIGHUP。
3.2 nohup:让程序忽略 SIGHUP
例如:
nohup python app.py > app.log 2>&1 &
nohup 会在执行目标程序前,将 SIGHUP 的处理方式设置为忽略。
因此,即使后续程序收到了 SIGHUP,只要没有改变这一设置,它就可以继续运行。
但这里有个容易忽略的细节:
**nohup** 不是持续保护进程的守护程序。它只是设置初始的信号处理状态。
如果被启动的程序随后修改了 SIGHUP 的处理方式,nohup 的效果就可能被覆盖。
3.3 disown -h:阻止 Bash 转发 SIGHUP
例如:
nohup python app.py > app.log 2>&1 &
disown -h $!
这里:
$!表示最近启动的后台作业的 PID。disown -h将该作业标记为不接收 Bash 因挂断而转发的SIGHUP。
与 nohup 不同,disown -h 并不改变应用程序自己的信号处理方式。
它控制的是 Bash 是否向这个作业发送挂断信号。
另外,disown -h 必须在拥有该作业的 Shell 中执行,不能在重新登录后随便对旧进程执行。
3.4 setsid:为程序创建新的 Session
理解 setsid 前,需要区分三个概念。
| 概念 | 含义 |
|---|---|
| PID | 进程 ID,标识一个进程 |
| PGID | 进程组 ID,标识一组相关进程 |
| SID | Session ID,标识一个会话 |
Linux 中,多个进程可以属于同一个进程组,多个进程组可以属于同一个 Session。
例如,通过 SSH 登录后:
Session A:SSH 登录会话
│
├── Bash
│
├── Process Group 1
│ └── 前台命令
│
└── Process Group 2
└── 后台服务
即使执行:
nohup command &
这个命令通常仍然属于原来的 Session。
而 setsid 可以创建新的 Session:
setsid command > app.log 2>&1 < /dev/null &
执行后:
Session A:SSH 登录会话
└── Bash
Session B:新的独立会话
└── 后台服务
新的 Session 初始时没有控制终端,因此程序不再依赖原来的 SSH 控制终端。
简单理解:**nohup** 修改信号处理方式,**disown -h** 修改 Bash 的作业管理行为,**setsid** 修改进程所属的会话结构。
不过,setsid 不会自动让进程脱离 Docker 容器、cgroup,也不能阻止所有类型的终止信号。
四、实验一:排除 Docker 对 nohup 进程的统一清理
最初怀疑 SSH 断开后,Docker 容器可能会统一清理后台进程。
为了验证这一猜测,分别使用 nohup 和 setsid 启动两个简单的 sleep 测试进程。
4.1 测试命令
# 使用 nohup
nohup sleep 3600 \
> /tmp/nohup.log 2>&1 < /dev/null &
# 使用 setsid
setsid sleep 3600 \
> /tmp/setsid.log 2>&1 < /dev/null &
随后关闭 SSH 连接,并重新登录容器检查进程。
4.2 实验结果
| 测试程序 | 启动方式 | SSH 断开后 |
|---|---|---|
| sleep | nohup | 存活 |
| sleep | setsid | 存活 |
检查进程属性还发现,nohup sleep 仍属于原来的 SSH Session,而 setsid sleep 创建了新的 Session。
这说明:
在本次实验环境中,SSH 断开并不会必然清理所有后台进程;普通程序通过 **nohup** 就能够跨 SSH 断开存活。
所以,不能把原先两个程序退出的原因直接归结为 Docker 的统一进程清理。
接下来需要分别分析 socat 和 dsh web 的行为。
五、实验二:socat 为什么使用 nohup 后仍然退出?
5.1 socat 是什么?
socat 是一个通用的数据转发工具,可以在两个网络连接或数据流之间双向传输数据。
本案例中,需要将一个只监听本地地址的 Web 服务暴露到容器网络接口:
客户端
|
| TCP 8081
v
0.0.0.0:8081
|
v
socat
|
| 转发
v
127.0.0.1:8080
|
v
Web 服务
对应命令:
socat \
TCP-LISTEN:8081,fork,reuseaddr,bind=0.0.0.0 \
TCP:127.0.0.1:8080
参数说明:
| 参数 | 含义 |
|---|---|
TCP-LISTEN:8081 | 监听 TCP 8081 端口 |
fork | 为不同连接创建子进程 |
reuseaddr | 启用地址复用 |
bind=0.0.0.0 | 监听所有 IPv4 网络接口 |
TCP:127.0.0.1:8080 | 连接到同一网络命名空间内的 8080 端口 |
这条命令的语法没有问题。
需要注意,监听 0.0.0.0 不意味着 Docker 会自动向宿主机或公网发布端口,实际外部访问还受到 Docker 网络配置的影响。
5.2 使用日志定位问题
将原先丢弃全部日志的方式:
nohup socat ... >/dev/null 2>&1 &
改为:
nohup socat -d -d \
TCP-LISTEN:18081,fork,reuseaddr,bind=0.0.0.0 \
TCP:127.0.0.1:8080 \
> /tmp/socat-test.log 2>&1 < /dev/null &
其中 -d -d 用于输出更详细的运行日志。
重新进行 SSH 断开测试后,发现进程消失,并且日志出现:
socat[...] N socat_signal(): handling signal 1
socat[...] N exiting on signal 1
socat[...] N socat_signal(): finishing signal 1
socat[...] N exit(129)
这是最关键的证据。
前面已经介绍:
signal 1 = SIGHUP
因此可以确认,socat 收到 SIGHUP 后,执行了退出逻辑。
日志中的 129 对应 128 + 1,这是 socat 信号处理代码使用的退出状态。
5.3 为什么 nohup 没有阻止 socat 退出?
查看 socat 的公开源码,可以找到如下信号注册逻辑:
act.sa_handler = socat_signal;
Sigaction(SIGHUP, &act, NULL);
其含义是:
- 将
socat_signal指定为信号处理函数。 - 通过
sigaction()为SIGHUP注册该函数。
这会覆盖先前通过 nohup 设置的忽略行为。
其执行过程为:
nohup socat
|
v
nohup 设置 SIGHUP = SIG_IGN
|
v
socat 启动
|
v
socat 注册自己的 SIGHUP 处理函数
|
v
收到 SIGHUP
|
v
执行 socat_signal()
|
v
exit(129)
因此,socat 的问题并不是没有正确使用 **nohup**,而是程序自身注册了 SIGHUP 处理函数,使得 **nohup** 设置的忽略行为不再生效。
源码中的 socat_signal() 处理逻辑与实际日志相互吻合。
至此已经确认了 socat 收到 SIGHUP 后退出的原因,但日志本身并没有直接记录发送信号的进程 PID。
六、实验三:为什么 nohup npx dsh web 也会退出?
socat 的原因已经比较清楚,接下来分析 dsh web。
相比直接启动 socat,npx 启动方式有一个明显区别:它需要经过多层进程。
6.1 观察真实进程链
使用:
ps -eo pid,ppid,pgid,sid,tty,stat,args
可以看到 dsh web 的进程关系:
npm exec @deepseek-ai/dsh web
|
v
sh -c 'dsh' web
|
v
node .../dsh web
这里:
npm exec是 npm 命令执行器。sh用于执行实际命令。node最终运行 DSH Web 服务。
但要注意一个容易误解的地方:
存在多层子进程,并不意味着 **nohup** 的设置必然失效。
按照 Linux 的信号继承规则:
fork()创建子进程时,会继承父进程的信号处理设置。execve()执行新程序时,被忽略的信号通常继续保持忽略。- 通过
execve()执行新程序时,被捕获的信号则会重置为默认处理。
因此,真正需要检查的不是有多少层进程,而是每一层进程如何处理 SIGHUP。
6.2 使用 /proc 检查信号处理状态
Linux 会在 /proc/<PID>/status 中提供进程的信号状态。
例如:
grep -E 'SigIgn|SigCgt' /proc/<PID>/status
两个字段分别表示:
SigIgn:当前忽略的信号集合。SigCgt:当前捕获的信号集合。
它们采用十六进制位掩码表示。
例如:
SigIgn: 0000000000000001
最低位为 1,表示忽略 SIGHUP。
如果:
SigIgn: 0000000000000000
则表示没有忽略任何信号。
实际分析时,还需要看 SigCgt,判断进程是否通过自定义函数捕获了 SIGHUP。
6.3 dsh 进程链的实际检查结果
在使用 nohup 启动 DSH,并且服务仍然运行时,分别检查 npm exec、sh 和 node 的信号状态。
结果如下:
| 进程 | SIGHUP 处理方式 |
|---|---|
| npm exec | 捕获 |
| sh -c ‘dsh’ | 默认处理 |
| node dsh web | 默认处理 |
也就是说:
最终运行 DSH Web 的 Node 进程,并没有忽略 SIGHUP。
这个结果很重要。
nohup 原本应该设置 SIGHUP 为忽略,但实际运行时,npm exec 进程已经处于捕获 SIGHUP 的状态,后续 sh 和 node 进程则处于默认处理状态。
根据 Linux 的信号继承与 execve() 规则,这种状态变化在机制上可以解释。
不过,单凭 /proc 快照还不能定位究竟是哪一个函数修改了信号处理方式,需要源码分析或系统调用跟踪才能确认。
6.4 验证 SSH 断开后的结果
使用:
nohup npx @deepseek-ai/dsh web --port 18080 \
> /tmp/dsh-test.log 2>&1 < /dev/null &
启动测试服务。
启动后,端口正常监听。
关闭 SSH 并重新连接后,发现:
npm exec进程退出。- DSH Node 进程退出。
- 18080 端口停止监听。
因此故障得到复现。
但这时仍然存在一个问题:
DSH 的退出是否真的由 Bash 转发 SIGHUP 导致?
为此,需要进行最后一个对照实验。
七、实验四:使用 disown -h 验证 Bash 的作业控制机制
Bash 官方文档指出:当交互式 Bash 收到 SIGHUP 并退出时,会向管理的作业重新发送 SIGHUP。
而 disown -h 可以标记某个作业,使 Bash 不再向它发送此类挂断信号。
因此,可以在不修改 DSH 代码的情况下进行验证。
7.1 对照实验
正常启动:
nohup npx @deepseek-ai/dsh web --port 18080 \
> /tmp/dsh-test.log 2>&1 < /dev/null &
此前实验表明,SSH 断开后服务会退出。
这次在相同的启动方式后增加:
disown -h $!
完整命令:
nohup npx @deepseek-ai/dsh web --port 18080 \
> /tmp/dsh-test.log 2>&1 < /dev/null &
disown -h $!
然后关闭 SSH,重新连接,检查服务端口和进程状态。
7.2 实验结果
重新连接后:
npm exec 进程:仍然存在
node dsh web:仍然存在
Web 服务端口:正常监听
同时检查发现,npm exec 的 SID 仍然是原来的 SSH Session ID。
这说明:
程序并没有脱离原来的 Session,但仅仅增加 **disown -h**,就使得本次 SSH 断开后 DSH 继续存活。
这是一个非常有价值的对照实验。
因为 disown -h 主要修改的是 Bash 对后台作业的挂断信号转发行为,而不是应用自身的信号处理方式。
因此,实验结果强烈支持以下解释:
SSH 终端断开
|
v
Bash 收到挂断信号
|
v
Bash 向后台作业转发 SIGHUP
|
v
npm / DSH 进程链未忽略 SIGHUP
|
v
DSH 进程退出
加入 disown -h 后:
SSH 终端断开
|
v
Bash 退出
|
v
Bash 不向已标记作业转发 SIGHUP
|
v
DSH 继续运行
需要保持严谨的是:实验已经强烈支持 Bash 转发 SIGHUP 这一机制,但还没有通过 strace 或 eBPF 直接记录信号发送者的 PID。
因此,可以将其作为当前最有证据支持的原因,而不必声称已经完成内核级信号来源追踪。
八、最终解决方案
根据实际排查结果,对于需要在 SSH 断开后继续运行的任务,可以使用以下几种方式。
8.1 方法一:nohup + disown -h
适用于在交互式 Bash 中临时启动后台任务。
nohup npx @deepseek-ai/dsh web --port 8080 \
> dsh.log 2>&1 < /dev/null &
disown -h $!
这种方式同时利用了两种不同的机制:
nohup:设置初始的SIGHUP忽略状态。disown -h:阻止 Bash 向指定作业转发挂断信号。
即使应用修改了 nohup 的信号设置,只要没有从其他来源收到致命信号,它仍可能在 SSH 断开后存活。
8.2 方法二:setsid
对于不需要交互终端的临时后台服务,可以使用:
setsid npx @deepseek-ai/dsh web --port 8080 \
> dsh.log 2>&1 < /dev/null &
以及:
setsid socat \
TCP-LISTEN:8081,fork,reuseaddr,bind=0.0.0.0 \
TCP:127.0.0.1:8080 \
> socat.log 2>&1 < /dev/null &
setsid 为程序创建新的 Session,避免程序继续处于原 SSH 会话及控制终端关系中。
在本案例的实际环境中,该方式已经验证有效。
不过,setsid 不会自动忽略所有信号,也不能保证程序崩溃后重新启动。
8.3 方法三:使用进程管理器
对于正式部署、需要长期运行的业务服务,推荐使用进程管理机制,例如:
systemd:适合使用 systemd 的 Linux 主机。supervisord:适合在容器中管理多个长期服务。- Docker 重启策略:适合管理容器整体生命周期。
如果容器中需要同时运行 Web 服务与端口转发程序,可以使用 supervisord 统一管理,或者将服务拆分到不同容器中。
相比 nohup 和 setsid,进程管理器能够提供更明确的生命周期管理、退出状态监控和自动重启能力。
九、进一步理解:为什么这几种方式的效果不同?
可以将本次排查涉及的机制总结为:
| 工具 | 实际解决的问题 | 不负责什么 |
|---|---|---|
& | 让命令在后台执行 | 不负责信号防护 |
nohup | 初始设置忽略 SIGHUP | 不能阻止程序覆盖信号设置 |
disown -h | 阻止 Bash 转发 SIGHUP | 不能阻止其他来源的信号 |
setsid | 创建新的 Session | 不能保证程序永远存活 |
tmux | 保存可重新连接的交互终端 | 不等于正式服务管理 |
| 进程管理器 | 监控、重启、管理进程 | 仍受系统与容器生命周期约束 |
还有一个重要细节:
新的 Session 不代表新的 cgroup。
setsid 改变的是 Linux 进程会话结构,并不会自动使进程脱离 Docker 容器的资源管理范围。
因此,即使使用了 setsid,停止容器、强制杀死进程或发生某些资源限制事件,仍然可能导致程序退出。
十、排查同类问题的实用命令
以后遇到后台任务莫名退出,可以优先检查以下信息。
10.1 查看进程结构
ps -eo pid,ppid,pgid,sid,tty,stat,args
观察进程的父子关系、进程组、Session 和终端关联情况。
10.2 查看信号状态
grep -E 'SigIgn|SigCgt' /proc/<PID>/status
确认目标进程是否真的忽略了 SIGHUP。
10.3 查看端口监听
ss -lntp
确认服务是否已经启动,以及具体监听在哪个地址和端口。
10.4 保留错误日志
排查期间尽量使用:
command > app.log 2>&1 &
而不是:
command > /dev/null 2>&1 &
否则进程退出时最重要的错误信息可能直接丢失。
另外,一些 Web 服务会在启动日志中输出包含访问令牌的 URL。公开日志前,应删除 Token、凭证、内部地址和个人目录等信息。
十一、总结
本次故障最初表现为:Docker 容器中通过 SSH 启动服务,即使添加了 nohup,SSH 断开后后台进程仍然退出。
通过对照实验得到了以下结果:
| 实验对象 | 启动方式 | 结果 |
|---|---|---|
| sleep | nohup | 正常存活 |
| sleep | setsid | 正常存活 |
| socat | nohup | 收到 SIGHUP 后退出 |
| DSH Web | nohup | SSH 断开后退出 |
| DSH Web | nohup + disown -h | 正常存活 |
| DSH Web、socat | setsid | 正常存活 |
结合日志、进程信号状态及公开源码分析,可以得到两个结论:
1. socat 的退出机制
socat 自身注册了 SIGHUP 信号处理函数,覆盖了 nohup 预先设置的忽略行为。测试日志明确记录了收到信号 1 并以状态 129 退出的过程。
2. DSH Web 的退出机制
npx 启动链中的 npm 进程处于捕获 SIGHUP 的状态,而最终 DSH Node 进程没有忽略 SIGHUP。SSH 断开后服务退出,增加 disown -h 后则可以继续运行。
这一结果强烈支持 Bash 作业控制中的挂断信号转发是触发退出的关键环节,但信号发送者尚未通过系统调用跟踪直接确认。
这次排查带来的核心认识是:
**nohup** 不是后台服务存活的绝对保证,它只是改变程序启动时的信号处理状态。程序可以重新设置信号处理方式,而 Shell 也有自己的作业管理和信号转发逻辑。
理解 SIGHUP、信号继承、进程组、Session,以及 nohup、disown、setsid 的区别,才能真正解释为什么有些后台程序可以跨 SSH 断开存活,而另一些程序却会退出。
参考资料
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)