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

supervisor 托管的 Node 应用「崩了不重启、stop 停不掉、端口还被占」—— nohup 后台子进程逃逸 + exitcodes 不匹配 + stopasgroup 缺省

日期:2026-09-12 环境:Docker / Debian bookworm 技术栈:supervisor 4.2.5 · Node.js 18 · supervisord · supervisorctl 关键词:supervisor 无法重启 / supervisord FATAL Exited too quickly / exit status 0; not expected / gave up entered FATAL state / supervisorctl stop 停不掉 / nohup 后台进程逃逸 / 孤儿进程 PPid=1 / stopasgroup killasgroup / exitcodes startsecs startretries / #!/usr/bin/env node 孙进程 / 进程组信号 / EADDRINUSE address already in use / 端口被占用 / supervisorctl reread update 不 autostart


一、问题背景

一台机器上的 Node HTTP 服务由 supervisor(supervisord)托管。运维诉求很朴素:

  • 进程崩了要自动重启;
  • 进程退出了要能看出来;
  • 发版时 supervisorctl stop 要真的把进程停掉。

实际现象却很邪门:

  1. supervisorctl status 显示 FATAL Exited too quickly,但 curl localhost:8080/healthz 有时却是通的;
  2. 反过来也有:状态显示 RUNNING,但 ps 里那个 pid 根本不存在;
  3. ss -ltnp 显示 8080 被一个 node 占着,它的 PPid=1,supervisorctl stop 对它完全无效;
  4. supervisord.log 反复滚:
WARN exited: broken-node-app (exit status 0; not expected)
INFO gave up: broken-node-app entered FATAL state, too many start retries too quickly
  1. 下一次发版 → Error: listen EADDRINUSE: address already in use :::8080。

一句话:「supervisor 管不住真正干活的进程」。


二、复现步骤

完整环境在仓库分支 2026-09-12-1600 的 scenario/2026-09-12-1600/,用 Docker 一键复现(真实 supervisor + 真实 Node,无模拟):

git fetch origin && git checkout 2026-09-12-1600
cd scenario/2026-09-12-1600
docker compose up -d --build

# 一键:复现场景 → 演示后果 → 根因证据 → 应用修复 → 逐项验证
docker compose exec app bash ./scripts/run_all.sh

docker compose down -v

关键现场(坏配置 command 指向一个会 nohup ... & 的启动脚本):

; supervisor/conf.d/broken-node-app.conf.broken(坏配置)
[program:broken-node-app]
command=/workspace/scenario/2026-09-12-1600/entrypoint.sh   ; ← 脚本里 nohup node server.js & 然后 exit 0
autostart=true
autorestart=true
startsecs=2
startretries=1
exitcodes=0,2          ; 坑 2:node 崩溃是 exit 1,不在列表里
stopsignal=TERM
stopwaitsecs=10
;stopasgroup=false     ; 坑 3:缺省即 false
;killasgroup=false
# entrypoint.sh 里的「看起来很正常」的一行:
nohup node "$APP_DIR/server.js" --port "${PORT:-8080}" >>"$LOG" 2>&1 &
exit 0

复现出的核心证据:

$ supervisorctl -c supervisor/supervisord.conf status
broken-node-app    FATAL    Exited too quickly (process log may have details)

$ ss -ltnp | grep 8080
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:* users:(("node",pid=145349,fd=18))

$ ps -o pid,ppid,pgid -p 145349
  PID  PPID  PGID
145349     1 145346          # ← PPid=1,已过继给 init,不在 supervisor 的进程树里

kill -9 掉这个 node 后 5 秒内没有任何进程被拉起,8080 彻底不通。 supervisorctl stop broken-node-app 返回 ERROR (not running),但那个 node 还在跑、8080 还被占。


三、根本原因

supervisor 判断一个 program「死活」只靠两件事:

  1. 对自己 fork 出来的直系子进程调用 waitpid(),拿它的退出码;
  2. 该 program 的 stdout/stderr 管道是否还有写端被持有。

nohup node ... & 之后 exit 0,同时把这两条都破坏了:

  • supervisor 的直系子进程变成了那个 shell,它秒退且退出码 0 → waitpid() 拿到的是 shell 的 0,业务 node 的退出码永远没人回收(只有 init 收);
  • 业务 node 仍持有 program 的日志管道写端 → 「日志一直在长,但写的人不是 supervisor 以为的那个」。

