《深入剖析Kubernetes》-Docker的实现原理

Posted by 瞿广 on Thursday, January 17, 2019
Last Modified on Friday, January 18, 2019

TOC

容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造一个“边界”

Docker容器具有以下3个特点:

  • 轻量级:在同一台宿主机上的容器共享系统Kernel,这使得他们可以迅速启动而且占有的内存极少。镜像是以分层文件系统构造的,这可以让它们共享相同的文件,使得磁盘使用率和镜像下载速度得到提高。
  • 开放:Docker容器基于开放标准,这使得Docker容器可以运行在主流Linux发行版和Windows操作系统上。
  • 安全:容器将各个应用程序隔离开来,这给所有的应用程序提供了一层额外的安全防护。

对于Docker等大多数Linux容器来说,Cgroups技术是用来制造约束的主要手段,而Namespace技术则是用来修改进程视图的主要方法。

一、Linux Namespace的隔离能力

假设你已经有了一个 Linux 操作系统上的 Docker 项目在运行,比如我的环境是 Ubuntu 16.04 和 Docker CE 18.05。

接下来,让我们首先创建一个容器来试试。

$ docker run -it busybox /bin/sh

这样,我的 Ubuntu 16.04 机器就变成了一个宿主机,而一个运行着 /bin/sh 的容器,就跑在了这个宿主机里面。

上面的例子和原理,如果你已经玩过 Docker,一定不会感到陌生。此时,如果我们在容器里执行一下 ps 指令,就会发现一些更有趣的事情:

/ # ps
PID  USER   TIME COMMAND
  1 root   0:00 /bin/sh
  10 root   0:00 ps

可以看到,我们在 Docker 里最开始执行的 /bin/sh ,就是这个容器内部的第 1 号进程(PID=1),而这个容器里一共只有两个进程在运行。这就意味着,前面执行的 /bin/sh,以及我们刚刚执行的 ps,已经被 Docker 隔离在了一个跟宿主机完全不同的世界当中。

这种技术,就是 Linux 里面的 Namespace 机制。而 Namespace 的使用方式也非常有意思:它其实只是 Linux 创建新进程的一个可选参数。我们知道,在 Linux 系统中创建线程的系统调用是 clone(),比如:

int pid = clone(main_function, stack_size, SIGCHLD, NULL); 

这个系统调用就会为我们创建一个新的进程,并且返回它的进程号pid。

而当我们用clone()系统调用创建一个新进程时,就可以在参数中指定 CLONE_NEWPID 参数,比如:

int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL); 

这时,新创建的这个进程将会“看到”一个全新的进程空间,在这个进程空间里,它的 PID 是 1。之所以说“看到”,是因为这只是一个“障眼法”,在宿主机真实的进程空间里,这个进程的 PID 还是真实的数值,比如 100。

当然,我们还可以多次执行上面的 clone() 调用,这样就会创建多个 PID Namespace,而每个 Namespace 里的应用进程,都会认为自己是当前容器里的第 1 号进程,它们既看不到宿主机里真正的进程空间,也看不到其他 PID Namespace 里的具体情况。

除了我们刚刚用到的 PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼法”操作。

namespace 引入的相关内核版本 被隔离的全局系统资源 在容器语境下的隔离效果
Mount namespaces Linux 2.4.19 文件系统挂接点 每个容器能看到不同的文件系统层次结构
UTS namespaces Linux 2.6.19 nodename 和 domainname 每个容器可以有自己的 hostname 和 domainame
IPC namespaces Linux 2.6.19 特定的进程间通信资源,包括System V IPC 和 POSIX message queues 每个容器有其自己的 System V IPC 和 POSIX 消息队列文件系统,因此,只有在同一个 IPC
PID namespaces Linux 2.6.24 进程 ID 数字空间 (process ID number space) 每个 PID namespace 中的进程可以有其独立的 PID; 每个容器可以有其 PID 为 1 的root 进程;也使得容器可以在不同的 host 之间迁移,因为 namespace 中的进程 ID 和 host 无关了。这也使得容器中的每个进程有两个PID:容器中的 PID 和 host 上的 PID。
Network namespaces 始于Linux 2.6.24 完成于 Linux 2.6.29 网络相关的系统资源 每个容器用有其独立的网络设备,IP 地址,IP 路由表,/proc/net 目录,端口号等等。这也使得一个 host 上多个容器内的同一个应用都绑定到各自容器的 80 端口上。
User namespaces 始于 Linux 2.6.23 完成于 Linux 3.8) 用户和组 ID 空间 在 user namespace 中的进程的用户和组 ID 可以和在 host 上不同; 每个 container 可以有不同的 user 和 group id;一个 host 上的非特权用户可以成为 user namespace 中的特权用户;

