☰
containerd native snapshotter 深度解析:从镜像拉取卡顿到快照生命周期管理
2026/10/26 20:19:10 网站建设 项目流程

1. 从一次镜像拉取卡顿说起:为什么要啃 snapshotter 这块硬骨头

第一次被 snapshotter 绊住,是好几年前在一个离线环境的交付现场。镜像拉取到一半卡死,ctr images pull挂在那里一动不动,日志里翻来覆去就是几行关于 content store 和 snapshot 的记录。当时我对 containerd 的理解还停留在“docker 底下那个跑容器的”这个层面,snapshotter 这个词对我来说基本是个黑盒。后来花了整整一个通宵才搞明白,问题出在默认 snapshotter 的存储驱动和底层文件系统不匹配上——镜像层解压写盘的时候,overlayfs 的 upper 层落在了不支持某些特性的挂载点上,导致每一步 unpack 都在重试。

那次之后我就养成了一个习惯:凡是碰 containerd,先把 snapshotter 这条链路摸清楚。因为它是镜像和容器之间那道最容易被忽视、却最容易出事的桥。你拉下来的镜像层是只读的 tar 包,容器跑起来需要一个可写的根文件系统,这中间的转换、叠加、复用、清理,全靠 snapshotter 在管。它管得好,你几乎感觉不到它的存在;它管得不好,轻则磁盘暴涨,重则容器起不来、数据写丢。

这篇东西我打算从最基础的nativesnapshotter 讲起。为什么是 native?因为它足够简单,简单到你可以把它当成理解整个 snapshotter 机制的“教学样本”。overlayfs 那些多层叠加、lowerdir/upperdir 的花活固然强大,但如果你连 native 这种“一层一个目录、老老实实全量拷贝”的模型都没吃透,直接去看 overlay 的实现,很容易被各种 diff、mount 参数绕晕。native 就像是手动挡教练车,离合器、变速箱都摆在明面上,学会了再开自动挡就顺了。

这篇文章适合谁看?如果你已经在用 containerd 跑容器,但从来没关心过/var/lib/containerd底下那些目录是干嘛的;或者你正在排查磁盘占用、镜像层复用、容器启动慢这类问题;又或者你打算自己写一个 snapshotter 插件——那这篇从 native 切入的拆解应该能帮你把地基打牢。我会把 snapshotter 的接口模型、native 的目录结构、一次完整的 prepare/commit/mount 流程、以及我踩过的那些坑,尽量掰开揉碎讲清楚。涉及具体操作的地方,我会给出可以直接照着敲的命令和配置,但重点永远放在“为什么这么设计”上,因为命令会忘,思路不会。

2. snapshotter 到底在解决什么问题:先把模型想明白

2.1 镜像层、快照、可写层三者的关系

要理解 snapshotter,先得把三个概念分清楚:镜像层(image layer)、快照(snapshot)、可写层(active snapshot)。很多人把这三个混为一谈,结果一看到Snapshots目录里一堆 SHA256 命名的文件夹就懵了。

镜像层是只读的,来自 registry 拉下来的 tar 包,解压后落在 content store 里,每一层用 digest 标识。但容器不能直接往只读层上写东西,所以需要一个可写的空间。snapshotter 的职责,就是把这些只读层“挂”成一个看起来完整的根文件系统,再在上面叠一个可写层。这个“挂”的动作,不同 snapshotter 的实现天差地别:overlayfs 用联合挂载把多层叠起来,几乎不占额外空间;native 则是老老实实把父层的内容全量复制一份,再让你在副本上写。

这里有个关键概念叫parent。每个快照都可以有一个父快照,形成一条链。链的顶端是可写的 active 快照,往下是只读的 committed 快照,最底下是空。当你基于某个镜像启动容器时,snapshotter 会沿着这条链把各层准备好,最终 mount 出一个合并视图。native 的做法是:commit 的时候把当前可写层的内容和父层合并成一个新的只读目录,下次再基于它创建快照时,直接从这个合并后的目录拷贝。所以 native 的链其实在 commit 之后就被“压平”了,父层内容已经物理合并进子层,不像 overlay 那样保留多层引用。

