声明:这是我在大学毕业后进入第一家互联网公司学习的内容
深入浅出Docker原理及实战系列第六篇,我主要讲一下Docker容器的本质。
从进程说起。
进程
假如,现在你要写一个计算加法的小程序,这个程序需要的输入来自于一个文件,计算完成后的结果则输出到另一个文件中。
由于计算机只认识 0 和 1,所以无论用哪种语言编写这段代码,最后都需要通过某种方式翻译成二进制文件,才能在计算机操作系统中运行起来。
而为了能够让这些代码正常运行,我们往往还要给它提供数据,比如我们这个加法程序所需要的输入文件。这些数据加上代码本身的二进制文件,放在磁盘上,就是我们平常所说的一个“程序”,也叫代码的可执行镜像(executable image)。
然后,我们就可以在计算机上运行这个“程序”了。
首先,操作系统从“程序”中发现输入数据保存在一个文件中,所以这些数据就会被加载到内存中待命。同时,操作系统又读取到了计算加法的指令,这时,它就需要指示 CPU 完成加法操作。而 CPU 与内存协作进行加法计算,又会使用寄存器存放数值、内存堆栈保存执行的命令和变量。同时,计算机里还有被打开的文件,以及各种各样的 I/O 设备在不断地调用中修改自己的状态。
就这样,一旦“程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运行起来后的计算机执行环境的总和,就是我们今天的主角:进程。
所以,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。
而容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。
Docker的底层原理
我们在前面讲过Docker的底层原理其实就是几个概念
- NameSpace 命名空间
- Cgroups 控制组
- Union file systems 联合文件系统
- Container format 容器格式。
对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法,Union file systems 技术存放了项目(代码)启动的所有环境依赖。
NameSpace
Linux namespaces 是对全局系统资源的一种封装隔离,使得处于不同 namespace 的进程拥有独立的全局系统资源,改变一个 namespace 中的系统资源只会影响当前 namespace 里的进程,对其他 namespace 中的进程没有影响。
我们首先创建一个容器来试试。
$ docker run -it busybox /bin/sh/
这个命令是 Docker 项目最重要的一个操作,即大名鼎鼎的 docker run。
而 -it 参数告诉了 Docker 项目在启动容器后,需要给我们分配一个文本输入 / 输出环境,也就是 TTY,跟容器的标准输入相关联,这样我们就可以和这个 Docker 容器进行交互了。
而 /bin/sh 就是我们要在 Docker 容器里运行的程序。
所以,上面这条指令翻译成人类的语言就是:请帮我启动一个容器,在容器里执行 /bin/sh,并且给我分配一个命令行终端跟这个容器交互。
容器的第一个进程
我们在讲Dockerfile制作的时候提到过,一个Dockerfile只能一个CMD指令。如果出现多个CMD 则只有最后一个CMD才会生效。因为容器启动的时候第一条命令就是容器内部的第一号进程。
接着上文,如果我们在容器里执行一下 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 隔离在了一个跟宿主机完全不同的世界当中。
NameSpace的障眼法
本来,每当我们在宿主机上运行了一个 /bin/sh 程序,操作系统都会给它分配一个进程编号,比如 PID=100。这个编号是进程的唯一标识,就像员工的工牌一样。
所以 PID=100,可以粗略地理解为这个 /bin/sh 是我们公司里的第 100 号员工,而第 1 号员工就自然是比尔 · 盖茨这样统领全局的人物。
而现在,我们要通过 Docker 把这个 /bin/sh 程序运行在一个容器当中。这时候,Docker 就会在这个第 100 号员工入职时给他施一个“障眼法”,让他永远看不到前面的其他 99 个员工,更看不到比尔 · 盖茨。这样,他就会错误地以为自己就是公司里的第 1 号员工。
这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第 100 号进程。
这种技术,就是 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 里的具体情况。
NameSpace小结
而除了我们刚刚用到的 PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼法”操作。
这,就是 Linux 容器最基本的实现原理了。所以,Docker 容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,指定了这个进程所需要启用的一组 Namespace 参数。这样,容器就只能“看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。
在理解了 Namespace 的工作方式之后,你就会明白,跟真实存在的虚拟机不同,在使用 Docker 的时候,并没有一个真正的“Docker 容器”运行在宿主机里面。Docker 项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数。这时,这些进程就会觉得自己是各自 PID Namespace 里的第 1 号进程,只能看到各自 Mount Namespace 里挂载的目录和文件,只能访问到各自 Network Namespace 里的网络设备,就仿佛运行在一个个“容器”里面,与世隔绝。
Cgroups
Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。
操作Cgroups
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。
指令把它们展示出来,这条命令是:
[root@VM-28-16 ~]# mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio,net_cls)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
可以看到,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。
这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对 CPU 子系统来说,我们就可以看到如下几个配置文件,这个指令是:
[root@VM-28-16 ~]# ls /sys/fs/cgroup/cpu
cgroup.clone_children cpuacct.usage cpu.rt_runtime_us kubepods.slice user.slice
cgroup.event_control cpuacct.usage_percpu cpu.shares notify_on_release
cgroup.procs cpu.cfs_period_us cpu.stat release_agent
cgroup.sane_behavior cpu.cfs_quota_us docker system.slice
cpuacct.stat cpu.rt_period_us kubepods tasks
如果熟悉 Linux CPU 管理的话,你就会在它的输出里注意到 cfs_period 和 cfs_quota 这样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间。
而这样的配置文件又如何使用呢?
你需要在对应的子系统下面创建一个目录,比如,我们现在进入 /sys/fs/cgroup/cpu 目录下:
[root@VM-28-16 cpu]# mkdir test
[root@VM-28-16 cpu]# cd test/
[root@VM-28-16 test]# ll
total 0
-rw-r--r-- 1 root root 0 Jul 18 17:31 cgroup.clone_children
--w--w--w- 1 root root 0 Jul 18 17:31 cgroup.event_control
-rw-r--r-- 1 root root 0 Jul 18 17:31 cgroup.procs
-r--r--r-- 1 root root 0 Jul 18 17:31 cpuacct.stat
-rw-r--r-- 1 root root 0 Jul 18 17:31 cpuacct.usage
-r--r--r-- 1 root root 0 Jul 18 17:31 cpuacct.usage_percpu
-rw-r--r-- 1 root root 0 Jul 18 17:31 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 Jul 18 17:31 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 Jul 18 17:31 cpu.rt_period_us
-rw-r--r-- 1 root root 0 Jul 18 17:31 cpu.rt_runtime_us
-rw-r--r-- 1 root root 0 Jul 18 17:31 cpu.shares
-r--r--r-- 1 root root 0 Jul 18 17:31 cpu.stat
-rw-r--r-- 1 root root 0 Jul 18 17:31 notify_on_release
-rw-r--r-- 1 root root 0 Jul 18 17:31 tasks
这个目录就称为一个“控制组”。你会发现,操作系统会在你新创建的 test 目录下,自动生成该子系统对应的资源限制文件。
现在,我们在后台执行这样一条脚本:
[root@VM-28-16 test]# while : ; do : ; done &
[1] 32705
[root@VM-28-16 test]# top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
32705 root 20 0 116496 1632 140 R 100.0 0.0 3:58.89 bash
可以看到CPU的打满了(%Cpu0 :100.0 us)
而此时,我们可以通过查看 test 目录下的文件,看到 container 控制组里的 CPU quota 还没有任何限制(即:-1),CPU period 则是默认的 100 ms(100000 us):
[root@VM-28-16 test]# cat cpu.cfs_quota_us
-1
[root@VM-28-16 test]# cat cpu.cfs_period_us
100000
接下来,我们可以通过修改这些文件的内容来设置限制。比如,向 test 组里的 cfs_quota 文件写入 20 ms(20000 us):
[root@VM-28-16 test]# echo 20000 > /sys/fs/cgroup/cpu/test/cpu.cfs_quota_us
结合前面的介绍,你应该能明白这个操作的含义,它意味着在每 100 ms 的时间里,被该控制组限制的进程只能使用 20 ms 的 CPU 时间,也就是说这个进程只能使用到 20% 的 CPU 带宽。
接下来,我们把被限制的进程的 PID 写入 test 组里的 tasks 文件,上面的设置就会对该进程生效了:
[root@VM-28-16 test]# echo 32705 > /sys/fs/cgroup/cpu/test/tasks
[root@VM-28-16 test]# top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
32705 root 20 0 116496 1632 140 R 20.1 0.0 8:01.77 bash
计算机的 CPU 使用率立刻降到了 20%(%Cpu0 : 20.8 us)。
Cgroups的设计
除 CPU 子系统外,Cgroups 的每一个子系统都有其独有的资源限制能力,比如:
- blkio,为块设备设定I/O 限制,一般用于磁盘等设备
- cpuset,为进程分配单独的 CPU 核和对应的内存节点
- memory,为进程设定内存使用的限制。
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 带宽。
Cgroups小结
一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。
Union File System
作为一个普通用户,我们希望:每当创建一个新容器时,容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如 /tmp)下进行操作,而完全不会受宿主机以及其他容器的影响。怎么才能做到这一点呢?
上文提到了Namespace里存在一种Mount Namespace
它的存在使我们可以在容器进程启动之前重新挂载它的整个根目录“/”。
这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。
chroot
在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。它的用法也非常简单。
假设,我们现在有一个 $HOME/test 目录,想要把它作为一个 /bin/bash 进程的根目录。
首先,创建一个 test 目录和几个 lib 文件夹:
mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}
然后,把 bash 命令拷贝到 test 目录对应的 bin 路径下
cp -v /bin/{bash,ls} $HOME/test/bin
接下来,把 bash 命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件可以用 ldd 命令:
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 进程的根目录:
chroot $HOME/test /bin/bash
这时,你如果执行 “ls /”,就会看到,它返回的都是 $HOME/test 目录下面的内容,而不是宿主机的内容。
更重要的是,对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被“修改”成 $HOME/test 了。
rootfs
实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。
而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。
所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等
$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var
现在,你应该可以理解,对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:
- 启用 Linux Namespace 配置;
- 设置指定的 Cgroups 参数
- 切换进程的根目录(Change Root)。
另外,需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。
在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。
容器的一致性
正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。
什么是容器的“一致性”呢?
由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。
有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。
这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
联合文件系统(Union File System)到底是怎么回事
Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。
Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。比如,我现在有两个目录 A 和 B,它们分别有两个文件:
[root@localhost test1]# tree
.
├── A
│ ├── a
│ └── x
└── B
├── b
└── x
然后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上:
[root@localhost test1]# mkdir C
[root@localhost test1]# mount -t aufs -o dirs=./A:./B none ./C
[root@localhost test1]# tree ./C
./C
├── a
├── b
└── x
可以看到,在这个合并后的目录 C 里,有 a、b、x 三个文件,并且 x 文件只有一份。
这,就是“合并”的含义。此外,如果你在目录 C 里对 a、b、x 文件做修改,这些修改也会在对应的目录 A、B 中生效。
Docker Image的底层原理
现在,我们启动一个容器
[root@localhost ~]# docker run -d ubuntu:latest sleep 3600
693d8e17e996e08819c0504ce01f0b58c7e67918e8346255b3cf2fddfb8800af
此时的Docker Image,实际上就是一个 Ubuntu 操作系统的 rootfs,它的内容是 Ubuntu 操作系统的所有文件和目录。
不过,与之前我们讲述的 rootfs 稍微不同的是,Docker 镜像使用的 rootfs,往往由多个“层”组成:
[root@localhost test1]# docker image inspect ubuntu:latest
...
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:e1c75a5e0bfa094c407e411eb6cc8a159ee8b060cbd0398f1693978b4af9af10",
"sha256:9e97312b63ff63ad98bb1f3f688fdff0721ce5111e7475b02ab652f10a4ff97d",
"sha256:ec1817c93e7c08d27bfee063f0f1349185a558b87b2d806768af0a8fbbf5bc11",
"sha256:05f3b67ed530c5b55f6140dfcdfb9746cdae7b76600de13275197d009086bb3d"
]
},
"Metadata": {
"LastTagTime": "0001-01-01T00:00:00Z"
}
}
]
可以看到,这个 Ubuntu 镜像,实际上由四层组成。这四层就是四个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/C”目录)。
overlay2是最新的Docker CE版本18.06.0上的默认存储驱动。
这个挂载点就是/var/lib/docker/overlay2
比如:
#下载Ubuntu后的图层
[root@localhost overlay2]# ll
total 0
drwx------ 4 root root 72 Jul 19 19:22 02dccddd7184a977f6a105f849ecfa69753e62e3931628325249bf2fa013524f
drwx------ 4 root root 55 Jul 19 19:22 930e827392a2b099d8b2d4c4431b1a6b8685bbfdca55b97c5522906170c78f72
brw------- 1 root root 253, 0 Jun 1 15:10 backingFsBlockDev
drwx------ 4 root root 72 Jul 19 19:22 cc3d690e853be0c422705cd4e449a30bdc4a121db5fc9d8e1c433f6071756111
drwx------ 3 root root 47 Jul 19 19:22 fecbaa2f5e0fff06b09a6164443a50bad7829c484d5c5db732fd51abc4506beb
drwx------ 2 root root 142 Jul 19 19:22 l
#启动一个Ubuntu镜像的容器
[root@localhost overlay2]# docker run -d ubuntu:latest sleep 3600
#启动后的图层
[root@localhost overlay2]# ll
total 0
drwx------ 4 root root 72 Jul 19 19:22 02dccddd7184a977f6a105f849ecfa69753e62e3931628325249bf2fa013524f
drwx------ 4 root root 72 Jul 19 20:12 930e827392a2b099d8b2d4c4431b1a6b8685bbfdca55b97c5522906170c78f72
brw------- 1 root root 253, 0 Jun 1 15:10 backingFsBlockDev
drwx------ 4 root root 72 Jul 19 19:22 cc3d690e853be0c422705cd4e449a30bdc4a121db5fc9d8e1c433f6071756111
drwx------ 5 root root 69 Jul 19 20:12 e9165a3fd83378e53b1dfe4c72c3c5003d4d165a4acfd80b265f8c455bb31bbc
drwx------ 4 root root 72 Jul 19 20:12 e9165a3fd83378e53b1dfe4c72c3c5003d4d165a4acfd80b265f8c455bb31bbc-init
drwx------ 3 root root 47 Jul 19 19:22 fecbaa2f5e0fff06b09a6164443a50bad7829c484d5c5db732fd51abc4506beb
drwx------ 2 root root 210 Jul 19 20:12 l
你会发现新增了2个目录,即被联合挂载之后形成新的目录
进入目录后可以看见,这个里面就是容器里的东西
[root@localhost merged]# pwd
/var/lib/docker/overlay2/e9165a3fd83378e53b1dfe4c72c3c5003d4d165a4acfd80b265f8c455bb31bbc/merged
[root@localhost merged]# ll
total 0
lrwxrwxrwx 1 root root 7 Jul 3 09:56 bin -> usr/bin
drwxr-xr-x 2 root root 6 Apr 15 19:09 boot
drwxr-xr-x 1 root root 43 Jul 19 20:12 dev
drwxr-xr-x 1 root root 66 Jul 19 20:12 etc
drwxr-xr-x 2 root root 6 Apr 15 19:09 home
lrwxrwxrwx 1 root root 7 Jul 3 09:56 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Jul 3 09:56 lib32 -> usr/lib32
lrwxrwxrwx 1 root root 9 Jul 3 09:56 lib64 -> usr/lib64
lrwxrwxrwx 1 root root 10 Jul 3 09:56 libx32 -> usr/libx32
drwxr-xr-x 2 root root 6 Jul 3 09:57 media
drwxr-xr-x 2 root root 6 Jul 3 09:57 mnt
drwxr-xr-x 2 root root 6 Jul 3 09:57 opt
drwxr-xr-x 2 root root 6 Apr 15 19:09 proc
drwx------ 2 root root 37 Jul 3 10:00 root
drwxr-xr-x 1 root root 21 Jul 7 05:56 run
lrwxrwxrwx 1 root root 8 Jul 3 09:56 sbin -> usr/sbin
drwxr-xr-x 2 root root 6 Jul 3 09:57 srv
drwxr-xr-x 2 root root 6 Apr 15 19:09 sys
drwxrwxrwt 2 root root 6 Jul 3 10:00 tmp
drwxr-xr-x 1 root root 18 Jul 3 09:57 usr
drwxr-xr-x 1 root root 17 Jul 3 10:00 var
那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的 Ubuntu 文件系统的呢?
可以查询挂载的信息
[root@localhost merged]# cat /proc/mounts| grep overlay2
overlay /var/lib/docker/overlay2/e9165a3fd83378e53b1dfe4c72c3c5003d4d165a4acfd80b265f8c455bb31bbc/merged
overlay rw,relatime,
lowerdir=/var/lib/docker/overlay2/l/X644A6N6JAYX6FLP76U6HFD3HQ:/var/lib/docker/overlay2/l/K4OIX32VD6WIAQ7R6LLXJXRVLQ:/var/lib/docker/overlay2/l/IJGD7NOFAJBGJFMMOCZVG7OXNC:/var/lib/docker/overlay2/l/OR4MXA5Z63JQCCXOQRSCFYMY2U:/var/lib/docker/overlay2/l/CPSWVQCU3X4JZWB3XEXL7TULDW,
upperdir=/var/lib/dockeroverlay2/e9165a3fd83378e53b1dfe4c72c3c5003d4d165a4acfd80b265f8c455bb31bbc/diff,
workdir=/var/lib/docker/overlay2/e9165a3fd83378e53b1dfe4c72c3c5003d4d165a4acfd80b265f8c455bb31bbc/work 0 0
[root@localhost overlay2]# df -h
overlay 200G 4.3G 196G 3% /var/lib/docker/overlay2/e9165a3fd83378e53b1dfe4c72c3c5003d4d165a4acfd80b265f8c455bb31bbc/merged
一个容器的组成部分
OverlayFS在单个Linux主机上分层两个目录,并将它们显示为单个目录。这些目录称为图层,统一过程称为联合安装。OverlayFS指的是下层lowerdir目录a和上层目录a upperdir。统一视图通过其自己的目录公开merged。
镜像层(只读)是lowerdir,容器层(读写)是upperdir
- lower文件指向镜像层,即OverlayFS lowerdir。
- upper文件指向容器层,该层对应于OverlayFS upperdir。
- merged目录是联合安装的lowerdir和upperdir的挂载点,该方法包括从正在运行的容器内的文件系统的镜像。
- work目录在OverlayFS内部。用于实现copy_up操作。
启动后的容器分为3层
- 第一部分,只读层。
它是这个容器的 rootfs 最下面的四层,对应的正是 ubuntu:latest 镜像的四层。它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout,至于什么是 whiteout,我下面马上会讲到)。这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分。
- 第二部分,可读写层。
它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即 read write。
可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?
为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。
比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的含义。我喜欢把 whiteout 形象地翻译为:“白障”。
在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量 rootfs 的好处。
- 第三部分,Init 层。它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。
具体细节后面我会深入讲解,这里就不重点讲了
可以参考官方文档 使用OverlayFS存储驱动程序
所以搞清楚一点即可
镜像的层都放置在 /var/lib/docker/overlay2/分层ID/diff 目录下,然后启动容器后被联合挂载在 /var/lib/docker/overlay2/联合层ID/merge 里面。
UFS小结
容器镜像,也叫作:ufs(有的人喜欢叫rootfs)。它只是一个操作系统的所有文件和目录,并不包含内核,最多也就几百兆。而相比之下,传统虚拟机的镜像大多是一个磁盘的“快照”,磁盘有多大,镜像就至少有多大。
通过结合使用 Mount Namespace 和 rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。当然,这个功能的实现还必须感谢 chroot 和 pivot_root 这两个系统调用切换进程根目录的能力。
而在 rootfs 的基础上,Docker 公司创新性地提出了使用多个增量 rootfs 联合挂载一个完整 rootfs 的方案,这就是容器镜像中“层”的概念。
通过“分层镜像”的设计,以 Docker 镜像为核心,来自不同公司、不同团队的技术人员被紧密地联系在了一起。而且,由于容器镜像的操作是增量式的,这样每次镜像拉取、推送的内容,比原本多个完整的操作系统的大小要小得多;而共享层的存在,可以使得所有这些容器镜像需要的总空间,也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作,要比基于动则几个 GB 的虚拟机磁盘镜像的协作要敏捷得多。
更重要的是,一旦这个镜像被发布,那么你在全世界的任何一个地方下载这个镜像,得到的内容都完全一致,可以完全复现这个镜像制作者当初的完整环境。这,就是容器技术“强一致性”的重要体现。
Docker存在的问题
我们既然要重新了解Docker,它的一些缺点注定是我们绕不过去的,首先,容器不是万能的,不然要虚拟机干嘛。我也是通过容器的3个技术分析它们的劣势
Namespace
- Namespace隔离得不彻底,首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。
- 在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。
如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。相比于在虚拟机里面可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是用户必须考虑的一个问题。
由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。
但是,在后面分享容器设计模式时,我还会推荐其他更好的解决办法。这是因为容器本身的设计,就是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。
Cgroups
Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。
众所周知,Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。
但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。
在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的 CPU 核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。
UFS
上面的读写层通常也称为容器层,下面的只读层称为镜像层,所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。
知道这一点,就不难理解镜像文件的修改,比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write。
但是如果你的底层镜像发生变化,可能牵一发而动全身。因为其他的镜像如果都按照一个基础镜像的制作,那么这个改变会影响很多现有镜像。
比如我们现在java项目的环境依赖jdk1.7,由于项目的升级及其他原因,导致了jdk1.7必须升级到1.8才能完成,但是并不是所有的项目都需要升级。
应对这种情况就必须要更加细致化的区分基础镜像。
比如制作一个centos7的镜像
基于这个镜像制作一个jdk1.7 和一个jdk1.8 2个不同的基础镜像
然后项目分别跑着不同的基础环境中。
而虚拟机可以随意切换环境,重新启动项目即可。
总结
容器,其实是一种特殊的进程而已。
Namespace 的作用是“隔离”,它让应用进程只能看到该 Namespace 内的“世界”
而 Cgroups 的作用是“限制”,它给这个“世界”围上了一圈看不见的墙。这么一折腾,进程就真的被“装”在了一个与世隔绝的房间里,而这些房间就是 PaaS 项目赖以生存的应用“沙盒”。
通过结合使用 Mount Namespace 和 rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。
通过这样的剖析,对于曾经“神秘莫测”的容器技术,你是不是感觉清晰了很多呢?
参考资料
版权声明:
原创不易,洗文可耻。除非注明,本博文章均为原创,转载请以链接形式标明本文地址。