封面图来自维基共享资源,原作者为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。尽管业界正积极探索如何界定命令行界面的合规标准,但这种极度的抽象性起初确实令我倍感困惑。

下面的示意图展现了容器的生命周期,包括先前提到的五个操作:

在本文接下来的部分中,我将专注于创建(Create)和启动(Start)这两个阶段的具体细节。

分步解析

正如上图所示,通过OCI运行时运行容器化进程分为两个步骤:创建(Create)、启动(Start)。对于习惯驾驭Docker等高层容器运行时的开发者而言,很容易将这些OCI操作与docker container [create|start]等命令混为一谈。这具有一定误导性,因为两者尽管语义相似,但并不等同。要理解其中的原因,我们必须观察当这两个操作在Linux宿主机上执行时,操作系统进程究竟发生了什么。

下面所有的实验都是针对我称为mybundleOCI bundle进行的。它是从Docker Hub镜像docker.io/library/nginx生成的,其rootfs可以使用crane等OCI镜像处理工具导出。该bundle的目录结构如下所示,其中config.json是容器的OCI运行时配置文件,由runc spec或容器运行器/引擎生成。

1
2
3
4
5
6
7
8
9
10
11
12
mybundle
├── config.json
└── rootfs
├── bin
├── dev
├── docker-entrypoint.sh
├── etc
├── home
├── lib
├── ...
├── usr
└── var

runc需要根目录,还需与容器ID对应的子目录(mycontainer)用于存储各操作间的状态。此类目录树通常交由containerd等容器管理器代为维护,但本文的实验已剥离了这层上级管控。

1
mkdir -p /run/runc/mycontainer

创建(Create)

在我们的bundle上执行runc create命令:

1
2
3
4
5
runc --root /run/runc \
create \
--bundle ~acotten/mybundle \
--pid-file ~acotten/init.pid \
mycontainer

查询其状态:

1
2
3
4
5
6
7
8
9
10
$ runc --root /run/runc state mycontainer
{
"ociVersion": "1.1.0",
"id": "mycontainer",
"pid": 19374,
"status": "created",
"bundle": "/home/acotten/mybundle",
"rootfs": "/home/acotten/mybundle/rootfs",
"created": "2023-08-17T19:10:49.347132023Z"
}

值得玩味的一个细节是,尽管目前状态为created而非running,但容器状态中竟然已经包含了进程ID19374)。这正是它与docker container create及其同类命令的首个主要区别——后者仅从指定镜像创建容器,而不会启动它。

现在让我们检查正在运行的进程。在基于Ubuntu的WSL实例中,进程树如下所示:

1
2
3
4
5
6
$ ps axjf
PPID PID PGID SID ... COMMAND
0 1 0 0 ... /init
1 18594 18594 18594 ... \_ /init
18594 18595 18595 18595 ... \_ -zsh
18594 19374 19374 19374 ... \_ runc init

上面的输出揭示了第二个有趣但重要的细节:进程ID对应的是runc init,与我们的nginx bundle毫无关联。

启动(Start)

当我们执行runc start时,来看看这个过程会如何进行:

1
runc --root /run/runc start mycontainer

刹那之间,我们可以瞥见容器的入口点(entrypoint)命令正沿用相同的进程ID启动执行,但旋即消失无踪:

1
2
3
4
5
6
$ ps axjf
PPID PID PGID SID ... COMMAND
0 1 0 0 ... /init
1 18594 18594 18594 ... \_ /init
18594 18595 18595 18595 ... \_ -zsh
18594 19374 19374 19374 ... \_ /bin/sh /docker-entrypoint.sh nginx -g daemon off
1
2
3
4
5
6
$ ps axjf
PPID PID PGID SID ... COMMAND
0 1 0 0 ... /init
1 18594 18594 18594 ... \_ /init
18594 18595 18595 18595 ... \_ -zsh
#gone!

再次查询其状态:

1
2
3
4
5
6
7
8
9
10
$ runc --root /run/runc state mycontainer
{
"ociVersion": "1.1.0",
"id": "mycontainer",
"pid": 0,
"status": "stopped",
"bundle": "/home/acotten/mybundle",
"rootfs": "/home/acotten/mybundle/rootfs",
"created": "2023-08-17T19:10:49.347132023Z"
}

容器正处于stopped状态,并且不再返回进程ID。

运行(Run)

