容器镜像(3):多架构镜像构建

一、什么是多架构镜像

1.1 OCI Image Index

上一篇介绍了单平台镜像的结构:一个 Manifest 指向 Config 和若干 Layer blob。多架构镜像在此之上多了一层——OCI Image Index(也叫 Manifest List),是一个轻量的索引文件,把多个单平台 Manifest 组织在一起:

$ docker manifest inspect golang:1.22-alpine
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.oci.image.index.v1+json",
   "manifests": [
      {
         "digest": "sha256:3b4d6fe...",
         "platform": { "architecture": "amd64",  "os": "linux" }
      },
      {
         "digest": "sha256:9c2a1fe...",
         "platform": { "architecture": "arm64",  "os": "linux", "variant": "v8" }
      },
      {
         "digest": "sha256:f12b3cd...",
         "platform": { "architecture": "arm",    "os": "linux", "variant": "v7" }
      },
      {
         "digest": "sha256:a4c8d91...",
         "platform": { "architecture": "s390x",  "os": "linux" }
      }
   ]
}

层次关系如下:

Image Index
├── linux/amd64  → Manifest A → Config + Layer blobs(x86 二进制)
├── linux/arm64  → Manifest B → Config + Layer blobs(ARM64 二进制)
├── linux/arm/v7 → Manifest C → Config + Layer blobs(ARMv7 二进制)
└── linux/s390x  → Manifest D → Config + Layer blobs(IBM Z 二进制)

docker pull golang:1.22-alpine 时,客户端在请求头里携带当前平台信息,Registry 返回 Image Index 后,客户端找到匹配条目,再用对应的 digest 拉取实际的单平台 Manifest 和 blobs,整个过程对用户透明。

1.2 构建多架构镜像的前提

编写一个同时支持 linux/amd64linux/arm64 的 Dockerfile 时,FROM 的基础镜像本身也必须支持这两个架构——构建工具在打包 amd64 版本时会自动拉取对应变体,打包 arm64 时同理。若基础镜像缺少某个架构,构建立即报错:

ERROR: failed to solve: golang:1.22-alpine: no match for platform linux/arm64

在可以直连 Docker Hub 的环境里这不是问题,官方镜像基本都有完整的多架构支持。麻烦在于企业内网——构建集群往往只能访问私有 Registry,基础镜像需要先从公共 Registry 搬运进来。


二、向私有 Registry 同步多架构镜像

2.1 docker pull + push 会丢失 Image Index

最直觉的搬运方式有一个隐蔽的陷阱:

# ❌ 错误做法
docker pull golang:1.22-alpine            # 只拉取了当前机器架构的单平台 Manifest
docker tag  golang:1.22-alpine registry.internal/golang:1.22-alpine
docker push registry.internal/golang:1.22-alpine  # Image Index 已丢失

推完之后检查,会发现 mediaType 变成了单平台 Manifest,多架构信息完全丢失,arm64 构建必然失败。

2.2 用 skopeo 正确复制

skopeo 不依赖 Docker daemon,直接操作 Registry API,天然支持保留 Image Index。

# 复制全部架构,--all 保留完整 Image Index
skopeo copy --all \
  docker://docker.io/library/golang:1.22-alpine \
  docker://registry.internal/golang:1.22-alpine

# 需要目标 Registry 认证时,加 --dest-creds
skopeo copy --all \
  --dest-creds user:password \
  docker://docker.io/library/golang:1.22-alpine \
  docker://registry.internal/golang:1.22-alpine

存储空间有限时,可以只同步所需架构,分两步完成:

# 第一步:分别复制单平台镜像
skopeo copy --override-arch amd64 --override-os linux \
  docker://docker.io/library/golang:1.22-alpine \
  docker://registry.internal/golang:1.22-alpine-amd64

skopeo copy --override-arch arm64 --override-os linux \
  docker://docker.io/library/golang:1.22-alpine \
  docker://registry.internal/golang:1.22-alpine-arm64

# 第二步:重新组装 Image Index
docker buildx imagetools create \
  --tag registry.internal/golang:1.22-alpine \
  registry.internal/golang:1.22-alpine-amd64 \
  registry.internal/golang:1.22-alpine-arm64

2.3 用 crane 复制

crane 支持在一条命令里直接过滤架构,无需两步:

# 复制全部架构
crane copy --platform all \
  golang:1.22-alpine \
  registry.internal/golang:1.22-alpine

# 只复制 amd64 和 arm64
crane copy --platform linux/amd64,linux/arm64 \
  golang:1.22-alpine \
  registry.internal/golang:1.22-alpine

# crane 需要单独登录目标 Registry
crane auth login registry.internal -u user -p password

