写在前面的话 整个分享过程中有以下几个关键词:Linux、Docker、fork、exec、task_struct、namespace以及dockerinit。分享内容可能比较底层,但对大家理解Docker的本质会很有帮助,大家要有耐心哦。 分享的内容包含三部分: Linux进程的基本知识; Docker与Linux进程的关系; Docker中dockerinit,entrypoint和cmd三者的关系。 Part1Linux进程的基本知识这部分我们会比较基础地介绍Linux进程管理中的fork、exec、进程描述符task_struct以及namespace。 1Linux中创建进程的基本模型Linux操作系统中,由父进程创建并执行子进程,创建通过fork完成,执行通过exec完成。简单示意图如下: 在上图中,我们看到进程A创建了一个新的进程B,最终两个进程各自运行。创建时,进程A通过fork系统调用来完成。fork之后,两个进程最大的区别就是:进程A依然拥有原来的PID,新创建的进程B会占用一个全新的PID,两者的PID不同。 并且Linux内核会在fork系统调用时,会拷贝进程A的task_struct,拷贝的副本是为进程B准备的。完成fork操作之后,拥有全新PID的进程B会执行exec操作,保证执行新的程序,真正开始进程B的运行逻辑。 进程B运行过程中,假若B正常或者异常退出,那么内核就会给进程B的父进程A,发送一个SIGHOLD信号,父进程A则对退出的进程B执行wait操作,实现对B进程资源的回收,如进程描述符task_struct等。 如此一来,Linux系统中进程最基本的生命周期就完整了。大家不要觉得这和Docker没有什么关系,其实我也卖个关子,Docker容器的底层实现原理,就是基于Linux进程的fork和exec。 2Linuxfork的具体实现细节do_forkLinux操作系统在实现fork时,其实更为底层的是:实现一个名为do_fork的函数。Do_fork函数是在内核态,完成了新进程的创建流程。do_fork函数的运行流程可以如下图:上图中,我们最关心的Linux内核为子进程,拷贝父进程内容的环节(copy_process)。这个环节中,Linux会:首先,查看传入的flag参数,为后续是否创建新namespace做准备;随后,拷贝父进程的进程描述符task_struct;接着查看系统的资源限制;而后,拷贝父进程的具体细节,这个环节也是本次分享的一个重点,因为其中涉及了命令空间的拷贝(copy_namespaces);最后,内核仍会完成一些其他操作。 在这里,我们已经多次强调了task_struct和namespace,那不妨对两者多做一些介绍。简单理解,task_struct唯一地定义了一个进程的多种属性,内核调度进程时,绝大多数的信息都来源于task_struct。 而namespace为进程定义了一整套的命名空间,实现不同命名空间内进程之间的完全隔离。说到隔离,Docker就有能力实现容器间的隔离,其实最终的原理正是通过namespace来实现的。 这部分内容比较底层,大家可以多多揣摩一下哈。 3task_struct和namespace的关系通俗一点来讲,Linux内核在调度进程的时候,所有信息都来源于进程描述符task_struct,并从task_struct中找到相应的命令空间信息。也就是说通过task_struct,内核有能力找到这个进程的namespace。 比如说:一个进程A要使用网络设备(网卡)实现网络通信。那在宿主机上有多个网络设备的时候,如何定位要具体的网络设备呢。很简单,Linux内核首先找到进程A的进程描述符task_struct,然后在task_struct中找到进程具体所在的网络命名空间(netnamespace),netnamespace中会有一个对象真正对应于具体的网络设备,那么关于改进程具体的网络通信,Linux内核就会交给这个网络设备来完成。 看下图可以理解的更清楚: 在进程描述符task_struct中,有一个nsproxy会为进程代理所有的namespace,具体的nsproxy会有多个指针指向具体的namespace。具体由nsproxy代理的namespace,有:uts_namespace,mnt_namespace,pid_namespace,ipc_namespace和netnamespace。 讲了这么多的namespace,大家肯定会有疑惑,namespace中到底包含了哪些内容: 具体可以看下图哦: 比如说,pidnamespace可以认为是为Linux容器完成内部进程PID的管理。在pidnamespace中,会有很多的数据结构。其中有一个属性特别重要,那就是一个名为child_reaper的指针,执行一个task_struct。顾名思义,这个属性可以实现对很多子进程进程清理。这个进程一般会被认为是容器的init进程(pid=1),一旦这个进程退出,那么内核会给容器内其他的所有进程发送一个终止信号,确保容器的退出。 总结而言,就是容器init进程,决定容器的存活~ 另外,像mnt_namespace,它为容器实现了根目录的隔离视角,也就是传统意义上chroot的作用。该mnt_namespace中最重要的自然是root属性,定义了容器的根目录。 还有uts_namespace,定义了容器的命名信息等。 以上的所有内容,就是为Docker原理准备的基本内容。总结一下,有Linux进程的fork、exec、task_struct以及namespace。 下面,我们会来介绍Linux进程与Docker的关系。 Part2Docker与Linux进程的关系第一部分讲了很多Linux进程的概念,我们今天的主题是Docker,Docker在哪里呢? 回到Docker最经典的三层架构:dockerclient、dockerdaemon以及dockercontainer。DockerDaemon作为守护进程管理着所有的容器,其实在创建容器的时候,DockerDaemon仅仅是实现了一个fork,然后在fork的过程中实现了copy_namespaces。 换言之,Docker容器的诞生仅仅是通过fork和exec一个进程而已。 具体解释原因前,我们来看看DockerDaemon的fork和我们程序员普通的fork有什么区别,为什么Docker的fork,fork出的是容器,而我们的却不叫容器呢?且看下图: 原因是DockerDaemon在fork容器进程的时候,传入了一些特殊的flag参数,而这些参数恰恰在内核执行do_fork函数时,被用来创建新的namespace。 大家如果去看Docker的源码,可以发现Docker对于namespace的支持如下图: 其中user_namespace在Docker中仍然没有被支持,那说明Docker容器还不能支持用户uid的映射,容器的root和宿主机的root属于同一个uid,均为0。其实,这样是否会导致安全问题,肯定是大家最关心的。 回到Docker的fork,DockerDaemon一旦完成fork,那么后面肯定还需要完成子进程的exec。 容器进程被fork之后,进程描述符task_struct有了,新的命名空间也有了,但是进程还没有开始运行,还没有被exec。因此,exec的内容就是Docker的精髓,Docker容器的重中之重。 Part3Docker中dockerinit,entrypoint和cmd三者的关系 1?dockerinit,entrypoint和cmd三者的初步介绍此时,我们进入Docker的很多细节部分了,围绕Docker容器的exec,我们需要知道exec的内容到底是什么,因为exec的内容代表了Docker容器的运行逻辑到底是怎么样的。 先看下图: Docker的世界中,不知道大家是否听说过dockerinit、entrypoint和cmd。相信大家使用Dockerfile打包自身应用的时候,肯定涉及过entrypoint和cmd,然而一般会很少涉及到dockerinit。 从功能的角度来介绍三者: dockerinit:是在新namespace中第一个运行的内容,作用是设置新namespace中的挂载资源,初始化容器内的网络栈等。完成的属于容器层系统环境的初始化工作。 以网络namespace为例:当DockerDaemon创建容器时,仅仅是fork了一个进程,那么如何让这个进程的netnamespace中包含一个可用的网络栈(虚拟网络设备veth)呢?为容器内部初始化网络栈的角色就是dockerinit,dockerinit会获取DockerDaemon传递来的网络信息,并用来初始化这个容器的netnamespace。保证后续的进程拥有足够的网络能力。 entrypoint:由用户指定,完成容器内用户态配置的工作。 cmd:由用户指定,为容器的运行提供默认的参数,比如默认的运行启动入口等。 既然都属于在容器内部运行的内容,而且最终容器的角色要转移到用户指定的应用指定cmd,而容器内的进程只有一个init进程(pid=1),那么dockerinit、entrypoint和cmd三者与容器init进程之间的关系,会非常重要,毕竟init进程决定的容器的生命状态。 更为清晰的Docker容器进程图如下,其中包含dockerinit、entrypoint以及cmd: 大家可以看到容器进程被fork出来之后,实现exec的时候,第一部分直接exec的是dockerinit,而后是entrypoint,最后是cmd。 图中有一个非常重要的点是,三部分的内容会占用同一个PID,整个流程不会有新的pid诞生。原因很简单,那就是dockerinit执行entrypoint的时候,使用的也是exec操作,确保进程PID不变,进程执行程序切换到entrypoint。Entrypoint到cmd的过程,原理也是如此。 流程的角度来讲,最终容器的init进程(pid=1)的角色能满足最终嫁接到用户指定应用的cmd处。当然图中也显示了,整个容器都处于全新的namespace中,也就是说容器与容器,容器与宿主机之间都是可以实现隔离的。 2DockerDaemon与dockerinitDockerDaemon作为fork容器进程的发起者,自然是父进程,而dockerinit作为容器内第一个执行的内容,自然是子进程。其实在父子进程之间,还存在一些细枝末节,值得大家去深思与玩味。 简言之,DockerDaemon与dockerinit还存在一些数据的交互。大家还记得dockerinit的作用吗?初始化新建的namespace内的一些重要资源。但是这些资源呢,又是DockerDaemon在宿主机上申请的,比如说虚拟网络设备对(vethpair)。DockerDaemon在原始的命名空间中创建了这些内容之后,需要一种方式,把网络设备对的一个veth交给容器,那就是交给dockerinit。 DockerDaemon与dockerinit具体的通信形式如下: 看上图,我们可以发现最重要的字眼有两个:syncPipe与blocked。 前者可以称为同步管道,作用是协调DockerDaemon和dockerinit的运行顺序。 后者指的是:dockerinit的运行会被阻塞住,直dockerinit从同步管道syncPipe中读到内容为止。 接下来,我们就详细介绍阻塞的原因与两者的协作运行的情况。 上图中,左边代表DockerDaemon的运行流程,右边代表dockerinit的运行流程。现在,我们一步一步详细介绍两者的运行流程,以便深入了解Docker容器的原理。 DockerDaemon的流程如下: CreateCommand创建容器的执行程序,也就是为Docker容器exec的内容,当然这部分内容自然是dockerinit,毕竟dockerinit是Docker容器内第一个执行的内容。 创建一个同步管道syncPipe,自然是为了和马上要创建的dockerinit建立通信。 将同步管道syncPipe的一端以文件描述符的形式,为即将创建的子进程准备。 执行北京治疗白癜风医院哪里比较好白颠疯
|