sleep infinity 当 PID 1:僵尸进程如何把 docker exec 卡死在 cgroup freeze
发布于 2026-07-23
给 Linux 系统调用笔记写了一套可重复跑的示例脚本:在 OrbStack 里起一个长期存活的 Debian 容器,挂载笔记目录,然后靠 docker exec 进去编译、运行、超时杀掉。容器的“主命令”最初是最省事的那种:
docker run -d --name syscall-lab ... sleep infinity
跑几次没问题。跑多了——尤其是带 --repeat、带超时 kill、示例程序自己崩溃——下一次 docker exec 开始偶发卡住,最后报:
waiting for the cgroup to freeze
重启容器能暂时缓过来,不重启又会再撞上。这篇文章记录这次排查:症状看起来像资源不够,机制上却是另一回事。
一、第一反应:什么不够用了?
看到 freezer / cgroup 相关报错,很容易往这些方向想:
- 容器内存/CPU 顶到了 cgroup limit;
- 进程数撞上了
pids.max/pid_max; - OrbStack / Docker Desktop 的 Linux VM 整体压力太大。
在这个场景里,这些都不是主因。教学容器负载很轻;僵尸进程再多,短时间内通常也远到不了 pid_max。真正卡住的,是一次同步条件一直不成立。
二、场景里实际发生了什么
2.1 长期容器 + 反复 exec
这个实验室容器不是“跑完就退”,而是一直挂着,方便反复 docker exec:
宿主机: run.sh
└─ docker exec → 容器里 gcc / 示例程序 / timeout kill
每次 exec 都会在容器里再起一批子进程。超时或崩溃后,这些子进程会退出;如果没人 wait(),它们就变成僵尸(zombie,Z 状态)。
2.2 sleep infinity 不配当 PID 1
没有 --init 时,sleep infinity 就是容器的 PID 1。
Linux 里,孤儿进程最终会被挂到 PID 1 下面,约定由 init 负责 wait 收尸。sleep 不会做这件事——它只会睡。于是:
超时 kill / 崩溃 / --repeat
↓
子进程退出,变成僵尸
↓
挂在 sleep(PID 1)下面,永不消失
↓
进程树越来越脏
僵尸几乎不吃 CPU/内存,但会占进程表槽位,更重要的是:它说明进程生命周期管理已经坏了。
三、docker exec 为什么要 freeze cgroup
docker exec 并不是让容器里的 PID 1 去 fork 出新进程。底层大致是:runc 在宿主机(或 VM)侧 setns 进容器的命名空间,再 fork/execve 出你要跑的命令——两个进程更像“合租室友”,不是父子。
在进入/改动 cgroup、应用部分资源规则时,runtime 常常会先把容器的 cgroup freeze 住,避免“一边改规则、一边还有任务在跑”的竞态。状态机是:
写 freezer.state = FROZEN
↓
内核:FREEZING(还在冻)
↓
等组内任务都安静下来
↓
FROZEN ← runc 在轮询等这个
↓
做完再 THAWED
报错 waiting for the cgroup to freeze 的字面意思就是:
一直停在
FREEZING,迟迟到不了FROZEN,用户态轮询超时。
四、卡在 FREEZING 时,真正缺的是什么
内核文档写得很直白:freeze 可以不完整。某个任务正忙着做“现在不能被冻住”的事,整组就会停在 FREEZING。
常见拦路虎包括:
| 拦路虎 | 为什么 |
|---|---|
| D 状态(不可中断睡眠) | 任务卡在内核里等 I/O / 锁,freezer 拽不动 |
| 正在 fork/exec,同时别人在 freeze | 新任务加入 cgroup,会把状态从 FROZEN 打回 FREEZING |
| 并发的 exec / update / pause | 多方抢 freezer,互相 freeze/thaw,半冻态拖着不走 |
这里要纠正一个直觉:
僵尸进程本身通常不是“冻不住的那个任务”。 它们已经死了,不再调度。真正让 wait 超时的,往往是旁边那些 D 状态的 runc init、半截 exec,或者 freeze↔exec 的竞态。
所以因果更准确的写法是:
PID 1 不收尸
→ 僵尸堆积 = 进程生命周期很脏
→ 更容易留下半截 runtime / D 状态 / freeze 竞态
→ 下一次 docker exec 写 FROZEN,卡在 FREEZING
→ 超时:"waiting for the cgroup to freeze"
不够用的不是内存配额,而是这个条件一直不满足:
∀ task ∈ cgroup:该 task 已进入可判定的 frozen 状态
runc 只有有限重试/超时(实践里大约十秒量级)。超时后报错;若 cgroup 还留在半冻的 FREEZING,后续再碰 freezer 的操作也会继续挂。
相关上游讨论可以顺着这些看:
- containerd#3286 — 先前 exec 留下僵尸后,后续
docker exec挂住 - runc#2753 / runc#2774 — freeze 不完整、
FREEZING卡死、与并发 exec 的竞态 - 内核 freezer 文档 —
FREEZING可以长期不完整,需要重试或 thaw
五、修复:给容器一个真正的 init
Docker 的 --init 会在真正的命令前面插一个 tini 当 PID 1:
docker run -d --name syscall-lab --init ... sleep infinity
进程树变成:
tini (PID 1)
└─ sleep infinity
└─(以及被收养的孤儿 / exec 留下的子进程)
tini 的本职工作就是当迷你 init:收尸。子进程退出后会被正确 wait,不再在 sleep 下面攒成一片僵尸。
对应脚本里创建容器的关键一行:
docker run -d --name "$CONTAINER" --init --privileged \
-v "$SCRIPT_DIR:/work" "$IMAGE" sleep infinity
并在启动前检查旧容器是否带了 HostConfig.Init;没有就重建——旧的“裸 sleep”容器会把问题原样带回来。
六、治本之外,还要能自愈
--init 降低复发率,但不能保证 OrbStack / Docker Desktop 这条路径上永远不撞 freezer 超时。脚本里对 docker exec 又包了一层恢复:
- 抓到
waiting for the cgroup to freeze; - 第一次:
docker restart; - 还不行:删掉重建带
--init的容器; - 若连
docker rm -f都超时(shim 无响应),再重启整个 OrbStack 引擎。
这是工程上的兜底:先从根上少造脏进程树,再对已知卡死路径自动恢复,避免每次手动点菜单栏图标。
七、写自愈脚本时踩的三个坑
写这套恢复逻辑的过程中,又撞上三个跟 freezer 本身没关系、但从外面看起来一模一样的坑——排查它们花的时间不比原来那个卡死问题少。
7.1 机器上装了不止一个容器 runtime
这台机器上 Docker Desktop 和 OrbStack 都装着。docker 命令实际连的是哪个引擎,取决于当前生效的 docker context——这里是 orbstack,不是 desktop-linux。恢复逻辑最初以为跑的是 Docker Desktop,容器卡死时就去强杀、重启 Docker Desktop 这个 App。看起来“有效”——因为之后 docker info 每次都很快恢复——但那只是因为真正在跑容器的 OrbStack 压根没被动过。Docker Desktop 一直在旁边空转、占着内存,跟卡住的容器毫无关系。
教训:给“重启 Docker”这类恢复动作写自动化之前,先 docker context ls / docker context show 确认一下。机器上如果装了不止一个容器 runtime,一定要搞清楚 CLI 实际在跟谁说话——重启错了引擎,效果跟什么都不做没区别,只是更慢、更容易把人绕晕。
7.2 手写的 timeout 把脚本静默杀死了
macOS 默认没有 timeout(也没有 gtimeout,除非用 Homebrew 装了 coreutils)。手写一个可移植版本看起来很直接:
run_with_timeout() {
local secs="$1"; shift
"$@" &
local cmd_pid=$!
( sleep "$secs"; kill -9 "$cmd_pid" 2>/dev/null ) &
local watcher_pid=$!
local status
wait "$cmd_pid" 2>/dev/null; status=$?
...
}
在 set -e 下这是坏的。wait "$cmd_pid"; status=$? 是用 ; 隔开的两条独立语句,没有被 &&、|| 或 if 保护。wait 一旦返回非零——恰恰就是这个函数存在的意义所在的那种情况——shell 立刻在那一行退出,连 status=$? 都没机会执行。没有报错信息,没有返回值,脚本就这么没了。
wait "$cmd_pid" 2>/dev/null || status=$?
把这次失败变成 || 的被测试分支,set -e 就不会管它了。修复只差一个符号;但它造成的现象——脚本报个裸退出码、什么都不解释就消失——跟这次 freezer 报错从外面看几乎一样,一度被当成同一个 bug 的延续来查。
7.3 升级条件抓错了对象
恢复逻辑第一版把“docker rm -f 失败”直接等同于“容器卡死了,重启整个引擎”,压根没看失败的具体原因。容器本来就不存在时(第一次跑,或者刚删干净),docker rm -f 同样会返回非零。不加区分的话,每次全新运行都会先莫名其妙重启一轮引擎,事情还没开始做就先自己折腾一次。
修法是升级前先看真实的报错文本:
out="$(run_with_timeout 20 docker rm -f "$CONTAINER" 2>&1)" && return 0
printf '%s' "$out" | grep -q "No such container" && return 0 # 没什么好删的
# 到这里才是真的卡死了
该不该升级,得靠匹配具体的失败特征(waiting for the cgroup to freeze、did not receive an exit event)来判断——跟第六节里检测循环的做法一致——而不是“清理步骤只要非零就升级”。
八、可以带走的结论
- 长期存活、靠
docker exec干活的容器,不要让sleep当 PID 1。 加--init(或自己放一个会 reap 的 init)。 waiting for the cgroup to freeze多半不是“配额用尽”。 它是 freezer 状态机等不到FROZEN。- 僵尸是脏环境的强信号,不一定是 freeze 的直接拦路虎。 真正拦路的往往是 D 状态任务或 freeze/exec 竞态;不收尸会让这类故障更容易出现、也更容易反复出现。
- 排查时先分清两类问题: 资源耗尽(内存/pids)vs 同步超时(freezer)。两者症状都可能是“exec 卡住”,机制完全不同。
- 自愈代码自己也需要“事后复盘”的自觉。 一个假设错了 runtime、在
set -e下悄悄吞掉自己失败、或者见到任何错误就升级(而不是只认准自己要处理的那个特征)的恢复脚本,完全可能藏在它本该修复的那个症状背后。
写这套 syscall 笔记时,本意只是找个方便的 Linux 环境;结果容器 runtime 自己又上了一课:PID 1、僵尸、cgroup freezer,正好都是笔记里会碰到的系统调用与内核机制。工具链翻车,有时候是最好的教材。