容器的历史

这时候关键来了,由于需要在一个虚拟机上启动很多个来自不同用户的应用, Cloud Foundry 会
调用操作系统的 Cgroups 和 Namespace 机制为每一个应用单独创建一个称作 “ 沙盒 ” 的隔离环境,
然后在 “ 沙盒 ” 中启动这些应用进程。这样,就实现了把多个用户的应用互不干涉地在虚拟机里批
量地、自动地运行起来的目的——这一点和docker 是一样的。

而 Docker 镜像解决的,恰恰就是打包这个根本性的问题。 所谓 Docker 镜像,其实就是一个
压缩包。但是这个压缩包里的内容,比 PaaS 的应用可执行文件 + 启停脚本的组合就要丰富多了。
实际上,大多数 Docker 镜像是直接由一个完整操作系统的所有文件和目录构成的,所以这个压
缩包里的内容跟你本地开发和测试环境用的操作系统是完全一样的—-这一点是docker的特点

Docker 项目给 PaaS 世界带来的 “ 降维打击 ” ,其实是提供了一种非常便利的打包机
制。这种机制直接打包了应用运行所需要的整个操作系统,从而保证了本地环境和云端环
境的高度一致,避免了用户通过 “ 试错 ” 来匹配两种不同运行环境之间差异的痛苦过程。

而 Docker 项目之所以能取得如此高的关注,一方面正如前面我所说的那样,它解决了应用
打包和发布这一困扰运维人员多年的技术难题;而另一方面,就是因为它第一次把一个纯
后端的技术概念,通过非常友好的设计和封装,交到了最广大的开发者群体手里。

而 Swarm 的最大亮点,则是它完全使用 Docker 项目原本的容器管理
API 来完成集群管理,所以在部署了 Swarm 的多机环境下,用户只需要使用原先的 Docker 指令创建一个容器,这个请
求就会被 Swarm 拦截下来处理,然后通过具体的调度算法找到一个合适的 Docker Daemon 运行
起来。

Fig项目是docker compose项目的前身,它第一次在开发者面前提出了容器编排的概念
其实, “ 编排 ” ( Orchestration )在云计算行业里不算是新词汇,它主要是指用户如何通过某些工
具或者配置来完成一组虚拟机以及关联资源的定义、配置、创建、删除等工作,然后由云计算平
台按照这些指定的逻辑来完成的过程。

虽然不能提供像 Swarm 那样的原生 Docker API , Mesos 社区却拥有一个独特的竞争力:超
大规模集群的管理经验。
早在几年前, Mesos 就已经通过了万台节点的验证, 2014 年之后又被广泛使用在 eBay 等大型互
联网公司的生产环境中。而这次通过 Marathon 实现了诸如应用托管和负载均衡的 PaaS 功能之
后, Mesos+Marathon 的组合实际上进化成了一个高度成熟的 PaaS 项目,同时还能很好地支持
大数据业务。
所以,在这波容器化浪潮中, Mesosphere 公司不失时机地提出了一个名叫 “DC/OS” (数据中心
操作系统)的口号和产品,旨在使用户能够像管理一台机器那样管理一个万级别的物理机集群,
并且使用 Docker 容器在这个集群里自由地部署应用。而这,对很多大型企业来说具有着非同寻
常的吸引力。

所以这次, Google 、 RedHat 等开源基础设施领域玩家们,共同牵头发起了一个名为
CNCF ( Cloud Native Computing Foundation )的基金会。这个基金会的目的其实很容易理解:
它希望,以 Kubernetes 项目为基础,建立一个由开源基础设施领域厂商主导的、按照独立基金
会方式运营的平台级社区,来对抗以 Docker 公司为核心的容器商业生态。
而为了打造出这样一个围绕 Kubernetes 项目的 “ 护城河 ” , CNCF 社区就需要至少确保两件事情:

  1. Kubernetes 项目必须能够在容器编排领域取得足够大的竞争优势;
  2. CNCF 社区必须以 Kubernetes 项目为核心,覆盖足够多的场景。

