libvirt-goの紹介:Cgoを使わないGo向けLibvirt API

本日、ZimaSpace Engineeringはlibvirt-goを公開します。これは、puregoで構築した、cgoを使わないGo向けlibvirtバインディングの実験的な開発者プレビューです。このプロジェクトは、ZVMの開発と、ユーザーがHome Assistant OS、Windows、pfSenseなどのワークロード用マシンを作成・管理するホームサーバー仮想化の実用的なニーズから生まれました。Goとlibvirtの開発者がこのアプローチを検証し、その限界のテストに協力できるよう、今回ライブラリを公開します。libvirt-goは、リリース済みのどのZimaOSビルドにも含まれていません。今後、既存のZVMバックエンドを改善するために段階的に活用する予定の、独立した実験的プロジェクトです。
ZVMの構築中に直面した課題
ZVMを構築する際、libvirtを通じて仮想マシンの定義、起動、停止、状態確認、削除を行うGoコードが必要でした。これらの処理は、ストレージの接続やネットワークインターフェースの構成を含む、ホームサーバーVMの通常のライフサイクルを支えます。リリースビルドを複雑にすることなく、バックエンドがそのライフサイクルをより広く扱えるようにしたいと考えていました。
主な課題は、cgoのクロスコンパイルでした。リリース用のクロスビルドをCGO_ENABLED=0で実行したいと考えていましたが、この設定ではcgoコードがビルドから除外されます。開発者が別のターゲット向けにZVMをビルドする場合、cgoベースのバインディングにはGoコンパイラー以外も必要です。ビルドマシンには、libvirtの開発用ヘッダー、それらを参照するpkg-configメタデータ、対象プラットフォーム用の動作するCコンパイラーも必要です。依存関係が増えるたびに、一貫したGoバイナリーを生成すべきリリースパイプラインへ、ホストとターゲットに関する前提が加わります。
また、各ターゲットシステムにインストールされているlibvirtのバージョンも考慮する必要がありました。あるマシンでは、古い環境に存在しないシンボルやAPIが公開されている場合があります。1組のヘッダーに対するコンパイル時チェックだけでは、対応が必要なすべての実行環境を表現できません。そのため、Go APIには慎重に定めた対応範囲、実行時のバージョン認識、関数が利用できない場合の明確な動作が必要です。リソースの所有権も重要です。VM、接続、コールバックのハンドルには明示的な解放規則が必要であり、長時間にわたるライフサイクル処理でネイティブリソースをリークさせてはいけません。
この経験から、puregoでlibvirt-goを構築し、libvirtを動的に読み込んでcgoなしで呼び出す方法に至りました。ZVMから着想を得た独立した実験として始め、テスト後に有用な部分を既存のバックエンドへ段階的に導入する予定です。現在、リリース済みのZimaOSビルドにはこの統合は含まれていません。
なぜ別のGoバインディングを構築するのか?
既存のGoバインディングは、長年にわたりlibvirtユーザーとGoの仮想化プロジェクトを支えてきました。libvirt-goは、ZVMの開発中に直面した特定の制約に対して、異なる手法を採用します。アプリケーションのビルドはCGO_ENABLED=0で成功する必要があるため、ビルド環境にはlibvirtのヘッダー、pkg-config、対象プラットフォーム向けのCコンパイラーが不要です。これにより、Goを中心とするリリースパイプライン内でクロスコンパイルを維持しやすくなります。
生成されたバイナリーは、引き続き同一プロセス内でネイティブのlibvirt共有ライブラリにアクセスします。Goアプリケーションとlibvirtの間に、付随するサービスや別のプロトコルはありません。このプロジェクトは、アップストリームのメタデータから広範な低レベルAPIを生成し、より安全なGoのライフサイクルが必要なリソースハンドルと値には、所有権を考慮して手書きしたラッパーを追加します。
実行時のシンボル検出も中核的な目標です。インストール済みのlibvirtは生成に使用したメタデータより古い場合がある一方、ディストリビューションが一部の新しい関数をバックポートしている場合もあります。libvirt-goは実際に存在するシンボルを確認するため、1つのビルド時バージョンですべてのターゲットを表せると想定せず、呼び出し側が利用できない処理を明示的に扱えます。この組み合わせは、ZVMのクロスビルドとデプロイの制約に適しています。また、cgoを使わないビルド、同一プロセス内でのlibvirtアクセス、多様なLinux環境にわたる慎重な互換性対応を必要とする、ほかのGoプロジェクトにも適している可能性があります。
libvirt-goの仕組み
libvirt-goには、生成された低レベル層と、手書きの高レベル層があります。実行時にpuregoがネイティブのlibvirt共有ライブラリを開き、CシンボルをGoから呼び出せる関数にバインドします。これにより、libvirtを同じプロセスで動作させながら、アプリケーションのビルドからcgoを除外できます。そのため、プログラムを実行するマシンには、引き続き互換性のあるlibvirt共有ライブラリが必要です。

