Skip to main content

容器技术概念

·1469 words·7 mins
WFUing
Author
WFUing
A graduate who loves coding.
Table of Contents

从进程开始
#

在前面的 4 篇预习文章中,我梳理了“容器”这项技术的来龙去脉,通过这些内容,我希望你能理解如下几个事实:

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

紧接着,我详细介绍了容器技术圈在过去五年里的“风云变幻”,而通过这部分内容,我希望你能理解这样一个道理:容器本身没有价值,有价值的是"容器编排"

也正因为如此,容器技术生态才爆发了一场关于“容器编排”的“战争”。而这次战争,最终以 Kubernetes 项目和 CNCF 社区的胜利而告终。所以,这个专栏后面的内容,我会以 Docker 和 Kubernetes 项目为核心,为你详细介绍容器技术的各项实践与其中的原理。

不过在此之前,你还需要搞清楚一个更为基础的问题:容器,到底是怎么一回事儿?

容器其实是一种沙盒技术。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用“装”起来的技术。这样,应用与应用之间,就因为有了边界而不至于相互干扰;而被装进集装箱的应用,也可以被方便地搬来搬去,这不就是 PaaS 最理想的状态嘛。

不过,这两个能力说起来简单,但要用技术手段去实现它们,可能大多数人就无从下手了。

所以,我就先来跟你说说这个“边界”的实现手段。

假如,现在你要写一个计算加法的小程序,这个程序需要的输入来自于一个文件,计算完成后的结果则输出到另一个文件中。

由于计算机只认识 0 和 1,所以无论用哪种语言编写这段代码,最后都需要通过某种方式翻译成二进制文件,才能在计算机操作系统中运行起来。

而为了能够让这些代码正常运行,我们往往还要给它提供数据,比如我们这个加法程序所需要的输入文件。这些数据加上代码本身的二进制文件,放在磁盘上,就是我们平常所说的一个“程序”,也叫代码的可执行镜像(executable image)。

镜像的概念就是一个持续运行环境的数据化、静态化

然后,我们就可以在计算机上运行这个“程序”了。

首先,操作系统从“程序”中发现输入数据保存在一个文件中,所以这些数据就会被加载到内存中待命。同时,操作系统又读取到了计算加法的指令,这时,它就需要指示 CPU 完成加法操作。而 CPU 与内存协作进行加法计算,又会使用寄存器存放数值、内存堆栈保存执行的命令和变量。同时,计算机里还有被打开的文件,以及各种各样的 I/O 设备在不断地调用中修改自己的状态。

就这样,一旦“程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运行起来后的计算机执行环境的总和,就是我们今天的主角:进程。

所以,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。

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

1、约束和修改指的是什么?

2、什么是约束、什么是修改

3、怎么约束、怎么修改

修改:通过linux提供namespace实现 linux中创建进程的方式是通过clone函数,里边可以设置参数(CLONE_NEWPID),它为创建的进程提供了一个新的进程空间,将和原来的进程隔离开。比如:linux中的/bin/bash 的PID 100,用了namespace后 在这个进程中查看PID的情况时,就会发现/bin/bash的PID为 1。

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

  • Cgroups资源限制
  • Namespace隔离资源

你可能会觉得 Cgroups 和 Namespace 这两个概念很抽象,别担心,接下来我们一起动手实践一下,你就很容易理解这两项技术了。

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

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

容器,其实就是操作系统在启动进程时通过设置一些参数实现了隔离不相关资源后的一个特殊进程

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

这个命令是 Docker 项目最重要的一个操作,即大名鼎鼎的 docker run。

-it 参数告诉了 Docker 项目在启动容器后,需要给我们分配一个文本输入 / 输出环境,也就是 TTY,跟容器的标准输入相关联,这样我们就可以和这个 Docker 容器进行交互了。而 /bin/sh 就是我们要在 Docker 容器里运行的程序。

所以,上面这条指令翻译成人类的语言就是:请帮我启动一个容器,在容器里执行 /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 隔离在了一个跟宿主机完全不同的世界当中。

这究竟是怎么做到的呢?

本来,每当我们在宿主机上运行了一个 /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 里的具体情况。

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

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

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

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

所以说,容器,其实是一种特殊的进程而已

隔离与限制
#

Namespace 技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。但对于宿主机来说,这些被“隔离”了的进程跟其他进程并没有太大区别。

Namespace是一种资源逻辑隔离机制,在架构设计时经常用到。

说到这一点,相信你也能够知道我在上一篇文章最后给你留下的第一个思考题的答案了:在之前虚拟机与容器技术的对比图里,不应该把 Docker Engine 或者任何容器管理工具放在跟 Hypervisor 相同的位置,因为它们并不像 Hypervisor 那样对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身:

Docker本身并没有对应用进程进行隔离,也没有创建所谓的“容器”,而是通过宿主机的操作系统通过namespace对应用进程进行隔离,通过cgroups对被隔离的应用进程限制相关资源属性

所以,在这个对比图里,我们应该把 Docker 画在跟应用同级别并且靠边的位置。这意味着,用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的 Namespace 参数。而 Docker 项目在这里扮演的角色,更多的是旁路式的辅助和管理工作。

这里还有一件事情没有说清楚,不同的Docker软件在不同操作系统上的实现,Linux估计是最直接的,但是其他如Windows、Mac这些是如何实现的,另外如果说是Docker软件在负责屏蔽运行时的差异,那哪些情况下会使用虚拟机来运行操作系统,哪些情况下时直接使用原生OS来运行的。

这里考虑linux

后续分享 CRI 和容器运行时的时候还会专门介绍,其实像 Docker 这样的角色甚至可以去掉。

2020年12月,k8s已经决定不使用docker,降低docker相对于容器运行时之外部分的维护成本,直接通过CRI与容器运行时进行交互。

这样的架构也解释了为什么 Docker 项目比虚拟机更受欢迎的原因。

这是因为,使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。

而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

所以说,“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这种更细粒度的资源管理平台上大行其道的重要原因

  • 敏捷是指快速打包,
  • 快速发布 高性能是指容器进程本来就是系统里的普通进程,没有虚拟化层的性能损失。
  • 但缺点是没有虚拟化层的隔离性

不过,有利就有弊,基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底

容器化对资源隔离的不足,最终它是通过共享宿主机上的操作系统内核来运行的。

  • namespace是对进程的隔离,解决可见性问题
  • cgroup是对系统资源的隔离,解决资源隔离问题

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

尽管你可以在容器里通过 Mount Namespace 单独挂载其他不同版本的操作系统文件,比如 CentOS 或者 Ubuntu,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。

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

使用容器时要考虑2个不能被隔离的资源,内核和时间。

这就意味着,如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。相比于在虚拟机里面可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是用户必须考虑的一个问题。

此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。

更为棘手的是,尽管在实践中我们确实可以使用 Seccomp 等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,必然会拖累容器的性能。何况,默认情况下,谁也不知道到底该开启哪些系统调用,禁止哪些系统调用。

所以,在生产环境中,没有人敢把运行在物理机上的 Linux 容器直接暴露到公网上。当然,我后续会讲到的基于虚拟化或者独立内核技术的容器实现,则可以比较好地在隔离与性能之间做出平衡。

在介绍完容器的“隔离”技术之后,我们再来研究一下容器的“限制”问题

也许你会好奇,我们不是已经通过 Linux Namespace 创建了一个“容器”吗,为什么还需要对容器做“限制”呢?

我还是以 PID Namespace 为例,来给你解释这个问题。

虽然容器内的第 1 号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第 100 号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第 100 号进程表面上被隔离了起来,但是它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。

而 Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能

有意思的是,Google 的工程师在 2006 年发起这项特性的时候,曾将它命名为“进程容器”(process container)。实际上,在 Google 内部,“容器”这个术语长期以来都被用于形容被 Cgroups 限制过的进程组。后来 Google 的工程师们说,他们的 KVM 虚拟机也运行在 Borg 所管理的“容器”里,其实也是运行在 Cgroups“容器”当中。这和我们今天说的 Docker 容器差别很大。

Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等

此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。在今天的分享中,我只和你重点探讨它与容器关系最紧密的“限制”能力,并通过一组实践来带你认识一下 Cgroups。

在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。在 Ubuntu 16.04 机器里,我可以用 mount 指令把它们展示出来,这条命令是:

$ 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)
...

它的输出结果,是一系列文件系统目录。如果你在自己的机器上没有看到这些目录,那你就需要自己去挂载 Cgroups,具体做法可以自行 Google。

sudo apt install cgroupfs-mount

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

$ 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

如果熟悉 Linux CPU 管理的话,你就会在它的输出里注意到 cfs_period 和 cfs_quota 这样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间。

而这样的配置文件又如何使用呢?

你需要在对应的子系统下面创建一个目录,比如,我们现在进入 /sys/fs/cgroup/cpu 目录下:

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 目录下,自动生成该子系统对应的资源限制文件。

现在,我们在后台执行这样一条脚本:

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

在输出里可以看到,CPU 的使用率已经 100% 了(%Cpu0 :100.0 us)。

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

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

