一个镜像跑遍 amd64 和 arm64:buildx / QEMU,以及四个只在容器里犯的错

手上有 x86 的开发机和 ARM 的云服务器,想用同一个 tag 在两边都能 docker run 起来,还要在容器里常驻两个服务。这件事的难点不在 Dockerfile,在于多架构构建的几个前提条件不满足时,报错信息完全不指向真正的原因;以及容器环境和你平时的 Linux 习惯有几处不一样,踩上了不会崩,只会安静地不工作。

结论先放前面:多架构要三个前提——docker-container driver、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
2
docker buildx create --name multiarch --driver docker-container --bootstrap
docker buildx build --builder multiarch --platform linux/amd64,linux/arm64 ...

--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 installpip 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
2
3
4
docker buildx build --builder multiarch \
--platform linux/amd64,linux/arm64 \
-t myimage:tag \
--output "type=oci,dest=./myimage.oci.tar" .

我最后把三种模式写进了一个脚本:local 只构建本机架构并 load(日常用,快)、multiarch 构建全平台并推送、oci 导出 tar 供离线拷贝。

1.4 推完之后确认 manifest

1
docker buildx imagetools inspect imwl/hexo-jupyter:hexo8

真实输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Name:      docker.io/imwl/hexo-jupyter:hexo8
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:5599a1886246f86480955ca87e018a70d1a6c152e55202c537345f9a84a6b903

Manifests:
Name: ...@sha256:764ac88d16a1f479b520da03206b13f95125deded76e97d33fb45817824e3ee0
Platform: linux/amd64

Name: ...@sha256:93e059248096dfa02e66411bd735dad6cb7cc04163d9ff3dc37fdfdf85163f3c
Platform: linux/arm64

Name: ...@sha256:60aa8611e4ac9bc55af012be89b2a5a793dc57ce562d474862ee43149a48862f
Platform: unknown/unknown
Annotations:
vnd.docker.reference.type: attestation-manifest

顺便解释一下那两条 unknown/unknown:它们不是构建出错,是 buildx 默认附带的 attestation manifest(provenance 和 SBOM),每个真实平台配一条。看到它们不用管;不想要可以加 --provenance=false --sbom=false

1.5 真正的验证:把异构那份跑起来

docker pull 成功什么都证明不了——registry 会按你的架构给你那一份,你拉到的永远是能跑的那个。要验证另一份,得显式指定平台并真的执行它:

1
2
$ docker run --rm --platform linux/amd64 imwl/hexo-jupyter:hexo8 uname -m
x86_64

在 ARM 机器上得到 x86_64,说明 amd64 那份确实完整、且能通过 QEMU 跑起来。反过来同理。只 inspect.Architecture 是不够的,那只读元数据,层里缺东西照样看不出来。

2. PEP 668:Debian 12 起不让你 pip 进系统 Python

基础镜像是 Debian 12(bookworm)。想在里面装个 Python 工具,直接 pip install 会被拒绝。原因是这个文件:

1
2
3
4
5
6
7
$ ls /usr/lib/python3.11/EXTERNALLY-MANAGED
/usr/lib/python3.11/EXTERNALLY-MANAGED

$ head -3 /usr/lib/python3.11/EXTERNALLY-MANAGED
[externally-managed]
Error=To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to install.

这是 PEP 668 的标记,意思是”这个 Python 由发行版的包管理器管着,别用 pip 往里塞”。网上很多镜像的做法是加 --break-system-packages 或者干脆把这个文件删掉——能用,但你等于是把 apt 装的包和 pip 装的包混在同一个 site-packages 里,以后升级基础镜像时的冲突会很难查。

正路就是发行版自己在错误信息里写的那句:建个 venv。

1
2
3
4
ENV VIRTUAL_ENV=/opt/venv
RUN python3 -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN pip install --no-cache-dir jupyterlab notebook

把 venv 的 bin 放进 PATH 之后,后面所有 pippython 自然落在 venv 里,不需要每层都 source activate(在 Dockerfile 里 source 也不会跨 RUN 生效)。

