jimyag's Blog

用 oci2vm 把容器镜像转换成可启动的虚拟机磁盘

Last modified:

我想把已有的容器镜像做成虚拟机镜像:应用继续用 Dockerfile 构建,交付时生成一个能由虚拟机固件引导、开机后自动运行应用的 qcow2 磁盘。

调研后,我选择了 d2vm 的实现方式,在它的代码基础上做了 oci2vm。它负责安装内核和 init 系统、分区、写入文件系统和安装引导程序。我补上了镜像入口服务、环境变量、cloud-init、SSH,以及转换过程中遇到的网络和引导问题。

本文先用一个多阶段构建的 Go HTTP 服务,把这条路径从 Dockerfile 跑到虚拟机,再介绍 kube-ovn、virt-handler 等复杂镜像需要补齐的系统工具。

Go 服务教程使用 v0.1.0,基础实现的源码说明固定到提交 a48dfc0。后文的包管理器自动恢复、启动分区自动分配和 Ubuntu 的 lsb-release 补齐来自提交 a20f010,v0.1.0 不包含这些改动。

转换时补齐了什么

容器镜像通常没有内核、分区表和引导程序。应用启动所需的 Entrypoint、Cmd、Env、User、WorkingDir 保存在镜像配置里,单独导出文件系统会丢掉这部分信息。OCI 镜像配置规范

oci2vm 同时读取文件系统和镜像配置,把它们组装成虚拟机系统:

输入或缺失的部分oci2vm 的处理
镜像的 Linux 文件系统按发行版模板安装软件,再展平镜像层,写入磁盘
内核和 init安装内核;Alpine 使用 OpenRC,其他支持的发行版使用 systemd
分区和引导创建 MBR 分区表,安装 syslinux 或 GRUB
ENTRYPOINT、CMD、USER、WORKDIR生成 oci2vm-entrypoint 服务,以镜像指定的用户和工作目录运行
ENV写进入口脚本,同时导出给登录 shell
网络和 DNS配置 eth0 使用 DHCP,默认从 DHCP 获取 DNS
实例初始化和远程登录安装 cloud-init 和 OpenSSH 服务端,首次启动生成 SSH 主机密钥

这些处理默认启用,不需要再加 --ssh、--cloud-init 或 --entrypoint-service。交互式基础镜像里的单独一个 sh、bash 等命令会被跳过,避免把登录 shell 安装成开机服务。

支持的基础发行版包括 Ubuntu、Debian、Kali、Alpine、CentOS、Rocky Linux 和 AlmaLinux。最终镜像需要保留受支持的发行版环境;FROM scratch、distroless 镜像不能直接转换。v0.1.0 还要求包管理器可用,新提交增加了部分精简镜像的恢复能力,具体条件在后文说明。各版本的支持情况见 项目文档。

虚拟机启动后由自己的内核运行应用。容器运行时提供的 volume、端口映射、额外权限和运行时环境变量,需要在虚拟机部署时重新配置。镜像里的 EXPOSE 8080 只是一份端口声明,不会自动打开宿主机的 8080 端口。

从已有镜像还是从 Dockerfile 开始

oci2vm 的两个入口共用同一套转换逻辑。已经有镜像时用 convert,优先使用 Docker 服务端的本地镜像,找不到时从仓库拉取;--pull 强制拉取。下面以 amd64 为目标:

1
2
oci2vm convert ubuntu:24.04 \
  --platform linux/amd64 -s 4G -o ubuntu.qcow2

只有 Dockerfile 时用 build,先构建应用镜像,再转换最终镜像:

1
oci2vm build --platform linux/amd64 -s 4G -o app.qcow2 ./app

多阶段 Dockerfile 按 Docker 的正常构建流程执行。oci2vm 只处理最终阶段生成的镜像,不需要还原原来的 Dockerfile,也不会把编译阶段的工具链装进虚拟机。最终阶段仍须基于支持的发行版。

如果镜像需要从另一台机器带过来,先保存镜像,再导入目标 Docker 服务端:

1
2
3
4
5
6
# 在保存镜像的机器上
docker save -o app.tar my-app:1.0

