😸

100 行未満で macOS 上に仮想マシンを起動する

に公開

eBPF の本を読みながら試すとか、壊れても良い Linux 環境をお手軽に作って作業したいときに、最近は仮想マシンを実行するための機能である macOS の Virtualization.Framework を使っています。
あまり記事を見かけたこともないので、ちょっと使い方を書いてみました。

Apple が提供しているサンプルコード を実行するのが一番簡単なんですが、自作しても 100 行程度なので今回は作ってみました。
作ったものは Xcode を使わず swiftc (not swift) コマンドを使ったアプリケーション作成方法にしていますが、これは遊ぶ部分が欲しかっただけでただの趣味です。

環境

  • macOS 15.4.1 (on Apple M3)
  • Command Line Tools for Xcode 16.3
    • Swift 6.1

作るもの

今回は Ubuntu 24.04.2 のライブ環境を動かす仮想マシンを作っていきます。揮発することを前提にしていて、永続化周りの処理を端折ったシンプルなものにしています。

ディレクトリ構成

今回はディレクトリに全てのファイルを平置きし、起動に必要なファイルも配置してしまいます。
EFI は仮想マシンを起動した際に自動で作成されるため、自分では触りません。
main (拡張子がない方) が最終的に実行するバイナリになります。

project root
├  EFI
├  entitlements.plist
├  main.swift
├  main
└  ubuntu-24.04.2-live-server-arm64.iso

実装

まずはソースが見たいという人のために、 Swift の全体を置いておきます。

main.swift の全体
import AppKit
import Virtualization

func makeVirtualMachine() throws -> VZVirtualMachine {
  let config = VZVirtualMachineConfiguration()
  config.cpuCount = 4
  config.memorySize = 8 * 1024 * 1024 * 1024
  config.keyboards = [VZUSBKeyboardConfiguration()]
  config.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]

  let platform = VZGenericPlatformConfiguration()
  platform.machineIdentifier = VZGenericMachineIdentifier()
  config.platform = platform

  let bootLoader = VZEFIBootLoader()
  bootLoader.variableStore = try VZEFIVariableStore(creatingVariableStoreAt: URL.currentDirectory().appending(path: "EFI", directoryHint: .notDirectory),
                                                    options: [.allowOverwrite])
  config.bootLoader = bootLoader

  let diskURL = URL.currentDirectory().appending(path: "ubuntu-24.04.2-live-server-arm64.iso", directoryHint: .notDirectory)
  config.storageDevices = [VZUSBMassStorageDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: true))]

  let graphicsDevice = VZVirtioGraphicsDeviceConfiguration()
  graphicsDevice.scanouts = [VZVirtioGraphicsScanoutConfiguration(widthInPixels: 1280, heightInPixels: 720)]
  config.graphicsDevices = [graphicsDevice]

  try config.validate()
  return VZVirtualMachine(configuration: config)
}

final class VirtualMachineDelegate: NSObject, VZVirtualMachineDelegate {
  func guestDidStop(_ virtualMachine: VZVirtualMachine) {
    NSApplication.shared.terminate(nil)
  }
}

final class WindowController: NSWindowController {
  let delegate = VirtualMachineDelegate()

  init() {
    let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1280, height: 720 + 8),
                          styleMask: NSWindow.StyleMask(arrayLiteral: .titled, .closable),
                          backing: .buffered,
                          defer: true)
    super.init(window: window)

    let virtualMachine = try! makeVirtualMachine()
    virtualMachine.delegate = delegate
    let view = VZVirtualMachineView()
    view.virtualMachine = virtualMachine
    virtualMachine.start(completionHandler: { result in
      if case .failure(let error) = result {
        print("start failed: \(error)")
        NSApplication.shared.terminate(nil)
      }
    })
    window.contentView = view
    window.makeKeyAndOrderFront(self)
    window.center()
  }

  required init?(coder: NSCoder) {
    super.init(coder: coder)
  }
}

final class AppDelegate: NSObject, NSApplicationDelegate {
  var windowController: WindowController?

  func applicationWillFinishLaunching(_ notification: Notification) {
    self.windowController = WindowController()
    NSApplication.shared.activate(ignoringOtherApps: true)
  }
}

let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
NSApplication.shared.setActivationPolicy(.regular)
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

それでは作っていくので、 main.swift ファイルを作成・編集します。

仮想マシンの構成

VZVirtualMachineConfiguration が仮想マシンの設定を保持するので、これを設定していきます。

CPU・メモリ・キーボード・ポインティングデバイス

この 4 つは比較的単純で、値を設定するか、定義されている struct のインスタンスをそのまま設定するだけです。
ここでは 4vCPU に 8GiB のメインメモリの仮想マシンを構成します。

let config = VZVirtualMachineConfiguration()
config.cpuCount = 4
config.memorySize = 8 * 1024 * 1024 * 1024
config.keyboards = [VZUSBKeyboardConfiguration()]
config.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]

プラットフォーム

