容器基础2:隔离与限制

在我们真的了解容器吗这文章中,我们讨论了Linux容器中用来实现“隔离”的技术手段:Namespace。接下来,我们一起来了解一下容器的限制机制。

一、再谈隔离

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

此外,我们需要知道的是,Docker Engine或者其他任何容器管理工具,并不会像虚拟化技术中的Hypervisor那样对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身。这也就是说,用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的Namespace参数。而Docker项目在里扮演的角色,更多的是旁路式的辅助和管理工作。当然,像Docker这样的角色其实也是可以去掉的。

现在我们应该能理解为什么Docker项目比虚拟机更受欢迎。这是因为,使用虚拟化技术作为应用沙盒,就必须要由Hypervisor来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完成的Guest OS才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用Namespace作为隔离手段的容器并不需要单独的Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

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

首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。尽管我们可以在容器中通过Mount Namespace单独挂载其他不同版本的操作系统文件,比如CentOS或者Ubuntu,但这并不能改变其共享宿主机内核的事实。这意味着,如果我们要在Windows宿主机上运行Linux容器,或者在低版本的Linux宿主机上运行高版本的Linux容器,都是行不通的。而相比之下,拥有硬件虚拟化技术和独立Guest OS的虚拟机就要方便得多了。

其次,在Linux内核中,有很多资源和对象是不能被Namespace化的,最典型的例子就是时间。当我们在容器中使用系统调用settimeofday(2)修改时间后,整个宿主机的时间都会被随之修改,这显然是不符合预期的。而在虚拟机中我们是可以随便折腾的。

由于上述问题,尤其是共享宿主机内核这一事实,容器暴露出来的攻击面是相当大的,应用“越狱”的难度比虚拟机低很多。尽管在实践中我们确实可以使用Seccomp等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,必然会拖累容器的性能。更何况,默认情况下,谁也不知道到底该开启哪些系统调用,又该禁止哪些系统调用。所以,在生产环境中,没有人敢把运行在物理机上的Linux容器直接暴露到公网上。

二、理解Cgroups机制

在明白了隔离机制后,我们再来讨论一下容器的“限制”问题。

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

Linux Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能。Linux Cgroups的全称是Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等。此外,Cgroups还能对进程进行优先级设置和审计,以及将进程挂起和恢复等操作。接下来,我们重点讨论它的“限制”能力。

2.1、Cgroups的操作接口

在Linux中,Cgroups给用户暴露出来的操作接口是文件系统,即它以文件和目录的形式组织在操作系统的/sys/fs/cgroup目录下。我们可以用mount指令把它们展示出来,如下是Ubuntu 18.04的结果:

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

可以看到,它的输出结果,是一系列文件系统目录。

2.2、Ggroups的子系统

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# ls -l /sys/fs/cgroup/cpu/
total 0
-rw-r--r-- 1 root root 0 Oct 1 11:15 cgroup.clone_children
-rw-r--r-- 1 root root 0 Oct 1 11:15 cgroup.procs
-r--r--r-- 1 root root 0 Oct 1 11:15 cgroup.sane_behavior
-r--r--r-- 1 root root 0 Oct 1 11:15 cpuacct.stat
-rw-r--r-- 1 root root 0 Oct 1 11:15 cpuacct.usage
-r--r--r-- 1 root root 0 Oct 1 11:15 cpuacct.usage_all
-r--r--r-- 1 root root 0 Oct 1 11:15 cpuacct.usage_percpu
-r--r--r-- 1 root root 0 Oct 1 11:15 cpuacct.usage_percpu_sys
-r--r--r-- 1 root root 0 Oct 1 11:15 cpuacct.usage_percpu_user
-r--r--r-- 1 root root 0 Oct 1 11:15 cpuacct.usage_sys
-r--r--r-- 1 root root 0 Oct 1 11:15 cpuacct.usage_user
-rw-r--r-- 1 root root 0 Oct 1 11:15 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 Oct 1 11:15 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 Oct 1 11:15 cpu.shares
-r--r--r-- 1 root root 0 Oct 1 11:15 cpu.stat
-rw-r--r-- 1 root root 0 Oct 1 11:15 notify_on_release
-rw-r--r-- 1 root root 0 Oct 1 11:15 release_agent
-rw-r--r-- 1 root root 0 Oct 1 11:15 tasks

