Read OSS

ビルドシステム:gRPC、BuildKit、そしてイメージの生成

上級

前提知識

  • 第1回:アーキテクチャとナビゲーションガイド
  • 第3回:`run` から終了までのコンテナライフサイクル
  • gRPC と Protocol Buffers の基本的な知識

ビルドシステム:gRPC、BuildKit、そしてイメージの生成

このシリーズのこれまでの記事では、XPC — CLI を API サーバーやヘルパーデーモンとつなぐ macOS ネイティブの IPC 機構 — を中心に話を進めてきました。ビルドシステムはそのパターンを完全に破っています。container build を実行すると、CLI は Linux コンテナ VM 内で動作する BuildKit プロセスと、vsock ソケット越しの gRPC で通信します。XPC も Mach サービスも launchd も介さず、macOS からゲスト VM への生のソケット接続だけです。

これは恣意的な設計ではありません。1コンテナ1VM というアーキテクチャから自然に導かれる必然です。BuildKit は Linux プロセスであり、Linux プロセスは macOS の XPC に参加できません。vsock 越しの gRPC が、この2つの世界をつなぐ最も自然な橋です。このシステムを理解することで、apple/container の通信パターンが制約にどう適応しているかの全体像が見えてきます。

ビルドに XPC ではなく gRPC を使う理由

ビルドシステムのアーキテクチャは、シンプルな制約から導き出されます。BuildKit は Linux VM の中で動作し、XPC は macOS プロセス間でしか機能しません。

container build を実行すると、コンテナ ID buildkit を持つ専用の「ビルダー」コンテナが起動します(すでに動いている場合は再利用されます)。このコンテナで BuildKit デーモンが起動し、接続を待ち受けます。macOS ホストは vsock ソケット — ネットワークスタックを経由せずにホストとゲスト間を直接つなぐ仮想ソケット — を通じてこのデーモンに接続します。

flowchart LR
    subgraph macOS Host
        CLI["container build<br/>(CLI process)"]
        API["container-apiserver"]
    end
    subgraph Linux VM
        BK["BuildKit daemon<br/>(Linux process)"]
    end

    CLI -->|"XPC: dial(buildkit, port)"| API
    API -->|"vsock fd"| CLI
    CLI -->|"gRPC over vsock"| BK

    style CLI fill:#4A90D9,color:#fff
    style API fill:#D94A4A,color:#fff
    style BK fill:#2ECC71,color:#fff

接続の確立は次の手順で行われます。まず API サーバーにビルダーコンテナの vsock ポートへの接続を依頼します(他の vsock 操作で使われる containerDial XPC ルートを再利用します)。接続済みソケットのファイルディスクリプタを受け取り、それを使って gRPC チャネルを確立します。

Builder 構造体:vsock 上の gRPC チャネル

Builder はビルドシステムの中心的な型です。イニシャライザは FileHandle(vsock ソケット)と EventLoopGroup を受け取り、接続済みソケット上に gRPC の ClientConnection を構成します。

39〜56行目 の接続設定は、ビルドのワークロードに合わせて調整されています。

config.connectionIdleTimeout = TimeAmount(.seconds(600))
config.connectionKeepalive = .init(
    interval: TimeAmount(.seconds(600)),
    timeout: TimeAmount(.seconds(500)),
    permitWithoutCalls: true
)
config.callStartBehavior = .fastFailure
config.httpMaxFrameSize = 8 << 10
config.maximumReceiveMessageLength = 512 << 20
config.httpTargetWindowSize = 16 << 10

注目すべき点がいくつかあります。maximumReceiveMessageLength は大きなビルド出力を受け取れるよう 512 MiB に設定されています。connectionKeepalive は10分間隔で permitWithoutCalls: true を指定しており、長時間のビルド中も接続を維持し続けます。callStartBehavior.fastFailure を指定しているため、サーバーが準備できていなければキューに積まずに即座に失敗します。

ソケットバッファは接続を作成する前に明示的に設定されます。366〜405行目setSockOpt 呼び出しにより、送信バッファ 4 MiB・受信バッファ 2 MiB が確保されます。