在进一步深入探究之前,让我们试着解释一下刚刚观察到的行为。为此,我们将使用runc的高层命令之一:runc run。该命令并不直接映射到此前列举的任何一种OCI运行时操作,但可以粗略地将其描述为createstart的结合,尽管存在一些细微差别。

首先,我们必须删除已停止的容器:

1
runc --root /run/runc delete mycontainer

然后就像先前调用runc create那样,执行runc run

1
2
3
4
5
runc --root /run/runc \
run \
--bundle ~acotten/mybundle \
--pid-file ~acotten/init.pid \
mycontainer

这次,命令没有立即退出。相反,容器的init进程(nginx)的标准输出被打印到我们终端的标准输出:

1
2
3
4
5
6
7
/docker-entrypoint.sh: Configuration complete; ready for start up
2023/08/17 19:12:34 [notice] 1#1: using the "epoll" event method
2023/08/17 19:12:34 [notice] 1#1: nginx/1.25.1
2023/08/17 19:12:34 [notice] 1#1: built by gcc 12.2.1 20220924 (Alpine 12.2.1)
2023/08/17 19:12:34 [notice] 1#1: OS: Linux 5.15.90.1-microsoft-standard-WSL2
2023/08/17 19:12:34 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1024:1024
2023/08/17 19:12:34 [notice] 1#1: start worker processes

让我们在另一个独立的终端窗口中检查容器的状态以及进程树:

1
2
3
4
5
6
7
8
9
10
$ runc --root /run/runc state mycontainer
{
"ociVersion": "1.1.0",
"id": "mycontainer",
"pid": 24977,
"status": "running",
"bundle": "/home/acotten/mybundle",
"rootfs": "/home/acotten/mybundle/rootfs",
"created": "2023-08-17T19:12:34.054132023Z"
}

runc state报告的进程ID现在显示为running状态:

1
2
3
4
5
6
7
8
9
10
11
$ ps axjf
PPID PID PGID SID ... COMMAND
0 1 0 0 ... /init
1 20434 20434 20434 ... \_ /init
20434 20435 20435 20435 ... \_ -zsh
20435 24963 24963 20435 ... \_ runc --root /run/runc run --bundle /home/ac...
24965 24977 24977 24977 ... \_ nginx: master process nginx -g daemon off
24977 24998 24977 24977 ... \_ nginx: worker process
24977 24999 24977 24977 ... \_ nginx: worker process
24977 25000 24977 24977 ... \_ nginx: worker process
24977 25001 24977 24977 ... \_ nginx: worker process

相应的进程在进程树中清晰可见,根据容器配置,其运行命令正对应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.txtecho 'hi' | wc时,shell会在底层执行以下操作:

  1. 使用fork()创建一个子进程
  2. 关闭该子进程的标准输出,并获取应重定向到的目标文件描述符(在上述示例中分别为文件和管道)
  3. 将此文件描述符分配给该进程的标准输出
  4. 通过调用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系统调用非常相似。

在创建(Create)阶段结束时,runc会将状态写入到state.json文件中,其存放于由--root命令行参数指定的目录下。该文件包含了诸如引导进程的进程ID等信息。随后所有的runc命令都会读取此文件,将其作为唯一依据,并根据其init进程的状态来确定容器的当前状态。

1
2
/run/runc/mycontainer
└── state.json

我之前一直故意没提,同一目录下还创建了名为exec.fifo的命名管道(FIFO)。

1
2
3
/run/runc/mycontainer
├── exec.fifo
└── state.json

正是这个命名管道,宿主机方能向引导进程传达启动容器的意图,并开启下一阶段:启动(Start)。该命名管道的写入端绑定至引导进程,而读取端则暂处于封闭状态。此时,引导进程会因尝试向exec.fifo执行写入调用而陷入阻塞态;直到外部执行runc start从中读取数据,就此扣下准许放行的「发令枪」后,阻塞方才解除。

在此阶段,调用者(例如containerd之类的容器管理器)在正式启动容器前,可以根据需要,执行任何其认为合适的额外步骤。在实际应用中,这通常意味着通过调用一系列CNI插件来配置容器的网络接口[2]。待此类操作完成后,调用者便可发起启动(Start)阶段。

而在容器启动的第二阶段(亦即最终收尾阶段),底层实际发生的动作核心,不过是一次exec()系统调用,其正由前文所述的exec.fifo管道读取事件所触发。由于引导进程在创建(Create)阶段就已经从父级runc进程接收了容器的运行时配置,因此无需再执行任何额外步骤。

最后,exec.fifo命名管道被删除。

附录:引导进程全貌