接下来,我们可以通过修改这些文件的内容来设置限制。

比如,向 container 组里的 cfs_quota 文件写入 20 ms(20000 us):

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

结合前面的介绍,你应该能明白这个操作的含义,它意味着在每 100 ms 的时间里,被该控制组限制的进程只能使用 20 ms 的 CPU 时间,也就是说这个进程只能使用到 20% 的 CPU 带宽。

接下来,我们把被限制的进程的 PID 写入 container 组里的 tasks 文件,上面的设置就会对该进程生效了:

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

我们可以用 top 指令查看一下:

$ 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%(%Cpu0 : 20.3 us)。

除 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 带宽。

深入理解容器镜像
#

Linux 容器最基础的两种技术:Namespace 和 Cgroups。希望此时,你已经彻底理解了“容器的本质是一种特殊的进程”这个最重要的概念。

而正如前面所说的,Namespace 的作用是“隔离”,它让应用进程只能看到该 Namespace 内的“世界”;而 Cgroups 的作用是“限制”,它给这个“世界”围上了一圈看不见的墙。这么一折腾,进程就真的被“装”在了一个与世隔绝的房间里,而这些房间就是 PaaS 项目赖以生存的应用“沙盒”。

可是,还有一个问题不知道你有没有仔细思考过:这个房间四周虽然有了墙,但是如果容器进程低头一看地面,又是怎样一副景象呢?

换句话说,容器里的进程看到的文件系统又是什么样子的呢

可能你立刻就能想到,这一定是一个关于 Mount Namespace 的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如 /tmp)下进行操作,而完全不会受宿主机以及其他容器的影响。

那么,真实情况是这样吗?

“左耳朵耗子”叔在多年前写的一篇关于 Docker 基础知识 的博客里,曾经介绍过一段小程序。这段小程序的作用是,在创建子进程时开启指定的 Namespace。

下面,我们不妨使用它来验证一下刚刚提到的问题。

#define _GNU_SOURCE
#include <sys/mount.h> 
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
  "/bin/bash",
  NULL
};

int container_main(void* arg)
{  
  printf("Container - inside the container!\n");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

int main()
{
  printf("Parent - start a container!\n");
  int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
  waitpid(container_pid, NULL, 0);
  printf("Parent - container stopped!\n");
  return 0;
}

这段代码的功能非常简单:在 main 函数里,我们通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)。

而这个子进程执行的,是一个“/bin/bash”程序,也就是一个 shell。所以这个 shell 就运行在了 Mount Namespace 的隔离环境中。

我们来一起编译一下这个程序:

$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!

这样,我们就进入了这个“容器”当中。可是,如果在“容器”里执行一下 ls 指令的话,我们就会发现一个有趣的现象: /tmp 目录下的内容跟宿主机的内容是一样的。

$ ls /tmp
# 你会看到好多宿主机的文件

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

这是怎么回事呢?

仔细思考一下,你会发现这其实并不难理解:Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

mout namespace只隔离增量,不隔离存量

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

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  // 如果你的机器的根目录的挂载类型是shared,那必须先重新挂载根目录
  // mount("", "/", NULL, MS_PRIVATE, "");
  mount("none", "/tmp", "tmpfs", 0, "");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

可以看到,在修改后的代码里,我在容器进程启动之前,加上了一句 mount(“none”, “/tmp”, “tmpfs”, 0, “”) 语句。就这样,我告诉了容器以 tmpfs(内存盘)格式,重新挂载了 /tmp 目录。

mount(source, target, type, options)

  • source: 要挂载的文件系统的来源,这里是 “none”,表示一个空的文件系统,通常用于创建临时文件系统。
  • target: 挂载点,也就是文件系统将要被挂载到的目录,这里是 “/tmp”。
  • type: 文件系统的类型,这里是 “tmpfs”,表示挂载一个临时文件系统到指定目录。
  • options: 挂载选项,这里是一个空字符串,表示使用默认选项。

上述命令的含义是将一个空的临时文件系统挂载到 “/tmp” 目录。在 Linux 中,tmpfs 是一种基于内存的文件系统,通常用于存储临时数据。这样的文件系统在系统重新启动时会被清空,因此适用于存储临时文件。

这段修改后的代码,编译执行后的结果又如何呢?我们可以试验一下:

$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!
$ ls /tmp

可以看到,这次 /tmp 变成了一个空目录,这意味着重新挂载生效了。我们可以用 mount -l 检查一下:

$ mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)

可以看到,容器里的 /tmp 目录是以 tmpfs 方式单独挂载的。

