Skip to content
Published on

containerd 2.3 的 EROFS 原生层 — 去掉解包,换来了什么

分享
Authors

引言 — 镜像拉取慢,其实是两个问题

说容器镜像拉取慢的时候,其实有两个不同的问题被揉进了同一句话里。

一个是下载时间,也就是从镜像仓库拉取几个 GB 数据的网络开销。另一个是解包时间,也就是流式解压 tar+gzip、把数万个文件逐一写进主机文件系统的开销。后者不是网络问题,而是 CPU 和文件系统元数据日志的问题,就算铺上 100Gbps 的链路,它也纹丝不动。

过去这几年,这个领域的话题大多围绕第一个问题。eStargz、SOCI、Nydus 这类懒加载(lazy pull)方案说的是"别全部拉下来,只拉需要的那部分";而 AWS 的 SOCI 干脆换了个方向,加了一个"干脆并行快速拉完"的模式。这条脉络在容器运行时比较一文中讨论过。

2026年4月30日发布的 containerd 2.3,正面瞄准的是第二个问题。思路很简单 — 与其解开 tar 再构建文件系统,不如一开始就把文件系统本身当作层来分发。 本文记录的是,这个想法实际走到了哪一步 — 用代码和镜像仓库的 manifest 逐一核实。

containerd 2.3 里实际合并了什么

先把日期和 PR 钉死。EROFS 本身并不是在 2.3 才首次出现的。

时间点内容
containerd 2.1.0 (2025-05-07)首次搭载 EROFS snapshotter 与 differ (#10705,2025-02-05 合并)
containerd 2.2.0 (2025-11-06)基于 mount manager 的改进、tar index 模式、并行解包
containerd 2.3.0 (2026-04-30)原生 EROFS 层 — 从镜像仓库直接接收 EROFS blob

进入 2.3 的部分是这些。

  • #12567(2026-01-06 合并) — 新增 media type application/vnd.erofs.layer.v1。照 PR 描述的原话,这是为了"让 containerd 能直接拉取 EROFS 原生容器镜像"。
  • #13091(2026-04-01 合并) — 当 os.features 中包含 erofs 时,让 transfer service 选择 EROFS snapshotter/differ。
  • #12502(2026-04-01 合并) — 为 EROFS snapshotter 添加 dm-verity 支持。
  • #13185(2026-04-12 合并) — application/vnd.erofs.layer.v1+zstd,用 zstd 包裹的 EROFS 层。

顺带一提,2.3.0 也是 containerd 发布策略调整后的第一个版本。按发布说明的措辞,节奏改成了配合 Kubernetes 时间表的约 4 个月一个周期,2.3 是这套体系下的第一个年度 LTS。endoflife.date 独立给出了相同的数字 — 2.3 发布于 2026-04-30,EOL 为 2028-04-30,标记为 LTS。补充一句,同一张表里 containerd 2.1 已经在 2026-07-03 到期。

differ 代码说明的事 — 解包消失在哪里

EROFS 的收益,与其看基准测试图表,不如看 differ 代码 里的几行来得清楚。containerd 2.3.0 的 plugins/diff/erofs/differ.go 分三条路径。

1. 收到普通的 OCI tar 层时 — 会拉起 mkfs.erofs,把 tar 实时转换成 EROFS。internal/erofsutils/mount.go 里的 ConvertTarErofs 做的正是这件事。

args := append([]string{"--tar=f", "--aufs", "--quiet", "-Enoinline_data"}, mkfsExtraOpts...)
cmd := exec.CommandContext(ctx, "mkfs.erofs", args...)
cmd.Stdin = r

也就是把 tar 流直接送进 mkfs.erofs 的标准输入。不是把文件一个个写进主机文件系统,而是一次性刻出一个 EROFS blob,所以元数据日志的流量比 overlayfs snapshotter 逐文件抽取要少,GC 时也不用删除数万个文件。这一点从 2.1 起就是这样。

2. 收到无压缩的原生 EROFS 层时 — 这是 2.3 的新路径,代码结尾是这样的。

layerBlobPath := path.Join(layer, "layer.erofs")
// Allow copy file range when there is an uncompressed native EROFS layer
if fastcopy {
    f, err := os.Create(layerBlobPath)
    ...
    _, err = io.Copy(f, content.NewReader(ra))
    f.Close()
    ...
    return desc, nil
}

这里是关键。没有 mkfs.erofs,没有 tar 解析器,也没有逐文件生成。只是把 content store 里的 blob 拷贝成 layer.erofs,然后就结束了。解包这一步并不是被消除了,而是被压缩成了一次 blob 拷贝。 之后 snapshotter 会把 layer.erofs 挂载到 fs/ 下,再和父层一起返回一个 overlayfs 挂载点。

3. 收到 +zstd 层时 — 留到后面单独讲。

直接到镜像仓库里核实一下

只读文档不够有意思,所以我实际戳了戳一个镜像仓库。EROFS 维护者上传了一些相关标签,放在 docker.io/hsiangkao/ubuntu 这个测试仓库里。以下是 2026-07-16 从 registry-1.docker.io 直接拉取的结果。

打开 22.04-platforms 标签的 OCI index,会看到里面有两份 amd64 manifest

{
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    { "digest": "sha256:1c4cc37c...",
      "platform": { "architecture": "amd64", "os": "linux" } },
    { "digest": "sha256:06ae2788...",
      "platform": { "architecture": "amd64", "os": "linux",
                    "os.features": ["erofs"] } }
  ]
}

