坑边闲话:大多数开发者每天都在用 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 可能同时对应 amd64arm64armv7 等多个架构的镜像,Manifest List 就是把这些架构的入口全部列在一个 JSON 里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:aaa111...",
"platform": { "architecture": "amd64", "os": "linux" }
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:bbb222...",
"platform": { "architecture": "arm64", "os": "linux" }
}
]
}

这个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:ccc333...",
"size": 7023
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:ddd444...",
"size": 32654
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:eee555...",
"size": 16724
}
]
}

结构很直白:config 字段指向运行时配置,layers 数组按顺序列出所有文件系统差分层的 digest 和大小。

1.3 Config Blob·

这是容器的运行时配置。

Config Blob 是一个 JSON 文件,定义了容器的环境变量 enventrypointcmdexposed ports、工作目录等。你用 docker inspect 看到的那些配置信息,就来自这个对象。

这里有一个极其常见的误解需要纠正:

1
2
3
$ docker images ubuntu
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 20.04 b7bab04fd9aa 4 weeks ago 72.8MB

这个 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
2
3
4
5
6
7
8
9
10
11
12
13
               Manifest List (sha256:AAA)
/ \
/ \
Image Manifest (amd64) Image Manifest (arm64)
(sha256:BBB) (sha256:...)
|
+--- Config Blob (sha256:CCC) <-- docker IMAGE ID
| env, entrypoint, cmd ...
|
+--- Layer 0 (sha256:DDD) <-- base layer (Ubuntu rootfs)
+--- Layer 1 (sha256:EEE) <-- second layer
+--- Layer 2 (sha256:FFF) <-- third layer
overlay2 merge --> rootfs

四层结构,每一层都是内容寻址的 JSON 或 tar 归档。这不是随意的设计:它构成了一棵 Merkle 树。

2. 为什么只需顶层 digest?·

上面四层结构的精妙之处在于:上层通过 digest 引用下层,形成了一条从叶子到根的哈希链。任何底层内容的变动,都会像多米诺骨牌一样向上传播。

考虑这样一个场景:镜像维护者更新了 Layer 2 的内容(比如升级了一个系统库)。

1
2
3
4
5
6
Layer 2 content changed
\-- Layer 2 digest: sha256:FFF --> sha256:FFF'
\-- Image Manifest references Layer 2's digest, JSON content changed
\-- Image Manifest digest: sha256:BBB --> sha256:BBB'
\-- Manifest List references Image Manifest's digest, JSON changed
\-- Manifest List digest: sha256:AAA --> sha256:AAA'

即使只改了最底层的一个 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
2
$ docker inspect --format '{{json .RepoDigests}}' ubuntu:20.04
["ubuntu@sha256:AAA..."]

这里的 sha256:AAA... 就是 Manifest List 的 digest。注意几个要点:

  • 这不是 OCI 标准的一部分,而是 Docker daemon 的私有元数据
  • 它是一张「收据」:「这个本地镜像是从 digest 为 AAA 的 Manifest List 拉取来的」
  • 数组通常只有一条记录(从一个 registry 拉取就一条)
  • 只有 docker pull 才会写入 RepoDigests. 这一点在后面讲 skopeo 时非常关键

3.4 三个 digest 不要搞混·

把 Docker 生态中常见的三个 digest 放在一起对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
+---------------------+-------------------+---------------------------+
| digest | corresponds to | where to find it |
+---------------------+-------------------+---------------------------+
| Manifest List | Layer 1 | HEAD /v2/.../manifests/ |
| digest (sha256:AAA) | (multi-arch index)| Docker-Content-Digest |
| | | docker inspect RepoDigests|
+---------------------+-------------------+---------------------------+
| Image Manifest | Layer 2 | Docker internal storage |
| digest (sha256:BBB) | (single-arch) | /var/lib/docker/image/... |
+---------------------+-------------------+---------------------------+
| Config Blob | Layer 3 | docker images IMAGE ID |
| digest (sha256:CCC) | (runtime config) | docker inspect .Id |
+---------------------+-------------------+---------------------------+

三者是完全不同的值,分别对应树形结构的不同层级。在做镜像管理的自动化工具时,混淆它们是最常见的 bug 来源。

4. 最轻量的更新检测·

本节介绍使用 OCI Distribution API 实现一个轻量的镜像更新检测。

OCI Distribution Spec 定义了 registry 的 HTTP API。要检测一个镜像是否有更新,整个流程只需要两步。

4.1 Token 认证·

不同 registry 的认证端点不同。通用的发现方式是:先向 /v2/ 发一个 GET 请求,registry 返回 401, 并在 WWW-Authenticate header 中告诉你去哪里拿 token.