更重要的是,因为我们创建的新进程启用了 Mount Namespace,所以这次重新挂载的操作,只在容器进程的 Mount Namespace 中有效。如果在宿主机上用 mount -l 来检查一下这个挂载,你会发现它是不存在的:

# 在宿主机上
$ mount -l | grep tmpfs

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

可是,作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?

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

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

假设,我们现在有一个 $HOME/test 目录,想要把它作为一个 /bin/bash 进程的根目录。

首先,创建一个 test 目录和几个 lib 文件夹:

$ mkdir -p $HOME/test
$ mkdir -p $HOME/test/{bin,lib64,lib}
$ cd $T

然后,把 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 了。

这种视图被修改的原理,是不是跟我之前介绍的 Linux Namespace 很类似呢?

没错!

实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。

当然,为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,我们在容器里通过执行 “ls /” 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件。

而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作: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 完全不同。

附录
#

容器 = cgroup + namespace + rootfs + 容器引擎

  • Cgroup:资源控制
  • namespace:访问隔离
  • rootfs:文件系统隔离。镜像的本质就是一个rootfs文件
  • 容器引擎:生命周期控制

Cgroup
#

Cgroup 是 Control group 的简称,是 Linux 内核提供的一个特性,用于限制和隔离一组进程对系统资源的使用。对不同资源的具体管理是由各个子系统分工完成的。

子系统 作用
devices 设备权限控制
cpuset 分配指定的CPU和内存节点
CPU 控制CPU使用率
cpuacct 统计CPU使用情况
memory 限制内存的使用上限
freezer 暂停Cgroup 中的进程
net_cls 配合流控限制网络带宽
net_prio 设置进程的网络流量优先级
perf_event 允许 Perf 工具基于 Cgroup 分组做性能检测
huge_tlb 限制 HugeTLB 的使用

在 Cgroup 出现之前,只能对一个进程做资源限制,如 ulimit 限制一个进程的打开文件上限、栈大小。而 Cgroup 可以对进程进行任意分组,如何分组由用户自定义。

子系统介绍

  1. cpuset 子系统

cpuset 可以为一组进程分配指定的CPU和内存节点。 cpuset 一开始用在高性能计算上,在 NUMA(non-uniform memory access) 架构的服务器上,通过将进程绑定到固定的 CPU 和内存节点上,来避免进程在运行时因跨节点内存访问而导致的性能下降。

cpuset 的主要接口如下:

  • cpuset.cpus: 允许进程使用的CPU列表
  • cpuset.mems: 允许进程使用的内存节点列表
  1. cpu 子系统

cpu 子系统用于限制进程的 CPU 利用率。具体支持三个功能:

  • CPU 比重分配。使用 cpu.shares 接口。
  • CPU 带宽限制。使用 cpu.cfs_period_us 和 cpu.cfs_quota_us 接口。
  • 实时进程的 CPU 带宽限制。使用 cpu_rt_period_us 和 cpu_rt_quota_us 接口。
  1. cpuacct 子系统

统计各个 Cgroup 的 CPU 使用情况,有如下接口:

  • cpuacct.stat: 报告这个 Cgroup 在用户态和内核态消耗的 CPU 时间,单位是 赫兹。
  • cpuacct.usage: 报告该 Cgroup 消耗的总 CPU 时间。
  • cpuacct.usage_percpu:报告该 Cgroup 在每个 CPU 上的消耗时间。
  1. memory 子系统

限制 Cgroup 所能使用的内存上限。

  • memory.limit_in_bytes:设定内存上限,单位字节。默认情况下,如果使用的内存超过上限,Linux 内核会试图回收内存,如果这样仍无法将内存降到限制的范围内,就会触发 OOM,选择杀死该Cgroup 中的某个进程。
  • memory.memsw,limit_in_bytes: 设定内存加上交换内存区的总量。
  • memory.oom_control: 如果设置为0,那么内存超过上限时,不会杀死进程,而是阻塞等待进程释放内存;同时系统会向用户态发送事件通知。
  • memory.stat: 报告内存使用信息。
  1. blkio

限制 Cgroup 对 阻塞 IO 的使用。

  • blkio.weight: 设置权值,范围在[100, 1000],属于比重分配,不是绝对带宽。因此只有当不同 Cgroup 争用同一个 阻塞设备时才起作用
  • blkio.weight_device: 对具体设备设置权值。它会覆盖上面的选项值。
  • blkio.throttle.read_bps_device: 对具体的设备,设置每秒读磁盘的带宽上限。
  • blkio.throttle.write_bps_device: 对具体的设备,设置每秒写磁盘的带宽上限。
  • blkio.throttle.read_iops_device: 对具体的设备,设置每秒读磁盘的IOPS带宽上限。
  • blkio.throttle.write_iops_device: 对具体的设备,设置每秒写磁盘的IOPS带宽上限。
  1. devices 子系统

