Read OSS

ネットワークとDNS: 仮想ネットワーク、IPアドレス割り当て、名前解決

上級

前提知識

  • 第1回: アーキテクチャとナビゲーションガイド
  • 第2回: XPC通信レイヤー
  • 第3回: コンテナのライフサイクル

ネットワークとDNS: 仮想ネットワーク、IPアドレス割り当て、名前解決

apple/container でコンテナが起動するとき、IPアドレス、ゲートウェイ、そしてホスト名を解決する仕組みが必要です。解決対象は外部のホストだけでなく、同じマシン上の他のコンテナも含まれます。共有VM内のLinuxカーネルnamespaceでネットワークを処理する従来のコンテナランタイムとは異なり、apple/container ではコンテナごとに独立したVMが存在します。そのため、仮想ネットワークはmacOSホストレベルで構成する必要があり、Apple の vmnet.framework が使われます。

この記事では、ネットワークスタック全体を解説します。ネットワークの作成と管理、IPアドレスの割り当て、そしてSwiftNIO上に構築された2つのカスタムDNSサーバーによるホスト名解決の仕組みを見ていきます。中でも、musl libc との互換性に関する興味深い回避策にも注目してください。

ネットワークのライフサイクル: 作成・アタッチ・破棄

apple/container におけるネットワークは、コンテナやボリュームと同様に管理対象のリソースです。APIサーバーの NetworksService がネットワークのライフサイクルを統括し、container-network-vmnet ヘルパー内で動作するネットワークごとの NetworkService インスタンスに処理を委譲します。

APIサーバーの起動時、デフォルトネットワークの存在が確認され、なければ作成されます。この処理は APIServer+Start.swift#L294-L331 で行われます。

sequenceDiagram
    participant API as container-apiserver
    participant Net as container-network-vmnet
    participant vmnet as vmnet.framework

    Note over API: Startup
    API->>API: Check for default network
    API->>Net: Create network (NAT mode)
    Net->>vmnet: Create vmnet network
    vmnet-->>Net: Subnet info (gateway, CIDR)
    Net-->>API: NetworkState (running)

    Note over API: Container attaches
    API->>Net: allocate(hostname)
    Net->>Net: AttachmentAllocator.allocate()
    Net-->>API: Attachment (IP, MAC, gateway)

コンテナをネットワークにアタッチする流れは3つのステップで構成されます。APIサーバーがネットワークサービスにIPアドレスの割り当てを要求し、割り当てられたIP・MAC・ゲートウェイをまとめた Attachment を受け取り、それをブートストラップ時にランタイムヘルパーへ渡します。第3回で説明したように、SandboxService.bootstrap() はXPC経由でこれらの割り当て済みアタッチメントを受け取り、VMのネットワークインターフェースの設定に使用します。

Network プロトコルとmacOSバージョンによる分岐

Network プロトコルはシンプルで、要件は3つだけです。

public protocol Network: Sendable {
    var state: NetworkState { get async }
    nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws
    func start() async throws
}

このプロトコルには2つの実装があり、実行時のmacOSバージョンに応じて切り替えられます。

classDiagram
    class Network {
        <<protocol>>
        +state: NetworkState
        +withAdditionalData(handler)
        +start()
    }
    class ReservedVmnetNetwork {
        +@available(macOS 26, *)
        -stateMutex: Mutex~State~
        -network: vmnet_network_ref?
        +start()
    }
    class AllocationOnlyVmnetNetwork {
        +actor
        -_state: NetworkState
        +start()
    }
    Network <|.. ReservedVmnetNetwork
    Network <|.. AllocationOnlyVmnetNetwork

ReservedVmnetNetwork はmacOS 26以降で利用可能で、vmnetの予約APIを使用します。vmnet_network_ref を作成することで、完全なネットワークインターフェースの分離を実現します。同じネットワーク上のコンテナ同士は通信でき、各コンテナには予約済みのインターフェースが割り当てられます。vmnetのコールバックは任意のdispatch queueで呼ばれるため、actorではなく final class として実装され、スレッドセーフな状態管理に Mutex<State> を使っています。

AllocationOnlyVmnetNetwork はmacOS 15向けのフォールバックです。actor として実装されており、IPアドレスの割り当ては担いますが、vmnetネットワークインターフェース自体は作成しません。macOS 15のvmnet frameworkは、コンテナ間の通信ができない分離ネットワークしかサポートしていないためです。また、サブネットの指定もできず、NATモードのみに対応しています。

ヒント: ReservedVmnetNetwork に付いている @available(macOS 26, *) の条件分岐が重要なポイントです。macOS 15でネットワークの問題をデバッグしている場合、AllocationOnlyVmnetNetwork とその制約(コンテナ間通信不可、カスタムネットワーク非対応、技術概要に記載されたサブネットのミスマッチの可能性)に向き合うことになります。

AttachmentAllocator によるIPアドレスとMACアドレスの割り当て

AttachmentAllocator は、ネットワークのサブネット内でIPアドレスの割り当てを管理するactorです。割り当て可能な範囲の下限と利用可能なアドレス数(サブネットのCIDRから算出)で初期化されます。

ホスト名とアドレスのマッピングはシンプルなdictionary(hostnames: [String: UInt32])で管理されます。内部のアドレスアロケーターはローテーション戦略を採用しており、常に最小の空きアドレスを再利用するのではなく、アドレスを順番に使い回します。これにより、アドレスが急速に再利用されるときに発生しやすいARPキャッシュの問題を回避できます。

