
很多人使用 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
   - 即使压缩方式不同，也能识别内容相同的层

## Registry 视角：push 和 pull 到底在传什么

上面从 `docker save` 导出的本地文件解释了 manifest、config、layer 的关系。换到 Registry 视角，它们仍然是同一套对象，只是通过 [OCI Distribution Spec](https://github.com/opencontainers/distribution-spec/blob/main/spec.md) 定义的 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。

整体关系可以画成这样：

```mermaid
flowchart LR
    Client["Docker / containerd / skopeo / crane"]

    subgraph API["Registry API"]
        Upload["POST /v2/&lt;repo&gt;/blobs/uploads/"]
        Blob["GET/DELETE /v2/&lt;repo&gt;/blobs/&lt;digest&gt;"]
        ManifestAPI["PUT/GET/DELETE /v2/&lt;repo&gt;/manifests/&lt;tag|digest&gt;"]
        Tags["GET /v2/&lt;repo&gt;/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 的哈希关系是：

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

### push 镜像的过程

推送镜像时，客户端大致做这些事：

1. 计算每个 layer blob 的 digest。
2. 上传 layer blob。
3. 上传 config blob。
4. 生成 manifest，把 config 和 layer 的 digest 串起来。
5. 把 manifest 以某个 tag 发布到仓库。

所以 `docker push` 不是“上传一个镜像文件”，而是先上传一批可按 digest 寻址的 blob，最后上传一个 manifest 作为入口。相同内容的 layer 在 Registry 里可以复用，因为它们的 digest 一样。

### pull 镜像的过程

拉取镜像时顺序反过来：

1. 根据仓库名和 tag 获取 manifest。
2. 如果返回的是 image index，根据本机平台选择对应的 manifest。
3. 根据 manifest 里的 digest 下载 config 和 layer blob。
4. 校验 digest。
5. 解压 layer，交给本地存储驱动或 snapshotter 组装为容器可用的 rootfs。

这也解释了为什么同一个 tag 在不同机器上可能拉到不同平台的镜像：tag 可能先指向 image index，真正的单平台 manifest 由客户端根据 `os/architecture` 选择。

多平台镜像可以理解为多包了一层 index：

```mermaid
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 是否启用了垃圾回收。

## 完整的 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
6. **Registry 是内容寻址存储**：push/pull 的核心是上传、下载 manifest 引用的 config 和 layer blob

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

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

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

## 参考资料

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