在容器编排领域, Kubernetes 项目需要面对来自 Docker 公司和 Mesos 社区两个方向的压力。不
难看出, Swarm 和 Mesos 实际上分别从两个不同的方向讲出了自己最擅长的故事: Swarm 擅长的
是跟 Docker 生态的无缝集成,而 Mesos 擅长的则是大规模集群的调度与管理。
这两个方向,也是大多数人做容器集群管理项目时最容易想到的两个出发点。也正因为如
此, Kubernetes 项目如果继续在这两个方向上做文章恐怕就不太明智了。
所以这一次, Kubernetes 选择的应对方式是: Borg 。
如果你看过 Kubernetes 项目早期的 GitHub Issue 和 Feature 的话,就会发现它们大多来自于 Borg
和 Omega 系统的内部特性,这些特性落到 Kubernetes 项目上,就是 Pod 、 Sidecar 等功能和设计
模式。
这就解释了,为什么 Kubernetes 发布后,很多人 “ 抱怨 ” 其设计思想过于 “ 超前 ” 的原因:
Kubernetes 项目的基础特性,并不是几个工程师突然 “ 拍脑袋 ” 想出来的东西,而是 Google 公司
在容器化基础设施领域多年来实践经验的沉淀与升华。这,正是 Kubernetes 项目能够从一开始
就避免同 Swarm 和 Mesos 社区同质化的重要手段。

而 Kubernetes 的应对策略则是反其道而行之,开始在整个社区推进 “ 民主化 ” 架构 ,即:从
API 到容器运行时的每一层, Kubernetes 项目都为开发者暴露出了可以扩展的插件机制,鼓励用
户通过代码的方式介入到 Kubernetes 项目的每一个阶段。
Kubernetes 项目的这个变革的效果立竿见影,很快在整个容器社区中催生出了大量的、基于
Kubernetes API 和扩展接口的二次创新工作,比如:
目前热度极高的微服务治理项目 Istio ;
被广泛采用的有状态应用部署框架 Operator ;
还有像 Rook 这样的开源创业项目,它通过 Kubernetes 的可扩展接口,把 Ceph 这样的重量级
产品封装成了简单易用的容器存储插件。

容器就是一种特殊的进程

本来,每当我们在宿主机上运行了一个 /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() ,比如:

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

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

  1. 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 容器最基本的实现原理了。

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

容器的隔离与限制

lalal.png
这幅图的左边,画出了虚拟机的工作原理。其中,名为 Hypervisor 的软件是虚拟机最主要的部
分。它通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如 CPU 、内存、
I/O 设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即 Guest OS 。
这样,用户的应用进程就可以运行在这个虚拟的机器中,它能看到的自然也只有 Guest OS 的文
件和目录,以及这个机器里的虚拟设备。这就是为什么虚拟机也能起到将不同的应用进程相互隔
离的作用。不应该把 Docker Engine 或者任何容器管理工具放在跟
Hypervisor 相同的位置,因为它们并不像 Hypervisor 那样对应用进程的隔离环境负责,也不会创
建任何实体的 “ 容器 ” ,真正对隔离环境负责的是宿主机操作系统本身:

不过,有利就有弊,基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其
中最主要的问题就是:隔离得不彻底。首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个
宿主机的操作系统内核。

尽管你可以在容器里通过 Mount Namespace 单独挂载其他不同版本的操作系统文件,比如
CentOS 或者 Ubuntu ,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在 Windows
宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通
的。
而相比之下,拥有硬件虚拟化技术和独立 Guest OS 的虚拟机就要方便得多了。最极端的例子
是, Microsoft 的云计算平台 Azure ,实际上就是运行在 Windows 服务器集群上的,但这并不妨碍
你在它上面创建各种 Linux 虚拟机出来。