叠加三处配置错误,于是出现两种都很要命的表现:

层现场写法后果
坑 1 · 子进程逃逸command=entrypoint.sh,脚本 nohup node server.js & 后 exit 0直系子进程秒退;waitpid 拿到 shell 的 0;业务 node 变 PPid=1 孤儿:崩了没人重启、stop 停不掉、端口一直占
坑 2 · 退出码语义exitcodes=0,2(node 崩溃是 exit 1),startretries=1「崩溃」被登记成「正常下班」→ autorestart 语义错乱;重试太少,反复秒退后直接 FATAL,业务却还活着
坑 3 · 信号投递缺省 stopasgroup=false / killasgroup=falsestopsignal 只发给直接子进程;shell 包装层、#!/usr/bin/env node 包裹层之下的真实 node 收不到 SIGTERM → stop 说停了、进程还在

用 ls -l /proc/<pid>/fd/1 可以直观看到「日志管道 fd 被谁持有」:

l-wx------ 1 root root 64 /proc/145349/fd/1 -> /var/log/supervisor/node-app.log
l-wx------ 1 root root 64 /proc/145349/fd/2 -> /var/log/supervisor/node-app.log

进程组对比(证明缺省 stopasgroup=false 时信号覆盖不到):

supervisord  pgid=60
orphan node  pgid=145346      ← 不同进程组 → SIGTERM 送不到

#!/usr/bin/env node 的场景更隐蔽:command 指向一个带 shebang 的 CLI, 这类 CLI 常常再 fork 一个真正的业务进程 → 业务进程成了「孙进程」:

setsid → bash -c → env(node) → node cli.js → node server.js
业务 node(server.js) pid=3482 ppid=1 pgid=3474

只给包裹层发 SIGTERM,2 秒后业务 node 仍在跑;改为 kill -TERM -- -PGID(整个进程组),业务 node 一起消失。


四、解决办法

只改 supervisor 配置,不改业务代码(supervisor/conf.d/fixed-node-app.conf.fixed):

[program:fixed-node-app]
command=node /workspace/scenario/2026-09-12-1600/web/server.js  ; ① 前台运行:直系子进程就是业务进程
directory=/workspace/scenario/2026-09-12-1600/web
autostart=true
autorestart=true
exitcodes=0            ; ② 只有 0 算正常退出;exit 1(崩溃)→ 异常 → 重启
startsecs=2            ;    连续存活 2s 才算启动成功
startretries=3         ;    连续失败 3 次 → FATAL(失败可见)
stopsignal=TERM
stopwaitsecs=10
stopasgroup=true       ; ③ 信号发给整个进程组(孙进程也能收到)
killasgroup=true
environment=PORT="8081",APP_LOG="/var/log/supervisor/fixed-node-app.log"
stdout_logfile=/var/log/supervisor/fixed-node-app.out.log
redirect_stderr=true

三个要点:

  1. 不要在 supervisor 下面 nohup ... & —— supervisor 自己就是守护进程。 需要 shell 包装时必须用 exec node server.js 替换 shell 并停在前台,不要 &。
  2. exitcodes=0 把「崩溃」和「正常退出」分开,autorestart 才会在崩溃后真正重启; startsecs / startretries 让「一直起不来」升级为可见的 FATAL,而不是静默卡住。
  3. stopasgroup=true + killasgroup=true 管住整个进程组,stop 才能真的停掉孙进程。

五、验证

docker compose exec app bash ./scripts/05_fix_and_verify.sh

修复后 4 项(合计 10 个 [PASS] / 0 个 [FAIL])全部通过:

== 3. 验证 A:supervisord 的直接子进程就是业务 node 本体
supervisor 记录 pid=148820 ; 业务 node pid=148820 ; 直系子进程=148820
healthz: ok pid=148820
  [PASS] command 即业务进程本体:supervisor 用 waitpid 直接盯着它,退出码语义成立

== 4. 验证 B:崩溃(exit 1)被识别为异常并自动重启
kill -9 业务 node pid=148820(模拟崩溃)
6 秒后业务 node pid=150013
  [PASS] 崩溃后 supervisor 自动拉起服务(148820 → 150013)