# 把 app.tar 传到执行转换的机器,再导入
docker load -i app.tar
oci2vm convert my-app:1.0 --platform linux/amd64 -o app.qcow2

docker save 保存镜像层和配置,适合这条路径。Docker 官方说明 docker export 导出的是容器文件系统,不保留入口等镜像配置;容器挂载的 volume 数据也需要另行备份。Docker export 说明 oci2vm 当前接收镜像引用,不直接接收 rootfs tar 文件。

安装和运行环境

转换需要 Linux 的 loop 设备、挂载和文件系统工具。在 Linux 上以 root 运行时,二进制直接执行这些操作;在 macOS 或 Linux 非 root 用户下,二进制会启动同版本的 oci2vm 容器,把工作交给 Docker 服务端。因此 macOS 上仍需要一个正在运行的 Linux Docker 环境。

从 Release 下载适合本机的二进制。Apple 芯片的 Mac 可以这样安装:

1
2
3
4
curl -fLo oci2vm \
  https://github.com/jimyag/oci2vm/releases/download/v0.1.0/oci2vm_darwin_arm64
chmod +x oci2vm
sudo install -m 0755 oci2vm /usr/local/bin/oci2vm

Linux x86_64 使用同一 Release 的 oci2vm_linux_amd64。下载校验值见该版本的 checksums.txt。Linux root 模式需要自行安装项目文档中列出的转换工具;使用非 root 模式时,工具由 ghcr.io/jimyag/oci2vm:v0.1.0 容器提供。

CPU 架构通过 --platform 明确指定。应用镜像、安装的内核和虚拟机架构必须一致。默认网络配置使用 eth0 的 DHCP,后面的 QEMU 示例使用 virtio 网卡。

示例:把 Go HTTP 服务做成 qcow2

完整构建文件在本文附件:Dockerfile、main.go、go.mod。把三个文件放到同一个 app/ 目录,后续命令都在它的上级目录执行。

HTTP 服务监听 8080,返回环境变量 GREETING。Dockerfile 用 Go 镜像编译应用,最终阶段只复制二进制,基于 Alpine 运行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /hello .

FROM alpine:3.22
COPY --from=build /hello /usr/local/bin/hello
ENV GREETING="Hello from oci2vm"
USER nobody
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/hello"]

在 x86_64 Linux 上构建 amd64 磁盘:

1
oci2vm build --platform linux/amd64 -s 4G -o hello.qcow2 ./app

在 Apple 芯片的 Mac 上构建 arm64 磁盘:

1
oci2vm build --platform linux/arm64 -s 4G -o hello.qcow2 ./app

amd64 默认用 syslinux 进行 BIOS 引导;arm64 默认用 GRUB EFI,生成独立的 FAT32 启动分区。转换完成后,qemu-img info hello.qcow2 可以检查输出格式和虚拟磁盘大小。-s 4G 表示虚拟容量,qcow2 的实际占用随写入的数据增长。

这份磁盘包含应用和完整的启动链,不需要 QEMU 额外传入 -kernel、-initrd。cloud-init 种子盘只负责配置当前实例。

用 cloud-init 配置 SSH 公钥

示例不在磁盘里设置 root 密码,用 NoCloud 种子盘提供 SSH 公钥。创建 seed/ 目录和 seed/user-data,把占位行换成自己的公钥,例如 ~/.ssh/id_ed25519.pub 的内容:

1
2
3
4
5
6
#cloud-config
# 实验中允许 root 使用公钥登录,关闭 SSH 密码认证。
disable_root: false
ssh_pwauth: false
ssh_authorized_keys:
  - ssh-ed25519 REPLACE_WITH_YOUR_PUBLIC_KEY [email protected]

再创建 seed/meta-data:

1
2
3
# 新建另一台实例时使用不同的 instance-id。
instance-id: oci2vm-hello-1
local-hostname: oci2vm-hello

NoCloud 可以从卷标为 cidata 的 ISO9660 或 FAT 文件系统读取初始化信息,user-data 和 meta-data 放在文件系统根目录。NoCloud 文档

Linux 上安装 cloud-image-utils 后运行:

1
cloud-localds seed.iso seed/user-data seed/meta-data

macOS 可以使用系统自带的 hdiutil:

1
2
hdiutil makehybrid -iso -joliet -default-volume-name cidata \
  -o seed.iso seed

oci2vm 已经配置了发行版的网络,模板中关闭了 cloud-init 的网络配置,避免两套网络管理方式互相覆盖。这里让 cloud-init 配置主机名和 SSH 密钥。

用 QEMU 启动

x86_64 Linux 主机安装 QEMU 并确认当前用户可以访问 /dev/kvm,然后运行:

1
2
3
4
5
qemu-system-x86_64 -accel kvm -cpu host -m 1024 -nographic \
  -drive file=hello.qcow2,if=virtio,format=qcow2 \
  -drive file=seed.iso,if=virtio,format=raw \
  -netdev user,id=n0,hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:127.0.0.1:8080-:8080 \
  -device virtio-net-pci,netdev=n0

Apple 芯片的 Mac 安装 Homebrew QEMU 后,使用 HVF 和它附带的 UEFI 固件:

1
2
3
4
5
6
qemu-system-aarch64 -M virt -accel hvf -cpu host -m 1024 -nographic \
  -bios /opt/homebrew/share/qemu/edk2-aarch64-code.fd \
  -drive file=hello.qcow2,if=virtio,format=qcow2 \
  -drive file=seed.iso,if=virtio,format=raw \
  -netdev user,id=n0,hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:127.0.0.1:8080-:8080 \
  -device virtio-net-pci,netdev=n0

这些转发只监听宿主机的 127.0.0.1:2222 对应虚拟机的 SSH,8080 对应应用。宿主机上已有服务占用这些端口时,更换 hostfwd 中的宿主机端口。

arm64 镜像当前的串口配置使用 ttyS0,QEMU virt 的串口是 ttyAMA0,所以不能靠串口出现登录提示来判断启动成功。另开终端,通过 SSH 检查系统:

1
ssh -i ~/.ssh/id_ed25519 -p 2222 [email protected]

初次连接先核对 SSH 主机密钥;反复重建同一实验实例后,如果客户端提示主机密钥改变,确认连接的是自己新建的虚拟机,再删除对应的旧记录。

检查系统和应用

在虚拟机里等待初始化完成并查看入口服务:

1
2
3
4
cloud-init status --wait
rc-service oci2vm-entrypoint status
ps -o user,pid,args | grep '[h]ello'
echo "$GREETING"

hello 进程应由 nobody 运行,环境变量应为 Hello from oci2vm。在宿主机请求应用:

1
2
curl --fail http://127.0.0.1:8080/
# Hello from oci2vm

本次在 Apple 芯片的 Mac 上,用 v0.1.0 的二进制和容器完成了这份附件的转换与启动。磁盘虚拟容量为 4 GiB,转换后文件约 205 MiB。UEFI 引导后,cloud-init status --wait 返回 status: done,OpenRC 入口服务为 started,hello 进程由 nobody 运行;SSH 登录 shell 和宿主机 HTTP 请求都取得了 Hello from oci2vm。本次复现使用 arm64,前面的 amd64 命令按项目的默认 BIOS 引导方式给出。

Alpine 的入口服务日志在 /var/log/oci2vm-entrypoint.log。Ubuntu、Debian 等使用 systemd 的镜像,可以用 systemctl status oci2vm-entrypoint 和 journalctl -u oci2vm-entrypoint 检查。

同一磁盘不应同时被两台虚拟机以可写方式打开。需要批量创建实例时,保留未启动的基础盘,为每台实例创建独立磁盘和种子盘,避免复制已经初始化的 SSH 主机密钥与 cloud-init 状态。

应用入口和环境变量怎样保留

转换前,Convert 读取原始镜像配置;发行版模板安装软件时临时切换为 root,但应用服务仍使用原始镜像里的用户。

入口脚本放在 /usr/local/bin/oci2vm-entrypoint。它导出镜像环境变量,切换到 WorkingDir,最后用 exec 运行 Entrypoint 和 Cmd 拼成的命令。参数按 POSIX shell 的单引号规则转义,避免空格、引号或 $() 改变原来的参数含义。脚本和服务模板

