我的实际环境是一个 Kubernetes WebIDE Pod:容器中没有 systemd,也没有 CAP_SYS_ADMIN。虽然可以把 Docker 静态二进制安装到 /usr/local/bin,甚至让 dockerd 使用 vfs 存储驱动启动,但拉取镜像时仍会在解包镜像层阶段失败:

1
2
failed to register layer: ApplyLayer exit status 1
Error creating mount namespace before pivot: operation not permitted

原因是当前 Pod 不能创建 mount namespace。在这个环境里继续切换 Docker 存储驱动、安装 iptables,或者把命令从 Docker 换成 Nerdctl,都无法补回缺失的内核权限。

因此,本文采用客户端与构建端分离的方式:

  1. 在 WebIDE Pod 中只安装 buildctl v0.14.1,用于提交构建任务。
  2. 在具备 CAP_SYS_ADMIN、containerd 和 systemd 的构建节点上运行 buildkitd。
  3. QEMU 和 binfmt_misc 也配置在构建节点,而不是 WebIDE Pod。
  4. 构建结果由 BuildKit 直接推送到 Registry,WebIDE 中不执行本地 docker pull 或 nerdctl run。

本文以 linux/amd64 和 linux/arm64 为例,记录这套适用于非特权开发容器的多架构构建流程。

本文参考了陈少文的《使用 Nerdctl 构建多架构镜像》,并结合实际使用重新组织和补充。

多架构镜像是什么

多架构镜像并不是一份二进制同时兼容 x86 和 ARM,而是一个 OCI Image Index 指向多份不同平台的子镜像:

1
2
3
4
5
example.com/demo/hello:1.0.0
|
+-- linux/amd64 --> sha256:...
|
+-- linux/arm64 --> sha256:...

使用者拉取统一标签时,容器运行时会根据宿主机平台选择对应的镜像。这样,部署清单不需要分别维护 hello-amd64 和 hello-arm64 两个地址。

这里的 WebIDE Pod 只运行 BuildKit 客户端。BuildKit daemon、containerd 以及跨架构执行所需的 QEMU 和 Linux binfmt_misc 全部位于远端构建节点。

在 WebIDE 中安装 buildctl v0.14.1

本文固定使用 BuildKit v0.14.1。Release 压缩包同时包含 buildctl 和 buildkitd,但 WebIDE 端只需要安装 buildctl。

先确认 WebIDE Pod 的架构:

1
uname -m

然后下载相应的 Linux 安装包,并只复制 buildctl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
set -euo pipefail

BUILDKIT_VERSION=v0.14.1

case "$(uname -m)" in
x86_64)
BUILDKIT_ARCH=amd64
;;
aarch64|arm64)
BUILDKIT_ARCH=arm64
;;
*)
echo "Unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac

archive="buildkit-${BUILDKIT_VERSION}.linux-${BUILDKIT_ARCH}.tar.gz"
download_url="https://github.com/moby/buildkit/releases/download/${BUILDKIT_VERSION}/${archive}"

curl -fL "${download_url}" -o "/tmp/${archive}"
rm -rf /tmp/buildkit-v0.14.1
mkdir -p /tmp/buildkit-v0.14.1
tar -xzf "/tmp/${archive}" -C /tmp/buildkit-v0.14.1
sudo install -m 0755 /tmp/buildkit-v0.14.1/bin/buildctl /usr/local/bin/buildctl
rm -f "/tmp/${archive}"
rm -rf /tmp/buildkit-v0.14.1

buildctl --version

输出应包含:

1
buildctl github.com/moby/buildkit v0.14.1

buildctl 只是客户端,不会在本地解包镜像层,也不要求 WebIDE Pod 内存在可用的 Docker daemon。

确认当前 Pod 的权限限制

可以用下面的命令确认当前环境是否允许创建 mount namespace:

1
2
unshare -m true
grep CapEff /proc/self/status

在非特权 WebIDE Pod 中,第一条命令通常返回:

1
unshare: unshare failed: Operation not permitted

这意味着本地 dockerd 即使使用下面的参数启动,也只能让 daemon 进程进入可响应状态,不能保证镜像层能够解包,更不能保证容器可以运行:

1
dockerd --storage-driver=vfs --iptables=false --bridge=none

vfs 可以绕过 overlay mount,--iptables=false --bridge=none 可以绕过 Pod 缺少 NET_ADMIN 导致的网络初始化问题,但 Docker 的 ApplyLayer 仍然需要创建 mount namespace。因此,这不是当前场景下可用的构建方案。

在远端构建节点安装 buildkitd

下面的操作不在 WebIDE Pod 内执行,而是在具有 systemd、containerd 和必要内核权限的构建节点上执行。

构建节点也安装 BuildKit v0.14.1。下载步骤与上一节相同,但需要安装完整的 bin 目录:

1
2
3
4
5
6
7
8
9
10
11
12
BUILDKIT_VERSION=v0.14.1
BUILDKIT_ARCH=amd64
archive="buildkit-${BUILDKIT_VERSION}.linux-${BUILDKIT_ARCH}.tar.gz"

curl -fL \
"https://github.com/moby/buildkit/releases/download/${BUILDKIT_VERSION}/${archive}" \
-o "/tmp/${archive}"

sudo tar -xzf "/tmp/${archive}" -C /usr/local
rm -f "/tmp/${archive}"

buildkitd --version

如果构建节点本身是 ARM64,需要把 BUILDKIT_ARCH 改成 arm64。

创建 BuildKit 配置目录和数据目录:

1
sudo mkdir -p /etc/buildkit /var/lib/buildkit

写入 /etc/buildkit/buildkitd.toml。下面使用 containerd worker,并通过带双向 TLS 的 TCP 端口向 WebIDE 提供服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
debug = false
root = "/var/lib/buildkit"

[grpc]
address = ["unix:///run/buildkit/buildkitd.sock", "tcp://0.0.0.0:1234"]

[grpc.tls]
cert = "/etc/buildkit/tls/server.crt"
key = "/etc/buildkit/tls/server.key"
ca = "/etc/buildkit/tls/ca.crt"

[worker.oci]
enabled = false

[worker.containerd]
enabled = true
address = "/run/containerd/containerd.sock"
namespace = "buildkit"
platforms = ["linux/amd64", "linux/arm64"]
gc = true
gckeepstorage = 9000

这里启用了 containerd worker,并关闭 OCI worker。platforms 声明 BuildKit worker 对外提供的目标平台,但不会自动安装 QEMU。namespace = "buildkit" 表示构建缓存及结果使用 containerd 的 buildkit namespace。

不要直接把未加密、未鉴权的 BuildKit TCP 端口暴露到公网。server.crt、server.key 和 ca.crt 需要由内部 CA 签发和分发;WebIDE 端只保存客户端证书,不应持有服务端私钥。

然后创建 /etc/systemd/system/buildkitd.service:

1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=BuildKit daemon
Documentation=https://github.com/moby/buildkit
After=containerd.service
Requires=containerd.service

[Service]
ExecStart=/usr/local/bin/buildkitd --config /etc/buildkit/buildkitd.toml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

加载并启动服务:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable --now buildkitd
sudo systemctl status buildkitd

在构建节点确认 worker:

1
sudo buildctl debug workers

输出中的 Platforms 应包含 linux/amd64 和 linux/arm64。

在构建节点安装跨架构支持

如果构建节点是 amd64,Dockerfile 中却需要在 arm64 阶段执行 RUN,构建节点必须能够解释 ARM 指令。可以在具有特权权限的构建节点上使用 Nerdctl 注册 QEMU:

1
2
sudo nerdctl run --privileged --rm \
tonistiigi/binfmt:latest --install arm64,amd64

该命令会修改构建节点的 binfmt_misc 配置,因此需要特权权限。不要在当前非特权 WebIDE Pod 中执行;即使以 root 用户运行,也不会获得 Pod 未被授予的 capability。共享构建机上还应使用内部审核过、最好固定到 digest 的 binfmt 镜像。

检查注册结果:

1
ls -1 /proc/sys/fs/binfmt_misc/qemu-*

在构建节点运行异构镜像进行验证:

1
2
sudo nerdctl run --rm --platform linux/arm64 \
alpine:3.20 uname -m

