制作便携的 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 有性能损失,但几乎不依赖宿主机安装了什么,行为稳定可靠。对"从无到有"这个目标,代价可以接受。效率、资源占用是之后再考虑的事情。下面按制作思路,一个环节一个环节说。
本文环境与工具链版本:
- 开发机系统:Debian 13
- 目标运行系统:Windows(10/11 x86_64)
- QEMU:11.1.0(Windows x64 预编译包,来自 ↪ Stefan Weil 的 QEMU 安装包发布网站 ,安装后整体拷贝,可精简文件)
- Alpine Linux:virt 3.24.1(
alpine-virt-3.24.1-x86_64.iso) - buildah:1.44.0(Alpine v3.24 仓库)
- Python:3.9+(工具开发与运行仅用标准库)
构建环境是怎么搭起来的
构建系统选 Alpine Linux 的 virt 变体。它镜像小,是官方发行版,直接拿来用,不需要为构建定制系统镜像,之后更新系统和软件都省事。
整个环境由三块盘组成:
| 盘 | 格式 | 作用 |
|---|---|---|
| system.iso | ISO,cdrom | Alpine virt live 启动盘,每次运行从这里冷启动 |
| base.qcow2 | vfat,只读挂载 | 承载 init.sh 与预缓存的 apk 包 |
| temp.qcow2 | ext4 | 一次性工作盘,中间文件与构建产物放这里 |
先制作 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)里直接确认:
fsdev/meson.build:9p 相关源文件只在host_os in ['linux', 'darwin', 'freebsd']时编译,其余平台只引入空的qemu-fsdev-dummy.c。hw/9pfs/meson.build的平台专用代码也只有9p-util-darwin.c、9p-util-freebsd.c、9p-util-linux.c,没有 win32 实现。- virtiofs 走 vhost-user-fs,依赖宿主机上一个单独的 virtiofsd 进程,Windows 没有这个后端。
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.c 用 GetConsoleMode 区分为两种路径:控制台走 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-layout 加 blobs/ 是 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.exe、qemu-img.exe),加 SeaBIOS、keymaps 和运行时依赖库,只保留构建真正用到的文件,体积可以降到一百多 M 甚至更小。
最终分发结构:
分发目录/
wimage.exe # Nuitka 打包产物
qemu/ # 精简后的 QEMU
images/
base.qcow2 # 内含 init.sh
system.iso # 启动盘
init.sh 已烧录进 base.qcow2,不需要单独分发。
结语
在 Windows 上构建 Linux 镜像,与其折腾必然出问题的宿主机环境,不如把构建环境当成工具的一部分打包分发。代价明确,性能损失、虚拟盘空间只增不回收、传输方式笨拙,换来的是与环境无关,以及普通用户多了一条真正能走到构建成功的路。工具目前只实现构建这一件事,配合 skopeo 可以覆盖日常构建、推送、拉取。
参考
- QEMU Windows 预编译包(w64):https://qemu.weilnetz.de/w64/2026/
- QEMU 源码(v11.1.0,与文章使用的分发版本一致):
fsdev/meson.build:https://github.com/qemu/qemu/blob/v11.1.0/fsdev/meson.buildhw/9pfs/meson.build:https://github.com/qemu/qemu/blob/v11.1.0/hw/9pfs/meson.buildnet/slirp.c:https://github.com/qemu/qemu/blob/v11.1.0/net/slirp.cchardev/char-stdio.c:https://github.com/qemu/qemu/blob/v11.1.0/chardev/char-stdio.cchardev/char-win-stdio.c:https://github.com/qemu/qemu/blob/v11.1.0/chardev/char-win-stdio.cchardev/meson.build:https://github.com/qemu/qemu/blob/v11.1.0/chardev/meson.build
- ↪ Python telnetlib 废弃说明(3.11 起弃用,3.13 移除)
- ↪ buildah
- ↪ Alpine Linux virt 镜像
- ↪ Nuitka
- ↪ skopeo