[snapshot]7-placeholder与恢复流程

0 阅读8分钟

[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 不是「往空目录里解压出一个进程」,而是:

  1. 目标容器的 Linux 命名空间已经存在(kubelet 已经把 pause + 业务容器拉起来);
  2. 在这个 mnt/pid/net 里执行 criu restore,把 dump 时的进程树接回去;
  3. 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):

  1. 必须设置 restore-from 注解,值为 PodSnapshot 名。
  2. 可选设置 restore-container-map,把一份 dump 灌入多个目标容器。
  3. 提供 snapshot-control emptyDir 卷,并按容器名使用 subPath 挂载。
  4. 设置 SNAPSHOT_CONTROL_DIR 环境变量。
  5. 入口必须保持惰性,例如执行 sleep infinity;也可由业务识别恢复等待标志后阻塞且不初始化。
  6. 建议 seccomp profiles/block-iouring.json(给 CRIU)
  7. 可选 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,不绑源节点),就可以恢复。

mermaid diagram

5.1 排队与预检

NodeController.runRestoreQueue → reconcileRestorePod:

  1. preflightRestore:同 ns 的 PodSnapshot 必须 Ready 且已 bind Content;Content 必须 Ready 且 backlink UID 对得上;podcontract.Validate;盘上 .../artifacts/{contentUID}/containers/{源容器名}/ 必须齐。
  2. 若开了 CRIU TcpEstablished,还要等 pod.Status.PodIP(给 TCP 地址 remap)。
  3. 给 Pod 打 restore finalizer,避免灌到一半被删。
  4. applyRestoredCondition(..., RestoreReasonInProgress)。
  5. 按 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-root remap;
  • 若 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 内的恢复顺序:

  1. 应用 rootfs 差量和删除记录
    把 dump 时 overlay 可写层差量应用到容器根。镜像只保证和源使用相同 base;运行中写过的文件由 artifact 补齐。
  2. 卸掉 placeholder 的共享内存挂载
    让 CRIU 按 dump 内容重建 tmpfs,避免空 shm 挡住。
  3. 临时允许写 sysctl
    restore 需要写部分 sysctl,结束后再只读回去。
  4. 若有 CUDA,提前打开 checkpoint helper
    CRIU 会恢复 dump 时的 mount namespace,工具 bundle 路径可能消失,因此提前保留文件描述符。
  5. 清掉旧的完成标志
    防止上次残留让探针提前放行。
  6. 执行 CRIU restore
    恢复 CPU、内存和部分文件描述符及网络状态。placeholder 使用新 network namespace,因此跳过 network lock,并可按新 Pod IP 做地址 remap。
  7. 执行 CUDA restore 和 unlock
    把 manifest 中的 CUDA PID 映射成恢复后的当前 PID,再由 helper 恢复 GPU 状态;跨卡时附带 device map。
  8. 校验恢复进程状态;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 镜像 stageagent/Dockerfile(AS placeholder,约 334 行起)
agent 工具箱 /snapshot-binaries同文件 AS agent
Restore Pod 合同api/podcontract,docs/reference/restore-pod-contract.md
认领本机 restore Podagent/internal/controller/controller.go(runRestoreQueue / runRestore)
挂 bundle + artifactagent/internal/nsmount
宿主机侧 restore 编排agent/internal/executor/restore.go(Restore / execNSRestore)
容器 ns 内 overlay/CRIU/CUDAagent/internal/executor/nsrestore.go,agent/cmd/nsrestore/main.go
CRIU restore 选项agent/internal/criu/restore.go