OCI运行时:容器创建流程
封面图来自维基共享资源,原作者为Wilfredor,使用CC0 1.0 通用公有领域贡献许可。
本文翻译自Antoine Cotten的《OCI runtime: container creation flow》,原文采用CC BY 4.0许可协议发布,本文亦采用该许可协议发布。
最近,我一直在钻研OCI运行时规范,以及该规范采用Go语言编写的参考实现——runc。早在Kubernetes问世之初,我便开始接触容器技术。然而,在开放容器计划(Open Container Initiative,OCI)持续推进的岁月里,Linux容器于底层运行时架构所经历的诸多标准化演进,却大多处于我的视线盲区。
我认为,这种「无感」恰恰证明了标准化处理得十分精妙,未对依赖容器技术的平台造成剧烈冲击。不过,此刻也确实是我填补这块知识盲区的绝佳契机。
如果你还不熟悉容器管理器与容器运行时之间的职责分工,我强烈建议你阅读Ivan Velichko的《Journey From Containerization To Orchestration And Beyond》。这篇文章清晰直观,附带了许多高质量的延伸资源,是探索这个无底洞的绝佳切入点。
OCI运行时的操作
若要符合OCI运行时规范,容器运行时则必须实现以下五个顾名思义的操作:
- 创建(Create)
- 启动(Start)
- 查询状态(State)
- 终止(Kill)
- 删除(Delete)
这些操作均属抽象范畴,规范本身并未针对CLI运行时(如runc)强制规定具体的命令行API。尽管业界正积极探索如何界定命令行界面的合规标准,但这种极度的抽象性起初确实令我倍感困惑。
下面的示意图展现了容器的生命周期,包括先前提到的五个操作:
%%{init: { 'sequence': { 'mirrorActors': false } } }%%
sequenceDiagram
participant runc
participant OS
runc->>+OS: 创建(Create)
OS-->>-runc: 进程 ID
runc->>OS: 保存状态(state.json)
Note right of OS: 已创建(created)
runc->>+OS: 查询状态(State)
OS-->>-runc: 容器状态
runc->>+OS: 启动(Start)
OS-->>-runc: 进程状态
Note right of OS: 运行中(running)
runc->>OS: 终止(Kill)
Note right of OS: 已停止(stopped)
runc->>OS: 删除(Delete)
Note right of OS: [已销毁]
在本文接下来的部分中,我将专注于创建(Create)和启动(Start)这两个阶段的具体细节。
分步解析
正如上图所示,通过OCI运行时运行容器化进程分为两个步骤:创建(Create)、启动(Start)。对于习惯驾驭Docker等高层容器运行时的开发者而言,很容易将这些OCI操作与docker container [create|start]等命令混为一谈。这具有一定误导性,因为两者尽管语义相似,但并不等同。要理解其中的原因,我们必须观察当这两个操作在Linux宿主机上执行时,操作系统进程究竟发生了什么。
下面所有的实验都是针对我称为mybundle的OCI bundle进行的。它是从Docker Hub镜像docker.io/library/nginx生成的,其rootfs可以使用crane等OCI镜像处理工具导出。该bundle的目录结构如下所示,其中config.json是容器的OCI运行时配置文件,由runc spec或容器运行器/引擎生成。
1 | mybundle |
runc需要根目录,还需与容器ID对应的子目录(mycontainer)用于存储各操作间的状态。此类目录树通常交由containerd等容器管理器代为维护,但本文的实验已剥离了这层上级管控。
1 | mkdir -p /run/runc/mycontainer |
创建(Create)
在我们的bundle上执行runc create命令:
1 | runc --root /run/runc \ |
查询其状态:
1 | runc --root /run/runc state mycontainer |
值得玩味的一个细节是,尽管目前状态为created而非running,但容器状态中竟然已经包含了进程ID(19374)。这正是它与docker container create及其同类命令的首个主要区别——后者仅从指定镜像创建容器,而不会启动它。
现在让我们检查正在运行的进程。在基于Ubuntu的WSL实例中,进程树如下所示:
1 | ps axjf |
上面的输出揭示了第二个有趣但重要的细节:进程ID对应的是runc init,与我们的nginx bundle毫无关联。
启动(Start)
当我们执行runc start时,来看看这个过程会如何进行:
1 | runc --root /run/runc start mycontainer |
刹那之间,我们可以瞥见容器的入口点(entrypoint)命令正沿用相同的进程ID启动执行,但旋即消失无踪:
1 | ps axjf |
1 | ps axjf |
再次查询其状态:
1 | runc --root /run/runc state mycontainer |
容器正处于stopped状态,并且不再返回进程ID。
运行(Run)
在进一步深入探究之前,让我们试着解释一下刚刚观察到的行为。为此,我们将使用runc的高层命令之一:runc run。该命令并不直接映射到此前列举的任何一种OCI运行时操作,但可以粗略地将其描述为create与start的结合,尽管存在一些细微差别。
首先,我们必须删除已停止的容器:
1 | runc --root /run/runc delete mycontainer |
然后就像先前调用runc create那样,执行runc run:
1 | runc --root /run/runc \ |
这次,命令没有立即退出。相反,容器的init进程(nginx)的标准输出被打印到我们终端的标准输出:
1 | /docker-entrypoint.sh: Configuration complete; ready for start up |
让我们在另一个独立的终端窗口中检查容器的状态以及进程树:
1 | runc --root /run/runc state mycontainer |
runc state报告的进程ID现在显示为running状态:
1 | ps axjf |
相应的进程在进程树中清晰可见,根据容器配置,其运行命令正对应nginx主进程。
你可能已经猜到了,我们在本实验中观察到的差异与标准I/O流(stdin、stdout、stderr)有关:
- 使用
runc run时,runc仍然是容器init进程(nginx)的父进程。由于runc是从shell中fork出来的,它继承了shell的I/O流(前台模式)。 - 使用
runc start时,容器的init进程已沦为孤儿进程,并被重新挂载至层级最近的宿主机init进程之下。随后,由于该进程向已关闭的标准输出(stdout)写入失败导致退出,其立刻被新接管的父进程回收。
倘若在整体架构中引入诸如containerd这类的容器管理器,系统便会在管理器与核心容器进程之间垫入一个「容器运行时shim(垫片)」进程,从而规避上述I/O断链的问题。同样,Ivan在《Journey From Containerization To Orchestration And Beyond》中很好地阐述了运行时shim的作用,因此我不打算在此进一步展开讨论。
此外,解释该进程在分离模式下的退出原因并非我在此关注的重点。言归正传,让我们看看在创建OCI容器的这两个相独立的阶段(创建与启动)中,究竟发生了什么。
多个初始化阶段
读者不禁会产生疑虑:既然在技术维度上容器完全能够一步到位启动,那么这种分两阶段的启动流程有何意义?OCI运行时规范并未阐述如此设计的初衷,但我相信,通过与UNIX创建进程的一些API(fork()和exec())相类比,可以很好地回答这个问题。
插曲:UNIX进程
在UNIX系统中,运行与当前调用程序不同的程序需要两次系统调用:
fork():创建一个与调用(父)进程(几乎)完全相同的副本exec():在不创建新进程的情况下,将当前运行的程序替换为另一个程序
这允许父进程在fork()和exec()之间执行一些代码。这种机制对于像UNIX shell这样的程序至关重要,因为它需要支持I/O流重定向和其他进程操作等功能。
例如,当执行shell命令echo 'hi' >out.txt或echo 'hi' | wc时,shell会在底层执行以下操作:
- 使用
fork()创建一个子进程 - 关闭该子进程的标准输出,并获取应重定向到的目标文件描述符(在上述示例中分别为文件和管道)
- 将此文件描述符分配给该进程的标准输出
- 通过调用
exec()运行echo命令
这一概念极具威力,Remzi和Andrea Arpaci-Dusseau所著的(免费)著作《Operating Systems: Three Easy Pieces》中,第五章「Process API」对此做了简洁精辟的解释。
OCI运行时初始化
澄清了这些基本概念后,我们现在了解了足够的背景知识,以探究在OCI容器生命周期的创建(Create)与启动(Start)阶段之间,究竟发生了什么。
在本文的第一部分中,我们看到runc create最终产生了一个新进程,其并不是预期的容器应用程序,而是runc本身。深挖libcontainer底层的源码脉络便不难发现,在这个被称为「bootstrap」(引导)的阶段中,系统会调用/proc/self/exe init命令(实质上是重新拉起runc二进制文件本身)来孵化出一个父进程。随后,该进程不仅会提前继承未来容器init进程(PID 1,即本文示例中的nginx)所需的Linux cgroup及命名空间上下文,还会从其源头父进程处接收待拉起容器的OCI运行时配置。此后,它将一直保持该状态,等待启动(Start)阶段的到来[1]。
从概念上讲,这与fork() + exec()语境下的fork()UNIX系统调用非常相似。
graph LR
subgraph host_ns ["宿主机命名空间 (host ns)"]
A[runc create]
end
subgraph container_cgroups ["容器cgroup"]
subgraph container_ns ["容器命名空间 (container ns)"]
B[runc init]
end
end
A -- "fork, nsenter" --> B
在创建(Create)阶段结束时,runc会将状态写入到state.json文件中,其存放于由--root命令行参数指定的目录下。该文件包含了诸如引导进程的进程ID等信息。随后所有的runc命令都会读取此文件,将其作为唯一依据,并根据其init进程的状态来确定容器的当前状态。
1 | /run/runc/mycontainer |
我之前一直故意没提,同一目录下还创建了名为exec.fifo的命名管道(FIFO)。
1 | /run/runc/mycontainer |
正是这个命名管道,宿主机方能向引导进程传达启动容器的意图,并开启下一阶段:启动(Start)。该命名管道的写入端绑定至引导进程,而读取端则暂处于封闭状态。此时,引导进程会因尝试向exec.fifo执行写入调用而陷入阻塞态;直到外部执行runc start并从中读取数据,就此扣下准许放行的「发令枪」后,阻塞方才解除。
在此阶段,调用者(例如containerd之类的容器管理器)在正式启动容器前,可以根据需要,执行任何其认为合适的额外步骤。在实际应用中,这通常意味着通过调用一系列CNI插件来配置容器的网络接口[2]。待此类操作完成后,调用者便可发起启动(Start)阶段。
而在容器启动的第二阶段(亦即最终收尾阶段),底层实际发生的动作核心,不过是一次exec()系统调用,其正由前文所述的exec.fifo管道读取事件所触发。由于引导进程在创建(Create)阶段就已经从父级runc进程接收了容器的运行时配置,因此无需再执行任何额外步骤。
%%{init: { 'sequence': { 'mirrorActors': false } } }%%
sequenceDiagram
autonumber
box 进程
participant RS as runc start
participant RI as runc init
participant NX as nginx
end
box 文件
participant FF as fifo
end
RI->>FF: 写入
activate FF
Note right of FF: 阻塞
FF-->>RS: 读取
deactivate FF
RI->>NX: exec
RS->>FF: 删除
最后,exec.fifo命名管道被删除。
附录:引导进程全貌
initProcess结构体的完整内容(为清晰起见,省略了一些无关属性)展示如下,以供参考:
1 | libcontainer.parentProcess(*libcontainer.initProcess) *{ |
暂且略去那些繁复的深层嵌套细节,有几个关键字段值得我们重点聚焦:
cmd描述了引导(bootstrap)命令:runc init。- 用于向引导进程传达启动(Start)阶段开始信号的命名管道
exec.fifo,其文件描述符在环境变量_LIBCONTAINER_FIFOFD中被引用。它引用了地址为(*os.File)(0xc000014be8)的cmd.ExtraFiles项,顺带一提,该项也同样被container.fifo引用。
- 用于向引导进程传达启动(Start)阶段开始信号的命名管道
- OCI运行时配置(
config.json)的反序列化版本在config字段中可见:包含容器cmd、环境变量、工作目录等。- 实际上,该配置的很大一部分隐藏在
config.Config与config.Capabilities字段之后,但在此处展开它们会给示例引入大量干扰,且这两者在OCI运行时规范中均有详细说明。 - 一旦引导进程启动,完整的容器配置便会通过一对UNIX域套接字
messageSockPair,以JSON格式传递给引导进程。此数据至关重要,引导进程因此才得以在启动(Start)阶段利用预期的环境对容器命令调用exec()。
- 实际上,该配置的很大一部分隐藏在
container和process两个字段,与config字段存在部分重叠。它们分别代表了容器的内部表示(由runc/libcontainer所见),以及专门针对容器init进程(即nginx)的表示。- 分配给引导进程(进而分配给容器init进程)的cgroup在
manager字段中可见:如cpu、memory、blkio等。