镜像的环境变量还会写到 /etc/profile.d/oci2vm-env.sh,供登录 shell 加载。其中 HOME、USER、LOGNAME、HOSTNAME、SHELL 和 TERM 保留登录会话的值,不能拿容器用户的配置覆盖 SSH 登录用户。其他自定义服务不会自动读取这个 profile,仍要自行声明环境变量。

生成的入口服务适合前台运行的程序。systemd 使用 Restart=on-failure,Alpine 使用 OpenRC 的 supervise-daemon。应用如果自行 daemonize,或者依赖容器内某个 supervisor,仍需检查它在生成服务下的退出和重启行为。

复杂镜像的转换与修复

基础镜像里通常还有完整的包管理工具,组件镜像则可能为了减小体积删除它们。实际转换 kubeovn/kube-ovn:v1.17.0 和 quay.io/kubevirt/virt-handler:v1.9.0 时,发行版识别成功,安装内核却分别停在:

1
2
/bin/sh: 1: apt-get: not found
/bin/sh: line 1: yum: command not found

这些镜像仍有 Ubuntu 或 CentOS 的系统文件,补回包管理器后可以继续转换。我最初用额外的 Dockerfile 手工补齐,确认可行后,把恢复步骤放进了共享转换流程。现在使用 a20f010 的实现,可以直接对原镜像执行 convert,不需要逐个写准备 Dockerfile。

这一节使用源码构建的版本。需要 Go 1.27、make 和可用的 Docker 服务,在新的目录中固定到上述提交构建:

1
2
3
4
5
git clone https://github.com/jimyag/oci2vm.git oci2vm-src
cd oci2vm-src
git checkout a20f010e716e815a99f6caa52237cd3765ce0aeb
make build-dev
./oci2vm version

make build-dev 同时构建本机二进制和本地转换容器,二者使用同一版本号。下面三个命令在 Apple 芯片的 Mac 上转换 ARM64 镜像,继续在源码目录运行,使用 ./oci2vm,避免调用前面安装的 v0.1.0。

1
2
3
4
5
6
7
8
./oci2vm convert kubeovn/kube-ovn:v1.17.0 \
  --platform linux/arm64 -s 8G -o kube-ovn.qcow2

./oci2vm convert quay.io/kubevirt/virt-handler:v1.9.0 \
  --platform linux/arm64 -s 8G -o virt-handler.qcow2

./oci2vm convert kindest/node:v1.35.1 \
  --platform linux/arm64 -s 8G -o kind-node.qcow2

恢复包管理器和依赖

转换先检查 apt-get、apk,或 YUM/DNF 命令是否存在。命令存在时沿用原镜像;命令缺失时,根据 /etc/os-release 选择基础镜像,增加一个恢复阶段,再安装内核和启动所需的软件。Ubuntu 按 VERSION_ID 选择,Debian 和 RPM 系按主版本选择,Alpine 使用主次版本,Kali 使用 rolling 镜像。检测与基础镜像选择

发行版恢复方式与前提
Ubuntu从同版本 Ubuntu 下载 APT、gpgv,用原镜像的 dpkg 安装;dpkg、包数据库和依赖仍需存在
Debian、Kali rolling恢复 APT 和该版本使用的验签工具 gpgv 或 sqv;同样依赖原有 dpkg 环境
Alpine先从相同主次版本取得静态 APK,用它恢复当前 apk-tools;原包数据库仍需保留
CentOS 7在同版本基础镜像中安装 YUM 及其依赖,再复制到待转换镜像
CentOS 8 / Stream、Rocky Linux、AlmaLinux用 DNF 的 installroot 安装 DNF 及其依赖;只有 yum 被删除、DNF 仍可用时直接复用 DNF

Debian 的旧版本和 CentOS 7/8 需要使用归档仓库,恢复阶段也沿用这一处理。仅复制一个 apt-get 或 dnf 文件不够:它们还需要验签工具、动态库、配置和包数据库。恢复步骤没有把任意 rootfs 变成完整发行版的能力,缺少 dpkg 等前提时仍会失败。

