worker 残留进程树清理
批处理 worker 最常见的「幽灵故障」:任务在调度器里早就失败了,宿主上的进程却还活着——父进程卡在管道读、子进程被 init 收养,内存、句柄、端口被一点点吃光,机器越跑越慢,重启一次好两天,然后又犯。
这类残留的清理不是 kill -9 一把梭:要能证明它确实是残留(而不是正在跑的任务),要按进程组整组清(否则子进程会被收养继续活),还要分清「清理」和「修复」——如果清理完又冒出来,那是代码里派发/超时/回收的问题。
> 适用对象:Python 包装器 + 子进程(ffmpeg 转码、Playwright/Puppeteer 浏览器、ML 推理、命令行工具)这一整类 worker 体系;命令以 Linux 宿主为准。
目录
- 何时使用
- 参数与占位符
- 症状索引
- 现场采集脚本
- 心智模型:进程组、孤儿与 init 收养
- 第一步:先发现
- 判定:残留三条件
- 清理:整组、有序
- 验证与复位
- 平台差异
- 错误处理约定
- 失败模式与恢复
- 完整走查(端到端样例)
- 不适用场景
- 反模式与常见问题
何时使用
自动触发:用户描述与下表场景相关时直接启用,不需要点名本技能。
| 用户会这么说 | 实际场景 |
|---|---|
| 「任务早就失败了,进程还挂在那儿,内存被吃光了」 | 残留进程树 → 第一步:先发现 |
| 「转码 worker 越跑越多,机器越来越卡」 | 重复拉起 / 孤儿堆积 → 心智模型 |
| 「kill 了父进程,子进程还在跑」 | 只杀父进程的经典坑 → 清理:整组、有序 |
| 「ps 里一堆 python xxx_worker,不知道哪些能动」 | 判定三条件 |
| 「清理完过两天又冒出来」 | 清理不是修复 → 反模式 |
手动调用:对话里说「用 worker 残留进程树清理处理」,或直接用 @ 引用本技能(平台的完整 slug 可能带作者后缀,不必写全名)。
不适合:Kubernetes 等编排层管理的 Pod、systemd 托管的服务、Windows 上的进程——见「不适用场景」。
参数与占位符
动手前先替换下表占位符;未替换就跑等于用错环境。
| 占位符 | 含义 | 从哪里取 |
|---|---|---|
<pid> |
进程 id | ps -eo pid,pgid,etime,rss,args 第一列 |
<pgid> |
进程组 id | 同上第二列(同一作业共享) |
<jobId> |
作业 id | 调度器作业列表,或命令行里的 --job 参数 |
<作业根目录> |
作业数据根 | 部署清单里的作业挂载点 |
症状索引
| 症状 | 看哪节 | 判据 |
|---|---|---|
内存持续下降,free -m 里 available 越来越少 |
第一步:先发现 | 按 RSS 排序找最占内存的 worker 树 |
| 同一作业在宿主机上有两套进程树 | 判定:残留三条件 | PGID 不同但命令行里的作业 id 相同 |
进程 CPU 0%,etime 却是几天 |
判定:残留三条件 | 卡在管道读、等一个永远不会来的就绪信号 |
kill <pid> 之后进程还在 |
清理:整组、有序 | 子进程被 init 收养,需要按进程组杀 |
| 清理完任务还是「运行中」 | 验证与复位 | 调度器读的是启动回执文件,不是进程 |
现场采集脚本
python scripts/orphan_scan.py --json 一条命令按 ps -eo pid,ppid,pgid,pcpu,stat,etime,rss,args 采全进程,按进程组聚合,用 --pattern 找 worker:孤儿(PPID=1,通常被 init/subreaper 收养,容器内 PID 1 可能是应用本身)、僵尸(STAT 含 Z)、同作业多套进程树、长空闲(≥1 天且瞬时 CPU≈0)。CPU 默认读 /proc/<pid>/stat 的 utime+stime 间隔采样(--cpu-sample-ms,默认 1000ms;采样不可用时回退 ps 的累计值并注明),--max-processes(默认 500)限制分析数量并在 raw.truncated 记录。脚本内没有任何 kill,只打印 PID/PGID 与建议命令。
| findings 的 code | 对应本节 |
|---|---|
E-ENV-06 |
「先发现」「判定:残留三条件」(孤儿 / 重复进程树 / 长空闲) |
E-ENV-07 |
僵尸进程:KILL 无效,需父进程回收 |
E-ENV-01 / E-ENV-02 |
清理阶段的进程组范围(脚本给 pgid,命令由人执行) |
--json 的结构(真实输出节选,raw 已省略):
{
"skill": "worker-orphan-process-cleanup",
"collected_at": "2026-11-04T09:12:33+08:00",
"target": "local:build-host",
"findings": [
{"code": "E-ENV-06", "severity": "high",
"evidence": "pid=24813 pgid=24800 etime=2-05:12:33 瞬时CPU=0.0% args=transcode_worker.py --job 8842",
"action": "确认作业 8842 已终结后整组清:kill -TERM -- -24800"}
],
"raw": {"matched": 3, "truncated": false}
}
心智模型:进程组、孤儿与 init 收养
做法:把每个作业看成一棵以进程组为边界的树,而不是一堆零散的 PID。
为什么:worker 通常是三层——调度侧拉的 bash -c → Python 包装器 → 真正的干活的子进程(ffmpeg、浏览器、命令行工具)。包装器被杀后,子进程会被 PID 1(init)收养,命令行里不再有父进程信息,ps 看到的只是「一个孤零零的 ffmpeg」。按 PID 逐个杀既找不到全,也会漏掉后来 fork 的孙子进程。进程组(PGID)才是这棵树的锚点:同一作业启动的所有进程共享一个 PGID,kill -- -<pgid> 一次带走整组。
验证:清理前用 ps -o pid,pgid,args -p <pid1>,<pid2> 看这几个 PID 的 PGID 是否相同;相同才说明它们属于同一个作业的同一棵树。
三种典型残留形态:
- 卡在管道读:包装器阻塞在
pipe_read等子进程的就绪标记,CPU 恒等于 0%,子进程随之长存(可挂数天)。 - 父进程已死、子进程被收养:调度器超时杀了
bash -c,Python 和它的子进程变成孤儿,继续跑。 - 重复拉起:派发重试没做「启动自认领」,同一作业被拉起两套树,资源翻倍。
第一步:先发现
做法:先只读地看清有哪些树、各自活了多久、占多少内存。
# 按 worker 特征过滤;重点看 PID / PGID / 存活时长(etime) / 内存(RSS)
ps -eo pid,pgid,etime,rss,args | grep -E "<你的 worker 特征>" | grep -v grep
# 按内存倒序,看谁在吃内存
ps -eo pid,pgid,etime,rss,args --sort=-rss | head -n 15
为什么:etime 是最强的信号——一个「正常该几分钟跑完」的任务活了两天,基本可以断定是残留;反过来只看 CPU 会漏掉「卡在 IO 上但仍在跑」的慢任务。
验证:把你怀疑的 PID 的 PGID 记下来,ps -eo pid,pgid,args | grep <pgid> 应该能看到同一组里的全部成员。
判读要点:
- etime 格式:
05:12是 5 分 12 秒,2-05:12:33是 2 天 5 小时,位数越多活得越久。 - RSS 单位是 KB:
3245678≈ 3.1 GB。 - STAT 里的
Z是僵尸(已死未回收),另一种问题,见 FAQ。
判定:残留三条件
做法:动手之前,三条同时成立才算残留:
- 作业状态已终结——调度器/数据库里该作业是「成功 / 失败 / 取消」。「没有记录」不等于已终结:记录可能已被清理、或调度器换了存储;这种情况要交叉验证三处——进程启动时间与作业提交时间是否吻合、作业目录里的日志最后一行停在哪个阶段、命令行里的 job id 能否在调度器里查到(含历史表);
- CPU ≈ 0%(瞬时)——读
/proc/<pid>/stat的 utime+stime(字段 14/15),间隔 1 秒再读一次求差,delta_ticks / (秒数 × SC_CLK_TCK) × 100;不要用ps -o %cpu——它是进程生命周期的平均值,一个跑了 3 天、刚忙起来的进程平均下来也可能只有 2%,会被误判成空闲(脚本的做法就是采样,采样不可用才回退并注明); etime以天计,远超该任务的正常耗时。
为什么:只凭「看着像死的」就杀,会误杀正在跑的长任务——尤其是那些安静等外部回调的作业(CPU 本来就接近 0)。第 1 条把「任务已结束」和「进程还在」这两件事对上,才能确认是残留。
验证:三条都记录在清理记录里(作业 id、状态、CPU、etime、RSS),事后有人问「为什么杀这个进程」能直接给证据。
清理:整组、有序
做法(顺序执行,别跳步):
# 0) 预检:确认树成员与进程组归属,确认没有别的进程共用这些进程组
ps -o pid,pgid,args -p <pid1>,<pid2>,<pid3>
pgrep -g <pgid1>; pgrep -g <pgid2>
# 1) 先 TERM 包装器,给它机会走收尾逻辑(写 result、释放资源),等 5 秒
kill -TERM <包装器 pid>; sleep 5
# 2) 按进程组整组 KILL(此时组内只剩 worker 与它的子进程)
kill -KILL -- -<pgid1> -<pgid2> 2>/dev/null
# 3) 验证:全部退出(预期每个 PID 都输出 "No such process")
for p in <pid1> <pid2>; do kill -0 "$p" 2>&1; done
为什么:
- 先 TERM 再 KILL:TERM 让包装器有机会把结果落盘、把资源还回去;直接 KILL 会让「结果文件」停在半截。
kill -- -<pgid>而不是kill <pid>:负号表示「整个进程组」。少了这个负号,子进程会被 init 收养继续活——这正是「kill 了但进程还在」的原因。- 预检
pgrep -g:确认组里没有别的作业混进来。共享进程组(比如同一 shell 里手动起的任务)会被整组误杀。 - 例外:如果确认某个旧版 worker 没有收尾逻辑(TERM 之下会二次卡死),可以跳过第 1 步直接整组 KILL——但要在清理记录里写明原因。
验证:第 3 步每个 PID 都应返回 No such process;还有活着的,说明你漏了某个 PGID 或漏了被收养的子进程。
验证与复位
做法:清理完做四件事——看内存、看资源池、清启动回执、跑一个正常任务。
# 内存是否回落(清理前后各跑一次对比)
free -m
# 启动回执:调度器靠它判断「这个作业还在跑」,不清会导致误判
ls -l <作业根目录>/<jobId>/started.pid && rm -f <作业根目录>/<jobId>/started.pid
# 资源池是否能再次分配:提交一个最小任务,确认能正常完成
为什么:进程死掉不等于系统复位——调度器可能还认为作业在跑(读的是启动回执),资源池的配额可能还挂着。只清进程不复位,下一次派发会被自己的残留挡住。
验证:free -m 的 available 明显回升;提交的最小任务在正常时长内完成;作业列表里该作业不再显示「运行中」。
平台差异
做法:这套命令按 Linux 宿主写,换平台要重新核对语义。
为什么:
- Linux:
pgrep -g <pgid>按进程组过滤,kill -- -<pgid>整组发送——本技能默认前提。 - macOS:
pgrep -g的-g是「按真实 GID(用户组)」过滤,语义完全不同,命令不能照搬;整组 kill 的写法也需要适配。 - 容器:容器内
ps只看得到本 PID namespace 的进程;要清宿主上的残留,得在宿主机上执行,或nsenter进去。 - Windows:没有 POSIX 进程组语义(对应的是 Job Object),本技能的命令全部不适用。
验证:换平台后先跑一次只读的 ps/pgrep 组合,确认过滤结果符合预期,再谈清理。
错误处理约定
本技能把出错分成四类(输入错误 / 环境错误 / 证据不足 / 结论冲突),处置与重试策略不同,不要混着来;四类判据表与统一输出格式见 references/error-handling.md 第一、二节。
本技能常见错误码:
| 代码 | 现象 | 处置 |
|---|---|---|
E-ENV-01 |
kill <pid> 后进程还在 |
改用 kill -KILL -- -<pgid> 整组清 |
E-ENV-02 |
整组 kill 后还有零星进程 | 对漏网进程单独按 PID 清,并在记录里注明 |
E-ENV-03 |
清理完调度器仍显示「运行中」 | 删掉回执,让调度器按终态回收 |
E-ENV-04 |
内存没回落 | 补清漏网进程;确认不是别的作业 |
E-CNF-05 |
误杀了别的任务的进程 | 立即停止批量清理,从调度器重新派发受影响作业 |
E-ENV-06 |
发现孤儿 / 重复进程树 / 长空闲残留 | 先确认作业终态,再整组清(kill -TERM -- -<pgid>),别只杀父进程 |
E-ENV-07 |
僵尸进程(STAT 含 Z) | kill 无效:需父进程 wait() 回收,父进程已死则等 init 收养或重启容器 |
输入校验清单、重试与退出语义见 references/error-handling.md。
失败模式与恢复
| 失败现象 | 常见原因 | 检查方法 | 恢复动作 | 可否重试 |
|---|---|---|---|---|
kill <pid> 后进程还在 |
只杀了父进程,子进程被 init 收养 | ps -eo pid,ppid,pgid,args 看父进程是不是 1 |
改用 kill -KILL -- -<pgid> 整组清 |
可重试(换整组) |
| 整组 kill 后还有零星进程 | 有子进程换了进程组(setsid) |
pgrep -f "<worker 特征>" 全量再扫一遍 |
对漏网进程单独按 PID 清,并在记录里注明 | 可重试 |
| 清理完调度器仍显示「运行中」 | 启动回执文件没删 | ls -l <作业根目录>/<jobId>/started.pid |
删掉回执,让调度器按终态回收 | 可重试 |
| 内存没回落 | 清理不彻底,或有别的作业在吃内存 | free -m + ps --sort=-rss 前 10 名 |
补清漏网进程;确认不是别的作业 | 可重试(先定位) |
| 误杀了别的任务的进程 | 共享了进程组,或 pgrep -f 匹配过宽 |
回看预检输出 pgrep -g <pgid> 是否有陌生进程 |
立即停止批量清理,从调度器重新派发受影响作业 | 不可重试(已中断) |
kill -0 报 Operation not permitted |
进程属主不是当前用户 | ps -o user= -p <pid> |
换对应属主身份(或 root)执行 | 可重试(换用户) |
失败时优先中止:清理是破坏性操作。一旦预检发现「组里有不认识的进程」或「作业状态其实是运行中」,立刻停下,把现场(ps 快照、作业状态)留全再决定——误杀正在跑的任务比残留更难收拾。
完整走查(端到端样例)
场景:批处理平台上一批视频转码任务早已失败,但 Python 包装器与 ffmpeg 子进程活了两天,吃掉 3 GB 内存。
输入:用户原话「转码任务昨天就失败了,进程还挂着,内存快满了,帮我清一下」。
ps -eo pid,pgid,etime,rss,args | grep -E "transcode_worker|ffmpeg" | grep -v grep
ps -p 24813,24820 -o pid,pgid,%cpu,etime,rss,args
中间输出(示例):
24813 24800 2-05:12:33 3245678 python3 /app/workers/transcode_worker.py --job 8842
24820 24800 2-05:12:31 98211 ffmpeg -i /data/in/8842.mp4 -c:v libx264 /data/out/8842.mp4
PID PGID %CPU ELAPSED RSS
24813 24800 0.0 2-05:12:33 3245678
(作业表里 job 8842 的状态是 failed,结束于两天前。)
定位与修复:作业已终结 + CPU 0.0% + etime 两天,三条齐全 = 残留;两个 PID 同属 PGID 24800,说明是同一棵树 →
- 预检整组:
pgrep -g 24800确认只有这两个进程; kill -TERM 24813; sleep 5——给它机会写 result、释放临时文件;kill -KILL -- -24800,整组带走(ffmpeg 是子进程,必须靠负号才能一并清掉)。
修复后复测:
$ for p in 24813 24820; do kill -0 "$p" 2>&1; done
kill: (24813): No such process
kill: (24820): No such process
$ free -m
total used free available
Mem: 15884 4210 9871 11342
$ rm -f /data/jobs/8842/started.pid && echo "回执已清"
回执已清
结论:这一轮验证了「判定三条件」和「整组清理」——如果只 kill 24813,24820(ffmpeg)会被 init 收养继续跑,内存不会回落,看起来就像「清理没生效」。
不适用场景
- Kubernetes / 编排层管理的 Pod:进程由 kubelet 管理,残留应通过删除 Pod(必要时
--force)解决,直接 kill 宿主进程只会被重建。 - systemd 托管的服务:用
systemctl stop <unit>,它会带走 cgroup 里的全部进程;手 kill 反而可能触发自动重启。 - Windows 宿主:没有 POSIX 进程组语义,需要 Job Object 或
taskkill /T。 - 需要保留现场取证:先
gcore/ 抓ps快照 / 保留日志,再考虑清理。 - 没有作业状态可查的环境:判定三条件缺第一条,无法区分「残留」与「正在跑的长任务」,不要贸然动手。
反模式与常见问题
反模式(每条都踩过)
- 只杀父进程:子进程被 init 收养后继续跑,看起来像「杀不死」。必须整组。
- 不查作业状态就动手:正在跑的长任务 CPU 也接近 0,误杀等于人为制造故障。
kill -9一把梭:跳过 TERM 会让结果文件停在半截、临时文件不清理,下次派发直接读到脏数据。- 删作业目录:
result.json、worker.log是失败原因的唯一证据,清进程不要清证据。 - 用
pgrep -f做唯一判据:命令行匹配太宽会误杀同名脚本;它只适合做「补漏」,前置判断要靠 PGID + 作业状态。 - 清完不验证:内存没回落、回执没删,等于没清干净——过两天还会「复发」。
常见问题(FAQ)
Q:会不会误杀别人的进程?
会,前提是你跳过了预检。先 pgrep -g <pgid> 看清组内成员,再动手;发现陌生进程就停下。
Q:僵尸进程(STAT 为 Z)要不要清?
僵尸已经死了,杀不掉也不需要杀——它占的只是一个表项(STAT 为 Z),kill -9 对它无效。回收必须由父进程完成:父进程还在就修它(wait())或重启它;父进程已经死了,通常由 PID 1(init / subreaper)收养后回收——但容器内 PID 1 可能就是应用本身,要先看 /proc/1/cgroup 或容器 ID 再下结论。注意:本技能的整组清理会带走父进程,但僵尸不会立即消失——要等 init 或新的父进程回收,所以「清完 ps 里还有 Z」是预期现象,先确认它没有继续跑。
Q:子进程已经被 init 收养,还能按组找回来吗?
能。收养改变的是 PPID,不改 PGID;ps -eo pid,ppid,pgid,args 里 PGID 仍然指向原作业的进程组。
Q:能不能写个定时任务自动清理?
可以,但只能自动「发现 + 告警」,清理动作建议留人工确认——判定三条件里第一条依赖作业状态,脚本判断错的代价是误杀生产任务。
Q:清理完又冒出来,怎么办?
这是「清理不是修复」:说明派发重试在重复拉起,或超时回收没覆盖该场景。回代码排查(启动自认领、硬超时强杀后代、调度器终态回收),不要加到清理清单里了事。
延伸阅读
references/error-handling.md