Read OSS

网络与 DNS:虚拟网络、IP 分配与域名解析

高级

前置知识

  • 第 1 篇:架构与导航指南
  • 第 2 篇:XPC 通信层
  • 第 3 篇:容器生命周期

网络与 DNS:虚拟网络、IP 分配与域名解析

容器在 apple/container 中启动时,需要一个 IP 地址、一个网关,以及解析主机名的能力——既要能访问外部网络,也要能与同一台机器上的其他容器通信。传统容器运行时通过共享 VM 内部的 Linux 内核命名空间来实现网络隔离,而这里每个容器都是独立的 VM。这意味着虚拟网络必须在 macOS 宿主机层面进行配置,依托 Apple 的 vmnet.framework 来实现。

本文将完整梳理整个网络栈:网络是如何创建和管理的,IP 地址是如何分配的,以及基于 SwiftNIO 构建的两个自定义 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)

将容器挂载到网络的流程分三步:API 服务器请求网络服务分配 IP 地址,收到包含已分配 IP/MAC/网关的 Attachment,然后在 bootstrap 阶段将这些信息传递给运行时辅助进程。正如第 3 篇所介绍的,SandboxService.bootstrap() 方法通过 XPC 接收这些已分配的 attachment,并据此配置 VM 的网络接口。

网络协议与 macOS 版本分支

Network 协议非常精简,只有三个要求:

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

该协议有两个实现,在运行时根据 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,提供完整的网络接口隔离——同一网络上的容器可以互相通信,每个容器都获得一个预留接口。该类使用 Mutex<State> 进行线程安全的状态管理(它是 final class 而非 actor,因为 vmnet 的回调来自任意 dispatch queue)。

AllocationOnlyVmnetNetwork 是 macOS 15 的降级方案。它是一个 actor,负责 IP 分配,但本身不创建 vmnet 网络接口——macOS 15 上的 vmnet framework 只支持隔离网络,容器之间无法直接通信,也不支持自定义子网,仅支持 NAT 模式。

提示: ReservedVmnetNetwork 上的 @available(macOS 26, *) 守卫是关键的条件分支。如果你在 macOS 15 上排查网络问题,实际运行的是 AllocationOnlyVmnetNetwork,需要注意其限制:容器间无法直接通信、不支持自定义网络,以及技术概览文档中记录的潜在子网不匹配问题。

使用 AttachmentAllocator 分配 IP 和 MAC 地址

AttachmentAllocator 是一个 actor,负责管理网络子网内的 IP 地址分配。它初始化时需要可分配范围的下界以及可用地址数量(由子网的 CIDR 推导得出)。

分配器使用一个简单的字典(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,而不是重新分配。这可以防止同一容器多次 bootstrap 时出现地址泄漏。

自定义 DNS 服务器:SwiftNIO UDP

apple/container 运行两个 DNS 服务器,均基于相同的 DNSServer 基础设施构建。这个 DNS 服务器是一个相当紧凑的 SwiftNIO 应用——它使用 DatagramBootstrap 绑定 UDP socket,将 channel 包装为 NIOAsyncChannel,并在 for try await 循环中处理数据包。

这两个服务器实例在 APIServer+Start.swift#L106-L150 中并发启动:

服务器 端口 用途
Container DNS 2053 将容器主机名解析为 IP 地址
Localhost DNS 1053 解析 .localhost 域名别名

两个服务器采用相同的架构:基于 CompositeResolver 模式构建的处理器链。组合解析器依次遍历 DNSHandler 实现列表,返回第一个非空结果:

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(仅本地),因此无法从网络外部访问。容器通过 /etc/resolv.conf 配置使用这些服务器,该文件在 VM bootstrap 阶段根据容器配置中的 DNSConfiguration 生成。

容器主机名解析与 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 记录,直接使用即可。但 musl libc(被 Alpine Linux 及众多精简容器镜像所采用)会将 AAAA 查询收到的 NXDOMAIN 解读为"该域名根本不存在",从而导致整个域名解析失败,即便 A 记录查询已经成功。

解决方案是返回 NODATA——即 returnCode: .noError 但 answers 数组为空的响应。这告诉客户端"该域名存在,但没有可用的 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 集成协同工作。