containerd Snapshotter 接口深度解析:懒加载镜像方案的技术基石

在《容器镜像加速全景指南》中我们反复提到一个结论:Nydus / eStargz / SOCI 等懒加载方案之所以能在 containerd 生态落地,根本原因是 containerd 把"文件系统准备"抽象成了一个可插拔接口,并且这个接口的契约里留了一个"关键的后门"——它只要求返回有效的挂载配置,并不要求层数据已经下载到本地。

本文把这个抽象彻底拆开讲透。理解了它,你就理解了所有 remote snapshotter 方案的设计原理。


一、问题域:从 blob 到 rootfs 之间缺失的一环

先看 containerd 内部从 pull 到 run 的完整数据流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──────────────┐   pull    ┌───────────────┐
│ Registry │ ────────► │ Content Store │ ← 第一站:内容寻址 blob 存储
│ (OCI 镜像) │ │ (压缩 tar 层) │ 此时数据是"死的"压缩包
└──────────────┘ └───────┬───────┘
│
│ unpack / snapshot
▼ ← ★ Snapshotter 的领地 ★
┌─────────────────┐
│ Snapshot Store │ ← 层被解开、组装成快照链
│ (可挂载的状态) │ 数据变成"活的"目录树
└────────┬────────┘
│
│ mount + runc
▼
┌─────────────────┐
│ 容器 rootfs │ ← 容器进程看到的世界
└─────────────────┘

三个阶段,三种实体:

  1. Content Store 阶段:manifest 和每层的压缩 tar blob 被下载、以 sha256 digest 校验并存储。注意此时层还是压缩包,根本无法挂载;
  2. Snapshot 阶段:每层被解压、解开 tar、处理 whiteout,层与层被组装成父子相连的快照链;
  3. Runtime 阶段:容器创建时,在快照链顶端再准备一个可写快照,挂载后交给 runc。

Snapshotter 的职责边界非常清晰:它不管镜像元数据解析(那是 image service 的事),不管下载校验(那是 content store 的事),不管进程创建(那是 runtime 的事)。它只回答一个问题——

“给我一个父链引用,我还你一组 mount 配置。”

这个"只问结果不问过程"的契约,是一切魔法的源头。


二、核心数据类型:先认识四个"名词"

在看接口之前,先认识 Snapshotter 世界里的四个核心数据类型,它们构成了整个状态模型。

2.1 Mount:挂载配置

1
2
3
4
5
6
7
// github.com/containerd/containerd/core/mount
type Mount struct {
Type string // 文件系统类型:"overlay"、"bind"、"fuse"...
Source string // 挂载源:设备名或路径
Target string // 可选:父挂载点下的子目录
Options []string // 挂载选项
}

这就是 Snapshotter 的"输出产品"。containerd 拿到它之后,实际执行等价于:

1
mount -t <Type> <Source> -o <Options...> <目标路径>

对于本地 overlayfs snapshotter,一次典型的输出是:

1
2
3
4
5
Type:    "overlay"
Source: "overlay"
Options: ["lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/10/fs",
"upperdir=/.../snapshots/11/fs",
"workdir=/.../snapshots/11/work"]

而对一个懒加载 snapshotter,输出可能是:

1
2
3
Type:    "fuse"
Source: "nydusd" (或 stargz / soci 的 FUSE daemon)
Options: [...]

注意这个本质差异:本地 snapshotter 返回的 mount 指向本地磁盘目录;remote snapshotter 返回的 mount 指向一个 FUSE 守护进程——数据在哪儿、什么时候拉,mount 配置里完全不体现。 containerd 不感知这个差异,这正是抽象的价值。

2.2 Info 与 Kind:快照的元数据与状态

1
2
3
4
5
6
7
8
9
10
11
12
// Info provides information about a particular snapshot.
type Info struct {
Kind Kind // active or committed snapshot
Name string // name or key of snapshot
Parent string // name of parent snapshot

// 注意:只有以 containerd.io/snapshot/ 为前缀的 label
// 会被快照继承,并传递给 Prepare / View / Commit
Labels map[string]string
Created time.Time
Updated time.Time
}
1
2
3
4
5
6
7
8
type Kind uint8

const (
KindUnknown Kind = iota
KindView // 活动的只读快照(有挂载点,不可写)
KindActive // 活动的可写快照(有挂载点,可写)
KindCommitted // 已提交的不可变快照(无挂载点)
)

三种状态、两态本质,构成了整个 Snapshotter 的状态机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
                 Prepare(要可写)