在 amd64 构建机上,如果输出 aarch64,说明 ARM64 模拟已经生效。

编写支持多架构的 Dockerfile

先准备一个最小示例:

1
2
3
4
5
6
# syntax=docker/dockerfile:1.8.1
FROM alpine:3.20

RUN printf 'image built for: ' > /platform.txt && uname -m >> /platform.txt

CMD ["cat", "/platform.txt"]

这个例子可以验证构建阶段的命令是否确实在目标平台环境中执行。真实项目还经常需要根据架构下载不同的预编译文件,此时可以使用 BuildKit 自动注入的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# syntax=docker/dockerfile:1.8.1
FROM --platform=$BUILDPLATFORM alpine:3.20 AS downloader

ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ARG APP_VERSION=1.2.3

RUN apk add --no-cache curl tar
RUN curl -fL \
"https://downloads.example.com/app/v${APP_VERSION}/app-${TARGETOS}-${TARGETARCH}.tar.gz" \
-o /tmp/app.tar.gz \
&& mkdir -p /out \
&& tar -xzf /tmp/app.tar.gz -C /out

FROM alpine:3.20
COPY --from=downloader /out/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]

几个变量的含义如下:

  • BUILDPLATFORM:实际运行构建任务的主机平台。
  • TARGETPLATFORM:当前输出镜像的完整目标平台,如 linux/arm64。
  • TARGETOS:目标操作系统,如 linux。
  • TARGETARCH:目标 CPU 架构,如 amd64 或 arm64。
  • TARGETVARIANT:架构变体,如 ARM 的 v7,也可能为空。

下载地址的命名方式并没有统一标准。有些项目使用 x86_64、aarch64,另一些使用 amd64、arm64,不能直接假设 ${TARGETARCH} 一定与上游文件名一致。必要时应在 Dockerfile 中建立显式映射。

从 WebIDE 连接远端 BuildKit

将内部 CA 签发的客户端证书放入 WebIDE,例如:

1
2
3
${HOME}/.config/buildkit/ca.crt
${HOME}/.config/buildkit/client.crt
${HOME}/.config/buildkit/client.key

设置远端地址并检查 worker:

1
2
3
4
5
6
7
8
export BUILDKIT_HOST=tcp://buildkit.example.com:1234

buildctl \
--addr "${BUILDKIT_HOST}" \
--tlscacert "${HOME}/.config/buildkit/ca.crt" \
--tlscert "${HOME}/.config/buildkit/client.crt" \
--tlskey "${HOME}/.config/buildkit/client.key" \
debug workers

输出中应包含 linux/amd64 和 linux/arm64。如果连接超时,需要检查 Service、网络策略、防火墙以及端口是否可达;如果 TLS 校验失败,则检查证书中的主机名与访问地址是否一致。

使用 buildctl 构建并推送镜像

BuildKit 会通过会话读取客户端的 Docker Registry 凭据。可以在 WebIDE 中执行 docker login,这个操作只写入 ${HOME}/.docker/config.json,不要求本地 Docker daemon 正常运行:

1
2
3
printf '%s' "${REGISTRY_PASSWORD}" | docker login \
--username "${REGISTRY_USERNAME}" \
--password-stdin registry.example.com

定义完整镜像地址:

1
image=registry.example.com/demo/hello:1.0.0

从当前目录提交 Dockerfile 和构建上下文,直接把双架构镜像推送到 Registry:

1
2
3
4
5
6
7
8
9
10
11
buildctl \
--addr "${BUILDKIT_HOST}" \
--tlscacert "${HOME}/.config/buildkit/ca.crt" \
--tlscert "${HOME}/.config/buildkit/client.crt" \
--tlskey "${HOME}/.config/buildkit/client.key" \
build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--opt platform=linux/amd64,linux/arm64 \
--output "type=image,name=${image},push=true"

--output type=image,...,push=true 会让远端 BuildKit 生成 OCI Image Index,并直接推送各平台镜像及统一标签。WebIDE Pod 不需要先把镜像导入本地 Docker 或 containerd,因此不会触发本地 ApplyLayer 错误。

