文章

【笔记】OCI 容器镜像规范与分层复用机制

【笔记】OCI 容器镜像规范与分层复用机制

中心问题

skopeo、crane 这类”操作镜像仓库”的工具,职责和原理是什么?它们能够互相操作彼此的产物,靠的是什么规范体系?镜像分层能够复用(磁盘、传输、构建三个不同场景),背后各自依赖什么标识机制,这些标识之间是什么关系?

整体模型:三份规范,各管一段

容器从”写 Dockerfile”到”进程真正跑起来”,要经过三个解耦的阶段,OCI(Open Container Initiative)对每个阶段各发布一份规范:

1
2
3
4
5
image-spec        ──转换──▶  runtime bundle  ──runc/crun──▶  实际运行的容器进程
(镜像长什么样)                  │
                                 └─ runtime-spec 定义 bundle 结构(config.json + rootfs/)

distribution-spec:定义"怎么通过 HTTP 把 image-spec 描述的东西存取到仓库"
  • image-spec:定义镜像的数据格式——一个镜像由 Manifest(清单)、Config(配置)、Layer(层)、可选的 Index(多架构索引)组成,规定这些 JSON/blob 长什么样、互相怎么引用。
  • distribution-spec:定义仓库的 HTTP API 协议——按什么路径、方法去存取 image-spec 定义的这些 JSON 和 blob,不关心内容本身的语义。
  • runtime-spec:定义容器运行时怎么启动一个进程——bundle 目录结构(config.json + rootfs/)、namespace/cgroup 隔离配置、生命周期状态机。与拉/推镜像无关,是镜像落地成 rootfs/ 之后才用到的规范;跟 skopeo/crane 这类仓库操作工具直接相关的只是 image-spec 和 distribution-spec 两份。

skopeo、crane、docker、containerd、podman、Harbor/GHCR/ECR 等,都是这三份规范(主要是前两份)的具体实现,这也是为什么 crane 能直连 Docker Hub 拉镜像、推到 Harbor,全程不需要 docker daemon 参与——规范保证了协议和数据格式的互操作性,跟具体是哪家工具、哪家仓库无关。

distribution-spec:只关心”怎么存取”

核心端点只有两类,且路径本身不区分内容类型:

1
2
GET  /v2/<name>/manifests/<ref>   拿 Manifest 或 Index(哪种由内容协商决定,见下)
GET  /v2/<name>/blobs/<digest>    拿 Config JSON 或 Layer tar.gz(按内容寻址)

内容协商:一条路径怎么同时服务 Manifest 和 Index

Registry 本质是”按 key 存字节”的存储,不理解镜像语义。区分 Manifest 还是 Index,靠 HTTP 的 Accept / Content-Type 头:

  • 请求方在 Accept 里列出所有能处理的 mediaType(如 application/vnd.oci.image.index.v1+json...manifest.v1+json 都列上)
  • 响应的 Content-Type 告诉客户端拿到的具体是哪种,据此分支处理:是 Index 就按本机架构从 manifests[] 里选一条 digest,再发一次请求换成拉具体 Manifest;是 Manifest 就直接往下解析 config/layers

单架构(从未构建过多架构)的镜像,<ref> 对应存的直接就是 Manifest,不是 Index——客户端逻辑必须写成”先请求再按 Content-Type 分支”,不能预设固定类型。

认证:Bearer Token 挑战-响应

几乎所有 registry 都遵循同一套流程:匿名请求先拿 401 WWW-Authenticate: Bearer realm=...,拿着 realm/scope 去 token 服务换 Bearer token,带 token 重试。这是 skopeo/crane/docker 等工具能共享同一套凭据配置(如 ~/.docker/config.json)的基础。

跨仓库复用:blob mount

1
POST /v2/<target-repo>/blobs/uploads/?mount=<digest>&from=<source-repo>

如果目标要推的 blob 在同一 registry 的另一个 repo 下已存在(且有读权限),直接”挂载”过去,不用重传数据。crane/skopeo 推送前会先 HEAD /v2/<name>/blobs/<digest> 探测是否已存在,不存在再尝试 mount,最后才老实上传。

这解决的是”两个不同 namespace(如两个 Java 微服务各自的 repo)的镜像能否共享同一个基础镜像层”的问题,分两层:registry 底层存储通常按 digest 全局去重,同 registry 下不同 repo 引用同一 digest 大概率天然共享物理存储;上面这条 mount API 是显式触发跨 repo 复用的机制。但这个复用范围局限于同一个 registry 实例内——跨 registry(如 Harbor A 和 Harbor B)即便 digest 完全相同也不会自动复用,因为 distribution-spec 只定义单实例内部行为,registry 之间没有协议互通。

待验证/已核实的更新:OCI distribution-spec v1.1(2024-03)之后,from 参数已变为可选——registry 可以在不知道具体来源 repo 的情况下也支持 mount(适用于”基础镜像最初是从别的 registry 拉的,来源信息已丢失”的场景)。此前的理解(mount 必须显式指定 from)已过时,以此为准。

