很多人使用 Docker 很久,却不太清楚镜像到底是怎么构建出来的。manifest、config、layer 这些概念听起来很抽象,SHA256 计算更是像黑盒。
本文通过一个实际的 Docker 镜像构建案例,带你一步步拆解镜像内部结构,验证 SHA256 的计算方式,让你彻底理解 Docker 镜像的构建原理。
先创建一个包含多个指令的 Dockerfile,这样能看到多个 layer 的生成过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
FROM alpine:3.18
# 第一层:安装基础工具
RUN apk add --no-cache curl jq
# 第二层:创建应用目录
WORKDIR /app
# 第三层:复制应用文件
RUN echo '#!/bin/sh' > /app/start.sh && \
echo 'echo "Hello from Docker"' >> /app/start.sh && \
echo 'echo "Container started at: $(date)"' >> /app/start.sh && \
chmod +x /app/start.sh
# 第四层:设置环境变量
ENV APP_VERSION=1.0.0
ENV APP_NAME=demo-app
# 第五层:设置启动命令
CMD ["/app/start.sh"]
|
构建镜像:
1
2
|
# 禁用 BuildKit 以看到传统的构建过程
DOCKER_BUILDKIT=0 docker build --no-cache -t demo-app:1.0 .
|
构建过程输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
Sending build context to Docker daemon 2.048kB
Step 1/7 : FROM alpine:3.18
---> de2b9975f8fd
Step 2/7 : RUN apk add --no-cache curl jq
---> Running in 8798ebf75a3f
...
---> 6888b70c8d5f
Step 3/7 : WORKDIR /app
---> Running in 2c814f8bfbdf
---> 4bb06c8ad8b4
Step 4/7 : RUN echo '#!/bin/sh' > /app/start.sh && ...
---> Running in 95a96f3ef551
---> f38fbafae081
Step 5/7 : ENV APP_VERSION=1.0.0
---> fb3c4c20e49a
Step 6/7 : ENV APP_NAME=demo-app
---> 1909f3861474
Step 7/7 : CMD ["/app/start.sh"]
---> 43ca17c11025
Successfully built 43ca17c11025
Successfully tagged demo-app:1.0
|
注意到每个 step 都生成了一个镜像 ID(如 de2b9975f8fd、6888b70c8d5f 等),这些就是每一层的结果。
使用 docker history 查看每一层的详细信息:
1
|
docker history demo-app:1.0 --no-trunc
|
输出:
1
2
3
4
5
6
7
8
9
|
IMAGE CREATED CREATED BY SIZE COMMENT
sha256:43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426 6 seconds ago /bin/sh -c #(nop) CMD ["/app/start.sh"] 0B
sha256:1909f38614741c93a15a80d70dc063a93faad1bc969bf74e83e184f59d47d974 6 seconds ago /bin/sh -c #(nop) ENV APP_NAME=demo-app 0B
sha256:fb3c4c20e49ae6440badb18d1b820b43e6ab18ac80ed1d26510974c3a4a431f6 6 seconds ago /bin/sh -c #(nop) ENV APP_VERSION=1.0.0 0B
sha256:f38fbafae081301da86a3e443e1709b9572f19f1fbe94eedddc90544f1e43d98 7 seconds ago /bin/sh -c echo '#!/bin/sh' > /app/start.sh && ... 2.81kB
sha256:4bb06c8ad8b438583c22f01add8a53474c9659a52807655604dff84ddd3f66f4 7 seconds ago /bin/sh -c #(nop) WORKDIR /app 0B
sha256:6888b70c8d5f3b9ab23b065ebaf4c43db726812c8350e4f0c7901433263c37d3 7 seconds ago /bin/sh -c apk add --no-cache curl jq 6.11MB
sha256:de2b9975f8fd4ab0d5ea39f52592791fadff62c0592a6e7db5640dc0d6469a01 13 months ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 13 months ago ADD alpine-minirootfs-3.18.12-aarch64.tar.gz / # buildkit 7.67MB buildkit.dockerfile.v0
|
关键信息:
- 每一列的 IMAGE 列就是该层的 SHA256(完整版本)
- SIZE 显示这一层的大小(0B 表示只修改元数据,不产生文件层)
#(nop) 表示这是一个元数据操作,不执行 shell 命令
empty_layer: true 的层(如 ENV、CMD)不会产生新的文件层,只会更新 config
Docker 镜像本质上是一组文件,可以用 docker save 导出:
1
|
docker save -o demo-app.tar demo-app:1.0
|
解压查看:
1
2
3
|
mkdir -p demo-extract && cd demo-extract
tar -xf ../demo-app.tar
ls -la
|
输出:
1
2
3
4
5
6
7
8
|
total 16
drwxr-xr-x 7 jimyag wheel 224 3月 28 21:43 .
drwxrwxrwt 47 root wheel 1504 3月 28 21:43 ..
drwxr-xr-x 3 jimyag wheel 96 3月 28 21:43 blobs
-rw-r--r-- 1 jimyag wheel 358 1月 1 1970 index.json
-rw-r--r-- 1 jimyag wheel 1371 1月 1 1970 manifest.json
-rw-r--r-- 1 jimyag wheel 31 1月 1 1970 oci-layout
-rw-r--r-- 1 jimyag wheel 88 1月 1 1970 repositories
|
镜像的结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
demo-extract/
├── blobs/
│ └── sha256/
│ ├── 43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426 (Config)
│ ├── fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc (OCI Manifest)
│ ├── 171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544 (Layer 1: Alpine 基础)
│ ├── 301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166 (Layer 2: curl/jq)
│ ├── ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58 (Layer 3: WORKDIR)
│ └── defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36 (Layer 4: start.sh)
├── index.json (OCI 格式的索引)
├── manifest.json (Docker 格式的 manifest)
├── oci-layout (OCI 布局标记)
└── repositories (仓库标签信息)
|
Manifest 是镜像的"目录",记录了镜像包含哪些层和配置文件。
1
|
cat manifest.json | jq .
|
输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
[
{
"Config": "blobs/sha256/43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426",
"RepoTags": [
"demo-app:1.0"
],
"Layers": [
"blobs/sha256/171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544",
"blobs/sha256/301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166",
"blobs/sha256/ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58",
"blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36"
]
}
]
|
关键字段:
Config: 指向配置文件的路径
RepoTags: 镜像的标签
Layers: 所有文件层的路径(按顺序从底到顶)
输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc",
"size": 854,
"annotations": {
"io.containerd.image.name": "docker.io/library/demo-app:1.0",
"org.opencontainers.image.ref.name": "1.0"
}
}
]
}
|
OCI 格式使用 index.json 指向实际的 manifest 文件(fe19fc8e...)。
1
|
cat blobs/sha256/fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc | jq .
|
输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426",
"size": 2666
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544",
"size": 7956992
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166",
"size": 6397952
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58",
"size": 1536
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36",
"size": 7680
}
]
}
|
Manifest 的核心作用:
- 引用 config 文件(通过 digest)
- 引用所有 layer(通过 digest)
- 记录每个文件的大小和媒体类型
Config 文件记录了镜像的所有配置信息和构建历史。
1
|
cat blobs/sha256/43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426 | jq .
|
输出(关键部分):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
{
"architecture": "arm64",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"APP_VERSION=1.0.0",
"APP_NAME=demo-app"
],
"Cmd": [
"/app/start.sh"
],
"WorkingDir": "/app"
},
"created": "2026-03-28T13:43:51.519899657Z",
"docker_version": "28.5.2",
"history": [
{
"created": "2025-02-14T03:03:06Z",
"created_by": "ADD alpine-minirootfs-3.18.12-aarch64.tar.gz / # buildkit",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2025-02-14T03:03:06Z",
"created_by": "CMD [\"/bin/sh\"]",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
},
{
"created": "2026-03-28T13:43:50.329678511Z",
"created_by": "/bin/sh -c apk add --no-cache curl jq"
},
{
"created": "2026-03-28T13:43:50.467841313Z",
"created_by": "/bin/sh -c #(nop) WORKDIR /app"
},
{
"created": "2026-03-28T13:43:50.862205766Z",
"created_by": "/bin/sh -c echo '#!/bin/sh' > /app/start.sh && ..."
},
{
"created": "2026-03-28T13:43:51.098903795Z",
"created_by": "/bin/sh -c #(nop) ENV APP_VERSION=1.0.0",
"empty_layer": true
},
{
"created": "2026-03-28T13:43:51.384462712Z",
"created_by": "/bin/sh -c #(nop) ENV APP_NAME=demo-app",
"empty_layer": true
},
{
"created": "2026-03-28T13:43:51.519899657Z",
"created_by": "/bin/sh -c #(nop) CMD [\"/app/start.sh\"]",
"empty_layer": true
}
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544",
"sha256:301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166",
"sha256:ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58",
"sha256:defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36"
]
}
}
|
Config 的关键内容:
- config 字段:运行时配置(Env, Cmd, WorkingDir 等)
- history 字段:构建历史,记录每一步的命令和时间
empty_layer: true 表示该步骤不产生文件层(如 ENV、CMD)
- rootfs.diff_ids:所有文件层的未压缩 SHA256
- 注意:这是未压缩的 tar 文件的 SHA256
- 与 manifest 中的 digest(压缩后的 SHA256)不同
Layer 是实际的文件系统层,每个 layer 是一个 tar 包,包含文件系统的变更。
输出:
1
2
3
4
|
-rw-r--r-- 1 jimyag wheel 7.6M 171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544 (Alpine 基础)
-rw-r--r-- 1 jimyag wheel 6.1M 301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166 (curl/jq)
-rw-r--r-- 1 jimyag wheel 1.5K ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58 (WORKDIR)
-rw-r--r-- 1 jimyag wheel 7.5K defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36 (start.sh)
|
1
|
file blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36
|
输出:
1
|
blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36: POSIX tar archive
|
可以看到 layer 是未压缩的 POSIX tar 包。
注意:这是 docker save 导出的本地镜像格式。实际推送到 Registry(如 Docker Hub)时,layer 会被压缩成 gzip 格式。这是理解 diff_ids 和 digest 关系的关键。
1
|
tar -tvf blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36
|
输出:
1
2
3
4
5
6
|
drwxr-xr-x 0 0 0 0 3月 28 21:43 app/
-rwxr-xr-x 0 0 0 72 3月 28 21:43 app/start.sh
drwxr-xr-x 0 0 0 0 3月 28 21:43 etc/
drwxr-xr-x 0 0 0 0 2月 14 2025 etc/ssl/
drwxr-xr-x 0 0 0 0 3月 28 21:43 etc/ssl/certs/
-rw-r--r-- 0 0 0 2739 3月 28 21:43 etc/ssl/certs/orbstack-root.crt
|
这个 layer 包含:
app/start.sh - 我们的启动脚本
etc/ssl/certs/orbstack-root.crt - OrbStack 添加的根证书
可以直接从 tar 包中提取单个文件:
1
|
tar -xOf blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36 app/start.sh
|
输出:
1
2
3
|
#!/bin/sh
echo "Hello from Docker"
echo "Container started at: $(date)"
|
这就是我们在 Dockerfile 中创建的启动脚本。
现在来验证 SHA256 是如何计算的。关键原则:文件名就是文件内容的 SHA256。
1
|
sha256sum < blobs/sha256/43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426
|
输出:
1
|
43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426 -
|
确实,文件名就是内容的 SHA256。
1
|
sha256sum < blobs/sha256/fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc
|
输出:
1
|
fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc -
|
同样正确。
1
|
sha256sum < blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36
|
输出:
1
|
defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36 -
|
完美匹配!
这是容易混淆的概念:
- diff_ids:在 Config 文件中,是未压缩的 tar 文件的 SHA256
- digest:在 Manifest 中,通常是压缩后的 tar.gz 文件的 SHA256
但在我们的案例中,所有 layer 都是未压缩的 tar 包,所以 diff_ids 和 digest 完全相同:
Config 中的 diff_ids:
1
2
3
4
5
6
|
"diff_ids": [
"sha256:171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544",
"sha256:301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166",
"sha256:ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58",
"sha256:defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36"
]
|
Manifest 中的 digest:
1
2
3
4
5
6
|
"layers": [
{"digest": "sha256:171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544"},
{"digest": "sha256:301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166"},
{"digest": "sha256:ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58"},
{"digest": "sha256:defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36"}
]
|
如果 layer 被压缩(gzip),那么:
- digest = SHA256(layer.tar.gz)
- diff_ids = SHA256(layer.tar)
- digest ≠ diff_ids
理解了 diff_ids 和 digest 的区别后,一个关键问题是:什么时候会压缩?
本地 Docker 存储中,layer 通常不压缩:
1
2
3
4
5
|
/var/lib/docker/overlay2/
├── <layer-id>/
│ └── diff/ ← 解压后的文件系统
└── l/
└── <symlinks>
|
原因:
- 直接挂载使用,启动容器快
- OverlayFS 支持直接挂载目录
这就是为什么我的演示中看到的是未压缩的 tar 包,且 diff_ids 和 digest 相同。
推送到 Registry 时,layer 会压缩成 gzip:
1
2
3
4
5
6
|
# 推送镜像时
docker push your-registry/demo-app:1.0
# 实际上传的文件:
# layer.tar.gz(压缩后的 tar 包)
# manifest.json(引用压缩后的 digest)
|
此时 Manifest 中的 digest 会变化:
1
2
3
4
5
6
7
8
9
|
{
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:abc123...", ← 压缩后的 SHA256
"size": 1234567
}
]
}
|
而 Config 中的 diff_ids 保持不变(因为它是未压缩 tar 的 SHA256):
1
2
3
4
5
6
7
|
{
"rootfs": {
"diff_ids": [
"sha256:def456..." ← 未压缩的 SHA256
]
}
}
|
可以通过 docker manifest inspect 查看远程镜像的压缩情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# 查看远程镜像的 manifest
docker manifest inspect alpine:latest
# 输出示例:
{
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 2811981,
"digest": "sha256:4f4fb700ef..."
}
]
}
|
注意 mediaType 是 tar.gzip,说明 Registry 存储时是压缩的。
-
双重校验:
- Registry 用 digest 验证压缩文件的完整性
- Docker 用 diff_ids 验证解压后文件的完整性
-
性能优化:
- 本地存储不压缩 → 快速挂载启动
- Registry 存储压缩 → 节省存储和网络带宽
-
去重能力:
- 相同内容的 tar 包 → 相同的 diff_ids
- 即使压缩方式不同,也能识别内容相同的层
上面从 docker save 导出的本地文件解释了 manifest、config、layer 的关系。换到 Registry 视角,它们仍然是同一套对象,只是通过 OCI Distribution Spec 定义的 HTTP API 分发。
Registry 本质上可以理解为一个带索引的内容寻址 blob store:
- layer 是 blob,通常是压缩后的
tar.gz,用压缩后内容的 digest 寻址。
- config 也是 blob,用 config JSON 内容的 digest 寻址。
- manifest 是 JSON 文档,里面引用 config digest 和所有 layer digest。
- tag 不是镜像本身,只是指向某个 manifest 的可读名字,例如
alpine:latest。
- 多架构镜像多一层 image index(也叫 manifest list),index 再指向不同平台的 manifest。
整体关系可以画成这样:
flowchart LR
Client["Docker / containerd / skopeo / crane"]
subgraph API["Registry API"]
Upload["POST /v2/<repo>/blobs/uploads/"]
Blob["GET/DELETE /v2/<repo>/blobs/<digest>"]
ManifestAPI["PUT/GET/DELETE /v2/<repo>/manifests/<tag|digest>"]
Tags["GET /v2/<repo>/tags/list"]
end
subgraph Registry["Container Registry"]
TagMap["tag -> manifest digest\nlatest, v1, debug"]
Index["Image Index\nmulti-platform entry"]
AmdManifest["Manifest\nlinux/amd64"]
ArmManifest["Manifest\nlinux/arm64"]
SingleManifest["Manifest\nsingle-platform"]
AmdConfig["Config blob"]
ArmConfig["Config blob"]
SingleConfig["Config blob"]
AmdLayer["Filesystem layer blob"]
ArmLayer["Filesystem layer blob"]
SingleLayer["Filesystem layer blob"]
end
Client --> Upload
Client --> Blob
Client --> ManifestAPI
Client --> Tags
Upload --> AmdLayer
Blob --> AmdLayer
Blob --> ArmLayer
Blob --> SingleLayer
ManifestAPI --> TagMap
Tags --> TagMap
TagMap --> Index
TagMap --> SingleManifest
Index --> AmdManifest
Index --> ArmManifest
AmdManifest --> AmdConfig
AmdManifest --> AmdLayer
ArmManifest --> ArmConfig
ArmManifest --> ArmLayer
SingleManifest --> SingleConfig
SingleManifest --> SingleLayer
在这个图里,Registry API 操作的对象只有几类:blob、manifest、tag 列表。多平台镜像并没有引入一套新的 API,而是让某个 tag 先指向 image index,再由 index 指向不同平台的 manifest。
再把单平台镜像拆开看,config、manifest、layer 的哈希关系是:
flowchart LR
subgraph Layers["Image Layers"]
L0["Layer 0\nuncompressed tar"]
L1["Layer 1\nuncompressed tar"]
L2["Layer 2\nuncompressed tar"]
end
subgraph Config["Config JSON"]
Platform["architecture / os"]
DiffIDs["rootfs.diff_ids\nsha256(uncompressed tar)"]
Runtime["Cmd / Env / User / WorkingDir"]
end
subgraph Manifest["Manifest JSON"]
ConfigDesc["config descriptor\nmediaType + digest + size"]
LayerDesc["layer descriptors\nmediaType + digest + size"]
end
L0 -->|"sha256(tar)"| DiffIDs
L1 -->|"sha256(tar)"| DiffIDs
L2 -->|"sha256(tar)"| DiffIDs
Config -->|"sha256(config JSON)\nImage ID"| ConfigDesc
L0 -->|"usually sha256(tar.gz)"| LayerDesc
L1 -->|"usually sha256(tar.gz)"| LayerDesc
L2 -->|"usually sha256(tar.gz)"| LayerDesc
ConfigDesc --> ManifestDigest["sha256(manifest JSON)\nImage digest for single-platform image"]
LayerDesc --> ManifestDigest
这里最容易混淆的是两组 digest:
rootfs.diff_ids 指向未压缩 layer tar 的 SHA256。
manifest.layers[].digest 通常指向推送到 Registry 的压缩 blob,例如 tar.gz 的 SHA256。
推送镜像时,客户端大致做这些事:
- 计算每个 layer blob 的 digest。
- 上传 layer blob。
- 上传 config blob。
- 生成 manifest,把 config 和 layer 的 digest 串起来。
- 把 manifest 以某个 tag 发布到仓库。
所以 docker push 不是“上传一个镜像文件”,而是先上传一批可按 digest 寻址的 blob,最后上传一个 manifest 作为入口。相同内容的 layer 在 Registry 里可以复用,因为它们的 digest 一样。
拉取镜像时顺序反过来:
- 根据仓库名和 tag 获取 manifest。
- 如果返回的是 image index,根据本机平台选择对应的 manifest。
- 根据 manifest 里的 digest 下载 config 和 layer blob。
- 校验 digest。
- 解压 layer,交给本地存储驱动或 snapshotter 组装为容器可用的 rootfs。
这也解释了为什么同一个 tag 在不同机器上可能拉到不同平台的镜像:tag 可能先指向 image index,真正的单平台 manifest 由客户端根据 os/architecture 选择。
多平台镜像可以理解为多包了一层 index:
flowchart LR
Tag["tag\nexample: latest"] --> Index["Image Index\napplication/vnd.oci.image.index.v1+json"]
subgraph IndexDoc["index.manifests[]"]
AmdDesc["descriptor\nplatform: linux/amd64\ndigest: sha256:aaa..."]
ArmDesc["descriptor\nplatform: linux/arm64\ndigest: sha256:bbb..."]
AttestDesc["descriptor\nplatform: unknown/unknown\nartifact / provenance"]
end
Index --> AmdDesc
Index --> ArmDesc
Index --> AttestDesc
AmdDesc --> AmdManifest["Manifest\nlinux/amd64"]
ArmDesc --> ArmManifest["Manifest\nlinux/arm64"]
AttestDesc --> ArtifactManifest["Manifest\nartifact or provenance"]
AmdManifest --> AmdConfig["Config"]
AmdManifest --> AmdLayers["Layer blobs"]
ArmManifest --> ArmConfig["Config"]
ArmManifest --> ArmLayers["Layer blobs"]
ArtifactManifest --> Artifact["Artifact blob"]
客户端拿到 index 后,会按平台选择对应 descriptor,再继续按单平台镜像的方式拉取 manifest、config 和 layer。构建工具附带的 provenance、SBOM 等 artifact 也可能通过额外的 manifest descriptor 挂在同一个 index 旁边。
Registry 里的 tag、manifest、blob 是不同层次的对象。删除 tag 通常只是删除“名字到 manifest”的引用,不等于立刻删除 layer 数据。即使某个 manifest 不再有 tag,底层 blob 也可能仍被别的 manifest 引用;如果直接删 blob,可能破坏复用同一层的其他镜像。
因此排查“为什么删除镜像后空间没释放”时,要区分三件事:
- tag 是否还存在;
- manifest 是否还能通过 digest 访问;
- blob 是否仍被其他 manifest 引用,以及 Registry 是否启用了垃圾回收。
总结 Docker 镜像 SHA256 的计算流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
1. 构建每一层(RUN、COPY 等)
↓
2. 将文件系统变更打包成 tar 包
↓
3. 计算 tar 包的 SHA256
- 如果未压缩:digest = SHA256(tar)
- 如果压缩:digest = SHA256(tar.gz), diff_ids = SHA256(tar)
↓
4. 生成 Config JSON
- 包含所有 diff_ids
- 包含构建历史
↓
5. 计算 Config JSON 的 SHA256
↓
6. 生成 Manifest JSON
- 引用 config digest
- 引用所有 layer digest
↓
7. 计算 Manifest JSON 的 SHA256(用于 OCI 格式)
|
日常工作中,可以使用这些命令快速查看镜像信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# 查看镜像历史
docker history <image> --no-trunc
# 查看镜像详细信息
docker image inspect <image>
# 查看镜像 manifest(需要启用实验性功能)
docker manifest inspect <image>
# 导出镜像并查看结构
docker save -o image.tar <image>
tar -tf image.tar | head -20
# 使用 skopeo 查看远程镜像 manifest(需安装 skopeo)
skopeo inspect docker://alpine:latest --raw | jq .
|
通过这个实际案例,我们验证了:
- 文件名即 SHA256:每个文件(config、manifest、layer)的文件名都是其内容的 SHA256
- Layer 是 tar 包:每个 layer 是一个 POSIX tar archive,包含文件系统的变更
- Manifest 是目录:Manifest 记录了镜像包含的所有文件及其 digest
- Config 是配置中心:Config 包含运行时配置、构建历史和 diff_ids
- diff_ids vs digest:未压缩时两者相同,压缩时 digest 是压缩后的 SHA256
- Registry 是内容寻址存储:push/pull 的核心是上传、下载 manifest 引用的 config 和 layer blob
理解这些原理后,Docker 镜像不再是黑盒,你可以:
- 手动验证镜像的完整性
- 排查镜像构建问题
- 优化镜像大小(减少层数、合并层)
- 理解镜像分发和缓存机制
下次看到镜像的 SHA256 时,你就知道它是怎么计算出来的了。