同样的架构,同样的操作系统,只有 os.features 不同。打开各自 manifest 的层,media type 就分开了。

格式media type层大小
标准 tar+gzipapplication/vnd.oci.image.layer.v1.tar+gzip29,536,798 B (28.17 MiB)
EROFS 原生(无压缩)application/vnd.erofs.layer.v145,289,472 B (43.19 MiB)
EROFS + zstdapplication/vnd.erofs.layer.v1+zstd27,214,834 B (25.95 MiB)

权衡全都在这三行里。无压缩 EROFS 相比 tar+gzip,wire bytes 约为 1.53 倍 — 换句话说,为了去掉解包,多花了 53% 的网络流量。而用 zstd 包一下,反而比 tar+gzip 还小了大约 8%。

有个必须诚实附上的说明。这不是基准测试,只是打开了一份 manifest。 只是一个镜像(ubuntu 22.04 基础镜像,单层),而且还是 EROFS 项目自己上传的测试产物,我也没有核实过 EROFS blob 的内容是否和 tar 层字节级一致(同一个 rootfs)。压缩率会随镜像内容大幅波动。这里应该读出来的不是精确比例,而是方向 — 无压缩原生层会变大,套上 zstd 又会变小。

os.features — 一个标签同时服务两个世界

来看看上面这份 index 为什么巧妙。客户端如果用不了 EROFS,就选普通的 tar+gzip manifest;能用就选 EROFS manifest。同一个标签,同一条拉取命令。PR 描述里出现了类似 ctr i pull --platform="linux(+erofs)" docker.io/hsiangkao/ubuntu:22.04-platforms 这样的写法。

os.features 原本是为这个用途设计的吗?读一下 OCI image-spec 的 image-index 文档,会看到这样的定义 — 这个 OPTIONAL 属性是一个描述所需操作系统特性的字符串数组,当 oswindows 时,实现应该(SHOULD)理解 win32k 这个值。紧接着有决定性的一句。

os 不是 windows 时,取值由实现自行定义(implementation-defined),并且应该(SHOULD)被提交到本规范以推动标准化。

也就是说,Linux 上的 os.features 是规范特意留下的一扇门 — "各自按需使用,但请来标准化" — containerd 走进了这扇门。目前还没有提交。

有一点容易被误解 — os.features 支持不是自动切换。 PR #13091 的描述自己把这点钉死了。

用户要运行 EROFS 原生镜像,仍然需要显式指定 EROFS snapshotter,这是有意为之的设计。这只是改善了解包过程的用户体验。

用默认 snapshotter 跑,遇到 EROFS 镜像时,得到的不再是 overlayfs 抛出的莫名其妙的抽取错误,而是一个清晰的错误提示 —— 改进的实质仅此而已。

+zstd — 找回了压缩,但只避开了一半的解包

无压缩原生层 53% 的代价确实疼,+zstd 正是为此而生。这个思路在 #12506 RFC 里梳理得很清楚 — 文件系统本身保持无压缩(这样运行时读取更快),只在传输时用 zstd 包一层(这样拉取更快)。

