症状
在一个生产环境 runner 的 PID 上,我们数出约 108 个 <defunct> 进程,它们在 13 天里逐渐堆积。每一个都是已经死掉的 PodDaemon,内核把它留在进程表里,等一个永远不会调 wait() 的父进程来回收。这个数量和 pod 的创建-销毁循环对得上:每跑一轮泄漏一个。
僵尸进程不占内存、不占文件描述符,只占进程表里的一个 slot,占满之前都无害,占满即硬故障。值得讲的不是怎么清理,而是一个本该比父进程活得更久的守护进程,为什么反而被父进程攥着。
PodDaemon 是什么,以及为什么 Release 看起来没问题
PodDaemon 持有某个 pod 的 agent 会话的 PTY,并接收来自 runner 的 IPC 做 I/O 转发。它的核心需求是跨 runner 重启存活:runner 升级时,已有的 pod 会话必须继续跑,新 runner 重新 attach 上去。所以这个守护进程在设计上就必须脱离 runner 的生命周期。
旧的启动逻辑用 os.StartProcess 加 proc.Release() 来做这件事:
proc, err := os.StartProcess(binPath, []string{binPath}, attr)
// ...
// Release the process so it becomes a proper daemon
if err := proc.Release(); err != nil {
return pid, fmt.Errorf("release daemon process: %w", err)
}
attr 里已经设了 Setsid: true,守护进程拿到了自己的会话。从命名看像是完成了完整脱离。其实没有。
Release() 只丢掉 Go 运行时堆里的 *os.Process 簿记——它释放的是句柄,仅此而已,根本不碰内核里的父子关系。守护进程的 ppid 依然是 runner。当守护进程后来死掉(pod 销毁时),内核给 runner 发 SIGCHLD,并把尸体挂成僵尸,直到有人回收。但没人回收,因为 runner 早就扔掉了那个本来用来 Wait() 的句柄。
这就是陷阱:Setsid 脱离的是会话;只有父进程退出(或被 PID 1 收养)才脱离父子关系。释放句柄去掉的是回到尸体的唯一通路,却没去掉尸体本身。
关键动作:真正的 double-fork
要让守护进程既能跨 runner 重启存活、又不留僵尸,它的父进程必须真的消失。父进程退出时,内核会把它的子进程 reparent 给 init(1),由 init 负责回收。这正是旧代码从未达成的性质。
修复引入了一个 launcher 子进程。runner 用一个标记参数 __processmgr_launcher__ 重新 exec 自己,这个短命进程拉起真正的守护进程、把守护进程的 PID 报回来,然后退出。它的退出,正是把守护进程 ppid 翻成 init(1) 的那一步:
// RunLauncher() 内部 —— __processmgr_launcher__ 子命令
cmd := exec.Command(binPath, args...)
configureDaemonSysProcAttr(cmd) // Setsid: 自己的会话
if err := cmd.Start(); err != nil { /* ... */ }
// 把守护进程 PID 经 fd-3 管道报上去,runner 才知道它
fmt.Fprintln(pipe, cmd.Process.Pid)
// 这里 Release 然后退出 —— 内核把守护进程 reparent 给 init
cmd.Process.Release()
os.Exit(0)
launcher 本身是进程管理器用普通 exec.Cmd 拉起的,而那个 Cmd 是被 Wait 的。所以 launcher 不产生僵尸,它留下的守护进程归 init 而不是 runner。守护进程的 PID 经文件描述符 3(ExtraFiles[0])的管道传回,受 LauncherStartTimeout(默认 10 秒)约束;超时还没报 PID 的 launcher 被当成 fork 失败并杀掉。
Windows 没有僵尸状态,也不继承 ExtraFiles,所以 launcher 这套机制在那里结构上不可行。Windows 路径直接用 DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP 拉起守护进程,这已经提供了 Windows 用户需要的父进程脱离。启动按平台拆开,而不是套一层两边都不合身的共享抽象去糊弄。
一个管理器,每个子进程一次 Wait
根因比 PodDaemon 更普遍:代码里有好几处直接调 exec/StartProcess,而「每个 Start() 恰好配一个 Wait()」这条不变量只活在各个作者脑子里。修复把这条不变量做成结构性的。
新增的 internal/processmgr 包(23 个非测试 .go 文件)成为 runner 拉起的每个长生命周期子进程的唯一真相源。调用方用 Spec 描述需求、拿回一个 Handle,永远看不到底层的 *exec.Cmd:
type Mode int
const (
ModeNormal Mode = iota // 长生命周期子进程,Stop 时回收
ModePTY // PTY 子进程
ModeDaemon // double-fork 脱离;跨 runner 重启存活
)
返回 Handle 而不是 Cmd,是这里承重的决策。如果你根本拿不到那个本来需要 Wait 的东西,你就无法做出「调了 Start 不调 Wait」的写法。内部每个 exec.Cmd 都在一个 panic-safe 的 goroutine 里恰好有一次 cmd.Wait()。PodDaemon、ACP client、MCP server lifecycle 全部迁了过来。
Stop 策略写在管理器里,而不是散在调用点。StopAll 刻意跳过守护进程——这正是保住跨重启语义的地方:
func (m *manager) StopAll(ctx context.Context) error {
return m.stopMatching(ctx, func(p Handle) bool { return p.Mode() != ModeDaemon })
}
双保险:一个永远不该触发的 reaper
管理器在 30 秒定时器上跑一个兜底清扫:
func reapOrphans() int {
count := 0
for {
var ws syscall.WaitStatus
pid, err := syscall.Wait4(-1, &ws, syscall.WNOHANG, nil)
if pid <= 0 { return count }
// ...
count++
}
}
设计意图是它永远返回 0。返回非零意味着某条 Start() 路径绕过了 processmgr——于是 reaper 给 runner_zombie_reaped_total 加一并打一条 warning,指向 /debug/processes。这个指标是泄漏探测器,不是清理机制。同一个端点按 owner、mode、PID、uptime 列出每个被跟踪的子进程,运维不用搭 Prometheus 就能回答「这个 PID 归哪个 pod」。
让这个 bug 重新变得无法表达
依赖大家记得规则的修复会腐化。两道闸门防止它复发:
forbidigolint 禁止在processmgr之外任何地方调os.StartProcess。bazel run //runner:lint在 review 之前就抓住裸写法。错误信息里点名了替代方案,所以这条 lint 顺手把修复教给你。TestE2E_ManyDaemonsLeakNoZombies跑 20 轮真实的守护进程创建-销毁——真 fork、真SIGKILL、真ppid重新挂到init(1)——然后用ps扫进程表里属于测试进程的Z状态条目,断言为零。它还断言 reaper 什么都没抓到,以此证明守护进程路径确实走了管理器,而不是泄漏后被清扫掉。
更早一轮稳定性改动(#189)已经给守护进程加了 panic 恢复和会自动重启的 goroutine,让崩溃留下栈而不是空日志。那一轮让守护进程的故障可诊断;#411 让它们不再泄漏。
结论
Release()不是脱离。它释放句柄,不改ppid。如果你要一个子进程比父进程活得久又不变僵尸,父进程必须退出(或你必须 reparent 到init),而经由一个一次性 launcher 的 double-fork 是可移植的做法。Setsid脱离的是会话,不是父子关系。这是两种不同的关系,会各自独立地失败。- 「每个
Start都要Wait」的持久修复,是让未配对的写法变得无法表达——只发Handle,永不发Cmd——再用 lint 加属性测试兜底,让这条不变量不依赖记忆。 - reaper 这类兜底网,价值在于当探测器。如果它在稳态下真在干活,把这当告警,而不是功能。