Docker 构建多平台镜像

Docker 构建多平台镜像

最近公司在组织各个系统的开发人员在搞信创改造,其中有部分改造内容就是要让系统能兼容 ARM 架构的 CPU。我们的系统都是运行在 Kubernetes 的容器中的,所以需要将应用打包到不同架构的镜像中。

Docker 提供了多平台的支持,可以将不同架构的镜像打包成一个镜像,部署时再根据运行的架构不同拉取不同架构的镜像运行,构建多平台镜像可以使用 BuildX 组件实现。

Docker BuildX

我们可以使用 docker buildx 命令构建多平台容器镜像。Buildx 是 Docker 的组件,支持很多构建特性。通过 Buildx 的构建都是在 Moby Buildkit 构建引擎里执行的。 Docker 23.0 版本的 docker build 命令也将默认基于 Buildx 构建。

Buildx 默认构建当前机器架构的镜像,如果需要构建不同架构的镜像需要使用 --platform 参数,比如:--platform=linux/arm64。如果想构建支持多个架构的镜像,可以使用逗号分隔多个架构的值。

1
docker buildx build --platform=linux/amd64,linux/arm64 .

使用 Buildx 构建多平台镜像时,需要创建支持的构建实例。上面说过,buildx 是运行在 BuildKit 里的,所以在构建多平台镜像之前需要创建一个构建实例。有些 Docker 环境提供的默认构建实例本身支持则无需再额外创建。可以通过 docker buildx ls 命令检查:

1
2
3
4
5
6
7
8
# docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
m1_builder * docker-container
m1_builder0 unix:///var/run/docker.sock running v0.10.5 linux/arm64, linux/amd64, linux/amd64/v2, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6
default docker
default default running 20.10.21 linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6
desktop-linux docker
desktop-linux desktop-linux running 20.10.21 linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6

如果不支持,可以手动创建一个。

1
docker buildx create --use

构建多平台镜像时,实际上是每个平台分别构建一次,最终合并成一个镜像。如下面镜像:

1
2
FROM alpine
RUN echo "Hello" > /hello

构建的时候,buildx 会拉取不同架构的 alpine 镜像,最终在执行的时候,分别执行的是各自架构的二进制。

这里告诉我们,构建多平台镜像时,依赖的基础镜像也应该支持多平台。

准备 Dockerfile

Docker 官方文章《Faster Multi-Platform Builds: Dockerfile Cross-Compilation Guide》介绍了一种更快的多平台镜像构建方法。

运行在 Apple M1 上的构建示例,蓝色的包含 x86 文件,黄色的包含 ARM 文件

总体上说,思路是在构建的机器上直接构建生成目标架构的可执行文件,再将文件复制到镜像中进行打包。因为如果在目标架构上构建,意味着需要使用 BuildKit 模拟目标架构来运行,中间存在指令转换的开销,严重影响构建效率。

可以通过多阶段构建(Multi-stage build)的 Dockerfile 实现构建和打包运行镜像分开,只有最终的 stage 才会生成镜像,其它 stage 实际上是构建的中间过程。

在构建 stage 使用的 FROM debian 的语句,实际上是让构建器拉取的当前构建的目标架构的 Debian 镜像(--platform 参数指定)。如果想让构建 stage 直接以构建主机的架构构建需要手动指定架构,如使用 x86 架构进行构建可以这样配置基础镜像:

1
FROM --platform=linux/amd64 debian as builder

这样配置后,无论当前是构建 x86 还是 ARM 架构的镜像,builder 阶段都使用 x86 的基础镜像进行构建。

Docker 提供了一些预定义的全局构建参数,用于描述当前的构建情况。上面提到的 linux/amd64 在更换平台进行构建时需要调整,而使用全局定义参数 BUILDPLATFORM 就可以解决这个问题,这个值在构建时会自动填充成当前构建系统的架构。

常用的预定义参数如下:

参数 说明 示例
BUILDPLATFORM 当前主机平台 linux/amd64
BUILDOS 当前系统类型 linux
BUILDARCH 当前主机架构 amd64, arm64, riscv64
BUILDVARIANT ARM 版本 v7
TARGETPLATFORM 目标平台 linux/arm64
TARGETOS 目标系统 linux
TARGETARCH 目标架构 arm64

现在,配合预定义参数,构建镜像的 Dockerfile 可以配置成:

1
2
3
4
5
6
7
8
9
FROM --platform=$BUILDPLATFORM alpine AS build
# RUN <install build dependecies/compiler>
# COPY <source> .
ARG TARGETPLATFORM
RUN compile --target=$TARGETPLATFORM -o /out/mybinary

