Dockerで本物のVMを起動するdocker-vm-runnerを作った話

13 min read

はじめに

DockerでLinux環境を起動するのは簡単です。

docker run --rm -it ubuntu

これだけでUbuntuのユーザーランドをすぐに試すことができます。

ただし、コンテナでは足りない場面があります。

たとえば、systemdを普通に動かしたい場合、カーネルやブート周りの挙動を確認したい場合、OSインストーラを起動したい場合、ディスクレイアウトやファームウェア、cloud-init、ネットワークブートなどを検証したい場合です。

こういう用途では、やはりコンテナではなくVMが必要になります。

一方で、QEMUやlibvirtを直接扱うのはそれなりに面倒です。毎回 qemu-system-x86_64 の長いオプションを書くのも大変ですし、ディスク、ネットワーク、ポートフォワード、VNC、SSH、cloud-initなどを毎回組み立てるのも面倒です。

そこで、Dockerの操作感で本物のVMを起動できるツールとして、docker-vm-runner を作成しました。

リポジトリはこちらです。

https://github.com/MuNeNiCK/docker-vm-runner

Docsはこちらです。

https://munenick.github.io/docker-vm-runner/

この記事では概要と代表的な使い方を紹介します。細かい環境変数、ネットワーク、ストレージ、アクセス方法、Agent Skill、ExamplesについてはDocsにまとめています。

docker-vm-runnerとは

docker-vm-runner は、Dockerコンテナの中からQEMU/libvirtを使ってVMを起動するツールです。

名前の通り、DockerでVMをrunします。

ただし、起動しているのはコンテナではありません。ゲストOSは独自のカーネル、ファームウェア、ディスク、コンソールを持った実際の仮想マシンです。

そのため、通常のコンテナでは検証しづらい以下のような用途に使えます。

- cloud imageの起動確認
- OSインストーラISOの起動
- systemdやカーネルモジュール周りの検証
- cloud-initの検証
- ディスクサイズや追加ディスクの検証
- NAT、Bridge、Directなどのネットワーク検証
- iPXEによるネットワークブート検証
- VNC/noVNCを使ったGUIインストーラの操作
- Redfish/IPMIを使ったBMC風の制御
- QEMU Guest Agent経由でのゲスト内コマンド実行

コンテナで十分なものはコンテナで良いと思います。 ただ、OSそのものの挙動や、VMでないと再現できない部分を扱いたい場合は、VMを使った方が素直です。

docker-vm-runner は、そのVMの起動やアクセスをDockerのワークフローに寄せるためのツールです。

まず起動してみる

一番単純な起動方法は以下です。

docker run --rm -it \
  --name docker-vm-runner \
  --device /dev/kvm \
  ghcr.io/munenick/docker-vm-runner:latest

Linuxホストで /dev/kvm が使える場合は、KVMによるハードウェア支援を使ってVMを起動できます。

/dev/kvm がない環境では、以下のように --device /dev/kvm を外すことでソフトウェアエミュレーションにフォールバックできます。

docker run --rm -it \
  --name docker-vm-runner \
  ghcr.io/munenick/docker-vm-runner:latest

ただし、性能面ではKVMが使えるLinuxホストで動かすのが基本です。

起動すると、VMのシリアルコンソールに接続されます。

一時的に試すだけならこの形で十分です。コンテナを終了すると、VMのディスクやダウンロード済みイメージキャッシュも削除されます。

ディスクを永続化する

毎回VMを作り直すのではなく、ディスクやイメージキャッシュを保持したい場合は /data をマウントします。

docker run --rm -it \
  --name docker-vm-runner \
  --device /dev/kvm \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

/data をマウントすると、VMのディスク、状態、イメージキャッシュが保持されます。

ちょっとした検証用VMとして使う場合は、この形が使いやすいと思います。

バックグラウンドで起動したい場合は -dit を使います。

docker run -dit --name docker-vm-runner \
  --device /dev/kvm \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

ログを見る場合は以下です。

docker logs -f docker-vm-runner

シリアルコンソールへ再接続する場合は以下です。

docker attach docker-vm-runner

Docker attach中にVMを止めずに抜けたい場合は、Ctrl+P の後に Ctrl+Q を押します。