┌────────────────────────────────┐
│ ▼
┌─────────────┐ ┌─────────────────┐
│ Committed │ │ Active(可写) │
│ 只读、不可变 │ │ 有挂载点、可修改 │
│ 无挂载点 │ │ key 为临时标识 │
│ 可作 parent │ └───────┬─────────┘
└─────────────┘ ▲ │
▲ │ Commit │
│ └──────────────────────┘
│ 固化为新 committed
│ │
│ View(只要只读) │
│ ┌────────────────────────┤
│ ▼ ▼
┌─────────────┐ ┌─────────────────┐
│ Committed │ │ Active(只读) │
│ (不变,View │ │ 即 View 状态 │
│ 不改变父层) │ │ 只读、不能 Commit │
└─────────────┘ └─────────────────┘

三句话概括:

  • Prepare 产出"可写的 Active"——容器的可写层、解包过程中的临时写区;
  • View 产出"只读的 Active"——一个只读挂载的"观察窗口";
  • Commit 是状态跃迁的操作——把可写 Active 的变更"拍平"为新的 Committed。

注意一个容易混淆的点:Committed 才是"镜像层"的正式身份,Active 只是过渡态。镜像之所以能被多个容器、多个镜像引用复用,靠的正是 Committed 的不可变性。

接口文档里还有两条容易被忽略的硬性规定:

  • Commit 之后,key 标识的 active 快照就被移除了;反过来 active 快照永远不能作为 parent,只有 committed 可以;
  • 同一个 key 空间内,active 快照不能与 committed 快照同名。

2.3 Usage:资源统计

1
2
3
4
type Usage struct {
Inodes int64 // number of inodes in use
Size int64 // usage, in bytes
}

供 docker system df / crictl image 这类命令查询磁盘占用。接口注释特别强调:它只统计快照自身,不含父快照。它也解释了前文最佳实践中"关注 inode 而不只看容量"的由来——大量小文件的层可能容量不大但 inode 爆炸。


三、接口全貌

3.1 完整接口定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Snapshotter interface {
// ===== 生命周期三核心 =====
Prepare(ctx context.Context, key, parent string, opts ...Opt) ([]mount.Mount, error)
View(ctx context.Context, key, parent string, opts ...Opt) ([]mount.Mount, error)
Commit(ctx context.Context, name, key string, opts ...Opt) error

// ===== 元数据管理 =====
Stat(ctx context.Context, key string) (Info, error)
Update(ctx context.Context, info Info, fieldpaths ...string) (Info, error)
Walk(ctx context.Context, fn WalkFunc, filters ...string) error

// ===== 资源管理 =====
Usage(ctx context.Context, key string) (Usage, error)
Mounts(ctx context.Context, key string) ([]mount.Mount, error)
Remove(ctx context.Context, key string) error
Cleanup(ctx context.Context) error

// ===== 生命周期收尾 =====
Close() error
}

方法不多,但每个都有精确的语义分工。其中 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
2
3
4
5
mount -t overlay overlay \
-o lowerdir=/snapshots/42/fs, ← 父链(所有 Committed 层堆叠,只读)
upperdir=/snapshots/58/fs, ← 本次新创建的可写层
workdir=/snapshots/58/work ← overlayfs 原子操作所需
/目标挂载点

挂载后容器/解包进程看到的目录树中:

  • 读:优先命中 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
2
3
4
5
6
Prepare 的 mount 选项(KindActive):
overlay, lowerdir=父层, upperdir=新目录, workdir=新目录 ← 有可写层

View 的 mount 选项:
├─ 只有 1 个 parent → bind 挂载父层的 fs,选项 ["ro", "rbind"]
└─ 有 2 个以上 parent → overlay, 只给 lowerdir,没有 upperdir/workdir

也就是说:单父层时 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 的典型使用场景

  1. Pull 流程中的层差异计算:解包前需要以只读方式挂载父链,比对即将写入的内容(处理目录合并、whiteout 语义时不能污染父层);
  2. 镜像导出/检查:docker save、镜像一致性校验需要读取层的完整内容,但不能改它;
  3. BuildKit 构建中的只读挂载:比如 RUN --mount=type=cache,ro 或把前序阶段以只读方式挂进当前阶段(COPY --from 的底层机制之一);
  4. 诊断工具:查看某个容器/镜像某一层的实际文件内容而不产生副作用。