控制 Cgroup 的进程对哪些设备有访问权限

  • devices.list: 只读文件,显示目前允许被访问的设备列表,文件格式为
    • 类型[a|b|c] 设备号[major:minor] 权限[r/w/m 的组合]
    • a/b/c 表示 所有设备、块设备和字符设备。
  • devices.allow: 只写文件,以上述格式描述允许相应设备的访问列表。
  • devices.deny: 只写文件,以上述格式描述禁止相应设备的访问列表。

Namespace
#

Namespace 是将内核的全局资源做封装,使得每个namespace 都有一份独立的资源,因此不同的进程在各自的namespace内对同一种资源的使用互不干扰。 举个例子,执行 sethostname 这个系统调用会改变主机名,这个主机名就是全局资源,内核通过 UTS Namespace可以将不同的进程分隔在不同的 UTS Namespace 中,在某个 Namespace 修改主机名时,另一个 Namespace 的主机名保持不变。

目前,Linux 内核实现了6种 Namespace。

Namespace 作用
IPC 隔离 System V IPC 和 POSIX 消息队列
Network 隔离网络资源
Mount 隔离文件系统挂载点
PID 隔离进程ID
UTS 隔离主机名和域名
User 隔离用户和用户组

与命名空间相关的三个系统调用:

  • clone创建全新的Namespace,由clone创建的新进程就位于这个新的namespace里。创建时传入 flags参数,可选值有 CLONE_NEWIPC, CLONE_NEWNET, CLONE_NEWNS, CLONE_NEWPID, CLONE_NEWUTS, CLONE_NEWUSER, 分别对应上面六种namespace。
  • unshare为已有进程创建新的namespace。
  • setns把某个进程放在已有的某个namespace里。

6种命名空间

  1. UTS namespace

UTS namespace 对主机名和域名进行隔离。为什么要隔离主机名?因为主机名可以代替IP来访问。如果不隔离,同名访问会出冲突。

  1. IPC namespace

Linux 提供很多种进程通信机制,IPC namespace 针对 System V 和 POSIX 消息队列,这些 IPC 机制会使用标识符来区别不同的消息队列,然后两个进程通过标识符找到对应的消息队列。

IPC namespace 使得 相同的标识符在两个 namespace 代表不同的消息队列,因此两个namespace 中的进程不能通过 IPC 来通信。

  1. PID namespace

隔离进程号,不同namespace 的进程可以使用相同的进程号。

当创建一个 PID namespace 时,第一个进程的PID 是1,即 init 进程。它负责回收所有孤儿进程的资源,所有发给 init 进程的信号都会被屏蔽。

  1. Mount namespace

隔离文件挂载点,每个进程能看到的文件系统都记录在 /proc/$$/mounts 里。在一个 namespace 里挂载、卸载的动作不会影响到其他 namespace。

  1. Network namespace

隔离网络资源。每个 namespace 都有自己的网络设备、IP、路由表、/proc/net 目录、端口号等。网络隔离可以保证独立使用网络资源,比如开发两个web 应用可以使用80端口。 新创建的 Network namespace 只有 loopback 一个网络设备,需要手动添加网络设备。

  1. User namespace

隔离用户和用户组。它的厉害之处在于,可以让宿主机上的一个普通用户在 namespace 里成为 0 号用户,也就是 root 用户。这样普通用户可以在容器内“随心所欲”,但是影响也仅限在容器内。

最后,回到 Docker 上,经过上述讨论,namespace 和 cgroup 的使用很灵活,需要注意的地方也很多。 Docker 通过 Libcontainer 来做这些脏活累活。用户只需要使用 Docker API 就可以优雅地创建一个容器。docker exec 的底层实现就是上面提过的 setns 。

rootfs
#

rootfs 代表一个 Docker 容器在启动时(而非运行后)其内部进程可见的文件系统视角,或者叫 Docker 容器的根目录。

先来看一下,Linux 操作系统内核启动时,内核会先挂载一个只读的 rootfs,当系统检测其完整性之后,决定是否将其切换到读写模式。

Docker 沿用这种思想,不同的是,挂载rootfs 完毕之后,没有像 Linux 那样将容器的文件系统切换到读写模式,而是利用联合挂载技术,在这个只读的 rootfs 上挂载一个读写的文件系统,挂载后该读写文件系统空空如也。Docker 文件系统简单理解为:只读的 rootfs + 可读写的文件系统。