起動するOSを選ぶ

docker-vm-runneros-iso-catalog のイメージIDを使って、起動するOSイメージを選択できます。

デフォルトのカタログは以下です。

https://munenick.github.io/os-iso-catalog/v1/all.json

利用可能なイメージ一覧は以下で確認できます。

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros

Ubuntuを検索する場合は以下です。

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --search ubuntu

イメージタイプで絞ることもできます。

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --type cloud-image

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --type iso

docker run --rm \
  ghcr.io/munenick/docker-vm-runner:latest --list-distros --type disk-image

普段使いでは、cloud-initが使えて起動も速い cloud-image が扱いやすいと思います。

特定のイメージを指定して起動する場合は、DISTRO を指定します。

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

デフォルトのゲストユーザーは user、パスワードは password です。

SSH鍵を入れたい場合は、SSH_PUBKEY を指定します。

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

起動前に設定を確認する

複雑な設定を入れる場合、いきなりVMを起動する前に --show-config--dry-run を使うと便利です。

解決後の設定を見る場合は以下です。

docker run --rm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e CPUS=4 \
  -e MEMORY=8192 \
  -e NETWORK_MODE=nat \
  -e PORT_FWD=8080:80 \
  ghcr.io/munenick/docker-vm-runner:latest --show-config

起動せずに検証だけする場合は以下です。

docker run --rm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e CPUS=4 \
  -e MEMORY=8192 \
  ghcr.io/munenick/docker-vm-runner:latest --dry-run

VMのXMLを見たい場合は --show-xml も使えます。

このあたりは、環境変数が増えてきた時の確認に使えます。

SSHで接続する

デフォルトのNATネットワークでは、ホスト側の2222番をゲストの22番に転送できます。

docker run --rm -it \
  --device /dev/kvm \
  -p 2222:2222 \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

起動後は以下で接続できます。

ssh user@localhost -p 2222

パスワードを変更したい場合は、GUEST_PASSWORD を指定します。

docker run --rm -it \
  --device /dev/kvm \
  -p 2222:2222 \
  -e GUEST_PASSWORD='change-me' \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

追加のポートを転送したい場合は PORT_FWD を使います。

たとえば、ゲストの80番をホスト側8080番で見たい場合は以下です。

docker run --rm -it \
  --device /dev/kvm \
  -p 8080:8080 \
  -e PORT_FWD=8080:80 \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

PORT_FWDcontainer_port:guest_port の形式です。複数指定する場合はカンマ区切りです。

-e PORT_FWD=8080:80,8443:443

Dockerの -p も合わせて指定する必要があります。

ゲスト内でコマンドを実行する

docker-vm-runner では、QEMU Guest Agentを使ってゲスト内でコマンドを実行する guest-exec も用意しています。

まず、VMをバックグラウンドで起動します。

docker run -dit --name ubuntu-vm \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -v ubuntu-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

その後、ホスト側から以下のようにゲスト内コマンドを実行できます。

docker exec ubuntu-vm guest-exec --wait "uname -a"

引数形式でも実行できます。

docker exec ubuntu-vm guest-exec --wait id user

--wait を付けると、VMドメインとQEMU Guest Agentが利用可能になるまで待ってからコマンドを実行します。

SSHでログインせずに、VM内でOSレベルのコマンドを実行できるので、CIや自動テストで便利です。

たとえば、コンテナではなくVMの中で systemctl やパッケージマネージャーを動かしたい場合に使えます。

docker exec ubuntu-vm guest-exec --wait systemctl is-system-running
docker exec ubuntu-vm guest-exec --wait "apt-get update"

注意点として、guest-exec はPTYではなくQEMU Guest Agent経由で実行します。そのため、コマンドによっては標準出力のバッファリングやstdout/stderrの順序が通常のターミナル実行と少し違う場合があります。

ISOインストールもできる

cloud imageだけでなく、ISOからのインストールにも対応しています。

docker run --rm -it \
  --device /dev/kvm \
  -e BOOT_FROM=https://example.com/installer.iso \
  -e GUEST_NAME=installed-vm \
  -e CPUS=4 \
  -e MEMORY=8192 \
  -e DISK_SIZE=80G \
  -v installed-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