这个差异直接决定了两者的适用场景。overlay 省空间、启动快,但依赖内核的 overlayfs 支持,且层数多了之后性能会下降(lowerdir 有数量上限)。native 费空间、每次都要全量拷贝,但实现简单、不依赖特殊文件系统、行为可预测。你在一个资源紧张但要求稳定的边缘设备上,native 反而是更稳妥的选择。

2.2 snapshotter 接口的核心方法拆解

containerd 把 snapshotter 抽象成了一套 gRPC 接口,核心方法就那么几个,但每个都有讲究。我按调用顺序捋一遍,顺便说说每个方法背后的意图。

Prepare(key, parent)是最常用的。它的意思是“给我准备一个可写的快照,key 是它的名字,parent 是它的父快照”。返回的是一个Mount数组,告诉调用方怎么把这个快照挂到某个目录上。注意这里返回的是“怎么挂”,而不是“已经挂好了”——挂载动作由调用方(通常是 containerd 的 shim 或 runtime)自己执行。这个设计很关键,它把“准备文件系统”和“执行挂载”解耦了,snapshotter 不需要有 mount 权限,只需要告诉别人怎么 mount。

Commit(name, key)把某个 active 快照转成 committed 只读快照。对 native 来说,这一步就是一次实打实的目录合并拷贝,耗时和层大小成正比。Mounts(key)返回某个快照的挂载信息,用于已经存在的快照。Remove(key)删快照,但要注意它只删自己那一层,父层还在。Stat(key)查元信息,Walk()遍历所有快照,Usage(key)统计磁盘占用——这个在排查磁盘暴涨时特别好用,后面会细说。

还有一个容易被忽略的Update(key, info, fieldpaths),用来更新快照的元数据,比如给快照打标签。很多人在做镜像预热或者快照复用的时候会用到它,但文档里讲得不多,实际用起来要小心 fieldpaths 的语义,传错了可能把不想改的字段覆盖掉。

提示:snapshotter 接口里所有 key 都是字符串,但 containerd 内部对 key 的命名有约定,通常带sha256:前缀或者extract-之类的语义前缀。自己写工具直接调接口时,别随便造 key,否则和 containerd 自己管理的快照容易冲突。

2.3 为什么 native 是理解全局的最佳入口

我见过不少人一上来就研究 overlayfs snapshotter 的源码,结果被lowerdir、upperdir、workdir三件套和一堆 mount option 劝退。不是说 overlay 难,而是它的复杂度有很大一部分来自“优化”,而不是“本质”。你把优化层剥掉,剩下的模型和 native 是一模一样的:都是 prepare 出一个可写空间,commit 成只读,mount 出视图。

native 的好处在于,它的每一步都能在文件系统上直接看到结果。你 prepare 一个快照,ls一下目录就知道它干了什么;你 commit 一下,du -sh就能看到空间变化。这种“所见即所得”对建立直觉太重要了。等你把 native 的 prepare/commit/mount 流程在脑子里跑通了,再去看 overlay 的 mount option,就会明白那些参数不过是在“怎么少拷贝点数据”上做文章,核心状态机没变。

而且 native 在生产里并非没有用武之地。一些不支持 overlayfs 的老内核、某些特殊的只读文件系统场景、以及需要严格隔离每一层内容的合规环境,native 都是兜底选项。把它吃透,等于给自己留了一条后路。

3. native snapshotter 的目录结构与状态机

3.1 一个快照在磁盘上长什么样

native snapshotter 的根目录默认在/var/lib/containerd/io.containerd.snapshotter.v1.native。进去之后你会看到两个平级的目录:snapshots和metadata.db(老版本可能是metadata.db加一些 boltdb 文件)。snapshots底下是按数字 ID 命名的目录,每个目录代表一个快照,里面通常只有一个fs子目录,真正的文件系统内容就在fs里。