initProcess结构体的完整内容(为清晰起见,省略了一些无关属性)展示如下,以供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
libcontainer.parentProcess(*libcontainer.initProcess) *{
cmd: *os/exec.Cmd {
Path: "/proc/self/exe",
Args: []string len: 2, cap: 2, [
"/usr/local/bin/runc",
"init",
],
Env: []string len: 7, cap: 12, [
"GOMAXPROCS=",
"_LIBCONTAINER_INITPIPE=3",
"_LIBCONTAINER_STATEDIR=/run/runc/mycontainer",
"_LIBCONTAINER_LOGPIPE=4",
"_LIBCONTAINER_LOGLEVEL=4",
"_LIBCONTAINER_FIFOFD=5",
"_LIBCONTAINER_INITTYPE=standard",
],
Dir: "/home/acotten/mybundle/rootfs",
Stdin: io.Reader(*os.File) *{
file: *(*os.file)(0xc000082060),},
Stdout: io.Writer(*os.File) *{
file: *(*os.file)(0xc0000820c0),},
Stderr: io.Writer(*os.File) *{
file: *(*os.file)(0xc000082120),},
ExtraFiles: []*os.File len: 3, cap: 4, [
*(*os.File)(0xc000014bb8),
*(*os.File)(0xc000014bc8),
*(*os.File)(0xc000014be8),
],},
messageSockPair: libcontainer.filePair {
parent: *(*os.File)(0xc000014bb0),
child: *(*os.File)(0xc000014bb8),},
logFilePair: libcontainer.filePair {
parent: *(*os.File)(0xc000014bc0),
child: *(*os.File)(0xc000014bc8),},
config: *libcontainer.initConfig {
Args: []string len: 4, cap: 4, [
"/docker-entrypoint.sh",
"nginx",
"-g",
"daemon off;",
],
Env: []string len: 4, cap: 4, [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NGINX_VERSION=1.25.1",
"PKG_RELEASE=1",
"NJS_VERSION=0.7.12",
],
Cwd: "/",
Capabilities: *(*"libcontainer/configs.Capabilities")(0xc0000bf200),
User: "0:0",
AdditionalGroups: []string len: 11, cap: 16, [
"0","1","2","3","4","6","10","11","20","26","27"],
Config: *(*"libcontainer/configs.Config")(0xc0000e21e0),
Networks: []*libcontainer.network len: 0, cap: 0, nil,
PassedFilesCount: 0,
ContainerId: "mycontainer",
Rlimits: []libcontainer/configs.Rlimit len: 1, cap: 1, [
(*"libcontainer/configs.Rlimit")(0xc00002d4d0),
],},
manager: libcontainer/cgroups.Manager(*libcontainer/cgroups/fs.manager) *{
cgroups: *libcontainer/configs.Cgroup {
Name: "",
Parent: "",
Path: "/default/1b260428b931d1c22fa618e251c7da66f84d18fe66f2ee67d...+15 more",
ScopePrefix: "",
Resources: *(*"libcontainer/configs.Resources")(0xc000002300),
Rootless: false,
OwnerUID: *int nil,},
paths: map[string]string [
"memory": "/sys/fs/cgroup/memory/default/1b260428b931d1c22fa618e2...+40 more",
"net_cls": "/sys/fs/cgroup/net_cls/default/1b260428b931d1c22fa618...+42 more",
"net_prio": "/sys/fs/cgroup/net_prio/default/1b260428b931d1c22fa6...+44 more",
"": "/sys/fs/cgroup/unified/default/1b260428b931d1c22fa618e251c7d...+35 more",
"cpuset": "/sys/fs/cgroup/cpuset/default/1b260428b931d1c22fa618e2...+40 more",
"rdma": "/sys/fs/cgroup/rdma/default/1b260428b931d1c22fa618e251c7...+36 more",
"devices": "/sys/fs/cgroup/devices/default/1b260428b931d1c22fa618...+42 more",
"cpu": "/sys/fs/cgroup/cpu/default/1b260428b931d1c22fa618e251c7da...+34 more",
"perf_event": "/sys/fs/cgroup/perf_event/default/1b260428b931d1c2...+48 more",
"misc": "/sys/fs/cgroup/misc/default/1b260428b931d1c22fa618e251c7...+36 more",
"cpuacct": "/sys/fs/cgroup/cpuacct/default/1b260428b931d1c22fa618...+42 more",
"pids": "/sys/fs/cgroup/pids/default/1b260428b931d1c22fa618e251c7...+36 more",
"blkio": "/sys/fs/cgroup/blkio/default/1b260428b931d1c22fa618e251...+38 more",
"hugetlb": "/sys/fs/cgroup/hugetlb/default/1b260428b931d1c22fa618...+42 more",
"freezer": "/sys/fs/cgroup/freezer/default/1b260428b931d1c22fa618...+42 more",
],}
container: *libcontainer.linuxContainer {
id: "mycontainer",
root: "/run/runc/mycontainer",
config: *(*"libcontainer/configs.Config")(0xc0000e21e0),
cgroupManager: libcontainer/cgroups.Manager(*libcontainer/cgroups/fs.manager) ...,
initPath: "/proc/self/exe",
initArgs: []string len: 2, cap: 2, [
"/usr/local/bin/runc",
"init",
],
initProcess: libcontainer.parentProcess(*libcontainer.initProcess) ...,
initProcessStartTime: 0,
state: libcontainer.containerState(*libcontainer.stoppedState) ...,
created: (*time.Time)(0xc0001d42b0),
fifo: *(*os.File)(0xc000014be8),},
fds: []string len: 0, cap: 0, nil,
process: *libcontainer.Process {
Args: []string len: 4, cap: 4, [
"/docker-entrypoint.sh",
"nginx",
"-g",
"daemon off;",
],
Env: []string len: 4, cap: 4, [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bi...+1 more",
"NGINX_VERSION=1.25.1",
"PKG_RELEASE=1",
"NJS_VERSION=0.7.12",
],
User: "0:0",
AdditionalGroups: []string len: 11, cap: 16, [
"0","1","2","3","4","6","10","11","20","26","27"],
Cwd: "/",
Stdin: io.Reader(*os.File) ...,
Stdout: io.Writer(*os.File) ...,
Stderr: io.Writer(*os.File) ...,
ExtraFiles: []*os.File len: 0, cap: 0, nil,
Capabilities: *(*"libcontainer/configs.Capabilities")(0xc0000bf200),
Rlimits: []libcontainer/configs.Rlimit len: 1, cap: 1, [
(*"libcontainer/configs.Rlimit")(0xc00002d4d0),
],
Init: true,
ops: libcontainer.processOperations nil,
LogLevel: "4",
SubCgroupPaths: map[string]string nil,},
bootstrapData: io.Reader(*bytes.Reader) *{
s: []uint8 len: 32, cap: 32, [
32,0,0,0,48,242,1,0,1,0,0,0,0,0,0,0,8,0,145,106,0,0,2,108,8,0,151,106,0,0,0,0],
i: 0,
prevRune: -1,},
}

