NVIDIA Dynamo Snapshot 内部逻辑
从原「snapshot分析」里拆出的实现细节。先读 snapshot0-snapshot介绍。对照仓库 ai-dynamo/snapshot。
1. 三个 CRD:为什么要拆成 Snapshot + Content
表面上看,好像有一个 namespaced 的 PodSnapshot 就够了:用户写意图,agent 直接干活。项目却故意做成两层,很像 Kubernetes 存储里 PVC / PV 的拆法:
- PVC(namespaced):用户声明"我要一块存储",跟着业务命名空间走,方便做权限隔离和配额。
- PV(cluster-scoped):真正代表集群里那块物理存储,由系统绑定和管理。
Snapshot 这边是:
PodSnapshot(namespaced):用户/上层系统可见的句柄------"请拍这个 Pod",或"restore 时引用这份快照"。它待在业务 namespace 里,RBAC、多租户边界都好做。PodSnapshotContent(cluster-scoped) :物理 artifact 的记录和节点侧工单。里面钉死源 Pod 所在nodeName、Content UID(决定盘上目录)、以及回指哪个PodSnapshot。调用方不要自己创建;operator 创建,agent 认领并写状态。
为什么不能只用其中一种
如果只有 namespaced 的 PodSnapshot:
- 节点认领不干净。 dump 必须在源 Pod 所在节点执行。cluster-scoped 的 Content 可以把
nodeName降维抄进对象,再打上nvidia.com/snapshot-node,让每个 agent 用 label selector 只看本机工单。若一切都塞进各命名空间的 Snapshot,agent 就要跨所有 namespace 扫对象,再自己解析 Pod 调度结果,缓存和权限模型都会更糙。 - 生命周期容易缠在一起。 artifact 往往要活过"当时那个业务 Job / 临时命名空间"。Content 做成集群对象后,可以按物理产物独立回收;
PodSnapshot仍可作为命名空间里的引用句柄。删 Snapshot 时通过 finalizer 级联 Content 和磁盘目录,而不是把"用户可见引用"和"磁盘真相"糊成一个对象。 - 权限边界。 业务账号通常只该在自己的 namespace 里创建/引用 Snapshot;真正碰节点、碰共享盘路径的是集群组件。拆开后,用户 API 和系统内部工单可以分开授权。
如果只有 cluster-scoped 的 Content:
用户每次都要在集群范围创建对象,多租户下很难管:谁能看哪份快照、能不能误删别人的 Content,都会变成额外策略负担。业务侧也缺少一个"跟我的 namespace 走"的自然句柄------尤其 restore 注解 nvidia.com/restore-from 指向的是同 namespace 的 PodSnapshot 名。
所以拆成两个不是重复建设,而是:
| 对象 | 回答的问题 |
|---|---|
PodSnapshot |
用户要什么?restore 时引用谁? |
PodSnapshotContent |
文件实际在哪套身份下?该哪个节点去拍?拍得怎样了? |
两者通过 binding 连起来:PodSnapshot.status.boundSnapshotContentName ↔ PodSnapshotContent.spec.snapshotRef。
PodSnapshot
调用方创建。spec.source.podRef 钉死源 Pod 的 name、UID、以及要 dump 的容器。v1alpha1 强制恰好一个容器 (MaxItems=1),注释里说等 runtime 支持多容器 dump 再放开。
spec 用 CEL 锁成 immutable:建错了只能删再建。status 里最重要的是绑定的 Content 名,以及 Ready / Failed。
PodSnapshotContent
物理 artifact 的记录。operator 创建时从 live Pod 抄入 nodeName;路径用 Content 的 UID,不跟源节点永久绑定。同一份 dump 可以在别的匹配节点上 restore;约束是 GPU 硬件和驱动版本要兼容,不是"必须回到原机"。
SnapshotJob
一次性的 "跑起来再拍" 封装。给 podTemplate 和 podSnapshotTemplate,controller 注入 snapshot 合同(control volume、探针、seccomp 等),再建成 batch/v1 Job。镜像、GPU、sidecar、DRA、shm 全是调用方的事。
完成是两段,不是 dump 完就算完:
PodSnapshotReady →Captured=True- 源 Job 结束,且非 target 容器全部 exit 0 →
Completed=True,再删 Job
为什么成功 capture 时,底层 Job 常常看起来像 Failed?
这里要把"dump 成功"和"Kubernetes Job 成功"拆开看。
-
CRIU 默认会结束被 dump 的进程。
agent 调 CRIU 时
LeaveRunning=false:dump 完成后不会让 target 容器里的原进程继续跑,而是把它停掉/杀掉。对 Snapshot 来说这是预期设计------CUDA 状态被 lock 过的源进程本来就不会再 resume。结果是:被指定 dump 的那个容器(target)退出了,而且退出码通常不是 0。 -
batch/v1Job 怎么判定成败。Job 看的是 Pod 里容器的退出情况。target 容器非 0 退出时,Job 控制器往往会把 Job 标成 Failed 。所以你会看到一种反直觉的现象:快照其实拍成功了(
PodSnapshot/ Content 已经 Ready),但下面的源 Job 状态仍是 Failed。 SnapshotJob 的注释写得很直白:成功 capture 时 Job 几乎每次都会报 Failed,因为 checkpoint 本身就会干掉 target。 -
为什么 SnapshotJob 还要再等一轮,而不是 dump 完立刻删 Job?
Pod 里常常不止 target 一个容器。还可能有 helper / sidecar,例如注释里的 GMS saver :主推理容器在被 dump 的同时,旁边可能还有一个容器在把权重或其它产物写到别处。如果 Snapshot 一看到
PodSnapshotReady 就删掉 Job,kubelet 会连带杀掉这些还在干活的 helper,数据可能写一半。所以完成条件拆成两段:
- 先认 capture 结果(
Captured=True) - 再等所有非 target 容器 exit 0 (helper 正常收工),才把 SnapshotJob 标
Completed=True,然后才删 Job
target 自己的退出码不要求是 0------它本来就是被 dump 杀掉的。
- 先认 capture 结果(
-
真正失败时为什么保留 Job?
若是 deadline、capture 失败、helper 非 0 退出等,SnapshotJob 会标
Failed=True,并故意不急着删 底层 Job,方便你用kubectl describe/logs查容器退出原因。成功路径在确认 helper 都干净退出后才会清理 Job。
PodSnapshot 故意没有 ownerReference 指回 SnapshotJob,因为 artifact 要活得比 Job 长。反查靠 label nvidia.com/snapshot-job 和 nvidia.com/snapshot-job-uid。对象是 one-shot:UID 对不上、同名冲突、删了再建,一律终端失败,不会悄悄重跑 workload。
2. Capture 实际路径
Capture 就是"按用户请求把源容器冻住并落盘"的整条控制流 + 数据流。上一章的 SnapshotJob 会自动创建 PodSnapshot;你也可以直接创建 PodSnapshot。无论哪种,落到节点上后做的都是同一次 dump。
dump 是什么?内容存在哪?远端 restore 怎么"拷过去"?
- dump:把活进程的状态序列化成文件的动作(CRIU dump + CUDA checkpoint + overlay 打包)。说"做了一次 dump",就是说 artifact 已经写出来了。
- 存在哪 :当前实现里,本机 agent 写到它挂载的 checkpoint 存储上(默认 PVC,挂载点
/checkpoints)。目录按 PodSnapshotContent 的 UID + 容器名 组织,不按节点名组织。精确路径见下一小节。 - 远端怎么用 :Snapshot 没有 单独的"节点 A scp 到节点 B"步骤。节点 A 的 agent 写入的是集群共享的 RWX 盘 ;节点 B 的 agent 挂的是同一块盘,restore 时直接按 Content UID 打开同一路径。对上层来说像"拷到了远端",底层其实是共享存储可见性,不是 Snapshot 自己做跨节点复制。细节见下文存储。
- 控制面不存 dump 字节。
PodSnapshot/PodSnapshotContent只记身份、绑定、节点、Ready/Failed。真正的文件在盘上。
dump 落盘了哪些数据?目录长什么样?
实现入口是 agent/internal/executor/checkpoint.go。先写到同 Content 下的 .tmp/<uuid>/,成功后再 rename 到最终路径,避免 restore 读到半截 dump。最终路径由 nsmount.ResolveArtifactPath 拼出:
text
<storage.basePath>/artifacts/<PodSnapshotContent.UID>/containers/{容器名}/
basePath 默认 /checkpoints。示意:
text
/checkpoints/ ← agent 挂载的 RWX PVC(业务 Pod 不挂)
└── artifacts/
└── <PodSnapshotContent.UID>/
├── .tmp/ ← dump 过程中的暂存
└── containers/
└── {容器名}/ ← 最终制品(atomic rename 进来)
├── manifest.yaml
├── criu.conf
├── dump.log
├── pages-*.img 等 ← CRIU 镜像(具体文件名由 CRIU 决定)
├── cuda-checkpoint-job ← 有 CUDA、尤其多卡时
├── rootfs-diff.tar
└── deleted-files.json
同一目录里按来源分四类(顺序是 先 CUDA checkpoint,再 CRIU dump,再 overlay):
| 来源 | 落盘内容 | 说明 |
|---|---|---|
| 元数据 | manifest.yaml、criu.conf、dump.log |
manifest.yaml 记 Content UID、容器名、CRIU 挂载策略、源 Pod 身份(namespace/name/IP/PID/containerID/stdio FD)、overlay 信息、CUDA PID 与源 GPU UUID。restore 先读它。 |
| CUDA / GPU | 显存与 CUDA context 没有单独再写一份"显存大文件" | helper 对进程 lock → checkpoint:GPU 态先摘到 host 内存 ,随后进 CRIU 镜像。多卡时另拷 cuda-checkpoint-job(podcontract.CUDAJobFileName);checkpoint 会改这份文件,全部进程 checkpointed 后再 refreshJobFileArtifact 拷一次,restore 用刷新后的那份。单卡缺 job file 可以继续;多卡缺则 capture 失败。 |
| CRIU | 同目录下的 *.img 等镜像,以及 dump.log |
寄存器、主机内存页、线程、部分打开的 FD / pipe。网络 namespace dump 时被 externalize ,不把源 Pod 网卡原样带进制品。LeaveRunning=false,dump 成功后源进程退出。 |
| overlay | rootfs-diff.tar、deleted-files.json |
容器启动后改过的可写层(upperdir)和 whiteout。尽力而为:打失败只打日志,不让整个 dump 失败;没有 diff 时 restore 仍可用基础镜像文件系统。 |
不进这份 artifact 的: 只读镜像层、业务 PVC(模型盘、日志盘等)、节点本地 overlay 本身。跨节点靠的是制品里的 rootfs diff + 共享存储,不是把源节点 overlay 挂过去。容量规划按"同时保留几份完整 worker 内存态"来估,别按普通日志盘来想。
文字版可以和介绍篇里的 mermaid 对照着看:
perl
调用方创建 PodSnapshot
│
▼
PodSnapshotReconciler
· 等源 Pod 调度到节点
· 校验 Pod UID(防同名重建)
· 创建 PodSnapshotContent(钉死 nodeName)
│
▼
本机 Agent:reconcilePodSnapshotContent(pre-bind gate)
· 核对 Pod 还在、UID/node 对得上
· 打上 nvidia.com/snapshot-capture-eligible
│
▼
source-pod informer(只盯这个 label)触发真正 dump
│
▼
executor.Checkpoint
1. 解析 containerd/CRI-O 容器、cgroup、rootfs、GPU UUID
2. 有 CUDA 进程则 stage cuda job file
3. cuda-checkpoint 锁 GPU 状态
4. CRIU dump(源进程退出,这是预期行为)
5. overlay upperdir → rootfs-diff.tar(打进同一份 artifact)
6. 先写到 .tmp,成功后再 rename 到最终目录(共享存储上)
│
▼
Content Ready → Operator 镜像到 PodSnapshot Ready
安全闸是 nvidia.com/snapshot-capture-eligible:agent 先验证,再打标,informer 才跟 capture。没过闸的 Pod 不会被 dump。
"源 Pod 死了"这件事不好判,因为 dump 本身就会把进程干掉。operator 有 sourcePodTerminalGrace;agent 的 gate 也会看是否已有 in-flight / 已提交的 artifact,避免刚 dump 成功就把 Content 标成 Failed。
3. Restore:注解 + 合同,不走 CRD 调和
Capture 路径是:PodSnapshot → operator 建 PodSnapshotContent → agent dump。
Restore 路径刻意更短:不新建一套"Restore CR"让 operator 调和。 你只要起一个符合约定的普通 Pod,打上注解;哪个节点的 agent 来做 ,取决于这个 Pod 最终被调度到哪台机器(见 snapshot0-snapshot介绍)。
3.1 生产里谁触发 restore(不是 snapshotctl)
先澄清一个容易误解的点:snapshotctl 只是开发/排障工具,生产链路不该靠人去敲它 。它的 README 自己写了:正常用户路径是 PodSnapshot CR -> operator -> snapshot-agent,snapshotctl 用于"直接从一份 Pod manifest 手动验证 checkpoint/restore 行为",并且明确说它不替代 operator 管理的流程。
restore 之所以看起来"没有控制器",是因为它的触发方式本身就是声明式的 Pod 字段 ,而不是一条命令:只要 Pod 上带了 nvidia.com/restore-from 且满足合同,落到哪台节点,哪台节点的 agent 就会自动接手。所以生产里真正的触发者是创建这个 Pod 的那个东西,通常是:
| 触发方 | 典型做法 |
|---|---|
| 你的平台/上层 operator(推荐) | 在自己的 CRD 调和里,把注解和合同字段注入生成的 Pod 模板(Go 侧直接调 podcontract.Build) |
| 普通 Deployment / StatefulSet | 把注解、control volume、startupProbe、seccomp 一次性写进 spec.template,之后扩容出来的每个副本都会自动走 restore |
| Argo CD / Helm 等 GitOps | 把上面那份已符合合同的 manifest 纳入版本管理,正常发布即可 |
snapshotctl restore |
只建议用于本地验证、排障、e2e |
所以不存在"capture 完要人工去 restore"。一次 capture 产出的 PodSnapshot 是个可复用句柄 :只要它还 Ready,后续任意多次扩容、重启、重调度,只要新 Pod 带着同一个 restore-from,就都会从这份快照恢复。仓库自己的 e2e 也是这么做的------直接在 Pod 的 metadata.annotations 里写死 nvidia.com/restore-from,而不是调 CLI。
完整链路:
- 上层(平台 operator / Deployment 模板 / GitOps)产出符合合同 的 Pod spec,带上
nvidia.com/restore-from。 - Pod 被创建。
- kube-scheduler 选节点(你可用
nodeSelector、affinity、GPU resource 限定落点)。 - 该节点的 snapshot-agent 通过 pod informer 看到注解,做 preflight 校验并开始 restore。
- 成功后 agent 写 Pod condition(
Restored系列)并落restore-complete哨兵文件,startupProbe 随之放行。
Snapshot 的 operator 不参与 这一步编排;它的职责是保证当初 capture 留下的 PodSnapshot、PodSnapshotContent 和盘上产物还在、状态可查。
3.2 注解怎么写
- 必填
nvidia.com/restore-from: {同 namespace 的 PodSnapshot 名}
指向那份已 Ready 的快照句柄。agent 再顺着 binding 找到 Content UID,从而在共享盘上定位 artifact。 - 可选
nvidia.com/restore-container-map: main=engine-0,main=engine-1
一份 dump(这里源容器叫main)灌进多个目标容器。每个映射的 source 必须等于当初 dump 的那一个容器名;destination 必须在本 Pod 里真实存在。不写 map 时,默认要求 Pod 里有一个与源容器同名的容器来接。
3.3 restore 怎么和 capture 时那份 artifact 对上号
这是最关键的一环:注解里只写了一个快照名 ,agent 却要在共享盘上找到唯一正确的目录。中间靠的是 capture 阶段建立的 binding 链,一步步解析(见 agent/internal/controller/controller.go 的 restore preflight):
text
Pod 注解 nvidia.com/restore-from: worker-snapshot
│ (限定在 Pod 自己的 namespace 内查找)
▼
PodSnapshot worker-snapshot
· 必须 Ready,未被删除、未 Failed
· 读 status.boundSnapshotContentName
│
▼
PodSnapshotContent <bound-name>(集群级)
· 反查 spec.snapshotRef,要求 namespace/name/UID 与上面的 PodSnapshot 完全一致
(UID 对不上 = 快照被删后同名重建,判为 stale,拒绝恢复)
· 必须 Ready
· 从 spec.source.podRef.containers 取出当初 dump 的那一个源容器名
│
▼
artifact 路径 = <storage.basePath>/artifacts/<PodSnapshotContent.UID>/containers/{源容器名}
· basePath 默认 /checkpoints(agent 挂载的共享 RWX PVC)
· 读 manifest.yaml 校验,产物没就绪就先 pending 重试
· 目录布局与文件清单见上文 Capture 落盘
几个要点:
- 对应关系的锚点是
PodSnapshotContent的 UID,不是 Pod 名、不是节点名。 capture 时 artifact 就写在这个 UID 目录下,restore 时按同一个 UID 找回来,所以换节点也能对上。 - UID 双重校验防错配。 除了 Snapshot → Content 的正向引用,agent 还会检查 Content 回指的
snapshotRef.UID是否等于当前PodSnapshot的 UID。你把快照删掉再建一个同名的,旧 Content 会被识别为 stale,而不是悄悄恢复成一份错误的状态。 - 源容器名来自 Content,不来自你的注解。 你在
restore-container-map里写的 source 必须等于当初捕获的那个容器名,否则校验直接失败。 - 未就绪不算失败。 快照还没 Ready、Content 还没 Ready、artifact 目录还没落全,都会先记成 pending 并重试,而不是立刻把 Pod 判死。
3.4 什么叫"合同"(Restore Pod Contract)
agent 不会对任意 Pod 动手:目标 Pod 必须长成约定形状,详见 docs/restore-pod-contract.md。Go 集成应优先用 api/podcontract(已从旧包名迁过来,无兼容 alias)。
合同里几件最容易踩的事:
(1)同一 Pod 内共享的 emptyDir:snapshot-control
这里的"共享"指的是:同一个 restore Pod 里的多个容器之间 ,共用一个名叫 snapshot-control 的 emptyDir 卷;不是跨 Pod、也不是跨节点共享。
- Pod
spec.volumes里声明一次:name: snapshot-control,emptyDir: {} - 每个需要被 restore 的目标容器 都把它挂到
/snapshot-control - 但挂载时使用不同的
subPath = 该容器自己的名字
效果是:底层是同一块 emptyDir,目录上却给每个容器隔出独立子目录,例如:
text
emptyDir 根
├── engine-0/ ← 容器 engine-0 看到的 /snapshot-control
│ └── restore-complete
└── engine-1/ ← 容器 engine-1 看到的 /snapshot-control
└── restore-complete
这样 agent 可以按容器分别写哨兵文件,startupProbe 也不会互相抢同一个路径。环境变量 SNAPSHOT_CONTROL_DIR(迁移期还有废弃别名 DYN_SNAPSHOT_CONTROL_DIR)指向这个挂载点,避免业务硬编码路径。
(2)占位进程必须先活着
Snapshot 不改 你的 command,也不注入 standby 环境变量。你必须自己提供 inert 入口(常见 sleep infinity),让容器先跑起来占着坑,等 agent 用 CRIU 把真正进程换进去。
(3)seccomp 禁 io_uring
CRIU 搞不定 io_uring。Helm 会下发 localhost seccomp profile;podcontract.Build 默认只打在 restore 目标容器上,其它 sidecar 不强制改。
(4)可选但几乎都要开的 startupProbe
cat /snapshot-control/restore-complete。在哨兵出现前,kubelet 不认为容器启动完成,也就不会过早把流量打进来。默认大约允许 1800 次 × 1s 的失败窗口。agent 总会写哨兵,但不会检查你有没有配 probe------配不配是工作负载策略。
3.5 podcontract.Build 和结果判定
Build 是纯函数:深拷贝,失败就报错,不改输入。已有冲突的 annotation / volume / env / seccomp 会直接拒绝,而不是偷偷覆盖。
看恢复结果用 ClassifyRestoreOutcome:
| 结果 | 含义 |
|---|---|
RestoreSucceeded |
所有 destination 都恢复成功 |
RestorePartiallySucceeded |
只恢复了一部分 |
RestoreFailed |
全部失败 |
部分成功和失败都是终态:不能在同一个 Pod 上原地再试一次,只能删掉再建一个新的 restore Pod。
3.6 节点上 agent 实际做什么
- 读注解,解析
PodSnapshot→ Content UID → 共享存储上的 artifact 路径,读manifest.yaml - 校验 placeholder 容器与 dump 时记录的假设是否对得上(含 GPU 等相关信息)
- 把 agent 的 binary bundle 和 checkpoint 目录,mount 进 placeholder 的 mount namespace(业务 Pod 自己仍然不挂 checkpoint PVC)
- 在容器 namespace 里跑
nsrestore:先套 overlay/rootfs diff → CRIU restore → CUDA restore - 往该容器对应的 control 子目录写
restore-complete;若事后 umount 清理失败,只告警(RestoreCleanupError),restore 本身仍可算成功
网络 namespace 在 dump 时被 externalize,restore 时按新 Pod 的 IP 做 inet remap。进程回到的是新 Pod 的网络身份,不是源 Pod 那张网卡。
4. 节点上三件套
CRIU
agent/internal/criu,go-criu v8。dump 时 LeaveRunning=false:CUDA lock 过的源进程不会再 resume。挂载策略会把一部分 mount externalize、一部分 skip;maskedPaths 来自 OCI spec。
CUDA
agent/internal/cuda。GPU UUID 优先走 kubelet PodResources(nvidia.com/gpu);DRA(gpu.nvidia.com)时常报不全,就 nsenter 进容器跑 nvidia-smi。多卡 dump 必须带上 CUDA launch-job 状态,restore 缺文件会直接失败。
驱动要求写在 chart 里:NVIDIA driver 580.xx+ 。e2e 还要求 GPU Operator 26.3.0+、目标节点关 MIG、RuntimeClass/nvidia。
具体依赖了哪些 CUDA 接口、以及由此带来的运行约束,见下文,以及 snapshot5-cuda-checkpoint依赖。
Overlay / rootfs
只有内存快照不够:容器跑起来之后改过的文件在 overlay upperdir (可写层)里。dump 时 agent 在源节点上把 upperdir 打成 rootfs-diff.tar,再记下 deleted-files.json,一并放进同一份 artifact(写到共享 checkpoint 存储)。
不在同一节点时怎么办?
不需要把源节点的 overlay 挂载"搬"到目标节点。流程是:
- 节点 A dump 时已经把可写层差量打包进 artifact
- artifact 落在 RWX 共享盘上
- 节点 B restore 时从共享盘读出 tar,在新容器的 rootfs 上把差量打回去
所以跨节点依赖的是 artifact 里的 rootfs diff + 共享存储,不是"两个节点共用同一块本地 overlay"。若共享盘不可见或 tar 不完整,远端 restore 会在套 rootfs 阶段失败。staging 仍是先写临时文件再 rename,避免半截 tar 留在最终路径。
5. 存储:新旧 Pod 怎么"看到"同一份快照
先把结论说清楚:
业务 Pod(被拍的旧 Pod、要恢复的新 Pod)都不挂 checkpoint PVC。
真正读写 artifact 的是各节点上的 snapshot-agent 。agent 把文件写进 / 从共享存储读出,再通过 mount namespace 注入等手段,在容器里完成 dump/restore。Chart 原文也写了:Workload pods never mount checkpoint storage.
因此跨节点的故事是这样:
- 旧 Pod 在节点 A 上跑。节点 A 的 agent dump 完成后,把 artifact(含 CRIU / CUDA / rootfs-diff)写到共享存储(当前实现是集群级 RWX PVC,挂在每个 agent 的
/checkpoints)。 - 新 Pod 被调度到节点 B(只要 GPU/驱动匹配)。节点 B 的 agent 读到同一个 PVC 上的同一份目录,按 Content UID + 容器名找到 artifact,执行 restore。没有额外的节点间拷贝步骤。
- 新旧 Pod 自己的业务盘(模型文件 PVC、日志盘等)仍按你原来的工作负载设计来;Snapshot 不管那些盘,只保证"进程内存态 + 容器可写层 diff"这一包。制品里具体有哪些文件、目录怎么排,见上文 Capture 落盘。
那是不是"只能用共享存储"?
就今天这份代码而言:是的,跨节点 restore 依赖所有 GPU 节点的 agent 都能看到同一份 checkpoint 后端。
现状:
storage.type只实现了pvc- 该 PVC 必须是 ReadWriteMany,因为多个节点的 agent 会同时挂载
values.yaml里的 S3 等后端还是占位,chart 遇到非pvc会直接 fail- Helm 建出来的 claim 默认很大(1Ti ),卸 release 后 retain,避免误删快照数据
- 已有的 RWO claim 不能原地改成 RWX;要换新 RWX 盘再拷数据
所以:
- 不是"旧 Pod 和新 Pod 必须挂同一块业务盘"
- 而是"集群里的 snapshot-agent 们必须共享同一块 checkpoint 存储"
- 单节点玩玩,理论上也能用只挂在那一台的盘,但产品和 e2e 假设的是多节点可恢复,因此把 RWX 写成了硬条件
operator 还会定期扫孤儿目录(默认 10 分钟一次,每轮最多删 10 个 root)。PodSnapshot 带 finalizer,删 Snapshot 会级联 Content,再落到盘上。容量规划按"同时保留几份完整 worker 状态"来估,别按普通日志盘来想。
6. 部署时会踩的点
Helm 一次装整个集群一份:operator、agent DaemonSet、PVC、cluster RBAC、seccomp ConfigMap。
硬条件:
- x86_64 GPU 节点
- containerd(默认)或 CRI-O(
runtime.type=crio);OpenShift 还要openshift.enabled=true - 集群允许特权 DaemonSet,以及
hostPID/hostIPC/hostNetwork - 能供应 RWX 的 StorageClass
CRD:helm upgrade 默认不更新 CRD。operator 用 crd-installer init container 补这道缝。关掉的话要自己 kubectl apply --server-side,否则新字段可能被静默丢掉。
snapshotctl 偏开发/排障。checkpoint 仍走 CR;restore 只创建符合合同的 Pod。它吃的是 Pod manifest,不是 Deployment/Job。
7. 和 Dynamo 的关系
仓库在 ai-dynamo 下,但 Snapshot 对 Dynamo 零感知 (SnapshotJob 注释写明:不认识 Dynamo、GMS 或任何具体消费者)。机制在 Snapshot;何时拍、拍哪份 worker、新 Pod 要不要 restore,在 Dynamo operator 里。
产品入口不是手写 PodSnapshot / restore-from,而是:
| Dynamo 对象 | 作用 |
|---|---|
DynamoGraphDeployment(DGD) |
serving 图;GPU worker 上开 checkpoint.enabled |
DynamoCheckpoint(dckpt) |
生命周期句柄:起 checkpoint Job、等 Ready、给 DGD 引用 |
| worker 的 placeholder 镜像 | 在 runtime 镜像上包 CRIU / cuda-checkpoint / nsrestore |
| snapshot mutating webhook | DGD 建 restore Pod 时改成合同形态(注解、占位入口等) |
对照文档:dynamo docs/kubernetes/snapshot.md。功能仍是 preview。
7.1 核心时机:golden 快照,后续 Pod restore
产品语义可以记成:
先有一份已经初始化完的 worker 状态落盘,之后新起来的同类 serving Pod 从这份状态恢复,跳过模型加载。
需要收窄的一点:Dynamo 一般不会 dump 正在接流量的那份业务 Pod。 dump 会杀掉目标进程。实际是另起一个 checkpoint Job(模板对齐 worker:同模型、同镜像、同 GPU 规格),Job 里冷启动 → 初始化完 → Snapshot dump → 源进程退出。线上 serving 副本继续活着(如果当时已经有的话)。
所以"业务 Pod 启动完成后拍黄金快照"这个理解,对的是效果 (初始化完成后的状态可复用),不对的是拍谁:拍的是 Job 里那份孪生 worker,不是把 Deployment 副本拿去杀。
7.2 Dynamo 里的两条路径
(1)先拍再引用:DynamoCheckpoint + checkpointRef
先独立创建 DynamoCheckpoint,Job 跑完变 Ready,再部署 DGD:
yaml
checkpoint:
enabled: true
checkpointRef: qwen3-06b-bf16 # DynamoCheckpoint 名
operator 不会再拍一份;新 worker Pod 做成 restore 形态,agent 灌这份 artifact。适合:serving 出现之前就预热、多套 DGD 共用同一个可读名字、快照寿命和某次 DGD 解耦。
(2)DGD 托管自动拍:只开 enabled: true,不写 checkpointRef
operator 为这个 DGD、这个 component、这一代 worker hash 建一份 DGD 拥有的 DynamoCheckpoint,再起 Job dump。自动快照 不跨 DGD 复用(哪怕 yaml 一样)。
startupPolicy 决定 serving 和 Job 谁先:
| 策略 | 行为 |
|---|---|
Immediate(默认) |
serving 可以先冷启动接流量,同时 Job 在拍。已经跑着的冷启动 Pod 不会被原地改成 restore ;快照 Ready 之后,新创建的副本(扩容、滚动、重建)才 restore。 |
WaitForCheckpoint |
副本先留 0,等 Job Ready,再起的 serving Pod 一上来就 restore。 |
deletionPolicy:Delete 随 DGD 删掉自动快照;Retain 留下,以后用 checkpointRef 接着用。
同一代 worker 缩容再扩容,可以复用这份 DGD 作用域快照;镜像/配置变了(新 generation)会再拍一份,不会拿旧黄金状态硬套新命令行。
7.3 除了"第一次初始化 → golden → 后续 restore",还有哪些场景
| 场景 | 怎么用 |
|---|---|
| 扩容 | 快照已 Ready 时,新 replica restore,而不是再 load 一遍。 |
| 重启 / 被抢占 / 换节点 | 新 Pod 仍带 restore 合同;调度到兼容 GPU/驱动的节点,本机 agent 从共享盘恢复。 |
| serving 出现之前预热 | 只建 DynamoCheckpoint,DGD 后写 checkpointRef。没有"先有业务 Pod 再拍"这一步。 |
| 多套 serving 共用一份 | 独立 dckpt + 多份 DGD 的 checkpointRef;自动拍的那份默认不这么用。 |
| DGD 删了快照还要留 | 自动路径 deletionPolicy: Retain,以后 checkpointRef。 |
| 配置/镜像换代 | 新 generation 新 Job、新快照;旧黄金状态不保证能套新 spec。 |
当前 没有、或明确关掉的:
- 把线上那份接流量的 worker 当场 dump 当主路径(会杀进程;Dynamo 用旁路 Job)
- GMS + Snapshot / failover restore(文档写 currently disabled)
- 已存在的冷启动 Pod 在快照变 Ready 后自动变 restore(create-only admission)
- 跨 DGD 凭"yaml 长得像"自动复用托管快照
snapshotctl 只绕过 Dynamo、直接打 Snapshot,用于排障,不是这条产品路径。
7.4 Snapshot 仍然不负责的部分
选哪份快照、跨驱动是否兼容、KV cache 进不进 dump、多进程/多容器一致性(alpha 仍单容器 dump)------这些 Snapshot 不做。Dynamo 用 checkpointRef / DGD 作用域 ID / placeholder 镜像 / webhook 补上"何时拍、谁 restore";GPU SKU 和 580+ 驱动 匹配仍是硬约束。
8. 现状和边界
README 自己说了:早期开发,不适合生产。对照代码,已经能跑通的是:
- 类似 PVC/PV 的 Snapshot / Content 绑定
- SnapshotJob 的 one-shot 状态机
- agent 侧 CRIU + CUDA + overlay 的完整 dump/restore
- restore 合同和
podcontract包 - Helm、RBAC、孤儿扫描、e2e
当前主要限制
- 一次只 dump 一个容器
- spec 全 immutable,改错只能删再建
- 跨节点 restore 绑定 GPU 型号和 580+ 驱动
- dump 会杀掉源进程,不能"拍完原 Pod 继续服务";要热备得另起副本再拍
- agent 以特权 DaemonSet 运行(可进 host PID/IPC/Network、碰容器运行时),再加上集群共享的 RWX checkpoint 盘:攻击面和误操作影响面都比较大------一旦 agent 或这块盘被打穿/误清,影响的不只是单个业务命名空间
- restore 失败不能在原 Pod 上原地重试,只能再起一个新 Pod
- 必须挡住
io_uring;应用如果依赖它,这条路径不可用
9. 代码上值得跟的入口
| 想搞清 | 先看 |
|---|---|
| CRD 字段和 condition | api/v1alpha1/*_types.go,constants.go |
| restore 合同 | docs/restore-pod-contract.md,api/podcontract |
| operator 绑定/镜像 | operator/internal/controller/podsnapshot_reconciler.go |
| SnapshotJob 两段完成 | operator/internal/controller/snapshotjob_reconciler.go |
| agent 认领工单、打 eligible | agent/internal/controller/podsnapshotcontent.go |
| dump / restore 流水线 | agent/internal/executor/checkpoint.go,restore.go,nsrestore.go |
| 盘上目录布局 | agent/internal/nsmount/,agent/internal/types/manifest.go |
| CUDA 依赖与 helper | agent/cmd/cuda-checkpoint-helper/main.c,agent/internal/cuda/,agent/Dockerfile |
| restore 时的快照解析 | agent/internal/controller/controller.go(restore preflight 部分) |
10. 对 cuda-checkpoint 的依赖
CRIU 只能救 CPU 侧。显存里的模型权重、CUDA context、stream 状态这些,必须靠 NVIDIA 的 checkpoint 能力。Snapshot 在这块的依赖比想象中深,且分成三层。
展开说明见同目录 snapshot5-cuda-checkpoint依赖。 下面是缩略。
三层不是三套并行方案:第 1 层驱动 API 才真正冻 GPU;第 2 层 helper 是 Snapshot 自己写的 CLI 包装(agent 只 exec 它);第 3 层官方 cuda-checkpoint + CRIU cuda_plugin 管多卡 --launch-job 和 CRIU 协同,内部同样落到第 1 层。
10.1 依赖的三层构成
| 层 | 具体依赖 | 在镜像里的位置 / 来源 | 作用 |
|---|---|---|---|
| 驱动 API | libcuda.so 的 CUDA Checkpoint Driver API (cuCheckpoint* 系列) |
编译期链 -lcuda(用 /usr/local/cuda/lib64/stubs),运行期用节点上的真实驱动 |
真正冻结/恢复 GPU 进程状态 |
| 自研包装 | cuda-checkpoint-helper(仓库自带的 C 程序,agent/cmd/cuda-checkpoint-helper/main.c) |
编译进 agent 镜像,/usr/local/bin/cuda-checkpoint-helper |
把上面那些 Driver API 包成命令行;agent 全部通过它调用 |
| 上游工具 / 插件 | NVIDIA 官方 cuda-checkpoint 二进制;CRIU 的 cuda_plugin |
/usr/local/sbin/cuda-checkpoint;CRIU 构建时 make install-cuda_plugin |
提供 --launch-job 等能力,并让 CRIU dump/restore 时能协同处理 CUDA 进程 |
注意:agent 的 Go 代码不直接 调 libcuda,也不在主路径直接跑上游 cuda-checkpoint 命令,而是统一 exec 自己那个 helper。所以要看"依赖了哪些接口",主要看 helper 用到的 Driver API。
10.2 依赖的 CUDA Driver API 接口
来自 agent/cmd/cuda-checkpoint-helper/main.c:
| CUDA Driver API | helper 暴露的用法 | Snapshot 何时用 | 说明 |
|---|---|---|---|
cuCheckpointProcessLock |
--action lock [--timeout <ms>] |
capture 第一步 | 冻住目标进程的 CUDA 活动,进入 locked,后续才能安全 dump |
cuCheckpointProcessCheckpoint |
--action checkpoint |
capture 第二步 | 把 GPU 侧状态落下来,进程进入 checkpointed(显存可被释放) |
cuCheckpointProcessRestore |
--action restore [--device-map <old=new,...>] |
restore | 把 GPU 状态恢复回来;换卡时用 CUcheckpointGpuPair 做 UUID 映射 |
cuCheckpointProcessUnlock |
--action unlock |
restore 最后一步 | 解冻,让进程继续跑 |
cuCheckpointProcessGetState |
--get-state |
排障 / unlock 失败兜底 | 返回 running / locked / checkpointed / failed;unlock 报错但状态已是 running 时视作成功 |
cuCheckpointProcessGetRestoreThreadId |
--get-restore-tid |
capture 前筛进程 | 判断某 PID 是否真持有 CUDA context。刻意不用 --get-state 筛,因为它会误判 --launch-job 这类协调进程 |
cuGetErrorName / cuGetErrorString |
错误输出 | 全程 | 把 CUresult 翻成可读信息,进 agent 日志 |
进程状态机(CUprocessState)大致是:running → locked → checkpointed,restore 时反向走回 running;出错则是 failed。
10.3 launch-job 文件:多卡的硬依赖
多 GPU 场景还依赖 CUDA 的 launch-job 机制:
| 项 | 值 / 位置 | 说明 |
|---|---|---|
| 环境变量 | CUDA_CHECKPOINT_JOB_FILE |
helper 收到 --job-file 后 setenv 给驱动 |
| 容器内固定路径 | podcontract.CUDAJobFilePath |
源 Pod 启动时由 launch wrapper 落在这里 |
| artifact 内副本 | podcontract.CUDAJobFileName |
dump 时拷进 artifact,作为后续 restore 的种子 |
| 硬性要求 | 源进程需在 cuda-checkpoint --launch-job 下启动 |
多卡时缺这个文件,capture 直接报错:multi-GPU CUDA source is missing ... |
还有个易踩的细节:CUDA 在 checkpoint 过程中会修改 这个 job file,所以 agent 在所有进程都进入 checkpointed 之后会重新拷贝一次 (refreshJobFileArtifact),早期那份校验用的拷贝不能当恢复种子。restore 时再由 PrepareLiveJobFile 在容器内还原成一份可写工作副本,artifact 里那份保持不可变,供后续多次 restore 复用。
10.4 GPU 身份识别:三条发现路径
--device-map 需要知道"源用了哪几张卡、目标是哪几张卡",所以还依赖 GPU UUID 发现:
| 优先级 | 途径 | 依赖 | 何时走这条 |
|---|---|---|---|
| 1 | DRA API(gpu.nvidia.com) |
K8s DRA / ResourceClaim | Pod 用 DRA 申请 GPU;还需容器内 nvidia-smi 校准顺序 |
| 2 | kubelet PodResources gRPC | /var/lib/kubelet/pod-resources/kubelet.sock,资源名 nvidia.com/gpu |
传统 device plugin 分配 |
| 3 | 容器内 nvidia-smi |
nsenter 进 mount/pid namespace |
上面两条都没报出设备时兜底 |
BuildDeviceMap 的行为值得记一下:同名 UUID 优先做恒等映射;全部恒等时返回空串,让 CUDA 走默认恢复路径,而不是强行触发跨 GPU 迁移。这正是同节点 restore(卡还是那几张,只是顺序不同)不该被当成迁移的原因。
10.5 这带来哪些运行约束
- 只支持 x86_64。 上游
cuda-checkpoint没有其它架构的二进制,Dockerfile 里对非 amd64 直接exit 1。 - 驱动版本卡死在 580.xx+。
cuCheckpoint*是较新的 Driver API,旧驱动上根本没有。 - CRIU 必须带 cuda_plugin 从源码构建。 镜像里是自己
make install-cuda_plugin,不是发行版包。 - 恢复节点的 GPU 要能对上。 数量必须一致(
BuildDeviceMap会直接报 count mismatch),型号/驱动要兼容。 - helper 失败即整体失败。 代码注释写明:CUDA lock/checkpoint 任一步失败,调用方应让整个操作失败并终止负载------被 lock 过的进程不会再被放回去继续服务。
参考
- 仓库:github.com/ai-dynamo/s...
- 仓库内:
README.md,docs/restore-pod-contract.md,charts/snapshot/README.md,operator/cmd/snapshotctl/README.md,e2e/README.md - 介绍:snapshot0-snapshot介绍。同目录:snapshot2-snapshot部署、snapshot4-cuda-checkpoint分析、snapshot5-cuda-checkpoint依赖
附录:常用术语
| 术语 | 含义 |
|---|---|
| Capture | 捕获 / 拍快照:按请求把源容器状态落到 artifact 的整条流程。 |
| dump | 把活进程状态序列化成文件的具体动作(CRIU/CUDA/overlay 打包)。常与 checkpoint 混用。 |
| Restore | 在新容器里把冻住的进程状态灌回去,从冻结点继续执行。 |
| Artifact | dump 落盘后的整包产物(CRIU 镜像、CUDA 文件、rootfs diff、manifest.yaml 等),不是容器镜像。 |
| CRIU | Checkpoint/Restore In Userspace。Linux 用户态工具,负责 CPU 侧状态。 |
| cuda-checkpoint | NVIDIA 侧冻结/恢复 CUDA/GPU 状态的工具;agent 里为 cuda-checkpoint-helper。 |
| Operator | 控制面 Deployment:调和 CR、建工单、镜像状态、清理孤儿目录。 |
| Node Agent | 每台 GPU 节点上的特权 DaemonSet:真正 dump/restore,并读写共享 checkpoint 存储。 |
| PodSnapshot | 命名空间级 CR:用户侧"拍谁 / restore 引用谁"的句柄。 |
| PodSnapshotContent | 集群级 CR:物理 artifact 与节点工单记录;用户不要自己建。 |
| SnapshotJob | 命名空间级 CR:一句话完成"起 Pod → Ready → 拍成 PodSnapshot"。 |
| Source Pod | 被拍的那个正在跑的 Pod。 |
| Target / destination container | dump 的源容器;restore 时接状态的目标容器。 |
| Binding | PodSnapshot 与 PodSnapshotContent 的互相绑定。 |
| Capture eligible | Label nvidia.com/snapshot-capture-eligible:校验通过后才允许真正 dump。 |
| Restore Pod Contract | restore 目标 Pod 必须满足的形态约定。 |
| Control volume / sentinel | Pod 内 emptyDir(snapshot-control)及其中的 restore-complete 等哨兵文件。 |
| placeholder / inert entrypoint | restore 前先跑着的占位进程(如 sleep infinity)。 |
| RWX / RWO | PVC 多节点读写 / 单节点读写。当前 checkpoint 盘要求 RWX。 |
| overlay / rootfs diff | 容器可写层差量;打进 artifact,供跨节点 restore 打回新容器。 |
| io_uring | Linux 异步 I/O;CRIU 不能可靠支持,故默认 seccomp 禁用。 |
| seccomp | 限制系统调用的内核机制;这里主要用于禁 io_uring。 |
| GMS | 仓库注释中的上层/周边组件名;Snapshot 不感知。常用来举例 helper sidecar(如 GMS saver)。 |
| Dynamo | NVIDIA 分布式推理相关项目族;Snapshot 对其零感知。 |
| DRA | Dynamic Resource Allocation;GPU 走 DRA 时 agent 可能改用 nvidia-smi 查 UUID。 |
| MIG | Multi-Instance GPU;当前 e2e 要求目标节点关闭。 |
| nsrestore | 在目标容器 namespace 内执行恢复的二进制。 |
| policy-neutral | 只负责机制,不负责"何时拍/拍谁/失败策略"。 |