容器生命周期:从 `container run` 到进程退出
前置知识
- ›第一篇:架构与导航指南
- ›第二篇:XPC 通信层
容器生命周期:从 container run 到进程退出
前两篇文章分别梳理了整体架构,并深入剖析了 XPC 通信层。现在,让我们来观察这些组件如何协同运作。本文将完整追踪一条 container run 命令的执行路径——从你在终端敲下回车,到容器进程退出、命令提示符重新出现的全过程。
四层架构、Service/Harness 模式、双服务端点握手,以及文件描述符传递,所有这些机制在这里汇聚成一套完整、协调的执行流程。
CLI 解析与 ContainerConfiguration
一切从 ContainerRun.swift 开始。该命令使用 Swift Argument Parser 的 @OptionGroup 模式,将各类标志按逻辑分组:进程选项(tty、interactive)、资源选项(CPU、内存、存储)、管理选项(name、detach、auto-remove)以及镜像仓库选项。
flowchart TD
A["container run --name web -p 8080:80 nginx"] --> B[Parse Flags]
B --> C[Generate Container ID]
C --> D[Check for Existing Container]
D --> E["Utility.containerConfigFromFlags()"]
E --> F[ContainerConfiguration]
F --> G[ContainerClient.create]
G --> H[ContainerClient.bootstrap]
各标志组由 Utility.containerConfigFromFlags() 汇总,生成一个 ContainerConfiguration——这是描述容器全部信息的核心数据类型。该结构体实现了 Codable 协议,以 JSON 格式嵌入 XPC 消息,跨进程边界传递。
配置结构完整记录了容器的规格参数:
| 字段 | 用途 |
|---|---|
id |
容器唯一标识符 |
image |
OCI 镜像引用 |
mounts |
宿主机到容器的文件系统挂载 |
publishedPorts |
端口映射(宿主机:容器) |
networks |
网络连接配置 |
resources |
CPU 核心数、内存(默认 1 GiB)、存储配额 |
rosetta |
启用 x86-64 转译 |
ssh |
转发 SSH agent socket |
readOnly |
以只读方式挂载根文件系统 |
runtimeHandler |
指定使用的运行时 plugin(默认:container-runtime-linux) |
initProcess |
容器内运行的初始进程 |
提示:
runtimeHandler字段默认为"container-runtime-linux",但可以自定义配置——这正是 plugin 系统支持替换运行时的实现方式。
ContainerClient:通过 XPC 创建与引导容器
配置构建完成后,CLI 通过 ContainerClient 发起两次 XPC 调用:create() 和 bootstrap()。
第 48–76 行的 create() 调用将 ContainerConfiguration、内核信息和创建选项序列化为 JSON,打包进路由为 .containerCreate 的 XPCMessage,发送至 API server。
第 116–146 行的 bootstrap() 调用则更为关键:它将 stdio 文件句柄(stdin、stdout、stderr 管道)直接打包进 XPC 消息。这些文件描述符将从 CLI 进程出发,经由 API server,最终抵达容器运行时——借助 XPC 的内核级 fd 传递机制,穿越两道进程边界。
sequenceDiagram
participant CLI as container CLI
participant API as container-apiserver
participant LD as launchd
participant RT as container-runtime-linux
CLI->>API: containerCreate(config, kernel)
API->>API: Persist ContainerSnapshot
API->>API: Find runtime plugin
API->>LD: bootstrap plist for runtime
LD->>RT: Launch process
API-->>CLI: OK
CLI->>API: containerBootstrap(id, stdio fds)
API->>RT: createEndpoint (public Mach service)
RT-->>API: XPC endpoint
API->>RT: bootstrap(stdio fds, attachments)
RT->>RT: Boot Linux VM
RT-->>API: OK
API-->>CLI: OK + ClientProcess handle
ContainersService:Plugin 注册与沙箱配置
在服务端,ContainersService 是负责管理所有容器状态的 actor。收到 create 请求后,它会依次执行以下步骤:
- 从 XPC 消息中反序列化
ContainerConfiguration - 创建
ContainerSnapshot——持久化状态记录 - 通过
FilesystemEntityStore将其写入磁盘 - 使用
pluginLoader查找对应的运行时 plugin - 调用
pluginLoader.registerWithLaunchd()将运行时 plugin 注册到 launchd
持久化层由 FilesystemEntityStore 实现——这是一个将 JSON 文件写入磁盘并维护内存索引的 actor。每个容器都拥有独立的目录 <appRoot>/containers/<id>/,其中包含序列化快照的 entity.json 文件。
ContainersService 在内存中维护一个 ContainerState 结构体字典,每项记录包含快照数据、已连接的 SandboxClient,以及已分配的网络连接信息。
调用 bootstrap 时,ContainersService 会执行第二篇文章中描述的端点握手流程:连接运行时的公共 Mach 服务,获取匿名端点,并建立直连通道。
SandboxClient 端点握手
SandboxClient.create() 静态方法实现了完整的双服务握手流程:
- 构造 Mach 服务标签:
com.apple.container.runtime.container-runtime-linux.{uuid} - 创建连接到该服务的
XPCClient - 发送
createEndpoint请求 - 从响应中提取
xpc_endpoint_t - 调用
xpc_connection_create_from_endpoint获取直连连接 - 返回基于该直连的
SandboxClient
此后,所有与运行时的通信都完全绕过公共 Mach 服务。bootstrap、createProcess、start、wait 等操作全部通过匿名连接进行。
SandboxService:VM 创建与 Linux 启动
在运行时 helper 内部,SandboxService 是负责管理 VM 生命周期的 actor。其 bootstrap 方法位于第 126–179 行,Linux VM 正是在这里真正启动的。
引导流程如下:
- 若容器 bundle 不存在,则在磁盘上创建
- 从 bundle 加载容器配置和内核
- 配置内核启动参数(包括安全模块:
lsm=lockdown,capability,landlock,yama,apparmor) - 通过
containerization库创建VZVirtualMachineManager - 从 XPC 消息中提取已分配的网络连接
- 若未显式配置,则动态设置 DNS 名称服务器
- 根据 macOS 版本选择网络接口策略
- 启动 VM
flowchart TD
A[bootstrap message] --> B[Load config from bundle]
B --> C[Configure kernel args]
C --> D[Create VZVirtualMachineManager]
D --> E[Extract network attachments]
E --> F{macOS version?}
F -->|"macOS 26+"| G[NonisolatedInterfaceStrategy]
F -->|"macOS 15"| H[IsolatedInterfaceStrategy]
G --> I[Attach interfaces to VM]
H --> I
I --> J[Boot Linux VM]
J --> K[Start guest agent]
K --> L[Create init process]
RuntimeLinuxHelper+Start.swift#L67-L72 中的网络接口策略选择逻辑使用 #available(macOS 26, *) 进行版本判断:macOS 26 及以上使用 NonisolatedInterfaceStrategy(支持完整的容器间网络通信),macOS 15 则使用 IsolatedInterfaceStrategy(容器之间网络相互隔离)。
ProcessIO:标准 I/O、信号与终端尺寸调整
回到 CLI 侧,ProcessIO 负责管理用户终端与容器 stdio 流之间的连接。其 create 方法位于第 46–84 行,根据不同模式建立不同的 I/O 管道:
交互式 TTY 模式(--tty --interactive):通过 Terminal.setraw() 将终端切换到原始模式,使用带 readabilityHandler 回调的非阻塞 I/O 读取 stdin。容器的 stdout 直接输出到用户的 stdout,stderr 则合并到 stdout(这是 TTY 模式的标准行为)。
非 TTY 模式:stdout 和 stderr 分别拥有独立的管道和 readabilityHandler 回调。IoTracker 协调流的完成状态——它通过 AsyncStream<Void> 来感知每个输出流何时结束(接收到空数据即表示 EOF)。
后台模式(--detach):不创建任何输出管道,直接打印容器 ID 后立即退出。
flowchart LR
subgraph CLI Process
STDIN["Host stdin"]
STDOUT["Host stdout"]
STDERR["Host stderr"]
end
subgraph "Pipes (via XPC)"
P1["stdin pipe"]
P2["stdout pipe"]
P3["stderr pipe"]
end
subgraph Runtime Process
VM["Container VM"]
end
STDIN -->|readabilityHandler| P1
P1 -->|fd passed via XPC| VM
VM -->|fd passed via XPC| P2
P2 -->|readabilityHandler| STDOUT
VM -->|fd passed via XPC| P3
P3 -->|readabilityHandler| STDERR
信号处理统一由 ProcessIO 负责,注册了 SIGTERM、SIGINT、SIGUSR1、SIGUSR2 和 SIGWINCH 的处理器。在 TTY 会话中,SIGWINCH(终端尺寸变化)会触发一条通过 XPC 发往容器的 resize 命令。在非 TTY 会话中,SignalThreshold 计数器允许用户在连续发送三次 SIGINT/SIGTERM 后强制退出。
提示: 使用
OSFile.makeNonBlocking()将 stdin 设为非阻塞模式至关重要。如果 stdin 以阻塞方式读取,进程将无法响应信号,也无法感知容器何时已退出。
容器状态与退出
容器经历一个简洁的状态机:
stateDiagram-v2
[*] --> created: create()
created --> running: bootstrap()
running --> stopped: exit / stop / kill
stopped --> [*]: delete()
created --> [*]: delete()
ContainersService 通过持久化到磁盘的 ContainerSnapshot 跟踪这些状态。CLI 调用 bootstrap 时,服务将快照更新为 running;容器的 init 进程退出后,运行时通过退出监控机制发出通知,状态随之变为 stopped。
退出流程值得关注。bootstrap 返回后,CLI 的 ContainerRun.run() 方法在第 165 行调用 io.handleProcess(process:log:),启动容器的 init 进程并等待其退出。等待通过 containerWait XPC 路由实现,该调用会阻塞直到运行时返回退出码。
容器进程的退出码会一路传回 CLI,并在第 173 行以 ArgumentParser.ExitCode 的形式抛出。容器以 0 退出,CLI 就以 0 退出;容器以 1 退出,CLI 就以 1 退出——干净、透明。
若设置了 --remove 标志,容器在退出后会被自动删除。如果运行过程中发生错误,CLI 会在第 167 行的 catch 块中调用 client.delete(id:) 尝试清理资源。
下一篇
至此,我们已经完整追踪了一个容器的生命周期——但有一个关键子系统还没有深入探讨:网络。容器是如何获取 IP 地址的?容器之间如何通过主机名互相解析?为什么项目内置了自己的 DNS server?下一篇文章将深入剖析网络栈——从虚拟网络的创建、IP 地址的分配,到 DNS handler 中一个颇具针对性的 musl libc 兼容性处理。