docker之启动创建容器流程

libcontainer的工作流程

      execdriver的run方法通过docker daemon提交一份command信息创建了一份可供libcontainer解读的容器配置container,继而创建真正的docker容器。OCI组织成立后,libcontainer进化为runC ,因此从技术上说,未来libcontainer/runC创建的将是符合Open Container Format (OCF)标准容器

      这个阶段,execdriver需要借助libcontainer处理一些事情,

创建libcontainer构建容器需要使用的“进程”,进程对象(非真正进程),称为(Process)

设置容器的输出管道,这里使用的就是docker daemon提供给libcontainer的pipes,

使用名为Factory的工厂类,通过factory.Create(<容器>,<容器配置>)创建一个逻辑上的容器,称为Container,在这个过程中,容器配置container会填充到Container对象的config项里,container的使命至此就完成

执行Container.Start (Process)启动物理容器

execdriver执行由Docker daemon 提供的startcallback完成回调动作

execdriver执行Process.Wait, 一直等上Process的所有工作都完成

      可以看到。libcontainer对Docker 容器做了一层更高级的抽象,它定义了Process和Container来对应Linux中“进程”和“容器”的关系。一旦“物理”的容器创建成功,其他调用者就可以通过容器ID获取这个逻辑容器Container ,接着使用container.start得到容器的资源使用信息,或者执行Container.Destory来销毁这个容器

     综上,libcontainer中最主要的内容是Process、Container以及Factory这三个逻辑实体的实现原理。而execdriver或者其他调用者只要一次执行“使用Factory创建逻辑容器Container”、“启动逻辑容器container”和“逻辑容器创建物理容器”,即可完成docker容器创建

libcontainer实现原理

  用Factory创建逻辑容器Container

    libcontainer中Factory存在的意义就是能够创建一个逻辑“容器对象”container,这个逻辑上的“容器对象”并不是一个运行着的docker容器,而包含了容器要运行的指令以及其参数、namespace和cgroups配置参数等。对于docker daemon来说,容器的定义只需要一种就够了,不同的容器只是实例的内容(属性和参数)不一样而已。对于libcontainer来说,由于它需要与底层系统打交道,不同的平台就需要创建出完全异构的“逻辑容器对象”(比Linux容器和Windows容器),这也是解释了为什么这里会使用“工厂模式”:今后libcontainer可以支持更多平台上各种类型的容器的实现,而execdriver调用libcontainer创建容器的方法却不会受到影响

验证容器运行的根目录(默认/var/lib/docker/containers)、容器ID(字母、数字和下化线,长度范围1~1024)和容器配置这三项内容的合法性

验证上述容器ID与现在有的容器不冲突

在根目录下创建以ID为名的容器工作目录(/var/lib/docker/containers/{容器ID})。

返回一个Container对象,其中的信息包括了容器ID、容器工作目录、容器配置、初始化指令和参数(即dockerinit),以及cgroups管理器(这里有直接通过文件操作管理和systemd管理两个选择。默认选第一种)

至此,Container就已经创建和初始化完成了

启动逻辑容器

        参与物理容器创建过程的Process一个有两个实例,第一个叫Process,用于物理容器内进程的配置和IO的管理,前面的伪码中创建的Process就是指它,另一个叫ParentProcess负责从物理容器外部处理容器启动工作,与container对象直接进行交互,启动工作完成后,Parent-Process.start()来启动物理容器

       创建ParentProcess的过程

(1)创建一个管道(pipe),用于与容器内部未来要运行的进程通信(这个pipe不同于前面的输出流pipe)

(2)根据逻辑容器container中与容器内未来要运行的进程相关的信息创建一个容器内进程启动命令cmd对象,这个对象有Golang语言中的os/exec包进行声明。docker 会调用os/exec包中的内置函数,根据cmd对象来创建一个新的进程,即容器中的第一个进程dockerinit。而cmd对象则需要从Container中获得的属性包括启动命令的路径、命令参数、输入输出、执行命令的根目录以及进程管道pipe等

(3)为cmd添加一个环境变量_LIBCONTAINER_INITTYPE=standard来告诉将来的容器进程(dock-erinit)当前执行的“创建”动作。设置这个标志是因为libcontainer还可以进入已有的容器执行子进程,即docker exec指令执行效果

(4)将容器需要配置的namespace添加到cmd的Cloneflags中,表示将来这个cmd要运行在上述namespace中。若需要加入user namespace,还要针对配置项进行用户映射,默认映射到宿主机的root用户

(5)将Container中的容器配置和Process中的Entrypoint信息合并为一份容器配置加入ParentProcess当中

      实际上,ParentProcess是一个接口,上述过程真正创建的是一个称谓initProcess的具体实现对象,cmd、pipe、cgroup管理器和容器配置这4部分共同组成了一个initProcess。这个对象是用来“创建容器”所需的ParentProcess,这主要是为了同setnsProcess区分,后者作用是进入已有的容器。逻辑容器Container启动的过程实际上是initProcess对象的构建过程,而构建initProcess则是为了创建容器做准备。接下逻辑容器Container执行initProcess.start(),真正的Docker容器就诞生了

用逻辑容器创建物理容器

    逻辑容器container 通过initProcess.start()方法新建物理容器的过程如下

