背景

过去,我一直在使用 x86_64 架构的 MacBook Pro 进行开发,服务器环境则是 x86_64 架构的 CentOS,因此从未担心过多架构的 Docker 镜像构建问题。

然而,公司最近更新了一批 M1 MacBook Pro。

在 x86 系统上运行为 ARM/M1 架构构建的镜像时,可能会遇到以下错误:

1
standard_init_linux.go:211: exec user process caused "exec format error"

这意味着容器中的二进制文件并非为当前 CPU 架构构建的。

那么,如何构建一个多架构的 Docker 镜像呢?

本文将介绍两种方法:通过 manifestbuildx 构建多架构镜像。

方法一: 使用 manifest

1.1 什么是 Docker manifest

Docker manifest 是镜像的元数据列表,包含镜像的大小、层数(layers)、digest、操作系统、CPU 架构等信息。通过 manifest,我们可以为不同 CPU 架构提供兼容的镜像,实现「build once, run anywhere」。

1.2 创建 manifest

在创建 manifest 前,先构建出所需的镜像。以下示例中,我们从 Docker Hub 拉取两个不同架构的镜像(alpine:3.19),并将其分别标记为 arm-v6amd64

镜像名称CPU 架构
harbor.baijiayun.com/proxy/alpine3.19arm-v6
harbor.baijiayun.com/proxy/alpine3.19amd64

使用 manifest 前,需确保目标镜像已推送至仓库,manifest 命令仅适用于仓库中的镜像。

在 Harbor 中可以看到两个不同架构的镜像。接下来,通过以下命令创建 manifest:

1
2
3
4
5
6
7
8
9
# 创建 manifest
docker manifest create harbor.baijiayun.com/proxy/alpine:3.19 harbor.baijiayun.com/proxy/alpine3.19:arm-v6 harbor.baijiayun.com/proxy/alpine3.19:amd64

# 添加注解
docker manifest annotate harbor.baijiayun.com/proxy/alpine:3.19 harbor.baijiayun.com/proxy/alpine3.19:arm-v6 --os linux --arch arm64
docker manifest annotate harbor.baijiayun.com/proxy/alpine:3.19 harbor.baijiayun.com/proxy/alpine3.19:amd64 --os linux --arch amd64

# 推送 manifest 至 Harbor
docker manifest push harbor.baijiayun.com/proxy/alpine:3.19

推送完成后,Harbor 中将显示一个包含不同架构的镜像。此 manifest 镜像支持自动适配系统,Docker Engine 会根据当前系统选择合适的镜像。可通过 docker manifest inspect 查看详细的元数据:

 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
  ~ docker manifest inspect harbor.baijiayun.com/proxy/alpine:3.19
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
   "manifests": [
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:13b7e62e8df80264dbb747995705a986aa530415763a6c58f84a3ca8af9a5bcd",
         "platform": {
            "architecture": "amd64",
            "os": "linux"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:45eeb55d6698849eb12a02d3e9a323e3d8e656882ef4ca542d1dda0274231e84",
         "platform": {
            "architecture": "arm64",
            "os": "linux",
            "variant": "v6"
         }
      }
   ]
}

打开 Harbor 找到对应的镜像,切到 Artifacts 页面,查看的结果如下:

image.png

1.3 manifest 的优势

  • 统一镜像访问:跨平台兼容性更好,支持多架构和多操作系统。
  • 简化镜像管理:统一推送和拉取镜像。
  • 高效分发:自动匹配系统的正确镜像。

方法二:使用 buildx

在 19.03 之后的 Docker 版本中,为改善构建速度,Docker image 构建引擎被重写,这也是后来熟知的 BuildKit。BuildKit 是 Docker 镜像构建引擎的一次彻底重写,它保留了之前的所有功能,同时增加了一些很棒的新特性,以生成更好的 OCI 镜像,例如:

  • 可以为 COPY 指令添加 --chown #34263
  • 真正的并行化多阶段镜像构建,只在需要依赖项时才等待

我建议即使只构建单架构镜像,也要总是启用 Buildkit,因为它至少可以提高构建速度。

在后面的版本中,BuildKit 总是默认开启的。需要手动开启或关闭时,只需要设置环境变量即可,如:

1
2
3
export DOCKER_BUILDKIT=1 # 当前会话中,全部开启
# or 单条命令开启
DOCKER_BUILDKIT=1 docker build -t xx .

谈到多架构构建,BuildKit 内置的 buildx 就可以胜任,使用方式也比较简单。

3.1 创建 container builder

默认情况下,Docker 只是开启了 BuildKit 特性,但是并不能直接构建 non-native 平台的镜像。想开启这个功能,就要使用 docker-container driver,更多细节可参考官方文档

使用 docker-container driver 有以下优势:

  • 能够指定 BuildKit 版本
  • 能够构建 multi-arch 镜像
  • 有更多的缓存功能,如 multi caches

创建 docker-container driver 的方式也很简单,直接通过 buildx 命令,如下所示:

1
2
3
docker buildx create \
  --name container \
  --driver=docker-container

创建后,通过 docker buildx ls 可以查看创建的结果: image.png

3.2 构建并推送镜像

创建了 docker-container driver 后,即可使用 –platform 指定需要构建的平台。构建命令如下:

1
2
3
4
5
docker buildx build \
--tag jackcipher/demo:latest \
--platform linux/arm/v7,linux/arm64/v8,linux/amd64 \
--builder container \
--push .

3.3 小结

通过 manifest 的方式可以创建多架构的镜像,而 buildx 的方式更简单。不用先创建两个镜像,组装成 manifest 文件再推送 manifest,而是直接通过 buildx 指定 platform 参数,中间的过程全部都是由 buildx 自动完成。在实际使用中,更推荐使用 buildx 的方式。

4. 构建多架构 Go 服务实践

有了前面的基础后,构建多架构的 Go 服务乍一看好像比较简单:Go 在编译时提供了 GOOS 与 GOARCH 参数,构建的时候设置下这两个参数就好了。

可问题是,当使用 buildx 在外层构建时,内部的 Dockerfile 是如何知道要构建哪个目标的镜像呢?

在仔细查阅了官方文档后,得知到,当使用 buildx 构建时,buildx 会将一些信息通过 build_arg 的方式传递到 Dockerfile,以方便在 Dockerfile 中使用这些变量。

根据 文档 可知,以下 ARG 变量会被自动设置:

  • TARGETPLATFORM - 构建的目标平台,如  linux/amd64linux/arm/v7windows/amd64.
  • TARGETOS - TARGETPLATFORM 对应的 OS
  • TARGETARCH - TARGETPLATFORM 对应的 CPU 架构
  • TARGETVARIANT - TARGETPLATFORM 对应的 variant,如指定 platform=linux/arm/v7 那么 arch 就是 arm variant 就是 v7
  • BUILDPLATFORM - 构建环境的平台
  • BUILDOS - BUILDPLATFORM 对应的 OS
  • BUILDARCH - BUILDPLATFORM 对应的架构
  • BUILDVARIANT - BUILDPLATFORM 对应的 variant

有了这些变量后,那么在 buildx 中指定 platform 参数后,就可以通知到 Dockerfile 了。

4.1 编译目标二进制文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM --platform=$BUILDPLATFORM golang:1.19 as builder
ARG TARGETOS
ARG TARGETARCH
ARG APP=app

RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLE=0 go build go build -o /build/$APP

FROM --platform=$BUILDPLATFORM alpine:3.16.0

COPY --from=builder /build /app
WORKDIR /app
CMD ["app"]

根据上一节的变量自动注入的知识可以得到,当执行以下命令时:

1
2
3
4
5
docker buildx build \
--tag jackcipher/demo:latest \
--platform linux/arm/v7,linux/arm64/v8,linux/amd64 \
--builder container \
--push .

这个 Dockerfile 会被多次构建,每次构建时,执行的 go build 参数又不同。在这个 case 中,会分别执行

1
2
3
GOOS=linux GOARCH=arm go build
GOOS=linux GOARCH=arm64 go build
GOOS=linux GOARCH=amd64 go build

5. CI 中如何使用呢

image.png

image.png

将选择 CPU 架构的地方,通过下拉框的形式交给用户选择,再将得到的变量传给 shell 中执行对应的结果即可。