sequenceDiagram
    participant CLI as BuildCommand
    participant CC as ContainerClient
    participant B as Builder

    CLI->>CC: dial(id: "buildkit", port: 8088)
    CC-->>CLI: FileHandle (vsock socket)
    CLI->>B: Builder(socket: fh, group: eventLoopGroup)
    B->>B: Configure gRPC ClientConnection
    CLI->>B: info()
    B-->>CLI: InfoResponse (BuildKit ready)
    CLI->>B: build(config)

CLI の BuildCommand がこの流れを統括します。vsock ポートに接続し、Builder を生成し、info() を呼び出して BuildKit の動作を確認してからビルドを開始します。ビルダーコンテナが起動していない場合は、BuilderStart.start() を使って自動的に起動し、準備が整うまで待機します。

HPACK メタデータヘッダーとしてのビルド設定

コードベースの中でも特に独特なパターンがここにあります。ビルドのパラメータを gRPC リクエストのメッセージボディに含めるのではなく、HTTP/2 の HPACK メタデータヘッダーとして渡しています。

extension CallOptions {
    public init(_ config: Builder.BuildConfig) throws {
        var headers: [(String, String)] = [
            ("build-id", config.buildID),
            ("context", URL(filePath: config.contextDir).path(percentEncoded: false)),
            ("dockerfile", config.dockerfile.base64EncodedString()),
            ("progress", config.terminal != nil ? "tty" : "plain"),
            ("target", config.target),
        ]
        for tag in config.tags {
            headers.append(("tag", tag))
        }
        for platform in config.platforms {
            headers.append(("platforms", platform.description))
        }
        // ... build args, labels, secrets, cache options, outputs
        self.init(customMetadata: HPACKHeaders(headers))
    }
}

Dockerfile の内容は base64 エンコードされてヘッダーに格納されます。タグ、プラットフォーム、ビルド引数、ラベル、シークレットもすべてヘッダーに入ります。同じヘッダーキーを複数回使うことで、複数の値を表現しています(HPACK はこれをサポートしています)。ビルドシークレットは特に興味深く、id=base64data の形式で secrets ヘッダーに base64 エンコードされます。

なぜメッセージボディではなくヘッダーなのでしょうか。この設計は、Linux 側の BuildKit シムの動作に対応したものです。コンテナ内のシムプロセスは、ストリーミングが始まる前の gRPC 呼び出しセットアップの段階でこれらのヘッダーを受け取ります。シムはヘッダーを使って BuildKit セッションを設定し、その後の双方向ストリームで実際のビルド I/O をやり取りします。設定(ヘッダー)とデータ(ストリーム)を分離するこの構造は明快で、ストリーミングが始まる前に設定を同期的に取得できます。

双方向ストリーミング:進捗表示とターミナルリサイズ

ビルド操作は performBuild による gRPC 双方向ストリーミングを使います。クライアントは ClientStream メッセージを送信しながら、同時に ServerStream メッセージを受信します。

BuildPipeline がサーバー側ストリームを管理します。BuildPipelineHandler 実装のチェーンを保持し、それぞれが異なる種類のサーバーメッセージを担当します。

sequenceDiagram
    participant Client as macOS (Builder)
    participant Stream as gRPC Stream
    participant Server as BuildKit (Linux VM)

    Note over Client,Server: Bidirectional streaming

    Server->>Stream: ServerStream (file sync request)
    Stream->>Client: BuildFSSync handles
    Client->>Stream: ClientStream (file data)

    Server->>Stream: ServerStream (content proxy request)
    Stream->>Client: BuildRemoteContentProxy handles
    Client->>Stream: ClientStream (content data)

    Server->>Stream: ServerStream (build progress)
    Stream->>Client: BuildStdio renders to terminal

    Client->>Stream: ClientStream (terminal resize)
    Stream->>Server: Resize command

28〜35行目 のパイプラインに含まれるハンドラは次のとおりです。

