同一个应用往往需要运行在不同 CPU 架构的机器上。例如,大部分传统服务器和个人电脑使用 amd64,越来越多的云服务器、Apple Silicon 设备以及国产化环境则使用 arm64。

如果只构建一份 amd64 镜像,把它放到 arm64 机器上运行,通常会遇到下面的错误:

1
exec format error

原因是镜像中的可执行文件已经按照特定 CPU 指令集编译。amd64 和 arm64 的指令集不同,容器虽然隔离了文件系统和进程,但仍然共享宿主机内核,并不能让 ARM CPU 直接执行 x86 指令。

解决这个问题的一种方式,是分别构建 amd64 和 arm64 镜像,再使用 Docker Manifest 把它们组合到同一个镜像标签下。最终,使用者只需要执行普通的 docker pull,Docker 就会根据当前机器的操作系统和 CPU 架构自动下载正确的镜像。

什么是双架构镜像

所谓“双架构镜像”,并不是把两套架构的二进制文件硬塞进同一个镜像文件中,而是由以下三部分组成:

  1. 一份只能在 linux/amd64 环境运行的镜像。
  2. 一份只能在 linux/arm64 环境运行的镜像。
  3. 一份 Manifest List,记录统一标签与两份单架构镜像之间的对应关系。

它们之间的关系可以表示为:

1
2
3
4
5
demo-app:20261009_xxx
|
+-- linux/amd64 --> amd64 镜像的 digest
|
+-- linux/arm64 --> arm64 镜像的 digest

因此,双架构能力来自两份真实存在、分别为不同平台构建的镜像。Manifest 本身只负责建立索引,并不会把 amd64 镜像转换成 arm64 镜像,也不会替我们完成跨架构编译。

Manifest 的作用

普通的单架构镜像标签通常指向一份 image manifest。它描述了镜像的配置、文件系统层及其内容摘要。

多架构镜像标签指向的则是 manifest list,在 OCI 规范中也称为 image index。这个列表不直接保存镜像层,而是保存多个子镜像的摘要及平台信息,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"manifests": [
{
"digest": "sha256:amd64-image-digest",
"platform": {
"os": "linux",
"architecture": "amd64"
}
},
{
"digest": "sha256:arm64-image-digest",
"platform": {
"os": "linux",
"architecture": "arm64"
}
}
]
}

当我们在不同机器上执行相同命令时:

1
docker pull iregistry.example.com/team/demo-app:20261009_xxx

Docker 客户端会先读取 manifest list,再识别本机平台:

  • 在 linux/amd64 机器上选择 amd64 子镜像。
  • 在 linux/arm64 机器上选择 arm64 子镜像。

这就是同一个镜像地址能够同时支持两种架构的原因。统一标签屏蔽了底层平台差异,使用者不需要记忆或手动判断 -amd64、-arm64 等不同标签。

流水线脚本

下面的脚本假设前置的两个构建场景已经分别生成并推送了单架构镜像,变量 amd64_image 和 arm64_image 保存它们的完整地址。

仓库用户名和密码应由流水线密钥管理功能注入,不要直接写在脚本或代码仓库中。推荐使用 --password-stdin,避免密码出现在命令参数和日志里。

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
#!/usr/bin/env bash
set -euo pipefail

export DOCKER_CLI_EXPERIMENTAL=enabled

: "${REGISTRY_USERNAME:?REGISTRY_USERNAME is required}"
: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD is required}"
: "${amd64_image:?amd64_image is required}"
: "${arm64_image:?arm64_image is required}"
: "${WORKSPACE:?WORKSPACE is required}"

registry="iregistry.example.com"
namespace="your-namespace"
image_name="demo-app"

printf '%s' "${REGISTRY_PASSWORD}" |
docker login "${registry}" \
--username "${REGISTRY_USERNAME}" \
--password-stdin

