Read OSS

XPC通信レイヤー:プロセス間のメッセージのやり取り

上級

前提知識

  • 第1回:アーキテクチャとナビゲーションガイド
  • Swift の actor および async/await に関する基礎知識
  • macOS の XPC と Mach サービスの基本的な理解

XPC通信レイヤー:プロセス間のメッセージのやり取り

第1回で見たように、apple/container は単一のプロセスではなく、5つの実行ファイルが協調して動作する仕組みになっています。それらの間を流れるすべてのメッセージは、Sources/ContainerXPC/ に存在するカスタム XPC 抽象化レイヤーを経由します。このレイヤーは主に3つの役割を担っています。XPC ディクショナリへの型安全なアクセスの提供、XPC のコールバックベース API を Swift の async/await に変換すること、そしてすべての受信メッセージに対する EUID ベースのセキュリティチェックの適用です。

本記事では、このレイヤーの各コンポーネントを詳しく解説します。続いて、サービスターゲットがその上にどのように型付きの API コントラクトを構築しているかを見ていき、最後にコンテナランタイムヘルパーが採用している特に洗練されたセキュリティパターンを紹介します。

XPCMessage:xpc_object_t を包む型付きディクショナリ

Apple の XPC フレームワークは、不透明な xpc_object_t 型を使って動作します。XPC ディクショナリから文字列を取り出すには、C 文字列のキーを渡して xpc_dictionary_get_string を呼び出し、返ってくる UnsafePointer<CChar>? を変換する必要があります。これは煩雑でバグも混入しやすい処理です。

XPCMessagexpc_object_t をラップし、プロジェクトで必要なすべての型に対して型安全なアクセサを提供します。

classDiagram
    class XPCMessage {
        +routeKey: String$
        +errorKey: String$
        -object: xpc_object_t
        -lock: NSLock
        +string(key) String?
        +set(key, String)
        +data(key) Data?
        +set(key, Data)
        +bool(key) Bool
        +uint64(key) UInt64
        +int64(key) Int64
        +date(key) Date
        +fileHandle(key) FileHandle?
        +set(key, FileHandle)
        +endpoint(key) xpc_endpoint_t?
        +reply() XPCMessage
        +error() throws
        +set(error: ContainerizationError)
    }