为什么用数字 ID 而不是 key?因为 key 是逻辑名,可能很长、可能带特殊字符,而数字 ID 是 snapshotter 内部自增分配的,用来做目录名最省事。key 和 ID 的映射关系存在metadata.db里,这是一个 boltdb 数据库。所以你ls snapshots看到的一堆1、2、3,得去数据库里查才知道哪个对应哪个镜像层。

每个快照目录里除了fs,还可能有一个work目录(overlay 用得多,native 一般不需要),以及一些状态标记。native 的快照状态有三种:active、committed、view。active 是可写的,committed 是只读的,view 是一种特殊的只读视图,通常用于挂载已 commit 的快照而不产生新的可写层。状态信息同样存在 metadata.db 里,不在目录结构上体现,这点和有些人想象的“目录名带状态后缀”不一样。

我建议你在一个测试环境里手动跑一遍,把每一步的目录变化都ls出来看。这种笨办法比看十页文档都管用。下面这张表是我整理的 native 快照目录里常见内容的含义,方便你对照:

路径/文件含义备注
snapshots/<id>/fs快照的实际文件系统内容native 下就是普通目录
snapshots/<id>/work工作目录native 基本不用,overlay 必需
metadata.dbboltdb 元数据库存 key、parent、状态、ID 映射
metadata.db.lock数据库锁文件排查“数据库被占用”时看它

3.2 prepare 到 commit 的状态流转

一次典型的容器启动,snapshotter 侧的状态流转是这样的:containerd 先对镜像的每一层调用Prepare,key 通常是extract-<digest>之类,parent 是上一层。这些 prepare 出来的快照在解压完成后会被Commit成只读。等所有层都 commit 完,containerd 再Prepare一个最终的 active 快照,parent 指向最顶层的 committed 快照,这个就是容器的可写层。容器删除时,这个 active 快照被Remove,底下的 committed 层如果没被别的容器引用,也会被逐步清理。

native 在这个流程里有个很“重”的动作:每次Commit都要把 active 快照的fs和父快照的fs合并。合并方式是遍历父层所有文件,拷贝到子层,遇到同名文件以子层为准。这意味着如果你有一个 500MB 的基础镜像,再叠一个 200MB 的应用层,commit 应用层的时候要把 500MB 基础层整个拷一遍,最终这个 committed 快照占 700MB。层数越多、层越大,这个放大效应越明显。这也是为什么 native 在生产里通常只用于层数少、镜像小的场景。

Prepare本身在 native 下反而很轻:它只是创建一个空目录,记一条元数据,返回一个 bind mount 的 Mount 信息。真正重的是Commit。这个“轻 prepare、重 commit”的特点,和 overlay 的“prepare 时算 lowerdir、commit 时几乎不拷贝”正好相反。理解了这个不对称,你就能预判 native 在什么环节会慢。

3.3 元数据库里到底存了什么

metadata.db是 boltdb,你可以用boltbrowser之类的工具打开看(生产环境别直接开,先停服务或者拷一份出来)。里面主要是一个snapshotsbucket,key 是数字 ID 的字节表示,value 是一个结构体,包含 name(逻辑 key)、parent(父 ID)、kind(active/committed/view)、created、updated 时间戳,以及一些 labels。

labels 这块值得单独说。containerd 在 prepare 快照时会带上一堆 label,比如镜像层的 digest、是否可远程、解压状态等。这些 label 是 snapshotter 和上层沟通的“暗号”,比如 containerd 的垃圾回收(GC)就是靠 label 判断某个快照还被不被引用。你自己写工具操作快照时,如果漏了必要的 label,可能导致 GC 误删或者镜像层无法复用。我踩过一次坑:手动 commit 了一个快照但没打上正确的 label,结果 containerd 的 GC 认为它是孤儿,过一会儿就给清了,容器直接起不来。所以除非你完全清楚后果,否则别绕过 containerd 直接改数据库。

