containerd Snapshotter 接口深度解析:懒加载镜像方案的技术基石
containerd Snapshotter 接口深度解析:懒加载镜像方案的技术基石
在《容器镜像加速全景指南》中我们反复提到一个结论:Nydus / eStargz / SOCI 等懒加载方案之所以能在 containerd 生态落地,根本原因是 containerd 把"文件系统准备"抽象成了一个可插拔接口,并且这个接口的契约里留了一个"关键的后门"——它只要求返回有效的挂载配置,并不要求层数据已经下载到本地。
本文把这个抽象彻底拆开讲透。理解了它,你就理解了所有 remote snapshotter 方案的设计原理。
一、问题域:从 blob 到 rootfs 之间缺失的一环
先看 containerd 内部从 pull 到 run 的完整数据流:
1 | ┌──────────────┐ pull ┌───────────────┐ |
三个阶段,三种实体:
- Content Store 阶段:manifest 和每层的压缩 tar blob 被下载、以 sha256 digest 校验并存储。注意此时层还是压缩包,根本无法挂载;
- Snapshot 阶段:每层被解压、解开 tar、处理 whiteout,层与层被组装成父子相连的快照链;
- Runtime 阶段:容器创建时,在快照链顶端再准备一个可写快照,挂载后交给 runc。
Snapshotter 的职责边界非常清晰:它不管镜像元数据解析(那是 image service 的事),不管下载校验(那是 content store 的事),不管进程创建(那是 runtime 的事)。它只回答一个问题——
“给我一个父链引用,我还你一组 mount 配置。”
这个"只问结果不问过程"的契约,是一切魔法的源头。
二、核心数据类型:先认识四个"名词"
在看接口之前,先认识 Snapshotter 世界里的四个核心数据类型,它们构成了整个状态模型。
2.1 Mount:挂载配置
1 | // github.com/containerd/containerd/core/mount |
这就是 Snapshotter 的"输出产品"。containerd 拿到它之后,实际执行等价于:
1 | mount -t <Type> <Source> -o <Options...> <目标路径> |
对于本地 overlayfs snapshotter,一次典型的输出是:
1 | Type: "overlay" |
而对一个懒加载 snapshotter,输出可能是:
1 | Type: "fuse" |
注意这个本质差异:本地 snapshotter 返回的 mount 指向本地磁盘目录;remote snapshotter 返回的 mount 指向一个 FUSE 守护进程——数据在哪儿、什么时候拉,mount 配置里完全不体现。 containerd 不感知这个差异,这正是抽象的价值。
2.2 Info 与 Kind:快照的元数据与状态
1 | // Info provides information about a particular snapshot. |
1 | type Kind uint8 |
三种状态、两态本质,构成了整个 Snapshotter 的状态机:
1 | Prepare(要可写) |
三句话概括:
- Prepare 产出"可写的 Active"——容器的可写层、解包过程中的临时写区;
- View 产出"只读的 Active"——一个只读挂载的"观察窗口";
- Commit 是状态跃迁的操作——把可写 Active 的变更"拍平"为新的 Committed。
注意一个容易混淆的点:Committed 才是"镜像层"的正式身份,Active 只是过渡态。镜像之所以能被多个容器、多个镜像引用复用,靠的正是 Committed 的不可变性。
接口文档里还有两条容易被忽略的硬性规定:
Commit之后,key标识的 active 快照就被移除了;反过来 active 快照永远不能作为 parent,只有 committed 可以;- 同一个 key 空间内,active 快照不能与 committed 快照同名。
2.3 Usage:资源统计
1 | type Usage struct { |
供 docker system df / crictl image 这类命令查询磁盘占用。接口注释特别强调:它只统计快照自身,不含父快照。它也解释了前文最佳实践中"关注 inode 而不只看容量"的由来——大量小文件的层可能容量不大但 inode 爆炸。
三、接口全貌
3.1 完整接口定义
1 | type Snapshotter interface { |
方法不多,但每个都有精确的语义分工。其中 Prepare / View / Commit 是真正的核心,第四节单独展开。
版本说明:上面是 containerd 1.x(当前生产部署的主流)的形态。containerd 2.x 里
Cleanup被拆到了独立的Cleaner接口(供实现了"快速删除 + 延迟清理"的 snapshotter 选用),接口本身的方法集与语义未变。
3.2 其余方法
- Stat:查询一个快照的 Info(是否存在、parent 是谁、kind 是什么、labels 是什么)——pull 流程靠它判断"这层我是否已有",有则整层跳过;
- Update:修改 labels 等元数据(不能改 parent / kind);
- Walk:对匹配过滤器的快照逐个回调(过滤器支持
name、parent、kind、labels.<label>),供 GC 和调试使用; - Mounts:取回某个 active 快照的挂载配置——用于在
Prepare/View之后重新拿回 mount 列表(比如 containerd 重启后需要恢复挂载); - Remove:删除一个快照——如果该快照是另一个快照的 parent,必须先删掉子快照,否则失败(这正是 GC 引用计数的语义)。注意 overlayfs 实现下
Remove只是把快照标记为不可用,磁盘空间要到下一次Cleanup才真正释放; - Cleanup:清理孤儿临时快照(比如解 tar 解到一半 daemon 崩了,留下的
extract-*残骸); - Close:释放 snapshotter 自身的内部资源。
四、三大核心操作详解:Prepare、View、Commit
前面反复出现的那句话——Committed(只读层)→ Active(可写层)→ Committed(新层)——就是这个状态机的全部流转。下面把三个操作逐一展开,把每句话背后的机制、细节和易错点讲透。
4.1 Prepare:创建可写层
操作语义:基于父镜像层准备一个可写层,返回 mounts 供挂载。拆开看四个要素:
| 要素 | 说明 |
|---|---|
| 输入 | key(临时名)+ parent(父快照的 Committed 名)+ labels |
| 输出 | 一组 Mount(挂载配置),containerd 拿去实际执行 mount |
| 状态效果 | 产生一个新的 Active 快照,key 即它的名字 |
| 约束 | parent 必须是 Committed 状态——Active 不能作 parent |
挂载后看到什么:“父层内容 + 可写层”
这是联合文件系统的核心效果。以本地 overlayfs snapshotter 为例,Prepare 返回的 mount 配置大致相当于:
1 | mount -t overlay overlay \ |
挂载后容器/解包进程看到的目录树中:
- 读:优先命中 upperdir,未命中则沿 lowerdir 逐层向下查找(copy-up 前的读取直接走 lower);
- 写:首次修改 lower 层中的文件时,触发写时复制——整个文件先被复制到 upperdir,再在 upperdir 上修改;
- 删除:在 upperdir 写入 whiteout 标记(一个特定命名的字符设备文件),遮蔽 lower 层的同名文件。
"变更只记录在可写层"指的就是这个机制——父层(lowerdir)从头到尾一个字节都不会被改动,这是层不可变性和可复用性的物理保证。
谁会调用 Prepare
两类典型调用方:
① Pull / unpack 流程(构建镜像层链时)
拉取镜像解包第 N 层时,containerd 以第 N-1 层的 Committed 快照为 parent,Prepare 一个临时 key(形如 extract-xxxx),然后把第 N 层的 tar 解压到这个挂载点上。解压产生的所有文件写入、覆盖、whiteout 都落在可写层,解完后 Commit。
② 容器运行时(创建容器 rootfs 时)
Kubelet 通过 CRI 创建容器时,containerd 以镜像最顶层快照为 parent,Prepare 一个以容器 ID 为 key 的快照。这个 Active 快照就是容器的可写层,容器的所有运行时写入(日志、临时文件、应用数据)都发生在它上面。
注意一个本质差异:场景① 的 Active 会被 Commit(变成镜像层),场景② 的 Active 永远不会被 Commit(容器销毁时直接 Remove)。这解释了为什么"容器里写的数据不会污染镜像"——可写层从未被固化。
失败清理
Prepare 产生的 Active 是"有挂载点、有资源占用"的临时态。如果调用方中途崩溃(比如解 tar 到一半 daemon 挂了),会留下无人认领的 Active 快照。Snapshotter 的 Cleanup 操作就是用来回收这类孤儿垃圾的——这也是为什么生产上偶尔能看到 extract-* 残留目录,它们就是异常中断的 Active。
4.2 View:创建只读视图
与 Prepare 的精确差异
“与 Prepare 类似,但返回的 mounts 带只读标记”——这个"只读标记"在 overlayfs 实现里的具体形态非常直观:mount 配置里根本不分配可写区域。
不过这里有一个真实的实现细节值得澄清。containerd 的 overlayfs snapshotter 在 mounts() 里是按 parent 数量分情况的:
1 | Prepare 的 mount 选项(KindActive): |
也就是说:单父层时 View 干脆返回一个只读 bind 挂载,连新文件系统都不构造;多父层时退化为一个只有 lowerdir 的 overlay 挂载。 两条路径的共同点是都没有 upperdir。
这比"加了只读标志"更彻底:不是"禁止写",而是"无处可写"。还有一个佐证——overlayfs snapshotter 建目录时只给 KindActive 创建 work/ 子目录,View 连工作目录都没有。
为什么 View 不能 Commit
这背后有一层语义设计值得展开:
Commit 的语义是"把可写层上的变更固化为新层"。而 View 从头到尾没有产生任何变更——它只是父链的一个只读投影。对"零变更"做 Commit 在语义上是荒谬的:那会创建一个和 parent 内容完全相同的"空层"。
更深一层的原因:Committed 快照的身份来自它承载的 diff 内容。如果允许 View 提交,就会出现"两个名字、内容却无差异"的层,破坏"快照名(chainID)→ 唯一内容状态"的对应关系,进而破坏整个复用体系。
所以在实现层面,Commit 会检查目标 key 的状态:只有可写的 Active 才是合法的提交对象,View(只读 Active)提交会被拒绝。
View 的典型使用场景
- Pull 流程中的层差异计算:解包前需要以只读方式挂载父链,比对即将写入的内容(处理目录合并、whiteout 语义时不能污染父层);
- 镜像导出/检查:
docker save、镜像一致性校验需要读取层的完整内容,但不能改它; - BuildKit 构建中的只读挂载:比如
RUN --mount=type=cache,ro或把前序阶段以只读方式挂进当前阶段(COPY --from的底层机制之一); - 诊断工具:查看某个容器/镜像某一层的实际文件内容而不产生副作用。
一句话定位:View 是"安全地看一眼",Prepare 是"准备动手改"。
一个重要性质:View 不改变被观察对象
前面状态机图里"Committed → 调用 View → Active(只读)"这个箭头需要精确理解:父快照本身不发生任何状态变化,仍是那个 Committed。View 创建的是一个挂在父链之上的新的观察者快照(有自己的 key,状态为只读 Active),观察结束后 Remove 掉它,父层毫发无损。
可以类比为 Git:Committed 层像 commit,View 像 git checkout <commit>(不产生新分支、不能提交),而 Prepare 像 git checkout -b <new-branch>(可以改,之后可以 merge/commit 成新提交)。
4.3 Commit:变更固化
操作语义逐句拆解
“将 active 快照的变更固化成一个新的 committed 快照。调用后,可写层被标记为只读,可作后续快照的父层,原 active key 被移除。”
① “固化成一个新的 committed 快照”
Commit 的输入是两个名字:name(新 Committed 的名字)和 key(待提交的 Active 的名字)。执行后,那个可写层的内容被"封印"——从 overlayfs 视角看,upperdir 从此不再作为可写层参与任何新挂载,而成为后续挂载的 lowerdir 的一部分。
② “可作后续快照的父层”
这是 Commit 最重要的产出价值。状态跃迁完成后,新 Committed 具备了两个 Active 没有的能力:
- 可被作为 parent 传给后续的 Prepare/View;
- 可被多个调用方共享(多容器、多镜像引用同一条链)。
③ “原 active key 被移除”
这句的精确含义是:Commit 是一次"换发身份证"操作,不是"复制"。Active 时代的名字(extract-xxxx 这类临时 key)在 Commit 后不复存在,同一个文件系统状态以新名字(通常是 chainID)存在于 Committed 状态中。
这一点可以在 overlayfs 实现里直接看到——它的 Commit 总共只做两件事:
1 | func (o *snapshotter) Commit(ctx context.Context, name, key string, opts ...snapshots.Opt) error { |
没有数据拷贝、没有目录搬迁——磁盘上的数据目录只是被重新归属,Commit 是一次纯元数据操作,成本极低。
Commit 时刻:whiteout 与覆盖的"结算点"
一个值得单独强调的细节:层与层之间的覆盖、删除语义,是在挂载了父链的可写层上"实时生效"的(解 tar 时写入 whiteout 文件、覆盖文件直接写进 upperdir),而 Commit 把这些效果永久封存进新层。
举例:解包第 N 层时,tar 中包含删除指令(针对第 N-1 层某文件 foo),过程是:
1 | 解包前: Prepare(extract-x, parent=chainID(N-1)) |
Commit 之前,这些变更只存在于这个特定 Active 的挂载视角中;Commit 之后,它们成为层定义的一部分,对所有后续消费者生效。 这就是"提交"二字的分量。
Commit 的名字:链式哈希
Committed 快照的规范命名是 chainID(下一节展开)。它在 Commit 的语境下意义格外清晰:Commit 封存的不是"这一层写了什么",而是"叠在特定父链之上的这一层写了什么"。相同的层内容叠在不同的父链上,chainID 不同、快照也不同——身份同时编码了内容和历史。
五、Chain ID:快照复用的身份体系
5.1 为什么单层 digest 不够
快照的语义是"从零开始、层层叠加后的累计状态"。L2 单独的 digest 只描述 L2 的增量内容,而 L2 对应的快照实际承载的是"L1+L2 的联合结果"。
如果直接拿单层 digest 当快照名,会出大问题:镜像 A 的层序是 [L1, L2],镜像 B 的层序是 [L2, L1](层内容相同但顺序不同)——它们的联合结果完全不同,但 L2 的 digest 相同。
5.2 Chain ID 的定义
containerd 用链式哈希解决身份问题。这段逻辑并不在 containerd 里,而在 github.com/opencontainers/image-spec/identity 包中:
1 | // ChainIDs calculates the recursively applied chain id for each identifier |
也就是说:
1 | chainID(L1) = diffID(L1) |
当前层的 chain ID 由"父链的 chain ID + 当前层的 diffID"共同决定。它同时编码了"这一层是什么"和"它叠在什么之上"两个信息。
注意这里用的是 diffID(未压缩内容的哈希)而不是层 blob 的 digest——因为快照里存的是解压后的目录树,身份必须对应解压后的内容。
5.3 带来的复用语义
- 两个镜像共享相同的前缀层序列 → 前 prefix 层的 chain ID 完全一致 → 快照物理共享,只存一份;
- 同一镜像被 100 个容器使用 → 全部以同一个 chainID(L_top) 为 parent,只读层共享,各容器只有自己的可写层;
Stat(chainID)命中即跳过下载 + 解包——内容寻址复用从 registry 一路贯穿到本地快照层。
可以把 Chain ID 理解为:“把’镜像层的血缘关系’纳入了哈希”——身份不只是内容,还是历史。
六、两个完整剧本:pull 与 run
三个操作串起来,正好覆盖了生命周期状态机的两条路径。
剧本 A:Pull 镜像(Prepare → Commit 循环)
以三层镜像 L1→L2→L3 为例,unpack 阶段的操作序列:
1 | 第 1 层: |
注意 Stat 探测:每层 Prepare/Commit 之前,containerd 先查 Stat(chainID)——如果该快照已存在(被其他镜像复用),整个"Prepare→解压→Commit"三连直接跳过,连 blob 下载都省了。这是本地层缓存复用在操作序列层面的落点。
另外两个值得划重点的细节:
- View 的角色:在更复杂的 apply 逻辑中(需要逐层比对、处理目录合并语义时),containerd 会先 View 父层、在其上准备写层,最后 Commit——View 是"以只读方式引用父链"的标准姿势;
- 解 tar 发生在挂载点上:文件是边解压边写进快照挂载点的,不是先解到临时目录再搬过去。这保证 whiteout 与覆盖语义在联合文件系统语境下被正确结算。
剧本 B:运行容器(只 Prepare,永不 Commit)
1 | Prepare(key="<container-id>", parent=chainID(L3)) ← 可写层诞生 |
两个剧本合起来,正好覆盖状态机的两条路径:pull 走"Prepare→修改→Commit→新 Committed",run 走"Prepare→修改→Remove(不 Commit)"。镜像的不可变与容器的可写,就在"是否调用 Commit"这个分叉上分道扬镳。
至此,整个模型闭环了:pull 时 Commit 出不可变层链,run 时在链顶 Prepare 可写层,容器销毁只回收链顶。
七、常见疑问澄清
Q1:Commit 之后还能修改这一层吗?
不能。Committed 是不可变的,这是整个复用体系的根基。想"改"只能在其上 Prepare 新的可写层再 Commit 出新层——这正是镜像层叠的本质。
Q2:View 的快照占资源吗?
占,但极少。View 有自己的挂载点和元数据记录(属于 Active 状态),所以用完要 Remove 释放;只是它没有 upperdir,不产生可写数据。
Q3:Prepare 的 key 冲突了怎么办?
Prepare 要求 key 唯一——接口文档明确说明所有快照共用同一个 key 空间,active 快照不能与 committed 快照同名。所以临时 key 通常带随机后缀(extract-<uuid> 之类),容器场景则直接用容器 ID。
Q4:如果 Commit 用了一个已存在的 name?
报错。Committed 名字(chainID)是身份,不允许覆盖。这也从侧面保证了内容寻址的一致性:同名必然同内容。
Q5:这三个操作和懒加载(Nydus/eStargz/SOCI)是什么关系?
关系恰恰在于这套三拍模型对 remote snapshotter 同样成立:remote 实现在 Prepare 时返回指向 FUSE 的 mount 配置(数据未落地但挂载有效),Commit 时登记 chainID 到自己的元数据中——状态机和调用序列完全同构,只是"可写层里放的是什么、数据何时到位"换了实现。正因如此,换懒加载方案不需要改 containerd 和 Kubelet——这就是第八节所说的"抽象边界决定扩展性边界"在三个具体操作上的体现。
八、关键宽松契约:懒加载的合法性来源
现在正式回答开头埋下的问题——为什么"懒加载"没有被接口契约阻止?
重新审视 Prepare/View 的契约:
“调用者给出 parent 引用,snapshotter 返回一组 能够成功挂载 的 mount 配置。”
契约要求的全部内容仅此而已。没有任何一条要求"parent 链对应的文件数据此刻已经存在于本地磁盘"。
于是 remote snapshotter 的做法在契约内完全合法:
1 | 本地 overlayfs snapshotter: |
抽象边界画在哪里,决定了扩展性画在哪里。containerd 把边界画在"mount 配置是否有效",而不是"数据是否落地",这一步设计直接孵化了整个懒加载生态。
九、Remote Snapshotter 协议:不只是"挂载时偷懒"
懒加载方案如果只在挂载阶段起作用,那 pull 阶段还是会老老实实下载全部 blob——加速效果就只剩"解压省了"。真正的 remote snapshotter 还需要第二个能力:在 pull 阶段就让 containerd 跳过下载。
containerd 为此定义了 remote snapshotter 的扩展协议,核心机制是 labels 传递 + Prepare 前置探测。
9.1 Labels:信息下发的通道
containerd 在调用 Prepare 时,会把层的上下文信息通过 opts 中的 labels 传给 snapshotter。接口源码里对这些 label 的定义(core/snapshots/snapshotter.go)说明了协议的机制:
1 | // LabelSnapshotRef is set by the unpacker on the extraction Prepare to |
配合 CRI 侧注入的镜像上下文:
1 | containerd.io/snapshot.ref = 本层最终的 chain ID |
注意:CRI 侧的这些 annotation 由
disable_snapshot_annotations = false开启(默认即开)。Nydus / SOCI / stargz 全靠这几个 label 知道"该去哪个 registry、拉哪个镜像的数据"——这是它们接入的命脉。
9.2 反向探测:Prepare 即声明"这层我能远程提供"
pull 解包某层之前,containerd 会先带着 labels 调用一次 snapshotter 的 Prepare。这里的握手细节值得精确描述,因为它和"返回一个 mount 配置"并不是一回事:
- 普通本地 snapshotter:labels 里没有它认识的东西,Prepare 需要 parent 已存在才能成功 → 返回错误 → containerd 走正常路径:fetch blob → Prepare → 解 tar → Commit;
- remote snapshotter:从 labels 里解析出
snapshot.ref(目标 chainID)和镜像引用 → 检查远端(registry)是否有该层的懒加载格式数据(eStargz 层 / RAFS blob / zTOC 索引)→ 有 → snapshotter 自己把名为该 chainID 的快照 Commit 出来,并返回ErrAlreadyExists→ unpacker 收到这个特定错误后,跳过 fetch 和 apply,直接认为这层已经就绪。
注意这里的精妙之处:snapshotter 不是"返回一个 mount 就完事",而是主动把快照建好、然后用一个约定的错误码告诉 unpacker"别下载了"。这个错误码在普通场景下是"失败",在这里是"成功且无需你动手"——协议语义完全建立在 ErrAlreadyExists 这个特定返回值上。
一句话总结这个"反向握手":
本地 snapshotter 被 containerd 问:“parent 我给你,你能挂出来吗?”
Remote snapshotter 被 containerd 问:“这层还没下载,你能挂出来吗?”——而它可以回答"能"。
这就是"镜像一个 blob 都不下载,容器照样启动"的完整机制链:pull 时零下载(Prepare 探测成功 + ErrAlreadyExists)+ 运行时按需读取(FUSE 拦截 read)。
9.3 优雅降级路径
Remote snapshotter 的探测是尽力而为的:
1 | Prepare 探测 |
SOCI 的"没有 zTOC 就回退全量下载"、eStargz 的"旧 runtime 当普通 gzip 用",都是这个降级逻辑的体现——懒加载永远不比传统路径更坏,只在其条件满足时更好,这是这些方案敢于在生产铺开的前提。
十、Proxy Plugin:进程外 snapshotter 的接入形态
前面看到的接口是 Go 语言接口,但 stargz-snapshotter、nydus-snapshotter、SOCI snapshotter 都是独立 daemon 进程,它们如何"实现"这个 Go 接口?
答案是 containerd 的 proxy plugin 机制:snapshotter 接口可以被映射为一条 ttrpc / gRPC 通道,containerd 作为客户端,外部 daemon 作为服务端,每个方法(Prepare/View/Commit/Stat…)都对应一次 RPC 调用。
配置在 /etc/containerd/config.toml:
1 | [proxy_plugins] |
进程外架构的好处:
- 升级解耦:snapshotter daemon 独立于 containerd 升级、重启(崩溃不拖垮容器运行时);
- 语言/实现自由:nydusd 用 Rust 写,SOCI snapshotter 用 Go 写,各用最擅长的技术栈;
- 故障隔离:FUSE daemon 的问题只影响该节点新镜像的懒加载,已运行的容器不受影响。
配合 K8s 侧,通常再用一个 DaemonSet 把 snapshotter daemon + containerd 配置打到每个节点上,完成全集群接入。
十一、三大懒加载实现在接口上的对照
同一套接口,三种实现策略,正好展示了这个抽象的表达力上限:
| 维度 | stargz-snapshotter | nydus-snapshotter | SOCI snapshotter |
|---|---|---|---|
| Prepare 探测依据 | 远端层是否为 eStargz 格式(gzip 尾部 TOC) | 远端是否有 RAFS blob + metadata | registry referrers API 是否挂有 zTOC |
| 返回的 Mount 类型 | FUSE | FUSE(v5)/ EROFS+fscache(v6,内核态) | FUSE |
| 数据按需粒度 | stargz 文件 entry | RAFS chunk(~1MB,可去重) | zTOC 描述的数据段 |
| 本地缓存命中时 | 直接 overlayfs 组装 | 直接本地 RAFS | 直接 overlayfs 组装 |
| 探测失败时 | 回退全量下载 + 解包 | 回退(或需要预先转换格式) | 回退全量下载 + 解包 |
| labels 的用途 | 定位远端 eStargz 层 | 定位镜像引用,拉 metadata | 定位镜像引用,查 referrers |
三者的差异全部体现在"Prepare 时怎么探测、Mount 指向什么、read 时怎么服务",而与 containerd 的对接方式完全一致——这正是好抽象的标志:容器运行时核心保持稳定,创新发生在插件边界之外。
十二、与 Docker graph driver 的对照:为什么这套设计是进步
回看 Docker daemon 时代,存储驱动(aufs / devicemapper / overlay2)编译进 daemon:
- 换存储驱动 = 换数据目录甚至重装 daemon;
- 想做懒加载?必须 fork daemon,维护一个不兼容的发行版;
- snapshot 状态、镜像状态、容器状态耦合在 daemon 的单一元数据库里。
containerd 的三层拆分——Content(CAS 下载校验)/ Snapshot(组装与挂载)/ Runtime(进程创建)——各自独立演进:
- Content 层的演进方向:registry 协议、压缩算法、P2P(Dragonfly 在这一层发力);
- Snapshot 层的演进方向:文件系统格式、按需加载(Nydus/eStargz/SOCI 在这一层发力);
- 层与层之间只用 digest 引用和 mount 配置通信,任何一层的创新都不需要动另外两层。
一个直观的体现:把一个普通 K8s 节点的 snapshotter 从 overlayfs 换成 soci,containerd 本体、Kubelet、镜像、registry 全都不用改——只换一个 socket 背后的进程和一行配置。
十三、总结:一张图记住全部
1 | Prepare(可写) Commit(name, key) |
最后提炼四句话,作为整个 Snapshotter 知识体系的记忆锚点:
- Prepare 开启变更、View 只读观察、Commit 定格历史——三个操作构成的这个小状态机,就是 containerd 镜像分层存储与容器 rootfs 构建的完整心脏;
- Chain ID 编码层的历史——身份 = 内容 + 血缘,相同前缀的镜像物理共享快照;
- 契约只管 mount 有效,不管数据在不在本地——这个宽松性是懒加载的全部合法性来源;
- Labels 下行 + Prepare 探测上行——remote snapshotter 靠这两个通道,把"全量下载再启动"改写成"零下载即启动、读取时再取数"。
理解了 Snapshotter,前面那篇加速全景里那些"加速方案"就不再是孤立的工具,而是同一套接口契约下不同策略的实现——构建期瘦身是让"需要的数据"变少,Chain ID 复用是让"已有的数据"不再搬,而懒加载是让"将要的数据"晚点到。三者共享同一个底座,这个底座就是本文讲的这套接口。