同步完成后,用下面的命令确认目标架构就位,再启动构建:

$ docker buildx imagetools inspect registry.internal/golang:1.22-alpine \
    --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}'
linux/amd64
linux/arm64

三、离线传输:将多架构镜像搬运到内网机器

有时目标机器既无法访问外网,也无法访问源 Registry(例如完全隔离的内网或现场环境),只能通过物理介质(U 盘、安全摆渡机)或单次 SCP 传输来搬运镜像。这时需要先把镜像存为文件,再导入目标端的 Registry 或 Docker。

3.1 docker save / load 仍然不适用于多架构

docker savedocker pull 有同样的问题,只导出当前平台的单架构镜像:

# ❌ docker save 只导出当前平台的单架构镜像
docker save registry.internal/myapp:v1.0 -o myapp.tar
docker load -i myapp.tar    # 目标机器上只有 amd64,arm64 缺失

3.2 用 skopeo 保存为 OCI 归档文件(推荐)

skopeo 的 oci-archive: 传输协议会将镜像打包成一个单一的 tar 文件,同时完整保留 Image Index:

# ── 在源端(有访问 Registry 权限的机器)──────────────────────────
# 将多架构镜像导出为单个 tar 文件
skopeo copy --all \
  docker://registry.internal/myapp:v1.0 \
  oci-archive:/tmp/myapp.tar

# 传输到目标机器(SCP / rsync / U 盘均可)
scp /tmp/myapp.tar user@target-host:/opt/images/

# ── 在目标端 ──────────────────────────────────────────────────────
# 方式一:直接推送到目标 Registry(推荐)
skopeo copy --all \
  oci-archive:/opt/images/myapp.tar \
  docker://target-registry/myapp:v1.0

# 方式二:加载到本地 Docker(只会加载当前平台的那个变体)
# 如果目标机器只需要运行,不需要重新推送,可用此方式
docker load -i /opt/images/myapp.tar

验证推入目标 Registry 后 Image Index 是否完整:

$ skopeo inspect --raw docker://target-registry/myapp:v1.0 \
  | python3 -c "import sys,json; idx=json.load(sys.stdin); \
    [print(m['platform']['architecture']) for m in idx['manifests']]"
amd64
arm64

3.3 用 skopeo 保存为 OCI 目录格式

oci: 传输协议将镜像保存为一个目录结构(符合 OCI Image Layout 规范),更便于后续检查各个 blob 文件:

# 保存为目录
skopeo copy --all \
  docker://registry.internal/myapp:v1.0 \
  oci:/opt/images/myapp-oci

# 打包传输
tar czf myapp-oci.tar.gz -C /opt/images myapp-oci/
scp myapp-oci.tar.gz user@target-host:/opt/

# 目标端解压后推送
tar xzf myapp-oci.tar.gz -C /opt/images
skopeo copy --all \
  oci:/opt/images/myapp-oci \
  docker://target-registry/myapp:v1.0

oci-archive:oci: 的区别仅在于前者是单个 tar 文件(方便传输),后者是目录(方便检查内容)。

3.4 两端都能访问 Registry 时:直接跨 Registry 复制

如果源端和目标端通过跳板机或 VPN 能同时访问两个 Registry,无需落盘,skopeo 可以直接在两个 Registry 之间流式传输:

skopeo copy --all \
  --src-creds  src-user:src-pass \
  --dest-creds dst-user:dst-pass \
  docker://source-registry/myapp:v1.0 \
  docker://target-registry/myapp:v1.0

这是最简洁的方式,避免了中间文件的生成,也是搭建内网镜像同步服务的常用做法。


四、示例项目

后续几节用同一个 Golang 项目演示多架构构建。项目只有两个文件。

main.go:运行时打印当前操作系统和 CPU 架构,便于验证拉取到的是否是正确平台的镜像:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Printf("Hello! Running on %s/%s\n", runtime.GOOS, runtime.GOARCH)
}

Dockerfile:多阶段构建,编译阶段使用 golang 镜像,运行阶段只保留最终二进制:

FROM golang:1.22-alpine AS build
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .

FROM alpine:3.21
COPY --from=build /out/app /usr/local/bin/app
ENTRYPOINT ["app"]

CGO_ENABLED=0 关闭 cgo,产出纯静态二进制,不依赖 libc。-trimpath -ldflags="-s -w" 去掉调试信息,减小体积。


五、多架构构建的本质:各架构分别构建,最后合并

在介绍 buildx 的 QEMU 模拟方案之前,先看一下多架构构建的本质做法:在对应架构的原生机器上分别构建,得到两个单平台镜像,最后用 imagetools createpodman manifest create 把它们合并成一个 Image Index。

