构建系统:gRPC、BuildKit 与镜像创建
前置知识
- ›第 1 篇:架构与导航指南
- ›第 3 篇:容器从 run 到退出的完整生命周期
- ›具备 gRPC 与 Protocol Buffers 的基本知识
构建系统:gRPC、BuildKit 与镜像创建
本系列此前的所有文章,都围绕着 XPC 展开——这是 macOS 原生的 IPC 机制,将 CLI、API server 与各个 helper daemon 串联在一起。而构建系统则彻底打破了这一模式。当你执行 container build 时,CLI 会通过 vsock socket 上的 gRPC,与运行在 Linux 容器 VM 内部的 BuildKit 进程直接通信。没有 XPC,没有 Mach services,没有 launchd——只有一条从 macOS 宿主机直通 guest VM 的原始 socket 连接。
这并非随意为之,而是一 VM 一容器架构带来的必然结果:BuildKit 是一个 Linux 进程,而 Linux 进程无法参与 macOS 的 XPC 通信。gRPC over vsock 因此成为连接两个世界的天然桥梁。理解了这套系统,也就完整地看清了 apple/container 的通信模式如何因地制宜地适应各种约束。
构建为何选择 gRPC 而非 XPC
构建系统的架构,源于一个简单的约束:BuildKit 运行在 Linux VM 内部,而 XPC 只能在 macOS 进程之间使用。
执行 container build 时,系统会启动(或复用已有的)一个专用的"builder"容器,其容器 ID 为 buildkit。这个容器内运行着一个 BuildKit daemon,负责监听连接。macOS 宿主机通过 vsock socket 与之通信——vsock 是一种虚拟 socket,无需经过网络协议栈,即可实现宿主机与 guest 之间的直接通信。
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 server 请求拨通 builder 容器上的一个 vsock 端口(复用的是其他 vsock 操作所用的 containerDial XPC 路由),取回已连接 socket 的文件描述符,再基于该文件描述符建立 gRPC channel。
Builder 结构体:vsock gRPC Channel
Builder 是构建系统的核心类型。其初始化方法接受一个 FileHandle(vsock socket)和一个 EventLoopGroup,然后在已连接的 socket 上配置 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 调用显式设置 socket 缓冲区——发送缓冲区 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 是否就绪,然后启动构建。如果 builder 容器尚未运行,命令会自动调用 BuilderStart.start() 将其启动,并等待就绪。
将构建配置编码为 HPACK Metadata Headers
这是整个代码库中最非常规的设计之一。构建参数并非编码在 gRPC 请求体中,而是作为 HTTP/2 HPACK metadata headers 传递:
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 编码后放入 header。tag、平台、构建参数、label 和 secret 也都通过 header 传递,对于多值情况,则多次使用同一个 header key(HPACK 支持这种方式)。build secret 的处理尤为值得注意:它们以 id=base64data 的格式 base64 编码后,放入 secrets header。
为何选择 header 而非消息体?这一设计与 Linux 侧 BuildKit shim 的工作方式直接对应。容器内的 shim 进程在 gRPC 调用建立阶段、流式传输开始之前,就能读取这些 header,并据此配置 BuildKit 会话。随后,双向流才负责实际的构建 I/O 数据传输。这种将配置(header)与数据(stream)分离的方式非常清晰——配置在流式传输开始前已同步可用。
双向流:进度输出与终端大小调整
构建操作通过 performBuild 使用 gRPC 双向流。客户端发送 ClientStream 消息,同时并发接收 ServerStream 消息。
BuildPipeline 负责管理服务端流。它维护着一条由 BuildPipelineHandler 实现链构成的 pipeline,每个 handler 负责处理不同类型的服务端消息:
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 行定义了 pipeline 中的各个 handler:
| Handler | 职责 |
|---|---|
BuildFSSync |
将构建上下文文件从宿主机同步到 BuildKit |
BuildRemoteContentProxy |
代理宿主机内容仓库中的 OCI 内容 |
BuildImageResolver |
在构建过程中解析基础镜像 |
BuildStdio |
将构建进度输出渲染到终端 |
每个 handler 实现 accept(_ packet:) -> Bool 来判断自身是否能处理某个 packet,并通过 handle(_ sender:, _ packet:) 执行具体处理逻辑。pipeline 按顺序遍历各个 handler,在第一个接受该 packet 的 handler 处停止。
在客户端到服务端方向,第 80–131 行的 build 方法负责发送终端大小调整事件。一个 SIGWINCH 信号处理器监听终端尺寸变化,并发送包含序列化 TerminalCommand 结构体(含新尺寸信息)的 ClientStream 消息。
提示:
BuildPipeline使用了自定义的untilFirstError并发原语,而非标准的withThrowingTaskGroup。标准 task group 在主循环仍在迭代 stream 时,无法在单个任务失败后立即退出。第 98–167 行的untilFirstError实现通过将 stream 消费与错误监控作为并发任务运行,优雅地解决了这一问题。
构建导出:从 BuildKit 输出到本地内容仓库
构建完成后,BuildKit 会将输出(OCI 镜像层)写入宿主机可访问的位置。BuildExport 类型定义了支持的输出格式:
oci— 导出为 OCI 镜像归档,再通过ClientImage.load加载到本地内容仓库tar— 导出为 tar 归档文件到指定目标路径local— 将构建输出导出到本地目录
BuildCommand.swift#L390-L442中 CLI 的导出处理逻辑,清晰展示了 OCI 导出的构建后流程:输出的 tar 包被加载到 images service,针对目标平台完成解包,并以所有请求的镜像名称打上 tag。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 services 和 audit token 验证的 XPC。对于 macOS 宿主机与 Linux VM guest 之间:使用 gRPC over vsock,因为 guest 无法参与 macOS 的 IPC 原语。项目并未强行将所有通信抽象为同一种机制,而是针对每个边界的实际约束灵活适配。
构建系统还体现了一 VM 一容器模型如何自然地延伸至开发工作流。BuildKit 拥有独立的 VM,与正在运行的容器相互隔离。即使构建出现问题,也不会影响到正在运行的服务。如果不同项目需要不同的 BuildKit 配置,甚至可以并行运行多个 builder 容器。
至此,我们对 apple/container 的深入探索告一段落。在这六篇文章中,我们从顶层进程模型一路追溯到单条 XPC 消息,从 CLI 参数解析,经过 VM 启动,直到 DNS 解析。代码库在模式上保持着高度的一致性——Service/Harness 的分层、client/server target 配对、类型化的 route enum——一旦理解了这些约定,整个代码库就变得清晰可导航。而唯一刻意打破惯例的部分——基于 gRPC 的构建系统——之所以这样做,有着充分且合理的理由。