[snapshot]7-placeholder与恢复流程
对照仓库实现(agent/Dockerfile 的 placeholder target、api/podcontract、agent/internal/controller、executor、nsmount、cmd/nsrestore)整理。总览见 [snapshot0-snapshot介绍]。
结论先说: placeholder 的唯一职责就是给 Restore 当落点。它不是 dump 工具,也不跑业务初始化。Capture 拍的是源 Pod;Restore 需要一个已经调度好、命名空间还活着、但进程尽量什么都不做的容器,agent 再把快照灌进去。
1. 两个容易混的「placeholder」
日常说 placeholder,其实叠了两层。编 agent 镜像时看到的那个,和恢复 Pod 里跑着的那个,不是同一件事。
| 说法 | 是什么 | 干什么 |
|---|---|---|
Dockerfile --target placeholder | 一份镜像 | 以业务 BASE_IMAGE 为根,做成 restore 兼容的壳:root、有 /checkpoints 挂载点,不带 CRIU、nsrestore 和 checkpoint helper |
| 恢复 Pod 里的 placeholder 容器 | 一次 Pod 生命周期里的进程 | 入口必须是惰性进程(通常 sleep infinity),占住 pid / mnt / net / uts / ipc,等 agent 把 dump 恢复成兄弟进程 |
编 --target agent 不会起 placeholder 容器。ARG BASE_IMAGE=placeholder-requires-base-image-arg 只是占位默认值,避免没传 BASE_IMAGE 时 agent 编不过;真正出 placeholder 镜像要:
docker build --platform linux/amd64 --target placeholder \
--build-context api=../api \
--build-arg BASE_IMAGE={源业务镜像} \
-t placeholder:latest .
联调 smoketest 甚至可以不编这份镜像:源 Pod 和恢复 Pod 都用 ubuntu:24.04,恢复侧把 command 换成 exec sleep infinity 即可。官方合同要求的是「惰性入口 + 合同注解/卷」,不是必须名叫 placeholder:latest 的 tag。
--target placeholder 真正有用的场景:业务镜像很大、入口会加载模型。restore 时仍要用同一套 rootfs / glibc / 依赖,但不能再走原 entrypoint。于是从业务镜像派生一份壳,只改运行身份和挂载点,工具仍从 agent 的 /snapshot-binaries 按需挂入。
2. 为什么 Restore 必须有一个壳,而不能「直接起业务进程」
CRIU restore 不是「往空目录里解压出一个进程」,而是:
- 目标容器的 Linux 命名空间已经存在(kubelet 已经把 pause + 业务容器拉起来);
- 在这个 mnt/pid/net 里执行
criu restore,把 dump 时的进程树接回去; - GPU 路径还要在 CRIU 之后对恢复出来的 PID 做
cuda-checkpoint-helper --action restore/ unlock。
如果容器 PID 1 已经是 vLLM / smoketest 自己:
- 会再跑一遍初始化(加载第二份模型),restore 就失去意义;
- 和即将恢复出来的进程抢 GPU、抢端口、抢 pid 1 语义;
- dump 时的进程树也无法干净地落到「空」的 pid ns。
所以合同写死:调用方负责提供 inert、long-running 的入口。Snapshot 不改 container command,也不注入 SNAPSHOT_RESTORE_STANDBY——那是上层(或手写 YAML)的事。agent 只认注解 nvidia.com/restore-from,看到符合合同的本机 Pod 就干活。
源 Pod (capture) 恢复 Pod (placeholder)
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ PID 1: smoketest / vLLM │ dump │ PID 1: sleep infinity │
│ 已 cudaMalloc / 已加载权重 │ ──────► │ 占着 ns,几乎不碰 GPU │
│ ready-for-snapshot │ artifact │ 等 restore-complete │
└─────────────────────────────┘ └──────────────┬──────────────┘
│ CRIU restore
▼
┌─────────────────────────────┐
│ PID 1: 仍是 sleep(占位) │
│ PID N: 从快照醒来的业务进程 │
│ GPU 状态 restore + unlock │
└─────────────────────────────┘
关键点:placeholder 的 sleep 一般不会被替换成 PID 1。恢复出来的是同容器里的兄弟进程。业务从冻结点继续跑;sleep 继续占着,直到 Pod 结束。失败时 agent 会 SIGKILL 这个 placeholder host PID,让 kubelet 按 restartPolicy 重启壳再试(或标失败)。
3. Placeholder 镜像里有什么、没有什么
agent/Dockerfile 里 AS placeholder 几乎是空的:USER root、校验 amd64、mkdir /checkpoints。注释写得很清楚:
- 不带 CRIU、nsrestore、cuda-checkpoint-helper、CRIU plugins 及其动态库;
- GPU runtime 由节点驱动注入,不打进镜像;
- restore 时,agent 把工具包 bind-mount 到容器的临时目录。
约束(编错了要到 restore 才爆):
- placeholder 基础镜像的 glibc 必须兼容 agent 基础镜像,否则工具可能无法启动。
- 节点 kernel 至少为 5.12。
- 临时目录必须可写,供 bundle 和 artifact 挂载。
- 容器要能以 root 运行,agent 才能进入 namespace、挂盘并运行 CRIU。
agent 镜像侧对应的是「工具箱」:bundle 里有 nsrestore、CRIU、plugins、cuda-checkpoint-helper、ip、tar 和依赖动态库(不含 glibc / libcuda)。
4. Restore 合同:agent 认哪些 Pod
Restore 不经过 operator。无论通过 snapshotctl 还是手写 Deployment,流程都是创建普通 Pod、添加 restore 注解,再调度到有 GPU 和 agent 的节点。本机 agent 检查:
- Pod 已调度到本机;
- restore-from 注解指向同 namespace 的 PodSnapshot。
最低合同(api/podcontract,docs/reference/restore-pod-contract.md):
- 必须设置 restore-from 注解,值为 PodSnapshot 名。
- 可选设置 restore-container-map,把一份 dump 灌入多个目标容器。
- 提供 snapshot-control emptyDir 卷,并按容器名使用 subPath 挂载。
- 设置 SNAPSHOT_CONTROL_DIR 环境变量。
- 入口必须保持惰性,例如执行 sleep infinity;也可由业务识别恢复等待标志后阻塞且不初始化。
- 建议 seccomp
profiles/block-iouring.json(给 CRIU) - 可选 startup/readiness:
cat /snapshot-control/restore-complete(agent 写 sentinel,不检查探针本身)
podcontract.Build() 是给 Go 调用方用的纯函数:深拷贝、校验、注入卷/环境/seccomp/startup gate。手写 YAML 只要结果符合 Validate 即可。
5. 恢复详细流程(控制面 → 数据面)
Restore 落点由集群里真正给 Pod 绑定 nodeName 的调度器决定(默认是 kube-scheduler,也可以是 Volcano、调度扩展、还是自研 scheduler)。Snapshot 不会再选一台「restore 专用节点」。agent 只认已经调度到本机的 Pod:那台机器上有 agent,且能通过共享存储读到同一份 artifact(路径按 Content UID,不绑源节点),就可以恢复。
5.1 排队与预检
NodeController.runRestoreQueue → reconcileRestorePod:
preflightRestore:同 ns 的PodSnapshot必须 Ready 且已 bind Content;Content 必须 Ready 且 backlink UID 对得上;podcontract.Validate;盘上.../artifacts/{contentUID}/containers/{源容器名}/必须齐。- 若开了 CRIU
TcpEstablished,还要等pod.Status.PodIP(给 TCP 地址 remap)。 - 给 Pod 打 restore finalizer,避免灌到一半被删。
applyRestoredCondition(..., RestoreReasonInProgress)。- 按 mapping 对每个目标容器起 worker(可并行)。
快照未 Ready、盘未挂上时返回 pending,过一会儿再入队,不是立刻 Failed。
5.2 找到 placeholder 进程
worker 从 kubelet status 读取 container ID,必要时轮询 containerd,再解析出 placeholder 的宿主机 PID。后续进入 namespace、bind-mount 和写 sentinel 都通过这个 PID 完成。
同时:
- 从该 PID 解析 cgroup root,给 CRIU
--cgroup-rootremap; - 若 manifest 里有 CUDA:对比 dump 时的 GPU UUID 和当前目标容器可见 UUID,拼
--device-map(跨卡恢复)。
5.3 把工具和产物挂进壳的 mnt ns
nsmount 通过 ns-bind-mount 把两类内容挂进容器:
- 工具 bundle:只读且可执行;
- 本次 artifact 目录:只读且不可执行。
挂载钉在打开的 /proc/{pid}/ns/mnt fd 上,避免 PID 复用后挂错 ns。
5.4 进命名空间执行 nsrestore
agent 自己不在宿主机 pid namespace 里直接执行 CRIU restore。它先打开 nsrestore 二进制,再进入 placeholder 的 mount、UTS、IPC、network 和 pid namespace,然后执行 nsrestore。这里故意不进入 cgroup namespace,便于 CRIU 从宿主机可见的 cgroup 层级完成 remap。nsrestore 还会把命令和动态库搜索路径指向工具 bundle。
stdout 只回 JSON(含恢复出的 PID);日志走 stderr。
5.5 壳内部:overlay → CRIU → CUDA
容器 namespace 内的恢复顺序:
- 应用 rootfs 差量和删除记录
把 dump 时 overlay 可写层差量应用到容器根。镜像只保证和源使用相同 base;运行中写过的文件由 artifact 补齐。 - 卸掉 placeholder 的共享内存挂载
让 CRIU 按 dump 内容重建 tmpfs,避免空 shm 挡住。 - 临时允许写 sysctl
restore 需要写部分 sysctl,结束后再只读回去。 - 若有 CUDA,提前打开 checkpoint helper
CRIU 会恢复 dump 时的 mount namespace,工具 bundle 路径可能消失,因此提前保留文件描述符。 - 清掉旧的完成标志
防止上次残留让探针提前放行。 - 执行 CRIU restore
恢复 CPU、内存和部分文件描述符及网络状态。placeholder 使用新 network namespace,因此跳过 network lock,并可按新 Pod IP 做地址 remap。 - 执行 CUDA restore 和 unlock
把 manifest 中的 CUDA PID 映射成恢复后的当前 PID,再由 helper 恢复 GPU 状态;跨卡时附带 device map。 - 校验恢复进程状态;agent 再拆掉 bundle/artifact 挂载。清理失败可以只告警(
RestoreCleanupError),不一定杀掉已恢复的业务。
5.6 收尾:sentinel、condition、失败自杀
成功:agent 经 placeholder host PID 在 control 卷上写 restore-complete。源侧业务若在 dump 前进入「等这个文件」的 quiesce,此时退出等待、继续服务。kubelet 的 startup/readiness 也可以 cat 同一个文件。随后 nvidia.com/Restored=True(Reason Succeeded)。
失败:failRestore 对 placeholder SIGKILL。壳死了 kubelet 才会按策略重启;agent 不会在半恢复的 GPU 进程上重放 CRIU。agent 重启且 condition 仍是 InProgress 时,若 sentinel 已在则跳过重放。
多容器 mapping:一份 dump 可灌多个目标。协调器汇总:全成 / 全败 / Partial(部分成功需新 Pod 才能重试失败的那几个)。
6. Capture 和 Restore 里「容器」角色对照
| Capture 源容器 | Restore placeholder | |
|---|---|---|
| 入口 | 真业务(初始化完、写 ready-for-snapshot) | 惰性 sleep / standby |
| 谁执行工具 | 节点 agent(host PID + helper) | agent 把工具 挂进壳,nsrestore 在壳的 pid ns 里跑 CRIU |
| dump 后进程 | 默认 LeaveRunning=false,源进程被停掉 | sleep 留下,业务以新 PID 醒来 |
| 镜像 | 业务镜像 | 同 glibc 的业务镜像或 --target placeholder 派生壳 |
| 控制面 | 必须经过 PodSnapshot → Content 工单 | 只看 Pod 注解,不经 operator 建工单 |
所以:placeholder 就是 restore 的靶容器,不是第二条 agent,也不是第二种 dump。
7. 代码索引
| 主题 | 位置 |
|---|---|
| placeholder 镜像 stage | agent/Dockerfile(AS placeholder,约 334 行起) |
agent 工具箱 /snapshot-binaries | 同文件 AS agent |
| Restore Pod 合同 | api/podcontract,docs/reference/restore-pod-contract.md |
| 认领本机 restore Pod | agent/internal/controller/controller.go(runRestoreQueue / runRestore) |
| 挂 bundle + artifact | agent/internal/nsmount |
| 宿主机侧 restore 编排 | agent/internal/executor/restore.go(Restore / execNSRestore) |
| 容器 ns 内 overlay/CRIU/CUDA | agent/internal/executor/nsrestore.go,agent/cmd/nsrestore/main.go |
| CRIU restore 选项 | agent/internal/criu/restore.go |