KVMとは何か——ハードウェア仮想化支援とVM Exitの仕組み

RustでKVMを使い、Firecrackerのような軽量な仮想マシンモニタ(VMM)を自作しています。実装に入る前に、KVMがLinuxカーネルの中でどう位置づけられているか、CPUのハードウェア仮想化支援機能が何を解決しているか、VMMがKVMをどういう手順で呼び出してゲストOSを実行するのかを整理します。

仮想化が解決している問題

x86のCPUには特権レベルがRing 0〜3の4段階あり、OSのカーネルはRing 0で動作して、割り込み制御やページテーブルの書き換えといった特権命令を実行します。仮想マシンの中でゲストOSを動かす場合、ゲストOSのカーネルも本来はRing 0で動きたいわけですが、物理CPUのRing 0はホスト側のカーネル(あるいはハイパーバイザ)がすでに使っています。1つの物理CPU上でRing 0を名乗れる主体は1つしかないため、ゲストOSにそのままRing 0を渡すわけにはいきません。

この問題への古典的な対処法が、ゲストOSをRing 1のような低い特権レベルで動かし、特権命令を実行しようとするたびにCPUに例外を発生させてハイパーバイザに処理を委ねる「トラップ・アンド・エミュレート」です。しかしx86の命令セットには、特権命令のはずなのに例外を発生させず黙って失敗する命令がいくつか存在し、この方式だけでは正しく仮想化できないことが知られていました。この制約を回避するために、実行前に命令列を書き換えるバイナリトランスレーションや、ゲストOS自体を仮想化前提に書き換えるパラバーチャライゼーションといった手法が使われてきました。

ハードウェア仮想化支援(VT-x/AMD-V)

2000年代半ば以降のCPUには、この問題をハードウェアレベルで解決する仮想化支援機能が搭載されています。IntelはVT-x、AMDはAMD-Vと呼びます。

VT-xは、既存のRing 0〜3とは別に「VMX root mode」と「VMX non-root mode」という2つの動作モードを追加します。ハイパーバイザはVMX root modeで動作し、ゲストOSはVMX non-root modeの中でRing 0〜3をそのまま使って動作します。ゲストOSから見れば自分がRing 0にいることに変わりはなく、特権命令もそのまま実行できます。ゲストが特権命令やI/Oアクセスなど、ハイパーバイザの管理下に置くべき操作を行おうとすると、CPUがハードウェアレベルでVMX root modeへ制御を戻します。この「non-root modeからroot modeに制御が戻る」イベントをVM Exitと呼びます。

つまりVT-xは、トラップ・アンド・エミュレートを成立させるために必要な「特権命令を確実に捕捉する仕組み」を、ソフトウェアの工夫ではなくCPUの機能として提供するものです。/proc/cpuinfoのCPUフラグにvmx(Intel)やsvm(AMD)が立っているかどうかで、このハードウェア機能に対応しているか確認できます。

KVMの位置づけ

KVM(Kernel-based Virtual Machine)は、このVT-x/AMD-Vをアプリケーションから使えるようにするLinuxカーネルモジュールです。kvmモジュールがCPUベンダ共通の処理を担い、kvm_intelまたはkvm_amdがベンダ固有の処理を担います。

KVM自体が提供するのはCPUとメモリの仮想化に必要な最小限の機能だけで、キーボードやディスク、ネットワークカードといったデバイスのエミュレーションは範囲外です。KVMはユーザー空間のプロセスに対して/dev/kvmというデバイスファイル経由のioctl APIを公開するだけで、そのAPIを使ってVMを組み立て、デバイスをエミュレートし、ゲストのライフサイクルを管理するのはユーザー空間側のVMMの仕事です。

この役割分担のおかげで、同じKVMというカーネル機能の上に性格の異なる複数のVMMが存在します。QEMU/KVMはQEMUが持つフル機能のデバイスエミュレーション(BIOS、PCIバス、多種多様なディスク/ネットワークデバイス)をKVMと組み合わせたもので、汎用性が高い一方コード規模も大きくなります。FirecrackerはRust製の自作VMMで、virtio-netとvirtio-blockなど必要最小限のデバイスしか実装しないことで、起動の速さと攻撃面の小ささを実現しています。本プロジェクトで作るVMMも位置づけとしてはFirecracker側で、KVMのAPIを直接呼び出し、必要なデバイスだけを自分で実装していきます。

VMMから見たKVM APIの呼び出し順序

VMMが1つのゲストを起動するまでの手順は、大まかに次のような順序になります。

  1. /dev/kvmをopenし、システム全体のKVM fdを取得する
  2. KVM fdに対してKVM_CREATE_VM ioctlを発行し、VM fdを取得する(この時点でVMという「入れ物」がカーネル内に作られる)
  3. VM fdに対してKVM_SET_USER_MEMORY_REGIONioctlを発行し、VMMがmmapで確保したホスト側のメモリ領域を、ゲストの物理アドレス空間として登録する
  4. VM fdに対してKVM_CREATE_VCPUioctlを発行し、vCPU fdを取得する(1vCPUにつき1つ)
  5. vCPU fdに対してレジスタの初期値(KVM_SET_REGS/KVM_SET_SREGSなど)を設定する
  6. vCPU fdに対してKVM_RUNioctlを発行すると、実際にゲストコードの実行が始まる

KVM_RUNは、ゲストの実行が何らかの理由で中断されるまでブロックするioctlです。中断の理由には、ゲストがhlt命令を実行した、I/Oポートにアクセスした、ページフォルトが発生した、といったものがあり、これがVM Exitです。VM Exitが起きるとKVM_RUNは制御をVMMに返し、vCPU fdをmmapした共有メモリ領域(struct kvm_run)にexit reasonと関連情報が書き込まれます。VMMはそれを見てI/Oエミュレーションなど必要な処理を行い、再びKVM_RUNを呼んで実行を再開します。ゲストOSが動いている間、VMMはこの「KVM_RUNを呼ぶ→VM Exitを処理する→またKVM_RUNを呼ぶ」というループを回し続けることになります。