一句话定位: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (o *snapshotter) Commit(ctx context.Context, name, key string, opts ...snapshots.Opt) error {
return o.ms.WithTransaction(ctx, true, func(ctx context.Context) error {
// 1. 拿到 key 对应的内部 id
id, _, _, err := storage.GetInfo(ctx, key)
if err != nil {
return err
}
// 2. 统计可写层的磁盘占用(供 Usage 使用)
usage, err := fs.DiskUsage(ctx, o.upperPath(id))
if err != nil {
return err
}
// 3. 在元数据里把 key 记录改成 name 记录
if _, err = storage.CommitActive(ctx, key, name, snapshots.Usage(usage), opts...); err != nil {
return fmt.Errorf("failed to commit snapshot %s: %w", key, err)
}
return nil
})
}

没有数据拷贝、没有目录搬迁——磁盘上的数据目录只是被重新归属,Commit 是一次纯元数据操作,成本极低。

Commit 时刻:whiteout 与覆盖的"结算点"

一个值得单独强调的细节:层与层之间的覆盖、删除语义,是在挂载了父链的可写层上"实时生效"的(解 tar 时写入 whiteout 文件、覆盖文件直接写进 upperdir),而 Commit 把这些效果永久封存进新层。

举例:解包第 N 层时,tar 中包含删除指令(针对第 N-1 层某文件 foo),过程是:

1
2
3
4
5
解包前: Prepare(extract-x, parent=chainID(N-1))
→ 挂载点 = [L1...L(N-1) 的联合] + 可写层
解包中: 在可写层写入 .wh.foo ← whiteout 标记
Commit: chainID(N) 封存了 ".wh.foo"
之后: 任何以 chainID(N) 为父的挂载,foo 都不可见

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ChainIDs calculates the recursively applied chain id for each identifier
// in the slice.
//
// As an example, given the chain of ids `[A, B, C]`, the result
// `[A, ChainID(A|B), ChainID(A|B|C)]` will be written back to the slice.
func ChainIDs(dgsts []digest.Digest) []digest.Digest {
if len(dgsts) < 2 {
return dgsts
}

parent := digest.FromBytes([]byte(dgsts[0] + " " + dgsts[1]))
next := dgsts[1:]
next[0] = parent
ChainIDs(next)

return dgsts
}

也就是说:

1
2
chainID(L1) = diffID(L1)
chainID(Ln) = sha256( chainID(Ln-1) || " " || diffID(Ln) )

当前层的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
第 1 层:
Prepare("extract-a", parent="") ← 空父 = 链头,可写
[解压 L1.tar → 文件写入可写层]
Commit(name=chainID(L1), key="extract-a") ← 临时 key 消失,层 1 固化

第 2 层:
Prepare("extract-b", parent=chainID(L1)) ← 以层 1 为父
[解压 L2.tar → 覆盖/whiteout 作用于层 1 之上]
Commit(name=chainID(L2), key="extract-b")

第 3 层:(同理)
Commit(name=chainID(L3), ...)

结果: 链 chainID(L3) → chainID(L2) → chainID(L1) 就绪

注意 Stat 探测:每层 Prepare/Commit 之前,containerd 先查 Stat(chainID)——如果该快照已存在(被其他镜像复用),整个"Prepare→解压→Commit"三连直接跳过,连 blob 下载都省了。这是本地层缓存复用在操作序列层面的落点。

另外两个值得划重点的细节:

  • View 的角色:在更复杂的 apply 逻辑中(需要逐层比对、处理目录合并语义时),containerd 会先 View 父层、在其上准备写层,最后 Commit——View 是"以只读方式引用父链"的标准姿势;
  • 解 tar 发生在挂载点上:文件是边解压边写进快照挂载点的,不是先解到临时目录再搬过去。这保证 whiteout 与覆盖语义在联合文件系统语境下被正确结算。

剧本 B:运行容器(只 Prepare,永不 Commit)

1
2
3
4
5
6
7
8
9
Prepare(key="<container-id>", parent=chainID(L3))   ← 可写层诞生
→ 返回 mounts: lowerdir=[三层堆叠], upperdir=容器专属
→ 挂载 → 写入 config.json → 交给 runc → 容器进程诞生

容器运行期间所有写入落在 upperdir

容器销毁:
Remove(key="<container-id>") ← 可写层回收
→ 三层 Committed 快照纹丝不动,供下一个容器复用

两个剧本合起来,正好覆盖状态机的两条路径: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
2
3
4
5
6
7
8
9
10
11
12
13
本地 overlayfs snapshotter:
Prepare(parent=chainID(L2))
→ 检查 L2 快照目录存在 → 组装 lowerdir/upperdir → 返回 mount ✓

