深入浅出Docker原理及实战(六)——重新认识Docker容器


声明:这是我在大学毕业后进入第一家互联网公司学习的内容


深入浅出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文件指向镜像层,即Ove​​rlayFS lowerdir。
  • upper文件指向容器层,该层对应于OverlayFS upperdir。
  • merged目录是联合安装的lowerdir和upperdir的挂载点,该方法包括从正在运行的容器内的文件系统的镜像。
  • work目录在OverlayFS内部。用于实现copy_up操作。

overlay_constructs.jpg

启动后的容器分为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,容器就能够为进程构建出一个完善的文件系统隔离环境。

通过这样的剖析,对于曾经“神秘莫测”的容器技术,你是不是感觉清晰了很多呢?

参考资料

使用OverlayFS存储驱动程序

深入剖析Kubernetes


版权声明:

原创不易,洗文可耻。除非注明,本博文章均为原创,转载请以链接形式标明本文地址。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值