containerd 快照(Snapshots)设计解析:从 graphdriver 到独立 Snapshotter 的分层文件系统模型
【免费下载链接】containerdAn open and reliable container runtime项目地址: https://gitcode.com/GitHub_Trending/co/containerd
导读
本文以 containerd 历史设计文档 docs/historical/design/snapshots.md 为主体,系统讲解 containerd 如何用一套独立的、与镜像/容器解耦的Snapshotter接口替代 Docker 时代深度耦合的graphdriver,实现"层"(layer)的分配、快照与挂载。文中会结合仓库中 Snapshotter 接口定义、Mount 对象实现、overlay 快照插件 与 元数据存储层 的源码,带你理解Prepare、View、Commit、Remove等核心操作的生命周期,以及导入镜像层、运行容器的完整调用链,最终具备独立阅读快照插件源码与二次开发 Snapshotter 的能力。
背景:从 Docker 的 graphdriver 到分层文件系统
Docker 容器自诞生之初就构建在一种名为层(layers)的快照方法论之上。层提供了一种能力:fork 一个文件系统、做出修改、再把变更集保存为新的层。历史上,这套机制以名为graphdriver的组件被深度集成在 Docker daemon 中,使得 Docker daemon 可以在多个操作系统上运行,同时保持提交和分发镜像变更时大体一致的快照语义。
但graphdriver与镜像的导入导出深度绑定,包括管理层之间的关系以及容器运行时文件系统,其行为甚至反向决定了镜像格式的传输方式。设计文档指出,这种紧密耦合带来两个问题:
- 驱动实现的表面积过大,导致各驱动之间行为不一致;
- 序列化、哈希、解包、打包、挂载等职责全部堆在一个组件里,难以演进。
基于此,containerd 提出一个更灵活的层管理模型:只提供基础快照功能的最小 API,不耦合镜像结构与标识;用更小的实现表面积,换取各驱动实现之间更一致的行为。
Snapshotter 与 graphdriver 的本质区别
| 维度 | graphdriver(Docker) | Snapshotter(containerd) |
|---|---|---|
| 对镜像/容器的认知 | 深度集成,管理层关系与容器运行文件系统 | 无任何认知,只面向"准备目录 + 提交目录" |
| 与 tar 变更集格式的关系 | 深度绑定,参与打包/解包 | 不介入 tar 格式,接口直接提供变更集访问 |
| 职责范围 | 序列化、哈希、解包、打包、挂载 | 仅提供面向挂载的快照访问与最小元数据 |
| 演进成本 | 重构困难 | 可由现有 graphdriver 重构而来,最小化新代码与测试 |
正如文档所述:"The best aspect is that we can get to this model by refactoring the existing graphdrivers, minimizing the need for new code and sprawling tests."(最好的地方在于,我们可以通过重构现有 graphdriver 得到这个模型,从而最小化新代码和不断膨胀的测试。)
范围(Scope):只做挂载导向的快照
设计文档明确划定了 Snapshotter 的边界:在 Docker 时代,graphdriver承担了序列化(serialization)、哈希(hashing)、解包(unpacking)、打包(packing)、挂载(mounting)等大量功能;而Snapshotter 只提供挂载导向(mount-oriented)的快照访问,仅携带最小元数据。
序列化、哈希、解包、打包和挂载都不在该设计中,取而代之的是各 graphdriver 之间的通用实现,而非专用实现。这对性能影响不大,因为接口提供了对变更集的直接访问。
这一边界在今天的仓库中依然清晰可见:
core/snapshots只管快照语义,而镜像的解包/打包逻辑分布在 core/images/archive、pkg/archive 等模块;快照插件只负责把"目录树"组织成可挂载的文件系统视图。
架构:以父子关系构建的快照图
Snapshotter 提供了一套 API,用于分配、快照和挂载抽象的、基于层的文件系统。其模型通过构建带父子关系的目录集合(即Snapshots)来工作。
一个 Snapshot 代表一个文件系统状态。每个快照都有一个父快照,空父快照用空字符串""表示。可以在父快照与它的快照之间做 diff,从而生成一个经典的"层"。
快照的生命周期
快照的生命周期最能说明其本质,Snapshotter 接口 的注释中给出了权威定义:
- 活动快照(Active):总是通过
Prepare或View从一个已提交快照(包括空快照)创建; - 已提交快照(Committed):总是通过
Commit从一个活动快照创建; - 活动快照永远不会变成已提交快照,反之亦然(
KindActive/KindView/KindCommitted在 Kind 枚举 中区分); - 所有快照都可以被移除。
挂载活动快照后,可以对其做出修改;提交(Commit)动作会创建一个已提交快照,该已提交快照继承活动快照的父快照,随后可作为新活动快照的父快照。活动快照永远不能作为父快照使用。
下面的示意图展示了快照之间的关系:
图中可以清楚看到:
- 活动快照a通过调用
Prepare(以已提交快照P₀为父)创建; - 修改后a变为a',再调用
Commit创建已提交快照P₁; - a'可继续修改为a'',再次调用
Commit创建第二个已提交快照P₂; - 注意:P₂的父是P₀,而不是P₁——因为
Commit只把活动快照"固化"下来,并不会改变其父指针。
操作(Operations):Prepare / View / Commit / Remove
快照的落地依赖于Mount对象以及由用户定义的、用于不透明数据存储的目录。创建新活动快照时,调用方提供一个名为key的标识符;该操作返回一组 mounts,挂载后即可在挂载路径上得到完全准备好的快照——这就是prepare 操作。
以下是四个核心操作的语义,与 Snapshotter 接口 的方法一一对应:
| 操作 | 接口方法 | 语义 |
|---|---|---|
| prepare | Prepare(ctx, key, parent, opts...) | 以key为标识创建活动快照(父为已提交快照或空串""),返回一组可挂载的 mounts;挂载后可写入新数据。相同 key 的多次Prepare/View应失败 |
| view | View(ctx, key, parent, opts...) | 与Prepare行为一致,但返回只读视图(mounts 可能带有只读标志),对底层文件系统的修改将被忽略;禁止对 view 的 key 调用Commit,资源回收必须调用Remove |
| commit | Commit(ctx, name, key, opts...) | 把key代表的活动快照固化为name标识的已提交快照,之后name可作为新活动快照的父;提交后key被移除 |
| remove | Remove(ctx, key) | 释放快照关联的所有资源;调用前应先卸载prepare/view返回的 mounts;删除已提交快照时,必须先删除其所有子快照 |
只读视图与覆盖层的实现细节
View的只读语义在 core/mount/mount.go 中有具体实现:readonlyMounts对 overlay 类型的 mount,会剥离upperdir=与workdir=选项并把 upperdir 追加到 lowerdir 末尾,从而把可写 overlay 转换为只读视图;对其他类型则统一追加ro选项。Mount.ReadOnly()(core/mount/mount.go#L96-L125)还会根据 mount 类型推导只读性,例如 erofs 天生只读、overlay 无upperdir=即只读。
接口中的辅助方法与元数据
除四个核心操作外,Snapshotter 接口 还定义了:
Stat:按 key 查询快照的Info,用于父解析、存在性检查与类型判别;Update:更新快照的可变属性(如 labels);Usage:返回快照自身(不含父)的磁盘占用与 inode 数(Usage{Inodes, Size});Mounts:在调用View/Prepare之后恢复 mounts;Walk:带过滤器遍历快照图(支持name、parent、kind、labels.(label)过滤);Close:释放内部资源。
Info结构(core/snapshots/snapshotter.go#L127-L141)包含Kind、Name、Parent、Labels、Created、Updated等字段。值得注意的是,只有以containerd.io/snapshot/为前缀的标签才会被Prepare、View、Commit继承,例如containerd.io/snapshot.ref(远程快照协议中的目标 chainID)、containerd.io/snapshot/diff-id、containerd.io/snapshot/uidmapping、containerd.io/snapshot/gidmapping以及containerd.io/snapshot/max-size(块设备/配额上限提示)。
图元数据(Graph metadata):支持对快照图的查询
随着快照被导入容器系统,会形成一个由快照及其父关系构成的"图"。对该图的查询必须是受支持的操作。这正是Walk、Stat、Storage.Remove中"删除前检查子节点"等能力的来源。
在元数据存储层 core/snapshots/storage/bolt.go 中,快照图以 BoltDB bucket 实现:每个快照是一个以 key 命名的 bucket,父子关系通过 parent index 维护。以 Remove 的实现 为例,它先通过 parent prefix 检查"cannot remove snapshot with child"(存在子快照时返回ErrFailedPrecondition),再删除父链接与自身 bucket——这正是文档中"删除已提交快照前必须先删除其子"的底层证据。而 CommitActive 会把活动快照 bucket 重命名为已提交快照 name,并处理重名冲突(返回ErrAlreadyExists)。
快照如何工作:以导入镜像层为例
为了把术语落到实处,设计文档从"导入层"的视角演示了 Snapshotter 的用法。下面我们用 Go API 复现整个过程,并对照接口签名补充现代实现(ctx参数与 GC 保护标签)。
导入一个层(Importing a Layer)
导入层时,只需让 Snapshotter 提供一组 mounts,使目标位置能够捕获变更集。首先取得层 tar 文件的路径,并创建临时解包目录:
layerPath, tmpDir := getLayerPath(), mkTmpDir() // just a path to layer tar file.然后使用 SnapshotterPrepare一个新的快照事务,使用key并从空父""下降。为了防止解包期间快照被垃圾回收,可以附加containerd.io/gc.root标签(对应 snapshots.WithLabels 选项):
noGcOpt := snapshots.WithLabels(map[string]string{ "containerd.io/gc.root": time.Now().UTC().Format(time.RFC3339), }) mounts, err := snapshotter.Prepare(ctx, key, "", noGcOpt) if err != nil { ... }从Snapshotter.Prepare得到一组 mounts,key标识活动快照。将其挂载到临时位置:
if err := mount.All(mounts, tmpDir); err != nil { ... }mount.All(core/mount/mount.go#L55-L64)会按顺序逐个挂载所有 mounts(子挂载须排在父挂载之后)。挂载完成后,临时位置就绪,可以捕获 diff——实践中这类似于一次文件系统事务。下一步解包层,unpackLayer会把层内容应用到目标位置,并计算解包后层的DiffID(这是 Docker 实现的要求):
layer, err := os.Open(layerPath) if err != nil { ... } digest, err := unpackLayer(tmpLocation, layer) // unpack into layer location if err != nil { ... }完成后我们得到代表该层内容的文件系统。严谨的实现应校验 digest 与预期的DiffID一致。随后卸载 mounts:
unmount(mounts) // optional, for now验证并解包层之后,把活动快照提交为name。示例中直接使用层 digest 作为名称,实践中更常用ChainID:
if err := snapshotter.Commit(ctx, digest.String(), key, noGcOpt); err != nil { ... }现在该层已存在于 Snapshotter 中,可通过提交时提供的 digest 访问。提交完成后,活动快照可以移除:
snapshotter.Remove(key)导入下一层(Importing the Next Layer)
让新层依赖已有层,过程与上面完全一致,唯一区别是调用Snapshotter.Prepare时把父指定为上一层的标识,并假设使用干净的临时位置:
mounts, err := snapshotter.Prepare(ctx, key, parentDigest, noGcOpt)然后像上面一样挂载、应用、提交。新快照将基于前一层的内容,形成层的链条。
运行容器(Running a Container)
运行容器时,只需把已提交的镜像快照作为父传给Snapshotter.Prepare。挂载后,准备好的路径可直接作为容器的文件系统:
mounts, err := snapshotter.Prepare(ctx, containerKey, imageRootFSChainID)返回的 mounts 可直接传给容器运行时。若想从该文件系统创建新镜像,则调用Snapshotter.Commit:
if err := snapshotter.Commit(ctx, newImageSnapshot, containerKey); err != nil { ... }大多数容器运行场景下,则调用Snapshotter.Remove通知 Snapshotter 放弃这些变更:
snapshotter.Remove(containerKey)从源码看 unpack 流程的印证
上述"导入层"流程在仓库中有多处直接实现:镜像解包器 core/unpack/unpacker.go 使用UnpackKeyPrefix = "extract"与UnpackKeyFormat = "extract-%s %s"(snapshotter.go 常量)构造提取专用 key,并借助containerd.io/snapshot.ref标签实现远程快照协议的跳过逻辑;overlay 快照插件的Prepare/Commit实现(plugins/snapshots/overlay/overlay.go#L268-L318)则在写事务中完成目录创建(prepareDirectory)、元数据写入(storage.CreateSnapshot)、Commit时以fs.DiskUsage统计 upperdir 占用并调用storage.CommitActive。
从设计到实现:overlay 快照插件如何落地
设计文档提出的模型在 plugins/snapshots 下落地为一系列实现:overlay、native、btrfs、devmapper、blockfile、erofs、windows、lcow。其中 overlay 是最典型的代表,值得对照源码理解设计如何变为现实。
overlay 快照的数据布局
以 plugins/snapshots/overlay/overlay.go 为例,NewSnapshotter(第 121-176 行)会在 root 下创建snapshots/目录存放各快照的 diff 数据,并创建metadata.db(BoltDB)保存快照元数据。它还做了若干防御性检查:
- 校验底层文件系统支持
d_type(如 xfs 需ftype=1格式化,否则报错); - 自动探测并追加
userxattr选项(解决老内核 overlay 用户 xattr 问题); - 在内核支持
index时追加index=off,且遵循"后写生效"规则避免覆盖用户显式配置的index=on。
关键操作的实现映射
| 设计操作 | overlay 实现 | 说明 |
|---|---|---|
| Prepare | createSnapshot(KindActive, ...)(第 268-270 行) | 写事务中创建快照目录 + 写入元数据,返回 overlay/bind mounts |
| View | createSnapshot(KindView, ...)(第 272-274 行) | 同上,但返回只读 mounts |
| Commit | 事务中storage.CommitActive(第 300-318 行) | 先统计 upperdir 磁盘占用,再把活动快照重命名为已提交名称 |
| Remove | 事务中storage.Remove+ 目录清理(第 323-351 行) | 支持asyncRemove选项:延迟到Cleanup时异步回收磁盘,key 可立即复用 |
此外,overlay 插件还实现了Cleaner接口(Cleanup),用于清理已移除或废弃快照遗留的磁盘目录(第 373-387 行),对应接口层定义的 Cleaner。通过WithUpperdirLabel选项,Stat/Walk还会把 upperdir 位置以containerd.io/snapshot/overlay.upperdir标签暴露出来。
可配置选项一览
overlay 插件通过选项函数(Opt)暴露配置能力,overlay.go 第 56-106 行:
AsynchronousRemove:异步删除,key 即时可复用,磁盘延迟回收;WithUpperdirLabel:为快照附加 upperdir 位置标签;WithMountOptions(options):定义 overlay mount 的默认挂载选项(不作用于 bind mount);WithMetaStore(ms):外部注入元数据存储;WithRemapIDs/WithSlowChown:用于用户命名空间 ID 映射与慢速 chown 场景。
设计价值与后续阅读
总结这份历史设计文档的核心观点:把"快照"从"镜像/容器"中彻底剥离,用最小、挂载导向的 API 表达层的本质。这一设计至今仍是 containerd 的基石——core/snapshots定义语义,各插件提供实现,core/mount提供通用挂载能力,core/snapshots/storage用 BoltDB 维护快照图元数据。
若想继续深入,同一目录下的姊妹文档可对照阅读:mounts.md(Mount 对象的细节)、lifecycle.md(快照/镜像生命周期)、data-flow.md(数据流)与 architecture.md(整体架构)。接口的权威 Go 文档即 core/snapshots/snapshotter.go 中的Snapshotter接口注释,其中保留了与本文完全一致的层导入、容器运行示例代码。对实现细节感兴趣的读者,可以从 plugins/snapshots/overlay/overlay.go 与 core/snapshots/storage/bolt.go 开始逐行研读。
【免费下载链接】containerdAn open and reliable container runtime项目地址: https://gitcode.com/GitHub_Trending/co/containerd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考