(1)docker daemon 利用Golang的exec包执行initProcess.cmd,其效果等价于创建一个新的进程并称为它设置namespace。这个cmd里指定的命令就是容器诞生时第一个进程,对于libcontainer来说,这个命令来自于execdriver新建容器时加载daemon的initPath,即docker工作目录下的/var/lib/docker/init/dockerinit-{version}文件,dockerinit进程所在的namespace即用户为最终docker容器指定的namespace

(2)把容器进程dockerinit的PID加入cgroups中管理,至此我们可以说dockerinit的容器隔离已经初步完成

(3)创建容器内部网络设备,包括lo和veth。这一部分涉及netlink等

(4)通过管道发送容器配置给容器内进程dockerinit

(5)通过管道等待dockerinit根据上述配置完成初始化工作,或者返回错误。

  综上所述。ParentProcess即(initProcess)启动一个子进程dockerinit作为容器内初始进程,接着,ParentProcess作为父进程通过pipe在容器外面对dockerinit管理和维护,在容器里,dockerinit进程只有一个功能,就是执行reexec.init(),该init方法做什么工作是由对应的execdriver注册到reexec当中的具体实现来决定的,对于libcontainer来说这里注册执行的是Factory当中的StartInitialization()。接下来所有动作都只在容器内完成

(1)创建pipe管道所需的文件描述符

(2)通过管道获取ParentProcess传来的容器配置,namespace网络等信息

(3)从配置信息中获取并设置容器内环境变量

(4)如果用户在docker run 指定了-ipc、-pid、-uts参数,则dockerinit还需要把自己加入到用户指定的上述namespace中

(5)初始化网络设备,这些网络设备正是在ParentProcess中创建的lo和veth。初始化工作包括:修改名称、分配MAC地址、添加IP地址等和默认配置网关

(6) 创建mount namespace,为挂载文件系统做准备

(7)在mount namespace中设置挂载点,挂载rootfs和各类文件设备、/proc.然后通过pivot_root切换进程根路径到rootfs的根路径

(8)写入hostname等,加载profile

(9)比较当前进程的父进程ID与初始化进程一开始记录下来的父进程ID,如果相同,说明父进程异常退出,终止初始化进程。否则执行最后一步

(10)最后一步,使用execv系统调用执行容器配置中的Args指令。

     Args[0]正是用户指定的Entrypoint,Args[1,2,3...]则是该指令后面的运行参数,所有当容器创建后,它里面运行的进程已经从dockerinit变成了用户指定的命令Entrypoint(如果不知道。默认运行时/bin/sh)。execv调用就是为了保证这个“替换”发生后的Entrypoint指令继续使用原先dockerinit的PID信息等

     dockerdaemon 通过容器创建所需的配置和用户需要启动的命令交给libcontainer,后者根据这些信息创建逻辑容器和父进程(步骤1),接下来父进程执行Cmd.start,才真正创建(clone)出了容器的namespace环境,并通过dockerinit以及管道来管理完成整个容器初始化过程,这个过程3个阶段

(1)docker daemon进程进行“用Factory创建出逻辑容器Container”“启动逻辑容器Container”等工作,构建ParentProcess对象,然后利用他创建容器内第一个进程dockerinit

(2)dockerinit利用reexec.init()执行starrtlnitialization()。这里dockerinit会将自己加入到用户指定的namespace(如果指定的话),然后开始进行容器内各种初始化工作

(3)starrtlnitialization()使用execv系统调用执行容器配置中的Arges指定的命令,即Entry-Point和docker run的参数

docker daemon 与容器间通信方式

       容器进程启动需要做初始化工作,就使用namespace隔离后的两个进程间通信。我们负责创建容器的进程称为父进程,容器进程称为子进程,父进程克隆出子进程后,依旧是共享内存的。让子进程感知内存中写入了新数据依旧是个问题,一般有4中方式:信号发送(signal)、对内存轮询访问(poll memory)、sockets通信(sockets)、文件和文件描述符(files and files-description)

      对于signal而言,本身包含的信息有限,需要额外记录,namespace带来的上下文变化使其操作更复杂,并不是最佳选择。显然通过轮询内存的方式来沟通是一个非常低效的方法,因为docker 会加入network namespace ,实际上初始化网络栈也是完全隔离的,所有socket方式并不可行

       docker最终选择的方式是管道—文件和文件描述符的方式。在Linux中,通过pipe(intfd[2])系统调用就可以创建管道,参数是一个包含两个整数的数组。调用完成后,在fd[1]端写入数据,就可以从fd[0]端读取

      调用pipe函数后,创建子进程会嵌入这个打开的文件描述符,对fd[0]写入数据后可在fd[0]端读取。通过管道,父子进程之间就可以通信,这个通信完成的标志就在EOF信号的传递。众所周知,当打开的文件描述符都是关闭时,才能读到EOF信号。因此libcontainer中父进程在通过pipe向子进程发送完毕初始化信息后,先关闭自己这一端的管道,然后等待子进程关闭另一端的管道文件描述符,传来EOF表示子进程已经完成这些初始化工作。

posted @ 2019-03-25 09:02  烟雨楼台,行云流水  阅读(3915)  评论(0编辑  收藏  举报