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 的操作也会继续挂。

相关上游讨论可以顺着这些看:

五、修复:给容器一个真正的 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 又包了一层恢复:

  1. 抓到 waiting for the cgroup to freeze
  2. 第一次:docker restart
  3. 还不行:删掉重建带 --init 的容器;
  4. 若连 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 freezedid not receive an exit event)来判断——跟第六节里检测循环的做法一致——而不是“清理步骤只要非零就升级”。

八、可以带走的结论

  1. 长期存活、靠 docker exec 干活的容器,不要让 sleep 当 PID 1。--init(或自己放一个会 reap 的 init)。
  2. waiting for the cgroup to freeze 多半不是“配额用尽”。 它是 freezer 状态机等不到 FROZEN
  3. 僵尸是脏环境的强信号,不一定是 freeze 的直接拦路虎。 真正拦路的往往是 D 状态任务或 freeze/exec 竞态;不收尸会让这类故障更容易出现、也更容易反复出现。
  4. 排查时先分清两类问题: 资源耗尽(内存/pids)vs 同步超时(freezer)。两者症状都可能是“exec 卡住”,机制完全不同。
  5. 自愈代码自己也需要“事后复盘”的自觉。 一个假设错了 runtime、在 set -e 下悄悄吞掉自己失败、或者见到任何错误就升级(而不是只认准自己要处理的那个特征)的恢复脚本,完全可能藏在它本该修复的那个症状背后。

写这套 syscall 笔记时,本意只是找个方便的 Linux 环境;结果容器 runtime 自己又上了一课:PID 1、僵尸、cgroup freezer,正好都是笔记里会碰到的系统调用与内核机制。工具链翻车,有时候是最好的教材。