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>? を変換する必要があります。これは煩雑でバグも混入しやすい処理です。
XPCMessage は xpc_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_create と xpc_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 に変換する
クライアント側では、XPCClient が xpc_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 サーバーが処理するすべてのルートを定義します。containerList、containerCreate、containerBootstrap、networkCreate、pluginLoad、ping など、数十種類のルートが含まれます。
このファイルにはさらに、XPCMessage に対する型付きの拡張メソッドも用意されており、生の文字列の代わりに XPCKeys や XPCRoute の値を受け取ります。たとえば message.string(key: "id") と書く代わりに message.string(key: .id) と記述できます。
サンドボックスサービスには、SandboxRoutes.swift に独自の enum が並行して定義されています。各ルートは com.apple.container.sandbox/ プレフィックスで名前空間が区切られています。たとえば com.apple.container.sandbox/bootstrap や com.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つの構造体によるパターンに従っています。
- Service(通常は
actor)— ビジネスロジックと可変状態を保持する。 - Harness(通常は
struct)— XPC メッセージのデシリアライズ、サービスの呼び出し、レスポンスのシリアライズを担当する。
ハーネスのメソッドは、API サーバーの起動時にルートハンドラとして登録されます。APIServer+Start.swift#L264-L292 を見てみましょう。ContainersService が actor で、ContainersHarness が struct です。XPCRoute.containerCreate のような各ルートが harness.create にマッピングされています。
この分離は非常にクリーンな設計です。サービス層は XPCMessage に一切触れないため、XPC なしでのテストが可能になります。ハーネスはリクエストのデコード、サービスの呼び出し、レスポンスのエンコードだけを担う薄いグルーコードです。API サーバー内のすべてのサービスがこのパターンに従っています。PluginsService/PluginsHarness、NetworksService/NetworksHarness、VolumesService/VolumesHarness、HealthCheckService/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 エンドポイントを作成して返します。
メインサーバーはその匿名コネクション上でリッスンします。こちらが実際の操作をすべて提供します。bootstrap、createProcess、start、stop、kill、resize、wait、dial、shutdown、statistics などです。
なぜこのような分割をするのでしょうか。公開された Mach サービス名はシステム上のあらゆるプロセスから発見可能です。公開サーフェスを createEndpoint の1つに絞ることで、たとえ攻撃者がサービスに接続できたとしても、できることを最小限に抑えられます。実際のサンドボックス操作は匿名エンドポイントを通じてのみアクセス可能であり、そのためにはまず createEndpoint の呼び出しに成功し、EUID チェックを通過する必要があります。
このハンドシェイクのクライアント側実装は SandboxClient.swift#L50-L75 にあります。静的メソッド create は公開 Mach サービスに接続し、createEndpoint を呼び出してレスポンスからエンドポイントを取り出し、そこから新しいコネクションを作成して、その直接コネクションを持つ SandboxClient を返します。
次回予告
通信レイヤーの全体像を把握したところで、1つの操作をエンドツーエンドで追跡する準備が整いました。次回は container run の実行をエンターキーを押した瞬間から追いかけます。CLI のパース、API サーバーへの XPC 呼び出し、launchd へのプラグイン登録、先ほど紹介したエンドポイントハンドシェイク、VM の作成、Linux の起動、stdio パイプの受け渡し、そして最終的なプロセスの終了まで、一連の流れを詳しく見ていきましょう。