Read OSS

容器生命周期:从 `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,打包进路由为 .containerCreateXPCMessage,发送至 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 请求后,它会依次执行以下步骤:

  1. 从 XPC 消息中反序列化 ContainerConfiguration
  2. 创建 ContainerSnapshot——持久化状态记录
  3. 通过 FilesystemEntityStore 将其写入磁盘
  4. 使用 pluginLoader 查找对应的运行时 plugin
  5. 调用 pluginLoader.registerWithLaunchd() 将运行时 plugin 注册到 launchd

持久化层由 FilesystemEntityStore 实现——这是一个将 JSON 文件写入磁盘并维护内存索引的 actor。每个容器都拥有独立的目录 <appRoot>/containers/<id>/,其中包含序列化快照的 entity.json 文件。

ContainersService 在内存中维护一个 ContainerState 结构体字典,每项记录包含快照数据、已连接的 SandboxClient,以及已分配的网络连接信息。

调用 bootstrap 时,ContainersService 会执行第二篇文章中描述的端点握手流程:连接运行时的公共 Mach 服务,获取匿名端点,并建立直连通道。

SandboxClient 端点握手

SandboxClient.create() 静态方法实现了完整的双服务握手流程:

  1. 构造 Mach 服务标签:com.apple.container.runtime.container-runtime-linux.{uuid}
  2. 创建连接到该服务的 XPCClient
  3. 发送 createEndpoint 请求
  4. 从响应中提取 xpc_endpoint_t
  5. 调用 xpc_connection_create_from_endpoint 获取直连连接
  6. 返回基于该直连的 SandboxClient

此后,所有与运行时的通信都完全绕过公共 Mach 服务。bootstrapcreateProcessstartwait 等操作全部通过匿名连接进行。

SandboxService:VM 创建与 Linux 启动

在运行时 helper 内部,SandboxService 是负责管理 VM 生命周期的 actor。其 bootstrap 方法位于第 126–179 行,Linux VM 正是在这里真正启动的。

引导流程如下:

  1. 若容器 bundle 不存在,则在磁盘上创建
  2. 从 bundle 加载容器配置和内核
  3. 配置内核启动参数(包括安全模块:lsm=lockdown,capability,landlock,yama,apparmor
  4. 通过 containerization 库创建 VZVirtualMachineManager
  5. 从 XPC 消息中提取已分配的网络连接
  6. 若未显式配置,则动态设置 DNS 名称服务器
  7. 根据 macOS 版本选择网络接口策略
  8. 启动 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 负责,注册了 SIGTERMSIGINTSIGUSR1SIGUSR2SIGWINCH 的处理器。在 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 兼容性处理。