1
2
3
4
GET /v2/
<-- 401 Unauthorized
<-- WWW-Authenticate: Bearer realm="https://auth.docker.io/token",
service="registry.docker.io"

常见 registry 的认证端点:

  • DockerHubhttps://auth.docker.io/token?service=registry.docker.io&scope=repository:<repo>:pull
  • GHCRhttps://ghcr.io/token?service=ghcr.io&scope=repository:<repo>:pull
  • 其他:从 WWW-Authenticate header 中解析 realmservice

4.2 HEAD 请求检测更新·

拿到 token 后,一个 HEAD 请求就够了:

1
2
3
4
5
6
7
HEAD /v2/<repo>/manifests/<tag>
Authorization: Bearer <token>
Accept: application/vnd.docker.distribution.manifest.list.v2+json,
application/vnd.oci.image.index.v1+json

<-- 200 OK
<-- Docker-Content-Digest: sha256:AAA...

把这个 digest 和本地 RepoDigests 中记录的 digest 比较,就能判断镜像是否需要更新。

整个检测流程的 HTTP 交互如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Client                        Registry                  Token Endpoint
| | |
|-- GET /v2/ ----------------->| |
|<---- 401 + WWW-Authenticate -| |
| | |
|-- GET /token?scope=... ------|--------------------------->|
|<---- 200 + Bearer token -----|----------------------------|
| | |
|-- HEAD /v2/.../manifests/tag | |
| Authorization: Bearer ... | |
|<---- 200 --------------------| |
| Docker-Content-Digest: | |
| sha256:AAA... | |
| | |
| compare with local digest | |
| --> same: up to date | |
| --> different: need update | |

整个检查只有 2 个有效 HTTP 请求(token + HEAD),HEAD 请求的 body 为 0 bytes。如果你管理 33 个镜像,总共也就 66 个请求,非常轻量。

4.3 为什么不比较更底层的 Image Manifest digest·

理论上也可以比较 Image Manifest 的 digest(Layer 2),但两端的获取成本都更高:

1
2
3
4
5
6
7
8
9
10
+----------------------------+-----------------------+-------------------------+
| method | remote cost | local cost |
+----------------------------+-----------------------+-------------------------+
| Manifest List digest | 1x HEAD (0 bytes) | docker inspect |
| (Layer 1) | | read RepoDigests |
+----------------------------+-----------------------+-------------------------+
| Image Manifest digest | 1x GET + parse JSON | dig into Docker storage |
| (Layer 2) | + match architecture | /var/lib/docker/image/ |
| | | overlay2/... |
+----------------------------+-----------------------+-------------------------+

远端需要额外做一次 GET 拉取完整的 Manifest List JSON 并解析当前架构,本地需要翻 Docker 的内部存储目录。Manifest List digest 是效率和复杂度的最优平衡点。

5. 拒绝 Docker daemon 全局代理·

在需要通过代理才能访问 container registry 的环境中(公司内网、教育网等),很多人的第一反应是修改 Docker daemon 的全局代理配置:

1
2
3
4
5
6
{
"proxies": {
"http-proxy": "http://proxy:1081",
"https-proxy": "http://proxy:1081"
}
}

写进 /etc/docker/daemon.json 或 systemd 环境变量,重启 daemon,搞定。

但在生产环境中,这种做法有四个问题:

  1. 影响范围过大。 Docker daemon 是全局服务,所有 docker pulldocker build 等操作都会走代理。如果你同时使用本地 registry(如 Harbor),本地 registry 的请求也会被错误地发往代理服务器。虽然有 no-proxy 配置,但维护成本高且容易遗漏。
  2. 需要重启 daemon。 systemctl restart docker 会中断所有正在运行的容器。在跑着业务的机器上,这是不可接受的。
  3. 缺乏细粒度控制。 你无法对不同 registry 使用不同的代理策略:比如 DockerHub 走代理、Harbor 直连、GHCR 走另一条线。
  4. 权限要求高。 修改 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
2
HTTPS_PROXY=http://192.168.88.103:1081 \
skopeo copy docker://jellyfin/jellyfin:latest docker-daemon:jellyfin/jellyfin:latest

这条命令的含义是:skopeo 通过 HTTP 代理从远端 registry 拉取镜像,然后通过 Docker Engine API(unix socket /var/run/docker.sock)将镜像推送给本地的 Docker daemon。

两种方式的传输路径对比:

1
2
3
4
5
6
7
8
9
docker pull (daemon global proxy):

Registry --[proxy?]--> Docker daemon --> local storage
(daemon sends requests)

skopeo copy (env var proxy):

