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 /stopasgroupkillasgroup/exitcodesstartsecsstartretries/#!/usr/bin/env node孙进程 / 进程组信号 / EADDRINUSE address already in use / 端口被占用 /supervisorctl reread update不 autostart
一、问题背景
一台机器上的 Node HTTP 服务由 supervisor(supervisord)托管。运维诉求很朴素:
- 进程崩了要自动重启;
- 进程退出了要能看出来;
- 发版时
supervisorctl stop要真的把进程停掉。
实际现象却很邪门:
supervisorctl status显示FATAL Exited too quickly,但curl localhost:8080/healthz有时却是通的;- 反过来也有:状态显示
RUNNING,但ps里那个 pid 根本不存在; ss -ltnp显示 8080 被一个 node 占着,它的PPid=1,supervisorctl stop对它完全无效;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
- 下一次发版 →
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「死活」只靠两件事:
- 对自己 fork 出来的直系子进程调用
waitpid(),拿它的退出码; - 该 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=false | stopsignal 只发给直接子进程;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
三个要点:
- 不要在 supervisor 下面
nohup ... &—— supervisor 自己就是守护进程。 需要 shell 包装时必须用exec node server.js替换 shell 并停在前台,不要&。 exitcodes=0把「崩溃」和「正常退出」分开,autorestart才会在崩溃后真正重启;startsecs/startretries让「一直起不来」升级为可见的FATAL,而不是静默卡住。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。
六、经验与避坑
- 一句话原则:别用
nohup/setsid/&起由 supervisor 托管的进程。 supervisor 就是守护进程,它只能通过waitpid()管自己的直系子进程; 你把进程丢后台,等于把监护权交给 init,supervisor 从此「看不见」它。 command必须「前台运行」。 需要 shell 包装时用exec:command=bash -c 'exec node app.js',或直接command=node /app/server.js。 直接写可执行文件路径还能少一层 shell,排查更简单。exitcodes要与程序的真实退出码对齐。exitcodes里的码 = 「预期退出」→ 不触发重启; 不在里面的码 = 「非预期退出」→ 触发autorestart。 配错就把「崩溃」登记成了「正常下班」。- 信号可能送不到孙进程:
stopasgroup=true+killasgroup=true。 这与「容器里 PID 1 不转发SIGTERM、docker stop超时被SIGKILL」是同一类问题。 只是写stopsignal=TERM不够,必须让信号覆盖整个进程组。 supervisorctl reread && supervisorctl update不会autostart。 它们只加载配置;autostart=true只在 supervisord 启动时生效,update出来的新 program 初始状态是STOPPED,必须再supervisorctl start <name>。- 坏配置会「一轮轮重试、每轮再丢一个新孤儿」。
command秒退时 supervisor 反复重试,线上就会出现「进程越查越多」。 排查时先supervisorctl remove <name>把 program 摘掉,再去看还剩谁在跑。 - 定位手法(可复用):
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 自己给的线索。
pgrep -f server.js会误伤 shell 包装层(因为-f匹配整条命令行)。 精确做法是扫/proc/*/exe指向 node、且cmdline含目标脚本的进程。- 与 systemd 的对应关系:
Type=simple/forking/notify↔command是否前台;Restart=always↔autorestart;KillMode=control-group↔stopasgroup+killasgroup;StartLimitBurst↔startretries。踩过的坑可以互相迁移。 - 本轮还额外修了复现脚本自身的「端口竞争假失败」:
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(完整解题记录)。
