容器技术的实现

容器技术的兴起

  • 容器技术的兴起源于PaSS技术的普及
  • Docker公司发布的Docker项目具有里程碑式的意义
  • Docker项目通过“容器镜像”,解决了应用打包的根本性难题

NameSpace隔离

我们经常看见一张对比虚拟机和Docker的图虚拟机 VS Docker

这幅图的左边,画出了虚拟机的工作原理。其中,名为 Hypervisor的软件是虚拟机最主要的部分。它通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如 CPU、内存、I/O 设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即 Guest OS。

而这幅图的右边,则用一个名为 Docker Engine 的软件替换了 Hypervisor。这也是为什么,很多人会把 Docker 项目称为“轻量级”虚拟化技术的原因,实际上就是把虚拟机的概念套在了容器上。

可是这样的说法并不严谨。容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

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

假如运行一个容器,并进入其交互界面

1
2
3

$ docker run -it busybox /bin/sh
/ #

再执行ps指令

1
2
3
4
5

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

可以看见,容器中的/bin/bashpid为1,以及命令本身包含的ps,这说明Docker已经被隔离在了一个完全隔离的世界中了。

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

1
2

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

这个系统调用就会为我们创建一个新的进程,并且返回它的进程号 pid。而当我们用 clone() 系统调用创建一个新进程时,就可以在参数中指定 CLONE_NEWPID 参数,比如:

1
2

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

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

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

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

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

由此可见,虚拟机和Docker的比对图应该如下所示

docker VS 虚拟机修正版

使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。相比之下,容器技术的应用并不需要单独的GuestOS,共享宿主机的内核。

但是,有利也有弊,Linux NameSpace在隔离上也存在隔离不彻底的问题。

首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。

这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。

其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。

这意味着,在容器中修改了时间,宿主机的时间也会被修改,这显然不符合期望的要求。

所以总结来说,容器,就是一种特殊的进程。

Cgroups限制

由于容器是一个特殊的进程,这意味着,它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。

好在Linux Cgroups就是Linux内核中用来设置资源限制的一个重要功能。

在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。可以通过mount指令显示

1
2
3
4
5
6
7
8

$ mount -t cgroup
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
...

可以看到,目录下有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对 CPU 子系统来说,我们就可以看到如下几个配置文件,这个指令是:

1
2
3
4

$ ls /sys/fs/cgroup/cpu
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks

其中cfs_period 和 cfs_quota 这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间,tasks可以用来指定进程的PID。

对于配置文件的使用,需要到对应的子系统下创建一个目录,例如:

1
2
3
4
5

root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks

这个目录被称为控制组,操作系统会在你新创建的 container 目录下,自动生成该子系统对应的资源限制文件。

假如在后台执行一个死循环脚本,让脚本将操作系统CPU吃满

1
2
3

$ while : ; do : ; done &
[1] 226

可以通过top指令来确认CPU实时变化

1
2
3

$ top
%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

而此时,可以通过查看container目录下的文件,可以看到container 控制组里的 CPU quota 还没有任何限制(即:-1),CPU period 则是默认的 100 ms(100000 us)

1
2
3
4
5

$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
-1
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
100000

接下来可以通过修改配置文件的方式来对CPU占用率进行限制

1
2
3
4
5
6
7

$ echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

$ echo 226 > /sys/fs/cgroup/cpu/container/tasks

$ top
%Cpu0 : 20.3 us, 0.0 sy, 0.0 ni, 79.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

可以看到,CPU占用率立马降为20%。除 CPU 子系统外,Cgroups 的每一项子系统都有其独有的资源限制能力,比如:

  • blkio:为块设备设定I/O 限制,一般用于磁盘等设备;
  • cpuset:为进程分配单独的 CPU 核和对应的内存节点;
  • memory:为进程设定内存使用的限制。

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。当然,至于控制组下面的资源文件限制值,可以在运行image的时候通过参数来指定

1
2
3
4
5
6
$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

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

总结来说,容器是一个单进程模型,这意味着,一个容器没法运行两个不同的应用,因为没法找到一个公共的PID=1来充当两个不同应用的父进程。这是因为容器本身的设计就是希望应用和容器同生命周期。

同样,Cgroups也是有利有弊,最明显的就是/proc文件系统问题。

/proc是用来存放当前内核的运行状态的一些特殊文件集合,比如CPU使用情况,内存使用情况,top指令的主要来源值就是来自于这里。但是,在容器中,proc显示的是宿主机的数据,这是绝对不允许的。

当然,可采用lxcfs来隔离宿主机和容器的文件系统。

容器文件系统的深入理解

由于NameSpace的作用是隔离,它的应用让容器只能看到该NameSpace内的世界;Cgroups的作用是限制,,它的作用是限制容器对于资源的使用率。对于理想的容器文件系统应该是看到一份完全独立的文件系统,且不受宿主机的影响。关于Mount NameSpace的小实验可以参考DOCKER基础技术,可以得出结论是:

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

对于刚进入容器的文件系统来说,容器使用的是Linux名为chroot的指令,即改变进程根目录到指定的目录下。用法如下所示:

首先,创建一个test目录和几个lib文件夹,再把bash命令拷贝到test目录下对应的bin目录下,接下来,把 bash 命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件可以用 ldd 命令

1
2
3
4
5
6
7
8
$ mkdir -p $HOME/test
$ mkdir -p $HOME/test/{bin,lib64,lib}
$ cd $T
$ cp -v /bin/{bash,ls} $HOME/test/bin

$ T=$HOME/test
$ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
$ for i in $list; do cp -v "$i" "${T}${i}"; done

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

1
$ chroot $HOME/test /bin/bash

这时,你如果执行 “ls /“,就会看到,它返回的都是 $HOME/test 目录下面的内容,而不是宿主机的内容。实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。

当然,为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。

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

现在,对于Docker项目来说,最核心的原理已经完成了,即:

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

不过需要注意的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

也就是说,同一台机器上的所有容器,都共享一个操作系统内核,这也是容器相比于虚拟机的主要区别:毕竟后者有模拟出来的硬件机器充当沙盒,每个沙盒中还有一个完整的GuestOS。

当然,在有了rootfs之后,还必须面对的一个问题就是,是否需要每次改动一下应用都需要重新制作一次rootfs。

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

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

1
2
3
4
5
6
7
8
$ tree
.
├── A
│ ├── a
│ └── x
└── B
├── b
└── x

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

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

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

1
2
3
4
5
$ tree ./C
./C
├── a
├── b
└── x

Docker采用AuFS这个联合文件系统来实现联合挂载,对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录:/var/lib/docker/aufs/diff/<layer_id>,而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上/var/lib/docker/aufs/mnt/<layer_id>

而且,从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:

docker分层

第一部分:只读层

它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。

1
2
3
4
5
6
$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...
etc sbin usr var
$ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...
run
$ ls /var/lib/docker/aufs/diff/a524a729adadedb900...
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

可以看到,这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分。

第二部分:可读写层

在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。

而对于删除来说,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。

第三部分:Init层

它是一个以-init结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。

需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。

所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。

参考

  1. https://coolshell.cn/articles/17010.html
  2. https://time.geekbang.org/column/intro/116
-------------本文结束感谢您的阅读-------------