注意:metadata.db是 native snapshotter 的唯一真相来源,目录结构只是它的投影。备份的时候两个都要备,只备snapshots目录不备数据库,恢复出来是一堆没有名字的孤儿目录,基本没法用。

4. 手把手跑一遍 native 的完整生命周期

4.1 环境准备与 snapshotter 选择

先确认你的 containerd 版本和默认 snapshotter。containerd config default输出里找[plugins."io.containerd.grpc.v1.cri".containerd]下面的snapshotter字段,默认通常是overlayfs。要切到 native,把它改成native,然后重启 containerd。改之前想清楚:切换 snapshotter 不会自动迁移已有镜像和容器,旧数据还在旧 snapshotter 的目录里,新 snapshotter 从零开始。所以这操作最好在干净的测试机上做。

[plugins."io.containerd.grpc.v1.cri".containerd] snapshotter = "native"

重启后ls /var/lib/containerd/io.containerd.snapshotter.v1.native/snapshots,应该是空的或者只有系统自建的少量条目。这时候拉一个体积小、层数少的镜像来练手最合适,比如一个几十 MB 的 alpine 类基础镜像。别一上来就拉几个 GB 的大镜像,native 的全量拷贝会让你等到怀疑人生。

4.2 用 ctr 观察 prepare 与 commit 的每一步

ctr是 containerd 自带的命令行工具,观察 snapshotter 行为比 docker 直观得多。拉镜像的时候加--snapshotter native显式指定,避免用到默认的。拉完之后ctr -n <namespace> snapshots ls能看到一串快照,注意看PARENT列和KIND列,committed 的层和 active 的层一目了然。

ctr -n default images pull --snapshotter native <镜像地址> ctr -n default snapshots ls

你会看到类似这样的输出:最底层 parent 为空,往上每层 parent 指向前一层,最顶上是一个 active 快照(如果你还没起容器,可能只有 committed 层)。这时候去snapshots目录里du -sh每个 ID 目录,能直观看到 native 的“空间放大”——第二层的目录大小约等于第一层加第二层的内容,第三层又约等于前两层加第三层。这个观察比任何文字描述都有说服力。

起一个容器再删掉,观察 active 快照的创建和销毁。ctr run的时候加--snapshotter native,容器跑起来后snapshots ls会多一个 active 条目,Remove容器后它消失。如果你在容器里写了大文件再删容器,注意看磁盘空间是不是立刻回收了——native 下通常是立刻的,因为 active 快照就是一个普通目录,删了就删了。这点比 overlay 省心,overlay 有时候会有 workdir 残留需要手动清。

4.3 手动构造一个快照链做实验

想真正吃透,光看不够,得自己造。我常用的实验方法是:先 prepare 一个空快照 A,往它的fs里写几个文件,commit 成只读;再基于 A prepare 一个 B,往 B 的fs里写同名和不同名文件,commit;然后对比 A 和 B 的目录内容,看 native 的合并规则到底怎么处理同名覆盖。

这个实验不用写代码,直接用ctr的snapshots prepare和snapshots commit子命令就能做(不同版本子命令名可能略有差异,用ctr snapshots --help确认)。做完之后你会亲眼看到:B commit 之后,A 里的文件被物理拷贝进了 B 的目录,B 里同名文件覆盖了 A 的版本,不同名文件两者都在。这就是 native “压平”链的本质。理解了这一步,你再看容器里看到的文件系统为什么是那个样子,就毫无障碍了。

实验完记得清理,ctr snapshots rm按依赖顺序从顶往下删,先删 B 再删 A,反过来会报“还有子快照引用”的错误。这个错误信息很直白,但新手容易慌,以为数据库坏了,其实只是删除顺序问题。

5. 那些文档里不会写的坑与排查手法

5.1 磁盘暴涨的三种典型成因