Remote snapshotter(懒加载):
Prepare(parent=chainID(L2))
→ 启动/复用一个 FUSE daemon
→ 告诉 daemon:"挂载点 X 对应远端镜像的 chainID(L2),metadata 在 registry"
→ 返回 Type="fuse" 的 mount 配置 ✓

此时本地磁盘上一个字节的层内容都没有——
但 mount 配置有效,containerd 挂载成功,容器正常启动。
read() 到达时,FUSE daemon 拦截请求,按需从 registry 拉取 chunk。

抽象边界画在哪里,决定了扩展性画在哪里。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
2
3
4
5
6
7
8
9
10
// LabelSnapshotRef is set by the unpacker on the extraction Prepare to
// the target chainID. A snapshotter that already has the layer commits a
// snapshot named after this value and returns ErrAlreadyExists, which
// makes the unpacker skip fetching and applying the layer (the remote
// snapshot protocol). It is inherited by FilterInheritedLabels.
LabelSnapshotRef = "containerd.io/snapshot.ref"

// LabelSnapshotDiffID is set by the unpacker on the extraction Prepare to
// the uncompressed digest (diffID) of the layer being unpacked.
LabelSnapshotDiffID = "containerd.io/snapshot/diff-id"

配合 CRI 侧注入的镜像上下文:

1
2
3
4
containerd.io/snapshot.ref                  = 本层最终的 chain ID
containerd.io/snapshot/diff-id = 本层的 diffID
containerd.io/snapshot/cri.image-reference = 镜像引用(nginx:1.25 等)
containerd.io/snapshot/cri.image-layers = 该镜像全部层的 digest 列表

注意: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
2
3
4
5
Prepare 探测
├─ 远端有懒加载数据 → 建好快照 + ErrAlreadyExists,零下载启动 ✨
├─ 远端没有(普通 OCI 层/无索引)→ 返回错误
│ → containerd 回退:fetch + 解 tar + Commit(传统全量路径)
└─ 层已在本地缓存 → 直接本地组装,性能与原生无异

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
2
3
4
5
6
7
8
9
10
[proxy_plugins]
[proxy_plugins.stargz]
type = "snapshot"
address = "/run/containerd-stargz-grpc/containerd-stargz-grpc.sock"

[plugins."io.containerd.grpc.v1.cri".containerd]
# 让 CRI 优先使用该 snapshotter(懒加载全节点生效的关键开关)
snapshotter = "stargz"
# 允许把镜像引用等信息通过 labels 传给 snapshotter(remote 协议的命脉)
disable_snapshot_annotations = false

进程外架构的好处:

  • 升级解耦: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
2
3
4
5
6
7
8
9
10
11
             Prepare(可写)            Commit(name, key)
Committed ────────────────► Active(可写) ────────────────► 新 Committed
(只读、可作 parent) (有挂载点、key 临时) (原 key 消失、内容封存)
│ ▲
│ View(只读) │
└───────────────► Active(只读) ──── Remove ────► 父层无恙
(无 upperdir、不可 Commit) (View 永不 Commit)

剧本 A (pull): Stat 查询 → [Prepare → 解 tar → Commit] × N 层 → 链就绪
剧本 B (run): Prepare(容器ID, parent=顶层) → 挂载 → 运行 → Remove
懒加载实现: 同样的三拍,Prepare 返回 FUSE 挂载,数据按需到达

最后提炼四句话,作为整个 Snapshotter 知识体系的记忆锚点:

  1. Prepare 开启变更、View 只读观察、Commit 定格历史——三个操作构成的这个小状态机,就是 containerd 镜像分层存储与容器 rootfs 构建的完整心脏;
  2. Chain ID 编码层的历史——身份 = 内容 + 血缘,相同前缀的镜像物理共享快照;
  3. 契约只管 mount 有效,不管数据在不在本地——这个宽松性是懒加载的全部合法性来源;
  4. Labels 下行 + Prepare 探测上行——remote snapshotter 靠这两个通道,把"全量下载再启动"改写成"零下载即启动、读取时再取数"。

理解了 Snapshotter,前面那篇加速全景里那些"加速方案"就不再是孤立的工具,而是同一套接口契约下不同策略的实现——构建期瘦身是让"需要的数据"变少,Chain ID 复用是让"已有的数据"不再搬,而懒加载是让"将要的数据"晚点到。三者共享同一个底座,这个底座就是本文讲的这套接口。