网络与 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 集成协同工作。