前回は「なぜMarmotがOVN, OVSを採用しているのか」を、ネットワークファブリックの実装から掘り下げました。今回は視点を仮想マシンの中身そのものに移し、「なぜMarmotが仮想化基盤としてQEMU/KVMと、その制御レイヤーであるlibvirtを採用しているのか」を、実装ベースで見ていきます。
この記事で書くこと
- Marmotが仮想マシンをどう作り、どう動かしているか
- なぜQEMU/KVMという仮想化技術そのものを選んでいるのか
- なぜQEMUを直接叩くのではなく、libvirtという抽象化レイヤーを挟んでいるのか
- libvirtのXML記述モデルが、Marmotの宣言的な設計思想とどう噛み合っているか
- 運用面でのトレードオフ
1. Marmotが仮想マシンに求めているもの
これまでの記事で見てきたように、Marmotは
- etcdでクラスター全体の状態を分散管理し
- OVS/OVNでホストをまたぐ仮想ネットワークを構築する
基盤です。この2つが「状態管理」と「ネットワーク」を担うのに対し、実際にCPU・メモリ・ディスク・NICを割り当てて「仮想マシンそのもの」を動かす部分を担っているのが、QEMU/KVMとlibvirtです。
READMEにある通り、Marmotのゴールは「検証・実験・学習のための、簡便で高速な仮想サーバーの実行環境」です。ここで求められる要件を整理すると、
- ハードウェア仮想化支援機能(Intel VT-x/AMD-V)を活かした、ネイティブに近い実行性能
- qcow2、生ボリューム(LVM)、iSCSI、Ceph RBDなど、複数のストレージバックエンドに対応できる柔軟性
- ホストブリッジやOVSなど、複数の方式のネットワーク接続に対応できる柔軟性
- YAMLで宣言した状態を、そのままVMの実体に落とし込める構成管理のしやすさ
といったものになります。これらを満たす選択肢として、Linux上でデファクトスタンダードとなっているQEMU/KVMと、その制御APIであるlibvirtが採用しています。
2. なぜQEMU/KVMなのか
2-1. ハードウェア支援仮想化によるネイティブに近い性能
KVM(Kernel-based Virtual Machine)は、Linuxカーネルに組み込まれたハイパーバイザーモジュールで、Intel VT-xやAMD-VといったCPUのハードウェア仮想化支援機能を直接利用します。QEMUはこのKVMをバックエンドとして使うことで、CPU命令の多くをホストCPU上でそのまま実行でき、完全なソフトウェアエミュレーションに比べて高いパフォーマンスを実現します。
実際、Marmotが生成するlibvirtドメインXMLを見ると、この点がはっきり表れています。
// pkg/virt/libvirtXml2.go の CreateDomainXML() より(抜粋)
dom := &libvirtxml.Domain{
Type: "kvm", ID: intPtr(1), Name: vs.Name, UUID: vs.UUID,
// ...
CPU: &libvirtxml.DomainCPU{
Mode: "host-passthrough",
Check: "none",
Migratable: "on",
},
Devices: &libvirtxml.DomainDeviceList{
Emulator: "/usr/bin/qemu-system-x86_64",
},
}
Type: "kvm" でKVMアクセラレーションを使うことを明示し、CPU.Mode: "host-passthrough" によってホストのCPU機能セットをそのままゲストに見せています。これにより、ゲスト側でホストと同じ命令セット拡張(AVXなど)を使えるようになり、エミュレーションのオーバーヘッドを最小限に抑えられます。
2-2. virtioによる準仮想化デバイス
ディスクやNICについても、Marmotは一貫してvirtioバスを使っています。
// ディスク: Target.Bus = "virtio"
Target: &libvirtxml.DomainDiskTarget{Dev: d.Dev, Bus: "virtio"},
// NIC: Model.Type = "virtio"
Model: &libvirtxml.DomainInterfaceModel{Type: "virtio"},
virtioは、実機のデバイスをそっくりエミュレートするのではなく、「準仮想化」として最初からゲストとホストの間でI/Oを高速にやり取りすることを前提に設計されたインターフェースです。IDEやRTL8139のようなレガシーデバイスのフルエミュレーションに比べて、ディスクI/O・ネットワークI/Oのオーバーヘッドを大きく減らせます。
2-3. 複数のストレージバックエンドを同じ枠組みで扱える
Marmotは、qcow2・raw(LVMの生ボリューム)・ISO・iSCSI・Ceph RBDという5種類のディスクソースに対応しています(pkg/virt/libvirtXml2.goのCreateDomainXML()内、d.Typeによるswitch文)。
switch d.Type {
case "raw":
disk.Source = &libvirtxml.DomainDiskSource{
Block: &libvirtxml.DomainDiskSourceBlock{Dev: d.Src},
}
case "qcow2":
disk.Source = &libvirtxml.DomainDiskSource{
File: &libvirtxml.DomainDiskSourceFile{File: d.Src},
}
case "iscsi":
disk.Source = &libvirtxml.DomainDiskSource{
Network: &libvirtxml.DomainDiskSourceNetwork{
Protocol: "iscsi",
// ...
},
}
case "rbd":
disk.Source = &libvirtxml.DomainDiskSource{
Network: &libvirtxml.DomainDiskSourceNetwork{
Protocol: "rbd",
Name: d.Src,
Auth: &libvirtxml.DomainDiskAuth{
Username: d.CephUser,
Secret: &libvirtxml.DomainDiskSecret{Type: "ceph", UUID: d.CephSecretUUID},
},
},
}
}
QEMUはブロックデバイスの抽象化層(QEMU block layer)を持っており、ファイルベースのqcow2から、LVMの生ボリューム、iSCSI経由のネットワークブロックデバイス、Ceph RBDまで、同じディスクデバイスの体裁で扱えます。特にrbdタイプでは、cephxの認証情報(ユーザー名+libvirt Secretに登録したUUID)を指定するだけで、QEMUが直接Cephクラスターと通信してRBDイメージを読み書きします。これはMarmotがCephをストレージバックエンドとして統合する上で、QEMU側の機能にそのまま乗っかれている好例です。
3. なぜQEMUを直接叩かず、libvirtを挟むのか
QEMUはqemu-system-x86_64コマンドに大量のオプションを渡すことでVMを起動できますが、Marmotはこれを直接呼び出すのではなく、libvirtという管理レイヤーを介して操作しています。実装はpkg/virt/libvirtXml2.goにまとまっており、公式のGo言語バインディング(libvirt.org/go/libvirt、libvirt.org/go/libvirtxml)を使っています。
// pkg/virt/libvirtXml2.go より
func NewLibVirtEp(url string) (*LibVirtEp, error) {
conn, err := libvirt.NewConnect(url)
// ...
}
libvirtを挟む理由は、大きく3つあります。
3-1. XMLによる宣言的な定義モデル
libvirtは、VMの構成(CPU・メモリ・ディスク・NIC・コンソールなど)を1つのXMLドキュメント(ドメインXML)として表現します。MarmotはこのXMLをlibvirtxml.DomainというGoの構造体として組み立て、Marshal()でXML化してlibvirtに渡すだけで済みます。
// pkg/virt/libvirtXml2.go の DefineAndStartVM() より
func (l *LibVirtEp) DefineAndStartVM(domain libvirtxml.Domain) (string, error) {
xmlString, err := domain.Marshal()
// ...
dom, err := l.Com.DomainDefineXML(xmlString)
// ...
err = dom.Create()
// ...
}
これは、これまでの記事で繰り返し出てきた「あるべき状態(Desired State)を宣言し、それを実体化する」というMarmot全体の設計思想と非常に相性が良い部分です。QEMUのコマンドライン引数を都度組み立てるスクリプトを自前で書くよりも、構造化されたXMLモデルとしてVM定義を扱えるlibvirtの方が、宣言的なアーキテクチャに乗せやすくなっています。
3-2. ライフサイクル管理とホスト再起動への対応
libvirtは単にVMを起動するだけでなく、定義(define)・起動(start)・停止(stop)・削除(undefine)・自動起動設定といったライフサイクル管理のAPIを一貫して提供します。
// pkg/virt/libvirtXml2.go の DefineAndStartVM() より
// オートスタートを設定しないと、HVの再起動からの復帰時、停止している。
err = dom.SetAutostart(true)
このコメントが示す通り、SetAutostart(true)を呼ぶことで、ハイパーバイザーホスト自体が再起動した際にもVMが自動的に起き上がるようになります。QEMUプロセスを直接fork/execして管理していたら、こうした「ホスト再起動後の自己復旧」を自前の仕組みとして作り込む必要がありますが、libvirtがOS起動時の自動起動処理を肩代わりしてくれます。
3-3. ライブでのリソース変更(CPU/メモリのホットプラグ)
pkg/virt/libvirtXml2.goのSyncDomainResources()は、libvirtのライブアップデートAPIを使って、稼働中のVMのvCPU数やメモリ量を、再起動なしに変更しようと試みます。
// pkg/virt/libvirtXml2.go の syncDomainVCPU() より(抜粋)
if err := domain.SetVcpusFlags(desired, libvirt.DOMAIN_VCPU_CONFIG); err != nil {
// ...
}
if live {
if err := domain.SetVcpusFlags(desired, libvirt.DOMAIN_VCPU_LIVE); err != nil {
slog.Warn("failed to apply live vcpu change; config is updated and will apply on reboot", ...)
}
}
DOMAIN_VCPU_CONFIGで永続設定を更新しつつ、DOMAIN_VCPU_LIVEで稼働中のVMにも即座に反映を試みる、という2段構えです。ライブ反映に失敗しても設定自体は残るため、次回の再起動時には desired な状態に収束します。メモリについても同様にSetMemoryFlags()でDOMAIN_MEM_CONFIG/DOMAIN_MEM_LIVEを使い分けています。
これは、以前の記事で触れた「あるべき状態と実際の状態を突き合わせるリコンサイルループ」の一部です。コントローラーが定期的にetcd上のサーバー定義(あるべきCPU/メモリ)と、libvirt経由で取得した実際のドメイン設定を比較し、差分があればこのSyncDomainResources()で埋める、という流れになっています。QEMU Monitor Protocol(QMP)を自前で叩いてホットプラグを実装することもできますが、libvirtはこれをより安全な高レベルAPIとしてラップしてくれています。
4. libvirtとOVSの連携で起きる実運用上の課題
前回の記事でOVSのオーバーレイネットワークについて触れましたが、VMのNICはlibvirtのドメインXML上で<interface type='bridge'>としてOVSブリッジに接続されます。
// pkg/virt/libvirtXml2.go より
} else if n.Bridge != "" {
// <interface type='bridge'> の場合
iface.Source = &libvirtxml.DomainInterfaceSource{
Bridge: &libvirtxml.DomainInterfaceSourceBridge{
Bridge: n.Bridge,
},
}
}
ここでlibvirtとOVSという2つのレイヤーが連携するがゆえに起きる、実運用上のエッジケースにも対応が入っています。
// pkg/virt/libvirtXml2.go の DefineAndStartVM() より
err = dom.Create()
if err != nil && isOVSPortAttachConflict(err) {
slog.Warn("domain start failed due to ovs port conflict, attempting stale-port cleanup and retry", "err", err)
cleanupStaleOVSPorts()
err = dom.Create()
}
これは、VMの異常終了などでOVSブリッジ上にポートの残骸(stale port)が残ってしまい、次にlibvirtが同名のポートを追加しようとして「already attached to bridge」エラーになるケースへの対応です。cleanupStaleOVSPorts()がovs-vsctlでエラー状態のインターフェース("no such device"エラーを持つもの)を検出して削除し、VM起動をリトライします。
このように、libvirtは仮想マシンのライフサイクルを一元管理してくれる一方で、その配下にあるOVSやQEMU block layerといった各コンポーネントとの整合性は、Marmot側でも意識して面倒を見る必要がある、という実態が見えてきます。
5. 「QEMUを直接叩く」との比較
改めて、QEMUを直接操作する場合とlibvirtを介する場合を比較してみます。
| 観点 | QEMUを直接操作 | libvirt経由 |
|---|---|---|
| VM定義の表現 | コマンドライン引数の組み立てを自前実装 | 構造化されたXML(ドメインXML)として宣言的に表現 |
| ライフサイクル管理 | プロセスのfork/exec、死活監視を自前実装 | define/start/stop/autostartを標準APIで提供 |
| ホスト再起動時の復旧 | 自前の仕組みが必要 |
SetAutostart()で標準サポート |
| リソースのライブ変更 | QMPを自前で叩く必要がある |
SetVcpusFlags/SetMemoryFlagsなどの高レベルAPI |
| ストレージバックエンド | QEMUのオプションを個別に組み立てる | qcow2/raw/iscsi/rbdを共通のXMLモデルで扱える |
| 権限分離・セキュリティ | 自前でsVirt/AppArmor等を統合する必要がある | SELinux/AppArmorとの統合(sVirt)が標準で用意されている |
| Go言語からの扱いやすさ | 独自のプロセス管理コードが必要 | 公式Goバインディング(libvirt.org/go/libvirt)がある |
実際、MarmotのCreateDomainXML()ではSecLabelとしてapparmorとdacの動的ラベリングを設定しており、これもlibvirtのsVirt統合機能をそのまま利用しています。QEMUプロセスを直接管理していたら、こうしたセキュリティ機構も自前で組み込む必要があったはずです。
6. それでもトレードオフはある
公平のため、QEMU/libvirtを採用することの代償にも触れておきます。
- 抽象化レイヤーが増える分、デバッグが難しくなる: 問題が起きたとき、libvirtのログなのかQEMUプロセスのログなのか、あるいはOVS側の問題なのか、切り分けに複数レイヤーを追う必要がある
-
libvirtのバージョン依存:
libvirtxmlのスキーマはlibvirtのバージョンに追従して変わるため、ホストのlibvirtバージョンとGoモジュールのバージョンの組み合わせに注意が必要(Marmotはlibvirt.org/go/libvirt v1.12003.0系を使用) -
コンテナ型仮想化との比較では重量級: LXCのようなコンテナ型仮想化に比べると、QEMU/KVMは完全仮想化である分、起動時間やリソースオーバーヘッドは大きくなる(Marmotのソースにも実験的なLXC対応コード
libVirtLxc._go_が存在しますが、現状はビルド対象から外れています)
とはいえ、Marmotが目指す「複数のOSを、本番に近い形で、ハードウェアに近い性能で動かす」という要件を考えると、コンテナではなくQEMU/KVMによる完全仮想化を選び、その上でVM管理の煩雑さをlibvirtに肩代わりしてもらう、というのは理にかなった選択だと言えます。
7. まとめ
MarmotがQEMU/libvirtを採用しているのは、
- KVMアクセラレーションと
host-passthrough、virtioデバイスによるネイティブに近い実行性能 - qcow2・LVM・iSCSI・Ceph RBDを同じディスクデバイスの枠組みで扱えるQEMU block layerの柔軟性
- ドメインXMLという構造化された宣言的モデルが、Marmot全体の「あるべき状態を宣言する」設計思想と噛み合っていること
- オートスタートやライブリソース変更といったライフサイクル管理・リコンサイルの土台をlibvirtが提供してくれること
- sVirt(AppArmor/SELinux統合)による標準的なセキュリティ機構
という、実用に耐える仮想化基盤を自前でゼロから作らずに済ませるための、現実的な選択です。前回・前々回で見てきたetcdによる状態管理、OVS/OVNによるネットワークと合わせて、Marmotは「枯れた実績のある技術を、宣言的なインターフェースの下に組み合わせる」という一貫した設計哲学で作られていることが見えてきます。
次回は、この記事で触れたcloud-initによる初期化処理や、qcow2イメージのキャッシュ・RBDインポートを含むVMプロビジョニングフロー全体について掘り下げていく予定です。
コントリビューター募集中
今回取り上げたQEMU/libvirt周りにも、伸びしろのあるテーマがいくつも残っています。
- OVSポート競合以外のlibvirt/QEMUエラーパターンの洗い出しと、リトライ処理の拡充
- LXCなど軽量な仮想化オプションの検討・再有効化
- ライブマイグレーションなど、libvirtが提供するより高度な機能の活用
もちろんこれ以外にも、ドキュメント整備、テスト追加、バグ報告、Issueのトリアージなど、小さな貢献も大歓迎です。
- Issues: github.com/takara9/marmot/issues — バグ報告や機能要望はこちらへ
- Pull Requests: 修正・機能追加はPRでどうぞ。まずは CONTRIBUTING.md に目を通してもらえるとスムーズです
- Discussions: github.com/takara9/marmot/discussions — 設計の相談や質問、アイデア出しはこちら
- ライセンス: GPL-3.0 license
関連リンク