老吴的研发日志

制作便携的 Docker 镜像构建工具

本文记录了一个在 Windows 上绕开 WSL 与 Hyper-V、通过 QEMU 虚拟机构建 Docker 镜像的工具的制作过程。该实现方式可用于任何可编译、运行 QEMU 和 Python 的系统环境。

该项目源代码可 在 Github 查看 ,或 直接下载

在 Windows 上不依赖 WSL/Hyper-V 从 Dockerfile 构建 Linux 镜像这个操作原生环境做不到。构建过程中 RUN 指令执行的是 Linux 程序,需要一个 Linux 容器运行时环境,Windows 原生提供不了。Docker Desktop、Podman 的实际做法是借助 Hyper-V 或 WSL 先起一个 Linux 环境,构建发生在那个环境里。问题在于这两层附加环境对普通用户很容易安装配置失败,且每台机器情况不同,出了问题没有一致的处理办法。装不上,就完全没有构建镜像的能力。

我的思路是绕开宿主机环境,用 QEMU 起一个 Linux 虚拟机来执行构建。QEMU 有性能损失,但几乎不依赖宿主机安装了什么,行为稳定可靠。对"从无到有"这个目标,代价可以接受。效率、资源占用是之后再考虑的事情。下面按制作思路,一个环节一个环节说。

本文环境与工具链版本:

构建环境是怎么搭起来的

构建系统选 Alpine Linux 的 virt 变体。它镜像小,是官方发行版,直接拿来用,不需要为构建定制系统镜像,之后更新系统和软件都省事。

整个环境由三块盘组成:

格式作用
system.isoISO,cdromAlpine virt live 启动盘,每次运行从这里冷启动
base.qcow2vfat,只读挂载承载 init.sh 与预缓存的 apk 包
temp.qcow2ext4一次性工作盘,中间文件与构建产物放这里

先制作 base.qcow2

Alpine virt live 启动后没有 mkfs.ext4 这个命令,e2fsprogs 不在默认安装里,无法把盘初始化成 ext4。基础盘只是放静态文件(init.sh、apk 包),vfat 够用,就选了 vfat。工作盘需要 ext4,因为 buildah 的 overlay 驱动要求权限、硬链接这些 vfat 给不了的东西,而 e2fsprogs 已经和其余构建工具一起打进预缓存 apk,初始化脚本运行时先安装再格式化。

制作流程如下。先创建两块盘:

qemu-img create -f qcow2 base.qcow2 1G
qemu-img create -f qcow2 temp.qcow2 8G

启动 Alpine live,挂上 ISO 和两块盘,串口接到 telnet:

qemu-system-x86_64 -m 2048 \
  -cdrom alpine-virt-3.24.1-x86_64.iso \
  -drive file=base.qcow2,format=qcow2,if=virtio \
  -drive file=temp.qcow2,format=qcow2,if=virtio \
  -nographic \
  -serial telnet:127.0.0.1:5555,server,nowait \
  -boot d \
  -nic user,model=virtio-net-pci

用 telnet 连上 5555,登录后初始化网络,把 vda 格式化为 vfat 并挂载:

ip link set eth0 up
udhcpc -i eth0
mkfs.vfat /dev/vda
mkdir -p /mnt/base
mount -t vfat /dev/vda /mnt/base

配置软件源,把构建工具预缓存到基础盘。缓存的目的是便携:这个工具的预想是与环境无关,包括网络,运行时不能依赖联网下载构建工具。

echo "https://dl-cdn.alpinelinux.org/alpine/v3.24/community" >> /etc/apk/repositories
echo "https://dl-cdn.alpinelinux.org/alpine/v3.24/main" >> /etc/apk/repositories
apk update
mkdir -p /mnt/base/apks
apk fetch --recursive --output /mnt/base/apks buildah runc fuse-overlayfs curl e2fsprogs netavark

接着把 init.sh 烧录进基础盘。宿主机起一个临时 HTTP 服务,guest 里下载:

# 宿主机
python3 -m http.server 8000 --bind 127.0.0.1

# guest,10.0.2.2 是 QEMU 用户网络里宿主机一侧的地址
wget -O /mnt/base/init.sh http://10.0.2.2:8000/init.sh

卸载并关机:

umount /mnt/base
poweroff

init.sh 做了什么

运行时机身每次都从 ISO 冷启动,环境处于初始状态。init.sh 在启动后把环境一次性准备好,核心命令如下:

# cgroup
mkdir -p /sys/fs/cgroup
mount -t cgroup2 none /sys/fs/cgroup 2>/dev/null || true

# 网络
ip link set eth0 up
udhcpc -i eth0

