深入理解 OCI 镜像底层
坑边闲话:大多数开发者每天都在用
docker pull,却从未想过这条命令背后到底发生了什么。镜像不是一个文件,而是一棵由内容寻址对象组成的 Merkle 树。理解这棵树的结构,不仅能纠正「IMAGE ID 就是镜像摘要」这类常见误解,还能帮你设计出只需一个 HEAD 请求就能检测镜像更新的轻量方案。本文试图把 OCI 镜像的底层拆开来讲清楚,顺便聊聊 skopeo 代理拉取和工程实践中那些容易踩的坑。
1. OCI 镜像的四层结构·
OCI (Open Container Initiative) 镜像不是一个单一的 tar 包或二进制文件。它是一个由多层内容寻址 (content-addressable) 对象组成的树形结构。所谓内容寻址,是指每个对象的标识符 (digest) 由其内容的 sha256 哈希值决定:内容变了,digest 就变了,和 Git 的 commit hash 是同一套思路。
从入口到底层,这棵树分为四层。下面逐层拆解。
1.1 Manifest List·
这是多架构的入口。
当你执行 docker pull ubuntu:20.04 时,Docker daemon 首先向 registry 发起请求,registry 返回的第一个对象就是 Manifest List(OCI 规范称之为 Image Index,Docker 生态里也叫 Fat Manifest)。
它的职责很简单:一个 tag 可能同时对应 amd64、arm64、armv7 等多个架构的镜像,Manifest List 就是把这些架构的入口全部列在一个 JSON 里。
1 | { |
这个 JSON 文件本身也有一个 digest(对整个 JSON 计算 sha256),它就是通过 HEAD /v2/<repo>/manifests/<tag> 请求拿到的 Docker-Content-Digest header 值。记住这一点,后面讲更新检测时会回来。
1.2 Image Manifest·
这是某一具体架构的内部清单。
Docker daemon 根据宿主机的架构(比如 amd64),从 Manifest List 中选出对应的 digest,再发一次 GET 请求拿到 Image Manifest。
Image Manifest 描述了这个架构下镜像的完整组成:一个 Config Blob 和若干个 Filesystem Layers。
1 | { |
结构很直白:config 字段指向运行时配置,layers 数组按顺序列出所有文件系统差分层的 digest 和大小。
1.3 Config Blob·
这是容器的运行时配置。
Config Blob 是一个 JSON 文件,定义了容器的环境变量 env、entrypoint、cmd、exposed ports、工作目录等。你用 docker inspect 看到的那些配置信息,就来自这个对象。
这里有一个极其常见的误解需要纠正:
1 | $ docker images ubuntu |
这个 IMAGE ID b7bab04fd9aa 是什么?它是 Config Blob 的 digest,不是 Image Manifest 的 digest,更不是 Manifest List 的 digest。 三者是完全不同的值,对应树形结构的不同层级。很多人下意识地以为 IMAGE ID 就是「镜像的 hash」,但实际上它只是配置层的 hash。
1.4 Filesystem Layers·
这是文件系统差分层。
最底层是实际的文件系统内容。每一层是一个 tar+gzip 归档,包含相对于上一层的文件变更(增、删、改)。Docker 使用 overlay2 存储驱动将这些层自底向上叠加,形成容器运行时可见的完整文件系统。
把以上四层画在一起,整体结构如下:
1 | Manifest List (sha256:AAA) |
四层结构,每一层都是内容寻址的 JSON 或 tar 归档。这不是随意的设计:它构成了一棵 Merkle 树。
2. 为什么只需顶层 digest?·
上面四层结构的精妙之处在于:上层通过 digest 引用下层,形成了一条从叶子到根的哈希链。任何底层内容的变动,都会像多米诺骨牌一样向上传播。
考虑这样一个场景:镜像维护者更新了 Layer 2 的内容(比如升级了一个系统库)。
1 | Layer 2 content changed |
即使只改了最底层的一个 filesystem layer,整棵树的根也就是 Manifest List 的 digest 也会跟着变。反过来,如果 Manifest List 的 digest 没变,那就意味着这棵树上的每一个节点都没变。
这个性质带来了一个极其实用的推论:检测镜像是否有更新,只需要比较 Manifest List 的 digest. 一个 HEAD 请求,0 bytes response body,一个 header 值,就能判断整个镜像树是否发生了任何变化。
3. Docker daemon 的本地视角·
理解了 registry 端的四层结构后,还需要搞清楚 Docker daemon 本地保存了什么、丢弃了什么。这两端的不对称性是很多实践问题的根源。
3.1 本地保存了什么·
docker pull 完成后,Docker daemon 的本地存储中包含:
- 当前架构的 Image Manifest
- Config Blob
- 所有 Filesystem Layers
3.2 本地丢弃了什么·
Manifest List 不会被保存。 Docker daemon 在下载过程中读取 Manifest List,从中选出当前架构的 Image Manifest digest,然后 Manifest List 本体就被丢弃了。本地存储中找不到它。
3.3 RepoDigests 是一张「收据」·
虽然 Manifest List 本体被丢弃了,但 Docker daemon 会在本地元数据中留下一条溯源记录:
1 | $ docker inspect --format '{{json .RepoDigests}}' ubuntu:20.04 |
这里的 sha256:AAA... 就是 Manifest List 的 digest。注意几个要点:
- 这不是 OCI 标准的一部分,而是 Docker daemon 的私有元数据
- 它是一张「收据」:「这个本地镜像是从 digest 为 AAA 的 Manifest List 拉取来的」
- 数组通常只有一条记录(从一个 registry 拉取就一条)
- 只有
docker pull才会写入 RepoDigests. 这一点在后面讲 skopeo 时非常关键
3.4 三个 digest 不要搞混·
把 Docker 生态中常见的三个 digest 放在一起对比:
1 | +---------------------+-------------------+---------------------------+ |
三者是完全不同的值,分别对应树形结构的不同层级。在做镜像管理的自动化工具时,混淆它们是最常见的 bug 来源。
4. 最轻量的更新检测·
本节介绍使用 OCI Distribution API 实现一个轻量的镜像更新检测。
OCI Distribution Spec 定义了 registry 的 HTTP API。要检测一个镜像是否有更新,整个流程只需要两步。
4.1 Token 认证·
不同 registry 的认证端点不同。通用的发现方式是:先向 /v2/ 发一个 GET 请求,registry 返回 401, 并在 WWW-Authenticate header 中告诉你去哪里拿 token.
1 | GET /v2/ |
常见 registry 的认证端点:
- DockerHub:
https://auth.docker.io/token?service=registry.docker.io&scope=repository:<repo>:pull - GHCR:
https://ghcr.io/token?service=ghcr.io&scope=repository:<repo>:pull - 其他:从
WWW-Authenticateheader 中解析realm和service
4.2 HEAD 请求检测更新·
拿到 token 后,一个 HEAD 请求就够了:
1 | HEAD /v2/<repo>/manifests/<tag> |
把这个 digest 和本地 RepoDigests 中记录的 digest 比较,就能判断镜像是否需要更新。
整个检测流程的 HTTP 交互如下:
1 | Client Registry Token Endpoint |
整个检查只有 2 个有效 HTTP 请求(token + HEAD),HEAD 请求的 body 为 0 bytes。如果你管理 33 个镜像,总共也就 66 个请求,非常轻量。
4.3 为什么不比较更底层的 Image Manifest digest·
理论上也可以比较 Image Manifest 的 digest(Layer 2),但两端的获取成本都更高:
1 | +----------------------------+-----------------------+-------------------------+ |
远端需要额外做一次 GET 拉取完整的 Manifest List JSON 并解析当前架构,本地需要翻 Docker 的内部存储目录。Manifest List digest 是效率和复杂度的最优平衡点。
5. 拒绝 Docker daemon 全局代理·
在需要通过代理才能访问 container registry 的环境中(公司内网、教育网等),很多人的第一反应是修改 Docker daemon 的全局代理配置:
1 | { |
写进 /etc/docker/daemon.json 或 systemd 环境变量,重启 daemon,搞定。
但在生产环境中,这种做法有四个问题:
- 影响范围过大。 Docker daemon 是全局服务,所有
docker pull、docker build等操作都会走代理。如果你同时使用本地 registry(如 Harbor),本地 registry 的请求也会被错误地发往代理服务器。虽然有no-proxy配置,但维护成本高且容易遗漏。 - 需要重启 daemon。
systemctl restart docker会中断所有正在运行的容器。在跑着业务的机器上,这是不可接受的。 - 缺乏细粒度控制。 你无法对不同 registry 使用不同的代理策略:比如 DockerHub 走代理、Harbor 直连、GHCR 走另一条线。
- 权限要求高。 修改 daemon 配置需要 root 权限,在多用户环境下会影响其他用户。
替代方案是使用 skopeo. 这是一个独立的容器镜像工具,可以通过标准的环境变量控制代理,不碰 Docker daemon 的全局配置。
6. skopeo copy 搭配代理·
skopeo copy 命令可以感知环境变量里的代理,从而可以更好地控制镜像传输请求。
6.1 skopeo 是什么·
skopeo 是一个独立的容器镜像操作工具,可以在不同镜像存储之间复制镜像。关键特性是:它不依赖 Docker daemon 来做网络请求。skopeo 自己就是一个完整的 registry 客户端。
6.2 代理控制的粒度差异·
docker pull 的网络请求由 Docker daemon 进程发起,代理只能通过 daemon 全局配置控制。而 skopeo 是一个独立进程,可以通过标准的 HTTPS_PROXY 环境变量精确控制:
1 | HTTPS_PROXY=http://192.168.88.103:1081 \ |
这条命令的含义是:skopeo 通过 HTTP 代理从远端 registry 拉取镜像,然后通过 Docker Engine API(unix socket /var/run/docker.sock)将镜像推送给本地的 Docker daemon。
两种方式的传输路径对比:
1 | docker pull (daemon global proxy): |
skopeo copy docker://... docker-daemon:... 的 docker-daemon: transport 通过 Docker Engine API 将 layer blobs 直接传给 Docker daemon。网络传输量和 docker pull 完全相同:都是从 registry 下载相同的 layer blobs。没有中间临时文件,没有额外的磁盘拷贝。
6.3 代价:RepoDigests 缺失·
skopeo 通过 Docker Engine API 灌入镜像时,Docker daemon 并不知道这个镜像来自哪个 registry 的哪个 Manifest List,因为 API 没有提供「附带写入 RepoDigests」的接口。结果:
1 | # docker pull |
回忆一下前面讲的更新检测方案:比较本地 RepoDigests 和远端 HEAD 请求返回的 digest。如果 RepoDigests 为空,每次检查都会误判为「需要更新」,导致反复拉取。
6.4 解决方案:本地 digest cache·
既然 Docker daemon 不帮你记录,那就自己记。在 skopeo pull 或 ``skopeo copy` 成功后,将远端 digest 缓存到本地文件:
1 | { |
查询本地 digest 时的三步逻辑:
1 | Step 1: docker image inspect <ref> |
注意 Step 1 和 Step 2 必须分开执行。如果直接用 docker inspect --format '{{index .RepoDigests 0}}',当 RepoDigests 为空数组时,Go 模板引擎会报 index out of range 错误(非零退出码)。这和镜像不存在导致的错误是同一种异常:无法区分。所以必须先确认镜像存在,再单独检查 RepoDigests。
6.5 library/ 前缀陷阱·
Docker 官方镜像(ubuntu、debian、nginx 等)在 registry API 中的完整路径是 library/ubuntu,但 Docker daemon 本地存储时会去掉 library/ 前缀:
1 | Registry path: docker://library/ubuntu:20.04 |
如果 skopeo copy 的 destination 写成 docker-daemon:library/ubuntu:20.04,Docker 会把它存为一个名叫 library/ubuntu 的「新」镜像,而 docker inspect ubuntu:20.04 根本找不到它。正确写法:
1 | # correct -- strip library/ prefix in destination |
这个坑在自动化脚本中尤其容易踩到:配置文件里写的 library/ubuntu:20.04 是 registry 端的规范路径,但传给 docker-daemon: transport 时必须去掉前缀。
7. 代理协议的选择·
在用 Python requests 库实现 registry API 客户端时,代理协议的选择有一个不明显的坑。
7.1 SOCKS5 + IPv6 基本不可用·
Python 生态中 SOCKS5 的实现依赖 PySocks 库,而 PySocks 不支持 IPv6。当目标域名(如 auth.docker.io)的 DNS 解析结果包含 IPv6 地址时,SOCKS5 代理会直接失败:
1 | # SOCKS5 proxy -- may fail |
7.2 为什么选择 HTTP 代理·
HTTP 代理不存在上述问题。这是因为,两种代理协议在处理目标地址时的行为完全不同:
1 | SOCKS5 (PySocks implementation): |
HTTP 代理使用 CONNECT 方法建立隧道,DNS 解析和连接建立都由代理服务器负责,客户端只需要提供域名。SOCKS5 在 PySocks 的实现中,客户端需要自己处理地址格式,遇到 IPv6 就崩了。
结论:registry API 访问推荐使用 HTTP 代理,避免使用 SOCKS5。
8. requests.Session 的两个关键配置·
在实现 V2 API 客户端时,requests.Session 的使用有两个值得展开的细节。
8.1 连接复用·
requests.Session 底层使用 urllib3 的连接池,对同一 host 的多次请求会复用 TCP 连接(HTTP keep-alive)。检查 33 个镜像时,对 registry-1.docker.io 的多次 HEAD 请求可以共享同一个 TCP 连接,减少 TCP 握手和 TLS 协商的开销。
8.2 显式代理控制·
通过配置 trust_env=False 可以显式地控制代理。
1 | session = requests.Session() |
trust_env=False 阻止 requests 自动读取环境变量中的 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY 等。为什么这是必要的?
- 服务器环境可能设置了全局的
ALL_PROXY(指向一个 SOCKS5 代理) - 我们需要精确控制使用哪个代理(从配置文件或特定环境变量读取)
- 如果不关闭
trust_env,requests可能会走环境变量中那个不支持 IPv6 的 SOCKS5 代理,然后你就会看到上一节描述的那个错误
先关掉自动检测,再手动指定,这是防御性编程的标准做法。
9. 为什么不做 token 缓存·
DockerHub 的认证 token 有效期通常为 300 秒。每次检查镜像都要重新获取 token,看起来很浪费。能不能缓存 token,在有效期内复用?
算一笔账:
1 | Scenario: 33 images, ~25 on DockerHub, ~8 on GHCR |
省下来的只是 31 次轻量级 HTTP 请求,总时间差异约 2-3 秒。而引入 token 缓存需要增加的代码复杂度包括:
- TTL 管理(token 过期时间跟踪)
- per-registry 缓存(不同 registry 的 token 不能混用)
- 线程安全(如果是并发检查的场景)
- 错误处理(token 提前失效的 retry 逻辑)
对于几十个镜像的规模,「每次请求新 token」的朴素策略完全够用。代码简单、行为可预测、不会出现 token 过期导致的诡异 bug。这是一个典型的「不做优化」比「做优化」更好的案例,即代码复杂度的增长远超性能收益。
总结·
把全文的核心知识点串一遍:
- OCI 镜像是一棵 Merkle 树。 从 Manifest List 到 Image Manifest 到 Config Blob 再到 Filesystem Layers,四层内容寻址对象通过 digest 互相引用。底层任何变动都会传播到根节点。
- IMAGE ID 不是你以为的那个 digest。
docker images显示的 IMAGE ID 是 Config Blob 的 digest(Layer 3),不是 Manifest List 的 digest(Layer 1),也不是 Image Manifest 的 digest(Layer 2)。 - 最轻量的更新检测:一个 HEAD 请求。 利用 Merkle 树的性质,只需比较 Manifest List 的 digest 就能检测到镜像的任何更新。2 个 HTTP 请求(token + HEAD),0 bytes body。
- Docker daemon 不保存 Manifest List。 但会通过 RepoDigests 留下一张「收据」。只有
docker pull才会写入 RepoDigests,skopeo copy 不会。 - 需要代理时,skopeo 比修改 daemon 全局配置更优。 环境变量控制代理,不重启 daemon,不影响其他操作。但要处理 RepoDigests 缺失和
library/前缀两个问题。 - 代理协议选 HTTP,不选 SOCKS5。 PySocks 不支持 IPv6,HTTP CONNECT 代理把 DNS 解析推给代理服务器,更可靠。
理解了这些底层机制,你就能在容器镜像管理的自动化场景中做出更准确的工程决策,而不是靠猜测和试错。










