Read OSS

プラグインシステムと launchd 連携

上級

前提知識

  • 第1回: アーキテクチャとナビゲーションガイド
  • 第2回: XPC 通信レイヤー

プラグインシステムと launchd 連携

apple/container を読み解いていくと、意外な事実に気づきます。Linux ランタイム、vmnet ネットワークマネージャー、images ヘルパーといった組み込みコンポーネントは、すべてプラグインとして実装されているのです。これらはサードパーティ製拡張が使うのとまったく同じ探索・登録の仕組みに従っています。これは後から付け足された拡張システムではなく、プロジェクト自身の機能を組み合わせるための根幹の仕組みです。

この記事では、プラグインシステムを config.json のスキーマから始め、ディレクトリスキャン、launchd plist の生成、Mach サービスの命名規則、そして未知のサブコマンドに対する CLI の透過的な execvp ディスパッチまで、一連の流れを追って解説します。

プラグインの種類と config.json スキーマ

プラグインは、config.json ファイルと、任意で実行ファイルを含む bin/ サブディレクトリで構成されるディレクトリです。スキーマは PluginConfig 構造体で定義されています。

最も重要な区別は、servicesConfig を持たない CLI プラグインと、持つデーモンプラグインの違いです。デーモンプラグインは DaemonPluginType を持つサービスを 1 つ以上宣言します。

classDiagram
    class PluginConfig {
        +abstract: String
        +author: String?
        +servicesConfig: ServicesConfig?
        +isCLI: Bool
    }
    class ServicesConfig {
        +loadAtBoot: Bool
        +runAtLoad: Bool
        +services: [Service]
        +defaultArguments: [String]
    }
    class Service {
        +type: DaemonPluginType
        +description: String?
    }
    class DaemonPluginType {
        <<enumeration>>
        runtime
        network
        core
        auxiliary
    }
    PluginConfig --> ServicesConfig
    ServicesConfig --> Service
    Service --> DaemonPluginType
種別 ライフタイム
runtime コンテナごとに 1 インスタンス container-runtime-linux
network ネットワークごとに 1 インスタンス container-network-vmnet
core シングルトン、API サーバーに紐づく container-core-images
auxiliary 将来の利用のために予約済み

組み込みランタイムプラグインの config.json を見ると、設計の意図がよくわかります。

{
    "abstract": "Linux container runtime plugin",
    "servicesConfig": {
        "loadAtBoot": false,
        "runAtLoad": false,
        "services": [{ "type": "runtime" }],
        "defaultArguments": []
    }
}

loadAtBootfalse になっている点に注目してください。ランタイムプラグインは API サーバーの起動時に launchd へ登録されるのではなく、コンテナが作成されたタイミングでオンデマンドに登録されます。ネットワークプラグインの config.json も同様に loadAtBoot: false ですが、runAtLoad: true が設定されており、launchd にロードされると同時に実行が開始されます。

ヒント: 99 行目 にある isCLI 算出プロパティは、servicesConfig == nil を確認するだけのシンプルな実装です。サービス設定を持たないプラグインが CLI プラグインとみなされます。逆に言えば、servicesConfig を含むプラグインはデーモンサービスと CLI インターフェースの両方を提供することも可能です。

プラグインの探索: config.json のディレクトリスキャン

PluginLoader.findPlugins() は、複数のディレクトリをスキャンしてインストール済みプラグインを探します。以下の優先順位で検索されます。

  1. ユーザープラグイン<installRoot>/libexec/container-plugins/
  2. App Bundle プラグインBundle.main.resourceURL/plugins/(.app インストールの場合)
  3. インストールルートプラグイン<installRoot>/libexec/container/plugins/(Unix ライクなインストールの場合)
flowchart TD
    A[findPlugins] --> B[For each plugin directory]
    B --> C[List subdirectories]
    C --> D[For each subdirectory]
    D --> E[Try each PluginFactory]
    E --> F{Factory creates Plugin?}
    F -->|Yes| G{Name already seen?}
    F -->|No| H[Try next factory]
    G -->|Yes| I[Skip - shadowed]
    G -->|No| J[Add to results]
    J --> K[Record name in set]

シャドーイングの仕組みは重要なポイントです。同じ名前のプラグインがユーザーディレクトリとインストールルートディレクトリの両方に存在する場合、ユーザー側が優先されます。これにより、インストール本体を変更することなく組み込みプラグインを上書きできます。ユーザーディレクトリが先にスキャンされ、pluginNamesSet<String> として管理されるため、重複が自動的に排除されます。

PluginFactory プロトコルによって、ディレクトリレイアウトの違いを吸収できます。DefaultPluginFactoryconfig.jsonbin/ ディレクトリがある標準的なレイアウトを前提とし、AppBundlePluginFactory は macOS の App Bundle レイアウトを扱います。このファクトリーパターンにより、探索ロジックがレイアウトの詳細を意識しなくて済む構造になっています。

launchd への登録: Plist 生成と bootstrap/bootout

デーモンプラグインを起動する際、PluginLoader.registerWithLaunchd() が launchd plist を生成して登録します。手順は次のとおりです。

  1. プラグイン名とオプションのインスタンス ID から launchd ラベルを生成する
  2. コマンドライン引数を構築する(デフォルトは ["start"] にリソースパスやデバッグフラグを追加)
  3. 環境変数を CONTAINER_* 系とプロキシ設定のみに絞り込む
  4. ラベル、引数、環境変数、Mach サービス名、セッションタイプを含む LaunchPlist 構造体を生成する
  5. plist をディスクに書き出す
  6. ServiceManager.register(plistPath:) を呼び出して launchctl bootstrap を実行する