2点、特筆すべき実装の詳細があります。まず、内部の xpc_object_t へのアクセスはすべて NSLock を通じてシリアライズされています。オブジェクト自体は nonisolated(unsafe) としてマークされており、これは Swift 6 の並行性モデルにおける脱出ハッチとも言える指定ですが、実際のアクセスは常にロックで保護されています。次に、エラーハンドリングは規約ベースの設計になっています。エラーは ContainerXPCError 構造体として JSON エンコードされ、既定のキーに格納されます(XPCMessage.swift#L78-L98)。クライアント側はすべてのレスポンスに対して message.error() を呼び出し、サーバー側で発生した失敗を確認します。

fileHandle のアクセサは特に注目に値します。XPC はプロセス間でファイルディスクリプタを受け渡す機能を持っており、カーネルが受信側プロセスのファイルテーブルにディスクリプタを複製します。この仕組みを利用して、stdin/stdout/stderr のパイプが CLI から API サーバーを経由してコンテナランタイムへと渡されます。XPCMessage.swift#L218-L235 の実装では、ディスクリプタのライフサイクルを管理するために xpc_fd_createxpc_fd_dup を使用しています。

ヒント: dataNoCopy(key:) はパフォーマンス最適化のためのメソッドで、データをコピーせずに XPC オブジェクトのメモリを直接参照する Data を返します。JSON デコードのようにデータをその場で消費する場合に有効ですが、XPC メッセージが解放されるとデータも無効になる点に注意してください。

XPCServer:ルートベースのディスパッチとセキュリティ

XPCServer は Mach サービスの識別子と、ルート文字列からハンドラクロージャへのマッピングを持つディクショナリを引数として初期化されます。listen() を呼び出すと、Mach サービスのリスナーを作成して接続を受け入れ、メッセージに埋め込まれたルートキーをもとに各メッセージを適切なハンドラへディスパッチします。

flowchart TD
    A[Incoming XPC Connection] --> B{xpc_get_type?}
    B -->|CONNECTION| C[handleClientConnection]
    B -->|ERROR| D[Finish Stream]
    C --> E[Receive Message]
    E --> F{Is Dictionary?}
    F -->|No| G[Reply Error]
    F -->|Yes| H{EUID Match?}
    H -->|No| I[Reply Unauthorized]
    H -->|Yes| J{Route Exists?}
    J -->|No| K[Reply Invalid]
    J -->|Yes| L[Call Handler]
    L --> M[Send Response]

セキュリティチェックはシンプルでありながら非常に重要です。XPCServer.swift#L166-L184 では、すべての受信メッセージから xpc_dictionary_get_audit_token で監査トークンを取り出し、クライアントの実効 UID を geteuid() で取得した自身の UID と照合します。一致しない場合、リクエストは即座に拒否されます。これにより、同一マシン上の別ユーザーがコンテナデーモンにコマンドを送信するのを防いでいます。

接続処理では、XPC のコールバックモデルを構造化並行処理へ橋渡しするために AsyncStream を活用しています。外側の listen() メソッドは接続イベントハンドラを AsyncStream<xpc_connection_t> でラップし、各接続のメッセージも AsyncStream<xpc_object_t> でラップされます。両ストリームとも withThrowingDiscardingTaskGroup を使って並行処理しています。

XPCClient:コールバックを async/await に変換する

クライアント側では、XPCClientxpc_connection_create_mach_service をラップし、非同期の send メソッドを提供します。特に興味深いのがタイムアウトの処理です。

send(_:responseTimeout:)withThrowingTaskGroup を使って2つのタスクを競争させます。1つは実際の XPC 送信(withCheckedThrowingContinuation でラップ)、もう1つはタイムアウト後にエラーをスローするスリープタスクです。先に完了した方が勝ち、もう一方はキャンセルされます。

sequenceDiagram
    participant Caller
    participant TaskGroup
    participant XPC as xpc_connection_send
    participant Timer as Task.sleep

    Caller->>TaskGroup: addTask(XPC send)
    Caller->>TaskGroup: addTask(sleep timeout)

    alt XPC responds first
        XPC-->>TaskGroup: XPCMessage
        TaskGroup->>Timer: cancel
        TaskGroup-->>Caller: response
    else Timeout fires first
        Timer-->>TaskGroup: throw timeout error
        TaskGroup->>XPC: cancel
        TaskGroup-->>Caller: throw error
    end

デフォルトのタイムアウトは60秒(XPCClient.xpcRegistrationTimeout)と意図的に長めに設定されています。コンテナランタイムヘルパーが launchd に初めて登録される際、macOS がプロセスを実際に起動するまで数秒かかることがあるためです。サービスが起動してしまえば、XPC リクエストはミリ秒単位で完了します。

ルートとキーの enum:型付き API コントラクト

生の XPCMessage は文字列キーを使って動作します。サービス層はその上に enum を使って型安全なコントラクトを構築しています。ContainerAPIClient の XPC+.swift では2つの enum が定義されています。

XPCKeys — API サーバーを経由して流れるすべてのデータのフィールド名を定義します。コンテナの設定、プロセス ID、stdio 用のファイルディスクリプタ、ネットワークの状態、ボリュームデータ、進捗状況のアップデートなどが含まれます。

XPCRoute — API サーバーが処理するすべてのルートを定義します。containerListcontainerCreatecontainerBootstrapnetworkCreatepluginLoadping など、数十種類のルートが含まれます。

このファイルにはさらに、XPCMessage に対する型付きの拡張メソッドも用意されており、生の文字列の代わりに XPCKeysXPCRoute の値を受け取ります。たとえば message.string(key: "id") と書く代わりに message.string(key: .id) と記述できます。

サンドボックスサービスには、SandboxRoutes.swift に独自の enum が並行して定義されています。各ルートは com.apple.container.sandbox/ プレフィックスで名前空間が区切られています。たとえば com.apple.container.sandbox/bootstrapcom.apple.container.sandbox/createProcess のような形式です。

classDiagram
    class XPCRoute {
        <<enumeration>>
        containerList
        containerCreate
        containerBootstrap
        containerStop
        networkCreate
        pluginLoad
        ping
        ...
    }
    class SandboxRoutes {
        <<enumeration>>
        createEndpoint
        bootstrap
        createProcess
        start
        stop
        wait
        dial
        shutdown
        ...
    }
    class XPCKeys {
        <<enumeration>>
        id
        containerConfig
        stdin
        stdout
        stderr
        exitCode
        ...
    }
    XPCRoute ..> XPCMessage : used with
    SandboxRoutes ..> XPCMessage : used with
    XPCKeys ..> XPCMessage : used with

ヒント: apple/container に新しい操作を追加する際は、まず対応する enum にルートを追加し、新しいデータフィールドがあればキーも追加するところから始めましょう。ビジネスロジックを書く前にコントラクトを確立しておくことが大切です。

Service/Harness パターン

コードベースのサーバー側サービスはすべて、一貫した2つの構造体によるパターンに従っています。

  1. Service(通常は actor)— ビジネスロジックと可変状態を保持する。
  2. Harness(通常は struct)— XPC メッセージのデシリアライズ、サービスの呼び出し、レスポンスのシリアライズを担当する。

ハーネスのメソッドは、API サーバーの起動時にルートハンドラとして登録されます。APIServer+Start.swift#L264-L292 を見てみましょう。ContainersService が actor で、ContainersHarness が struct です。XPCRoute.containerCreate のような各ルートが harness.create にマッピングされています。

この分離は非常にクリーンな設計です。サービス層は XPCMessage に一切触れないため、XPC なしでのテストが可能になります。ハーネスはリクエストのデコード、サービスの呼び出し、レスポンスのエンコードだけを担う薄いグルーコードです。API サーバー内のすべてのサービスがこのパターンに従っています。PluginsService/PluginsHarnessNetworksService/NetworksHarnessVolumesService/VolumesHarnessHealthCheckService/HealthCheckHarness がその例です。

container-runtime-linux の2サーバーセキュリティパターン

ランタイムヘルパーは、コードベース全体の中でも最も興味深い XPC パターンを採用しています。すべての操作を単一の Mach サービスで公開するのではなく、2つの XPC サーバーを並行して動作させるのです。

RuntimeLinuxHelper+Start.swift#L74-L122 を見てみましょう。

sequenceDiagram
    participant Client as API Server / CLI
    participant EP as Endpoint Server<br/>(public Mach service)
    participant Main as Main Server<br/>(anonymous connection)

    Note over EP: Only exposes createEndpoint route
    Client->>EP: createEndpoint
    EP->>EP: xpc_endpoint_create(anonymousConnection)
    EP-->>Client: XPC endpoint token
    Client->>Client: xpc_connection_create_from_endpoint
    Client->>Main: bootstrap, createProcess, wait...
    Main-->>Client: responses

エンドポイントサーバーは、公開された Mach サービス名(例:com.apple.container.runtime.container-runtime-linux.{uuid})で launchd に登録されています。ここで公開するルートはたった1つ、createEndpoint だけです。このルートは匿名コネクションから XPC エンドポイントを作成して返します。

メインサーバーはその匿名コネクション上でリッスンします。こちらが実際の操作をすべて提供します。bootstrapcreateProcessstartstopkillresizewaitdialshutdownstatistics などです。

なぜこのような分割をするのでしょうか。公開された Mach サービス名はシステム上のあらゆるプロセスから発見可能です。公開サーフェスを createEndpoint の1つに絞ることで、たとえ攻撃者がサービスに接続できたとしても、できることを最小限に抑えられます。実際のサンドボックス操作は匿名エンドポイントを通じてのみアクセス可能であり、そのためにはまず createEndpoint の呼び出しに成功し、EUID チェックを通過する必要があります。

このハンドシェイクのクライアント側実装は SandboxClient.swift#L50-L75 にあります。静的メソッド create は公開 Mach サービスに接続し、createEndpoint を呼び出してレスポンスからエンドポイントを取り出し、そこから新しいコネクションを作成して、その直接コネクションを持つ SandboxClient を返します。

次回予告

通信レイヤーの全体像を把握したところで、1つの操作をエンドツーエンドで追跡する準備が整いました。次回は container run の実行をエンターキーを押した瞬間から追いかけます。CLI のパース、API サーバーへの XPC 呼び出し、launchd へのプラグイン登録、先ほど紹介したエンドポイントハンドシェイク、VM の作成、Linux の起動、stdio パイプの受け渡し、そして最終的なプロセスの終了まで、一連の流れを詳しく見ていきましょう。