FROM alpine
# RUN <install runtime dependencies installed via emulation>
COPY --from=build /out/mybinary /bin

这个 Dockerfile 有两个阶段(stage),build 阶段和 runtime 阶段。

  • build 阶段负责准备编译环境和进行交叉编译生成目标平台的可执行文件。由于 Dockerfile Scope 的影响,全局预定义变量要想在命令中使用,需要使用 ARG 指令将参数传递到 stage 的本地 scope;
  • runtime 阶段是容器镜像最终生成的阶段,这里的 FROM 指令不配置 --platform 相当于配置了 FROM --platform=$TARGETPLATFORM,拉取当前构建目标架构的基础镜像,所以可以省略掉 --platform 参数。

多平台镜像的结构

生成的多个镜像最后会合并到一个 OCI 镜像中,BuildKit 会生成包含这些镜像的 manifest 信息,内容如下:

manifest list
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"digest": "sha256:33cc662c443c0c3f4bfcdc37d97b3c172d5b4c0bb4e9ed19f2b3d288466d9bfb",
"size": 738,
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"digest": "sha256:e969f63ec867a40c8363fb960c8f8d0b7e188ff26064b40c846bc9cd097ff0b3",
"size": 738,
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}

如果你使用的 Harbor 作为镜像管理仓库,可以访问这个页面快速获取镜像的 manifest(根据需要替换下面路径中的参数)。

1
https://registry.imoe.tech/v2/<project-name>/<image-name>/manifests/<tag>

使用 BuildX 构建时,也可以直接导出 OCI 镜像包,并查看该包的结构。

1
2
3
docker buildx build \
--platform linux/amd64,linux/arm64 \
-f Dockerfile . --output type=oci,dest=./dockertest.tar

上面命令会在当前目录生成 dockertest.tar 文件,解压后就能一窥 OCI 镜像的结构。

OCI 多平台镜像结构

Podman 构建多平台镜像

得益于容器的标准化,容器的管理工具有了更多选择。现在很多人使用 Podman 而不是 Docker 来构建和运行容器。但是 Podman 并不支持 Docker 的 BuildX 组件,上面第一节说的方法没法用了。好消息是,Podman 原生支持构建跨平台的镜像。

在使用 podman 构建前,需要登录一个 OCI 兼容的镜像仓库,这里登录腾讯云的镜像仓库。

1
podman login ccr.ccs.tencentyun.com -p Secret -u User

构建指定架构的镜像

Docker BuildX 支持在一次执行中自动生成多个架构的镜像并合并成一个镜像,这个过程完全是自动的。而在 Podman 中则需要手动创建不同的镜像,比如这里创建 arm64 和 amd64 的镜像:

1
2
podman build --platform linux/arm64/v8 -t ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0-linux-arm64 .
podman build --platform linux/amd64 -t ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0-linux-amd64 .

上面命令使用 --platform 参数指定了镜像的架构,格式和 Docker BuildX 一样但一次只能指定一个架构。所以上面实际创建了两个不同的镜像,用不同的 tag 进行了区分。

使用 podman image ls 查看生成的镜像。

1
2
3
4
$ podman image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ccr.ccs.tencentyun.com/<namespace>/hello-world v1.0.0-linux-amd64 de9a0c2f01c8 29 seconds ago 114 MB
ccr.ccs.tencentyun.com/<namespace>/hello-world v1.0.0-linux-arm64 0f3a0bc46798 43 seconds ago 136 MB

可以看到,除了 tag 不同,我们甚至看不出有什么区别。要查看镜像的架构,需要使用 inspect 命令。

1
2
$ podman image inspect ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0-linux-amd64 | grep Arch
"Architecture": "amd64",

合并多个架构镜像

OCI 镜像通过 manifest 来管理多平台的镜像,podman manifest 命令可以创建和管理 manifest。

合并现有的多平台镜像
1
2
3
4
5
6
7
8
podman manifest create \
ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0 \
ccr.ccs.tencentyun.com/<namespace>hello-world:v1.0.0-linux-arm64 \
ccr.ccs.tencentyun.com/<namespace>hello-world:v1.0.0-linux-amd64

podman manifest push ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0 \
docker://ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0
docker manifest rm ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0

上面第一个命令的作用是创建了一个 manifest,叫作 hello-world:v1.0.0,然后添加了两个镜像到 manifest 中(后面两个)。可以把这个命令拆分出来,先创建 manifest 再分别构建好镜像,直接加到 manifest 中。

构建多平台镜像
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
podman manifest create ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0

