docker buildx 构建 arm64 架构镜像,是我这两年被问得最多的话题之一。原因也很直白:开发机大多是 x86_64,但 ARM 开发板、边缘网关这些目标设备越来越多,两边 CPU 架构完全不同,直接 docker build 只能得到 amd64 产物,拿到 arm64 设备上根本跑不起来。过去要跨平台构建,得靠模拟器加交叉编译环境,操作繁琐,部署时还容易撞上 exec format error。buildx 普及之后,跨架构构建才从“能跑”变成“好用”。这篇就围绕这个场景,把原理、环境初始化、Dockerfile 适配、完整命令和排障经验一次讲透,适合手里只有 x86 机器、却要给 arm64 设备交付镜像的开发者,也适合准备从单架构发布升级到多架构发布的人参考。
1. 为什么需要专门用 buildx 构建 arm64 镜像
1.1 架构错位:x86_64 机器上为什么直接构建不出 arm64
Docker 镜像本质上是一个按层组织的根文件系统,外加一份运行时配置。docker build 的执行过程,是当前内核拉起来一个个构建容器,在容器里执行 RUN 指令。构建容器的进程会发起系统调用,这些系统调用最终落在宿主内核上。x86_64 内核提供的系统调用接口是 x86_64 的,构建容器里跑的是 amd64 用户态二进制,所以产物天然是 amd64。这一点很多人会忽略:镜像是可以跨平台分发的,但构建过程被死死锁在当前 CPU 架构上。
那么问题来了,目标设备是 arm64,镜像打包出来却全是 amd64 二进制,拿到 arm64 设备上拉取,Docker 会先去看 manifest 索引,发现没有 arm64 条目,要么直接报错,要么依靠设备侧自带的模拟层去硬跑——但很多精简系统根本没装模拟器,于是最常见的反馈就是 exec format error。这个报错的本质不是容器没装对,而是 ELF 文件头的架构字段和内核不匹配。所以跨架构构建不能靠“在 x86 里多设几个环境变量”解决,必须让构建引擎理解目标架构,并且能在必要时模拟出目标架构的执行环境。
1.2 buildx 的抽象层次:builder、node 和 BuildKit
docker buildx 并不是一个完全独立的镜像构建工具,它更像 Docker 官方构建框架的扩展层。它把整个构建能力抽象成三类东西:builder(构建器)、node(节点)、platform(目标平台)。一个 builder 可以包含多个 node,每个 node 对应当前 Docker 环境中一个具体的 BuildKit 实例;platform 则是你通过 --platform 指定的目标架构列表。
当你敲下 docker buildx build --platform linux/arm64 的时候,命令的处理顺序是这样的:buildx 先确定用哪个 builder,再把构建上下文和平台清单发给该 builder 对应的 BuildKit 服务;BuildKit 根据平台清单,为每个平台单独准备构建环境;最后在对应环境中执行 Dockerfile 中的指令,并把产物打包成镜像或直接推送。换句话说,buildx 的核心价值是“平台路由”——它让你不用手动切换机器、不用手工配置交叉编译环境,只要在命令里声明目标平台即可。默认的 docker driver 不支持这种多架构路由,所以我们需要创建一个 docker-container 驱动的 builder,这也是后续操作的基础。
1.3 模拟执行层:QEMU 与 binfmt_misc 的注册原理
docker-container 驱动虽然支持多架构,但 BuildKit 在 x86_64 宿主机上拉起一个 arm64 构建容器时,仍然需要某种方式让容器里的 arm64 二进制能够运行。这里的答案,是内核的 binfmt_misc 机制加上 QEMU 用户态模拟。
binfmt_misc 是 Linux 内核提供的一种“按文件格式指派解释器”的能力。当内核加载一个可执行文件时,会先看它的格式标识;如果这个格式在 binfmt_misc 中注册过,并且指定了解释器,内核就把执行工作交给解释器。QEMU 的用户态模式(qemu-user-static)就是这样一个解释器:它读取 arm64 的 ELF 可执行文件,逐条翻译成宿主机可以执行的操作,并通过系统调用转换,让一个 x86_64 进程去替 arm64 程序完成各种系统请求。整个过程对用户态的程序来说几乎是透明的。
所以,在 x86_64 机器上构建 arm64 镜像之前,必须先把 binfmt_misc 的注册项装好,同时把 qemu-user-static 放到宿主机能访问的位置。常见做法是运行一条特权容器命令,容器会把模拟器二进制复制到宿主机,并注册好 binfmt 条目;也可以直接通过系统包管理器安装 qemu-user-static 和 binfmt-support 再手动注册。遗漏这一环,最常见的问题就是构建看起来一切正常,但只要执行任何需要二进制运行的 RUN 指令就直接报错,而且报错信息往往藏在长长的构建日志中间,不容易一眼发现。
1.4 驱动类型对比:docker、docker-container 与 kubernetes
初始化 buildx 时,驱动是一个决定性参数。三个主流驱动各有取舍,我整理了一张表格方便对照:
| 驱动类型 | 多架构支持 | 缓存持久性 | 适用场景 |
|---|---|---|---|
| docker 驱动 | 不支持 | 依赖 Docker 守护进程,缓存不可控 | 老式构建流程,跨平台能力约等于零 |
| docker-container 驱动 | 支持 | 独立 BuildKit 容器,缓存可持久导出 | 单机多架构构建,最推荐的默认选择 |
| kubernetes 驱动 | 支持 | 由集群存储决定 | CI/CD、大规模并行构建,可调度不同架构节点 |
我的建议是:日常开发先在 docker-container 驱动上跑通,等构建量大、对性能有硬性要求,再考虑 kubernetes 驱动。docker 驱动虽然命令长得一样,但内部还是旧式构建路径,即使你写好了 --platform,它也不会真正为 arm64 单独构建,只会静默产出错误架构的镜像,十分容易误导新手。
2. 环境准备与初始化:先把多架构能力打开
2.1 检查版本:Docker、buildx 和内核缺一不可
开始之前先确认三件事:Docker 版本、buildx 插件是否可用、CPU 架构。新版 Docker 一般自带 buildx,可以直接执行 docker buildx version 看输出。如果提示 command not found,说明需要手动启用插件;如果 Docker 版本较旧,建议直接升级到最新稳定版。buildx 的很多参数,比如 --platform、--output、--cache-to,依赖 Docker 和 BuildKit 的版本配合,老版本会出现参数不存在或者行为不一致的问题。
然后看内核:uname -m 输出 x86_64 是正常状态,说明你正运行在 amd64 宿主机上。如果输出 aarch64,说明你本来就在 arm64 机器上,构建 arm64 镜像就不需要 QEMU 了,但构建 amd64 镜像时原理完全对称,所以后续内容依然适用。还有一个隐藏检查项:内核是否加载了 binfmt_misc 模块,通常能 ls /proc/sys/fs/binfmt_misc/ 看到目录就可以。这一步 30 秒就能完成,却能省掉后面的所有无效调试。
2.2 创建容器驱动构建器并设为默认
确认版本没问题后,按下面的顺序操作。我这里建议一步步来,不要一次性把所有命令粘进去执行,因为每条命令的输出信息对判断问题很有帮助,尤其是 create 之后的初始化失败提示,往往会被一堆滚动日志冲掉。
docker buildx create \ --name multiarch-builder \ --driver docker-container \ --use--name 指定构建器名称,--driver docker-container 选择容器驱动,--use 表示直接切换。这里有一个容易忽略的细节:如果不显式指定 driver,buildx 可能回退到默认驱动,导致后面所有平台参数形同虚设。创建之后,还要执行初始化检查:
docker buildx inspect --bootstrap--bootstrap 会立即启动 BuildKit 容器,并输出 builder 当前状态和节点信息。看到节点下面的状态是 ok,说明构建器已经就绪。如果状态不是 ok,大概率是镜像拉取失败或驱动容器启动异常,可以先看 Docker 里有没有名为 buildx_buildkit_multiarch-builder 的容器在运行,没有就去查 Docker 日志。
2.3 注册 binfmt 并确认目标平台可识别
这一步解决前文说的模拟执行层。在 docker-container 构建器创建之后,执行一次 binfmt 注册很有必要。我常用的方式是直接跑一个 qemu-user-static 安装容器,公共镜像仓库中搜索 qemu-user-static 就能找到最新版本,并传入 --install all 参数:
docker run --privileged --rm \ <qemu-user-static 镜像名> \ --install all注意:--privileged 是必须的,因为注册 binfmt_misc 需要写 /proc/sys/fs/binfmt_misc,普通容器没有这个权限。如果你对特权容器有顾虑,可以在构建机第一次初始化时执行一次,之后不再重复。某些离线环境下,还可以用系统软件包安装方式,比如安装 qemu-user-static 和 binfmt-support,效果一样。
注册完成后,把 builder 停掉再重新启动,让 BuildKit 识别新的 binfmt 配置。具体操作是 docker buildx stop multiarch-builder,然后再次 docker buildx inspect --bootstrap。最后检查支持的平台列表,输出中如果包含 linux/arm64,就可以进入下一步了。这个“重启 builder”的动作很多人会漏掉,后面构建失败时又反过来怀疑注册没成功,来回折腾老半天。
3. Dockerfile 里的 arm64 适配细节:工具链、依赖源和基础镜像
3.1 基础镜像的架构策略:多架构 manifest 是前提
Dockerfile 的第一行 FROM 看似简单,却是 arm64 构建成败最容易被忽略的环节。BuildKit 构建多平台镜像时,会按目标平台自动选择基础镜像中对应架构的层。也就是说,基础镜像本身必须提供 arm64 变体,并且它的 manifest 是以 manifest list 形式发布的。公共镜像仓库中大部分主流基础镜像都有多架构版本,但总有一些老镜像只发布了 amd64。
一个排查技巧:先不急着 build,用 docker buildx imagetools inspect <镜像名> 查看该镜像的 platform 列表。如果输出的数组里没有 arm64,要么换一个镜像,要么放弃 FROM 直拉,先解决基础镜像的多架构兼容问题。我还遇到过另一种情况:基础镜像支持 arm64,但默认 tag 指向的其实是单架构 manifest,比如某些带精简后缀的变体只发布了 amd64。遇到这类问题,建议直接换 tag,别硬扛。
3.2 交叉编译还是模拟编译?GOARCH、CGO_ENABLED 和 GOARM
工具链部分,行业里的共识是:能用交叉编译解决的就不要依赖 QEMU 模拟。以 Go 举例,同一段源码在 x86 上构建 arm64 产物,理论上只需要设置 GOOS=linux、GOARCH=arm64,然后正常执行 go build。因为生成目标架构的机器码是编译器的本职工作,并不需要目标架构的 CPU 参与。真正麻烦的是 CGO:一旦 CGO_ENABLED=1,编译器就要调用 C 工具链去处理依赖 C 库的代码,这时候必须让 C 编译器知道你最终面向的是 arm64,否则它默认生成 amd64 的目标文件,链接阶段再报错。在 buildx 的多阶段构建场景里,我习惯这样处理:
FROM 你惯用的 golang 构建镜像 AS build ARG TARGETOS ARG TARGETARCH ARG TARGETVARIANT ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ GOARM=${TARGETVARIANT} CGO_ENABLED=0 RUN go build -o /app .TARGETOS、TARGETARCH、TARGETVARIANT 是 BuildKit 在构建多平台时自动注入的构建参数,分别代表目标操作系统、CPU 架构和变体。比如在 arm/v7 场景下,GOARM 会被设置成 7。没有这几行,即使你在命令里指定了 --platform linux/arm64,Go 编译器仍会按宿主机架构去生成二进制,最终产物就是错的。CGO_ENABLED=0 的好处是把静态链接打开,省去交叉 C 编译器的配置。要提醒的是,关闭 CGO 之后,依赖 sqlite、网络认证库等 CGO 特性的 Go 包会退回纯 Go 实现,个别性能指标会有变化,需要针对性验证。
如果项目必须用 CGO,Dockerfile 里就不能只依赖模拟环境了。一种办法是在构建容器里安装交叉编译器,比如 aarch64-linux-gnu-gcc,然后让构建脚本去调用它;另一种办法是接受 QEMU 模拟编译慢的事实,在模拟环境里装 arm64 原生 gcc。后者的构建时间往往从几分钟膨胀到半小时以上,我的态度是:能静态就静态,不能静态就老老实实准备交叉编译链。
3.3 依赖安装的架构差异:apt、pip、npm 与预编译包
不同语言的依赖仓库对 arm64 的支持差异非常大,这也是跨架构构建里最容易让新手困惑的地方。apt 源方面,主流发行版的官方源一般都有 arm64 分支,但如果你换过镜像源,就要确认该源是否同步了 arm64 的包索引文件。pip 和 npm 的情况更微妙:一个包如果在目标架构上有对应 wheel,pip 会直接下载,整个过程非常快;如果找不到,pip 会回退到源码编译,在 QEMU 模拟环境下往往要编译几分钟甚至更久。
举一个例子:某次项目里用到了图像处理库,官方源其实提供了多平台 wheel,但当时锁定的依赖版本比较老,只发布了 amd64 和部分平台,arm64 用户就只能现场编译。后来把依赖升级到提供多架构 wheel 的版本,构建时间从 30 分钟骤降到 4 分钟。所以当构建日志里出现 Building wheel for 的时候,不要急着吐槽速度,先确认是不是依赖解析到了不支持 arm64 的版本;如果是,就锁定一个提供多架构预编译产物的版本。npm 同理,涉及 node-gyp 原生模块的包会触发 node-gyp rebuild,那也是在模拟环境里调用工具链,很容易失败,优先寻找有预编译二进制的替代包,成本低得多。
4. 实操:从单架构到多架构镜像的完整构建命令
4.1 单 arm64 镜像:--load 与 --push 如何选择
环境就绪后,第一条最直接的命令是:
docker buildx build \ --platform linux/arm64 \ -t 你的镜像名:arm64 \ .这条命令会用当前默认构建器(也就是刚才创建的 multiarch-builder)构建一个 arm64 镜像。但你会发现,构建成功后 docker images 里看不到它。原因在于 docker-container 驱动运行在独立容器里,构建产物默认保留在 BuildKit 自己的管理空间中,不会自动加载到当前 Docker 引擎。需要让本机能直接跑这个镜像,就加 --load;需要直接推到镜像仓库,就加 --push。
注意 --load 和多平台组合的硬限制:当前 docker-container 驱动在同一条命令里只能 load 一个平台的镜像。如果你想一次性构建 amd64 和 arm64 两个平台,还加了 --load,buildx 会直接报错。所以,单机调试时只 load 一个平台,多平台发布时直接用 --push。另一个细节是镜像命名:单架构调试镜像的 tag 加上 -arm64 后缀,能有效避免和本机 amd64 镜像混淆,我在本地调试阶段一直是这么做的。
4.2 双架构镜像:一次构建同时推 amd64 与 arm64
实际交付时,没人想对着同一套源码跑两遍。多架构镜像的正确打开方式是让 manifest list 自动管理架构差异:
docker buildx build \ --platform linux/amd64,linux/arm64 \ -t 你的镜像名:latest \ --push .这条命令会把两个平台的镜像层分别构建出来,然后由 BuildKit 生成一个 manifest index 推到远程仓库。arm64 设备拉取时,客户端会向仓库查询索引,拿到 arm64 的 digest;amd64 设备同理。对使用者来说,一个 tag 就够。这也是多架构发布最核心的价值:不需要用户手动区分平台,Docker 客户端会自动匹配。
这里有一个构建顺序细节:--platform 里 amd64 通常放在前面,但实际构建会按需调度,当前宿主机是 x86_64 时,BuildKit 会让 amd64 走原生路径,arm64 走 QEMU 模拟路径。两个平台共享 Dockerfile、共享源码目录,但各自独立执行 RUN 指令。正因为如此,任何在 RUN 阶段依赖宿主缓存或环境变量的逻辑都可能出问题,最典型的就是包管理器缓存的串扰,这也是为什么后文会专门讲缓存配置。
4.3 构建缓存:模拟环境下更要把缓存用好
跨平台模拟构建的成本比原生构建高很多,一个大的 Go 项目在 amd64 原生构建也许只要 5 分钟,切到 QEMU 模拟构建可能变成 20 分钟,如果每次全量重跑,流水线就彻底废了。buildx 的缓存导出机制正好对症下药:
docker buildx build \ --platform linux/arm64 \ --cache-from=type=registry,ref=你的镜像名:cache \ --cache-to=type=registry,ref=你的镜像名:cache,mode=max \ --push -t 你的镜像名:arm64 .这里把缓存独立成同一个仓库的 cache tag。type=registry 表示把缓存层推送到镜像仓库,ref 指定缓存对象;mode=max 表示把构建过程中产生的所有层元数据都缓存下来,包括 RUN 这一步的中间结果。默认的 mode=min 只缓存最后的产物层,对多阶段构建的帮助很有限。我把缓存从主 tag 里独立出来,是因为主 tag 的内容应该和正式发布保持一致,混入缓存层会让仓库里的 manifest 变得难以排查。
另一个提升缓存命中率的技巧是调整 Dockerfile 里 COPY 的顺序。依赖安装步骤固定不变的锁文件先拷进去,源码拷贝放到最后,这样只要依赖没有变化,即使改了业务代码,依赖层还是能直接命中缓存。这个技巧在普通 Dockerfile 里也适用,但在 QEMU 模拟环境下,一次命中的收益是十几分钟,价值被放大了很多。
4.4 进阶:用 Bake 配置单次构建多镜像多平台
当项目里不止一个镜像时,buildx build 命令会越来越长。我的建议是尽早迁到 docker buildx bake。Bake 支持用 HCL 文件集中描述构建目标,示例配置:
group "default" { targets = ["api", "worker"] } target "api" { context = "./services/api" platforms = ["linux/amd64", "linux/arm64"] output = ["type=registry"] args = { VERSION = "1.0.0" } } target "worker" { context = "./services/worker" platforms = ["linux/amd64", "linux/arm64"] output = ["type=registry"] }执行 docker buildx bake 之前,先用 docker buildx bake --print 检查解析后的完整参数,这个习惯能避免不少低级错误。Bake 的另一个优点是支持变量插值和目标之间的继承,比如所有服务都走同一份 platforms 和 output,可以抽到一个公共 target 里,再通过继承复用。等以后要加一个新的微服务,只需要加一个 target,不用改动任何命令脚本,这是命令行模式很难做到的。
第一次上手 Bake,建议只用 group 和 target 两个关键字,跑通一个镜像后再逐步引入变量。不要一开始就把 Compose 文件的变量风格带进来,HCL 的语法在熟悉之前容易踩坑,比如字符串拼接用加号而不是模板变量,这类小问题一旦出现,构建报错信息又不太直白,很容易劝退。
5. 常见问题与排查技巧实录
5.1 exec format error:先分构建期还是运行期
exec format error 大概是我被问得最多的报错。这个报错有两个完全不同的大场景,排查路径南辕北辙。
构建期报 exec format error,发生在 docker buildx build 执行过程中的某个 RUN 指令。这通常不是镜像内容问题,而是当前构建环境根本没执行目标架构二进制的能力。需要确认 binfmt 是否注册、builder 是不是 docker-container 驱动而不是默认驱动,再看 qemu-user-static 版本是否和当前内核匹配。
运行期报 exec format error,则是镜像构建成功、推到仓库、在目标设备上 docker run 报错。这多半是镜像的实际架构和目标设备不匹配,比如构建步骤都正常,但 Dockerfile 里 FROM 拉到的其实是 amd64 镜像,最终产物里混入了 amd64 二进制,执行自然失败。
判断手法:构建期先跑 docker buildx inspect --bootstrap 看节点状态;运行期用 docker buildx imagetools inspect 你的镜像名 看 manifest 列表里有没有 arm64 条目,再看目标设备的 uname -m 是不是 aarch64。两者都正常才需要往其他方向查。
5.2 QEMU 注册成功但构建仍然失败:重启 builder 走一遍
有一种情况很气人:系统监控里明明能看到 qemu 进程,但构建容器内执行任意命令都卡死或报错。我遇到过两次,最后定位到的原因都是 binfmt 注册发生在 builder 启动之前,而 BuildKit 容器启动时会缓存一部分执行环境信息,不会自动感知新注册的格式。解决方法特别朴素:把 builder 停掉重新拉起。docker buildx stop multiarch-builder,然后 docker buildx inspect --bootstrap。如果还是不行,检查宿主机的 binfmt_misc 目录,看 aarch64 对应的条目是否存在,qemu-aarch64-static 能否直接运行一个 arm64 的测试二进制。
再说一个容易踩的版本坑:Linux 内核版本过新,但 qemu-user-static 太老,可能在内核更新后出现模拟进程被信号杀死的现象,日志里甚至没有任何有效报错。升级 qemu-user-static 到最新版本,或者改用包含较新 QEMU 的安装镜像重新注册,基本能解决。这一条在跨平台构建圈子里踩过的人很多,值得记一笔。
5.3 缓存命中率低:问题常常出在 Dockerfile 编写顺序
缓存不命中的原因里,最常见但不唯一的问题是 COPY 顺序。很多项目习惯上来就是 COPY . .,然后 RUN go mod download 或 RUN npm ci。这样写,只要源码里任意一个文件变化,整个依赖下载层都要重新跑,在模拟环境下等于把 20 分钟白白浪费掉。标准解法是把依赖锁定文件单独 COPY:
COPY go.mod go.sum ./ RUN go mod download COPY . .第二个坑是把构建参数打到了缓存 key 里。buildx 对 --build-arg 的敏感度很高,如果你每次构建都传递了类似 --build-arg BUILD_TIME=$(date) 这种变化参数,即使 Dockerfile 没变,缓存也一定失效。对于版本号这类需要变化的参数,尽量只在最终产物层注入,不要把变化变量放在靠前的 RUN 指令里。第三个坑是 cache-to 的 mode 没有设成 max,多阶段构建的中间产物不会进入缓存,导致每次仍然重跑前面的阶段。这个在前面已经提过,但实际操作中它是最容易被忽略的,我再强调一次。
5.4 推送成功但拉取仍选错架构:仓库 manifest 索引细节
多架构镜像推上去后,最隐蔽的问题藏在仓库端。公共镜像仓库一般原生支持 manifest list,但某些私有仓库或者镜像代理服务,可能会把多平台镜像的推送过程做一次降级:当 BuildKit 推入 manifest list 时,仓库如果只支持单 manifest 格式,会只保留最后一个平台,其他架构直接丢弃。结果就是你从 x86 拉取正常,从 arm64 拉取时要么提示不存在、要么拿到错误架构。
排查时用 docker buildx imagetools inspect 你的镜像名:tag,看输出的顶层字段是不是 list 类型。如果显示的是一个单镜像 manifest,基本能断定仓库端配置有问题。解决思路是换到支持 OCI index 的仓库,或者检查仓库端是否开启了多架构支持开关。还有一个小经验:如果你看到 arm64 设备拉取时自动选到了 amd64 条目,说明你的 manifest 索引建立不完整,那种情况下不要用 --platform 硬指定去绕过,先修仓库端再重新推送,否则其他用户依旧会踩坑。
最后说几句个人习惯。第一,正式发多架构版本时,我不会复用开发机上长期存在的 builder,而是新建一个临时的 docker-container builder,用完就删,避免本地旧缓存和注册信息干扰判断。第二,本地调试单 arm64 镜像时一定用 --load,多平台上线一定用 --push,这两个参数看起来简单,混用或者省略的后果往往要过很久才暴露。第三,项目镜像一旦超过两三个,就尽快迁到 bake,用 HCL 文件统一管理平台列表、镜像标签和输出方式,比记忆长命令稳定得多。arm64 只是整个多架构发布链条里最常见的一环,等你把这一套跑顺之后,再加 linux/arm/v7 或者其他平台,只是改一行平台列表的事。希望这篇能帮你少踩几个 exec format error,尤其是第一个。