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

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

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

Go 服务教程使用 [v0.1.0](https://github.com/jimyag/oci2vm/releases/tag/v0.1.0)，基础实现的源码说明固定到提交 [a48dfc0](https://github.com/jimyag/oci2vm/tree/a48dfc09329be8f25e841af8d3f4a6746a648a3e)。后文的包管理器自动恢复、启动分区自动分配和 Ubuntu 的 lsb-release 补齐来自提交 [a20f010](https://github.com/jimyag/oci2vm/commit/a20f010e716e815a99f6caa52237cd3765ce0aeb)，v0.1.0 不包含这些改动。

<!--more-->

## 转换时补齐了什么

容器镜像通常没有内核、分区表和引导程序。应用启动所需的 `Entrypoint`、`Cmd`、`Env`、`User`、`WorkingDir` 保存在镜像配置里，单独导出文件系统会丢掉这部分信息。[OCI 镜像配置规范](https://github.com/opencontainers/image-spec/blob/v1.1.1/config.md)

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 还要求包管理器可用，新提交增加了部分精简镜像的恢复能力，具体条件在后文说明。各版本的支持情况见 [项目文档](https://github.com/jimyag/oci2vm/blob/a20f010e716e815a99f6caa52237cd3765ce0aeb/README.zh-CN.md#支持的发行版)。

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

## 从已有镜像还是从 Dockerfile 开始

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

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

只有 Dockerfile 时用 `build`，先构建应用镜像，再转换最终镜像：

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

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

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

```bash
# 在保存镜像的机器上
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 官方说明](https://docs.docker.com/reference/cli/docker/image/save/)
`docker export` 导出的是容器文件系统，不保留入口等镜像配置；容器挂载的 volume 数据也需要另行备份。[Docker export 说明](https://docs.docker.com/reference/cli/docker/container/export/)
oci2vm 当前接收镜像引用，不直接接收 rootfs tar 文件。

## 安装和运行环境

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

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

```bash
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](https://jimyag.com/posts/container-image-to-vm-disk/files/Dockerfile)、[main.go](https://jimyag.com/posts/container-image-to-vm-disk/files/main.go)、[go.mod](https://jimyag.com/posts/container-image-to-vm-disk/files/go.mod)。把三个文件放到同一个 `app/` 目录，后续命令都在它的上级目录执行。

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

```dockerfile
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 磁盘：

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

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

```bash
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` 的内容：

```yaml
#cloud-config
# 实验中允许 root 使用公钥登录，关闭 SSH 密码认证。
disable_root: false
ssh_pwauth: false
ssh_authorized_keys:
  - ssh-ed25519 REPLACE_WITH_YOUR_PUBLIC_KEY you@example.com
```

再创建 `seed/meta-data`：

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

NoCloud 可以从卷标为 `cidata` 的 ISO9660 或 FAT 文件系统读取初始化信息，`user-data` 和 `meta-data` 放在文件系统根目录。[NoCloud 文档](https://docs.cloud-init.io/en/latest/reference/datasources/nocloud.html#source-2-drive-with-labeled-filesystem)

Linux 上安装 `cloud-image-utils` 后运行：

```bash
cloud-localds seed.iso seed/user-data seed/meta-data
```

macOS 可以使用系统自带的 `hdiutil`：

```bash
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`，然后运行：

```bash
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 固件：

```bash
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 检查系统：

```bash
ssh -i ~/.ssh/id_ed25519 -p 2222 root@127.0.0.1
```

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

### 检查系统和应用

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

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

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

```bash
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](https://github.com/jimyag/oci2vm/blob/a48dfc09329be8f25e841af8d3f4a6746a648a3e/convert.go) 读取原始镜像配置；发行版模板安装软件时临时切换为 root，但应用服务仍使用原始镜像里的用户。

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

镜像的环境变量还会写到 `/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` 时，发行版识别成功，安装内核却分别停在：

```text
/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 服务，在新的目录中固定到上述提交构建：

```bash
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。

```bash
./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 镜像。[检测与基础镜像选择](https://github.com/jimyag/oci2vm/blob/a20f010e716e815a99f6caa52237cd3765ce0aeb/dockerfile.go)

| 发行版 | 恢复方式与前提 |
| --- | --- |
| 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 模板](https://github.com/jimyag/oci2vm/blob/a20f010e716e815a99f6caa52237cd3765ce0aeb/templates/centos.Dockerfile)

### 让 BuildKit 能执行 depmod

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

`depmod` 生成索引不需要向运行中的内核加载模块。各发行版模板因此在安装内核前检查 `kmod` 的文件 capability；镜像提供 `getcap`、`setcap` 且检测到 `CAP_SYS_MODULE` 时，移除该文件的 capability，再继续安装。这里没有放宽构建容器的权限，处理发生在生成镜像的文件上。[Ubuntu 模板中的处理](https://github.com/jimyag/oci2vm/blob/a20f010e716e815a99f6caa52237cd3765ce0aeb/templates/ubuntu.Dockerfile)

### 根据实际内容分配 /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；实现也修正了启动分区大小与整盘字节数直接比较的问题。[计算逻辑](https://github.com/jimyag/oci2vm/blob/a20f010e716e815a99f6caa52237cd3765ce0aeb/boot_size.go)、[分区大小检查](https://github.com/jimyag/oci2vm/blob/a20f010e716e815a99f6caa52237cd3765ce0aeb/builder.go)

这个处理作用于独立启动分区，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 返回：

```text
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 一起安装，重新转换同一个源镜像，再启动验证。结果变为：

```text
status: done
extended_status: done
errors: []
recoverable_errors: {}
cloud_init_exit=0 marker_exit=0
```

其中 `marker_exit=0` 来自测试种子盘中的 `runcmd` 标记文件检查。这次验证同时检查状态和退出码，避免只看到 `status: done` 就漏掉 degraded 状态。[修复所在提交](https://github.com/jimyag/oci2vm/commit/a20f010e716e815a99f6caa52237cd3765ce0aeb)

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

三个组件镜像都完成了自动转换，输出为 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](https://github.com/jimyag/oci2vm/blob/a48dfc09329be8f25e841af8d3f4a6746a648a3e/builder.go)、[发行版模板](https://github.com/jimyag/oci2vm/tree/a48dfc09329be8f25e841af8d3f4a6746a648a3e/templates) 和 [输出属主处理](https://github.com/jimyag/oci2vm/blob/a48dfc09329be8f25e841af8d3f4a6746a648a3e/cmd/oci2vm/owner_linux.go)。

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

oci2vm 的代码派生自 Linka Cloud 的 [d2vm](https://github.com/linka-cloud/d2vm)，run 子命令改编自 [LinuxKit](https://github.com/linuxkit/linuxkit)，保留了上游版权声明并采用 Apache License 2.0，来源列在项目的 [NOTICE](https://github.com/jimyag/oci2vm/blob/a48dfc09329be8f25e841af8d3f4a6746a648a3e/NOTICE) 中。