native 最让人头疼的就是磁盘。我整理过几次线上磁盘告警的根因,基本逃不出这三类。第一类是层数多且层大,全量拷贝的放大效应叠加,一个原本 1GB 的镜像在 native 下可能占 3GB 以上。第二类是active 快照泄漏,容器异常退出、shim 崩溃、或者上层编排系统没正确调 Remove,导致 active 快照一直挂着,它占的空间不会自动回收。第三类是committed 层没被 GC,镜像删了但快照还在,因为还有别的引用或者 label 不对。

排查顺序我一般是:先ctr snapshots ls看有没有大量 active 状态的条目,有的话基本就是泄漏;再看du -sh排前几名的快照 ID,对照metadata.db里的 name 判断是哪个镜像的哪一层;最后查 GC 日志,看有没有“skipping snapshot”之类的记录。ctr snapshots usage在某些版本里能直接给出每个快照的磁盘占用,比手动du快,但要注意它统计的是逻辑大小还是实际占用,不同版本行为有差异,别完全信。

现象可能原因排查动作
磁盘持续增长不回落active 快照泄漏snapshots ls过滤 active,核对容器列表
单镜像占用远超预期层多且大,全量拷贝放大du -sh各快照,对比镜像层大小
删镜像后空间不释放committed 层仍被引用或 label 异常查 GC 日志,检查快照 labels
commit 极慢甚至超时父层过大,拷贝耗时评估是否该换 overlayfs

5.2 commit 卡住与数据库锁冲突

有一次在批量导入镜像的场景里,多个进程同时触发 commit,结果 containerd 日志里出现大量 boltdb 的锁等待,commit 一个接一个排队,整体吞吐掉得厉害。原因是 native 的 commit 是重操作,而 boltdb 的写事务是串行的,commit 期间持有写锁的时间又长,其他快照操作全被堵住。这不是 bug,是 native 模型下的固有瓶颈。

缓解办法有几个:批量导入时控制并发度,别让几十个镜像同时解压 commit;把大镜像的导入错峰;如果业务允许,这类场景直接换 overlayfs,commit 几乎不拷贝,锁持有时间短得多。还有一个偏方是调大 boltdb 的初始 mmap 大小,减少扩容时的停顿,但这属于治标,根子还是在 native 的重 commit 上。

数据库锁冲突的另一个常见诱因是外部工具直接开库。我见过有人用 boltbrowser 在生产机上直接打开metadata.db看数据,结果工具持有了文件锁,containerd 后续所有快照操作全部失败,报“database is locked”或者“timeout”。记住前面说的:要看库,先停服务或者拷副本,别在活库上动手。

5.3 快照 key 冲突与命名规范

自己写脚本调 snapshotter 接口时,最容易犯的错是 key 冲突。containerd 内部对 key 有一套命名约定,比如解压镜像层用extract-<chainID>,容器可写层用<containerID>之类。你如果随手用一个test当 key,第一次没事,第二次 prepare 同 key 就会报“already exists”。更隐蔽的是,你的 key 恰好和 containerd 内部的某个 key 撞了,导致它以为某个层已经准备好,跳过了必要的步骤,最后容器起来缺文件。

我的做法是给自研工具的 key 统一加前缀,比如mytool-<uuid>,并且 prepare 之前先Stat一下确认不存在。commit 之后立刻打上自己的 label,方便后续识别和清理。这套习惯是从一次“key 撞车导致镜像层被误判为已解压”的事故里学来的,那次排查了大半天,最后发现就是命名太随意。

提示:native 的 key 一旦 commit 就基本固定了,改 key 等于新建快照。所以设计 key 命名规则的时候多花五分钟,比事后迁移省五小时。

5.4 从 native 迁移到 overlay 的注意事项

业务长大了,native 的磁盘和性能扛不住,想换 overlay,这时候有几个坑要提前知道。第一,不能原地切换,得重新拉镜像、重建容器,因为两种 snapshotter 的目录格式和元数据完全不兼容。第二,overlay 对底层文件系统有要求,upper 层所在的分区必须支持d_type,某些网络文件系统或者特殊挂载参数下会不满足,切换前用xfs_info或mount确认一下。第三,overlay 的层数有上限(受内核参数影响),镜像层特别多的场景要评估。