current_date=$(date +%Y%m%d)
current_timestamp_ms=$(($(date +%s) * 1000 + 10#$(date +%N) / 1000000))
manifest_image="${registry}/${namespace}/${image_name}:${current_date}_${current_timestamp_ms}"

docker manifest create --insecure \
"${manifest_image}" \
"${amd64_image}" \
"${arm64_image}"

docker manifest annotate \
"${manifest_image}" \
"${amd64_image}" \
--os linux \
--arch amd64

docker manifest annotate \
"${manifest_image}" \
"${arm64_image}" \
--os linux \
--arch arm64

docker manifest push --insecure "${manifest_image}"

manifest_cache_name=$(printf '%s' "${manifest_image}" | sed 's|/|_|g; s|:|-|g')
rm -rf "${HOME}/.docker/manifests/${manifest_cache_name}"*

printf 'AGILE_DOCKER_IMAGE_URL={"%s":"%s"}\n' \
"${image_name}" \
"${manifest_image}" >> "${WORKSPACE}/AGILE_OUT"

echo "multi-arch image: ${manifest_image}"

脚本中的仓库地址和命名空间使用了示例值,实际使用时替换为自己的私有仓库配置即可。

脚本逐段说明

1. 开启 manifest 命令

1
export DOCKER_CLI_EXPERIMENTAL=enabled

早期 Docker 版本把 docker manifest 作为实验特性,因此需要显式开启。较新的 Docker CLI 通常已经默认支持该命令,但保留这个变量有助于兼容部分旧版流水线 Agent。

执行 Manifest 的 Agent 必须安装支持 docker manifest 的 Docker CLI,并且能够访问镜像仓库。它不一定要重新构建镜像,但必须能够读取两个源镜像并推送最终的 manifest list。

2. 接收两个单架构镜像

1
2
: "${amd64_image:?amd64_image is required}"
: "${arm64_image:?arm64_image is required}"

这两个变量必须指向已经推送到仓库的镜像,例如:

1
2
iregistry.example.com/team/demo-app:build-123-amd64
iregistry.example.com/team/demo-app:build-123-arm64

这里最重要的前提是:标签名称不能只写对了,镜像内容也必须真的由对应平台构建。可以在构建阶段使用原生的 amd64、arm64 Agent,也可以使用 Docker Buildx 和 QEMU 进行跨平台构建。

3. 生成唯一标签

1
2
3
current_date=$(date +%Y%m%d)
current_timestamp_ms=$(($(date +%s) * 1000 + 10#$(date +%N) / 1000000))
manifest_image="${registry}/${namespace}/${image_name}:${current_date}_${current_timestamp_ms}"

日期加毫秒时间戳可以降低并发流水线标签冲突的概率。最终可能得到类似下面的地址:

1
iregistry.example.com/team/demo-app:20261009_1791512345678

如果流水线本身提供全局唯一的构建号或 Git Commit SHA,使用这些值通常更直观,也更便于追溯。

4. 创建 manifest list

1
2
3
4
docker manifest create --insecure \
"${manifest_image}" \
"${amd64_image}" \
"${arm64_image}"

这一步在本地创建一个 manifest list,并把两份源镜像加入列表。${manifest_image} 是对外暴露的统一镜像地址,后面两个参数是实际承载应用的单架构镜像。

--insecure 允许 Docker 与不满足默认 TLS 安全要求的仓库通信。只有私有仓库确实需要时才应使用;如果仓库证书配置正常,建议删除该参数。

5. 标注平台信息

1
2
3
4
5
docker manifest annotate "${manifest_image}" "${amd64_image}" \
--os linux --arch amd64

docker manifest annotate "${manifest_image}" "${arm64_image}" \
--os linux --arch arm64

annotate 明确告诉 Docker 每个子镜像对应的平台。拉取镜像时,客户端正是依据这些字段进行匹配。

平台信息必须与镜像真实内容一致。错误地把 amd64 镜像标记为 arm64 并不会改变其中的二进制文件,只会让 ARM 机器选中一份无法执行的镜像。

6. 推送统一入口

1
docker manifest push --insecure "${manifest_image}"

create 和 annotate 主要操作本地 manifest 数据,push 才会把最终索引发布到镜像仓库。推送成功后,其他机器便可以通过统一标签拉取镜像。

需要注意,推送 manifest list 不会重复上传两个子镜像的所有文件层。索引主要通过 digest 引用仓库中已经存在的单架构镜像,因此操作通常很快。

7. 清理本地 manifest 缓存

1
2
manifest_cache_name=$(printf '%s' "${manifest_image}" | sed 's|/|_|g; s|:|-|g')
rm -rf "${HOME}/.docker/manifests/${manifest_cache_name}"*

Docker CLI 会在 ~/.docker/manifests/ 下保存本地 manifest 数据。流水线 Agent 长期复用时,可以在推送成功后清理本次缓存,避免旧数据累积或影响后续同名操作。

8. 输出流水线制品变量

1
2
3
printf 'AGILE_DOCKER_IMAGE_URL={"%s":"%s"}\n' \
"${image_name}" \
"${manifest_image}" >> "${WORKSPACE}/AGILE_OUT"

最终输出的是镜像名到统一多架构镜像地址的映射。下游发布环节只需读取这个地址,不需要关心部署机器到底是 amd64 还是 arm64。

从构建到拉取的完整流程

整个流水线可以拆成四个阶段:

  1. amd64 构建任务生成并推送 linux/amd64 镜像。
  2. arm64 构建任务生成并推送 linux/arm64 镜像。
  3. Manifest 任务引用两份镜像,标注平台并推送统一标签。
  4. 部署机器拉取统一标签,Docker 自动选择匹配当前平台的子镜像。

Manifest 任务通常依赖前两个构建任务全部成功。如果某个架构镜像缺失,最终索引就不完整;即使 manifest 能够发布,对应平台的机器也无法正常拉取和运行。

拉取镜像时如何识别架构

架构识别从 Docker Engine 处理拉取请求时开始。以执行下面的命令为例:

1
docker pull demo-app:latest

Docker CLI 只负责把拉取请求发送给 Docker Engine。真正确定目标平台、解析 Manifest List 并选择子镜像的是 Docker Engine 及其底层容器运行时。

如果命令没有指定 --platform,Engine 会使用自身所在主机的默认平台,例如 linux/amd64 或 linux/arm64。这个平台信息来自 Docker 运行环境,并不是容器启动后由容器内部判断的,也不能通过 Manifest 猜出来。

简要时序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
用户/部署平台             Docker Engine                 镜像仓库
| | |
| docker pull | |
|------------------------>| |
| | 确定目标平台 |
| | linux/arm64 |
| | |
| | 请求 demo-app:latest |
| |-------------------------->|
| | 返回 Manifest List |
| |<--------------------------|
| | |
| | 按 linux/arm64 匹配条目 |
| | 得到 arm64 镜像 digest |
| | |
| | 请求该 digest 的配置和镜像层 |
| |-------------------------->|
| | 返回 arm64 镜像内容 |
| |<--------------------------|
| 拉取完成 | |
|<------------------------| |

整个选择过程可以概括为:

  1. 用户执行 docker pull,Docker CLI 将镜像地址和可选的 --platform 参数交给 Docker Engine。
  2. Engine 确定目标平台。显式传入 --platform 时使用指定值,否则使用 Engine 所在主机的默认平台。
  3. Engine 使用镜像标签向仓库请求 Manifest List。
  4. Engine 用目标平台与列表中各条目的 os、architecture,必要时还包括 variant 字段进行匹配。
  5. 匹配成功后,Engine 取得对应子镜像的 digest,并下载该架构的镜像配置和文件层。

因此,识别的起点是 Docker Engine 接收到拉取请求并确定“我要哪个平台”,选择则发生在 Engine 拿到 Manifest List 之后。镜像仓库负责返回索引和镜像内容,但通常不替客户端判断应该选择哪个架构。

如果 Manifest List 中没有与目标平台匹配的条目,拉取会失败,常见提示为 no matching manifest for linux/arm64。

也可以显式指定目标平台:

1
docker pull --platform linux/amd64 demo-app:latest

此时 Docker 会跳过默认平台,直接从 Manifest List 中选择 linux/amd64 条目。但成功拉取不等于能够运行:若宿主机是 arm64,运行该镜像还需要 QEMU 等指令集模拟能力,否则仍可能出现 exec format error。

如何验证双架构镜像

推送完成后,可以使用下面的命令查看远端 manifest:

1
docker manifest inspect --insecure "${manifest_image}"

输出中应至少包含两项平台信息:

1
2
linux/amd64
linux/arm64

如果 Docker 版本支持,也可以使用 Buildx 查看:

1
docker buildx imagetools inspect "${manifest_image}"

仅检查 manifest 元数据还不够。更稳妥的做法是在两种真实架构的节点上分别拉取并启动镜像:

1
2
docker pull "${manifest_image}"
docker run --rm "${manifest_image}" uname -m

通常,amd64 节点会输出 x86_64,arm64 节点会输出 aarch64。这一步能够同时验证平台选择、镜像内容以及应用启动是否正常。

常见问题

manifest 中显示双架构,为什么容器仍然启动失败

最常见的原因是平台标注与真实镜像内容不一致。例如,两个构建任务实际上都在 amd64 节点执行,却把其中一份标记成了 arm64。Manifest 只描述镜像,不负责转换指令集。

可以分别检查源镜像:

1
2
docker image inspect "${amd64_image}" --format '{{.Os}}/{{.Architecture}}'
docker image inspect "${arm64_image}" --format '{{.Os}}/{{.Architecture}}'

为什么拉取后只看到一个架构

这是正常现象。客户端会从 manifest list 中选择当前平台对应的子镜像,只下载这一份镜像的配置和文件层,而不是把所有架构都下载到本机。

两个子镜像能否放在不同仓库

规范上 manifest 可以引用不同镜像,但私有仓库通常会对引用范围、鉴权和跨仓库挂载进行限制。为了减少兼容性问题,建议将子镜像和 manifest list 放在同一个 Registry,最好也位于同一个命名空间。

是否还需要 DOCKER_CLI_EXPERIMENTAL

取决于流水线 Agent 上的 Docker 版本。新版 Docker 通常不需要,旧版本可能需要。最直接的验证方式是执行:

1
docker manifest --help

如果命令不存在,应升级 Docker CLI,而不是仅依赖环境变量解决。

总结

Docker 多架构镜像的核心不是“一份镜像兼容所有 CPU”,而是“一个统一标签索引多份平台镜像”。

  • 单架构镜像包含真正运行所需的二进制文件和文件系统层。
  • Manifest List 保存各平台与子镜像 digest 的映射关系。
  • Docker 客户端根据本机的 os/architecture 自动选择正确镜像。
  • Manifest 不会执行跨架构编译,两个源镜像必须提前正确构建并推送。

通过这种方式,上游流水线分别处理不同 CPU 架构,下游部署系统只使用一个稳定的镜像地址,既简化了发布配置,也避免了人工选择错误架构镜像的问题。