コンテナのライフサイクル:`container run` から終了まで
前提知識
- ›第1回:アーキテクチャとナビゲーションガイド
- ›第2回:XPC 通信レイヤー
コンテナのライフサイクル:container run から終了まで
前回の記事ではアーキテクチャ全体を俯瞰し、XPC 通信レイヤーを詳しく解剖しました。今回はいよいよ、それらが実際に連携する様子を追っていきます。container run コマンドが入力された瞬間からコンテナプロセスが終了してシェルプロンプトが戻るまで、処理の流れを一本の道筋として追跡しましょう。
4 層アーキテクチャ、Service/Harness パターン、2 サーバーによるエンドポイントハンドシェイク、そしてファイルディスクリプタの受け渡し。これらすべてが1つの統合されたフローとして収束するのが、この記事のテーマです。
CLI のパースと ContainerConfiguration
すべての起点は ContainerRun.swift です。このコマンドは Swift Argument Parser の @OptionGroup パターンを活用して、フラグを論理的なグループに整理しています。プロセスオプション(tty、interactive)、リソースオプション(CPU、メモリ、ストレージ)、管理オプション(name、detach、auto-remove)、そしてレジストリオプションです。
flowchart TD
A["container run --name web -p 8080:80 nginx"] --> B[Parse Flags]
B --> C[Generate Container ID]
C --> D[Check for Existing Container]
D --> E["Utility.containerConfigFromFlags()"]
E --> F[ContainerConfiguration]
F --> G[ContainerClient.create]
G --> H[ContainerClient.bootstrap]
これらのフラググループは Utility.containerConfigFromFlags() によって ContainerConfiguration へと統合されます。これはコンテナのあらゆる情報を表す中心的なデータ型で、Codable に準拠しており、XPC メッセージに JSON として埋め込まれてプロセス間を行き来します。
この configuration には、コンテナのスペックが余すところなく記録されています。
| フィールド | 用途 |
|---|---|
id |
コンテナの一意識別子 |
image |
OCI イメージの参照 |
mounts |
ホストからコンテナへのファイルシステムマウント |
publishedPorts |
ポートマッピング(ホスト:コンテナ) |
networks |
ネットワーク接続の設定 |
resources |
CPU 数、メモリ(デフォルト 1 GiB)、ストレージクォータ |
rosetta |
x86-64 トランスレーションの有効化 |
ssh |
SSH エージェントソケットのフォワード |
readOnly |
rootfs を読み取り専用でマウント |
runtimeHandler |
使用する runtime plugin(デフォルト: container-runtime-linux) |
initProcess |
コンテナ内で実行するプロセス |
ヒント:
runtimeHandlerのデフォルト値は"container-runtime-linux"ですが、変更可能です。プラグインシステムが代替ランタイムに対応できるのは、まさにこの仕組みのおかげです。
ContainerClient:XPC 経由でのコンテナ作成と bootstrap
configuration が組み上がったら、CLI は ContainerClient を通じて create() と bootstrap() の 2 つの XPC 呼び出しを行います。
48〜76 行目 の create() は、ContainerConfiguration、カーネル情報、生成オプションを JSON エンコードして XPCMessage に詰め込み、ルート .containerCreate として API サーバーへ送信します。
116〜146 行目 の bootstrap() はより興味深い処理です。stdin・stdout・stderr のパイプを表すファイルハンドルを XPC メッセージに直接パックします。これらのファイルディスクリプタは、CLI プロセスから API サーバーを経由してコンテナランタイムまで届けられます。XPC のカーネルレベルの fd 受け渡しにより、2 つのプロセス境界を越えていくのです。
sequenceDiagram
participant CLI as container CLI
participant API as container-apiserver
participant LD as launchd
participant RT as container-runtime-linux
CLI->>API: containerCreate(config, kernel)
API->>API: Persist ContainerSnapshot
API->>API: Find runtime plugin
API->>LD: bootstrap plist for runtime
LD->>RT: Launch process
API-->>CLI: OK
CLI->>API: containerBootstrap(id, stdio fds)
API->>RT: createEndpoint (public Mach service)
RT-->>API: XPC endpoint
API->>RT: bootstrap(stdio fds, attachments)
RT->>RT: Boot Linux VM
RT-->>API: OK
API-->>CLI: OK + ClientProcess handle
ContainersService:プラグインの登録とサンドボックスのセットアップ
サーバー側では、ContainersService がすべてのコンテナ状態を管理する actor です。create リクエストを受け取ると、以下の処理を順に実行します。
- XPC メッセージから
ContainerConfigurationをデシリアライズする - 永続状態レコードである
ContainerSnapshotを作成する FilesystemEntityStoreを通じてディスクに保存するpluginLoaderを使って適切な runtime plugin を探すpluginLoader.registerWithLaunchd()で runtime plugin を launchd に登録する
永続化レイヤーには FilesystemEntityStore が使われています。これは JSON ファイルをディスクに書き込み、インメモリのインデックスも維持する actor です。各コンテナは <appRoot>/containers/<id>/ 配下に専用のディレクトリを持ち、シリアライズされたスナップショットが entity.json として保存されます。
ContainersService はインメモリで ContainerState 構造体の辞書を保持しており、各エントリにはスナップショット、(接続後の)SandboxClient、割り当てられたネットワーク接続情報が格納されています。
bootstrap が呼ばれると、ContainersService は第 2 回の記事で説明したエンドポイントハンドシェイクを実行します。ランタイムのパブリック Mach サービスへ接続し、匿名エンドポイントを取得して、ダイレクト接続を確立します。
SandboxClient のエンドポイントハンドシェイク
SandboxClient.create() スタティックメソッドは、2 サーバーハンドシェイクを次の手順で実装しています。
- Mach サービスラベルを構築する:
com.apple.container.runtime.container-runtime-linux.{uuid} - そのサービスに接続する
XPCClientを作成する createEndpointリクエストを送信する- レスポンスから
xpc_endpoint_tを取り出す xpc_connection_create_from_endpointを呼び出してダイレクト接続を得る- そのダイレクト接続をバックエンドとする
SandboxClientを返す
この時点以降、ランタイムとのすべての通信はパブリック Mach サービスを完全に迂回します。bootstrap、createProcess、start、wait などの操作はすべて、この匿名接続を通じて行われます。
SandboxService:VM の作成と Linux の起動
runtime helper の内部では、SandboxService が VM のライフサイクルを管理する actor です。126〜179 行目 の bootstrap メソッドこそが、Linux VM が実際に起動する場所です。
bootstrap の処理手順は次のとおりです。
- コンテナバンドルがなければディスク上に作成する
- バンドルからコンテナ設定とカーネルを読み込む
- カーネル引数を設定する(セキュリティモジュールを含む:
lsm=lockdown,capability,landlock,yama,apparmor) containerizationライブラリからVZVirtualMachineManagerを作成する- XPC メッセージから割り当て済みのネットワーク接続情報を取り出す
- 明示的に指定されていない場合は DNS ネームサーバーを動的に設定する
- macOS のバージョンに基づいてネットワークインターフェース戦略を選択する
- VM を起動する
flowchart TD
A[bootstrap message] --> B[Load config from bundle]
B --> C[Configure kernel args]
C --> D[Create VZVirtualMachineManager]
D --> E[Extract network attachments]
E --> F{macOS version?}
F -->|"macOS 26+"| G[NonisolatedInterfaceStrategy]
F -->|"macOS 15"| H[IsolatedInterfaceStrategy]
G --> I[Attach interfaces to VM]
H --> I
I --> J[Boot Linux VM]
J --> K[Start guest agent]
K --> L[Create init process]
RuntimeLinuxHelper+Start.swift#L67-L72 では、#available(macOS 26, *) ガードによってネットワークインターフェース戦略を切り替えています。macOS 26 以降では NonisolatedInterfaceStrategy(コンテナ間の完全なネットワーク通信をサポート)が、macOS 15 では IsolatedInterfaceStrategy(コンテナ同士をネットワークで分離)が選択されます。
ProcessIO:stdio、シグナル、ターミナルリサイズ
CLI 側に戻ると、ProcessIO がユーザーのターミナルとコンテナの stdio ストリームの橋渡しを担います。46〜84 行目 の create メソッドは、モードに応じて I/O パイプラインを異なる方法で構築します。
インタラクティブ TTY モード(--tty --interactive):Terminal.setraw() でターミナルをローモードに切り替えます。stdin の読み取りは readabilityHandler コールバックを使ったノンブロッキング I/O で行われます。コンテナからの stdout はユーザーの stdout へ直接パイプされ、TTY モードの慣例に従って stderr は stdout にマージされます。
非 TTY モード:stdout と stderr はそれぞれ独立したパイプと readabilityHandler コールバックを持ちます。IoTracker がストリームの完了を調整し、AsyncStream<Void> を使って各出力ストリームが完了(EOF を示す空データの受信)したタイミングを通知します。
デタッチモード(--detach):出力パイプは作成されません。コンテナ ID を出力した後、CLI は即座に終了します。
flowchart LR
subgraph CLI Process
STDIN["Host stdin"]
STDOUT["Host stdout"]
STDERR["Host stderr"]
end
subgraph "Pipes (via XPC)"
P1["stdin pipe"]
P2["stdout pipe"]
P3["stderr pipe"]
end
subgraph Runtime Process
VM["Container VM"]
end
STDIN -->|readabilityHandler| P1
P1 -->|fd passed via XPC| VM
VM -->|fd passed via XPC| P2
P2 -->|readabilityHandler| STDOUT
VM -->|fd passed via XPC| P3
P3 -->|readabilityHandler| STDERR
シグナルハンドリングは一元化されています。ProcessIO は SIGTERM、SIGINT、SIGUSR1、SIGUSR2、SIGWINCH のハンドラを登録します。TTY セッションでは、SIGWINCH(ターミナルリサイズ)を受け取ると XPC 経由でリサイズコマンドをコンテナに送信します。非 TTY セッションでは、SignalThreshold カウンターが SIGINT/SIGTERM を 3 回連続で受け取った際に強制終了を許可します。
ヒント:
OSFile.makeNonBlocking()を使ったノンブロッキング stdin の仕組みは欠かせません。stdin をブロッキングで読み続けると、シグナルへの応答やコンテナの終了検知ができなくなってしまいます。
コンテナの状態と終了
コンテナはシンプルなステートマシンを通じて状態遷移します。
stateDiagram-v2
[*] --> created: create()
created --> running: bootstrap()
running --> stopped: exit / stop / kill
stopped --> [*]: delete()
created --> [*]: delete()
ContainersService はこれらの状態をディスクに永続化した ContainerSnapshot で追跡します。CLI から bootstrap が呼ばれるとスナップショットが running に更新され、コンテナの init プロセスが終了するとランタイムが exit monitor 経由で通知し、状態は stopped に移行します。
終了フローは仕組みをよく表しています。bootstrap が返った後、CLI の ContainerRun.run() は 165 行目 で io.handleProcess(process:log:) を呼び出します。これがコンテナの init プロセスを起動し、終了を待機します。待機は containerWait XPC ルートで実装されており、ランタイムが終了コードを報告するまでブロックします。
コンテナプロセスの終了コードは CLI まで伝播し、173 行目 で ArgumentParser.ExitCode としてスローされます。コンテナが 0 で終了すれば CLI も 0 で終了し、1 で終了すれば CLI も 1 で終了します。シンプルで透明な設計です。
--remove フラグが指定されている場合、コンテナは終了後に自動で削除されます。実行中にエラーが発生した場合は、167 行目 の catch ブロック内で client.delete(id:) を呼び出してクリーンアップを試みます。
次回予告
コンテナの完全なライフサイクルをトレースし終えましたが、重要なサブシステムについてはまだ触れていません。ネットワークです。コンテナはどのように IP アドレスを取得するのか。コンテナ同士がホスト名で名前解決できるのはなぜか。なぜこのプロジェクトは独自の DNS サーバーを持っているのか。次回の記事では、仮想ネットワークの作成から IP アドレスの割り当て、そして DNS ハンドラーに潜む musl libc 互換性への対応まで、ネットワークスタックを深掘りしていきます。