ハンドラ 役割
BuildFSSync ビルドコンテキストのファイルをホストから BuildKit に同期する
BuildRemoteContentProxy ホストのコンテンツストアから OCI コンテンツをプロキシする
BuildImageResolver ビルド中にベースイメージを解決する
BuildStdio ビルドの進捗をターミナルに表示する

各ハンドラは accept(_ packet:) -> Bool でそのパケットを処理できるかを判定し、handle(_ sender:, _ packet:) で実際に処理します。パイプラインはハンドラを順番に試し、最初に受け入れたハンドラで止まります。

クライアントからサーバーへの方向では、80〜131行目 のビルドメソッドがターミナルのリサイズイベントを送信します。SIGWINCH シグナルハンドラがターミナルサイズの変更を監視し、新しいサイズを持つ TerminalCommand 構造体をシリアライズした ClientStream メッセージを送出します。

ヒント: BuildPipelinewithThrowingTaskGroup の代わりに、独自の untilFirstError という並行処理プリミティブを使っています。標準のタスクグループでは、メインループがストリームを反復し続けている間に1つのタスクが失敗しても適切に終了できないためです。98〜167行目untilFirstError の実装は、ストリームの消費とエラー監視を並行タスクとして実行することでこの問題を解決しています。

ビルドのエクスポート:BuildKit の出力からローカルコンテンツストアへ

ビルドが完了すると、BuildKit はホストからアクセスできる場所に出力(OCI イメージレイヤー)を書き出します。BuildExport 型は、サポートされている出力フォーマットを定義しています。

  • oci — OCI イメージアーカイブとしてエクスポートし、ClientImage.load でローカルコンテンツストアに読み込む
  • tar — 指定した出力先に tar アーカイブとしてエクスポートする
  • local — ビルド出力をローカルディレクトリにエクスポートする

BuildCommand.swift#L390-L442 の CLI のエクスポート処理から、OCI エクスポートのビルド後のフローが見えてきます。出力された tar をイメージサービスに読み込み、対象プラットフォーム向けに展開し、指定されたすべてのイメージ名でタグ付けします。複数のエクスポートにわたる展開の進捗は ProgressTaskCoordinator が追跡します。

flowchart TD
    A[Build Complete] --> B{Export type?}
    B -->|oci| C[Load archive via ClientImage]
    C --> D[Unpack for target platform]
    D --> E[Tag with requested names]
    E --> F["Print: Successfully built <names>"]
    B -->|tar| G[Move out.tar to destination]
    G --> H["Print: Successfully exported to <dest>"]
    B -->|local| I[Copy local dir to destination]
    I --> H

アーキテクチャを振り返って

ビルドシステムを他の部分と並べて見ると、apple/container の明確な設計原則が浮かび上がります。それぞれの境界に適した通信機構を使う、ということです。

同じユーザーコンテキストを共有し、権限の分離が必要な macOS プロセス間では、Mach サービスと audit token 検証を伴う XPC を使います。macOS ホストと Linux VM ゲストの間では、ゲストが macOS の IPC プリミティブに参加できないため、vsock 越しの gRPC を使います。すべてを単一の抽象化に無理やり通すのではなく、各境界の制約に柔軟に適応しているのです。

また、ビルドシステムは1コンテナ1VM モデルが開発ワークフローにも自然に拡張できることを示しています。BuildKit は専用の VM を持ち、実行中のコンテナから隔離されています。ビルドが失敗しても、動いているサービスには影響しません。プロジェクトごとに異なる BuildKit の設定が必要なら、複数のビルダーコンテナを並行して動かすことも可能です。

これで apple/container の深掘り連載は完結です。全6回を通じて、トップレベルのプロセスモデルから個々の XPC メッセージまで、CLI の引数解析から VM 起動、DNS 解決に至るまでのアーキテクチャを追ってきました。このコードベースはパターンの一貫性が際立っています — Service/Harness の分割、client/server ターゲットのペア、型付きルート enum — 慣習を理解すれば全体を見通せる構造になっています。唯一意図的に慣習を破っているgRPC ベースのビルドシステムも、その理由はまったく正当なものです。