sequenceDiagram
    participant API as container-apiserver
    participant PL as PluginLoader
    participant SM as ServiceManager
    participant LD as launchd

    API->>PL: registerWithLaunchd(plugin, instanceId)
    PL->>PL: Generate LaunchPlist
    PL->>PL: Write plist to disk
    PL->>SM: register(plistPath)
    SM->>LD: launchctl bootstrap <domain> <plist>
    LD->>LD: Register Mach services
    LD-->>SM: OK

ServiceManager/bin/launchctl の薄いラッパーです。登録には launchctl bootstrap、登録解除には launchctl bootout、再起動には launchctl kickstart、シグナル送信には launchctl kill をそれぞれシェルアウトして呼び出します。ドメインは launchctl managername を問い合わせることで動的に決定されます。GUI セッションなら Aqua、バックグラウンドセッションなら Background、システムセッションなら System が返り、それぞれ gui/<uid>user/<uid>system にマッピングされます。

PluginLoader.swift#L268-L275 の環境変数フィルタリングはセキュリティ上の措置です。CONTAINER_ で始まる環境変数と一般的なプロキシ変数(http_proxyHTTP_PROXY など)だけをプラグインプロセスに渡すことで、センシティブな環境変数が意図せず漏洩するのを防いでいます。

Mach サービスの命名規則

Mach サービスの命名には、Plugin.swift#L59-L75 で定義された規則的なパターンが使われます。

com.apple.container.{type}.{pluginName}[.{instanceId}]

具体例を挙げると次のようになります。

  • com.apple.container.runtime.container-runtime-linux.abc123 — コンテナ abc123 のランタイムインスタンス
  • com.apple.container.network.container-network-vmnet — シングルトンのネットワークプラグイン
  • com.apple.container.core.container-core-images — シングルトンの images プラグイン
flowchart LR
    subgraph "Singleton plugins"
        N["com.apple.container.network.container-network-vmnet"]
        I["com.apple.container.core.container-core-images"]
    end
    subgraph "Per-instance plugins"
        R1["com.apple.container.runtime.container-runtime-linux.{uuid1}"]
        R2["com.apple.container.runtime.container-runtime-linux.{uuid2}"]
    end

インスタンス ID のサフィックスは runtime プラグインにとって欠かせません。各コンテナはそれぞれ独自のランタイムプロセスと Mach サービス名を必要とするためです。SandboxClient.machServiceLabel メソッドは接続時にこのラベルを構築し、ContainersService はプラグインを launchd に登録する際にこれを使用します。

launchd のラベルは少し異なるパターンに従います。com.apple.container.{pluginName}[.{instanceId}] — type コンポーネントが含まれない点に注目してください。launchd のラベルはすべてのサービスにわたって一意でなければならず、プラグイン名自体に十分なコンテキストが含まれているためです。

execvp による CLI プラグインのディスパッチ

プラグインシステムの最後のピースは、CLI 拡張の処理です。container foo のような未知のサブコマンドを入力すると、DefaultCommand がそれを受け取ります。

DefaultCommand は Application のコマンド設定で defaultSubcommand として登録されているため、既知のコマンドにマッチしない引数をすべて受け取ります。その run() メソッドは次の手順で動作します。

  1. API サーバー経由で PluginLoader を生成する
  2. 最初の引数をプラグイン名の候補として取り出す
  3. その名前に一致するプラグインを検索する
  4. CLI プラグインであることを確認する(plugin.config.isCLI
  5. シグナルハンドラーをデフォルトにリセットする(プラグインが自身でシグナルを管理できるように)
  6. plugin.exec(args:) を呼び出す — これが execvp を実行する
flowchart TD
    A["container foo --bar baz"] --> B[DefaultCommand.run]
    B --> C{Plugin 'foo' exists?}
    C -->|No| D[Print error with hint paths]
    C -->|Yes| E{Is CLI plugin?}
    E -->|No| D
    E -->|Yes| F[Reset SIGINT/SIGTERM]
    F --> G["plugin.exec(args)"]
    G --> H["execvp('/path/to/foo', args)"]
    H --> I[Plugin takes over process]

Plugin.swift#L102-L111execvp 呼び出しは、fork を伴わずに現在のプロセスを完全に置き換えます。プラグインのバイナリがプロセスを引き継ぎ、ユーザーからはネイティブのサブコマンドと変わらない操作感になります。DefaultCommand.swift#L104-L107 でのシグナルリセットにより、プラグインは CLI のカスタムハンドラーを引き継ぐことなく、クリーンなシグナル処理の状態から起動できます。

ヒント: CLI プラグインを開発している場合、プラグインが見つからなかったときのエラーメッセージには、プラグインをインストールすべき正確なディレクトリが表示されます。このパスはインストールルートから動的に算出されるため、ハードコードされておらず常に正確です。

次回予告

ここまでで、apple/container がプラグインシステムを通じてコンポーネントをどのように探索・ロード・管理するかを一通り見てきました。シリーズ最終回では、ビルドシステムを取り上げます。XPC ベースのアーキテクチャとは一線を画す、興味深い設計です。container build は vsock 経由の gRPC で Linux VM 内で動く BuildKit プロセスと通信し、HPACK メタデータヘッダーでビルド設定を渡し、進捗状況やターミナルのリサイズイベントのための双方向ストリーミングを管理します。まったく異なる通信モデルであり、その違いの理由を読み解くことで、プロジェクト全体のアーキテクチャ思想が見えてきます。