virt-handler 还有一处混合系统库的问题:它基于 CentOS Stream 9,却带有 Debian 多架构目录中的动态库。补回 DNF 后,RPM 工具可能先加载这些库,继续遇到版本不匹配。RPM 模板把 /usr/lib64 放到动态库配置前面,再执行 ldconfig,优先使用恢复的 RPM 库。

RPM 恢复阶段会复制基础镜像安装结果中的 /usr、/etc 和 /var/lib,随后把原镜像的 /etc 文件覆盖回来。生成镜像里的系统工具、动态库和包数据库会改变,应用仍需重新验证;源 Docker 镜像不变。RPM 模板

让 BuildKit 能执行 depmod

包管理器恢复后,安装内核还会运行 depmod,为内核模块生成依赖索引。部分镜像给 kmod 文件设置了 CAP_SYS_MODULE capability,而 depmod 又通过它执行。BuildKit 的构建环境不允许取得这项能力,可能在执行程序时直接报 Operation not permitted。

depmod 生成索引不需要向运行中的内核加载模块。各发行版模板因此在安装内核前检查 kmod 的文件 capability;镜像提供 getcap、setcap 且检测到 CAP_SYS_MODULE 时,移除该文件的 capability,再继续安装。这里没有放宽构建容器的权限,处理发生在生成镜像的文件上。Ubuntu 模板中的处理

根据实际内容分配 /boot

旧版独立启动分区默认只有 100 MiB。内核和 initramfs 较大的镜像可能放不下,需要人工指定 --boot-size。新实现把默认值改为 0,在发行版模板完成后统计 /boot 的占用,自动计算分区大小:

  • 把占用的 KiB 向上换算成 MiB。
  • 预留 64 MiB 给引导程序和文件系统,再计入 1 MiB 的分区起始偏移。
  • 向上取整到 32 MiB 的倍数,最小分配 128 MiB。

例如 /boot 占用 176 MiB 时,会分配 256 MiB。显式传入非零的 --boot-size 仍会覆盖自动计算,单位为 MiB;实现也修正了启动分区大小与整盘字节数直接比较的问题。计算逻辑、分区大小检查

这个处理作用于独立启动分区,arm64 的 GRUB EFI 路径会用到它。包管理器恢复没有扩大固件支持范围:CentOS 7/8、Rocky 8 和 AlmaLinux 8 仍需要 amd64 BIOS 引导,这些发行版的 GRUB EFI 路径要求版本为 9 或更新。

补齐 cloud-init 运行时调用的工具

kube-ovn 的第一份磁盘已经能启动,SSH、DHCP、根目录和 /boot 挂载、cloud-init 的 runcmd 都正常,但 cloud-init 返回:

1
2
3
status: done
extended_status: degraded done
errors: []

退出码为 2,可恢复警告指出 lsb_release --all 无法执行,因为文件不存在。原始 kube-ovn 镜像没有 lsb_release,也没有 cloud-init;容器运行本来不需要这些工具。转换模板添加 cloud-init 时,也需要补齐它在这份 Ubuntu 环境中调用的 lsb-release 包。

我在 Ubuntu 模板里把 lsb-release 和 cloud-init 一起安装,重新转换同一个源镜像,再启动验证。结果变为:

1
2
3
4
5
status: done
extended_status: done
errors: []
recoverable_errors: {}
cloud_init_exit=0 marker_exit=0

其中 marker_exit=0 来自测试种子盘中的 runcmd 标记文件检查。这次验证同时检查状态和退出码,避免只看到 status: done 就漏掉 degraded 状态。修复所在提交

实际启动结果和未修复的问题

三个组件镜像都完成了自动转换,输出为 8 GiB 虚拟容量的 qcow2,并通过 qemu-img check。另外,我还构建了主动删除包管理器的基础镜像,覆盖 Alpine、Debian、Kali、CentOS Stream、Rocky Linux 和 AlmaLinux;其中 Alpine 和 Debian 13 的测试镜像也给 kmod 加上了 CAP_SYS_MODULE,验证转换时的移除处理。

