白话容器基础(二):隔离与控制
“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这种更细粒度的资源管理平台上大行其道的重要原因。
不过,有利就有弊,基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。
首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。
尽管你可以在容器里通过 Mount Namespace 单独挂载其他不同版本的操作系统文件,比如 CentOS 或者 Ubuntu,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。
其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。
此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。
在研究完容器的“隔离”技术之后,我们再来研究一下容器的“限制”问题。
虽然第 100 号进程表面上被利用 Namespace 技术隔离了起来,但是它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。
而 Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。
Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。可以用 mount 指令把它们展示出来:
1 | $ mount -t cgroup |
可以看到,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对 CPU 子系统来说,我们就可以看到如下几个配置文件:
1 | $ ls /sys/fs/cgroup/cpu |
可以看到 cpu.cfs_period
和 cpu.cfs_quota
这样的关键词,这两个参数组合使用可以用来限制进程在长度为 cfs_period
时间内只能被分配到 cfs_quota
的 cpu 时间。
那如何利用这些配置文件进行资源的限制呢?
你需要在对应的子系统下面创建一个目录,比如,现在我们进入 /sys/fs/cgroup/cpu 目录下:
1 | $ mkdir container |
发现操作系统在新创建的 container 目录下自动生成了该子系统对应的资源限制文件。
现在我们尝试在后台执行这样一条脚本:
1 | $ while : ; do : ; done & |
这里执行了一个死循环,目的是把计算机的 CPU 吃满100%,根据它的输出,可以知道该进程的 PID 为 162337。
现在,我们可以用 top 指令确认一下 CPU 有没有被打满:
1 | $ top |
可以看到,刚刚启动的后台脚本已经把 CPU 吃到了 100%。
此时,我们可以查看当前 container 目录下的文件:
1 | $ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us |
可以看到 container 控制组里的 CPU quota 默认没有进行任何限制(-1),CPU period 也是默认的 100 ms(100000 us)。
接下来,就可以尝试对 CPU 资源进行限制。
比如,将 cfs_quota_us 修改为 20 ms(20000 us):
1 | $ echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us |
这意味着,在每 100 ms(cfs_period_us)时间内,被该控制组限制的进程只能使用 20 ms(cpu_quota_us)的 CPU 时间。
接下来,我们把被限制的进程的 PID 写入 container 组里的 tasks 文件,上面的设置就会对该进程生效了:
1 | $ echo 162337 > /sys/fs/cgroup/cpu/container/tasks |
可以再用 top 指令查看一下:
1 | $ top |
可以看到,该进程的 CPU 使用率降到了 20% 左右(测试发现这里不是精确的 20%,可能产生小幅度的波动)。
Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。
而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了,比如这样一条命令:
1 | $ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash |
在启动这个容器后,我们可以通过查看 Cgroups 文件系统下,CPU 子系统中,“docker”这个控制组里的资源限制文件的内容来确认:
1 | $ cat /sys/fs/cgroup/cpu/docker/cdda9d0707b4978d8c56b33b45e9faeeee861a46a3c0ad3ef8af78d275644532/cpu.cfs_period_us |
总结
从上述的讲述中,可以发现,容器是一个“单进程”模型。
由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。
但是,还有更好的解决方法,这是因为容器本身的设计,就是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。
另外,跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的是 /proc 文件系统的问题。
众所周知,Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。
但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。
造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:**/proc 文件系统不了解 Cgroups 限制的存在**。
在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的 CPU 核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方。