Docker原理—-Cgroup
介绍
在上篇介绍完容器的”隔离”技术之后,我们再来研究一下容器的”限制”问题
也许你会好奇,我们不是已经通过 Linux Namespace 创建了一个“容器”吗,为什么还需要对容器做“限制”呢?
我还是以 PID Namespace 为例,来给你解释这个问题。
虽然容器的第一号进程只能看到容器里的情况,但是由于是直接运行在宿主机上,所以它和宿主机上其他所有进程之间依然是平等的竞争关系.这就意味着虽然该进程在视图上被隔离起来了,但是他能够使用宿主机上的所有资源(比如CPU,内存).
这显然不是一个”沙盒”应该表现出来的合理行为
而Linux Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能
Cgroups
Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。在今天的分享中,我只和你重点探讨它与容器关系最紧密的“限制”能力,并通过一组实践来带你认识一下 Cgroups。
从字面上理解,cgroups就是把任务放到一个组里面统一加以控制。本质上来说,cgroups是内核附加在程序上的一系列hook,通过程序运行时对资源的调度触发相应的钩子以达到资源跟踪和限制的目的。在cgroup里,任务(task)就是系统的一个进程或者线程。
cgroups的四大作用:
- 资源限制: 比如设定任务内存使用的上限。
- 优先级分配: 比如给任务分配CPU的时间片数量和磁盘IO的带宽大小来控制任务运行的优先级。
- 资源统计:比如统计CPU的使用时长、内存用量等。这个功能非常适用于计费。
- 任务控制:cgroups可以对任务执行挂起、恢复等操作。
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。,我可以用 mount 指令把它们展示出来,这条命令是:
1 | cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd) |
cgroups以操作文件的方式作为API。它的操作目录是/sys/fs/cgroup
。我们来看看这个目录下有什么内容:
1 | [root@edward-rhel7-2 cloud-user]# ls /sys/fs/cgroup |
可以看到,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统(sub system)。子系统就是资源调度器。比如CPU子系统可以控制CPU的时间分配,memory子系统可以限制内存的使用量.这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。
Cgroup对CPU资源限制
对 CPU 子系统来说,我们就可以看到如下几个配置文件,这个指令是:
1 | ls /sys/fs/cgroup/cpu |
如果熟悉 Linux CPU 管理的话,你就会在它的输出里注意到 cfs_period 和 cfs_quota 这样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间。
而这样的配置文件又如何使用呢?
你需要在对应的子系统下面创建一个目录,比如,我们现在进入 /sys/fs/cgroup/cpu 目录下:
1 | root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container |
这个目录就称为一个“控制组”。你会发现,操作系统会在你新创建的 container 目录下,自动生成该子系统对应的资源限制文件。
现在,我们在后台执行这样一条脚本:
1 | $ while : ; do : ; done & |
显然,它执行了一个死循环,可以把计算机的 CPU 吃到 100%,根据它的输出,我们可以看到这个脚本在后台运行的进程号(PID)是 8498。
这样,我们可以用 top 指令来确认一下 CPU 有没有被打满.在输出里可以看到,CPU 的使用率已经 100% 了(%Cpu0 :100.0 us)。
1 | Tasks: 154 total, 2 running, 152 sleeping, 0 stopped, 0 zombie |
而此时,我们可以通过查看 container 目录下的文件,看到 container 控制组里的 CPU quota 还没有任何限制(即:-1),CPU period 则是默认的 100 ms(100000 us):
1 | $ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us |
接下来,我们可以通过修改这些文件的内容来设置限制。
比如,向 container 组里的 cfs_quota 文件写入 20 ms(20000 us):
1 | echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us |
结合前面的介绍,你应该能明白这个操作的含义,它意味着在每 100 ms 的时间里,被该控制组限制的进程只能使用 20 ms 的 CPU 时间,也就是说这个进程只能使用到 20% 的 CPU 带宽。
接下来,我们把被限制的进程的 PID 写入 container 组里的 tasks 文件,上面的设置就会对该进程生效了:
1 | echo 8498 > /sys/fs/cgroup/cpu/container/tasks |
我们可以用 top 指令查看一下:
1 | Tasks: 154 total, 2 running, 152 sleeping, 0 stopped, 0 zombie |
可以看到,计算机的 CPU 使用率立刻降到了 20%以内
除 CPU 子系统外,Cgroups 的每一项子系统都有其独有的资源限制能力,比如:
- blkio,为块设备设定I/O 限制,一般用于磁盘等设备;
- cpuset,为进程分配单独的 CPU 核和对应的内存节点;
- memory,为进程设定内存使用的限制。
Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。
而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了,比如这样一条命令
1 | [root@docker-dev container]# docker run -it -d --cpu-period=100000 --cpu-quota=20000 busybox /bin/sh |
在启动这个容器后,我们可以通过查看 Cgroups 文件系统下,CPU 子系统中,“docker”这个控制组里的资源限制文件的内容来确认:
1 | [root@docker-dev container]# cat /sys/fs/cgroup/cpu/docker/c992cf3cc50c/cpu.cfs_quota_us |
这就意味着这个 Docker 容器,只能使用到 20% 的 CPU 带宽。
Cgroup对内存限制
内存资源和CPU不同,CPU属于可压缩资源.当进程触发CPU限制阈值时,进程仍然可以正常运行,只是进程能使用的CPU分片时间受到限制.然而内存属于不可压缩资源,当进程触发内存资源阈值时,进程会立刻被杀死,也就是触发OOM事件.
下面用python的递归模拟一个内存占用的程序
1 | import time |
在/sys/fs/cgroup/memory/
目录下创建一个测试文件夹
1 | [root@docker-dev ~]# cd /sys/fs/cgroup/memory/ |
在该目录下,限制内存阈值,这里设置为10K
1 | [root@docker-dev ~]# cd /sys/fs/cgroup/memory/mem_test/ |
运行python程序,同时开启另一个shell终端,获取该进程的PID
1 | [root@docker-dev ~]# python3 mem.py |
将22958这个PID写入到mem_test
目录下的tasks文件内
1 | [root@docker-dev mem_test]# echo 22958 > tasks |
此时.python3的进程会被杀死,出现OOM现象
1 | [root@docker-dev mem_test]# less /var/log/messages | grep oom |
docker cgroup
通过上面2个小例子,我们演示了cgroup对本机进程的资源限制效果.docker在启动容器时也允许我们对该容器的CPU和内存进行一些资源限制.但是其资源限制的本质也同样是利用cgroup的功能.下面我们运行一个容器.该容器运行一个上文中的while死循环,但是这次我们对容器的CPU资源进行限制.
1 | [root@docker-dev mem_test]# docker run -d --name c2 --cpu-period=100000 --cpu-quota=20000 hub.doweidu.com/base/centos-demo:7 bash -c "while : ; do : ; done" |
通过top命令,我们可以看到容器的CPU限制已经生效了.cpu3被限制在20%的使用率之内
1 | top - 20:53:50 up 31 days, 10:09, 2 users, load average: 0.00, 0.01, 0.05 |
在/sys/fs/cgroup/cpu/docker
目录下.可以看到新生成了一个目录7b1cb8734d905dd25eb1cdcf4cb63ebf8c7e6182d90639db4ad15bc99ba19f63
.其实这就是我们上一步中刚启动的容器的ID.
1 | [root@docker-dev docker]# ll |
进入该容器ID的目录内.可以看到cpu.cfs_quota_us
文件已经设置了限额20000us.
1 | [root@docker-dev docker]# cd 7b1cb8734d905dd25eb1cdcf4cb63ebf8c7e6182d90639db4ad15bc99ba19f63 |
查看该容器的Pid以及tasks可以看到.docker自动将容器的进程Pid写入到了tasks文件中.
1 | [root@docker-dev 7b1cb8734d905dd25eb1cdcf4cb63ebf8c7e6182d90639db4ad15bc99ba19f63]# docker inspect c2 -f {{.State.Pid}} |
通过上面的例子中可以看到,docker使用cgroup解决了多个容器之前的资源竞争和互相干扰的问题.
总结
通过以上讲述,你现在应该能够理解,一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。
这也是容器技术中一个非常重要的概念,即:容器是一个“单进程”模型。
由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。
但是,在后面分享容器设计模式时,我还会推荐其他更好的解决办法。这是因为容器本身的设计,就是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。
另外,跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。
众所周知,Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。
但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。
造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。