我的实际环境是一个 Kubernetes WebIDE Pod:容器中没有 systemd,也没有 CAP_SYS_ADMIN。虽然可以把 Docker 静态二进制安装到 /usr/local/bin,甚至让 dockerd 使用 vfs 存储驱动启动,但拉取镜像时仍会在解包镜像层阶段失败:
1 | failed to register layer: ApplyLayer exit status 1 |
原因是当前 Pod 不能创建 mount namespace。在这个环境里继续切换 Docker 存储驱动、安装 iptables,或者把命令从 Docker 换成 Nerdctl,都无法补回缺失的内核权限。
因此,本文采用客户端与构建端分离的方式:
- 在 WebIDE Pod 中只安装
buildctl v0.14.1,用于提交构建任务。 - 在具备
CAP_SYS_ADMIN、containerd 和 systemd 的构建节点上运行buildkitd。 - QEMU 和
binfmt_misc也配置在构建节点,而不是 WebIDE Pod。 - 构建结果由 BuildKit 直接推送到 Registry,WebIDE 中不执行本地
docker pull或nerdctl run。
本文以 linux/amd64 和 linux/arm64 为例,记录这套适用于非特权开发容器的多架构构建流程。
本文参考了陈少文的《使用 Nerdctl 构建多架构镜像》,并结合实际使用重新组织和补充。
多架构镜像是什么
多架构镜像并不是一份二进制同时兼容 x86 和 ARM,而是一个 OCI Image Index 指向多份不同平台的子镜像:
1 | example.com/demo/hello:1.0.0 |
使用者拉取统一标签时,容器运行时会根据宿主机平台选择对应的镜像。这样,部署清单不需要分别维护 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 | set -euo pipefail |
输出应包含:
1 | buildctl github.com/moby/buildkit v0.14.1 |
buildctl 只是客户端,不会在本地解包镜像层,也不要求 WebIDE Pod 内存在可用的 Docker daemon。
确认当前 Pod 的权限限制
可以用下面的命令确认当前环境是否允许创建 mount namespace:
1 | unshare -m true |
在非特权 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 | BUILDKIT_VERSION=v0.14.1 |
如果构建节点本身是 ARM64,需要把 BUILDKIT_ARCH 改成 arm64。
创建 BuildKit 配置目录和数据目录:
1 | sudo mkdir -p /etc/buildkit /var/lib/buildkit |
写入 /etc/buildkit/buildkitd.toml。下面使用 containerd worker,并通过带双向 TLS 的 TCP 端口向 WebIDE 提供服务:
1 | debug = false |
这里启用了 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 | [Unit] |
加载并启动服务:
1 | sudo systemctl daemon-reload |
在构建节点确认 worker:
1 | sudo buildctl debug workers |
输出中的 Platforms 应包含 linux/amd64 和 linux/arm64。
在构建节点安装跨架构支持
如果构建节点是 amd64,Dockerfile 中却需要在 arm64 阶段执行 RUN,构建节点必须能够解释 ARM 指令。可以在具有特权权限的构建节点上使用 Nerdctl 注册 QEMU:
1 | sudo nerdctl run --privileged --rm \ |
该命令会修改构建节点的 binfmt_misc 配置,因此需要特权权限。不要在当前非特权 WebIDE Pod 中执行;即使以 root 用户运行,也不会获得 Pod 未被授予的 capability。共享构建机上还应使用内部审核过、最好固定到 digest 的 binfmt 镜像。
检查注册结果:
1 | ls -1 /proc/sys/fs/binfmt_misc/qemu-* |
在构建节点运行异构镜像进行验证:
1 | sudo nerdctl run --rm --platform linux/arm64 \ |
在 amd64 构建机上,如果输出 aarch64,说明 ARM64 模拟已经生效。
编写支持多架构的 Dockerfile
先准备一个最小示例:
1 | # syntax=docker/dockerfile:1.8.1 |
这个例子可以验证构建阶段的命令是否确实在目标平台环境中执行。真实项目还经常需要根据架构下载不同的预编译文件,此时可以使用 BuildKit 自动注入的变量:
1 | # syntax=docker/dockerfile:1.8.1 |
几个变量的含义如下:
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 | ${HOME}/.config/buildkit/ca.crt |
设置远端地址并检查 worker:
1 | export BUILDKIT_HOST=tcp://buildkit.example.com:1234 |
输出中应包含 linux/amd64 和 linux/arm64。如果连接超时,需要检查 Service、网络策略、防火墙以及端口是否可达;如果 TLS 校验失败,则检查证书中的主机名与访问地址是否一致。
使用 buildctl 构建并推送镜像
BuildKit 会通过会话读取客户端的 Docker Registry 凭据。可以在 WebIDE 中执行 docker login,这个操作只写入 ${HOME}/.docker/config.json,不要求本地 Docker daemon 正常运行:
1 | printf '%s' "${REGISTRY_PASSWORD}" | docker login \ |
定义完整镜像地址:
1 | image=registry.example.com/demo/hello:1.0.0 |
从当前目录提交 Dockerfile 和构建上下文,直接把双架构镜像推送到 Registry:
1 | buildctl \ |
--output type=image,...,push=true 会让远端 BuildKit 生成 OCI Image Index,并直接推送各平台镜像及统一标签。WebIDE Pod 不需要先把镜像导入本地 Docker 或 containerd,因此不会触发本地 ApplyLayer 错误。
验证远端结果
可以使用只读取 Registry manifest 的工具查看镜像索引。该操作不需要在 WebIDE 中解包镜像层:
1 | docker buildx imagetools inspect "${image}" |
输出中应同时出现:
1 | linux/amd64 |
不要在当前 WebIDE Pod 中用 docker pull 作为验证方式,它会在解包镜像层时再次触发 mount namespace 权限错误。真正的运行验证应放到具有完整容器权限的原生节点:
1 | nerdctl pull --platform linux/amd64 "${image}" |
元数据检查只能证明索引中存在对应条目,不能证明应用真的可用。条件允许时,还应在原生 amd64、arm64 节点上各运行一次冒烟测试,避免把 QEMU 模拟成功误认为生产环境验证通过。
常见问题
提示 exec format error
通常有三种原因:
- QEMU 或
binfmt_misc没有正确注册。 - 下载的业务二进制与
${TARGETARCH}不匹配。 - 镜像索引中的平台标注与镜像真实内容不一致。
可以先用 uname -m 的最小镜像验证模拟环境,再逐步检查业务二进制:
1 | file /usr/local/bin/app |
连接不到 BuildKit
先在远端构建节点检查:
1 | sudo systemctl status buildkitd |
然后在 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 | WebIDE Pod 中的 buildctl |
在无 systemd、无 CAP_SYS_ADMIN 的 WebIDE Pod 中,能够启动 Docker daemon 不等于能够拉取或运行镜像。将 buildctl 客户端与有权限的远端 BuildKit 分离,既绕开了本地 mount namespace 限制,也避免为了构建镜像而把开发 Pod 直接改成特权容器。最后仍需检查远端镜像索引,并在原生 amd64、arm64 节点上完成冒烟测试。