这是多架构镜像最直白的构建路径,也完整展示了 Image Index 的本质——它本身只是一张"指针表",把若干个独立的单平台镜像关联在一起,每个镜像可以完全独立构建、独立部署。

5.1 在各自架构的机器上分别构建

# ── 在 amd64 机器上执行 ────────────────────────────────────────
$ uname -m
x86_64
$ docker build \
    --platform linux/amd64 \
    --tag registry.internal/myapp:v1.0-amd64 \
    --push \
    .

# ── 在 arm64 机器上执行(SSH 到 ARM 服务器或树莓派)──────────────
$ uname -m
aarch64
$ docker build \
    --platform linux/arm64 \
    --tag registry.internal/myapp:v1.0-arm64 \
    --push \
    .

此时 Registry 里有两个独立的单平台镜像,各自完全可用:

# amd64 机器上运行 amd64 版本
$ docker run --rm registry.internal/myapp:v1.0-amd64
Hello! Running on linux/amd64

# arm64 机器上运行 arm64 版本
$ docker run --rm registry.internal/myapp:v1.0-arm64
Hello! Running on linux/arm64

5.2 合并为多架构镜像

# 用 imagetools create 将两个单平台镜像合并为一个 Image Index
$ docker buildx imagetools create \
    --tag registry.internal/myapp:v1.0 \
    registry.internal/myapp:v1.0-amd64 \
    registry.internal/myapp:v1.0-arm64

# 验证合并结果
$ docker buildx imagetools inspect registry.internal/myapp:v1.0
Name:      registry.internal/myapp:v1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:c7a2b31...

Manifests:
  Name:      registry.internal/myapp:v1.0@sha256:a1b2c3...
  MediaType: application/vnd.oci.image.manifest.v1+json
  Platform:  linux/amd64

  Name:      registry.internal/myapp:v1.0@sha256:d4e5f6...
  MediaType: application/vnd.oci.image.manifest.v1+json
  Platform:  linux/arm64

imagetools create 做的事非常简单:读取两个单平台镜像各自的 Manifest digest,生成一个新的 Image Index JSON,把它们的 digest + platform 条目填进去,推送到 Registry。两个单平台镜像的 blob 数据原封不动,只是新建了一个索引来引用它们。

用 Podman 也是完全相同的思路:

# 各架构分别构建并推送(在对应机器上执行)
podman build -t registry.internal/myapp:v1.0-amd64 --push .   # amd64 机器
podman build -t registry.internal/myapp:v1.0-arm64 --push .   # arm64 机器

# 合并
podman manifest create registry.internal/myapp:v1.0 \
  registry.internal/myapp:v1.0-amd64 \
  registry.internal/myapp:v1.0-arm64

podman manifest push registry.internal/myapp:v1.0

这种分步做法在 CI 系统里也非常常见:amd64 和 arm64 构建任务并行跑在各自的 runner 上,都完成后触发一个合并任务生成最终的 Image Index。


六、用 Docker buildx 在单机构建(QEMU 方案)

如果没有 arm64 原生机器,可以在单台 amd64 机器上通过 QEMU 模拟执行异架构二进制,一条命令完成两个平台的构建。代价是 arm64 编译步骤要通过软件模拟执行,速度明显慢于原生。

6.1 安装 QEMU 并注册 binfmt

sudo apt install -y qemu-user-static binfmt-support

# 用 multiarch 官方镜像一键注册所有架构的 binfmt_misc
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

# 验证 arm64 已可模拟
ls /proc/sys/fs/binfmt_misc/ | grep qemu-aarch64
qemu-aarch64

6.2 创建 buildx builder

docker buildx create \
  --name multiarch-builder \
  --driver docker-container \
  --use

# 启动并确认支持 arm64
docker buildx inspect --bootstrap | grep -A2 Platforms
Platforms: linux/amd64, linux/arm64, linux/arm/v7, ...

6.3 构建并推送

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag registry.internal/myapp:v1.0 \
  --push \
  .

构建过程中两个平台并行进行,arm64 步骤通过 QEMU 模拟,耗时约为 amd64 的 2–4 倍:

[+] Building 42.3s
 => [linux/amd64 build 3/3] RUN CGO_ENABLED=0 go build ...    5.3s
 => [linux/arm64 build 3/3] RUN CGO_ENABLED=0 go build ...   14.7s  ← QEMU 模拟
 => pushing manifest list                                      1.1s

6.4 验证

# 查看 Image Index
$ docker buildx imagetools inspect registry.internal/myapp:v1.0
...
Platform:  linux/amd64
Platform:  linux/arm64