ISOから起動する場合は、空のディスクを用意して、CD-ROMを優先して起動します。

永続化したVMでは、インストール完了後に次回起動すると、ISOではなくインストール済みディスクから起動するようにしています。

再度ISOを接続したい場合や、レスキューISOとして繰り返し起動したい場合は FORCE_ISO=1 を使います。

docker run --rm -it \
  --device /dev/kvm \
  -e BOOT_FROM=https://example.com/rescue.iso \
  -e FORCE_ISO=1 \
  -e GUEST_NAME=rescue-vm \
  -v rescue-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

BOOT_FROM にはURL、ローカルパス、OCI参照、ISO、disk image、blank などを指定できます。

空ディスクから始めたい場合は以下のようにします。

docker run --rm -it \
  --device /dev/kvm \
  -e BOOT_FROM=blank \
  -e DISK_SIZE=40G \
  -v blank-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

noVNCでブラウザから操作する

GUIインストーラを使いたい場合は、noVNCを有効にできます。

docker run --rm -it \
  --device /dev/kvm \
  -p 6080:6080 \
  -e GRAPHICS=novnc \
  -e BOOT_FROM=https://example.com/desktop-installer.iso \
  -e GUEST_NAME=desktop-install \
  -e MEMORY=8192 \
  -e DISK_SIZE=80G \
  -v desktop-install-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

起動後、ブラウザで以下を開きます。

https://localhost:6080/

noVNCのエンドポイントは自己署名証明書を使います。

VNCクライアントを別途用意したい場合は、GRAPHICS=vnc も使えます。

docker run --rm -it \
  --device /dev/kvm \
  -p 5900:5900 \
  -e GRAPHICS=vnc \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

この場合は、VNCクライアントで localhost:5900 に接続します。

ネットワーク

デフォルトではNATを使います。

Dockerでコンテナを起動して、必要なポートだけ -p で公開するのに近い使い方です。普段の検証では、まずNATで使うのが簡単です。

一方で、VMをLAN上の普通のマシンのように見せたい場合はBridgeを使えます。

docker run -d --name bridge-vm \
  --network host \
  --cap-add NET_ADMIN \
  --device /dev/kvm \
  --device /dev/net/tun \
  --device /dev/vhost-net \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e NETWORK_MODE=bridge \
  -e NETWORK_BRIDGE=br0 \
  -e NO_CONSOLE=1 \
  -v bridge-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

Bridgeを使う場合は、ホスト上に br0 のようなLinux bridgeが存在している必要があります。

物理NICにVMを直接ぶら下げたい場合はDirectも使えます。

MACVTAP_MAJOR="$(awk '$2 == "macvtap" { print $1 }' /proc/devices)"

docker run -d --name direct-vm \
  --network host \
  --cap-add NET_ADMIN \
  --device /dev/kvm \
  --device /dev/vhost-net \
  --device-cgroup-rule "c ${MACVTAP_MAJOR}:* rwm" \
  -v /dev:/dev:ro \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e NETWORK_MODE=direct \
  -e NETWORK_DIRECT_DEV=eth0 \
  -e NO_CONSOLE=1 \
  -v direct-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

BridgeやDirectはホスト側のネットワーク構成に強く依存します。まずはNATで動かして、必要になったらBridgeやDirectを使うのが良いと思います。

また、複数NICにも対応しています。たとえば1枚目をNATの管理用NIC、2枚目をBridgeやDirectの検証用NICにする、といった構成もできます。

docker run --rm \
  -e DISTRO=alpine-3.22-cloud-amd64 \
  -e NETWORK_MODE=nat \
  -e NETWORK2_MODE=bridge \
  -e NETWORK2_BRIDGE=br0 \
  ghcr.io/munenick/docker-vm-runner:latest --show-config

iPXEによるネットワークブート

docker-vm-runner はiPXEによるネットワークブートにも対応しています。