image-spec:数据长什么样

四类对象,Manifest 是索引核心:

1
2
3
4
5
6
7
8
// Manifest:一个镜像的"总目录"
{
  "config": { "digest": "sha256:aaa...", "mediaType": "...image.config.v1+json" },
  "layers": [
    { "digest": "sha256:bbb...", "mediaType": "...image.layer.v1.tar+gzip" },
    { "digest": "sha256:ccc...", "mediaType": "...image.layer.v1.tar+gzip" }
  ]
}
  • Config:镜像运行的默认参数(Entrypoint/Cmd/Env/WorkingDir),以及 rootfs.diff_ids[](见下)。这份 JSON 后续会被转换成 runtime-spec 的 config.json,是 image-spec 到 runtime-spec 之间的桥梁。
  • Layer:文件系统的一次差量快照(tar.gz),layers[] 的顺序即叠加顺序。
  • Index(可选):多架构镜像的”清单的清单”,按 platform 字段区分 amd64/arm64 等,指向各自的 Manifest digest。

Runtime Bundle(对接 runtime-spec):rootfs/(各层 tar 解压叠加后的完整目录树)+ config.json(Config JSON 转换而来)。runc create --bundle 直接消费这个目录,不直接消费镜像本身——镜像必须先经这一步转换。

标识体系:从 tag 到 ChainID 的五层链条

这是最容易混淆的部分,本质是同一份数据在不同阶段、不同哈希对象上派生出的多个身份

1
2
3
4
5
6
7
8
9
10
11
12
13
tag (可变指针,人类可读)
  │  registry 维护 tag → digest 映射
  ▼
Manifest digest = sha256(manifest JSON 原始字节)   ← 唯一"不可变引用",image@sha256:xxx
  │  manifest.layers[].digest / manifest.config.digest
  ▼
Layer digest(压缩后 tar.gz 字节的哈希)           ← distribution-spec 用,服务传输/存储
  │  对应 config.rootfs.diff_ids[](同索引位置)
  ▼
DiffID(解压后原始 tar 内容的哈希)                ← image-spec 用,服务内容语义判断
  │  本地 layer store 递归计算
  ▼
ChainID(DiffID + 父链递归哈希)                   ← 不进规范,纯本地缓存 key

digest vs diffID:压缩前后的两把尺子

layers[](Manifest)和 diff_ids[](Config)按索引一一对应、描述同一层,但哈希对象不同:

 哈希对象服务场景受压缩参数影响
layer digest压缩后 tar.gz 字节网络传输校验、registry 存储去重、HEAD 存在性探测会变
diffID解压后原始 tar 字节内容语义判断(是否是同一层内容)、驱动 ChainID不会变

分开存在的原因:同一份内容用不同压缩级别/实现重新打包,压缩后字节会变但内容不变。只有 layer digest 会导致”内容明明一样却被判定成不同层”;只有 diffID 则没法在下载前后做完整性校验,也没法在不解压的情况下用 HEAD 探测。两者是互补关系,不是互相替代——layers[]diff_ids[] 两个数组按索引严格一一对应、描述同一组层,只是记录的哈希对象不同。

从使用侵重上看:layer digest 偏分发规范/传输流程这一侧(网络、registry API);diffID 偏端侧(本地内容比对、构建缓存判断层内容、runtime 组装 rootfs 时驱动 ChainID)。

ChainID:本地挂载缓存,不影响正确性

1
2
ChainID(L₀)        = DiffID(L₀)
ChainID(L₀|...|Lₙ) = sha256( ChainID(L₀|...|Lₙ₋₁) + " " + DiffID(Lₙ) )

diff_ids[] 数组本身(有序列表)已经完整描述了”要按什么顺序叠加哪些内容”,这是决定正确性的数据,跟着镜像分发。ChainID 只是在这份数据基础上算出来的缓存索引,回答一个纯效率问题:”这条前缀链本地是否已经展开挂载过,能不能不再重新解压?”——不携带任何 diff_ids[] 里没有的新信息。

去掉 ChainID,系统依然完全正确,只是每次都要从头老实解压叠加,损失的是性能不是正确性。这跟数据库索引的性质一样:没索引结果依然对,只是要全表扫描。

关键推论:ChainID 由”内容 + 父链”共同决定,父链不同则 ChainID 不同——即使某一层的 DiffID 完全相同。例如镜像1 = A,B,C,镜像2 = A,C,即便两边的 C 层内容逐字节一样(DiffID 相同),ChainID(A|B|C) ≠ ChainID(A|C),本地 layer store 会把二者的挂载产物当成两个不同的对象分别管理,C 层要重新解压叠加一次——但这只发生在”本地展开挂载”这一步;registry 存储和网络传输层面,两边的 C 层因为 digest/diffID 相同,依然会被正确识别为同一份 blob,不会重复传输。