暂且略去那些繁复的深层嵌套细节,有几个关键字段值得我们重点聚焦:

  • cmd描述了引导(bootstrap)命令:runc init
    • 用于向引导进程传达启动(Start)阶段开始信号的命名管道exec.fifo,其文件描述符在环境变量_LIBCONTAINER_FIFOFD中被引用。它引用了地址为(*os.File)(0xc000014be8)cmd.ExtraFiles项,顺带一提,该项也同样被container.fifo引用。
  • OCI运行时配置(config.json)的反序列化版本在config字段中可见:包含容器cmd、环境变量、工作目录等。
    • 实际上,该配置的很大一部分隐藏在config.Configconfig.Capabilities字段之后,但在此处展开它们会给示例引入大量干扰,且这两者在OCI运行时规范中均有详细说明。
    • 一旦引导进程启动,完整的容器配置便会通过一对UNIX域套接字messageSockPair,以JSON格式传递给引导进程。此数据至关重要,引导进程因此才得以在启动(Start)阶段利用预期的环境对容器命令调用exec()
  • containerprocess两个字段,与config字段存在部分重叠。它们分别代表了容器的内部表示(由runc/libcontainer所见),以及专门针对容器init进程(即nginx)的表示。
  • 分配给引导进程(进而分配给容器init进程)的cgroup在manager字段中可见:如cpumemoryblkio等。

  1. 实际上,runc create在bootstrap(引导)序列中会执行两次fork,从而允许第二个子进程在最终的Linux命名空间内启动,而第一个子进程随后则会退出。这一流程细节丰富,足以专门写一篇文章来探讨。 ↩︎

  2. CNI规范并不属于开放容器计划(OCI)。然而,它是云原生计算基金会(CNCF)生态系统中被广泛采用的标准,主要通过Kubernetes项目得以推广应用。 ↩︎