比如,Mount Namespace ,用于让被隔离进程只看到当前 Namespace 里的挂载点信息; Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。

这,就是 Linux 容器最基本的实现原理了。

在理解了 Namespace 的工作方式之后,你就会明白,跟真实存在的虚拟机不同,在使用 Docker 的时候,并没有一个真正的“Docker 容器”运行在宿主机里面。Docker 项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数。

note

所以,Docker 容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,**指定了这个进程所需要启用的一组 Namespace 参数**。**这样,容器就只能“看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。**而对于宿主机以及其他不相关的程序,它就完全看不到了。 **所以说,容器,其实是一种特殊的进程而已。**

二、Linux Cgroup的限制能力

一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。

Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。通过Cgroup,可以方便地限制某个进程的资源占用,并且可以实时地监控进程的监控和统计信息。

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合 。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。

而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了,比如这样一条命令:

$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

在启动这个容器后,我们可以通过查看 Cgroups 文件系统下,CPU 子系统中,“docker”这个控制组里的资源限制文件的内容来确认:

$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us 
100000
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us 
20000

这就意味着这个 Docker 容器,只能使用到 20% 的 CPU 带宽。

三、rootfs的文件系统

容器里的进程看到的文件系统又是什么样子的呢?

其实,即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

这时,你可能已经想到了一个解决办法:创建新进程时,除了声明要启用 Mount Namespace 之外,我们还可以告诉容器进程,有哪些目录需要重新挂载,就比如这个 /tmp 目录。于是,我们在容器进程执行前可以添加一步重新挂载 /tmp 目录的操作:

这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。

不难想到,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。

在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。它的用法也非常简单。

#执行 chroot 命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录:
$ chroot $HOME/test /bin/bash

而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等:

$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

而你进入容器之后执行的 /bin/bash,就是 /bin 目录下的可执行文件,与宿主机的 /bin/bash 完全不同。

现在,你应该可以理解,对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:

  1. 启用 Linux Namespace 配置;
  2. 设置指定的 Cgroups 参数;
  3. 切换进程的根目录(Change Root)。

这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。这两个系统调用虽然功能类似,但是也有细微的区别

由于云端与本地服务器环境不同,应用的打包过程,一直是使用 PaaS 时最“痛苦”的一个步骤。但有了容器之后,更准确地说,有了容器镜像(即 rootfs)之后,这个问题被非常优雅地解决了。

由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。比如 Golang 的 Godeps.json。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。

这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

四、从rootfs文件系统到容器镜像

不过,这时你可能已经发现了另一个非常棘手的问题:难道我每开发一个应用,或者升级一下现有的应用,都要重复制作一次 rootfs 吗?

比如,我现在用 Ubuntu 操作系统的 ISO 做了一个 rootfs,然后又在里面安装了 Java 环境,用来部署我的 Java 应用。那么,我的另一个同事在发布他的 Java 应用时,显然希望能够直接使用我安装过 Java 环境的 rootfs,而不是重复这个流程。

一种比较直观的解决办法是,我在制作 rootfs 的时候,每做一步“有意义”的操作,就保存一个 rootfs 出来,这样其他同事就可以按需求去用他需要的 rootfs 了。

但是,这个解决办法并不具备推广性。原因在于,一旦你的同事们修改了这个 rootfs,新旧两个 rootfs 之间就没有任何关系了。这样做的结果就是极度的碎片化。

那么,既然这些修改都基于一个旧的 rootfs,我们能不能以增量的方式去做这些修改呢?这样做的好处是,所有人都只需要维护相对于 base rootfs 修改的增量内容,而不是每次修改都制造一个“fork”。

Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File System)的能力。

Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。比如,我现在有两个目录 A 和 B,它们分别有两个文件:

$ tree
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x

然后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上:

$ mkdir C
$ mount -t aufs -o dirs=./A:./B none ./C

这时,我再查看目录 C 的内容,就能看到目录 A 和 B 下的文件被合并到了一起:

$ tree ./C
./C
├── a
├── b
└── x

可以看到,在这个合并后的目录 C 里,有 a、b、x 三个文件,并且 x 文件只有一份。这,就是“合并”的含义。此外,如果你在目录 C 里对 a、b、x 文件做修改,这些修改也会在对应的目录 A、B 中生效。

我的环境是 Ubuntu 16.04 和 Docker CE 18.05,这对组合默认使用的是 AuFS 这个联合文件系统的实现。你可以通过 docker info 命令,查看到这个信息。AuFS 的全称是 Another UnionFS,后改名为 Alternative UnionFS,再后来干脆改名叫作 Advance UnionFS.

参考书目

参考文章