但看看实际合并进 2.3.0 的代码,+zstd 路径并不会走 fastcopy 分支。它会先经过标准的 zstd 处理器链,再拷贝到 layer.erofs

processor := diff.NewProcessorChain(diffLayerType, content.NewReader(ra))
...
if native {
    f, err := os.Create(layerBlobPath)
    _, err = io.Copy(f, rc)
    ...
}

归纳起来是这样。

  • 依然不会跑 mkfs.erofs — 没有 tar 解析,也没有逐文件生成。
  • 但是整体流式解压路径回来了。 接收 27MB,解压成 43MB 写到磁盘上。

也就是说,+zstd 落在无压缩原生层和 tar+gzip 之间的某个位置。顺带一提,differ 只接受 +zstd,拒绝其他后缀(unsupported erofs layer suffix)。+gzip 压根不在考虑范围内,按 PR #13185 描述的说法,是因为 zstd 有 skippable frame。这一点引出了下一节。

懒加载还不存在

这是本文最重要的部分。

#12703 是一份打算把 EROFS 层格式文档化的提案。第一节动机列表最上面写着 — "运行时随机访问(懒加载): 容器无需等待整个镜像下载或解包完成即可立即启动,数据按需获取。"

设计也很具体。在一个 zstd skippable frame(magic 为 0x184D2A5E)里放一张 Chunk Mapping Table,标准 zstd 解码器会直接跳过它,而懂这套结构的读取器则用它把非压缩偏移映射到压缩范围,从而取回特定的 chunk。头部包含 magic、版本、总大小、chunk 大小(例如 4MiB)、哈希算法,每个 chunk 还带有在 blob 内的绝对偏移和可选校验和。所需元数据通过 OCI manifest 的注解传递 — dev.containerd.erofs.zstd.chunk_table_offsetdev.containerd.erofs.zstd.chunk_digestdev.containerd.erofs.dmverity.root_digest 等等。

问题是,这份提案自 2025-12-18 开出以来,至今尚未合并。 提案正文第一行就写着"This is a draft proposal, I'm opening as PR rather than issue to make it easier to comment"。

实现这一侧更加确凿。#12764「Seekable-erofs」 于 2026-01-09 开出,2026-04-13 最后一次更新,目前仍未合并。PR 正文把已实现和未实现的内容并列列出,未实现的部分是这样的。

Does NOT implement:

  • lazy-loading: the chunk table is written but not consumed
  • dm-verity usage: the dm-verity data is decoded but yet not used in the differ / at mount time

chunk table 写了,但没有被读取。 也就是说,截至 2026 年 7 月,containerd 的 EROFS 路径里并不存在懒加载。提案的标题式说法和实际船上装的东西之间,差的就是这段距离。

所以,如果要把 containerd 2.3 的 EROFS 原生层浓缩成一句话,那就是 — 它不是"懒惰地拉取",而是"不解包"。 镜像整体提前拉取这个事实没有变,消失的是解包这一步。这不是坏事,只是在解决另一个问题;如果带着"镜像拉取会变快"的预期去开启它,就会落空。

dm-verity — 安静地更有意思的一部分

被懒加载的话题盖过去了,但 2.3 里真正完整落地的是完整性这一块。

EROFS snapshotter 现在提供三种完整性手段。

  • set_immutable — 给层 blob 加上 IMMUTABLE_FL,阻止删除、重命名、修改,并立即 flush 脏数据。它抓不住由硬件故障导致的数据损坏。这是有代价的,containerd 文档记录了自己的实测数据 — 在 EXT4 上,tensorflow:2.19.0 的解包时间从 10.090 秒增长到 21.074 秒,增幅 108.86%。这是项目自己的测量结果,条件仅限于那个镜像、那个文件系统,并且文档明确说明不影响运行时性能。
  • enable_fsverity — 在 commit 时启用 fs-verity,并在挂载前验证状态。正如文档坦诚写明的那样,因为容器里每一次镜像读取都要先验证默克尔哈希树,所以会带来运行时开销。
  • dmverity_mode — 由 #12502 引入的新成员。为每一层创建一个 dm-verity 设备,并以只读方式挂载。