マザーボードの識別子に当たる部分を構成します。 VZGenericPlatformConfiguration に VZGenericMachineIdentifier を設定します。
今回は使い捨ての仮想マシンなので作るだけで終わりですが、繰り返し使う場合はファイルなどに書き込むことになります。

let platform = VZGenericPlatformConfiguration()
platform.machineIdentifier = VZGenericMachineIdentifier()
config.platform = platform

EFI

EFI の部分は VZEFIBootLoader を使います。
EFI の変数を保存する部分は必ずファイルに書き込む形になっているので、それに従って出力先を指定します。
ただ使い捨てではあるので、 init(creatingVariableStoreAt:options:) の options で上書き可能と設定しています。

let bootLoader = VZEFIBootLoader()
bootLoader.variableStore = try VZEFIVariableStore(creatingVariableStoreAt: URL.currentDirectory().appending(path: "EFI", directoryHint: .notDirectory),
                                                  options: [.allowOverwrite])
config.bootLoader = bootLoader

ディスクイメージ

続いて Ubuntu の ISO ファイルを接続します。
どんな種類の装置なのかを Configuration が、実態がなんなのかを Attachment が表すようになっています。
ここでは USB 接続のストレージ機器として、ディスクイメージのファイルを使うように構成します。

let diskURL = URL.currentDirectory().appending(path: "ubuntu-24.04.2-live-server-arm64.iso", directoryHint: .notDirectory)
config.storageDevices = [VZUSBMassStorageDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: true))]

モニター

仮想の画面の構成で、ここでは HD 解像度のモニターにしています。
GPU に関しては細かい設定があるのかなと思って調べてみましたが、特にありませんでした。

let graphicsDevice = VZVirtioGraphicsDeviceConfiguration()
graphicsDevice.scanouts = [VZVirtioGraphicsScanoutConfiguration(widthInPixels: 1280, heightInPixels: 720)]
config.graphicsDevices = [graphicsDevice] 

設定の検証

さて、ここまでで仮想マシンの構成ができたので、問題がないかを確認するためのメソッドを実行するようにします。
CPU の割り当てが上限を超えている、ディスクが指定のパスに無い、といった問題があればエラーが投げられます。

try config.validate()

インスタンスの作成

検証が通ったら、設定を使って仮想マシンのクラスのインスタンスを作成します。
ここまでで一回コンパイルして間違いないか確認したいという人もいると思うので、これまで書いたコードを含めて関数にして、 import も追加します。

import Virtualization

func makeVirtualMachine() throws -> VZVirtualMachine {
  // ここまで書いてきたコード

  return VZVirtualMachine(configuration: config)
}

コンパイル

コンパイルは以下のコマンドを実行します。問題がなければ何も表示されません。

swiftc main.swift

アプリとして起動するためのコードの追加

ここは趣味の部分が多いので、まずは何も考えずに以下の import と、全体のソースから final class VirtualMachineDelegate: NSObject, VZVirtualMachineDelegate { の行から最後までをコピーしてください。

import AppKit

仮想マシンの表示は VZVirtualMachineView という view が用意されていて、インスタンスを設定するだけでよしなにしてくれます。

let view = VZVirtualMachineView()
view.virtualMachine = virtualMachine

仮想マシンの終了イベントなどは VZVirtualMachineDelegate で定義されているので、これを継承したクラスで受け取ることができます。

virtualMachine.delegate = delegate

そして作成したインスタンスは start メソッドで起動します。
起動した結果はコールバックで受け取れますし、 async 版のメソッドもあります。

virtualMachine.start(completionHandler: { result in
  if case .failure(let error) = result {
    print("start failed: \(error)")
    NSApplication.shared.terminate(nil)
  }
})

entitlements ファイルの作成

Virtualization.Framework を使用するときは entitlements で許可を得る必要があるので、 com.apple.security.virtualization キーに true を設定した plist ファイルを作成します。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.virtualization</key>
	<true/>
</dict>
</plist>

実行バイナリの作成

まずはコンパイルします。

swiftc main.swift

次に entitlements を付与して署名します。
手元で動かすだけなので、証明書を使用しない ad-hoc signing とする --sign - のオプションを使います。

codesign --sign - --entitlements=entitlements.plist main

署名が完了したら ./main で起動します。 macOS のアプリケーションとして振る舞わせているのでウィンドウが立ち上がり、仮想マシンの画面が表示されます。
今回は手抜きで実装したので、メインメニューには項目が何もありません。
終了するには起動したシェルで Control + C を入力してシグナルを送信してください。
また、仮想マシンの中でシャットダウンしてもアプリケーションは終了します。

終わりに

仮想マシンの構成だけなら 20 行の Swift で、想像よりもはるかに簡単だったのではないでしょうか。
Virtualization.Framework はハイパーバイザーなのでホスト同一アーキテクチャのみ、作成できるのは macOS か Linux とされているなど制限もありますが、複数の仮想マシンを作るようなアプリケーションも実現できるので、興味を持ったら試してみてはいかがでしょうか。

Discussion