很多人使用 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 镜像 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
理解这些原理后,Docker 镜像不再是黑盒,你可以:
- 手动验证镜像的完整性
- 排查镜像构建问题
- 优化镜像大小(减少层数、合并层)
- 理解镜像分发和缓存机制
下次看到镜像的 SHA256 时,你就知道它是怎么计算出来的了。