迁移节奏上,我建议先在新节点上用 overlay 跑一段时间,确认稳定再逐步替换老节点,别一刀切。老节点上的 native 数据在确认没有容器引用后,整个io.containerd.snapshotter.v1.native目录可以清掉释放空间,但清之前一定确认 GC 已经跑过、没有残留引用,否则可能出现“镜像列表里还有、实际层没了”的尴尬状态。

6. 把 native 当成尺子:理解 snapshotter 设计的取舍

6.1 简单性与效率的永恒拉扯

native 这个设计,本质上是在用空间和 commit 时间换实现的简单和行为的可预测。它没有花哨的联合挂载,没有内核特性依赖,每一层都是实实在在的目录,出问题时你ls和du就能定位大半。这种“笨”在特定场景下是优点:边缘设备内核老、文件系统特殊、运维力量薄弱,native 的确定性比 overlay 的性能更值钱。

但它的代价也很清楚:空间放大、commit 慢、层数一多就难受。containerd 把 snapshotter 做成可插拔接口,就是为了让不同场景选不同实现。你理解了 native 的取舍,再看 overlayfs、btrfs、zfs、devmapper 这些 snapshotter,就能快速判断它们各自在“简单-效率”这条轴上的位置。比如 devmapper 用块设备做快照,空间效率高但配置复杂;btrfs 依赖文件系统特性,快照几乎零成本但跨平台性差。没有银弹,只有匹配。

6.2 自己写 snapshotter 时该抄 native 的哪些作业

如果你打算实现一个自定义 snapshotter,native 的代码是最值得读的参考实现之一。它的状态机清晰、错误处理直白、没有太多历史包袱。我建议重点看三块:Prepare里怎么处理 parent 不存在和 key 冲突;Commit里怎么保证合并过程的原子性(native 是先写临时目录再 rename,避免半成品被看到);Remove里怎么处理“还有子快照”的依赖检查。

原子性这块特别值得抄。native 的 commit 不是直接往目标目录写,而是先在一个临时位置完成合并,最后一步原子 rename。这样即使中途崩溃,也不会留下一个“看起来 commit 了实际不完整”的快照。很多自研 snapshotter 栽在这上面,commit 到一半进程挂了,元数据说 committed,目录里却缺文件,容器起来各种诡异报错。这个模式不复杂,但很容易被忽略。

6.3 一个真实场景的选型复盘

最后分享一个我参与过的选型复盘。某离线交付场景,设备是低配工控机,内核版本偏老,不支持 overlayfs 的某些特性,镜像层数控制在三层以内、单层不超过 200MB。最初团队想硬上 overlay,折腾了两周各种 mount 失败,最后退回 native,问题立刻消失。代价是磁盘占用比预期高了约 2.5 倍,但设备本身预留了足够空间,commit 慢的问题通过错峰导入缓解了。

这个案例说明,选型不能只看“哪个更先进”,要看约束条件。native 在这个场景里不是妥协,是正解。反过来,如果是一个高密度、频繁拉取大镜像的云环境,native 的全量拷贝会成为灾难,这时候哪怕 overlay 配置麻烦点也值得。判断标准其实就三条:底层文件系统支持什么、镜像的层数和体积分布、以及你对 commit 延迟的容忍度。把这三条量化出来,选型基本不会错。

我在实际使用中的体会是,snapshotter 这块知识最大的价值不在于记住某个命令,而在于建立一套“从目录和元数据反推状态”的直觉。下次再遇到镜像拉取卡住、容器起不来、磁盘莫名增长,你能第一时间想到去snapshots目录和metadata.db里找线索,而不是对着日志干瞪眼。native 因为足够透明,是训练这套直觉最好的靶子。等你把它的 prepare/commit/mount 在脑子里跑得跟呼吸一样自然,再去碰 overlay 那些优化细节,会发现不过是同一套骨架上的不同肌肉而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询