CI 里的孤儿进程:用进程组和 trap 一次清干净

举报
yd_289817328 发表于 2026/11/06 09:05:24 2026/11/06
【摘要】 CI 里 job 失败后端口仍被占用,多是进程树没被回收。用 setsid 隔离进程组、trap EXIT 兜底,配合 TERM 宽限与 KILL 降级,把孤儿进程一次清干净。

流水线报错信息往往指向错误的地方。比如这样一个现场:job 日志最后一行是 listen EADDRINUSE :::8080,于是大家去查端口配置、查并行度,最后发现占用 8080 的其实是上一个 job 的进程——它在 job 被标记为 failed 之后仍活着,只是换了个父进程。

这类残留不是"偶发",而是信号语义和进程树结构的必然结果。要解决,得先接受一个事实:kill $PID 从来不负责杀进程树。

一、为什么 kill 一个 PID 不够

bash 在非交互模式下默认关闭 job control(除非显式 set -m)。这意味着脚本里用 & 启动的后台进程,与脚本本身处在同一个进程组,脚本是组长。而 kill $pid 只向单个 PID 投递信号,信号不会向下递归。

问题在于,我们启动的往往不是"一个进程":

  • sh -c "a && b" 是一个 shell 带两个孩子;
  • npm run start 是 npm 再拉起 node;
  • java -jar、pytest -n auto、浏览器驱动都会再 fork。

父进程收到 SIGTERM 退出后,没退出的子孙进程会被 reparent 到 PID 1(或被最近的 subreaper 收养)。它们继续持有监听 socket、临时目录、数据库连接,且不再有人对它们负责。runner 清理时按 PID 查杀,自然查不到。

常见的补救是 pkill -f "服务名",但这是按命令行做全局匹配,在同一个 runner 上并行跑多个 job 时,很容易把别的 job 的同名进程一起带走。它解决的是"看不见",不是"杀不干净"。

二、让服务自成一个进程组

正确思路是把清理单位从 PID 换成进程组:启动时用 setsid 让服务成为新会话的首进程,同时也就成了新进程组的组长,PGID == PID;退出时向整个组投递信号:

setsid bash -c 'exec 你的服务命令' >"$LOG" 2>&1 &
svc_pid=$!
svc_pgid=$(ps -o pgid= -p "$svc_pid" | tr -d ' ')
# ...
kill -TERM -- "-$svc_pgid"

-$svc_pgid 前面的负号是"发给整个进程组"的语义,-- 则是防止被解析成命令行选项。这一条命令覆盖了组内所有成员,包括清理时才 fork 出来的子孙。

另外要克制:不要图省事写成 kill -- -$$。那是杀自己所在的进程组。如果 CI runner 的 shim 或 agent 与脚本同组,会连带把 runner 本身杀掉,表现为"job 莫名中断、没有日志"。只杀自己创建的那个组。

三、可运行的最小实现

下面这段脚本可以直接跑,用 sleep 模拟一个忽略 SIGTERM 的服务,能验证"先 TERM、再超时、最后 KILL"的完整路径。

#!/usr/bin/env bash
set -euo pipefail

LOG="${LOG:-/tmp/svc.log}"
svc_pid=""
svc_pgid=""

cleanup() {
  local rc=$?
  trap - EXIT INT TERM
  if [[ -n "$svc_pgid" ]] && kill -0 -- "-$svc_pgid" 2>/dev/null; then
    kill -TERM -- "-$svc_pgid" 2>/dev/null || true
    for _ in $(seq 1 50); do
      kill -0 -- "-$svc_pgid" 2>/dev/null || break
      sleep 0.1
    done
    kill -KILL -- "-$svc_pgid" 2>/dev/null || true
    wait "$svc_pid" 2>/dev/null || true
  fi
  exit "$rc"
}
trap cleanup EXIT INT TERM

# 用 setsid 隔离进程组;stdin 接 /dev/null,避免脱离终端后读 TTY 阻塞
setsid bash -c 'trap "" TERM; exec sleep 300' </dev/null >"$LOG" 2>&1 &
svc_pid=$!
svc_pgid=$(ps -o pgid= -p "$svc_pid" | tr -d ' ' || true)

sleep 1
echo "service pid=$svc_pid pgid=$svc_pgid"
exit 0

跑完后用 ps -o pid,pgid,cmd -g "$svc_pgid" 检查,应该一条不剩;如果脚本里去掉 trap "" TERM,同一个检查会更快返回,说明 TERM 路径生效、没有白等 5 秒。

四、三个容易踩的细节

退出码必须第一时间保存。 cleanup 的第一行是 local rc=$?,因为后面任何一条命令都会覆盖 $?。如果先 kill 再读,trap 拿到的永远是 kill 的返回值,会把真实的失败伪装成 0。

先解除 trap 再 exit。 trap - EXIT INT TERM 不是可选项。在 EXIT trap 里再 exit 会重新进入 EXIT trap,轻则重复清理,重则递归。

TERM 之后要等,别立刻 KILL。 进程需要时间关闭连接、flush 日志、提交事务。给一个可配置的宽限期(上面是 5 秒)轮询 kill -0,超时再用 SIGKILL 兜底。这不是礼貌问题,直接 KILL 会造成日志截断和半提交状态,排查成本更高。

trap 不是万能的。 如果 runner 在超时后直接发 SIGKILL,进程没有机会执行 trap,整个进程组会原地留下。这种情况下只能在 runner 侧用 cgroup 或作业对象做兜底回收,脚本层的 trap 只能覆盖正常退出、set -e 退出和可捕获的 TERM。把这两层分开设计,比在一个脚本里堆 try/finally 更可靠。

还有一段是很多人漏掉的:cleanup 里应当临时关闭 set -e。清理动作本身就是"尽力而为",rm 遇到不存在的文件、kill 遇到已退出的进程都会返回非零;在 set -e 生效时,第一条清理命令失败就直接跳出清理函数,后面的兜底收尸全部跳过。通常的写法是在进入 cleanup 后立刻 set +e,最后显式 exit "$rc" 把原始状态交还给 runner。trap 里最怕的不是报错,而是把自己的退出码改掉。

还有一个不值得的优化:为了"更快"把超时设成 0.5 秒。CI 里大多数服务关停都在百毫秒级,但数据库和浏览器进程经常超过这个数,结果是每次都走 KILL。宽限期应当按最慢的被测服务来定,不是按最快路径。

五、边界与取舍

setsid 让进程脱离控制终端,这是收益也是代价。CI 里本来就没有 TTY,问题不大;本地复现时如果服务依赖 stdin 交互,会直接失败,所以脚本显式把 stdin 接到 /dev/null,让失败发生在启动阶段而不是随机挂起。

容器场景要单独处理:PID 1 不会自动转发信号,脚本作为 1 号进程时 trap 根本收不到 TERM。解决办法是 docker run --init 或用 tini 做 init,别指望在脚本里绕过去。这点常被误认为是 trap 写错了。

清理进程组只是第一步。端口和临时目录同样需要回收,可以在 cleanup 里追加 rm -rf "$TMPDIR",但要放在 KILL 之后并容忍失败——进程可能还在写,删早了会制造新的 flaky 现象。

代价也很明确:多一层 setsid 和子 shell,退出路径变长,出问题时信号传播链不如直连清晰。但和"随机端口冲突、每个 job 重试三次"相比,这笔成本是值得的。

CI 的稳定性更多取决于退出路径是否可控,而不是重试次数是否足够。把每个 job 的进程都关进自己的进程组,是这里成本最低的一步。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。