启动测试使用一台 x86_64 Linux 主机上的 QEMU 8.2.2,通过 ARM TCG 软件模拟启动这些 ARM64 磁盘。每台测试机分配 2 vCPU、2 GiB 内存,使用 AAVMF UEFI、virtio 磁盘和网卡、NoCloud 种子盘。每个实例使用独立的 qcow2 overlay,基础盘只读;验收检查 SSH 密钥登录、cloud-init 状态和退出码、runcmd 标记、DHCP 地址与默认路由,以及根目录和 /boot 挂载。

转换产物启动验收结果
kube-ovn v1.17.0,Ubuntu 26.04补齐 lsb-release 后完整通过,cloud-init 退出 0
virt-handler v1.9.0,CentOS Stream 9完整通过,cloud-init 退出 0
kindest/node v1.35.1,Debian 13完整通过,cloud-init 退出 0
删除 APK 的 Alpine 3.22完整通过,cloud-init 退出 0
删除 APT 的 Debian 11、Debian 13均完整通过,cloud-init 退出 0
删除 DNF 的 AlmaLinux 10、CentOS Stream 10、Rocky Linux 9均完整通过,cloud-init 退出 0
删除 APT 的 Kali rolling系统和 cloud-init 启动成功,但 DHCP 配置失败,SSH 不可达

AlmaLinux 10 在 QEMU 的 max CPU 模型下,加载内核模块时出现 panic,堆栈落在 __memset 的 ARM MOPS 指令处。同一份磁盘换成 cortex-a72 后通过,因此这里记录为与模拟器 CPU 模型相关的差异,不能把第一次 panic 直接归因于磁盘转换。

Kali 则有实际的启动配置问题。生成的系统缺少 udev,存在 virtio_net 驱动,但 DHCP 配置执行时 eth0 尚不可用,日志报 Cannot find device "eth0"。接口后来出现,仍没有启用、没有 IP 和默认路由。

我只在独立诊断副本里添加了 /etc/modules-load.d/oci2vm-boot-probe.conf,让系统提前加载 virtio_net。副本随后通过了相同的 SSH、网络和 cloud-init 检查;同一 CPU 模型下,未改动的磁盘仍失败。这项对照支持驱动加载时序的判断,Kali 的原始转换产物和模板尚未修复。

这些结果证明了磁盘启动和基础系统初始化。kube-ovn、virt-handler、kind 节点加入 Kubernetes 集群后的组件功能,需要在具备对应配置和依赖的环境中另行验收;这次 ARM 软件模拟测试也没有覆盖 x86_64/KVM 的启动表现。

其他引导和系统配置处理

容器构建环境会影响安装软件时生成的系统配置。我在实现中处理了几处会影响虚拟机启动的问题:

  • Debian、Ubuntu 安装内核前设置 RESUME=none。initramfs-tools 会探测构建环境的 swap 设备,虚拟机找不到这个设备时,启动会额外等待。
  • GRUB 配置生成期间提供根分区的 /dev/disk/by-uuid 链接,让内核参数使用文件系统 UUID,避免把构建时的 loop 设备路径带进虚拟机。
  • FAT32 启动分区设置 ESP 标志,在 MBR 分区表中标记为 EFI System Partition。
  • 使用 ifupdown 的模板禁用不负责网络的 systemd-networkd 等待服务,避免开机等待另一套网络配置。
  • 安装 SSH 后删除构建时生成的主机密钥,首次启动再生成;Alpine 还限制 cloud-init 查找 NoCloud、ConfigDrive 和 None,避免本地没有种子盘时等待云厂商元数据服务。
  • 输出文件按执行 sudo 的用户或输出目录的所有者设置属主,避免转换成功后普通用户无法打开磁盘。

对应实现见 builder.go、发行版模板 和 输出属主处理。

--raw 的含义是跳过安装软件的发行版模板,要求源镜像已经有内核和 init 系统;它不表示输出 raw 格式。输出格式由 -o 的扩展名决定,例如 -o disk.raw。qcow2 之外还支持 vmdk、vdi、vhdx 等格式,但目标虚拟化平台要求的固件、驱动和平台代理仍需单独核对。

oci2vm 的代码派生自 Linka Cloud 的 d2vm,run 子命令改编自 LinuxKit,保留了上游版权声明并采用 Apache License 2.0,来源列在项目的 NOTICE 中。

#虚拟化 #Qemu #Docker #Oci2vm #Cloud-Init