dm-verity 的实现相当干净。它没有调用 veritysetup CLI,而是用 containerd/go-dmverity 库直接构建默克尔树,把树内联附加在 blob 里,只把根哈希和偏移量单独放进一个 layer.erofs.dmverity 的 JSON 文件。其余参数留在 blob 内的超级块里,挂载时会自动检测。块大小在普通模式下是 4096 字节,在 tar-index 模式下是 512 字节(这是 dm-verity 的 logical_block_size 限制)。

模式有三种。auto(默认,有 .dmverity 元数据就用,没有就按普通 EROFS 挂载)、on(强制所有层都要有,没有就失败)、off(完全禁用)。

这里有个陷阱。文档用加粗字体给出了警告 — 如果在已经存在未经 dm-verity 解包的层的情况下打开 dmverity_mode = "on",那些层因为没有 .dmverity 文件,挂载会失败。 必须清理快照,并配合 differ 的 enable_dmverity = true 重新拉取。如果打算把这个功能推广到已有节点上,这就不只是一个陷阱,而是一次需要规划的迁移。

提案 #12703 在这里更进一步 — 如果存在 dm-verity 数据,应该把它的根哈希用作层的 DiffID。 这意味着内核按块验证的值,会和镜像 config 指向的值变成同一个值,但这仍然只是个提案。

需要付出的代价

开启之前该知道的事,大多来自 containerd 的 EROFS snapshotter 文档

内核。 需要 EROFS 模块,且 Linux 版本要在 5.4 以上(modprobe erofs)。但真正想要的是 file-backed mount,那需要 Linux 6.12 以上。低于这个版本会退回到走 loop 设备。要用 dm-verity,还额外需要 CONFIG_DM_VERITY 和 device-mapper 模块。

外部二进制文件。 要把非原生层的普通镜像转换成 EROFS,需要 mkfs.erofs。按文档要求,erofs-utils 要 1.7 以上;用于可复现构建的 -T0 --mkfs-time 需要 1.8 以上;避免不必要 tar 重排序的 --sort=none 需要 1.8.2 以上。也就是说,光有 containerd 这一个二进制文件是不够的,节点镜像会多出一项包依赖。

必须显式指定。 EROFS snapshotter 不是默认项。需要传入 --snapshotter erofs 或在配置里指定,还得动一下 differ 的优先级(default = ["erofs","walking"])。

维护者自己贴的标签。 containerd 的 EROFS 追踪 issue #11340 把原生 blob 相关的 PR 归到这样一行下面 — "Native EROFS blob support in content store and image (experimental)"。"实验性"不是我的评价,是项目自己的标注。同一份清单里,go-erofs 原生库的集成还没做完。

生态。 这大概是最大的一堵墙。目前镜像仓库里的 EROFS 原生镜像,实际上都是项目相关人员上传的测试产物。标准构建流水线不会产出 EROFS 层,主流镜像仓库、扫描器、签名工具是否理解这个厂商 media type 也没有任何保证。镜像构建流水线的整体情况,在Docker BuildKit 与 OCI 镜像层一文中讨论过,那个世界目前仍然是 tar。

基准测试没能核实。 containerd 文档里收录了 Top25 镜像和大型 AI 镜像的解包基准测试,但数字只存在于 PNG 图片里,没有文本形式。而且如文档所写,测试条件是 containerd 2.2.1、本地镜像仓库、不启用并行解包。无法核实的数字,我不会引用。网络搜索片段里流传着"WordPress 镜像提升 14%"之类的数值,但把原始文档拉下来 grep 一遍,并没有这样的句子。

标准走到哪一步了

退一步看,这个故事真正的主题不是 EROFS,而是 OCI 标准化的速度。

containerd 用的 media type 是 application/vnd.erofs.layer.v1,不是 org.opencontainers.*。注解也是 dev.containerd.*,提案 #12703 在脚注里写道 — "Future Goal: 随着采用度扩大,把 dev.containerd.* 注解迁移到标准化的命名空间(例如 org.opencontainers.*)。"

那 OCI 那边的门是什么状态呢?把 image-spec 仓库里相关 issue 连同日期一起摆出来,画面就清楚了。

issue开出日期状态
#815 添加 eStargz 规范(支持懒拉取)2020-12-09open,最后更新 2021-10-21,2 条评论
#936 用可随机访问的格式替代压缩 tar2022-08-15open
#1191 添加 erofs/squashfs 层 media type2024-05-29open,18 条评论

