[snapshot]1-snapshot内部逻辑

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:

  1. 节点认领不干净。 dump 必须在源 Pod 所在节点执行。cluster-scoped 的 Content 可以把 nodeName 降维抄进对象,再打上 nvidia.com/snapshot-node,让每个 agent 用 label selector 只看本机工单。若一切都塞进各命名空间的 Snapshot,agent 就要跨所有 namespace 扫对象,再自己解析 Pod 调度结果,缓存和权限模型都会更糙。
  2. 生命周期容易缠在一起。 artifact 往往要活过"当时那个业务 Job / 临时命名空间"。Content 做成集群对象后,可以按物理产物独立回收;PodSnapshot 仍可作为命名空间里的引用句柄。删 Snapshot 时通过 finalizer 级联 Content 和磁盘目录,而不是把"用户可见引用"和"磁盘真相"糊成一个对象。
  3. 权限边界。 业务账号通常只该在自己的 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 完就算完:

  1. PodSnapshot Ready → Captured=True
  2. 源 Job 结束,且非 target 容器全部 exit 0 → Completed=True,再删 Job
为什么成功 capture 时,底层 Job 常常看起来像 Failed?

这里要把"dump 成功"和"Kubernetes Job 成功"拆开看。

  1. CRIU 默认会结束被 dump 的进程。

    agent 调 CRIU 时 LeaveRunning=false:dump 完成后不会让 target 容器里的原进程继续跑,而是把它停掉/杀掉。对 Snapshot 来说这是预期设计------CUDA 状态被 lock 过的源进程本来就不会再 resume。结果是:被指定 dump 的那个容器(target)退出了,而且退出码通常不是 0。

  2. batch/v1 Job 怎么判定成败。

    Job 看的是 Pod 里容器的退出情况。target 容器非 0 退出时,Job 控制器往往会把 Job 标成 Failed 。所以你会看到一种反直觉的现象:快照其实拍成功了(PodSnapshot / Content 已经 Ready),但下面的源 Job 状态仍是 Failed。 SnapshotJob 的注释写得很直白:成功 capture 时 Job 几乎每次都会报 Failed,因为 checkpoint 本身就会干掉 target。

  3. 为什么 SnapshotJob 还要再等一轮,而不是 dump 完立刻删 Job?

    Pod 里常常不止 target 一个容器。还可能有 helper / sidecar,例如注释里的 GMS saver :主推理容器在被 dump 的同时,旁边可能还有一个容器在把权重或其它产物写到别处。如果 Snapshot 一看到 PodSnapshot Ready 就删掉 Job,kubelet 会连带杀掉这些还在干活的 helper,数据可能写一半。

    所以完成条件拆成两段:

    • 先认 capture 结果(Captured=True)
    • 再等所有非 target 容器 exit 0 (helper 正常收工),才把 SnapshotJob 标 Completed=True,然后才删 Job
      target 自己的退出码不要求是 0------它本来就是被 dump 杀掉的。
  4. 真正失败时为什么保留 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。

完整链路:

  1. 上层(平台 operator / Deployment 模板 / GitOps)产出符合合同 的 Pod spec,带上 nvidia.com/restore-from。
  2. Pod 被创建。
  3. kube-scheduler 选节点(你可用 nodeSelector、affinity、GPU resource 限定落点)。
  4. 该节点的 snapshot-agent 通过 pod informer 看到注解,做 preflight 校验并开始 restore。
  5. 成功后 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 实际做什么

  1. 读注解,解析 PodSnapshot → Content UID → 共享存储上的 artifact 路径,读 manifest.yaml
  2. 校验 placeholder 容器与 dump 时记录的假设是否对得上(含 GPU 等相关信息)
  3. 把 agent 的 binary bundle 和 checkpoint 目录,mount 进 placeholder 的 mount namespace(业务 Pod 自己仍然不挂 checkpoint PVC)
  4. 在容器 namespace 里跑 nsrestore:先套 overlay/rootfs diff → CRIU restore → CUDA restore
  5. 往该容器对应的 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 挂载"搬"到目标节点。流程是:

  1. 节点 A dump 时已经把可写层差量打包进 artifact
  2. artifact 落在 RWX 共享盘上
  3. 节点 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.

因此跨节点的故事是这样:

  1. 旧 Pod 在节点 A 上跑。节点 A 的 agent dump 完成后,把 artifact(含 CRIU / CUDA / rootfs-diff)写到共享存储(当前实现是集群级 RWX PVC,挂在每个 agent 的 /checkpoints)。
  2. 新 Pod 被调度到节点 B(只要 GPU/驱动匹配)。节点 B 的 agent 读到同一个 PVC 上的同一份目录,按 Content UID + 容器名找到 artifact,执行 restore。没有额外的节点间拷贝步骤。
  3. 新旧 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 只负责机制,不负责"何时拍/拍谁/失败策略"。
相关推荐
yi0111 小时前
DAY28: LeetCode 111. 二叉树的最小深度
人工智能·笔记·python·算法·leetcode·二叉树·递归
方方洛1 小时前
ai-agent教程-05-上下文工程
人工智能·llm·agent
阿里云大数据AI技术1 小时前
从原始Ego视频到训练就绪:用 EMR Serverless Spark 上的 Ray 提效具身数据生产管线
人工智能·spark
智圣新创011 小时前
全国高校第二课堂数字化建设供需适配指南:从政策要求到落地运营的全链路效能提升路径
人工智能
土星云SaturnCloud1 小时前
土星云边缘计算+AI视觉+视频存管:连锁汽修数字化升级实践
服务器·人工智能·ai·边缘计算
糖糖单片机设计1 小时前
基于 STM32 的配网变压器涌流抑制器设计与实现
人工智能·stm32·嵌入式硬件·51单片机·语音识别
倔强的小石头_1 小时前
从Go+SQLite编译到浏览器开局:landlord部署、AI对局与固定地址实战
人工智能·golang·sqlite
Rany-1 小时前
基于机器学习的长距离原水管线泄漏检测和定位
人工智能
Dawson Zhu2 小时前
《Agentic Design Patterns》第 17 章导读:推理技术
人工智能·语言模型·架构·aigc·agi