其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时
间。
这就意味着,如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时
间都会被随之修改,这显然不符合用户的预期。相比于在虚拟机里面可以随便折腾的自由度,在
容器里部署应用的时候, “ 什么能做,什么不能做 ” ,就是用户必须考虑的一个问题。
此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大
的,应用 “ 越狱 ” 的难度自然也比虚拟机低得多。
更为棘手的是,尽管在实践中我们确实可以使用 Seccomp 等技术,对容器内部发起的所有系统
调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,一定会拖累
容器的性能。何况,默认情况下,谁也不知道到底该开启哪些系统调用,禁止哪些系统调用。
所以,在生产环境中,没有人敢把运行在物理机上的 Linux 容器直接暴露到公网上。当然,我后
续会讲到的基于虚拟化或者独立内核技术的容器实现,则可以比较好地在隔离与性能之间做出平
衡。

在介绍完容器的 “ 隔离 ” 技术之后,我们再来研究一下容器的 “ 限制 ” 问题。
也许你会好奇,我们不是已经通过 Linux Namespace 创建了一个 “ 容器 ” 吗,为什么还需要对容器
做 “ 限制 ” 呢?
我还是以 PID Namespace 为例,来给你解释这个问题。
虽然容器内的第 1 号进程在 “ 障眼法 ” 的干扰下只能看到容器里的情况,但是宿主机上,它作为第
100 号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第 100 号进程表面上
被隔离了起来,但是它所能够使用到的资源(比如 CPU 、内存),却是可以随时被宿主机上的其
他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情
况,显然都不是一个 “ 沙盒 ” 应该表现出来的合理行为。而 Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。

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

此外, Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。在今天的
分享中,我只和你重点探讨它与容器关系最紧密的 “ 限制 ” 能力,并通过一组实践来带你认识一下
Cgroups 。在 Linux 中, Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操
作系统的 /sys/fs/cgroup 路径下。在 Ubuntu 16.04 机器里,我可以用 mount 指令把它们展示出来,
这条命令是:

  1. mount -t cgroup
  2. cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,name=systemd)
  3. cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)
  4. cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
  5. cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
  6. cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
  7. cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
  8. cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
  9. cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
  10. cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
  11. cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
  12. cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
  13. cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)

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

  1. doct@doct ~ ls /sys/fs/cgroup/cpu
  2. cgroup.clone_children cpuacct.usage_percpu_sys cpu.stat
  3. cgroup.procs cpuacct.usage_percpu_user docker
  4. cgroup.sane_behavior cpuacct.usage_sys notify_on_release
  5. cpuacct.stat cpuacct.usage_user release_agent
  6. cpuacct.usage cpu.cfs_period_us system.slice
  7. cpuacct.usage_all cpu.cfs_quota_us tasks
  8. cpuacct.usage_percpu cpu.shares user.slice

如果熟悉 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% ,根据它的输出,我们可以看到这
个脚本在后台运行的进程号( PID )是 226 。
这样,我们可以用 top 指令来确认一下 CPU 有没有被打满:
$ 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
在输出里可以看到, 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

通过以上讲述,你现在应该能够理解,一个正在运行的 Docker 容器,其实就是一个启用了多个
Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。
这也是容器技术中一个非常重要的概念,即:容器是一个 “ 单进程 ” 模型。
由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他
后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应
用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很
多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。
但是,在后面分享容器设计模式时,我还会推荐其他更好的解决办法。这是因为容器本身的设
计,就是希望容器和应用能够同生命周期 ,这个概念对后续的容器编排非常重要。否则,一旦
出现类似于 “ 容器是正常运行的,但是里面的应用早已经挂了 ” 的情况,编排系统处理起来就非常
麻烦了。

另外,跟 Namespace 的情况类似, Cgroups 对资源的限制能力也有很多不完善的地方,被提及最
多的自然是 /proc 文件系统的问题。
众所周知, Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率
等,这些文件也是 top 指令查看系统信息的主要数据来源。
但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数
据,而不是当前容器的数据。
造成这个问题的原因就是, /proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资
源限制,即: /proc 文件系统不了解 Cgroups 限制的存在。
在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的 CPU 核数、可用内存等
信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器
化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方

深入理解容器镜像