这个例子也说明了 layer 身份的判断标准:只认最终写出来的字节,不认怎么写出来的。两个镜像各自的 ENV 或执行上下文即便不同,只要命令产出的文件字节逐字节一样,DiffID 就相同,是同一层;ENV 只有在被命令实际用到、写进了产物字节时才会导致内容不同(进而 DiffID 不同)。这跟”配方”是否相同是两回事,配方相同/不同都可能产出内容相同的层(见下文 Build cache 对比)。

Build cache:跟 ChainID 并行、独立的另一套判断

构建时(docker build 逐层执行 Dockerfile 指令)真正决定”要不要重新执行这条指令”的,不是 ChainID,是构建缓存的配方哈希:

1
2
CacheKey(FROM ...)     = 基础镜像 digest/ID
CacheKey(instructionₙ) = hash( CacheKey(instructionₙ₋₁) + 这条指令的"配方" )

不同指令类型的”配方”取材不同:

指令类型配方 =判断依据
RUN指令的字面文本纯字符串比较,不检查执行结果——命令有非确定性时(如 RUN date >> log)会返回旧结果,是经典的缓存失真坑点
COPY/ADD被拷贝文件内容的哈希 + 目标路径/权限源文件内容变了必定 miss,即使指令文本没变
ENV/LABEL/ARG/WORKDIR指令文本本身不产生文件系统层,只改 Config

一旦某层 cache miss,因为父 CacheKey 被编进了后续每层的哈希输入,后面所有层级联失效,即使后面几层的”配方”字面上完全没变。这是”依赖装配步骤要放前面、易变的源码拷贝放后面”这条 Dockerfile 最佳实践的根源。

Build cache 与 ChainID 是两套独立运作的机制,互不依赖:

 Build cacheChainID
判断依据指令配方(文本或拷贝内容)层的产出内容(DiffID)
回答的问题要不要重新执行这条指令?要不要重新解压叠加这层?
两个 Dockerfile 配方不同但结果字节相同必 miss,重新执行一遍可能命中(若 diffID 和父链都一样)

即:两个镜像即便各自独立 build(配方文本相同或不同都可能),产出的层内容如果字节一样,DiffID/digest 层面依然会被正确识别为同一份,只是”本地是否需要重新展开挂载”这一步各走各的判断,谁也不看谁的结果。构建一条指令时二者顺序衔接但互不依赖:先查 build cache 决定是否跳过执行,再查 ChainID 决定是否跳过解压挂载。

三个层面的层复用,范围逐层收窄

层面复用范围机制
本机磁盘(运行时)同一台机器上所有容器/镜像OverlayFS union mount,按 ChainID 判断是否已展开挂载
同一 registry跨 repo / 跨 namespace底层 blob 存储按 digest 去重(隐式)+ 显式 blob mount API
跨 registry不复用distribution-spec 只定义单实例内部行为,registry 之间无协议互通

“Docker 基础架构的创新点”严格讲是两个技术叠加:分层文件系统(解决本机磁盘/运行时复用,OverlayFS)+ 内容寻址(解决传输和跨 repo 存储复用),后者被 OCI 规范化后,才让 crane/skopeo 这类跨仓库同步工具能做到”探测已存在就跳过传输”的高效同步。

易混点小结

  • tag ≠ manifest digest:tag 是人类可读的可变别名,manifest digest(image@sha256:xxx)才是某一刻真正指向的不可变版本;同一 tag 不同时间拉取可能拿到不同内容,digest 引用永远精确。
  • layer digest ≠ diffID:前者哈希压缩后字节(服务传输/去重),后者哈希解压后字节(服务内容判断),二者按索引对应同一层但数值不同。
  • ChainID ≠ layer digest/diffID:前者是本地私有、不进规范、不随镜像分发的挂载缓存索引;后二者是 image-spec 正式定义、随镜像分发的字段。
  • ChainID ≠ build cache key:分别管”本地要不要重新展开挂载”和”要不要重新执行指令”,判断维度(内容 vs 配方)和作用阶段(构建后落盘 vs 构建执行前)都不同,互相独立。

未决问题 / 下一步

  • 本文 blob mount 的 from 参数可选化,只核实了 OCI 官方 blog 的说明,尚未在具体 registry 实现(Harbor/ECR/GHCR)里验证实际支持程度,不同厂商落地进度可能不一致。
  • BuildKit(现代 docker build 默认引擎)的远程缓存导入/导出(--cache-from/--cache-to)机制提到但未展开,其”内容可寻址的远程缓存”具体如何组织 cache key、如何与本地 layer store 交互,值得单独深入。
  • runtime-spec 本文只做了定位说明(bundle 结构),未深入 namespace/cgroup 配置细节和 runc 生命周期状态机,如果后续要理解容器安全边界(如 jgs-scout 项目里的沙箱设计),可以专门展开。
本文由作者按照 CC BY 4.0 进行授权