for IMAGEARCH in amd64 arm64 ; do
podman build --platform linux/${IMAGEARCH} --layers=false -f Dockerfile \
--build-arg=TARGETOS=linux \
--build-arg=TARGETARCH="${IMAGEARCH}" \
--build-arg=TARGETPLATFORM="linux/${IMAGEARCH}" \
--build-arg=BUILDPLATFORM="linux/amd64" \
-t ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0-${IMAGEARCH} .
podman manifest add --arch=${IMAGEARCH} --os=linux \
ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0 \
docker://ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0-${IMAGEARCH}
done

podman manifest push --all ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0 \
docker://ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0

podman manifest push 命令用于将 manifest 推到镜像仓库中,推送后就可以使用 podman manifest rm 删除本地的 manifest。

注意一:Podman 不提供全局预定义变量,需要手动通过 --build-arg 进行设置。
注意二:Podman 不像 BuildX 能在 Builder 阶段使用 FROM --platform 指定拉取和 podman build --platform 不同平台的镜像。Podman 中拉取的始终都是 podman build 命令指定的架构,所以使用 Podman 环境下,只能在构建镜像前进行交叉编译产生可执行文件再使用 Podman 打包到镜像中。Dockerfile 文件也需要进行调整:

1
2
3
4
5
6
7
8
FROM alpine AS build
ARG TARGETPLATFORM
ADD ./output /output
RUN cp /output/"$TARGETPLATFORM"/app /app

FROM alpine
# RUN <install runtime dependencies installed via emulation>
COPY --from=build /app /bin

上面在 build 阶段把所有的输出复制到层中,再使用 RUN cp 把需要的目标文件复制出来。这样做的原因是 ADD 这类指令不支持使用变量,所以多绕了一点路。

这样分两阶段,而不是合并,优点是最后阶段只打包需要的平台的可执行文件,而不是把所有的平台都打包,可以减小镜像的尺寸。以下一节的 Go 示例,对应的构建脚本可以是:

1
2
3
4
5
for i in "arm64" "amd64" ; do
echo "building for $i..."
GOOS=linux GOARCH="$i" go build -o ./output/linux/$i/app cmd/main.go
chmod +x ./output/linux/$i/app
done

这里也可以进一步优化,在打包前构建后使用脚本直接处理好文件(移动到对应位置),而不用在 Dockerfile 中再对文件进行选择。

除了 Podman 原生的支持外,还可以使用 Buildah 来实现相同的功能,这里就不再赘述了。

Go 构建样例

对于 Java 应用来说,一次构建产生的 Jar 包可以处处运行,不需要对不同平台进行交叉编译,所以 Java 应用只需要使用支持多平台的基础镜像构建容器镜像就可。

Go 语言编写的应用则不同,一般都需要编译成特定的可执行文件,比较适合作为上文所说的 构建+打包 的多阶段 Dockerfile 示例,这里举例一个 Go 应用使用多平台镜像的示例。

单平台镜像构建
1
2
3
4
5
6
7
FROM golang:1.19-alpine AS build
WORKDIR /src
COPY . .
RUN go build -o /out/myapp .

FROM alpine
COPY --from=build /out/myapp /bin

上文是不进行多平台镜像构建的 Dockerfile,只需要简单的构建打包既可。接下来我们对其进行修改,增加交叉编译和多平台相关的内容。

Go 交叉编译非常简单,只需要在调用 go build 命令时传入 GOOSGOARCH 两个环境变量既可。GOOSGOARCH 的值与 BuildKit 的预定义变量 TARGETOSTARGETARCH 的值是一样的。

所以只需要用 ARG 获取到值,简单地赋于 GOOSGOARCH 就行,改造后的 Dockerfile 如下:

多平台构建优化后
1
2
3
4
5
6
7
8
FROM --platform=$BUILDPLATFORM golang:1.19-alpine AS build
WORKDIR /src
COPY . .
ARG TARGETOS TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/myapp .

FROM alpine
COPY --from=build /out/myapp /bin

总结

Docker 目前对于多平台/平台镜像的支持已经非常好,自己构建镜像也简单,但构建多平台镜像要注意依赖的上游基础镜像要支持多平台,如果不支持需要再另外找支持的镜像或自己制作。

随着 Oracle 等云厂商都提供了 ARM 架构的云主机,多平台镜像也得到越来越多的支持。基本上各大开源组织官方的基础镜像都已经完成了支持,在选择基础镜像的时候也应该尽量使用各大开源如织的镜像,能给我们减少很多麻烦。

引用

  1. Faster Multi-Platform Builds: Dockerfile Cross-Compilation Guide
  2. Multi-platform images
  3. OCI Image Format Specification
  4. OCI Image Index Specification
  5. Building Multi-Architecture Containers on OCI with Podman
作者

Jakes Lee

发布于

2023-01-05

更新于

2023-01-10

许可协议

评论