# 离线安装预缓存包(含 e2fsprogs,提供 mkfs.ext4)
apk add --no-cache --allow-untrusted --force-non-repository /mnt/base/apks/*.apk

# 格式化并挂载工作盘
echo y | mkfs.ext4 /dev/vdb
mkdir -p /mnt/temp
mount -t ext4 /dev/vdb /mnt/temp

# buildah storage 配置,全部路径位于工作盘
cat > /etc/containers/storage.conf << 'EOF'
[storage]
driver = "overlay"
graphroot = "/mnt/temp/containers/storage"
runroot = "/mnt/temp/containers/run"
[storage.options.overlay]
mountopt = "nodev"
EOF

# export 到当前 shell,供后续 buildah 使用
export TMPDIR=/mnt/temp/containers/tmp

# overlay 不可用时回退 vfs 驱动
grep -q overlay /proc/filesystems || modprobe overlay || \
  sed -i 's/driver = "overlay"/driver = "vfs"/' /etc/containers/storage.conf

这个脚本由工具以 . /mnt/base/init.sh 方式 source 执行,export 的环境变量才能保留在使用它的那个 shell 里。

每次构建的启动流程

构建时创建一块新的 temp.qcow2,从 ISO 冷启动,挂载基础盘并执行 init.sh,命令序列见"构建流程"一节。临时工作盘是刻意的一次性设计。guest 里删除文件后,宿主机 qcow2 已分配的空间不会回收,只会越来越大。用空间换实现简单和行为可预期,每次运行都是干净状态。

文件在宿主机和虚拟机之间怎么传

构建上下文在宿主机,构建产物要回宿主机。最先考虑共享目录,9p、virtiofs 这些把宿主机目录挂进 guest 的方案在 Windows 上全部不可用。这个结论可以在 QEMU 源码(v11.1.0)里直接确认:

QEMU 自带的 smb 参数(-nic user,smb=<dir>)同样不可用。源码 net/slirp.c 里,slirp_smb() 整体被 #if defined(CONFIG_SMBD_COMMAND) 包裹,它并不是 QEMU 内置的 SMB 服务,而是生成临时 smb.conf 后调用外部 smbd 命令,还使用了 getpwuid(geteuid()) 等 POSIX 接口。Windows 构建不启用该功能,宿主机上也没有 smbd 可调用。

两条路都堵死,最终方案是临时 HTTP 服务加临时盘中转。宿主机用 Python tarfile 把构建上下文打成 tar,起一个绑定 127.0.0.1 动态端口的 HTTP 服务,带随机 token,请求必须带 X-Wimage-Token 头。token 的目的只是挡一下本机其他进程:服务在回环地址,但本机任何进程都能访问,加个随机 token 成本很低(此 token 不提供加密传输,仅作为本地进程隔离的简易凭证)。

guest 用 curl 从 10.0.2.2 下载 tar 到工作盘,解包构建,产物再上传回来。接口就三个:

方法路径作用
GET/download/image/<i>下载第 i 个基础镜像 tar
GET/download/project/<j>下载第 j 个构建上下文 tar
POST / PUT/upload/image接收构建产物

两侧的实际命令:

# guest:下载构建上下文并解包
curl -fsS -H 'X-Wimage-Token: <token>' -o /mnt/temp/project_0.tar \
  http://10.0.2.2:<http_port>/download/project/0
mkdir -p /mnt/temp/work_0 && tar xf /mnt/temp/project_0.tar -C /mnt/temp/work_0

# guest:构建产物回传
curl -fsS -H 'X-Wimage-Token: <token>' -T /mnt/temp/output_0.tar \
  http://10.0.2.2:<http_port>/upload/image

这套方案在功能上比共享目录笨,但只依赖回环 TCP 服务和用户网络,与宿主机的文件系统能力无关,在任何机器上行为一致。

和虚拟机怎么交互

guest 里不装 agent,只用一个串口。QEMU 的 -serial 可以把串口接到 telnet,工具连上去得到一个交互 shell。不装 agent 是刻意的:agent 是装进客户机的一个服务,这里只是发命令、读输出,串口够用,客户机上不必多带一个东西。

串口 chardev 后端本身有多种选择,选型时比较过 -serial stdio-serial telnet

stdio 映射到进程的标准输入输出。在 Windows 上,标准输入输出既可能是控制台句柄,也可能是管道句柄,行为并不一致。QEMU 源码 chardev/char-win-stdio.cGetConsoleMode 区分为两种路径:控制台走 ReadConsoleInput 读键盘事件,管道走独立线程 ReadFile 逐字节读。对工具来说,进程以子进程方式驱动 QEMU,标准输入输出依赖调用方传入的句柄,行为不确定,不能用一次测试就断言某种调用方式一定可靠,需要在目标部署环境中逐一验证,或直接选择行为确定的方案。

telnet 是 TCP socket。无论 Windows 还是 Linux,socket 的可用性只取决于网络栈,行为确定、可预期;QEMU 的 -serial 直接支持 telnet:127.0.0.1:<port>,server,nowait,工具连上就是一个交互 shell。我选择 telnet 而不是 stdio,本质原因就是:管道(stdio)的行为高度依赖调用环境,TCP socket 的行为是确定性的。这与文件传输反复验证过的判断是一致的——文件走 TCP,串口也走 TCP,都选最确定的那条路。

telnet 客户端是自写的。Python 标准库的 telnetlib 已经废弃,3.11 起弃用,3.13 移除;而工具定位是纯标准库、零第三方依赖,为一条连接引依赖不划算。因此自实现一个最小化 telnet 客户端是兼顾兼容性与零依赖的唯一选择。

写这个客户端有一个值得注意的点:telnet 会话里服务器会主动发 IAC 协商(DO ECHO、NAWS、TTYPE 等),不回应的话协商字节会混进数据流,污染要解析的输出。对这个客户端,所有协商都是多余的,对 DO/DONT/WILL/WONT 一律回 WONT/DONT,全部拒绝,数据流里只剩命令输出。

判断命令执行结果不能用猜提示符的方式,guest 是交互 shell,没有可靠提示符。做法是每条命令末尾追加随机结束标记和退出码:

<命令>; echo WIMAGE_RC:<uuid>:$?

工具等到这个唯一标记出现,解析退出码,0 成功,非 0 失败。标记带随机 uuid,不会与命令输出里偶然出现的内容冲突。

串口输出直接记录没法看,带各种转义序列和回车符。记录前先剥掉转义序列,把孤立的 \r 归一为 \n。日志分两层:阶段信息显示在屏幕,技术细节(QEMU 启动命令、端口、实际下发的命令)只写日志文件,排查时再翻。

构建流程

guest 里的实际流程从挂载基础盘开始:

mkdir -p /mnt/base && mount -t vfat -o ro /dev/vda /mnt/base
. /mnt/base/init.sh
buildah --version

然后是加载基础镜像:下载 tar,buildah pull,清理;再逐个构建项目:下载上下文,解包,buildah build,buildah inspect,buildah push,回传,清理。

基础镜像格式在宿主机侧先识别:读 tar 顶层成员,有 manifest.json 是 docker-archive,有 oci-layoutblobs/ 是 oci-archive。识别失败无妨,guest 里 buildah pull 失败会自动换另一种格式重试一次。

buildah 在 guest 里的动作就四条:

buildah pull docker-archive:/mnt/temp/load_0.tar
buildah build --network host --tag myapp:latest /mnt/temp/work_0
buildah inspect --type image myapp:latest
buildah push myapp:latest docker-archive:/mnt/temp/output_0.tar

工具支持多个基础镜像、多个项目,共用一个虚拟机依次构建,单个项目失败不阻断后面,全部跑完汇总成功数和失败数。buildah 能力远不止构建,工具只实现了构建这一条,其余按需扩展。镜像推送拉取由 skopeo 这类现成工具覆盖,日常简单的构建、推送、拉取场景拼起来就够用。

远程 registry 同样支持:传入 registry 参数时,工具把 registries.conf 写入 guest,有认证时再写 auth.json,buildah 在 build 时自动从远程 registry 拉取 Dockerfile 的 FROM 镜像。配置用 base64 单行写入,避免 heredoc 结尾与命令结束标记冲突。

打成 exe 分发

工具本体是 Python,选择它是因为开发调试方便,而且作为简单工具跨平台用 Python 很常见。交付形态是单个 Windows 可执行文件,让最终用户装 Python 不可接受,目标是拿到手就能用。

打包用 Nuitka,把 Python 编译成 C 再编成原生机器码,产物不含 .pyc 字节码(反编译难度当然也大),性能接近 C 编译产物,体积也小。

Nuitka 的 onefile 模式有一个坑:运行时把程序解压到系统临时目录,__file__ 指向解压目录而不是 exe 所在位置,导致资源定位错误。工具要找到 exe 旁边的 qemu/images/,结果找到临时目录里去了。解决方式是加一个 launcher,在 import 主程序前把资源路径常量覆盖为 exe 所在目录。

QEMU 分发体积是另一个问题。完整 Windows 版 QEMU 安装目录约 516M,为整个工具分发这么大的东西不划算。实际构建只用两个可执行文件(qemu-system-x86_64.exeqemu-img.exe),加 SeaBIOS、keymaps 和运行时依赖库,只保留构建真正用到的文件,体积可以降到一百多 M 甚至更小。

最终分发结构:

分发目录/
  wimage.exe        # Nuitka 打包产物
  qemu/             # 精简后的 QEMU
  images/
    base.qcow2      # 内含 init.sh
    system.iso      # 启动盘

init.sh 已烧录进 base.qcow2,不需要单独分发。

结语

在 Windows 上构建 Linux 镜像,与其折腾必然出问题的宿主机环境,不如把构建环境当成工具的一部分打包分发。代价明确,性能损失、虚拟盘空间只增不回收、传输方式笨拙,换来的是与环境无关,以及普通用户多了一条真正能走到构建成功的路。工具目前只实现构建这一件事,配合 skopeo 可以覆盖日常构建、推送、拉取。

参考