根据官方的定义:runC是一个根据OCI标准创建并运行容器的CLI tool。
Docker就是基于runC创建的,简单地说,runC是Docker中最为核心的部分,容器的创建,运行,销毁等等操作最终都将通过调用runC完成。下面我们将演示如何使用runC,以最精简的方式创建并运行一个容器。
下载并编译runC
创建容器的根文件系统
利用runC的spec命令创建默认的配置文件config.json,其中包含了创建一个容器所需的所有配置信息
利用runC运行busybox容器
可以看到,容器成功运行,此时我们打开另一个终端观察容器的运行状态
事实上,是一个复合命令,它包含了容器的创建,启动以及在退出之后对容器进行的销毁,从演示的角度看它是最为直观的。但是如果想要深入理解runC内部的实现机制,将容器的创建,启动,销毁三个步骤分开,显然会让整个过程的分析更为简单和易于接受
下面我们就将结合源码,对整个容器技术最为核心的部分进行探究—— 容器是如何创建并启动的
首先,我们来对runC的整体代码结构做一个宏观的把控:
可以看到在runC的顶层目录中,有着一系列形如, , …的go文件,它们和runC的子命令,例如,,是一致的。 另外,在顶层目录中还有一个名为的子目录。对于Docker项目的发展历史有所了解的同学应该都知道,曾经是Docker中最为核心的包,容器的创建,删除等一系列工作,最终都是交由它来完成的。
这样一来,runC的代码结构就非常清晰了。我们知道,runC是符合OCI标准的容器运行时。不难猜出,它本质上是对的一层薄薄的封装。它会先读取符合OCI标准的容器配置,再将其转换成与兼容的格式,最后将转换后的配置交由来完成具体的工作。
create.go的工作主要分为如下两部分:
- 将容器配置从config.json文件加载到内存中,保存在一个类型为*specs.Spec (Spec即为OCI标准的容器配置在内存中的表现形式)的结构体中
- 调用完成容器的创建工作,值得注意的是 , 以及最终都将调用该函数,只是第三个参数不同而已,对于,该参数为,表示首次创建容器。接下来程序的执行路径将因该参数的不同而有所不同。
的工作由如下三部分组成:
- 从参数中获取容器的id,例如对于命令 ,则获取的id即为
- 调用 ,根据spec中Container相关的内容,调用 创建容器对象,且容器的状态设置为。此时的容器仅仅只是一个内存中的数据结构,并没有与之对应的进程
- 创建对象并调用相应的方法,该对象会将spec中的Process转换成兼容的模式,并对容器的IO进行配置
方法的工作同样由三部分组成:
- 将OCI标准的进程配置转换为符合格式的进程配置
- 调用对进程的IO进行配置,因为IO涉及的内容较为复杂,会在另外的文章中详细叙述
- 根据配置参数的不同,调用不同的方法,分别进行容器的创建,运行或者恢复,本文我们只讨论这种情况
到此为止,我们已经将OCI格式的配置,不管是Container还是Process都转换成了要求的格式。接着我们将深入,真正完成容器实例的创建工作。
方法仅仅只是对的一个封装并且会在容器状态为时(即新建容器时),创建一个路径为的管道文件,它的作用我们会在后文中详细描述。
值得注意的是方法的第二个参数对容器的状态进行了判断。事实上,命令和的代码的执行路径是类似的,它俩共享了大部分的代码。因此,这里我们需要对容器的状态进行判断,如果容器的状态为说明接下来应当进行容器的创建,否则应当在已有容器中exec一个新进程。
方法的工作也可以分为如下三部分:
- 调用创建 对象
- 调用的方法,它真正完成容器进程的创建以及初始化工作
- 如果参数为true,则说明执行的命令为,更新容器状态为,并且如果定义了hooks(回调函数),则还需要执行类型的hook函数。否则,如果执行的命令为,则更新容器状态为
首先创建了一个名为的管道,它一方面会在创建容器时给容器的初始化进程传输容器的配置信息,另一方面它也会用于runC和容器进程之间的同步。
之后,它会调用创建容器初始化进程的运行模板,如下所示:
从上面的代码中我们可以看出,环境变量也是runC进程和容器初始化进程之间进行交互的一种重要方式。上文中的 管道的信息就是通过环境变量的方式从runC传递给容器初始化进程的。
到这里,我们脑海中可能会浮现出另一个问题:应该就是容器初始化进程的二进制文件的路径,那么它是一个独立于runC的二进制文件么?它又是放在哪的呢?事实上,在上文初始化Container对象时会被初始化为,而被设置为 ,因此我们创建子进程的过程其实相当于执行了这条命令。
如果执行的命令为,还需要将前文提到的这个管道同样以环境变量的形式传递到容器初始化进程中。最后,调用将所有配置都填充至结构体中。
结构的方法真正完成了容器进程的创建,并通过管道协助其完成初始化工作。该方法首先调用创建一个独立的进程,执行命令。接着通过管道将容器配置写入管道中。然后再调用函数,通过管道与容器初始化进程进行同步,待其初始化完成之后,执行等一些回调操作。最后,关闭管道,容器创建完成。
runC端在创建容器时所做的工作我们已经基本了解了,下面我们来看看,也就是容器初始化进程具体完成了哪些工作。
作为容器的初始化进程,必须先通过管道获取配置才能进行下一步的工作。显然,我们首先要做的就是从环境变量中获取与runC进程进行交互的管道的信息,包括管道。对于还有管道,即上方代码中的。紧接着,调用函数,创建用于初始化的接口对象,该函数的代码如下:
该函数的作用非常明显,从管道中读取容器配置,解析至中。对于,创建结构,将各种配置信息写入其中。最后,调用该结构的方法真正对容器进行初始化。
方法真正完成了对容器的初始化工作,它会对容器的网络,路由,hostname等一系列属性进行配置。这些工作一般都是直接通过系统调用设置完成的,因此我们就不再细述了。接下来我们将重点描述容器初始化进程和其父进程,也就是runC进程的同步过程。
我们都知道,每个容器都有自己的根文件系统,到目前为止我们依然还是宿主机文件系统的视角,那么文件系统根目录的切换是在哪里进行的呢?答案是显然的,。
先对容器的Mounts和Dev等信息进行配置,之后再调用,通过管道向runC进程发送信号。runC进程接收到信号之后,执行容器的回调函数,再通过管道给容器初始化进程发送信号,通知其继续执行。可见容器的是在根目录尚未切换之前执行完成的。最终,调用函数,切换根目录。至此,容器的文件系统切换完毕。
在文件系统准备完成之后,方法还会对Console, hostname等属性进行配置。当一切就绪之后,调用通过管道通知runC进程,获取响应之后,关闭管道,同步结束,准备开始执行用户指定的容器进程。
不过在找到了用户指定的容器程序在容器文件系统的执行路径之后,初始化进程又打开了我们之前多次提到的这个管道,并且往里面写入了一个字节,之后才执行系统调用,切换到用户程序。既然是一个管道,那么我们在这一端写入之后,就必须有消费者在另外一端进行读取,否则写进程就会一直处于阻塞状态。
事实上,此处对管道的写阻塞正是和执行流的分界点。容器的创建工作,在容器初始化进程往管道进行写操作的那一刻,就全部结束了。
相对于容器的创建,容器的启动就非常简单了
当我们执行命令时,我们首先会获取相应容器的状态。显然,只有状态为的容器才是合法的,此时需要调用容器的方法。
方法仅仅只是对的简单封装。而方法的工作很简单,找到管道的路径,打开它,并调用从管道中将容器初始化进程从另一端写入的字节读出。一旦管道中的数据被读出,容器内的初始化进程将不再被阻塞,紧接着将完成系统调用,容器初始化进程将被切换为用户指定的程序。到此为止,一个容器真正启动成功。
可是这一路分析下来,似乎并没有对容器的namespace进行配置的操作?事实上,子进程的执行流在进入Go语言的运行时之前,会被包劫持,先去执行一段C代码。这段C代码同样会从管道中读取容器的配置,主要是namespace的路径,clone flag等等,并根据这些配置,调用系统调用,将容器进程加入到合适的namespace中。之后再进入Go的运行时,完成上文所述的各种初始化操作。
简而言之,runC创建容器的过程如下图所示:
- 命令加载文件中容器的配置并转化为与兼容的模式
- 根据配置创建以及对象
- 创建子进程,中间会被劫持,使子进程位于容器配置指定的各个namespace内
- 用管道将容器配置信息传输给进程,再据此进行容器的初始化操作。初始化完成之后,再向另一个管道进行写操作,进入阻塞状态
- 执行命令,从管道中读取上一个步骤写入的字节。进程不再阻塞,执行系统调用,切换至用户指定的容器进程,容器真正创建并启动完成
- 文中源码对应的runC版本为,commit:
- 文中引用的代码因文章效果做了部分删减,详细的源码注释参见我的Github
- runC源码
- OCI和runc:容器标准化和docker
版权声明:
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若内容造成侵权、违法违规、事实不符,请将相关资料发送至xkadmin@xkablog.com进行投诉反馈,一经查实,立即处理!
转载请注明出处,原文链接:https://www.xkablog.com/do-docker-k8s/11317.html