flowchart TD
    A["allocate(hostname: 'web')"] --> B{Hostname exists?}
    B -->|Yes| C[Return existing IP]
    B -->|No| D["UInt32.rotatingAllocator.allocate()"]
    D --> E["Map: 'web' → index"]
    E --> F["Return IPv4Address(index)"]

    G["deallocate(hostname: 'web')"] --> H["Remove from map"]
    H --> I["allocator.release(index)"]

NetworkService.allocate メソッドがこれらをまとめて処理します。コンテナがアタッチされると、IPインデックスを割り当て、MACアドレスを生成または受け入れ、完全な Attachment レコード(IPv4 CIDR、ゲートウェイ、オプションのIPv6、MACを含む)を構築して、XPC経由で返します。MACアドレスは呼び出し元から指定がなければ、ローカル管理ビットを設定したランダム値が生成されます。

冪等性についても重要なポイントがあります。同じホスト名がすでに割り当て済みの場合、アロケーターは新しいIPを割り当てず、既存のIPをそのまま返します。これにより、同じコンテナが複数回ブートストラップされてもアドレスが無駄に消費されるのを防いでいます。

カスタムDNSサーバー: SwiftNIO UDP

apple/container では2つのDNSサーバーが動作しており、どちらも同じ DNSServer 基盤の上に構築されています。このDNSサーバーはコンパクトなSwiftNIOアプリケーションで、DatagramBootstrap でUDPソケットをバインドし、チャネルを NIOAsyncChannel でラップして、for try await ループでパケットを処理します。

2つのサーバーインスタンスは APIServer+Start.swift#L106-L150 で並行して起動されます。

サーバー ポート 用途
Container DNS 2053 コンテナのホスト名をIPアドレスに解決する
Localhost DNS 1053 .localhost ドメインのエイリアスを解決する

どちらのサーバーも同じアーキテクチャを採用しており、CompositeResolver パターンでハンドラチェーンを構築しています。Composite resolverは DNSHandler のリストを順に試し、最初に非nilの回答を返したものを採用します。

flowchart LR
    Q[DNS Query] --> V[StandardQueryValidator]
    V --> CR[CompositeResolver]
    CR --> H1[ContainerDNSHandler]
    H1 -->|nil| H2[NxDomainResolver]
    H1 -->|answer| R[Response]
    H2 --> R2[NXDOMAIN]

StandardQueryValidator は非標準のクエリを弾きます。CompositeResolver は各ハンドラを順番に試します。ContainerDNSHandler でホスト名が解決できれば回答を返し、解決できなければ NxDomainResolver がフォールバックとしてNXDOMAINを返します。

ヒント: DNSサーバーは 127.0.0.1(localhost)にバインドされているため、ネットワーク外からはアクセスできません。コンテナへの設定は /etc/resolv.conf を通じて行われ、コンテナ設定内の DNSConfiguration に基づいてVMブートストラップ時に書き込まれます。

コンテナのホスト名解決とmusl libc の回避策

ContainerDNSHandler は、コンテナ間の名前解決を担うハンドラです。コンテナ「web」がホスト名でコンテナ「db」に接続しようとすると、DNSクエリがこのハンドラに届き、networkService.lookup(hostname:) を呼び出してIPの割り当てを検索します。

このハンドラはAレコード(IPv4)とAAAAレコード(IPv6)の両方に対応しています。IPv4の処理はシンプルで、ホスト名を検索してIPv4アドレスを返すだけです。一方、IPv6の処理には少し工夫があります。

39〜53行目を見てみましょう。

case ResourceRecordType.host6:
    let result = try await answerHost6(question: question)
    if result.record == nil && result.hostnameExists {
        // Return NODATA (noError with empty answers) when hostname exists but has no IPv6.
        // This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
        // musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
        // NODATA correctly indicates "no IPv6 address available, but domain exists".
        return Message(
            id: query.id,
            type: .response,
            returnCode: .noError,
            questions: query.questions,
            answers: []
        )
    }

問題を整理しましょう。コンテナがIPv4アドレスしか持っていない(IPv6なし)場合、通常のDNSサーバーはAAAAクエリに対してNXDOMAINを返します。ほとんどのDNSクライアントはこれを正しく処理し、Aレコードが取得できていればそちらを使います。しかし、Alpine Linuxや多くの軽量コンテナイメージで採用されている musl libc は、AAAAクエリへのNXDOMAINを「このドメイン自体が存在しない」と解釈し、Aレコードのクエリが成功していても名前解決全体を失敗させてしまいます。

対策として、NXDOMAINの代わりにNODATAを返します。returnCode: .noError でありながら回答配列が空のレスポンスです。これによりクライアントには「ドメインは存在するが、IPv6アドレスはない」と伝わるため、musl libc も正しく処理できます。

flowchart TD
    Q["AAAA query for 'db'"] --> L["networkService.lookup('db')"]
    L --> F{Found?}
    F -->|No| N1[Return nil → NXDOMAIN via fallback]
    F -->|Yes| V{Has IPv6?}
    V -->|Yes| R[Return AAAA record]
    V -->|No| ND["Return NODATA<br/>(noError + empty answers)<br/>musl libc workaround"]

これは、多様なコンテナイメージを本番環境で動かして初めて表面化するような、実世界の互換性問題の典型例です。修正箇所は if 文ひとつですが、これによってLinuxディストリビューションの一大カテゴリ全体でのDNS解決の失敗を防いでいます。

次回予告

コンテナがどのようにネットワーク上のアイデンティティを得て、互いを見つけ合うかがわかりました。次回はプラグインシステムに焦点を移します。ランタイムヘルパー、ネットワークヘルパー、CLIエクステンションまでをひとつの共通した仕組みで動かす拡張メカニズム — config.jsonベースのディスカバリーパターンと launchd インテグレーションについて解説します。