笔者日常维护和开发的服务都是将docker image部署到kubernetes集群,时常因为缺少对背后的细节和原理认知,踩过坑也走过弯路,让笔者深刻认识到实现的细节有多么重要。后端程序员接触到docker,也许是通过docker这个CLI命令,或许是通过kubernetes,也或者是想尝试一个开源工具,而这个工具提供了用docker启动的简单入门方式……而所有这些,多数开发者并不是直接接触到runc。但笔者觉得,runc在docker及整个生态中像一把关键钥匙,通过了解runc的背后实现,有助于我们更好的理解其中许多的奥秘。
笔者能力有限,无法站在一个更高的维度来更好的抽象runc的架构设计和面面俱到的阐述runc的实现细节。本文从runc实现namespace过程来抛砖引玉,从自己的理解出发,希望能给读者一些帮助。
阅读本文之前,推荐读者读一下这篇文章:
https://mkdev.me/en/posts/the-tool-that-really-runs-your-containers-deep-dive-into-runc-and-oci-specifications
runc简介
(照片来自www.pexels.com David Dibert 摄影师)
docker生态常见用航海,集装箱,航舵等等logo。马尔科姆·麦克莱恩被称为集装箱之父,自从有了这个标准尺寸的箱子,世界货运的历史从此被改变。而容器也常常被称为集装箱,巴别塔的故事告诉我们:一旦有了通用的标准,连上帝都会感觉恐怖。
runc是docker组件中的low-level runtime(与之相对应的high-level是containerd),它本身是一个创建并运行容器的命令行。它是真正实现将我们的应用运行在"沙箱"中的关键。
docker组件及进程间关系如下图所示:
如上图: 在一系列docker组件中,runc处于最后的处理部分,也是距离用户应用最近的一环。
runc经过多次自身调用,历经新旧namespace创建和切换,最后执行entrypoint替换掉自身进程,可谓是实现容器化的幕后英雄。
本文重点放在runc的多次自身调用实现切换namespaces上面。
本文分析使用的版本:
github repo: https://github.com/opencontainers/runc/
commit: 636f23dd21bda3a7d54c134f7612e9a25522e5f5
准备工作
bundle
docker处理流程到runc阶段,所需的容器名称(container id),根文件系统(rootfs),以及配置(config.json)已经准备就绪了。其中,rootfs和config.json称为bundle。(这一点跟macos的安装包的包内容很相似: mac的软件安装包内容放在一个Contents目录中,目录下有个info.plist的描述文件) docker的bundle有一个专门为此开放的规范称之为OCI: (Open Container Initiative Runtime Specification),后期之秀像gVisor等其他容器引擎都遵循OCI规范。简单来说,docker的bundle包含了完整的根目录及docker运行时的运行时库,文件,程序等等。其中的config.json配置描述docker运行起来要执行的应用程序路径,执行的终端信息,还包括了各个操作系统平台下的一些操作细节,比如: 在Linux环境下需要开启哪些命名空间,文件的挂载点和cgoups资源限制等等。
创建方法
$ mkdir /mycontainer; cd /mycontainer
# 创建一个根目录
$ mkdir rootfs
# 以busybox镜像为例,将busybox镜像中的文件导出到rootfs目录下
$ docker export $(docker create busybox) tar -C rootfs -xvf -
# 生成 config.json 的配置模板
$ runc spec
rootfs是一个可以chroot的完整文件根目录:
$ tree -L 1 rootfs
rootfs
├── bin
├── dev
├── etc
├── home
├── proc
├── root
├── sys
├── tmp
├── usr
└── var
如果将容器比作集装箱,那么config.json可以是这个"集装箱"的规格说明书。config.json长成这个样子:
{
"process": {
"terminal": false,
"user": { "uid": 0, "gid": 0},
"args": ["sh"],
"cwd": "/",
"capabilities": {},
"rlimits": [],
"noNewPrivileges": true
},
"hostname": "runc",
"mounts": [],
"linux": {
"resources": {},
"namespaces": [
{
"type": "pid"},
{
"type": "network"},
{
"type": "ipc"},
{
"type": "uts"},
{
"type": "mount"}
],
"maskedPaths": [],
"readonlyPaths": []
}
}
这个配置中的process.args就是用户在Dockfile中指定的entrypoint,linux.namespaces来指示runc生成的容器要创建的新的namespace。
调试工具
在分析开源代码时,一款调试工具有助于我们更清楚地追踪整个执行流程,掌握主体脉络。笔者这里选用delve和gdb这两款调试工具,这两个调试工具在使用上比较类似。这两款调试工具各有优劣。
dlv专职调试go程序,比如像gr/grs等可以查看goroutine信息,但dlv有一定局限性,无法对子进程进行调试。
gdb是老牌的调试器,它对c/c++等系统native程序支持完善。在调试go程序时,对go变量打印不太友好。这里选择gdb是因为gdb具有强大的follow-fork-mode特性,可以追踪runc的多个子进程。
需要说明的是,笔者的机器上安装的是gdb 8.1.1,这个版本对clone(2)的子进程进行调试时有bug(gdb follow-fork-mode bug),笔者最终下载最新的10.1版本gdb,调试过程非常流畅。
编译及调试
delve
# 进入到源码目录,debug后可跟package。这里指main包。如果想要给程序传递参数,则可跟在“--”之后。
dlv debug . -- create mycontainer -b /home/noodles/work/mydocker/
# b/break 这里在process_linux.go 315行设置断点
(dlv) b /home/noodles/codes/opensrc/runc/libcontainer/process_linux.go:315
gdb
go build -gcflags=all=
(gdb)
runc源码分析
接下来的分析将runc的执行过程分为三部分:
runc create
runc init
runc start
其中,runc create为runc init做准备工作,runc init 很少被直接使用,而是被 runc create 隐式地调用,runc init是本文要分析的重点。
另外,runc run其实是将三部走变成一步走。
runc create
runc create 调用路径
main.go:117 行是runc的所有子命令。
从createCommand开始,经过第一轮处理,runc create 会以runc init回到initCommand。过程比较清晰:
第1步中: setupSepc(context)加载bundle目录下的config.json配置。
在第5,6步,构造一个工厂函数,这个LinuxFactory的可执行文件设置为/proc/self/exe,其实就是runc自己,它的参数是init,也即,在后续过程中的某个地方执行runc init,于是整个流程又从上图中标注的第8步: initCommand子命令开始,进入到runc的init阶段。
在第7步之前,构造runner对象。在 create 阶段,runner.init为true。然后调用run()。
首先,从run()开始:
272 func(r *runner)run(config *specs.Process) (int, error) {
282 process, err := newProcess(*config, r.init)
319 switch r.action {
320 case CT_ACT_CREATE:
321 err = r.container.Start(process)
322 case CT_ACT_RESTORE:
323 err = r.container.Restore(process, r.criuOpts)
324 case CT_ACT_RUN:
325 err = r.container.Run(process)
326 default:
327 panic("Unknown action")
328 }
357 }
// newProcess: 将读取到的config.json的process配置构造成libcontainer.Process。
// 例如,这里的Args就是`sh`, Env是`PATH`环境变量。
106 func newProcess(p specs.Process, init bool) (*libcontainer.Process) {
107 lp := &libcontainer.Process{
108 Args: p.Args,
109 Env: p.Env,
111 User: fmt.Sprintf("%d:%d", p.User.UID, p.User.GID),
112 Cwd: p.Cwd,
113 Label: p.SelinuxLabel,
114 NoNewPrivileges: &p.NoNewPrivileges,
115 AppArmorProfile: p.ApparmorProfile,
116 Init: init, // true
118 }
143 return lp
144 }
执行libcontainer/container_linux.go中linuxContainer.Start():
252 func(c *linuxContainer) Start(process *Process) error {
258 if process.Init {
259 if err := c.createExecFifo(); err != nil {
260 re