验证远端结果

可以使用只读取 Registry manifest 的工具查看镜像索引。该操作不需要在 WebIDE 中解包镜像层:

1
docker buildx imagetools inspect "${image}"

输出中应同时出现:

1
2
linux/amd64
linux/arm64

不要在当前 WebIDE Pod 中用 docker pull 作为验证方式,它会在解包镜像层时再次触发 mount namespace 权限错误。真正的运行验证应放到具有完整容器权限的原生节点:

1
2
3
4
5
nerdctl pull --platform linux/amd64 "${image}"
nerdctl run --rm --platform linux/amd64 "${image}"

nerdctl pull --platform linux/arm64 "${image}"
nerdctl run --rm --platform linux/arm64 "${image}"

元数据检查只能证明索引中存在对应条目,不能证明应用真的可用。条件允许时,还应在原生 amd64、arm64 节点上各运行一次冒烟测试,避免把 QEMU 模拟成功误认为生产环境验证通过。

常见问题

提示 exec format error

通常有三种原因:

  1. QEMU 或 binfmt_misc 没有正确注册。
  2. 下载的业务二进制与 ${TARGETARCH} 不匹配。
  3. 镜像索引中的平台标注与镜像真实内容不一致。

可以先用 uname -m 的最小镜像验证模拟环境,再逐步检查业务二进制:

1
file /usr/local/bin/app

连接不到 BuildKit

先在远端构建节点检查:

1
2
3
sudo systemctl status buildkitd
sudo journalctl -u buildkitd --no-pager -n 100
sudo buildctl debug workers

然后在 WebIDE 中检查远端端口和 TLS 配置。还要确认 BuildKit 配置中的 containerd socket 地址与构建节点的实际地址一致。

Docker pull 仍然报 ApplyLayer 错误

这正是当前非特权 Pod 的环境限制。下面这些调整不能从根本上解决问题:

  • 将 Docker 存储驱动从 overlay2 改成 vfs。
  • 安装 iptables 并重新启动 dockerd。
  • 添加 --iptables=false --bridge=none。
  • 使用 Nerdctl 代替 Docker CLI。

解决方式是给 Pod 增加构建所需的 capability 并按安全规范运行特权构建容器,或者像本文一样把构建和镜像运行放到外部节点。若目的只是复制镜像而不是运行,也可以使用 Skopeo 在 Registry 与 OCI archive 之间复制,因为它不需要把镜像解包进本地容器文件系统。

Kubernetes 中看不到刚构建的镜像

containerd 使用 namespace 隔离资源。Kubernetes 常用 k8s.io,Nerdctl 默认 namespace 则可能不同。可以显式指定:

1
sudo nerdctl --namespace k8s.io images

不过,多架构镜像的可靠交付方式仍然是推送到 Registry,再由各节点按 digest 或统一标签拉取,而不是依赖某台机器的本地 namespace。

跨架构构建为什么很慢

QEMU 需要翻译 CPU 指令,包含大量编译工作的 RUN 阶段会明显变慢。可以采用以下优化方式:

  • 使用原生 amd64 和 arm64 构建节点并行构建。
  • 把编译阶段固定在 $BUILDPLATFORM,通过交叉编译输出目标架构程序。
  • 合理使用 BuildKit cache mount 和远端缓存。
  • 将依赖下载与业务编译拆分,减少无效缓存失效。

总结

当前环境下的关键链路是:

1
2
3
4
5
WebIDE Pod 中的 buildctl
-> 通过 TLS 连接远端 buildkitd
-> 构建节点上的 QEMU/binfmt 提供跨架构执行能力
-> 构建节点上的 containerd worker 执行构建
-> Registry 保存 OCI Image Index 和各平台镜像

在无 systemd、无 CAP_SYS_ADMIN 的 WebIDE Pod 中,能够启动 Docker daemon 不等于能够拉取或运行镜像。将 buildctl 客户端与有权限的远端 BuildKit 分离,既绕开了本地 mount namespace 限制,也避免为了构建镜像而把开发 Pod 直接改成特权容器。最后仍需检查远端镜像索引,并在原生 amd64、arm64 节点上完成冒烟测试。