一个镜像跑遍 amd64 和 arm64:buildx / QEMU,以及四个只在容器里犯的错
手上有 x86 的开发机和 ARM 的云服务器,想用同一个 tag 在两边都能 docker run 起来,还要在容器里常驻两个服务。这件事的难点不在 Dockerfile,在于多架构构建的几个前提条件不满足时,报错信息完全不指向真正的原因;以及容器环境和你平时的 Linux 习惯有几处不一样,踩上了不会崩,只会安静地不工作。
结论先放前面:多架构要三个前提——
docker-containerdriver、QEMU binfmt、以及必须推到 registry(manifest list 在本地存不下)。验证不能只看docker pull成功,得真的把异构那份跑起来看uname -m。另外四个坑:Debian 12 起不让 pip 装进系统 Python;ENV PATH对登录 shell 无效;多进程容器里一个服务死了另一个还占着端口;bind mount 加 root 会让 git 拒绝干活,而上层工具可能返回 0 假装成功。
0. 环境
| 构建机 | macOS / x86 |
| 目标 | linux/amd64 + linux/arm64 |
| 基础镜像 | node:22-bookworm-slim(官方有全部主流架构) |
| 容器内常驻 | 两个服务,各占一个端口 |
1. 多架构构建的三个前提
1.1 默认的 builder 不支持多平台输出
docker build --platform linux/amd64,linux/arm64 会直接失败。默认的 docker driver 走的是本地 daemon 的镜像存储,那套存储一个 tag 只能对应一个平台,装不下多平台的结果。
要换成 docker-container driver:
1 | docker buildx create --name multiarch --driver docker-container --bootstrap |
--bootstrap 是让它立刻把 builder 容器拉起来,不然第一次构建时才启动,报错会混在构建日志里不好认。
1.2 交叉构建靠 QEMU binfmt
在 x86 上构建 arm64 镜像,RUN 里的每一条命令都是 arm64 的二进制,要靠内核的 binfmt_misc 把它们转交给 QEMU 用户态模拟器。没注册的话,报错通常是 exec format error——这个错太泛了,看不出是缺模拟器。
一条命令注册:
1 | docker run --privileged --rm tonistiigi/binfmt --install all |
--privileged 是必须的,它要写 /proc/sys/fs/binfmt_misc。注册结果在宿主机重启后失效,所以放进构建脚本里每次跑一遍最省心。
代价是慢。模拟执行比原生慢好几倍,npm install、pip install 这种 CPU 密集的步骤感受最明显。如果构建时间不能接受,正路是用两台不同架构的机器各自原生构建,再手工 docker manifest create 合并——但那需要两台机器和一套 CI,个人项目用 QEMU 换省事很划算。
1.3 manifest list 只能在 registry 里生成
这是最反直觉的一条:多平台构建必须带 --push。
原因和 1.1 是同一个——本地 daemon 的镜像存储放不下”一个 tag 对应多个平台”这种结构。所谓多架构镜像,本质上是 registry 里的一个 manifest list:它自己不含任何层,只是一张”平台 → 具体 manifest 摘要”的索引表,客户端 pull 的时候按自己的架构去查表。这张表是 registry 侧的概念,本地没有对应物。
所以 --load(加载进本地 docker)和 --platform 多值是互斥的。想要离线产物,只能导出成 OCI 布局的 tar:
1 | docker buildx build --builder multiarch \ |
我最后把三种模式写进了一个脚本:local 只构建本机架构并 load(日常用,快)、multiarch 构建全平台并推送、oci 导出 tar 供离线拷贝。
1.4 推完之后确认 manifest
1 | docker buildx imagetools inspect imwl/hexo-jupyter:hexo8 |
真实输出:
1 | Name: docker.io/imwl/hexo-jupyter:hexo8 |
顺便解释一下那两条 unknown/unknown:它们不是构建出错,是 buildx 默认附带的 attestation manifest(provenance 和 SBOM),每个真实平台配一条。看到它们不用管;不想要可以加 --provenance=false --sbom=false。
1.5 真正的验证:把异构那份跑起来
docker pull 成功什么都证明不了——registry 会按你的架构给你那一份,你拉到的永远是能跑的那个。要验证另一份,得显式指定平台并真的执行它:
1 | $ docker run --rm --platform linux/amd64 imwl/hexo-jupyter:hexo8 uname -m |
在 ARM 机器上得到 x86_64,说明 amd64 那份确实完整、且能通过 QEMU 跑起来。反过来同理。只 inspect 看 .Architecture 是不够的,那只读元数据,层里缺东西照样看不出来。
2. PEP 668:Debian 12 起不让你 pip 进系统 Python
基础镜像是 Debian 12(bookworm)。想在里面装个 Python 工具,直接 pip install 会被拒绝。原因是这个文件:
1 | $ ls /usr/lib/python3.11/EXTERNALLY-MANAGED |
这是 PEP 668 的标记,意思是”这个 Python 由发行版的包管理器管着,别用 pip 往里塞”。网上很多镜像的做法是加 --break-system-packages 或者干脆把这个文件删掉——能用,但你等于是把 apt 装的包和 pip 装的包混在同一个 site-packages 里,以后升级基础镜像时的冲突会很难查。
正路就是发行版自己在错误信息里写的那句:建个 venv。
1 | ENV VIRTUAL_ENV=/opt/venv |
把 venv 的 bin 放进 PATH 之后,后面所有 pip 和 python 自然落在 venv 里,不需要每层都 source activate(在 Dockerfile 里 source 也不会跨 RUN 生效)。
3. ENV PATH 对登录 shell 无效
上一节那个 ENV PATH 有个陷阱:它只对非登录 shell 有效。
docker exec -it 容器 bash -l(或者任何触发 /etc/profile 的方式)会重新给 PATH 赋值,把 ENV 设的值整个盖掉。实测:
1 | # 把 profile.d 里的修正临时移走 |
而 docker exec myhexo jupyter --version(不走登录 shell)是好的。这种”手动进去敲不行、脚本里跑却可以”的现象最费时间,因为你会本能地怀疑安装步骤,而问题在 shell 初始化。
补一份 profile.d 就行,注意要幂等,别每次登录都往 PATH 前面叠一遍:
1 | RUN printf '%s\n' 'case ":$PATH:" in' \ |
4. 多进程容器:一个死了,另一个还占着端口
“一个容器一个进程”是对的,但也有确实不值得拆的场景——比如单机上两个共享同一份 bind mount 的辅助服务。我一开始起了三个容器(一个 shell、一个服务 A、一个服务 B),后来合成了一个。
合并之后要解决两件事。
第一,PID 1 的职责。 容器里的 PID 1 要负责转发信号和回收僵尸进程,普通程序不干这活。用 tini:
1 | ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] |
第二,任一服务挂掉时整个容器要一起退出。 这条最容易漏。如果不管,你会得到一个”半死不活”的容器:两个端口都还监听着(其实只剩一个进程在听),docker ps 显示 Up,restart 策略也不会触发——比直接崩掉难发现得多。
1 | service_a & pid_a=$! |
wait -n 是关键,它在任意一个子进程退出时返回,而不是等全部。注意它只有 bash 有,dash 没有——如果你的 entrypoint 第一行写的是 #!/bin/sh,在 Debian 上会被 dash 执行,wait -n 直接报语法错。这个错误发生在容器启动瞬间,日志一闪而过,很容易被当成”启动失败”而去查别的地方。
5. bind mount + root:git 会拒绝干活,而上层可能返回 0
宿主机上的项目目录属主是普通用户(uid 1000),容器默认以 root 跑。较新的 git 遇到这种”目录不属于当前用户”的情况会直接拒绝操作,报 detected dubious ownership。
1 | RUN git config --global --add safe.directory /usr/blog \ |
真正值得记住的是它引出的另一个问题:上层工具在环境缺东西时,未必会报错,可能只是降级成什么都不做。
我这边的具体表现是,容器里没配 user.name / user.email 时,部署工具内部的 git commit 失败了,但整个部署命令退出码是 0,日志只有一行 Everything up-to-date。看上去是”没有改动所以不用推”,实际是提交根本没建起来。我对着这行日志确认了两遍”部署成功”,才发现线上根本没变。
1 | ARG GIT_USER_NAME=itswl |
推论是:在容器里跑发布类命令,别只看退出码,要验证副作用。 部署完去看远端的 commit 有没有变、时间戳对不对,比读日志可靠。
6. 顺带一句:别急着拆容器
我最初起三个容器的理由是”职责分离”,但它们共享同一份 bind mount、跑在同一台机器上、生命周期完全一致——分开只是让 docker ps 更长、让”重启一下”这个动作变成三条命令。
拆容器的判据应该是独立伸缩、独立发布、或者故障域需要隔离,不是”看起来更规范”。这三条都不满足的时候,一个容器加一个正确的退出逻辑就够了。
7. 清单
- 多平台构建:
docker buildx create --driver docker-container,别用默认 builder。 - 交叉构建前
docker run --privileged --rm tonistiigi/binfmt --install all,宿主重启后要重来。 - 多平台必须
--push;要离线产物走--output type=oci。 imagetools inspect里的unknown/unknown是 attestation,不是错误。- 验证异构镜像要
--platform显式指定并真的执行,看uname -m,别只 inspect。 - Debian 12+ 装 Python 包用 venv,别删
EXTERNALLY-MANAGED。 ENV PATH盖不住登录 shell,补一份幂等的/etc/profile.d。- 多进程容器:tini 当 PID 1,
wait -n收尾并非零退出;entrypoint 用 bash 不用 sh。 - bind mount 加 root 记得
safe.directory,git 身份也一起烤进镜像。 - 发布类命令验证副作用,不要只看退出码。