3. ENV PATH 对登录 shell 无效

上一节那个 ENV PATH 有个陷阱:它只对非登录 shell 有效。

docker exec -it 容器 bash -l(或者任何触发 /etc/profile 的方式)会重新给 PATH 赋值,把 ENV 设的值整个盖掉。实测:

1
2
3
4
# 把 profile.d 里的修正临时移走
$ docker exec myhexo bash -c 'mv /etc/profile.d/10-venv.sh /tmp/'
$ docker exec myhexo bash -lc 'command -v jupyter || echo "找不到"'
找不到

docker exec myhexo jupyter --version(不走登录 shell)是好的。这种”手动进去敲不行、脚本里跑却可以”的现象最费时间,因为你会本能地怀疑安装步骤,而问题在 shell 初始化。

补一份 profile.d 就行,注意要幂等,别每次登录都往 PATH 前面叠一遍:

1
2
3
4
5
RUN printf '%s\n' 'case ":$PATH:" in' \
' *":/opt/venv/bin:"*) ;;' \
' *) PATH="/opt/venv/bin:$PATH" ;;' \
'esac' \
'export PATH' > /etc/profile.d/10-venv.sh

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
2
3
4
5
6
7
8
service_a & pid_a=$!
service_b & pid_b=$!

wait -n # 任一子进程退出就返回
code=$?
kill "$pid_a" "$pid_b" 2>/dev/null || true
wait 2>/dev/null || true
exit "${code:-1}" # 非零退出,交给 restart 策略重建整个容器

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
2
RUN git config --global --add safe.directory /usr/blog \
&& git config --global --add safe.directory /usr/blog/.deploy_git

真正值得记住的是它引出的另一个问题:上层工具在环境缺东西时,未必会报错,可能只是降级成什么都不做。

我这边的具体表现是,容器里没配 user.name / user.email 时,部署工具内部的 git commit 失败了,但整个部署命令退出码是 0,日志只有一行 Everything up-to-date。看上去是”没有改动所以不用推”,实际是提交根本没建起来。我对着这行日志确认了两遍”部署成功”,才发现线上根本没变。

1
2
3
4
ARG GIT_USER_NAME=itswl
ARG [email protected]
RUN git config --global user.name "$GIT_USER_NAME" \
&& git config --global user.email "$GIT_USER_EMAIL"

推论是:在容器里跑发布类命令,别只看退出码,要验证副作用。 部署完去看远端的 commit 有没有变、时间戳对不对,比读日志可靠。

6. 顺带一句:别急着拆容器

我最初起三个容器的理由是”职责分离”,但它们共享同一份 bind mount、跑在同一台机器上、生命周期完全一致——分开只是让 docker ps 更长、让”重启一下”这个动作变成三条命令。

拆容器的判据应该是独立伸缩、独立发布、或者故障域需要隔离,不是”看起来更规范”。这三条都不满足的时候,一个容器加一个正确的退出逻辑就够了。

7. 清单

  1. 多平台构建:docker buildx create --driver docker-container,别用默认 builder。
  2. 交叉构建前 docker run --privileged --rm tonistiigi/binfmt --install all,宿主重启后要重来。
  3. 多平台必须 --push;要离线产物走 --output type=oci
  4. imagetools inspect 里的 unknown/unknown 是 attestation,不是错误。
  5. 验证异构镜像要 --platform 显式指定并真的执行,看 uname -m,别只 inspect。
  6. Debian 12+ 装 Python 包用 venv,别删 EXTERNALLY-MANAGED
  7. ENV PATH 盖不住登录 shell,补一份幂等的 /etc/profile.d
  8. 多进程容器:tini 当 PID 1,wait -n 收尾并非零退出;entrypoint 用 bash 不用 sh。
  9. bind mount 加 root 记得 safe.directory,git 身份也一起烤进镜像。
  10. 发布类命令验证副作用,不要只看退出码。