ジェネレーターは、このリポジトリにベンダーとして取り込んだlibvirt 12.6.0の公式API XMLメタデータを読み込みます。main、admin、QEMU、LXCの各API全体で、このメタデータから正確に568個の関数と1,093個のenumが生成されます。生成されたサーフェスは、公開RawAPIメソッドをpuregoに適した型で提供します。そのカタログにはソースライブラリのルーティングも記録されるため、各関数はメインのlibvirtライブラリ、またはadmin、QEMU、LXCの各拡張ライブラリで検索されます。生成されたシンボル登録データは名前と呼び出し先を結び付け、導入バージョンはアップストリームのlibvirtが各関数を追加した時期を記録します。拡張ライブラリが見つからない場合は、そのライブラリが持つシンボルに影響しますが、メインライブラリの読み込みは妨げません。
生成処理は広い対応範囲を提供し、手書きのラッパーはXML宣言だけでは表現できない動作規則を提供します。これらのラッパーは、接続、ドメイン、ストリーム、その他のネイティブハンドルの所有権を追跡します。割り当て済みの戻り値をGoデータにコピーし、必要に応じてネイティブの割り当てを解放します。また、ネイティブ呼び出しが必要とする期間にわたってGoの値を保持し、コールバックの登録とクリーンアップを管理して、libvirtのスレッドローカルなエラー記録を構造化されたGoエラーに変換します。呼び出しが成功した場合でも、参照の所有権が移る、解放が必要なメモリーが返る、呼び出し終了後も有効な処理が登録される可能性があるため、これらの詳細は重要です。
互換性はローダー内に隠さず、呼び出し側から確認できます。HasSymbolは、読み込まれたライブラリが生成済みの関数をエクスポートしているかを報告します。SymbolVersionは、既知のシンボルが導入されたアップストリームのバージョンを報告します。要求した関数が存在しない場合、生成されたraw呼び出しと高レベル呼び出しはSymbolUnavailableErrorを返します。このエラーはErrSymbolUnavailableをラップするため、通常のGoのエラー検査で、シンボル名と導入バージョンを保持したまま共通の状態を認識できます。
このシンボル単位のモデルは、古いlibvirt環境とディストリビューションによるバックポートをサポートします。数値バージョンを完全な機能一覧として扱わず、実行時ライブラリがエクスポートする内容に基づきます。高レベルラッパーがない特殊な操作にはRawAPIを使用できますが、raw呼び出し側はCの失敗センチネル、ネイティブの所有権、スレッドローカルなエラー処理に引き続き責任を負います。アプリケーションコードでは、高レベル層のほうが安全な出発点です。
試してみる
リポジトリのサンプルは、libvirtのtest:///default URIを読み取り専用モードで開き、そのドメインを一覧表示します。
package main |
リポジトリのルートから、libvirtがインストールされた対応Linuxマシン上で、cgoを無効にしてサンプルを実行します。
CGO_ENABLED=0 go run ./examples/list-domains |
test:///defaultはlibvirtの合成テストドライバーです。サンプルリソースを提供し、実際のゲストには変更を加えません。ビルドにlibvirtの開発用ファイルは不要です。ただし、プログラムの実行時には互換性のあるlibvirt共有ライブラリが必要です。
現在の制限と検証
libvirt-goは実験段階にあり、本番ワークロードには推奨されません。実行時テスト済みでサポート対象となるプラットフォームはLinux amd64だけです。Linux arm64はコンパイルチェックのみ行っています。macOS、FreeBSD、NetBSDのローダー経路はこのプロジェクトではテストしておらず、サポート対象外です。
実行時には、互換性のあるネイティブlibvirt共有ライブラリが必要です。アプリケーションのビルドからcgoを除外すると、ビルド時のCツールチェーン依存はなくなりますが、libvirt自体が置き換わるわけではありません。アプリケーションは接続を閉じ、各APIの所有権規則に従って返されたリソースを明示的に解放する必要があります。RawAPIの呼び出し側は、ネイティブの失敗センチネルを認識し、同じOSスレッドでスレッドローカルなlibvirtエラーを取得する必要もあります。本番のlibvirt/QEMU/KVM環境では検証していません。
テストによる検証結果は、これらのサポート範囲とは分けて扱います。自動CIでは、ジェネレーター、ABIレイアウト、所有権ヘルパーを対象とするcgo無効のユニットテスト、Ubuntu 22.04と24.04上でtest:///defaultを使用する合成統合テスト、raceチェック、生成コードのチェックを実行します。ゲート付きの実環境統合ジョブでは、一時的なUbuntuランナー上で隔離されたQEMU/libvirtデーモンを起動し、使い捨てのフィクスチャ、ゲストライフサイクルのコールバック、クリーンアップを検証します。互換性チェックでは、異なるパッケージ版libvirtランタイムと、シンボルが見つからない場合の動作を扱います。GovulncheckとCodeQLにより、依存関係とコードのセキュリティ分析も追加します。
これらのチェックは、APIの動作とテストの隔離性について有用な証拠を提供します。本番環境におけるストレージ転送、ストリーム、コールバック負荷、長時間にわたるゲストの頻繁な入れ替わりに対する信頼性を確立するものではありません。
今後の展開
今後の方針の1つは、各部分がテストで有用だと確認された後、libvirt-goを既存のZVMバックエンドの段階的な改善に使用することです。このライブラリは独立したパッケージとして維持し、cgoを使わないビルドで同一プロセス内のlibvirtアクセスを必要とする、ほかのGoプロジェクトも利用できるようにします。短期的な作業では、高レベルラッパーの拡充、高負荷時のストリームとコールバックのテスト、リソース寿命に関する検証範囲の強化、古いものから新しいものまで幅広いlibvirt互換性マトリクスの検証、Linux arm64をコンパイルチェックから実行時検証へ進めることに重点を置きます。ZimaOSのバージョン、リリース日、統合スケジュールの発表はありません。
開発者プレビューを試す
libvirt-goの開発者プレビューを試し、不足している点を見つけたら、対象を絞ったGitHub issueを作成してください。Linuxディストリビューションとアーキテクチャ、インストール済みのlibvirtバージョン、不足している高レベルラッパーまたは利用できないシンボル、最小限の再現手順を含めてください。
古いlibvirtバージョンとLinux arm64での動作報告は、特に有用です。リソース、コールバック、ストリームに関する不具合では、クリーンアップの順序と、test:///defaultまたは実際のデーモンのどちらで問題を再現できるかを説明してください。コントリビューションも歓迎します。不足しているラッパーを追加する場合は、基盤となるlibvirt関数と所有権の動作を明記したissueから始めてください。そうすることで、実装とテストを一緒にレビューできます。