Read OSS

コンテナのライフサイクル:`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 リクエストを受け取ると、以下の処理を順に実行します。

  1. XPC メッセージから ContainerConfiguration をデシリアライズする
  2. 永続状態レコードである ContainerSnapshot を作成する
  3. FilesystemEntityStore を通じてディスクに保存する
  4. pluginLoader を使って適切な runtime plugin を探す
  5. pluginLoader.registerWithLaunchd() で runtime plugin を launchd に登録する

永続化レイヤーには FilesystemEntityStore が使われています。これは JSON ファイルをディスクに書き込み、インメモリのインデックスも維持する actor です。各コンテナは <appRoot>/containers/<id>/ 配下に専用のディレクトリを持ち、シリアライズされたスナップショットが entity.json として保存されます。

ContainersService はインメモリで ContainerState 構造体の辞書を保持しており、各エントリにはスナップショット、(接続後の)SandboxClient、割り当てられたネットワーク接続情報が格納されています。

bootstrap が呼ばれると、ContainersService は第 2 回の記事で説明したエンドポイントハンドシェイクを実行します。ランタイムのパブリック Mach サービスへ接続し、匿名エンドポイントを取得して、ダイレクト接続を確立します。

SandboxClient のエンドポイントハンドシェイク

SandboxClient.create() スタティックメソッドは、2 サーバーハンドシェイクを次の手順で実装しています。

  1. Mach サービスラベルを構築する:com.apple.container.runtime.container-runtime-linux.{uuid}
  2. そのサービスに接続する XPCClient を作成する
  3. createEndpoint リクエストを送信する
  4. レスポンスから xpc_endpoint_t を取り出す
  5. xpc_connection_create_from_endpoint を呼び出してダイレクト接続を得る
  6. そのダイレクト接続をバックエンドとする SandboxClient を返す

この時点以降、ランタイムとのすべての通信はパブリック Mach サービスを完全に迂回します。bootstrapcreateProcessstartwait などの操作はすべて、この匿名接続を通じて行われます。

SandboxService:VM の作成と Linux の起動

runtime helper の内部では、SandboxService が VM のライフサイクルを管理する actor です。126〜179 行目bootstrap メソッドこそが、Linux VM が実際に起動する場所です。

bootstrap の処理手順は次のとおりです。

  1. コンテナバンドルがなければディスク上に作成する
  2. バンドルからコンテナ設定とカーネルを読み込む
  3. カーネル引数を設定する(セキュリティモジュールを含む:lsm=lockdown,capability,landlock,yama,apparmor
  4. containerization ライブラリから VZVirtualMachineManager を作成する
  5. XPC メッセージから割り当て済みのネットワーク接続情報を取り出す
  6. 明示的に指定されていない場合は DNS ネームサーバーを動的に設定する
  7. macOS のバージョンに基づいてネットワークインターフェース戦略を選択する
  8. 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

シグナルハンドリングは一元化されています。ProcessIOSIGTERMSIGINTSIGUSR1SIGUSR2SIGWINCH のハンドラを登録します。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 互換性への対応まで、ネットワークスタックを深掘りしていきます。