Registry --[HTTPS_PROXY]--> skopeo --[unix socket]--> Docker daemon --> local storage
(skopeo sends requests) (Engine API)

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
2
3
4
5
6
7
# docker pull
$ docker inspect --format '{{json .RepoDigests}}' jellyfin/jellyfin:latest
["jellyfin/jellyfin@sha256:abc123..."] # <-- has receipt

# skopeo copy
$ docker inspect --format '{{json .RepoDigests}}' jellyfin/jellyfin:latest
[] # <-- empty!

回忆一下前面讲的更新检测方案:比较本地 RepoDigests 和远端 HEAD 请求返回的 digest。如果 RepoDigests 为空,每次检查都会误判为「需要更新」,导致反复拉取。

6.4 解决方案:本地 digest cache·

既然 Docker daemon 不帮你记录,那就自己记。在 skopeo pull 或 ``skopeo copy` 成功后,将远端 digest 缓存到本地文件:

1
2
3
4
{
"jellyfin/jellyfin:latest": "sha256:abc123...",
"library/ubuntu:20.04": "sha256:8feb4d..."
}

查询本地 digest 时的三步逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Step 1: docker image inspect <ref>
|
+-- fail (image not found) --> return "not found", skip cache
+-- success (image exists) --> continue
|
Step 2: docker inspect --format '{{json .RepoDigests}}' <ref>
|
+-- non-empty array --> extract digest, return
+-- empty array [] --> continue
|
Step 3: look up digest_cache.json
|
+-- hit --> return cached digest
+-- miss --> return "no digest" (triggers re-pull)

注意 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
2
Registry path:    docker://library/ubuntu:20.04
Docker local ref: ubuntu:20.04

如果 skopeo copy 的 destination 写成 docker-daemon:library/ubuntu:20.04,Docker 会把它存为一个名叫 library/ubuntu 的「新」镜像,而 docker inspect ubuntu:20.04 根本找不到它。正确写法:

1
2
3
4
5
# correct -- strip library/ prefix in destination
skopeo copy docker://library/ubuntu:20.04 docker-daemon:ubuntu:20.04

# wrong -- creates a separate image named library/ubuntu
skopeo copy docker://library/ubuntu:20.04 docker-daemon:library/ubuntu:20.04

这个坑在自动化脚本中尤其容易踩到:配置文件里写的 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
2
3
4
5
6
7
8
# SOCKS5 proxy -- may fail
REGISTRY_PROXY=socks5://192.168.88.103:1080 renoimg
# SOCKSHTTPSConnectionPool: Max retries exceeded
# (when auth.docker.io resolves to IPv6)

# HTTP proxy -- works reliably
REGISTRY_PROXY=http://192.168.88.103:1081 renoimg
# OK

7.2 为什么选择 HTTP 代理·

HTTP 代理不存在上述问题。这是因为,两种代理协议在处理目标地址时的行为完全不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
SOCKS5 (PySocks implementation):

Client resolves DNS locally
--> gets IPv6 address
--> PySocks tries to handle IPv6
--> fails (not supported)

HTTP CONNECT proxy:

Client sends: CONNECT auth.docker.io:443
--> proxy server resolves DNS
--> proxy server connects (IPv4 or IPv6)
--> client doesn't care about address family

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
2
3
4
session = requests.Session()
session.trust_env = False # critical
if proxy:
session.proxies = {"http": proxy, "https": proxy}

trust_env=False 阻止 requests 自动读取环境变量中的 HTTP_PROXYHTTPS_PROXYALL_PROXY 等。为什么这是必要的?

  • 服务器环境可能设置了全局的 ALL_PROXY(指向一个 SOCKS5 代理)
  • 我们需要精确控制使用哪个代理(从配置文件或特定环境变量读取)
  • 如果不关闭 trust_envrequests 可能会走环境变量中那个不支持 IPv6 的 SOCKS5 代理,然后你就会看到上一节描述的那个错误

先关掉自动检测,再手动指定,这是防御性编程的标准做法。

9. 为什么不做 token 缓存·

DockerHub 的认证 token 有效期通常为 300 秒。每次检查镜像都要重新获取 token,看起来很浪费。能不能缓存 token,在有效期内复用?

算一笔账:

1
2
3
4
5
6
7
8
9
Scenario: 33 images, ~25 on DockerHub, ~8 on GHCR

Without token cache:
33 token requests + 33 HEAD requests = 66 HTTP requests

With token cache (per-registry):
2 token requests + 33 HEAD requests = 35 HTTP requests

Savings: ~31 lightweight HTTP requests, ~2-3 seconds total

省下来的只是 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 解析推给代理服务器,更可靠。

理解了这些底层机制,你就能在容器镜像管理的自动化场景中做出更准确的工程决策,而不是靠猜测和试错。