1.2 Docker核心原理
➤➤1.2.2 Docker服务流程
Docker 服务运行的流程如图 1-2 所示。Docker 客户端与 Docker 守护进程通信,Docker守护进程负责构建、运行和分发Docker容器。Docker客户端和守护进程可以在同一个系统上运行,也可以将Docker客户端连接到远程Docker守护进程。Docker客户端和守护进程使用REST API通过UNIX套接字或网络接口进行通信。
图1-2 Docker服务运行的流程
➤➤1.2.3 Docker核心技术
前文提到 Docker 主要是使用了一些已有的技术实现的,主要的核心技术是 Cgroup+Namespaces和UnionFS。
1.Namespaces
命名空间(Namespaces)是 Linux 系统提供的一种内核级别的环境隔离方法,Docker 就是利用的这种技术来实现容器环境隔离的。Linux Namespace提供了对UTS、IPC、mount、PID、network、User等的隔离机制。
(1)UTS Namespace
UTS Namespace主要是用来隔离hostname和domainname两个系统标识的,可以通过Go语言创建一个UTS Namespace,如下所示。
上述代码需要在Root权限下执行。在编译并执行后进入新的UTS Namespace。
当前PID为20011,再通过pstree查看其进程树,可知其父进程是20006。下面验证是否在同一个UTS Namespace下。
可以看到当前进程和父进程不在一个UTS Namespace下,父进程和1号进程在同一个UTS Namespace下,说明当前UTS Namespace是新创建的。下面测试UTS Namespace对hostname的隔离功能。
可以看到,在新的UTS Namespace下先修改hostname为docker,查看修改成功,退出当前Namespace,或者打开一个新的终端,查看hostname还是原来的hostname,外部的hostname并没有受到影响。由此可以看到UTS Namespace的隔离作用。
(2)IPC Namespace
IPC Namespace主要提供了进程间通信的隔离能力。同样可以用Go语言实现IPC Namespace的创建。代码与创建 UTS Namespace 的基本相同,只要把标识符 CLONE_NEWUTS 换成CLONE_NEWIPC即可。
同样,编译后在Root环境下启动后就可以进入新的IPC Namespace。在此Namespace下,可以查看IPC Namespace与进程1的IPC Namespace不同,说明是新创建的IPC Namespace。
此时,在此被隔离的ipc namespace中创建一个消息队列。
在新建的IPC Namespace中已经有了一条消息队列0x9ea09b5b。另外再起一个终端,确认是否能够看到刚创建的消息队列。
确实无法看到刚创建的消息队列,说明消息队列确实被IPC Namespace隔离起来了。
(3)PID Namespace
PID Namespace用来隔离进程。同样的进程在不同的PID Namespace下拥有不同的PID,通过代码创建一个新的PID Namespace,同样只需要把UTS Namespace的代码做少量修改,把标识符修改为CLONE_NEWPID即可。
依旧编译后在Root下启动,通过命令查看在新的PID Namespace下此进程的PID,如下所示。
如果使用ps命令查看,看到的还是所有的进程,因为/proc文件系统(procfs)没有挂载到与原/proc不同的位置。如果只想看到PID Namespace本身应该看到的进程,则需要重新挂载/proc,如下所示。
可以看到在PID Namespace中只有bash和ps进程。进程通过Namespace隔离,需要注意的是,由于没有进行Mount Namespace的隔离,当退出当前Namespace再执行ps命令时,系统会报错,需要将/proc目录重新“mount”回去。
(4)Network Namespace
Network Namespace在Docker中被用来隔离网络。Network Namespace可以让每个容器拥有自己的网络设备、端口、IP地址等。因为其网络是完全隔离的,所以每个Namespace下的端口不会产生任何冲突。既然完全隔离了,容器与外部之间需要通信该怎么办?在Docker中可以通过虚拟网桥来实现。在Linux系统中,可以直接通过命令创建一个Network Namespace。当然,为了与上文保持一致,仍然通过代码来实现。代码实现方式与上文几个 Namespace 一样,只是把标识符改为CLONE_NEWNET,然后将代码编译并执行,通过ifconfig命令查看其Namespace下的网络设备,如下所示。
可以看到里面并没有任何的网络设备。然后在宿主机中查看宿主机的网络设备,可见Network Namespace网络隔离成功。
(5)Mount Namespace
Mount Namespace用来隔离各个进程看到的挂载点视图。在不同Namespace中的进程看到的文件系统层次是不一样的,不同的 Namespace 进行 mount 和 umount 操作只会对自己的Namespace内的文件系统产生影响,对其他的Namespace没有影响。
由于没有进行 Mount Namespace,所以退出 PID Namespace 时才会报错。所以在 PID Namespace的基础上再加上Mount Namespace进行测试。这个可以直接在PID Namespace的代码基础上再加上Mount Namespace的标识符。因为Mount Namespace是Linux 实现的第一个Namesapce 类型,其标识符比较特殊,是CLONE_NEWNS,代码如下:
测试方式是在Root下编译并执行的,在此Namespace下执行挂载/proc并执行ps命令,如下所示。
另起一终端,执行如下 ps 命令,可见两个不同的 Namespace 下的/proc 并不一样,说明mount已经隔离成功,在新的Namespace下的mount操作并没有影响到外部的Namespace下的系统文件。
(6)User Namespace
User Namespace 可以用来隔离用户的用户组ID。在不同的User Namespace下进程的User ID和Group ID是不同的。同样通过Go语言来实现创建一个新的User Namespace,代码与上面的基本相同,修改一个标识符为CLONE_NEWUSER即可。
首先在Root环境下查看宿主机当前的用户和用户组:
可以看到是root 用户。运行一下程序,创建新的User Namespace,在新的Namespace下查看用户和用户组。
2.Cgroups
(1)什么是Cgroups
Cgroups是Linux系统中提供的对一组进程及其子进程进行资源(CPU、内存、存储和网络等)限制、控制和统计的能力。Cgroup可以直接通过操作Cgroup文件系统的方式完成使用。例如,使用mount-t cgroup cgroup/cgroup命令进行操作,此时就会在/cgroup下生成很多默认的文件,这就创建一个Cgroup,在这个目录下每创建一个目录就表示创建了一个子Cgroup。进入子目录会发现里面会生成一些文件与上层 Cgroup 即/cgroup 目录内容大致相同。这就是Cgroup文件系统的树形层次结构。
创建完Cgroup之后,可以为其分配可用的资源并将不同的进程放进去。当创建完第一个Cgroup时,系统会把所有的进程都放到主Cgroup中,可以查看Cgroup中的tasks文件来查看此 Cgroup 中的进程 PID;同样可以通过在 tasks 中添加对应的进程 PID,会把该进程放入该Cgroup中。但需要注意,如果在子Cgroup中添加一个进程,则子Cgroup的上层Cgroup中的tasks文件中也会有这个PID,因为子Cgroup属于上层Cgroup,所以子Cgroup中的进程也同时会属于上层Cgroup,但是同一层级的Cgroup却不能同时拥有同一个进程。比如A和BCgroup同属于C的子Cgroup,那么A和B就不能同时拥有同一个进程。至于每个Cgroup中的资源配置量都是通过设置当前Cgroup的子系统来配置的。
Cgroups为不同的资源定义了各自的Cgroup 子系统,来实现对资源的具体管理。Cgroup实现的子系统及其实现的功能如下。
➢ devices:设备权限控制。
➢ cpuset:分配指定的CPU和内存节点。
➢ cpu:控制CPU占用率。
➢ cpuacct:统计CPU的使用情况。
➢ memory:限制内存的使用上限。
➢ freezer:暂停Cgroup中的进程。
➢ net_cls:配合traffic controller限制网络带宽。
➢ net_prio:可以动态控制每个网卡流量的优先级。
➢ blkio:限制进程的块设备 I/O。
➢ ns:使不同Cgroups中的进程使用不同的Namespace。
(2)Cgroups的使用
前文提到可以使用mount-t cgroup cgroup/cgroup命令创建cgroups,其中可以通过-o参数添加子系统,如命令mount-t cgroup-o cpu、cpuset、memory cgroup/cgroup。这个命令表示创建了一个名为Cgroup的层级,并附加了cpu、cpuset、memory三个子系统,并把层级挂载到/cgroup目录上。但实际执行命令时会报出already mounted错误,且执行不成功。这是因为该命令一般在Linux发行版启动时就已经执行了,对应的子系统的Cgroup已经被创建并挂载了。并且虽然cgroupfs可以挂载在任意目录中,但是标准挂载点是/sys/fs/cgroup目录并且在启动时已经挂载上了,所以一般并不需要执行该命令。由于系统的/sys/fs/cgroup目录已经挂载了各种cgroupfs,可以直接在该Cgroup上进行操作。
首先查看/sys/fs/cgroup,如下所示。
可以看到/sys/fs/cgroup 目录下有很多子目录,分别对应拥有对应子系统的 Cgroup,以cpuset为例,查看cpuset目录,如下所示。
可以看到里面有很多的控制文件,其中以cpuset开头的是cpuset子系统产生的,剩下的是由Cgroup产生的。前文已经提到过默认所有进程的PID都是在Cgroup的根目录的tasks文件中,通过mkdir创建一个childA目录,就创建了一个子Cgroup,如下所示。
接着进入childA目录对该子Cgroup进行配置,可以通过修改文件的方式进行配置,如下所示。
两个命令分别表示限制Cgroup里的进程只能在0号CPU上运行,并只会从0号内存节点分配内存。接下来是给Cgroup分配进程,上文也已经提到通过修改tasks的方式把进程添加到当前Cgroup中,如下所示。
上面的命令表示把当前进程添加到Cgroup中,其中$$变量表示当前进程的PID。这时进程的所有子进程也会被自动地添加到Cgroup中,并受到该Cgoup资源的限制。
3.UnionFS
(1)什么是UnionFS
UnionFS(联合文件系统)是把不同物理位置的目录合并到同一个目录中的文件系统服务。其早期是应用在LiveCD领域的,通过联合文件系统可以非常快速地引导系统初始化或检测磁盘等硬件资源。这是因为只需要把CD只读挂载到特定目录,然后在其上附加一层可读写的文件层,对文件的任何变动修改都会被添加到新的文件层内,这种技术被称为写时复制。
写时复制是一种可以有效节约资源的技术,它被很好地应用在Docker镜像上。其思想是如果有一个资源需要被重复利用,在没有任何修改的情况下,新旧实例会共享资源,并不需要进行复制,如果有实例需要对资源进行任何的修改,并不会直接修改资源,而是会创建一个新的资源并在其上进行修改,这样原来的资源并不会进行任何修改,而是与新创建的资源结合在外,表现为修改后的资源状态。这样做可以显著地减轻对未修改资源的复制而带来的资源消耗问题。下面通过讲解在Docker中如何使用UnionFS更深入地理解写时复制。
Docker支持的第一种UnionFS是AUFS,下面主要从镜像层和容器层两个方面介绍AUFS在Docker中的使用。
(2)AUFS在Images中的使用
Docker镜像是由一层层的只读层组合而成的。镜像层的内容存储在/var/lib/docker/aufs/diff目录下,在/var/lib/docker/aufs/layers目录下则存放着对应的metadata,描述镜像需要的层。
拉取一个ubuntu:latest镜像,看到ubuntu:latest镜像是分为5层的,可以在/var/lib/docker/aufs/diff目录中确认。
可以看到在本地没有别的镜像的情况下,目录中确实有5层,而这5层是如何组合成镜像的呢?来看/var/lib/docker/aufs/layers中的文件:
由于层ID太长,截取前5个字符表示一下,可以看出e85c0文件中包含了剩下的所有的层,说明其是在文件的最上层依赖于下面的所有层;而824dc文件是空的所以是最基础、最底层的,所以整个镜像文件从最高层到最底层依次是e85c0→47c4e→ed7b3→66915→824dc,再分别查看各目录的文件,如下所示。
根据上面各层的文件目录,可以推断出,在build ubuntu:latest的镜像过程中,最近的一次文件的修改是 run 目录下的,按时间顺序镜像推算,修改的文件所属目录为 bin,boot 等->etc,sbin,usr,var->var->etc->run。具体是不是这样呢?可以验证一下,如下是 ubuntu 镜像的Dockerfile。
从Dockerfile可以看到在制作ubuntu镜像的过程中共执行了5次文件修改命令,也可以看到该镜像是由5层组成的,每一层正好对应一次的文件修改。按照顺序文件,修改得越早,产生越早越在底层。由于在ADD添加一个压缩文件时会自动解压为目录,所以最开始修改的文件是bin、boot等,对应的是第一个ADD中添加的压缩文件ubuntu-artful-core-cloudimg-amd64-root.tar.gz解压出来的目录。再看第二条命令中一共修改了/usr、/sbin、/etc、/var这些目录文件,正好和第二层修改的文件目录相同,剩下的每一层的目录也正好和Dockerfile中修改的文件目录一一对应。由此可以确定,在Build镜像中对文件的每一次修改都会增加一个文件层包含着对应修改后的文件。而当进行删除文件操作时,也会创建一个新的层,在新层里创建一个特殊名称隐藏文件,这样的一个隐藏文件对应着一个文件的删除。
下面在ubuntu:latest镜像的基础上创建一个新的镜像test,首先创建一个test文件。然后编辑Dockerfile,生成镜像。
test镜像已经创建成功,进入/var/lib/docker/aufs目录下查看镜像层的变化。
可以看到只增加了一层,多了一个test文件,前4层被ubuntu和test两个镜像复用了,从而减少了磁盘的空间占用。
(3)AUFS在Container中的使用
AUFS在容器中的使用和在镜像中略有不同。在镜像中一个文件进行了改动,需要将整个文件进行复制,这样会对容器的性能产生一定的影响。在容器中,每一个镜像层最多只需要复制一次,后续的改动都会在复制的容器层上面进行,不需要再复制生成新的容器层。
首先在服务器上启动一个容器,查看文件层发生了哪些变化,如下所示。
发现多了两个文件层,其中的init文件层中主要存放与容器内环境相关的内容,这是一个只读文件层,另外一个文件层是一个可读写文件层,用来存储容器的写操作。当容器内部文件发生任何改变时,改变后的文件就会存储在这层文件系统中,当容器停止时它们仍然是存在的,只有容器被删除时才会删除这两个文件层。
同时还有一个/var/lib/docker/aufs/mnt 目录用来存放容器的 mount 目录,与/var/lib/docker/aufs/diff目录内容保持一致,当容器停止运行时,这些目录仍然是存在的,但是目录里面是空的,因为AUFS只在容器运行时才会把/var/lib/docker/aufs/diff中的内容映射过来。