== 5. 验证 C:supervisorctl stop 真正停掉业务进程、端口释放
fixed-node-app: stopped
  [PASS] 业务 node 已被停止(进程组被一起清理)

== 6. 验证 D:持续起不来时状态为 FATAL(失败可见)
crash-loop    FATAL    Exited too quickly (process log may have details)
  [PASS] 连续启动失败后状态为 FATAL

判据归纳:

  • 修复前:状态 FATAL 但 8080 被 PPid=1 的孤儿占着;kill -9 后无人拉起;stop 无效。
  • 修复后:supervisorctl status 的 pid == 业务 node pid;崩溃自动重启(pid 变化);stop 后进程消失;持续起不来 → FATAL。

六、经验与避坑

  1. 一句话原则:别用 nohup / setsid / & 起由 supervisor 托管的进程。 supervisor 就是守护进程,它只能通过 waitpid() 管自己的直系子进程; 你把进程丢后台,等于把监护权交给 init,supervisor 从此「看不见」它。
  2. command 必须「前台运行」。 需要 shell 包装时用 exec: command=bash -c 'exec node app.js',或直接 command=node /app/server.js。 直接写可执行文件路径还能少一层 shell,排查更简单。
  3. exitcodes 要与程序的真实退出码对齐。 exitcodes 里的码 = 「预期退出」→ 不触发重启; 不在里面的码 = 「非预期退出」→ 触发 autorestart。 配错就把「崩溃」登记成了「正常下班」。
  4. 信号可能送不到孙进程:stopasgroup=true + killasgroup=true。 这与「容器里 PID 1 不转发 SIGTERM、docker stop 超时被 SIGKILL」是同一类问题。 只是写 stopsignal=TERM 不够,必须让信号覆盖整个进程组。
  5. supervisorctl reread && supervisorctl update 不会 autostart。 它们只加载配置;autostart=true 只在 supervisord 启动时生效, update 出来的新 program 初始状态是 STOPPED,必须再 supervisorctl start <name>。
  6. 坏配置会「一轮轮重试、每轮再丢一个新孤儿」。command 秒退时 supervisor 反复重试,线上就会出现「进程越查越多」。 排查时先 supervisorctl remove <name> 把 program 摘掉,再去看还剩谁在跑。
  7. 定位手法(可复用):
    • pstree -p $(supervisorctl pid):业务进程在不在 supervisor 的树里;
    • ps -o pid,ppid,pgid -p <pid>:PPid=1 = 逃逸;pgid 不同 = 信号覆盖不到;
    • ls -l /proc/<pid>/fd/1 /proc/<pid>/fd/2:谁还握着日志管道写端;
    • ss -ltnp | grep :8080 + Error: listen EADDRINUSE:端口被不受管的进程占着;
    • grep -E "spawned|exited|gave up" /var/log/supervisor/supervisord.log:supervisor 自己给的线索。
  8. pgrep -f server.js 会误伤 shell 包装层(因为 -f 匹配整条命令行)。 精确做法是扫 /proc/*/exe 指向 node、且 cmdline 含目标脚本的进程。
  9. 与 systemd 的对应关系: Type=simple/forking/notify ↔ command 是否前台; Restart=always ↔ autorestart;KillMode=control-group ↔ stopasgroup+killasgroup; StartLimitBurst ↔ startretries。踩过的坑可以互相迁移。
  10. 本轮还额外修了复现脚本自身的「端口竞争假失败」: scenario/.../scripts/lib.sh 新增 spawn_detached_node / wait_port_free, 先等 8080 空出来、再起「事故现场的孤儿」,并确认它真的 listen 上了(失败重试)。 原因是上一环节残留的 node 还占着 8080 时,新起的 node 会 EADDRINUSE 秒退, 导致 orphan_pids 为空、脚本误报「没能重建出孤儿进程」。

附:一键复现命令速查

git fetch origin && git checkout 2026-09-12-1600
cd scenario/2026-09-12-1600
docker compose up -d --build
docker compose exec app bash ./scripts/run_all.sh   # 全流程:复现 + 根因 + 修复 + 验证
docker compose down -v

细节见 scenario/2026-09-12-1600/README.md(问题背景与判据)与 scenario/2026-09-12-1600/SOLUTION.md(完整解题记录)。

版权声明

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

分享这篇文章