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

在这里插入图片描述

一、问题背景

在 Linux 服务器上运行 Web 服务、模型推理程序或其他长时间任务时,经常需要通过 SSH 远程启动进程,并希望关闭终端后程序能够继续运行。

通常会使用下面的命令:

nohup command > app.log 2>&1 &

但实际使用过程中发现,即使添加了 nohup,SSH 连接断开后,后台进程依然可能退出。

本文以 Docker 容器中的两个实际案例为例:

  1. 使用 npx 启动 DeepSeek Harness Web 服务。
  2. 使用 socat 进行 TCP 端口转发。

经过多轮对照实验、Linux 进程状态分析和源码检查,发现问题并不是 nohup 本身失效,而是应用程序的信号处理机制与 Bash 的作业控制机制共同作用,使得进程最终仍然可能因为 SIGHUP 退出。

1.1 运行环境

实验环境如下:

项目环境
操作系统Ubuntu 22.04 LTS
运行环境Docker 容器
连接方式SSH 登录容器
ShellBash
容器 PID 1bash
进程管理容器内未运行 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)作用
SIGHUP1挂断信号,通常与终端断开有关
SIGINT2中断信号,常见于 Ctrl+C
SIGKILL9强制终止进程,不可捕获或忽略
SIGTERM15请求程序终止,允许程序执行清理
SIGCHLD17子进程状态改变时通知父进程

其中,本文最重要的是 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,标识一组相关进程
SIDSession 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 断开后
sleepnohup存活
sleepsetsid存活

检查进程属性还发现,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);

其含义是:

  1. 将 socat_signal 指定为信号处理函数。
  2. 通过 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 断开后后台进程仍然退出。

通过对照实验得到了以下结果:

实验对象启动方式结果
sleepnohup正常存活
sleepsetsid正常存活
socatnohup收到 SIGHUP 后退出
DSH WebnohupSSH 断开后退出
DSH Webnohup + disown -h正常存活
DSH Web、socatsetsid正常存活

结合日志、进程信号状态及公开源码分析,可以得到两个结论:

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 断开存活,而另一些程序却会退出。

参考资料

  1. GNU Coreutils — nohup 官方文档
  2. Linux signal(7) — 信号机制
  3. Linux execve(2) — 信号处理方式继承规则
  4. GNU Bash Manual — Signals
  5. GNU Bash Manual — Job Control Builtins
  6. Linux setsid(2) — Session 创建机制
  7. Linux socat(1) — socat 使用手册
  8. socat.c — 公开源码参考
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