# 当前机器(amd64)正常运行
$ docker run --rm registry.internal/myapp:v1.0
Hello! Running on linux/amd64

# 强制拉取 arm64 变体,通过 QEMU 运行
$ docker run --rm --platform linux/arm64 registry.internal/myapp:v1.0
Hello! Running on linux/arm64

七、用 Podman 构建多架构镜像

Podman 4.x 引入了 build --manifest 参数,可以在构建时直接指定目标 manifest list:

# 一条命令构建两个平台并生成本地 manifest list
podman build \
  --platform linux/amd64,linux/arm64 \
  --manifest myapp:v1.0 \
  .

# 推送 manifest list 及所有平台镜像
podman login registry.internal
podman manifest push myapp:v1.0 registry.internal/myapp:v1.0

想对每个平台做差异化控制时,也可以走显式分步流程(与第五节原生分步构建的逻辑完全一致):

podman build --platform linux/amd64 -t registry.internal/myapp:v1.0-amd64 .
podman build --platform linux/arm64 -t registry.internal/myapp:v1.0-arm64 .
podman push registry.internal/myapp:v1.0-amd64
podman push registry.internal/myapp:v1.0-arm64

podman manifest create registry.internal/myapp:v1.0 \
  registry.internal/myapp:v1.0-amd64 \
  registry.internal/myapp:v1.0-arm64
podman manifest push registry.internal/myapp:v1.0

八、各方案横向对比

8.1 构建方案对比

方案 需要条件 构建速度 适合场景
各架构原生分步构建 + 合并 各架构的原生机器 最快(原生编译) 大型项目、CI 多 runner
Docker buildx + QEMU 单台 x86 机器 + QEMU arm64 慢 2–4 倍 个人开发、小项目、快速验证
Podman --manifest 单台机器 + QEMU 同上 无 daemon 环境、rootless CI

8.2 镜像传输/同步方案对比

方案 适用场景 是否保留 Image Index
docker pull + push ❌ 不适用 ✗ 丢失
skopeo copy --all(Registry 间) 两端可同时访问 Registry
skopeo copy + oci-archive: 离线传输、物理介质
crane copy --platform 两端可同时访问 Registry

九、小结

多架构镜像的本质是 OCI Image Index:一个把多个单平台 Manifest 聚合起来的索引,客户端拉取时按平台自动匹配,Image Index 本身不包含任何二进制数据。

理解了这一点,构建和传输的正确路径就都清晰了:

构建的本质是分别为每个架构产出一个单平台镜像,再用 imagetools createmanifest create 生成一个指向它们的 Index。QEMU 方案只是在没有异架构原生机器时的便捷替代——通过软件模拟让单台机器完成这个过程。

传输时,docker save/loaddocker pull/push 均无法保留 Image Index,必须使用能感知 OCI Index 结构的工具。skopeo copy --all 配合 oci-archive: 传输协议是最可靠的离线搬运方式;如果两端有网络联通,skopeo 或 crane 可以直接做 Registry 间的流式复制,更为简洁。

下一篇,我们将介绍一些日常操作多架构镜像时实用的辅助工具:skopeocraneregctl,以及镜像签名工具 cosign

Read more

容器镜像(4):镜像的常用工具箱

容器镜像(4):镜像的常用工具箱

前几篇在讲多架构镜像时已经用过 skopeo 和 crane 做镜像复制,这篇系统整理这两个工具的完整能力,同时介绍几个日常操作镜像时同样好用的工具。 一、skopeo:不依赖 Daemon 的镜像瑞士军刀 skopeo 的核心价值是绕过 Docker daemon,直接与 Registry API 交互。上一篇用它做镜像复制和离线传输,但它的能力远不止于此。 1.1 安装 # Ubuntu / Debian sudo apt install -y skopeo skopeo --version # skopeo version 1.15.1 1.2 inspect:免拉取检查镜像元数据 docker inspect 需要先把镜像拉到本地,skopeo inspect 直接向 Registry

容器镜像(2):containerd 视角下的镜像

容器镜像(2):containerd 视角下的镜像

一、为什么需要了解 containerd 如果你只用 docker run 跑容器,从来不关心底层,那可以不了解 containerd。但如果你在用 Kubernetes,或者想真正理解"容器运行时"是什么,containerd 是绕不开的。 事实上,当你执行 docker run 的时候,containerd 早就在后台悄悄工作了——Docker 从 1.11 版本开始,就把核心运行时剥离出来交给 containerd 负责。 1.1 Docker 的架构演变 早期的 Docker(1.10 及之前)是一个"大一统"的单体程序:一个 dockerd