《基于Kubernetes的容器云平台实战》——1.4.2 Docker原理
1.4.2 Docker原理
Docker是基于操作系统虚拟化技术实现的,这一点与基于硬件虚拟化的各种虚拟机产品有很大不同,但是两者所要解决的问题在很多方面却有相似之处,比如,都希望为应用提供一个虚拟、完整和独占的运行环境,都希望能够提高这些虚拟环境之间的隔离度,也都希望能够从管理的角度对这些虚拟环境占用的宿主机资源进行方便的管控。在解释为何使用Docker的时候已经提到,Docker解决这些问题的途径是在共享宿主机内核的基础上包装内核提供的一系列API。具体到Linux操作系统上,内核所提供的相关API涉及很多方面,其中被大家关注得最多也是最主要的是CGroup和namespace。另外,在实现分层镜像和容器的根文件系统视图时,Docker使用了与虚拟文件系统相关的挂载和绑定挂载API,这些都不是新内容,只是Docker这类容器化产品将它们进行包装并推动了操作系统已有虚拟化能力的大众化而已。
下面就对这几个Docker中应用到的主要技术分别进行简单的介绍。
CGroup是Control Group的缩写,它是Linux中的特有功能,首先由Google公司提交的内核补丁实现。顾名思义,这个功能与控制、分组有关。其实,如果单从功能角度来看的话,也许叫做Group Control更好一些。这主要是因为该功能的核心在于对进程进行层次化分组,并能够以每个组的粒度实现诸如资源限制和策略控制功能,而这正是前面提到的虚拟化目标的基础。每一个虚拟环境(或者称之为“容器”)中的应用进程,以及后续加入该环境中的其他进程都应该受到同样的策略管控,这是很容易理解的;但是在没有这个基础设施之前,单从已有的操作系统功能上看是无法实现的。而有了这种分层的分组功能之后,不仅是容器运行时,包括系统管理员,也可以方便地实现对容器中进程占用的CPU、内存等资源的限制。而这种控制功能,在CGroup中是由很多控制器来完成的,主要的控制器包括blkio、cpu、cpuacct、cpuset、devices、freezer、hugetlb、memory、net_cls、net_prio和perf_event等。
这些控制器和不同的进程组之间的关系在第一个版本的CGroup实现中是很灵活的。每个控制器可以对应到单独的一个组,以及由该组的子组所构成的所谓“层次结构”。这也是目前Docker所支持的实现方式。但是这种实现被认为过于复杂,带来了很多技术上的障碍,因此在第二个版本的实现中将这种“层次结构”限定为只有一个。但是版本二的CGroup功能直到在4.15版本内核中才完整实现,目前可能还不成熟。
不管是哪一个版本的CGroup实现,对进程分组的控制都是通过一个虚拟文件系统下的目录节点和其中的节点文件来实现的,而各种控制器功能也是通过一些节点文件来实现。整个CGroup功能模块的对外接口就是通过这个虚拟文件系统来暴露。在使用了systemd作为init的Linux系统上,这个CGroup虚拟文件系统的挂接点路径为/sys/fs/cgroup。在下面的目录列表中,cpu和cpuacct是指向“cpu,cpuacct”的符号链接,systemd目录节点是systemd自己创建的一个“层次结构”,与任何控制器无关,只利用到CGroup核心的进程分组管理功能。其他目录节点和其下的子节点基本上每个都对应着一种控制器。下面是systemd管理下默认的CGroup层次结构的根列表。
# ls -p /sys/fs/cgroup
blkio/ cpu cpuacct cpu,cpuacct/ cpuset/ devices/ freezer/ hugetlb/ memory/ net_cls/ perf_event/ systemd/
默认情况下,Docker在这每个层次结构目录下都创建了自己的节点子目录,然后再为每个容器创建以容器ID为名称的节点目录,将所有容器相关的进程都管理在这个“层次结构”中。比如devices控制器是用来限定进程组对设备文件的访问权限的,它对应的层次结构的顶层节点文件列表如下:
# ls -p /sys/fs/cgroup/devices
cgroup.clone_children cgroup.procs devices.allow devices.list devices.deny release_agent notify_on_release cgroup.event_control cgroup.sane_behavior tasks docker/ system.slice/ user.slice/
其中与device控制器相关的节点文件以“devices.”开头,如devices.allow和devices.deny,按照一定格式写入设备号和权限控制串就能够实现设备访问控制,Docker中是以白名单方式来完成控制的。其他几个文件和CGroup的进程组管理功能有关:tasks,可以向其写入线程ID,将此线程纳入该层次结构内;cgroup.procs,可以向其写入线程组ID,将线程组纳入层次结构内;notify_on_release,是控制当前组销毁时是否调用release_agent中登记的程序的开关;release_agent,用于登记触发程序,但是只有顶层才有此文件;cgroup.event_control,用于通过eventfd系统调用在用户程序中得到控制器的事件通知;cgroup.clone_children,只用于cpuset控制器,控制子组是否在创建时复制父控制器的配置;cgroup.sane_behavior是一个过渡性文件,与是否启用第二版CGroup功能有关。只要一个进程被纳入某个控制组,那么在后续的fork调用时,新创建的进程仍然在此控制组中,这样就从进程的角度确立了容器的边界。
Docker在每个层次结构中都创建了自己的节点目录,该节点目录下也有与顶层类似的节点文件,而每个容器在默认情况下都有自己的子节点目录,其下同样有该层次的对应节点文件。顶层中除了docker节点目录外的另外两个节点目录是由systemd创建的,用于管理宿主机上的服务和用户会话。
# ls -p /sys/fs/cgroup/devices/docker
b61e023569ba3a0.../ tasks cgroup.procs notify_on_release cgroup.event_control devices.allow devices.list cgroup.clone_children devices.deny
其他控制器与devices控制器一样,也有自己的节点文件,限于篇幅,这里不再一一罗列,只简单说明一下前面第一个列表中显示出的各个控制器的主要功能:cpu,用于限制组中进程的CPU使用量;cpuacct,用于对组中进程的CPU使用进行计量;cpuset,用于限定组中进程可用的CPU和NUMA类型内存节点;memory,用于限定和报告组中进程的内存使用量;blkio,用于限制组中进程对块设备的I/O操作;freezer,用于挂起或者解冻组和子组中所有进程;net_cls,用于设置组中进程发送的网络数据包的类别ID;net_prio,用于设置与该组相关网络数据包的优先级;perf_event,用于控制是否对组中进程执行perf监控;hugetlb,用于限制和报告组中进程对大页面的使用量。
从上面的介绍中可以看出,CGroup功能划定了容器的进程组边界,并且也能够执行一定资源限制功能,但是组中的进程仍然可以看到与宿主机上进程相同的东西,也就是这些组之间还没有相互“隔离”。能够支持“隔离”功能的是内核中的命名空间(namespace)特性。
已经被Docker所利用的Linux内核的命名空间特性包括:
1)PID namespace:每当在此命名空间中启动一个程序,内核就为其分配一个唯一的ID,它与从宿主机中所见的不同。每个容器中的进程都有自己单独的进程ID空间。
2)MNT namespace:每个容器都有自己的目录挂载路径的命名空间。
3)NET namespace:每个容器都有自己单独的网络栈,其中的socket和网卡设备都是其他容器不能访问的。
4)UTS namespace:在此命名空间中的进程拥有自己的主机名和NIS域名。
5)IPC namespace:只有在相同的IPC命名空间中的进程才可以利用共享内存、信号量和消息队列相互通信。
6)User namespace:用户命名空间被内核用于隔离容器中用户ID、组ID以及根目录、key和capabilities,用户还能通过配置来映射宿主机和容器中的用户ID和组ID。
与CGroup的实现不同,该特性的实现不是通过虚拟文件系统接口,而是通过实际的系统调用完成的。与此特性相关的系统调用有三个:clone(fork函数实际是通过该系统调用实现的),在创建进程的同时根据参数中的标记为其新建命名空间,而没有新建标记时,子进程自动继承父进程的命名空间,这样由命名空间构建的沙箱边界在应用中得到保持;setns,用于加入一个已经存在的命名空间;unshare,根据参数标记为调用进程新建命名空间,并将调用进程加入此空间中。由这些系统调用创建出的命名空间构成树形结构,宿主机初始命名空间是根,新建的MNT和UTS命名空间复制了父命名空间的内容。对于setns调用来说,无疑需要一个已经存在的命名空间的标识,而这是通过打开/proc/<pid>/ns/目录下的符号链接文件来得到的。每个内核支持的命名空间在此目录下有对应的文件,文件名是确定的,如mnt、net、pid等。在Docker中不同容器共享某个命名空间正是通过打开这种文件来实现的。
这些调用都是与进程相关的,那么命名空间的生命周期是否完全与创建它的进程绑定呢?其实只要将/proc/<pid>/ns/目录下的符号链接文件绑定挂载到另一个目录下,那么即便该命名空间中的所有进程都已经销毁,该命名空间还将继续存在。Docker中创建的网络命名空间也使用到这个特性。
上面提到的各种命名空间引入内核的时间并不相同,其中User namespace不仅引入最迟,并且直到4.15版本内核还在进行较大功能改进。该特性与安全控制相关,不仅语义复杂,而且还需借助一些特殊机制来减少漏洞,如User ID和Group ID映射。在创建用户命名空间时可以对/proc/<pid>/目录下uid_map和gid_map文件执行一次写操作,写入内容是新命名空间中用户或者组ID在父命名空间中的映射ID,以起始ID、起始映射ID加ID段长度的格式写入,4.15版本内核之前只能写入5行,4.15版本内核开始支持340行。这个写操作只能执行一次。没有被这些映射规则覆盖的UID和GID自动参考/proc/sys/kernel/下overflowuid和overflowgid中的值。当创建文件时以映射后的用户和组ID来执行权限检查。
新建的网络命名空间中除了loopback设备之外没有任何网卡设备、路由表等资源,需要专门为其进行配置。一个网卡设备只能归属于一个网络命名空间,但是像veth这种虚拟网络设备则可以提供类似于管道的功能,以沟通不同的网络命名空间。
虽然新建的MNT命名空间自动复制了父命名空间的全部内容,但是在容器创建过程中,容器运行时首先根据镜像和配置参数为容器准备好一个根文件系统的目录树,然后执行pivot_root系统调用将新命名空间中的根文件系统切换到这个目录树上,再执行umount调用卸载原有内容,最终为容器环境准备好一个隔离的文件系统视图。
接下来需要特别提到的是,Docker容器镜像的构建基于一系列的镜像层,这些镜像本身是只读的,在容器运行时根据镜像创建容器时才为每个容器提供相互独立的读写层。Docker引擎提供的容器镜像构建工具还可以将某个镜像作为基础镜像来创建新镜像,当然这是分层技术的一个合理扩展。从读取的角度看,虚拟文件系统将多层镜像中的目录结构汇聚起来向用户提供单一的视图,并且上层文件覆盖下层;而从写入角度看,实现这种镜像分层的关键在于COW(Copy On Write)技术,简单地说,就是虚拟文件系统在用户更改某个文件时,才将原本共享的只读内容复制到读写层,供用户操作。这种通过堆栈式的只读镜像层来创建容器的方式带来的明显好处就是,在存储和传输镜像的时候,许多已经被缓存的共享镜像层就可以通过引用的形式来标注,无需再占用存储空间和网络带宽了。这种对资源的有效利用可以帮助Docker引擎更快地下载镜像,也能够帮助管理员在同一台设备上规划更多的应用容器。
对于容器的运行来说,有时仅仅提供隔离性是不够的。比如上面已经提到的CGroup技术,对它的管理是在宿主机上的/sys/fs/cgroup路径下某些节点中进行的,但是新建了MNT命名空间之后,这些节点路径和容器文件系统视图中的节点如何关联呢?这就不能不提到Linux下mount系统调用中的绑定挂载功能。
在Linux下可以采用绑定挂载的方式将一个文件或者目录绑定到某个挂载点上,使得在那个挂载点上可以看到这个文件或者目录的内容。并且这种绑定处理的效果可以跨越文件系统和命名空间的边界。显然,容器运行时正是利用了这个技术将容器内外环境拼接起来。另外,这种绑定挂载功能设计中支持单向和双向的共享设定,还能够进行递归性质和多级模式的设定,这些设定被称为传播模式。目前的容器运行时接口上已经能够完整支持这些参数设定。
在Docker中得到运用的不仅包括上述这些技术,还集成了Capabilities设置、Seccomp、SELinux/AppArmor等其他与安全控制相关的技术,它们都是操作系统通过一系列分离的API实现的,它们被Docker引擎通过标准化的配置和对外管理接口集成起来,为用户提供了一整套方便使用、持续演进的容器化应用构建、发布和运行工具。
- 点赞
- 收藏
- 关注作者
评论(0)