我们在容器的本质系列文章中多次强调过构成容器的三个支柱(three pillars):命名空间,Cgroups和chroot。而Cgroups和命名空间可以说是操作提供提供的一对能力cp(couple),其中命名空间负责对资源进行隔离,Cgroups对进程能够使用的资源总量进行限制,这是容器进程能够运行,能够在一台机器多进程运行,能够稳定运行的基础。
具体来说Cgroups约束了容器进程能够使用例如内存,CPU以及网络设备等系统资源,从安全的角度看,可靠性是安全非常重要的一部分,合理配置Cgroups能够约束进程资源使用的行为,不会让变节的进程恶意占用机器上所有可用的资源从而导致其他进程失败退出这种场景。另外control groups控制组也有一个叫pid的控制项,可以有效用来预防fork bomb攻击,黑客不断的调用fork系统调用来创建新的进程,一起消耗掉所有可用的进程ID资源。fork bomb大部分情况下通过并发的形式同时创建大量的线程,可能在PID没有耗尽之前,机器的资源就被消耗殆净了,运行在其中的业务应用就无法正常对外提供服务了。
咱们在上篇文章中介绍过,容器进程和普通运行在宿主机上的进程没有本质的区别,因此我们我们可以使用cgroups来约束每个运行的容器进程可是使用的资源,来防止哪些贪婪的进程吃掉机器上所有可用的硬件资源。
首先我们来详细了解一下资源在操作系统上是如何被管理的,以及硬件资源和cgroups的关系。具体来说,每一类硬件资源都有对应的cgroups类型来管理,运行在Linux上的进程在创建的时候,从父进程继承cgroups信息,而进程隶属于不同类型cgroups。
Linux操作系统内核通过一组pseudo-filesystems来和cgroups进行数据交互,这些伪文件一般被保存在路径/sys/fs/cgroup ,比如在笔者的本地虚拟机上对应目录下,运行ls命令的输出如下:
vagrant@vagrant:~$ cd /sys/fs/cgroup
vagrant@vagrant:/sys/fs/cgroup$ ls
blkio cpu cpuacct cpu,cpuacct cpuset devices freezer hugetlb memory net_cls net_cls,net_prio net_prio perf_event pids rdma systemd unified
从上边的输出可以看出,我们系统上的资源,包括cpu,memory,网络等都有对应的文件夹,而管理cgroups就主要和读取和写入这些文件夹中的文件相关,我们来cd到memory文件夹内看看,在机器上运行ls的输出如下:
由于cgroups是通过文件来对进程的资源进行约束,因此我们可以直接写数据到上图中对应文件来控制cgroup,另外上图中罗列的文件中,也包含操作系统需要的cgroups状态相关的信息。
坦白讲,如果直接看这些文件,我们很难直接分辨出来那些用来控制进程,那些用来给操作系统内核提供统计数据。但是基于文件名,搞技术的同学也能猜个八九,比如上图中的文件memory.limit_in_bytes,你大概能猜到这个文件用来限制进程能够使用的内存限制;比如文件memory.max_usage_in_bytes,通过名字我们大概能猜测到这个文件用来给操作系统内核报告控制组中内存使用的高水位。
在Linux操作系统上,memory文件夹属于根目录,控制着机器上所有进程的内存资源使用。而如果我们需要限制某个特定进程的内存资源使用,我们就需要创建一个新的cgroup,然后把进程id赋予这个控制组。
创建新的cgroups就是在memory文件夹下创建一个新的文件夹,操作系统内核会自动创建所有需要的子目录和文件:
如上图所示,我们创建了名叫yunpan的控制组cgroups后,操作系统内核会自动创建需要的文件,详细介绍这些文件在cgroups中具体承担的职责不在咱们的讨论范围内,大家如果感兴趣可以自行查询Linux操作系统的官方文档。
直接操作这个文件夹肯定不是常规的用法,当我们在机器上启动容器进程的时候,容器运行时会自动为容器进程创建新的cgroups,我们可以在Ubuntu操作系统上通过lscgroup工具来查看机器上所有cgroup信息。由于机器上有一般有很多很多cgroups的定义,因此咱们为了阅读体验,将讨论范围放在大家在运行虚拟化应用时最关心的内存资源上。
在启动新的容器进程之前,我们先对内存进行备份:root@vagrant:~$ lscgroup memory:/ > before.memory,然后通过命令sudo runc run sh来启动一个sh的容器进程,然后我们在另外一个窗口再次备份memory资源信息,最后使用diff来进行对比,在笔者的机器上输出如下:
vagrant@ubuntu-bionic:~$ lscgroup memory:/ > after.memory
vagrant@ubuntu-bionic:~$ diff before.memory after.memory
2a3
> memory:/user.slice/sh
我们可以看到差异部分就是生成了叫sh(进程名)的新的控制组,不出所料的是,这个控制组中包含了sh进程的资源控制信息,我们可以通过命令ls -l /sys/fs/cgroup/memory/user.slice/sh来验证。如下图所示:
读到这里大家可能会问,如果从容器里边看控制组,是不是和我们从外边看到的一样?我们在容器中运行cat /proc/$$/cgroup,从输出的结果来看,和我们从宿主机上看到的完全一致,如下图所示:
接下来我们先通过命令查看默认情况下内存的限制,以通过修改config.json后,控制组显示的内存限制大小:
root@ubuntu-bionic:/sys/fs/cgroup/memory# cat user.slice/sh/memory.limit_in_bytes
9223372036854771712
root@ubuntu-bionic:/sys/fs/cgroup/memory# cat user.slice/sh/memory.limit_in_bytes
5296128
从第二个输出可以看到,我们通过修改容器进程的启动参数,成功修改了内存的上限。了解了cgroup如何工作之后,接下来我们来看看在Docker中,是如何对cgroup进行操作的。
对于Docker来说,会自动为每种类型的资源创建cgroups,我们可以通过查看cgroups下名叫docker的文件夹来验证,运行命令ls */docker | grep docker,在笔者的ubuntu机器上输出如下:
当我们通过docker run启动一个容器进程后,docker会自动在docker文件夹下创建资源的约束信息,比如我们通过命令:
root@ubuntu-bionic:/sys/fs/cgroup# docker run --rm --memory 100M -d alpine sleep 10000
Unable to find image 'alpine:latest' locally
b86fef371ae0c6f53b82174115661bfea727af9ba5369c3b09575c6fedc0d3b2
创建id为b86fef371ae0c6f53b82174115661bfea727af9ba5369c3b09575c6fedc0d3b2新的容器进程,会自动在memory/docker目录下创建一容器实例ID为文件夹的控制组,如下图所示:
接着我们可以通过直接查看procs文件来验证控制组关联的进程ID为7934,并且通过查看7934这个进程会发现就是我们的sleep进程,我们也可以验证文件memory.limit_in_bytes中的内存限制信息,和输入的100m一致。
到这里为止我们对控制组的工作机制就有了完整的理解了,简单来说控制组就是操作系统上内核关心的一组文件,每个类型的资源都有自己的文件夹,我们通过cgroups.procs来把进程和对应文件夹的资源配置关联起来。对于docker来说,会按照容器ID创建控制组管理每个容器实例的资源。
好了,今天的内容就这么多了,希望大家通过阅读本篇文章,对控制组有完整透彻的认识,下篇文章我们继续讨论命名空间,容器进程时间是如何做到隔离的,敬请期待!