沉淀,再出发:docker的原理浅析
一、前言
在我们使用docker的时候,很多情况下我们对于一些概念的理解是停留在名称和用法的地步,如果更进一步理解了docker的本质,我们的技术一定会有质的进步和飞跃的。再看了一些文章之后,我总结了一些docker的本质和核心。
二、docker的构成
不难看出,用户是使用Docker Client与Docker Daemon建立通信,并发送请求给后者。而Docker Daemon作为Docker架构中的主体部分,首先提供Server的功能使其可以接受Docker Client的请求;而后Engine执行Docker内部的一系列工作,每一项工作都是以一个Job的形式的存在。Job的运行过程中,当需要容器镜像时,则从Docker Registry中下载镜像,并通过镜像管理驱动graphdriver将下载镜像以Graph的形式存储;当需要为Docker创建网络环境时,通过网络管理驱动networkdriver创建并配置Docker容器网络环境;当需要限制Docker容器运行资源或执行用户指令等操作时,则通过execdriver来完成。而libcontainer是一项独立的容器管理包,networkdriver以及execdriver都是通过libcontainer来实现具体对容器进行的操作。当执行完运行容器的命令后,一个实际的Docker容器就处于运行状态,该容器拥有独立的文件系统,独立并且安全的运行环境等。其实如果理解了这一张图,我们对docker的本质已经了如指掌了,下面我们一一分析:
2.1、Docker Client
Docker Client是Docker架构中用户用来和Docker Daemon建立通信的客户端。用户使用的可执行文件为docker,通过docker命令行工具可以发起众多管理container的请求。Docker Client可以通过以下三种方式和Docker Daemon建立通信:tcp://host:port,unix://pathtosocket和fd://socketfd。与此同时,与Docker Daemon建立连接并传输请求的时候,Docker Client可以通过设置命令行flag参数的形式设置安全传输层协议(TLS)的有关参数,保证传输的安全性。Docker Client发送容器管理请求后,由Docker Daemon接受并处理请求,当Docker Client接收到返回的请求相应并简单处理后,Docker Client一次完整的生命周期就结束了。当需要继续发送容器管理请求时,用户必须再次通过docker可执行文件创建Docker Client。
2.2、Docker Daemon
Docker Daemon是Docker架构中一个常驻在后台的系统进程,功能是接受并处理Docker Client发送的请求。该守护进程在后台启动了一个Server,Server负责接受Docker Client发送的请求;接受请求后,Server通过路由与分发调度,找到相应的Handler来执行请求。Docker Daemon启动所使用的可执行文件也为docker,与Docker Client启动所使用的可执行文件docker相同。在docker命令执行时,通过传入的参数来判别Docker Daemon与Docker Client。
Docker Daemon的架构,大致可以分为三部分:Docker Server、Engine和Job。
Docker Server在Docker架构中是专门服务于Docker Client的server。该server的功能是:接受并调度分发Docker Client发送的请求。在Docker的启动过程中,通过包gorilla/mux,创建了一个mux.Router,提供请求的路由功能。在Golang中,gorilla/mux是一个强大的URL路由器以及调度分发器。该mux.Router中添加了众多的路由项,每一个路由项由HTTP请求方法(PUT、POST、GET或DELETE)、URL、Handler三部分组成。若Docker Client通过HTTP的形式访问Docker Daemon,创建完mux.Router之后,Docker将Server的监听地址以及mux.Router作为参数,创建一个httpSrv=http.Server{},最终执行httpSrv.Serve()为请求服务。在Server的服务过程中,Server在listener上接受Docker Client的访问请求,并创建一个全新的goroutine来服务该请求。在goroutine中,首先读取请求内容,然后做解析工作,接着找到相应的路由项,随后调用相应的Handler来处理该请求,最后Handler处理完请求之后回复该请求。需要注意的是:Docker Server的运行在Docker的启动过程中,是靠一个名为”serveapi”的job的运行来完成的。原则上,Docker Server的运行是众多job中的一个,但是为了强调Docker Server的重要性以及为后续job服务的重要特性,将该”serveapi”的job单独抽离出来分析,理解为Docker Server。
Engine是Docker架构中的运行引擎,同时也Docker运行的核心模块。它扮演Docker container存储仓库的角色,并且通过执行job的方式来操纵管理这些容器。在Engine数据结构的设计与实现过程中,有一个handler对象。该handler对象存储的都是关于众多特定job的handler处理访问。举例说明,Engine的handler对象中有一项为:{“create”: daemon.ContainerCreate,},则说明当名为”create”的job在运行时,执行的是daemon.ContainerCreate的handler。
Job:一个Job可以认为是Docker架构中Engine内部最基本的工作执行单元。Docker可以做的每一项工作,都可以抽象为一个job。例如:在容器内部运行一个进程,这是一个job;创建一个新的容器,这是一个job,从Internet上下载一个文档,这是一个job;包括之前在Docker Server部分说过的,创建Server服务于HTTP的API,这也是一个job,等等。job的设计者,把Job设计得与Unix进程相仿。比如说:Job有一个名称,有参数,有环境变量,有标准的输入输出,有错误处理,有返回状态等。
2.3、Docker Registry
Docker Registry是一个存储容器镜像的仓库。而容器镜像是在容器被创建时,被加载用来初始化容器的文件架构与目录。在Docker的运行过程中,Docker Daemon会与Docker Registry通信,并实现搜索镜像、下载镜像、上传镜像三个功能,这三个功能对应的job名称分别为”search”,”pull” 与 “push”。其中,在Docker架构中,Docker可以使用公有的Docker Registry,即大家熟知的Docker Hub,如此一来,Docker获取容器镜像文件时,必须通过互联网访问Docker Hub;同时Docker也允许用户构建本地私有的Docker Registry,这样可以保证容器镜像的获取在内网完成。
2.4、Graph
Graph在Docker架构中扮演已下载容器镜像的保管者,以及已下载容器镜像之间关系的记录者。一方面,Graph存储着本地具有版本信息的文件系统镜像,另一方面也通过GraphDB记录着所有文件系统镜像彼此之间的关系。
其中,GraphDB是一个构建在SQLite之上的小型图数据库,实现了节点的命名以及节点之间关联关系的记录。它仅仅实现了大多数图数据库所拥有的一个小的子集,但是提供了简单的接口表示节点之间的关系。同时在Graph的本地目录中,关于每一个的容器镜像,具体存储的信息有:该容器镜像的元数据,容器镜像的大小信息,以及该容器镜像所代表的具体rootfs。
2.5、Driver
Driver是Docker架构中的驱动模块。通过Driver驱动,Docker可以实现对Docker容器执行环境的定制。由于Docker运行的生命周期中,并非用户所有的操作都是针对Docker容器的管理,另外还有关于Docker运行信息的获取,Graph的存储与记录等。因此,为了将Docker容器的管理从Docker Daemon内部业务逻辑中区分开来,设计了Driver层驱动来接管所有这部分请求。
在Docker Driver的实现中,可以分为以下三类驱动:graphdriver、networkdriver和execdriver。
graphdriver主要用于完成容器镜像的管理,包括存储与获取。即当用户需要下载指定的容器镜像时,graphdriver将容器镜像存储在本地的指定目录;同时当用户需要使用指定的容器镜像来创建容器的rootfs时,graphdriver从本地镜像存储目录中获取指定的容器镜像。
在graphdriver的初始化过程之前,有4种文件系统或类文件系统在其内部注册,它们分别是aufs、btrfs、vfs和devmapper。而Docker在初始化之时,通过获取系统环境变量"DOCKER_DRIVER”来提取所使用driver的指定类型。而之后所有的graph操作,都使用该driver来执行。
networkdriver的用途是完成Docker容器网络环境的配置,其中包括Docker启动时为Docker环境创建网桥;Docker容器创建时为其创建专属虚拟网卡设备;以及为Docker容器分配IP、端口并与宿主机做端口映射,设置容器防火墙策略等。
execdriver作为Docker容器的执行驱动,负责创建容器运行命名空间,负责容器资源使用的统计与限制,负责容器内部进程的真正运行等。在execdriver的实现过程中,原先可以使用LXC驱动调用LXC的接口,来操纵容器的配置以及生命周期,而现在execdriver默认使用native驱动,不依赖于LXC。具体体现在Daemon启动过程中加载的ExecDriverflag参数,该参数在配置文件已经被设为“native”。这可以认为是Docker在1.2版本上一个很大的改变,或者说Docker实现跨平台的一个先兆。
2.6、libcontainer
libcontainer是Docker架构中一个使用Go语言设计实现的库,设计初衷是希望该库可以不依靠任何依赖,直接访问内核中与容器相关的API。正是由于libcontainer的存在,Docker可以直接调用libcontainer,而最终操纵容器的namespace、cgroups、apparmor、网络设备以及防火墙规则等。这一系列操作的完成都不需要依赖LXC或者其他包。
另外,libcontainer提供了一整套标准的接口来满足上层对容器管理的需求。或者说,libcontainer屏蔽了Docker上层对容器的直接管理。又由于libcontainer使用Go这种跨平台的语言开发实现,且本身又可以被上层多种不同的编程语言访问,因此很难说,未来的Docker就一定会紧紧地和Linux捆绑在一起。而于此同时,Microsoft在其著名云计算平台Azure中,也添加了对Docker的支持,可见Docker的开放程度与业界的火热度。暂不谈Docker,由于libcontainer的功能以及其本身与系统的松耦合特性,很有可能会在其他以容器为原型的平台出现,同时也很有可能催生出云计算领域全新的项目。
2.7、Docker container
Docker container(Docker容器)是Docker架构中服务交付的最终体现形式。Docker按照用户的需求与指令,订制相应的Docker容器:
用户通过指定容器镜像,使得Docker容器可以自定义rootfs等文件系统;
用户通过指定计算资源的配额,使得Docker容器使用指定的计算资源;
用户通过配置网络及其安全策略,使得Docker容器拥有独立且安全的网络环境;
用户通过指定运行的命令,使得Docker容器执行指定的工作。
三、docker的工作过程
3.1、docker pull
docker pull命令的作用为:从Docker Registry中下载指定的容器镜像,并存储在本地的Graph中,以备后续创建Docker容器时的使用。
(1) Docker Client接受docker pull命令,解析完请求以及收集完请求参数之后,发送一个HTTP请求给Docker Server,HTTP请求方法为POST,请求URL为”/images/create? “+”xxx”; (2) Docker Server接受以上HTTP请求,并交给mux.Router,mux.Router通过URL以及请求方法来确定执行该请求的具体handler; (3) mux.Router将请求路由分发至相应的handler,具体为PostImagesCreate; (4) 在PostImageCreate这个handler之中,一个名为"pull”的job被创建,并开始执行; (5) 名为"pull”的job在执行过程中,执行pullRepository操作,即从Docker Registry中下载相应的一个或者多个image; (6) 名为"pull”的job将下载的image交给graphdriver; (7) graphdriver负责将image进行存储,一方创建graph对象,另一方面在GraphDB中记录image之间的关系。
3.2、docker run
docker run命令的作用是在一个全新的Docker容器内部运行一条指令。Docker在执行这条命令的时候,所做工作可以分为两部分:第一,创建Docker容器所需的rootfs;第二,创建容器的网络等运行环境,并真正运行用户指令。因此,在整个执行流程中,Docker Client给Docker Server发送了两次HTTP请求,第二次请求的发起取决于第一次请求的返回状态。
(1) Docker Client接受docker run命令,解析完请求以及收集完请求参数之后,发送一个HTTP请求给Docker Server,HTTP请求方法为POST,
请求URL为”/containers/create? “+”xxx”; (2) Docker Server接受以上HTTP请求,并交给mux.Router,mux.Router通过URL以及请求方法来确定执行该请求的具体handler; (3) mux.Router将请求路由分发至相应的handler,具体为PostContainersCreate; (4) 在PostImageCreate这个handler之中,一个名为“create”的job被创建,并开始让该job运行; (5) 名为"create”的job在运行过程中,执行Container.Create操作,该操作需要获取容器镜像来为Docker容器创建rootfs,即调用graphdriver; (6) graphdriver从Graph中获取创建Docker容器rootfs所需要的所有的镜像; (7) graphdriver将rootfs所有镜像,加载安装至Docker容器指定的文件目录下; (8) 若以上操作全部正常执行,没有返回错误或异常,则Docker Client收到Docker Server返回状态之后,发起第二次HTTP请求。请求方法为”POST”,
请求URL为”/containers/”+container_ID+”/start”; (9) Docker Server接受以上HTTP请求,并交给mux.Router,mux.Router通过URL以及请求方法来确定执行该请求的具体handler; (10) mux.Router将请求路由分发至相应的handler,具体为PostContainersStart; (11) 在PostContainersStart这个handler之中,名为”start”的job被创建,并开始执行; (12) 名为”start”的job执行完初步的配置工作后,开始配置与创建网络环境,调用networkdriver; (13) networkdriver需要为指定的Docker容器创建网络接口设备,并为其分配IP,port,以及设置防火墙规则,相应的操作转交至libcontainer中的netlink包来完成; (14) netlink完成Docker容器的网络环境配置与创建; (15) 返回至名为”start”的job,执行完一些辅助性操作后,job开始执行用户指令,调用execdriver; (16) execdriver被调用,初始化Docker容器内部的运行环境,如命名空间,资源控制与隔离,以及用户命令的执行,相应的操作转交至libcontainer来完成; (17) libcontainer被调用,完成Docker容器内部的运行环境初始化,并最终执行用户要求启动的命令。
四、docker的镜像
Image(镜像)是Docker术语的一种,代表一个只读的layer。而layer则具体代表Docker Container文件系统中可叠加的一部分。先让我们来认识一下与Docker镜像相关的4个概念:rootfs、Union mount、image以及layer。
4.1、rootfs
Rootfs:代表一个Docker Container在启动时(而非运行后)其内部进程可见的文件系统视角,或者是Docker Container的根目录。当然,该目录下含有Docker Container所需要的系统文件、工具、容器文件等。
传统来说,Linux操作系统内核启动时,内核首先会挂载一个只读(read-only)的rootfs,当系统检测其完整性之后,决定是否将其切换为读写(read-write)模式,或者最后在rootfs之上另行挂载一种文件系统并忽略rootfs。Docker架构下,依然沿用Linux中rootfs的思想。当Docker Daemon为Docker Container挂载rootfs的时候,与传统Linux内核类似,将其设定为只读(read-only)模式。在rootfs挂载完毕之后,和Linux内核不一样的是,Docker Daemon没有将Docker Container的文件系统设为读写(read-write)模式,而是利用Union mount的技术,在这个只读的rootfs之上再挂载一个读写(read-write)的文件系统,挂载时该读写(read-write)文件系统内空无一物。
举一个Ubuntu容器启动的例子。假设用户已经通过Docker Registry下拉了Ubuntu:14.04的镜像,并通过命令docker run –it ubuntu:14.04 /bin/bash将其启动运行。则Docker Daemon为其创建的rootfs以及容器可读写的文件系统为:
正如read-only和read-write的含义那样,该容器中的进程对rootfs中的内容只拥有读权限,对于read-write读写文件系统中的内容既拥有读权限也拥有写权限。通过观察可以发现:容器虽然只有一个文件系统,但该文件系统由“两层”组成,分别为读写文件系统和只读文件系统。这样的理解已然有些层级(layer)的意味,简单来讲,可以将Docker Container的文件系统分为两部分,Docker Daemon利用Union Mount的技术,将两者挂载。
4.2、Union mount
Union mount:代表一种文件系统挂载的方式,允许同一时刻多种文件系统挂载在一起,并以一种文件系统的形式,呈现多种文件系统内容合并后的目录。
一般情况下,通过某种文件系统挂载内容至挂载点的话,挂载点目录中原先的内容将会被隐藏。而Union mount则不会将挂载点目录中的内容隐藏,反而是将挂载点目录中的内容和被挂载的内容合并,并为合并后的内容提供一个统一独立的文件系统视角。通常来讲,被合并的文件系统中只有一个会以读写(read-write)模式挂载,而其他的文件系统的挂载模式均为只读(read-only)。实现这种Union mount技术的文件系统一般被称为Union Filesystem,较为常见的有UnionFS、AUFS、OverlayFS等。Docker实现容器文件系统Union mount时,提供多种具体的文件系统解决方案,如Docker早版本沿用至今的的AUFS,还有在docker 1.4.0版本中开始支持的OverlayFS等。更深入的了解Union mount,可以使用AUFS文件系统来进一步阐述上文中ubuntu:14.04容器文件系统的例子。如图所示:
AUFS等文件系统具有COW(copy-on-write)特性。COW文件系统和其他文件系统最大的区别就是:从不覆写已有文件系统中已有的内容。既然对用户而言,全然不知哪些内容只读,哪些内容可读写,这些信息只有内核在接管,那么假设用户需要更新其视角下的文件/etc/hosts,而该文件又恰巧是rootfs只读文件系统中的内容,内核是否会抛出异常或者驳回用户请求呢?答案是否定的。当此情形发生时,COW文件系统首先不会覆写read-only文件系统中的文件,即不会覆写rootfs中/etc/hosts,其次反而会将该文件拷贝至读写文件系统中,即拷贝至读写文件系统中的/etc/hosts,最后再对后者进行更新操作。如此一来,纵使rootfs与read-write filesystem中均由/etc/ hosts,诸如AUFS类型的COW文件系统也能保证用户视角中只能看到read-write filesystem中的/etc/hosts,即更新后的内容。
当然,这样的特性同样支持rootfs中文件的删除等其他操作。例如:用户通过apt-get软件包管理工具安装Golang,所有与Golang相关的内容都会被安装在读写文件系统中,而不会安装在rootfs。此时用户又希望通过apt-get软件包管理工具删除所有关于MySQL的内容,恰巧这部分内容又都存在于rootfs中时,删除操作执行时同样不会删除rootfs实际存在的MySQL,而是在read-write filesystem中删除该部分内容,导致最终rootfs中的MySQL对容器用户不可见,也不可访。
4.3、 image
Docker中rootfs的概念,起到容器文件系统中基石的作用。对于容器而言,其只读的特性,也是不难理解。神奇的是,实际情况下Docker的rootfs设计与实现比上文的描述还要精妙不少。继续以ubuntu 14.04为例,虽然通过AUFS可以实现rootfs与read-write filesystem的合并,但是考虑到rootfs自身接近200MB的磁盘大小,如果以这个rootfs的粒度来实现容器的创建与迁移等,是否会稍显笨重,同时也会大大降低镜像的灵活性。而且,若用户希望拥有一个ubuntu 14.10的rootfs,那么是否有必要创建一个全新的rootfs,毕竟ubuntu 14.10和ubuntu 14.04的rootfs中有很多一致的内容。
Docker中image的概念,非常巧妙的解决了以上的问题。最为简单的解释image,就是 Docker容器中只读文件系统rootfs的一部分。换言之,实际上Docker容器的rootfs可以由多个image来构成。多个image构成rootfs的方式依然沿用Union mount技术。
多个Image构成rootfs的示意图如下,rootfs中每一层image中的内容划分只为了阐述清楚rootfs由多个image构成,并不代表实际情况中rootfs中的内容划分:
从上图可以看出,举例的容器rootfs包含4个image,其中每个image中都有一些用户视角文件系统中的一部分内容。4个image处于层叠的关系,除了最底层的image,每一层的image都叠加在另一个image之上。另外,每一个image均含有一个image ID,用以唯一的标记该image。基于以上的概念,Docker Image中又抽象出两种概念:Parent Image以及Base Image。除了容器rootfs最底层的image,其余image都依赖于其底下的一个或多个image,而Docker中将下一层的image称为上一层image的Parent Image。以图2.3为例,imageID0是imageID1的Parent Image,imageID2是imageID3的Parent Image,而imageID_0没有Parent Image。对于最下层的image,即没有Parent Image的镜像,在Docker中习惯称之为Base Image。
通过image的形式,原先较为臃肿的rootfs被逐渐打散成轻便的多层。Image除了轻便的特性,同时还有上文提到的只读特性,如此一来,在不同的容器、不同的rootfs中image完全可以用来复用。多image组织关系与复用关系如图:
4.4、layer
Docker术语中,layer是一个与image含义较为相近的词。容器镜像的rootfs是容器只读的文件系统,rootfs又是由多个只读的image构成。于是,rootfs中每个只读的image都可以称为一层layer。除了只读的image之外,Docker Daemon在创建容器时会在容器的rootfs之上,再mount一层read-write filesystem,而这一层文件系统,也称为容器的一层layer,常被称为top layer。
因此,总结而言,Docker容器中的每一层只读的image,以及最上层可读写的文件系统,均被称为layer。如此一来,layer的范畴比image多了一层,即多包含了最上层的read-write filesystem。容器文件系统分为只读的rootfs,以及可读写的top layer,那么容器运行时若在top layer中写入了内容,那这些内容是否可以持久化,并且也被其它容器复用?答案是肯定的,Docker的设计理念中,top layer转变为image的行为(Docker中称为commit操作),大大释放了容器rootfs的灵活性。Docker的开发者完全可以基于某个镜像创建容器做开发工作,并且无论在开发周期的哪个时间点,都可以对容器进行commit,将所有top layer中的内容打包为一个image,构成一个新的镜像。Commit完毕之后,用户完全可以基于新的镜像,进行开发、分发、测试、部署等。不仅docker commit的原理如此,基于Dockerfile的docker build,其追核心的思想,也是不断将容器的top layer转化为image。
五、docker的container网络
5.1、docker container定义
应用程序在Docker Container内部的部署与运行非常便捷,只要有Dockerfile,应用一键式的部署运行绝对不是天方夜谭; Docker Container内运行的应用程序可以受到资源的控制与隔离,大大满足云计算时代应用的要求。其实,这很大一部分功能都需要归功于Linux内核。既然Docker Container内部可以运行进程,那么我们先来看Docker Container与进程的关系,或者容器与进程的关系。能否创建一个容器,而这个容器内部没有任何进程?答案是否定的。既然答案是否定的,那说明不可能先有容器,然后再有进程,那么问题又来了,“容器和进程是一起诞生,还是先有进程再有容器呢?”可以说答案是后者。阐述问题“容器是否可以脱离进程而存在”的原因前,相信大家对于以下的一段话不会持有异议:通过Docker创建出的一个Docker Container是一个容器,而这个容器提供了进程组隔离的运行环境。那么问题在于,容器到底是通过何种途径来实现进程组运行环境的“隔离”。这时,就轮到Linux内核技术隆重登场了。
说到运行环境的“隔离”,相信大家肯定对Linux的内核特性namespace和cgroup不会陌生。namespace主要负责命名空间的隔离,而cgroup主要负责资源使用的限制。其实,正是这两个神奇的内核特性联合使用,才保证了Docker Container的“隔离”。那么,namespace和cgroup又和进程有什么关系呢?
1、父进程通过fork创建子进程时,使用namespace技术,实现子进程与其他进程(包含父进程)的命名空间隔离;
2、子进程创建完毕之后,使用cgroup技术来处理子进程,实现进程的资源使用限制;
3、系统在子进程所处namespace内部,创建需要的隔离环境,如隔离的网络栈等;
4、namespace和cgroup两种技术都用上之后,进程所处的“隔离”环境才真正建立,这时“容器”才真正诞生!
从Linux内核的角度分析容器的诞生,精简的流程即如以上4步,而这4个步骤也恰好巧妙的阐述了namespace和cgroup这两种技术和进程的关系,以及进程与容器的关系。进程与容器的关系,自然是:容器不能脱离进程而存在,先有进程,后有容器。然而,大家往往会说到“使用Docker创建Docker Container(容器),然后在容器内部运行进程”。对此,从通俗易懂的角度来讲,这完全可以理解,因为“容器”一词的存在,本身就较为抽象。如果需要更为准确的表述,那么可以是:“使用Docker创建一个进程,为这个进程创建隔离的环境,这样的环境可以称为Docker Container(容器),然后再在容器内部运行用户应用进程。”对于Docker Container或者容器有了更加具体的认识之后,相信大家的眼球肯定会很快定位到namespace和cgroup这两种技术。Linux内核的这两种技术,竟然能起到如此重大的作用,不禁为止赞叹。那么下面我们就从Docker Container实现流程的角度简要介绍这两者。
首先讲述一下namespace在容器创建时的用法,首先从用户创建并启动容器开始。当用户创建并启动容器时,Docker Daemon 会fork出容器中的第一个进程A(暂且称为进程A,也就是Docker Daemon的子进程)。Docker Daemon执行fork时,在clone系统调用阶段会传入5个参数标志CLONENEWNS、CLONENEWUTS、CLONENEWIPC、CLONENEWPID和CLONE_NEWNET(目前Docker 1.2.0还没有完全支持user namespace)。Clone系统调用一旦传入了这些参数标志,子进程将不再与父进程共享相同的命名空间(namespace),而是由Linux为其创建新的命名空间(namespace),从而保证子进程与父进程使用隔离的环境。另外,如果子进程A再次fork出子进程B和C,而fork时没有传入相应的namespace参数标志,那么此时子进程B和C将会与A共享同一个命令空间(namespace)。如果Docker Daemon再次创建一个Docker Container,容器内第一个进程为D,而D又fork出子进程E和F,那么这三个进程也会处于另外一个新的namespace。两个容器的namespace均与Docker Daemon所在的namespace不同。Docker关于namespace的简易示意图如下:
再说起cgroup,大家都知道可以使用cgroup为进程组做资源的控制。与namespace不同的是,cgroup的使用并不是在创建容器内进程时完成的,而是在创建容器内进程之后再使用cgroup,使得容器进程处于资源控制的状态。换言之,cgroup的运用必须要等到容器内第一个进程被真正创建出来之后才能实现。当容器内进程被创建完毕,Docker Daemon可以获知容器内进程的PID信息,随后将该PID放置在cgroup文件系统的指定位置,做相应的资源限制。可以说Linux内核的namespace和cgroup技术,实现了资源的隔离与限制。那么对于这种隔离与受限的环境,是否还需要配置其他必需的资源呢。这回答案是肯定的,网络栈资源就是在此时为容器添加。当为容器进程创建完隔离的运行环境时,发现容器虽然已经处于一个隔离的网络环境(即新的network namespace),但是进程并没有独立的网络栈可以使用,如独立的网络接口设备等。此时,Docker Daemon会将Docker Container所需要的资源一一为其配备齐全。网络方面,则需要在用户指定的网络模式在,配置Docker Container相应的网络资源。
5.2、Docker Container的网络分析
Docker Container网络创建流程可以简化如下图:
Docker Container网络篇分析的主要内容有以下5部分:
1. Docker Container的网络模式; 2. Docker Client配置容器网络; 3. Docker Daemon创建容器网络流程; 4. execdriver网络执行流程; 5. libcontainer实现内核态网络配置。
Docker Container网络创建过程中,networkdriver模块使用并非是重点,故分析内容中不涉及networkdriver。需要强调的是,networkdriver在Docker中的作用:第一,为Docker Daemon创建网络环境的时候,初始化Docker Daemon的网络环境,比如创建docker0网桥等;第二,为Docker Container分配IP地址,为Docker Container做端口映射等。而与Docker Container网络创建有关的内容极少,只有在桥接模式下,为Docker Container的网络接口设备分配一个可用IP地址。
Docker可以为Docker Container创建隔离的网络环境,在隔离的网络环境下,Docker Container独立使用私有网络。其实,Docker除了可以为Docker Container创建隔离的网络环境之外,同样有能力为Docker Container创建共享的网络环境。换言之,当开发者需要Docker Container与宿主机或者其他容器网络隔离时,Docker可以满足这样的需求;而当开发者需要Docker Container与宿主机或者其他容器共享网络时,Docker同样可以满足这样的需求。另外,Docker还可以不为Docker Container创建网络环境。
总结Docker Container的网络,可以得出4种不同的模式:bridge桥接模式、host模式、other container模式和none模式。
5.2.1、bridge桥接模式
Docker Container的bridge桥接模式可以说是目前Docker开发者最常使用的网络模式。Brdige桥接模式为Docker Container创建独立的网络栈,保证容器内的进程组使用独立的网络环境,实现容器间、容器与宿主机之间的网络栈隔离。另外,Docker通过宿主机上的网桥(docker0)来连通容器内部的网络栈与宿主机的网络栈,实现容器与宿主机乃至外界的网络通信。
Docker Container的bridge桥接模式可以参考下图:
Bridge桥接模式的实现步骤主要如下:
1. Docker Daemon利用veth pair技术,在宿主机上创建两个虚拟网络接口设备,假设为veth0和veth1。而veth pair技术的特性可以保证无论哪一个veth接收到网络报文,
都会将报文传输给另一方。 2. Docker Daemon将veth0附加到Docker Daemon创建的docker0网桥上。保证宿主机的网络报文可以发往veth0; 3. Docker Daemon将veth1添加到Docker Container所属的namespace下,并被改名为eth0。
如此一来,保证宿主机的网络报文若发往veth0,则立即会被eth0接收,实现宿主机到Docker Container网络的联通性;
同时,也保证Docker Container单独使用eth0,实现容器网络环境的隔离性。
Bridge桥接模式,从原理上实现了Docker Container到宿主机乃至其他机器的网络连通性。然而,由于宿主机的IP地址与veth pair的 IP地址均不在同一个网段,故仅仅依靠veth pair和namespace的技术,还不足以是宿主机以外的网络主动发现Docker Container的存在。为了使得Docker Container可以让宿主机以外的世界感知到容器内部暴露的服务,Docker采用NAT(Network Address Translation,网络地址转换)的方式,让宿主机以外的世界可以主动将网络报文发送至容器内部。
具体来讲,当Docker Container需要暴露服务时,内部服务必须监听容器IP和端口号port0,以便外界主动发起访问请求。由于宿主机以外的世界,只知道宿主机eth0的网络地址,而并不知道Docker Container的IP地址,哪怕就算知道Docker Container的IP地址,从二层网络的角度来讲,外界也无法直接通过Docker Container的IP地址访问容器内部应用。因此,Docker使用NAT方法,将容器内部的服务监听的端口与宿主机的某一个端口port1进行“绑定”。
如此一来,外界访问Docker Container内部服务的流程为:
1 外界访问宿主机的IP以及宿主机的端口port_1; 2 当宿主机接收到这样的请求之后,由于DNAT规则的存在,会将该请求的目的IP(宿主机eth0的IP)和目的端口port1进行转换,转换为容器IP和容器的端口port0; 3 由于宿主机认识容器IP,故可以将请求发送给veth pair; 4 veth pair的veth0将请求发送至容器内部的eth0,最终交给内部服务进行处理。
使用DNAT方法,可以使得Docker宿主机以外的世界主动访问Docker Container内部服务。那么Docker Container如何访问宿主机以外的世界呢。以下简要分析Docker Container访问宿主机以外世界的流程:
1 Docker Container内部进程获悉宿主机以外服务的IP地址和端口port2,于是Docker Container发起请求。
容器的独立网络环境保证了请求中报文的源IP地址为容器IP(即容器内部eth0),另外Linux内核会自动为进程分配一个可用源端口(假设为port3); 2 请求通过容器内部eth0发送至veth pair的另一端,到达veth0,也就是到达了网桥(docker0)处; 3 docker0网桥开启了数据报转发功能(/proc/sys/net/ipv4/ip_forward),故将请求发送至宿主机的eth0处; 4 宿主机处理请求时,使用SNAT对请求进行源地址IP转换,即将请求中源地址IP(容器IP地址)转换为宿主机eth0的IP地址; 5 宿主机将经过SNAT转换后的报文通过请求的目的IP地址(宿主机以外世界的IP地址)发送至外界。
在这里,很多人肯定会问:对于Docker Container内部主动发起对外的网络请求,当请求到达宿主机进行SNAT处理后发给外界,当外界响应请求时,响应报文中的目的IP地址肯定是Docker宿主机的IP地址,那响应报文回到宿主机的时候,宿主机又是如何转给Docker Container的呢?关于这样的响应,由于port_3端口并没有在宿主机上做相应的DNAT转换,原则上不会被发送至容器内部。为什么说对于这样的响应,不会做DNAT转换呢。原因很简单,DNAT转换是针对容器内部服务监听的特定端口做的,该端口是供服务监听使用,而容器内部发起的请求报文中,源端口号肯定不会占用服务监听的端口,故容器内部发起请求的响应不会在宿主机上经过DNAT处理。
其实,这一环节的内容是由iptables规则来完成,具体的iptables规则如下:
iptables -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
这条规则的意思是,在宿主机上发往docker0网桥的网络数据报文,如果是该数据报文所处的连接已经建立的话,则无条件接受,并由Linux内核将其发送到原来的连接上,即回到Docker Container内部。
以上便是Docker Container中bridge桥接模式的简要介绍。可以说,bridger桥接模式从功能的角度实现了两个方面:第一,让容器拥有独立、隔离的网络栈;第二,让容器和宿主机以外的世界通过NAT建立通信。
然而,bridge桥接模式下的Docker Container在使用时,并非为开发者包办了一切。最明显的是,该模式下Docker Container不具有一个公有IP,即和宿主机的eth0不处于同一个网段。导致的结果是宿主机以外的世界不能直接和容器进行通信。虽然NAT模式经过中间处理实现了这一点,但是NAT模式仍然存在问题与不便,如:容器均需要在宿主机上竞争端口,容器内部服务的访问者需要使用服务发现获知服务的外部端口等。另外NAT模式由于是在三层网络上的实现手段,故肯定会影响网络的传输效率。
5.2.2、host模式
Docker Container中的host模式与bridge桥接模式有很大的不同。最大的区别当属,host模式并没有为容器创建一个隔离的网络环境。而之所以称之为host模式,是因为该模式下的Docker Container会和host宿主机共享同一个网络namespace,故Docker Container可以和宿主机一样,使用宿主机的eth0,实现和外界的通信。换言之,Docker Container的IP地址即为宿主机eth0的IP地址。
Docker Container的host网络模式可以参考下图:
上图最左侧的Docker Container,即采用了host网络模式,而其他两个Docker Container依然沿用brdige桥接模式,两种模式同时存在于宿主机上并不矛盾。Docker Container的host网络模式在实现过程中,由于不需要额外的网桥以及虚拟网卡,故不会涉及docker0以及veth pair。上文namespace的介绍中曾经提到,父进程在创建子进程时,如果不使用CLONENEWNET这个参数标志,那么创建出的子进程会与父进程共享同一个网络namespace。Docker就是采用了这个简单的原理,在创建进程启动容器的过程中,没有传入CLONENEWNET参数标志,实现Docker Container与宿主机共享同一个网络环境,即实现host网络模式。
可以说,Docker Container的网络模式中,host模式是bridge桥接模式很好的补充。采用host模式的Docker Container,可以直接使用宿主机的IP地址与外界进行通信,若宿主机的eth0是一个公有IP,那么容器也拥有这个公有IP。同时容器内服务的端口也可以使用宿主机的端口,无需额外进行NAT转换。当然,有这样的方便,肯定会损失部分其他的特性,最明显的是Docker Container网络环境隔离性的弱化,即容器不再拥有隔离、独立的网络栈。另外,使用host模式的Docker Container虽然可以让容器内部的服务和传统情况无差别、无改造的使用,但是由于网络隔离性的弱化,该容器会与宿主机共享竞争网络栈的使用;另外,容器内部将不再拥有所有的端口资源,原因是部分端口资源已经被宿主机本身的服务占用,还有部分端口已经用以bridge网络模式容器的端口映射。
5.2.3、other container模式
Docker Container的other container网络模式是Docker中一种较为特别的网络的模式。之所以称为“other container模式”,是因为这个模式下的Docker Container,会使用其他容器的网络环境。之所以称为“特别”,是因为这个模式下容器的网络隔离性会处于bridge桥接模式与host模式之间。Docker Container共享其他容器的网络环境,则至少这两个容器之间不存在网络隔离,而这两个容器又与宿主机以及除此之外其他的容器存在网络隔离。
Docker Container的other container网络模式可以参考下图:
上图右侧的Docker Container即采用了other container网络模式,它能使用的网络环境即为左侧Docker Container brdige桥接模式下的网络。Docker Container的other container网络模式在实现过程中,不涉及网桥,同样也不需要创建虚拟网卡veth pair。完成other container网络模式的创建只需要两个步骤:
1 查找other container(即需要被共享网络环境的容器)的网络namespace; 2 将新创建的Docker Container(也是需要共享其他网络的容器)的namespace,使用other container的namespace。
Docker Container的other container网络模式,可以用来更好的服务于容器间的通信。在这种模式下的Docker Container可以通过localhost来访问namespace下的其他容器,传输效率较高。虽然多个容器共享网络环境,但是多个容器形成的整体依然与宿主机以及其他容器形成网络隔离。另外,这种模式还节约了一定数量的网络资源。但是需要注意的是,它并没有改善容器与宿主机以外世界通信的情况。
5.2.4、none模式
Docker Container的第四种网络模式是none模式。顾名思义,网络环境为none,即不为Docker Container任何的网络环境。一旦Docker Container采用了none网络模式,那么容器内部就只能使用loopback网络设备,不会再有其他的网络资源。可以说none模式为Docker Container做了极少的网络设定,但是俗话说得好“少即是多”,在没有网络配置的情况下,作为Docker开发者,才能在这基础做其他无限多可能的网络定制开发。这也恰巧体现了Docker设计理念的开放。
补充:127.0.0.1和localhost和本机IP三者的区别:
1、什么是环回地址?与127.0.0.1有什么区别?
环回地址(loopback)是主机用于向自身发送通信的一个特殊地址(也就是一个特殊的目的地址)。可以这么说,同一台主机上的两项服务若使用环回地址而非分配的主机地址,就可以绕开TCP/IP协议栈的下层。不用再通过什么链路层,物理层,以太网传出去了,而是可以直接在自己的网络层,运输层进行处理了。IPv4的环回地址为:127.0.0.1到127.255.255.254(127.0.0.0和127.255.255.255特殊保留)都是环回地址,此地址中的任何地址都不会出现在网络中。所以说127.0.0.1是保留地址之一,只是被经常的使用,来检验本机TCP/IP协议栈而已。如果我们可以ping通的话,就说明:本机的网卡和IP协议安装都没有问题。
2、localhost首先是一个域名,也是本机地址,它可以被配置为任意的IP地址(也就是说,可以通过hosts这个文件进行更改的),不过通常情况下都指向:
IPv4:表示 127.0.0.1
IPv6:表示 [::1]
整个127.*网段通常被用作loopback网络接口的默认地址,按照惯例通常设置为127.0.0.1。我们当前这个主机上的这个地址,别人不能访问,即使访问,也是访问自己。因为每一台TCP/IP协议栈的设备基本上都有localhost/127.0.0.1
3、本机IP,我们可以理解为本机有三块网卡,一块网卡叫做loopback(虚拟网卡),一块叫做ethernet(有线网卡),一块叫做wlan(无线网卡)。
六、总结
文章从docker的整个架构,镜像,容器,网络栈的角度对docker进行了分析,让我们了解了docker的基本工作原理以及使用的相关的技术,封装的思想,分层的思想使得docker具有灵活的可移植性,使用起来非常的方便,由此可见一件产品能够有特色的最重要的原因是能够将一些当前做得不够好的部分通过巧妙的方式简化,从而构建出来更加精妙的东西。
参考文献:
http://open.daocloud.io/docker-source-code-analysis-part1/
http://open.daocloud.io/docker-source-code-analysis-part7-first/