インフラ・DevOps
Linux MicroVMスタックをApple Silicon上で再構築する
We Rebuilt the Linux MicroVM Stack on Apple Silicon (encore.dev)
要約
Encoreは、macOS上のApple SiliconでLinux microVM(Firecracker)を動作させるための「crackling」という新しいスタックを開発しました。これにより、開発者はローカルのMacで、本番環境と同じビルドシステムとイメージを動かせるようになり、リモートサーバーへの依存を解消しました。この再構築では、macOSのVirtualization.frameworkを利用し、LinuxのKVMに依存しない方法で、Firecrackerの機能と同等の環境を構築しました。
全文翻訳
Linux microVMスタックをApple Silicon上で再構築するAppleのハイパーバイザー上で同じmicroVMを起動するために必要なこと、そしてAppleが許可しない唯一の機能。
Encoreはバックエンドアプリケーションを構築・デプロイしており、2022年半ば以降、それらのビルドはすべてFirecracker microVM内で実行されてきました。FirecrackerはエミュレートされたハードウェアをLinuxカーネルが必要とするものだけに削減し、各ビルドにコンテナに近い起動コストで仮想マシンの分離性を提供します。FirecrackerはKVMを駆動するため、/dev/kvmを持つLinuxホストが必要ですが、Macにはそれがなく、EncoreのエンジニアのほとんどはMacで開発しています。開発元は、AppleのVirtualization.framework上に構築された動作中のプルーフ・オブ・コンセプトを断り、macOSをすぐにサポートする予定はないと述べているため、その問題を解決する計画はありません。そのため、4年間、ビルドシステムに取り組むということは、どこか別の場所でそれに取り組むことを意味していました。
私たちは、Firecrackerを本番環境に維持しながら、同じビルドシステムをラップトップ上で実行したいと考え、Linux上のFirecrackerとmacOS上のAppleのハイパーバイザーの両方を駆動する単一のmicroVM APIであるcracklingを構築しました。両方のプラットフォームで同じイメージを起動するには、macOS上で実行するためにLinuxイメージのツールチェーンの多くを再構築する必要がありました。
4年間の共有リモートマシンでの開発
各エンジニアのオンボーディングは、一度実行するスクリプトで行いました。それは共有ビルドマシンにrootとしてSSH接続し、あなたの公開鍵をhttps://github.com/<you>.keysから取得してユーザーを作成し、ハイパーバイザーにアクセスしてコンテナを実行できるようにkvmとdockerグループに追加しました。VMイメージを~/imagesにコピーし、firecrackerバイナリを~/binariesにハードリンクしました。これは、すべてのユーザーが単一のマシン上で自身のツリー内にそれらを必要としたためです。データセンター内にパーソナル環境ができあがり、Tailscale経由でアクセスでき、他の全員の環境の隣に配置されました。
その環境に変更を適用するには、2番目のスクリプトが必要でした。これは、CUE設定から、gitignoreされたエンジニアごとのファイルにあるユーザー名とポートを読み取りました。なぜなら、私たちはそのホストを共有しており、衝突しないように合意する必要があったからです。バイナリは簡単な部分でした。GOOS=linux GOARCH=amd64でクロスコンパイルし、結果をrsyncで転送し、転送されたファイルの数を数えて、何かを再起動する必要があるかどうかを判断しました。
イメージは難しい部分でした。なぜなら、Firecrackerはブロックデバイスを起動し、Dockerはレイヤーを生成するからです。DockerレイヤーをFirecrackerが起動できるブロックデバイスに変換する既存のツールを見つけることができなかったため、私たちはその変換を自分たちで構築しました。半分はラップトップ上で、半分はSSH経由で行いました。
# tools/dev-builder/deploy-dev-builder.sh
docker save -o "$imagesdir/$name.tar" "$docker_image"
tar -C "$layersdir" -xf "$imagesdir/$name.tar" # explode the layers
tar -C "$dst/" -xf "$imagesdir/$name.tar"
manifest.json
rsync -azP $layersdir ${username}@builder:~/images/
rsync -azP "$dst" ${username}@builder:~/images/
ssh ${username}@builder -- \
"bash -l -s squash_layers \"images/${outputdir}\" \"images/${name}\""
< $scriptpath
最後の行は、シェル関数をログインシェルにパイプしてリモートで実行します。bashは後にGoで書き直されましたが、パイプラインとホストは変更されませんでした。squash_layersは、マニフェストの順序で各レイヤーを再展開し、findを使用して.wh..wh..opqホワイトアウトマーカーを削除しました(tarでは適用されないため)、VMが他にDNSを持たなかったためハードコードされた/etc/resolv.confを書き込み、jqでイメージの環境変数をDocker設定から抽出し、最後にmksquashfsを呼び出してFirecrackerが起動できるものを作成しました。DockerイメージIDをキーとするキャッシュを使用し、一致した場合はパス全体をスキップできるようにしました。これは、イメージを変更するたびに実行され、ゲスト側で作業している場合はほとんどの場合そうでした。
再起動するには3番目のSSH接続が必要で、コンテナを停止してその置き換えを開始しました。
docker run --privileged \
-v ~/socks:/var/lib/buildsvc/socks:rw -v ~/logs:/tmp/encore-builds:rw \
-v ~/.keys:/.keys:ro -v ~/binaries:/usr/local/bin:ro \
-v ~/images:/usr/lib/buildsvc/images:ro \
--env-file service-envs \
--device /dev/kvm --device /dev/net/tun \
-p $port:9060 --name "${username}-builder" -d -t buildsvc-tester
FirecrackerはそこでDockerコンテナ内で実行されているため、--privileged、/dev/kvm、/dev/net/tunを渡す必要がありました。なぜなら、そのコンテナ内のプロセスがタップデバイスを作成し、それ自身の仮想マシンを起動するからです。Firecrackerは、これらのタップデバイスをホストブリッジにアタッチすることを期待しますが、Dockerコンテナ内にはホストブリッジがありません。そのため、buildsvc(ビルドサービス自体)が起動する前に実行されるシェルスクリプトで、それを構築しました。
# tools/dev-builder/container/start.sh
ip link add docker0 type bridge
ip link set eth0 master docker0
addr=$(ip address show eth0 | grep inet | xargs | cut -d " " -f2)
ip address del $addr dev eth0
ip address add $addr dev docker0 broadcast 172.17.255.255
ip link set docker0 up
ip r add default via 172.17.0.1 dev docker0
このスクリプトは、コンテナ内にdocker0という名前のブリッジを構築し、コンテナ自身のeth0をそれに従属させ、次にIPアドレスをeth0からブリッジに移動させます。タップデバイスは、実際のDockerホストのように見えるものにアタッチできるようになりました。
ビルドシステムはラップトップ以外で動作していました
このセットアップは機能し、2022年から最終的に置き換えるまで続きました。そのコストは、バックエンド開発がスムーズであるべきだ、つまり、アプリケーションを記述し、ローカルで実行し、インフラストラクチャが手動で保守するYAMLの山からではなくコードから派生するようにすべきだという、会社の全体的な主張にとって正当化するのが難しいものでした。一方、gitプッシュを実行中のアプリケーションに変える私たちの製品の部分は、私たちがソフトウェアを書いているマシンで実行できない唯一のものでした。
ビルドシステムがリモートにあったため、ローカルブレークポイントがトリガーされることはなく、ログを読むことはSSH経由でファイルをtailingすることを意味しました。プロファイラーをアタッチするには、まずそれをボックスにコピーする必要がありました。ゲスト側の変更もすべてdocker saveと展開されたイメージのrsyncを経由し、共有ホスト上での展開とmksquashfsを伴い、他のエンジニアがそこで自身のビルドを実行していました。ループは長かったため、投機的なものを試す前に二度考え、私たちが望んだのは、私たちがすでに座っているマシン上で、同じイメージを起動するビルドシステムをネイティブにMac上で実行することでした。
ほとんど共通点の少ない2つのハイパーバイザーに対する1つのAPI
私たちはまず既存のものを調べました。macOS上のLinux VMの実行には、いくつかの動作する実装があります。Apple自身のコンテナは6月に1.0に達し、LimaとTartは何年も前からそれを実行しており、podmanもlibkrunを通じて実行できます。macOS 15を実行するM3以降のMacでは、Firecrackerをそのまま実行できるLinux VM内で/dev/kvmを取得することさえできます。しかし、それらのどれも両方のホストをまたいでいません。また、ネストされたルートは、それをサポートするラップトップのサブセット上でLinux VM内に留まることになります。それらのいずれを採用しても、ラップトップにのみ存在する、動作が異なる2番目のビルド実行方法を持つことになります。
cracklingは、両方のプラットフォームでOCIイメージを軽量Linux VMとして起動するデーモンとCLIです。ゲスト内には1つのエージェントがあり、どちらの方法でもそれを駆動する1つのプロトコルがあります。Linux上ではFirecrackerがバックエンドのままで、macOS上ではAppleのVirtualization.framework、つまりVZがバックエンドとなります。これは、それがエクスポートするすべてのタイプにプレフィックスとして付いています。私たちは、コアクレートをどちらのハイパーバイザーからも独立させておきました。それは、vcpus、mem、kernel、rootfs、extra_disks、nics、vsock、およびバックエンドごとの追加情報を運ぶMachineSpecを通じてマシンを記述します。MachineStateは実行時にそれを追跡します。両方のバックエンドはMachineBackendを実装しています:start、shutdown、pause、resume、snapshot、wait、dispose、そして利用可能な場合はconnect_vsockです。バックエンドのディスパッチは静的です。なぜなら、特定のターゲットに対しては1つしか存在できないからです。