在输出中,我们可以看到有cfs_period和cfs_quota这样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为cfs_period的一段时间内,只能被分配到总量为cfs_quota的CPU时间。

那我们该如何使用这些配置文件呢?接下来,我们通过一个案例,实践一下这些配置文件的使用。

2.3、实践:使用Cgroups限制进程

2.3.1、生成控制组

首先,我们需要在相应的子系统下创建一个目录,比如,在CPU子系统对应的目录下创建一个container目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# cd /sys/fs/cgroup/cpu
# mkdir container
# ls -l container/
total 0
-rw-r--r-- 1 root root 0 Oct 1 11:31 cgroup.clone_children
-rw-r--r-- 1 root root 0 Oct 1 11:31 cgroup.procs
-r--r--r-- 1 root root 0 Oct 1 11:31 cpuacct.stat
-rw-r--r-- 1 root root 0 Oct 1 11:31 cpuacct.usage
-r--r--r-- 1 root root 0 Oct 1 11:31 cpuacct.usage_all
-r--r--r-- 1 root root 0 Oct 1 11:31 cpuacct.usage_percpu
-r--r--r-- 1 root root 0 Oct 1 11:31 cpuacct.usage_percpu_sys
-r--r--r-- 1 root root 0 Oct 1 11:31 cpuacct.usage_percpu_user
-r--r--r-- 1 root root 0 Oct 1 11:31 cpuacct.usage_sys
-r--r--r-- 1 root root 0 Oct 1 11:31 cpuacct.usage_user
-rw-r--r-- 1 root root 0 Oct 1 11:31 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 Oct 1 11:31 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 Oct 1 11:31 cpu.shares
-r--r--r-- 1 root root 0 Oct 1 11:31 cpu.stat
-rw-r--r-- 1 root root 0 Oct 1 11:31 notify_on_release
-rw-r--r-- 1 root root 0 Oct 1 11:31 tasks

这个目录就是一个“控制组”。可以看到,操作系统在我们创建的container目录下,自动生成了该子系统对应的资源限制文件。

2.3.2、启动进程

现在,我们在命令行执行如下命令:

1
2
# while :; do :; done &
[1] 59599

在这里,我们执行了一个死循环,它可以它计算机的CPU吃到100%,根据它的输出,我们可以看到这条命令在后台的进程号(PID)是59599。

1
2
# top
%Cpu(s):100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

通过top命令,我们可以看到,CPU的使用率已经是100%了。

2.3.3、设置限制

通过查看container目录下的文件:

1
2
3
4
# cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us 
-1
# cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
100000

我们可以看到,container控制组里的CPU quota还没有任何限制(即:-1),CPU period则是默认的100ms(100000us)。

接下来,我们通过修改这些文件的内容来设置限制。比如,向container组里的cfs_quota文件写入20ms(20000us):

1
# echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

这个操作的含义是,在每100ms的时间里,被该控制组限制的进程只能使用20ms的CPU时间,也就是说这个进程最多只能使用20%的CPU带宽。

2.3.4、限制进程

最后,我们把要限制的进程的PID写入container组里的tasks文件中,上面的设置就会对该进程生效了:

1
# echo 59599 > /sys/fs/cgroup/cpu/container/tasks

此时,我们再用top命令查看CPU的使用情况:

1
2
# top
%Cpu(s): 20.4 us, 0.3 sy, 0.0 ni, 79.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

可以看到,计算机的CPU使用率立刻降到了20%。

2.4、小结

除了CPU子系统外,Cgroups的每一个子系统都有其独有的资源限制能力,比如:

  • blkio,为块设备设定IO限制,一般用于磁盘等设备;
  • cpuset,为进程分配单独的CPU核和对应的内存节点;
  • memory,为进程设定内存使用限制。

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
2
3
4
# 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 Cgroups是Docker实现资源限制的主要方法。
  • Linux Cgroups的全称是Linux Control Group,它实际上就是一些子系统目录加上一组相应的资源文件的组合。

容器基础2:隔离与限制
https://kuberxy.github.io/2020/10/01/容器基础2:隔离与限制/
作者
Mr.x
发布于
2020年10月1日
许可协议