docker run --rm -it \
  --network host \
  --cap-add NET_ADMIN \
  --device /dev/kvm \
  --device /dev/net/tun \
  --device /dev/vhost-net \
  -e IPXE_ENABLE=1 \
  -e BOOT_ORDER=network,hd \
  -e NETWORK_MODE=bridge \
  -e NETWORK_BRIDGE=br0 \
  -e GUEST_NAME=ipxe-vm \
  -v ipxe-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

IPXE_ENABLE=1 を指定すると、プライマリNICにiPXE ROMを注入し、boot orderでnetworkを優先するようにできます。

PXE系のインストーラ環境や、netboot.xyzのような仕組みを検証したい場合に使えます。

実際のPXE環境では、ゲストがDHCP/TFTP/HTTPなどの上流サービスに到達できる必要があります。そのため、NATよりBridgeやDirectの方が現実的です。

ストレージ

メインディスクのサイズは DISK_SIZE で指定できます。

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e DISK_SIZE=40G \
  -v storage-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

DISK_SIZE には halfmax も指定できます。

追加ディスクは DISK2_SIZE から DISK6_SIZE で指定できます。

docker run --rm -it \
  --device /dev/kvm \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e DISK_TYPE=scsi \
  -e DISK2_SIZE=100G \
  -v storage-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

ディスクコントローラは DISK_TYPE で指定できます。対応している値は virtioscsinvmeideusb です。

ホストディレクトリをゲストに共有することもできます。

mkdir -p share

docker run --rm -it \
  --device /dev/kvm \
  -v "$PWD/share:/shared" \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e FILESYSTEM_SOURCE=/shared \
  -e FILESYSTEM_TARGET=shared \
  -e FILESYSTEM_DRIVER=9p \
  -e FILESYSTEM_ACCESSMODE=mapped \
  -v shared-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

ゲスト側では以下のようにマウントできます。

sudo mkdir -p /mnt/shared
sudo mount -t 9p -o trans=virtio,version=9p2000.L shared /mnt/shared

virtiofs も使えますが、アクセスモードの指定などは9pと違いがあります。細かい設定はDocsのStorageとReferenceを見てください。

Redfish/IPMIでVMを制御する

docker-vm-runner ではRedfishやIPMIも有効にできます。

Redfishを有効にする例です。

docker run --rm -it \
  --device /dev/kvm \
  -p 8443:8443 \
  -e DISTRO=ubuntu-24.04-cloud-amd64 \
  -e REDFISH_ENABLE=1 \
  -e REDFISH_PASSWORD='change-me' \
  -v redfish-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest

確認は以下です。

curl -k -u admin:change-me https://localhost:8443/redfish/v1/

Redfishを有効にする場合、デフォルトパスワードのままではなく REDFISH_PASSWORD を変更してください。

IPMIも同様に有効化できます。

docker run --rm -it \
  --device /dev/kvm \
  -p 623:623/udp \
  -e IPMI_ENABLE=1 \
  -e IPMI_PASSWORD='change-me' \
  -v ipmi-vm-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest
ipmitool -I lanplus -U admin -P change-me -H localhost -p 623 power status

IPMIはVirtualBMC経由でlibvirtのVMを制御します。

このあたりは、普通のローカルVMランナーとしては少し珍しい機能だと思います。物理サーバ向けの制御ツールや、bare metal provisioning系のツールをVM相手に試したい場合に使えます。

Ironic連携のExamples

DocsにはExamplesも用意しています。

https://munenick.github.io/docker-vm-runner/examples/

たとえば、Docker Composeで複数VMを起動する例があります。

examples/multiple-vms/

これはUbuntuとAlpineのVMを1つのComposeプロジェクトとして起動し、それぞれにSSHやnoVNCでアクセスできるようにする例です。

また、RedfishやIPMIでIronicからVMを操作する例もあります。

examples/redfish-ironic/
examples/ipmi-ironic/

これらは完全なOpenStack環境を再現するものではありませんが、IronicがRedfish/IPMI経由でノードを検出し、validateし、boot deviceを操作する流れをローカルで確認できます。

iPXEとnetboot.xyzを使う例もあります。

examples/ipxe-netbootxyz/

単発のコマンドで説明しづらいものは、Examplesの方にDocker Compose形式でまとめています。

AI Agent向けのSkill

