
很多人使用 Docker 很久，却不太清楚镜像到底是怎么构建出来的。`manifest`、`config`、`layer` 这些概念听起来很抽象，`SHA256` 计算更是像黑盒。

本文通过一个实际的 Docker 镜像构建案例，带你一步步拆解镜像内部结构，验证 SHA256 的计算方式，让你彻底理解 Docker 镜像的构建原理。

## 准备一个演示镜像

先创建一个包含多个指令的 Dockerfile，这样能看到多个 layer 的生成过程：

```dockerfile
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"]
```

构建镜像：

```bash
# 禁用 BuildKit 以看到传统的构建过程
DOCKER_BUILDKIT=0 docker build --no-cache -t demo-app:1.0 .
```

构建过程输出：

```
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` 查看每一层的详细信息：

```bash
docker history demo-app:1.0 --no-trunc
```

输出：

```
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` 导出：

```bash
docker save -o demo-app.tar demo-app:1.0
```

解压查看：

```bash
mkdir -p demo-extract && cd demo-extract
tar -xf ../demo-app.tar
ls -la
```

输出：

```
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
```

镜像的结构：

```
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

Manifest 是镜像的"目录"，记录了镜像包含哪些层和配置文件。

### Docker 格式的 manifest.json

```bash
cat manifest.json | jq .
```

输出：

```json
[
  {
    "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`: 所有文件层的路径（按顺序从底到顶）

### OCI 格式的 index.json

```bash
cat index.json | jq .
```

输出：

```json
{
  "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...`）。

### OCI Manifest 文件

```bash
cat blobs/sha256/fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc | jq .
```

输出：

```json
{
  "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 的核心作用：

1. 引用 config 文件（通过 digest）
2. 引用所有 layer（通过 digest）
3. 记录每个文件的大小和媒体类型

## 理解 Config

Config 文件记录了镜像的所有配置信息和构建历史。

```bash
cat blobs/sha256/43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426 | jq .
```

输出（关键部分）：

```json
{
  "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 的关键内容：

1. **config 字段**：运行时配置（Env, Cmd, WorkingDir 等）
2. **history 字段**：构建历史，记录每一步的命令和时间
   - `empty_layer: true` 表示该步骤不产生文件层（如 ENV、CMD）
3. **rootfs.diff_ids**：所有文件层的未压缩 SHA256
   - 注意：这是未压缩的 tar 文件的 SHA256
   - 与 manifest 中的 digest（压缩后的 SHA256）不同

## 理解 Layer

Layer 是实际的文件系统层，每个 layer 是一个 tar 包，包含文件系统的变更。

### 查看 Layer 的类型和大小

```bash
ls -lh blobs/sha256/
```

输出：

```
-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)
```

### 查看 Layer 的文件类型

```bash
file blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36
```

输出：

```
blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36: POSIX tar archive
```

可以看到 layer 是未压缩的 POSIX tar 包。

注意：这是 `docker save` 导出的本地镜像格式。实际推送到 Registry（如 Docker Hub）时，layer 会被压缩成 gzip 格式。这是理解 diff_ids 和 digest 关系的关键。

### 查看 Layer 的内容

```bash
tar -tvf blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36
```

输出：

```
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 添加的根证书

### 提取 Layer 中的文件

可以直接从 tar 包中提取单个文件：

```bash
tar -xOf blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36 app/start.sh
```

输出：

```bash
#!/bin/sh
echo "Hello from Docker"
echo "Container started at: $(date)"
```

这就是我们在 Dockerfile 中创建的启动脚本。

## SHA256 计算验证

现在来验证 SHA256 是如何计算的。关键原则：**文件名就是文件内容的 SHA256**。

### 验证 Config 文件的 SHA256

```bash
sha256sum < blobs/sha256/43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426
```

输出：

```
43ca17c1102512f884b06414b71609902362c57b19e6642fc2e32ffdf7ea5426  -
```

确实，文件名就是内容的 SHA256。

### 验证 OCI Manifest 的 SHA256

```bash
sha256sum < blobs/sha256/fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc
```

输出：

```
fe19fc8e7d7fe23e67d7b1d6b1b66897eda70c2125e16684bfce26328a4faedc  -
```

同样正确。

### 验证 Layer 的 SHA256

```bash
sha256sum < blobs/sha256/defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36
```

输出：

```
defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36  -
```

完美匹配！

## diff_ids vs digest

这是容易混淆的概念：

- **diff_ids**：在 Config 文件中，是未压缩的 tar 文件的 SHA256
- **digest**：在 Manifest 中，通常是压缩后的 tar.gz 文件的 SHA256

但在我们的案例中，所有 layer 都是未压缩的 tar 包，所以 diff_ids 和 digest 完全相同：

Config 中的 diff_ids：

```json
"diff_ids": [
  "sha256:171a26c7bc56cc6ba67549042b24db9f5bc7cc7d4f195e8f03aaf58e956b2544",
  "sha256:301f53f63f5929afbde5311a7ab0b9c9653f5236ada0811da4abaa8976b7d166",
  "sha256:ced34a41d9af2b41a826a35f57a23eddd3b8e1df58eff40208c22e931dc69b58",
  "sha256:defc394ea218775f58eea09fa8bc8c89b9c27ffa614710cb47982ddf4e446b36"
]
```

Manifest 中的 digest：

```json
"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

## 本地存储 vs Registry 存储

理解了 diff_ids 和 digest 的区别后，一个关键问题是：**什么时候会压缩？**

### 本地存储（Docker daemon）

本地 Docker 存储中，layer 通常**不压缩**：

```
/var/lib/docker/overlay2/
├── <layer-id>/
│   └── diff/          ← 解压后的文件系统
└── l/
    └── <symlinks>
```

原因：
- 直接挂载使用，启动容器快
- OverlayFS 支持直接挂载目录

这就是为什么我的演示中看到的是未压缩的 tar 包，且 diff_ids 和 digest 相同。

### Registry 存储（Docker Hub 等）

推送到 Registry 时，layer 会**压缩成 gzip**：

```bash
# 推送镜像时
docker push your-registry/demo-app:1.0

# 实际上传的文件：
# layer.tar.gz（压缩后的 tar 包）
# manifest.json（引用压缩后的 digest）
```

此时 Manifest 中的 digest 会变化：

```json
{
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:abc123...",  ← 压缩后的 SHA256
      "size": 1234567
    }
  ]
}
```

而 Config 中的 diff_ids 保持不变（因为它是未压缩 tar 的 SHA256）：

```json
{
  "rootfs": {
    "diff_ids": [
      "sha256:def456..."  ← 未压缩的 SHA256
    ]
  }
}
```

### 实际验证方法

可以通过 `docker manifest inspect` 查看远程镜像的压缩情况：

```bash
# 查看远程镜像的 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 存储时是压缩的。

### 为什么要这样设计？

1. **双重校验**：
   - Registry 用 digest 验证压缩文件的完整性
   - Docker 用 diff_ids 验证解压后文件的完整性
   
2. **性能优化**：
   - 本地存储不压缩 → 快速挂载启动
   - Registry 存储压缩 → 节省存储和网络带宽

3. **去重能力**：
   - 相同内容的 tar 包 → 相同的 diff_ids
   - 即使压缩方式不同，也能识别内容相同的层

## 完整的 SHA256 计算流程

总结 Docker 镜像 SHA256 的计算流程：

```
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 格式）
```

## 快速查看镜像信息的命令

日常工作中，可以使用这些命令快速查看镜像信息：

```bash
# 查看镜像历史
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 .
```

## 总结

通过这个实际案例，我们验证了：

1. **文件名即 SHA256**：每个文件（config、manifest、layer）的文件名都是其内容的 SHA256
2. **Layer 是 tar 包**：每个 layer 是一个 POSIX tar archive，包含文件系统的变更
3. **Manifest 是目录**：Manifest 记录了镜像包含的所有文件及其 digest
4. **Config 是配置中心**：Config 包含运行时配置、构建历史和 diff_ids
5. **diff_ids vs digest**：未压缩时两者相同，压缩时 digest 是压缩后的 SHA256

理解这些原理后，Docker 镜像不再是黑盒，你可以：

- 手动验证镜像的完整性
- 排查镜像构建问题
- 优化镜像大小（减少层数、合并层）
- 理解镜像分发和缓存机制

下次看到镜像的 SHA256 时，你就知道它是怎么计算出来的了。

## 参考资料

- OCI Image Spec: https://github.com/opencontainers/image-spec
- Docker Image Spec: https://github.com/moby/docker-image-spec
- Docker Documentation: https://docs.docker.com/build/building/understand-images/

