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 | # docker buildx ls |
如果不支持,可以手动创建一个。
1 | docker buildx create --use |
构建多平台镜像时,实际上是每个平台分别构建一次,最终合并成一个镜像。如下面镜像:
1 | FROM alpine |
构建的时候,buildx 会拉取不同架构的 alpine 镜像,最终在执行的时候,分别执行的是各自架构的二进制。
这里告诉我们,构建多平台镜像时,依赖的基础镜像也应该支持多平台。
准备 Dockerfile
Docker 官方文章《Faster Multi-Platform Builds: Dockerfile Cross-Compilation Guide》介绍了一种更快的多平台镜像构建方法。
总体上说,思路是在构建的机器上直接构建生成目标架构的可执行文件,再将文件复制到镜像中进行打包。因为如果在目标架构上构建,意味着需要使用 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 | FROM --platform=$BUILDPLATFORM alpine AS build |
这个 Dockerfile 有两个阶段(stage),build
阶段和 runtime
阶段。
build
阶段负责准备编译环境和进行交叉编译生成目标平台的可执行文件。由于 Dockerfile Scope 的影响,全局预定义变量要想在命令中使用,需要使用ARG
指令将参数传递到 stage 的本地 scope;runtime
阶段是容器镜像最终生成的阶段,这里的FROM
指令不配置--platform
相当于配置了FROM --platform=$TARGETPLATFORM
,拉取当前构建目标架构的基础镜像,所以可以省略掉--platform
参数。
多平台镜像的结构
生成的多个镜像最后会合并到一个 OCI 镜像中,BuildKit 会生成包含这些镜像的 manifest 信息,内容如下:
1 | { |
如果你使用的 Harbor 作为镜像管理仓库,可以访问这个页面快速获取镜像的 manifest(根据需要替换下面路径中的参数)。
1 | https://registry.imoe.tech/v2/<project-name>/<image-name>/manifests/<tag> |
使用 BuildX 构建时,也可以直接导出 OCI 镜像包,并查看该包的结构。
1 | docker buildx build \ |
上面命令会在当前目录生成 dockertest.tar
文件,解压后就能一窥 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 | podman build --platform linux/arm64/v8 -t ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0-linux-arm64 . |
上面命令使用 --platform
参数指定了镜像的架构,格式和 Docker BuildX 一样但一次只能指定一个架构。所以上面实际创建了两个不同的镜像,用不同的 tag 进行了区分。
使用 podman image ls
查看生成的镜像。
1 | $ podman image ls |
可以看到,除了 tag 不同,我们甚至看不出有什么区别。要查看镜像的架构,需要使用 inspect
命令。
1 | $ podman image inspect ccr.ccs.tencentyun.com/<namespace>/hello-world:v1.0.0-linux-amd64 | grep Arch |
合并多个架构镜像
OCI 镜像通过 manifest 来管理多平台的镜像,podman manifest
命令可以创建和管理 manifest。
1 | podman manifest create \ |
上面第一个命令的作用是创建了一个 manifest,叫作 hello-world:v1.0.0
,然后添加了两个镜像到 manifest 中(后面两个)。可以把这个命令拆分出来,先创建 manifest 再分别构建好镜像,直接加到 manifest 中。
1 | podman manifest create 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 | FROM alpine AS build |
上面在 build
阶段把所有的输出复制到层中,再使用 RUN cp
把需要的目标文件复制出来。这样做的原因是 ADD
这类指令不支持使用变量,所以多绕了一点路。
这样分两阶段,而不是合并,优点是最后阶段只打包需要的平台的可执行文件,而不是把所有的平台都打包,可以减小镜像的尺寸。以下一节的 Go 示例,对应的构建脚本可以是:
1 | for i in "arm64" "amd64" ; do |
这里也可以进一步优化,在打包前构建后使用脚本直接处理好文件(移动到对应位置),而不用在 Dockerfile 中再对文件进行选择。
除了 Podman 原生的支持外,还可以使用 Buildah 来实现相同的功能,这里就不再赘述了。
Go 构建样例
对于 Java 应用来说,一次构建产生的 Jar 包可以处处运行,不需要对不同平台进行交叉编译,所以 Java 应用只需要使用支持多平台的基础镜像构建容器镜像就可。
Go 语言编写的应用则不同,一般都需要编译成特定的可执行文件,比较适合作为上文所说的 构建+打包
的多阶段 Dockerfile 示例,这里举例一个 Go 应用使用多平台镜像的示例。
1 | FROM golang:1.19-alpine AS build |
上文是不进行多平台镜像构建的 Dockerfile,只需要简单的构建打包既可。接下来我们对其进行修改,增加交叉编译和多平台相关的内容。
Go 交叉编译非常简单,只需要在调用 go build
命令时传入 GOOS
和 GOARCH
两个环境变量既可。GOOS
和 GOARCH
的值与 BuildKit 的预定义变量 TARGETOS
和 TARGETARCH
的值是一样的。
所以只需要用 ARG
获取到值,简单地赋于 GOOS
和 GOARCH
就行,改造后的 Dockerfile 如下:
1 | FROM --platform=$BUILDPLATFORM golang:1.19-alpine AS build |
总结
Docker 目前对于多平台/平台镜像的支持已经非常好,自己构建镜像也简单,但构建多平台镜像要注意依赖的上游基础镜像要支持多平台,如果不支持需要再另外找支持的镜像或自己制作。
随着 Oracle 等云厂商都提供了 ARM 架构的云主机,多平台镜像也得到越来越多的支持。基本上各大开源组织官方的基础镜像都已经完成了支持,在选择基础镜像的时候也应该尽量使用各大开源如织的镜像,能给我们减少很多麻烦。