最近はCodexやClaude Code、OpenCodeのようなAI Coding Agentにコマンドを実行させる場面が増えています。

ただ、Agentにホスト環境を直接触らせるのは避けたい場合があります。

一方で、通常のコンテナではsystemd、カーネル、OSインストーラ、ネットワークブートのような検証ができません。

そこで、docker-vm-runner にはAI Coding Agent向けのSkillも用意しています。

https://munenick.github.io/docker-vm-runner/agent-skills/

このSkillを使うと、Agentに対して以下のようなVM操作をさせやすくなります。

- 一時VMや永続VMの起動
- guest-execによるゲスト内コマンド実行
- SSH接続
- noVNC/VNCによるGUIインストーラ操作
- RedfishによるBMC風制御
- ISO、cloud image、blank disk、iPXEなどからの起動
- ネットワーク、追加ディスク、ファイル共有、ブロックデバイスの設定

GitHub CLIが使える場合は、以下のようにインストールできます。

Codex向けです。

gh skill install MuNeNICK/docker-vm-runner skills/docker-vm-runner-agent \
  --agent codex \
  --scope user

Claude Code向けです。

gh skill install MuNeNICK/docker-vm-runner skills/docker-vm-runner-agent \
  --agent claude-code \
  --scope user

OpenCode向けです。

gh skill install MuNeNICK/docker-vm-runner skills/docker-vm-runner-agent \
  --agent opencode \
  --scope user

AgentにOSレベルの検証をさせたいが、ホスト環境は直接触らせたくない、という用途に使えると思います。

cleanup

VMを何度も起動していると、前回のコンテナ終了時にランタイムリソースが残る場合があります。

その場合は --cleanup を使えます。

docker run --rm \
  -v docker-vm-runner-data:/data \
  ghcr.io/munenick/docker-vm-runner:latest --cleanup

/data をマウントしている場合、永続ディスクやキャッシュは残しつつ、残っているランタイムリソースを削除します。

GUEST_NAMEDISTRO を指定していた場合は、cleanup時も同じ値を指定してください。

どういう時に使うか

自分が想定している用途は、主に以下です。

OSイメージの検証

cloud imageやdisk imageが正しく起動するか、cloud-initが通るか、SSHできるか、といった確認に使えます。

Kubernetesやインフラ系スクリプトのテスト

Kubernetesセットアップスクリプトのように、systemd、swap、カーネルモジュール、iptables、firewalld、SELinux/AppArmorなどに依存するものは、コンテナ上では検証しづらいです。

こういうものはVMでテストした方が安全です。

docker-vm-runner を使えば、Dockerのワークフローに寄せたまま、本物のVMで検証できます。

OSインストーラの検証

ISOを起動して、ディスクにインストールし、次回以降はインストール済みディスクから起動する、という流れを扱えます。

GUIインストーラが必要な場合はnoVNCも使えます。

bare metal系ツールの検証

Redfish/IPMIに対応しているため、BMCを前提にしたツールをVM相手に試せます。

Ironicのようなツールを、物理サーバなしである程度確認したい場合に便利です。

Agent用の隔離実行環境

AI Coding Agentに何かを検証させる場合、コンテナでは足りないが、ホストを直接触らせたくないことがあります。

その場合に、VMをサンドボックスとして使えます。

注意点

docker-vm-runner はLinuxホストを主対象にしています。

macOSやWindowsでも、Docker DesktopやWSL2などの環境によって一部は動く可能性がありますが、KVMやホストネットワーク周りはLinuxほど素直には使えません。

Bridge、Direct、ホストブロックデバイス、GPU、USB、TPM、ファイルシステム共有などは、ホスト側のデバイスやDocker実行オプションに依存します。

おわりに

docker-vm-runner は、Dockerのような手軽さで本物のVMを起動するためのツールです。

コンテナで済むものはコンテナで良いと思います。

ただし、systemd、カーネル、ファームウェア、ISO、ディスク、ネットワーク、Redfish/IPMIのような領域に入ると、コンテナではどうしても限界があります。

その境界を超えたい時に、QEMU/libvirtを直接書くのではなく、DockerのワークフローでVMを扱えるようにするのがこのツールの目的です。

興味があれば試してみてください。