把懒拉取加进规范的提案在 2020 年 12 月开出,2021 年 10 月之后近 5 年都没人碰过。EROFS 层类型的 issue 已经开了 2 年多。#1191 的问题定义放到今天读依然成立 — 大型制品(1)下载耗时太久,(2)解包耗时太久,而这个 issue 开始着手处理(2) — 而这个"开始"已经进入第 2 年了。

于是 containerd 没有等,先用厂商类型打了出去。这是观察,不是指责 — 有一个真正跑得起来的实现,标准化的讨论才有血有肉,这也是事实。但用它,就意味着依赖着一个还不是标准的东西,也意味着标准化过程中 media type 或注解名称都可能会变。提案自己也写明了这种可能性。

那么,现在该不该开

值得一试的情况

  • 节点上解包确实是实测确认的瓶颈。如果是文件数量很多的几 GB 级 AI 镜像,CPU/IO 都被解包耗掉了,那么光是 2.1 起就有的 EROFS snapshotter + differ(不用原生层)就已经有收益。用 mkfs.erofs 刻出 blob,比逐文件抽取更划算,GC 也变成删一个 blob,而不是删几万个文件。
  • 使用基于 VM 的运行时。containerd 文档明确列出了这个用途 — 在 Kata 这类 VM 容器里,把镜像层用 EROFS 直通(pass-through)代替 virtiofs 或 9p,性能和内存占用都更好,gVisor 也支持 EROFS。
  • 要求块级完整性。如果是必须对每一层强制 dm-verity 或 fs-verity 的合规环境,这是 overlayfs snapshotter 做不好的事。文档解释了原因 — FS_IMMUTABLE_FL 和 fs-verity 保护的是单个文件而不是子树,在文件数量多达数万的 overlayfs 层上效率不高。EROFS 的层本身就是一个文件,天然契合。
  • 需要给可写层加磁盘配额。可以用 default_size 把一个固定大小的块设备交给 overlayfs 当 upper 层。

还不到时候的情况

  • 问题出在下载而不是解包。EROFS 原生层在这里帮不上忙,如果是无压缩的,反而更糟。需要懒加载的话,去看 eStargz/SOCI/Nydus,或者干脆走并行快速拉取那条路。
  • 分发原生 EROFS 层。没有构建流水线,media type 不是标准,也不能保证镜像仓库周边的工具都理解它。
  • 节点内核低于 6.12,或者很难把 erofs-utils 塞进节点镜像。
  • experimental 标签让你无法接受。在生产环境里,这个标签的意思是"配置面和磁盘上的格式可能会在小版本里变化"。

再补充一点,开启 EROFS snapshotter 和拉取 EROFS 原生层,是两件不同的事。前者从 2.1 起就存在,今天就值得评估。后者是 2.3 的新故事,还处于实验阶段。不要把这两者混在一起判断。如果对 containerd 镜像处理的全貌感兴趣,也可以参考containerd 镜像管理一文。

结语

归纳一下。containerd 2.3(2026-04-30,第一个年度 LTS)新增了 application/vnd.erofs.layer.v1+zstd 这两个 media type,打通了一条从镜像仓库直接拉取 EROFS 文件系统镜像并挂载的路径。无压缩原生层的解包,在代码层面被压缩成一次 blob 拷贝;os.features 让一个标签能同时服务 EROFS 客户端和普通客户端;dm-verity 则能给每一层挂上块级完整性。

代价是,懒加载还不存在 — chunk table 的设计停留在提案里,实现 PR 处于开放状态,那个 PR 自己写着"表写了但没消费"。media type 是厂商类型,OCI 那边的门已经开了 2 年多。无压缩会多花 wire bytes,用 zstd 找回压缩率,解压路径又会跟着回来。需要内核 6.12 和 erofs-utils,维护者也把它标为 experimental。

如果这篇文章只带走一件事,希望是这个 — 下次容器镜像优化的话题里再出现"更快"这个词,先问一句,是下载更快,还是解包更快。 过去 5 年,这个领域的话题大多是前者,而 containerd 2.3 实际装船的是后者。你的节点上到底哪一边疼,答案不在发布说明里,而在你的性能剖析里。

参考资料