目录

容器 | Cgroup 整体介绍

Cgroups 是 Control Groups(控制组)的缩写,是 Linux 内核(从 Linux 2.6.24 开始)提供的一种可以用来将任务分组,并对一个进程组所能使用的多种物理资源(包括 CPU、内存、磁盘、网络带宽)进行限制、监控和隔离的功能。此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。

那么为什么除了 Namespace 之外还需要 Cgroups 呢?因为虽然容器里的 1 号进程只能看到容器里的情况,但是在宿主机上,它作为 100 号进程(比如宿主机上它的 PID 是 100)与其他所有进程之间依然是平等的竞争关系。也就是说,虽然 100 号进程表面上被隔离了起来,但是它能够使用的资源(比如 CPU、内存)是全部的,它可能把所有资源耗光。那么这样显然不是一个容器应该表现出来的合理行为。

Cgroups 中的分组功能是在内核中的核心 cgroup 代码中实现的,而资源跟踪和限制则是在每一种资源(例如 CPU、内存)的子系统中分别实现的。

之外,Cgroups 通过一个叫做 cgroupfs 的伪文件系统提供了用户接口,即用户操作 Cgroups 可以通过文件系统。cgroupfs 一般被挂载在 /sys/fs/cgroup 路径下。我们可以通过 mount 命令来查看一下,可以发现输出结果是一系列文件系统目录。

https://img.dawnguo.cn/Container/image-20201007112804858.png

我们查看 /sys/fs/cgroup 的时候还可以看到一系列子目录,这些子目录又被称为子系统。这些都是我这台机器上当前可以被 Cgroups 进行限制的资源种类。 比如:

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

https://img.dawnguo.cn/Container/image-20201007114231489.png

而在子目录下面可以看到该子系统对应的资源可以被限制的方法,比如对于 CPU 子系统来说,我们可以看到如下几个配置文件。

https://img.dawnguo.cn/Container/image-20201007114953909.png

其中 cpu.cfs_period_us 和 cpu.cfs_quota_us 这两个文件组合使用可以限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间。

完整的 Cgroups 子系统的介绍(官方):https://man7.org/linux/man-pages/man7/cgroups.7.html

1. 常用术语和基础知识

下面介绍 cgroups 中的一些常用术语和基本的知识。

  • 任务(Task) 是 cgroups 中用于管理的基本单位。在 cgroups v1 中,一个任务就是系统中的一个线程。

  • **控制组(Cgroup)**是一些按照某种标准划分的任务的集合。一个任务可以加入到一个控制组,也可以在不同的控制组之间进行迁移。Cgroups 中的资源限制都是以控制组为单位实现的。一个控制组内的所有任务可以使用 cgroups 为该控制组分配的资源,同时受到 cgroups 为该控制组所设定的限制。

    创建一个新的控制组,只需要在相应子系统下面新建一个目录即可,许多配置文件会自动填入目录中。用户对这些配置文件的修改会调用文件系统的相关函数(如 cpu_cfs_quota_write_s64 函数)。

  • 子系统(Subsystem) 是一个内核组件,每个子系统会对应一种类型的系统资源。内核中有许多已经实现的子系统,例如 CPU 和内存子系统等。通过这些子系统,用户可以监控和限制一个控制组使用的对应类型物理资源。例如,用户可以限制一个控制组所能使用的 CPU 时间或内存大小、获取该控制组已使用的 CPU 时间和内存等。由于子系统可以控制物理资源分配,它有时候也会被称作资源控制器(Resource Controllers)。

    Cgroups 定义了下面的一系列子系统,

    • CPU 子系统,主要限制进程的 CPU 使用率。
    • cpuacct 子系统,可以统计 cgroup 中的进程的 CPU 使用报告。
    • cpuset 子系统,可以为 cgroup 中的进程分配单独的 CPU 节点或者内存节点。
    • memory 子系统,可以限制进程的 Memory 使用量。
    • blkio 子系统,可以限制进程的块设备 IO。
    • devices 子系统,可以控制进程能够访问某些设备。
    • net_cls 子系统,可以标记 cgroup 中进程的网络数据包,然后可以使用 tc 模块(traffic control)对数据包进行控制。
    • freezer 子系统,可以挂起或者恢复 cgroup 中的进程。

    cgroup 文件系统大多数被挂载到 /sys/fs/cgroup 下,如下所示。比如,Memory Cgroup 的虚拟文件系统的挂载点一般在 “/sys/fs/cgroup/memory” 这个目录下。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    $ ll /sys/fs/cgroup/
    total 0
    dr-xr-xr-x 7 root root  0 Jan 11 10:56 blkio
    lrwxrwxrwx 1 root root 11 Dec  5 08:58 cpu -> cpu,cpuacct
    lrwxrwxrwx 1 root root 11 Dec  5 08:58 cpuacct -> cpu,cpuacct
    dr-xr-xr-x 7 root root  0 Jan 11 10:56 cpu,cpuacct
    dr-xr-xr-x 4 root root  0 Jan 12 11:31 cpuset
    dr-xr-xr-x 7 root root  0 Jan 11 10:56 devices
    dr-xr-xr-x 4 root root  0 Jan 12 11:31 freezer
    dr-xr-xr-x 4 root root  0 Jan 12 11:31 hugetlb
    dr-xr-xr-x 7 root root  0 Jan 11 10:56 memory
    lrwxrwxrwx 1 root root 16 Dec  5 08:58 net_cls -> net_cls,net_prio
    dr-xr-xr-x 4 root root  0 Jan 12 11:31 net_cls,net_prio
    lrwxrwxrwx 1 root root 16 Dec  5 08:58 net_prio -> net_cls,net_prio
    dr-xr-xr-x 4 root root  0 Jan 12 11:31 perf_event
    dr-xr-xr-x 7 root root  0 Jan 11 10:56 pids
    dr-xr-xr-x 2 root root  0 Jan 11 10:56 rdma
    dr-xr-xr-x 7 root root  0 Jan 11 10:56 systemd
    

    下面是 cgroups 对于 Docker 资源的控制示意图:

    https://img.dawnguo.cn/Container/1c762a6283429ff3587a7fc370fc090f.png

  • 层级(Hierarchy) 内核将控制组组织成树状结构,每一棵由控制组构成的树称为一个层级结构,它们拥有各自的 cgroups 文件系统。用户可以通过在一个 cgroups 文件系统中创建、删除和重命名子目录来构造或修改这一层级结构。在一个层级结构中,每个子目录都是一个控制组,子目录下有着许多文件,其中有一些文件是所有子系统都拥有的,例如 cgroup.procs 等,向该文件中写入一个进程的 PID 可以将这个进程加入该控制组;还有一些文件则是各个子系统所独有的,例如 cpu 子系统有着一些以 cpu 开头的文件,通过读写这些文件,我们便能与 cgroups 进行交互。

    任务在每个层级结构中可以属于一个控制组,同时我们可以为每个层级结构附加一些子系统,但在同一时刻,每个子系统只能被附加到一个层级结构中。这是因为同一任务在不同的层级结构中可能会属于不同的控制组,若一个任务属于的两个控制组对于同一种系统资源的限制不一致,则会产生矛盾。

    如图所示,我们将 cpu 和 cpuacct 两个子系统附加到了层级结构 A 中,将 memory 子系统附加到了层级结构 B 中。其实,就相当于层级结构 A 被挂载到了 /cpu,cpuacct 目录下,而层级结构 B 被挂载到了 /memory 目录下。

    https://img.dawnguo.cn/Container/image-20210222213438529.png

理解思路:Cgroups 是指一种机制,里面包含了分组和资源限制、监控和隔离两个功能,而分组功能对应的是控制组,资源限制、监控和隔离对应的是子系统。

2. Cgroups V2

Cgroups 有 v1 和 v2 两个版本:Cgroups v1 在 Linux 中很早就实现了,各种子系统比较独立,每个进程在各个 Cgroups 子系统中独立配置,可以属于不同的 group。虽然这样比较灵活,但是也存在问题,会导致对同一进程的资源协调比较困难(比如 memory Cgroup 与 blkio Cgroup 之间就不能协作)。虽然 v1 有缺陷,但是在主流的生产环境中,大部分使用的还是 v1。Cgroups v2 做了设计改进,解决了 v1 的问题,使各个子系统可以协调统一地管理资源。不过 Cgroups v2 在生产环境的应用还很少,因为该版本很多子系统的实现需要较新版本的 Linux 内核,还有无论是主流的 Linux 发行版本还是容器云平台,比如 Kubernetes,对 v2 的支持也刚刚起步。从 Linux4.5 内核开始,cgroupsV2 已经被标记为官方发布。相对于 cgroupsV1,cgroupsV2 有如下变化:

  • 整个系统只有一个层级,也就是整个 cgroups 系统只有一棵树(而不是之前的多棵树)。
  • 层级下的控制组可以选择拥有哪些子系统,即一个控制组可以拥有多个子系统。
  • 只通过将任务绑定到 1 个控制组,就可以控制多种系统资源的使用。

要想使用 Cgroup V2,需要在 Linux 系统里打开 Cgroup V2 的功能。因为目前即使最新版本的 Ubuntu Linux 或者 Centos Linux,仍然在使用 Cgroup v1 作为缺省的 Cgroup。

3. Cgroups 实现

整体实现过程是:

  1. 系统初始化的时候,初始化 Croups 各个子系统的操作函数,分配各个子系统的数据结构。
  2. mount cgroups 文件系统,创建文件系统的树形结构,以及操作函数。
  3. 写入子系统文件,设置 cpu 或者 memory 的相关参数,这个时候文件系统的操作函数会调用到 cgroups 子系统的操作函数,从而将参数设置到 cgroups 子系统的数据结构中。
  4. 写入 tasks 文件,将进程交给某个 cgroup 进行管理,因为 tasks 文件也是一个 cgroups 文件,所以也会调用相应的文件系统的操作函数将进程和 cgroup 关联起来,同时将进程和某个子系统关联起来,比如这个进程和某个 cgroup 关联起来之后并且和 CPU 子系统建立了联系,那么下次调用到这个进程的时候,会以 cgroup 方式的调度(调度是 CPU 子系统的);比如内存分配,那么也是类似的,CPU 子系统的 cgroup 是 task_group,mem 子系统的 cgroup 则是 mem_group。
  5. 对于 CPU 来讲,会修改 scheduled entity,放入相应的队列里面去,从而下次调度的时候就起作用了。对于内存的 cgroup 设定,在申请内存的时候就起作用。

https://img.dawnguo.cn/Container/c9cc56d20e6a4bac0f9657e6380a96c4.png

https://img.dawnguo.cn/Container/image-20210224002130174.png

4. Cgoups 配置示例

下面基于 Cgroups 提供的操作接口进行配置,我们先在相应的子系统中再创建一个目录,比如 container ,这个目录就被称为“控制组”。当将这个目录创建好了之后,你会发现这个目录已经生成了该子系统对应的资源限制文件。

https://img.dawnguo.cn/Container/image-20201007115841156.png

接下去,我们通过修改 container 中的文件内容来设置限制。比如向 container 中的 cpu.cfs_quota_us 中写入 20 ms,也就是 20000,就表示每 cpu_period_us (100ms)被控制组限制的进程只能使用 20ms 的 CPU 时间。

接下去我们把要限制的进程 PID 写入 container 目录中的 tasks 文件中,那么该设置对要限制的进程就有效了。

https://img.dawnguo.cn/Container/image-20201007120922703.png

https://img.dawnguo.cn/Container/image-20201007120955176.png

5. Docker 实现

对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(也就是创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 写入到这个控制组的 tasks 文件中即可。而控制组中的资源限制要填上什么值,就要看用户执行 docker run 时指定的参数内容。在 Docker 主机中,每个子系统下面都有 docker 控制组。

https://img.dawnguo.cn/Container/image-20201007125807573.png

下面我们来演示 docker 进行设置的效果,就会在 CPU 子系统的 docker 控制组中进行相关的设置。

1
docker run -it --rm --cpu-period=100000 --cpu-quota=20000 -d alpine sleep 500s

如下所示,docker 还会在 docker 控制组创建一个新的目录,在这个目录中对资源进行设置,从而达到限制资源使用的效果。

https://img.dawnguo.cn/Container/image-20201007125622003.png

6. 总结

跟 Namespace 情况类似,Cgoups 对资源的限制能力也有很多不完善的地方,其中被提及最多的是 /proc 文件系统的问题。/proc 目录存储着当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。

但是,如果你在容器里执行 top 指令,就会发现,它显示的信息居然还是宿主机的 CPU 和内存数据。这是因为 /proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,所以它返回的还是整个宿主机的。那么这个问题会导致,容器内的应用程序读取到的 CPU 核数、可用内存等信息还是宿主机的,而不是做了限制之后的。这就是容器相比较于虚拟机另一个不尽如人意的地方。

当然,为了解决上面的那个问题。直观的做法就是容器不挂载宿主机的该目录就可以了,那么实际上可以通过 lxcfs 来实现隔离,lxcfs 在宿主机上维护进程组的信息,然后容器启动的时候将 lxcfs 维护的进程组信息所在的目录挂载到容器的 /proc 目录,当容器中获取 /proc 信息时,实际上获取宿主机上对该容器的进程组信息。

20201008理解:/proc 存的是当前宿主机内核运行状态的情况,比如 CPU 使用情况、内存占用率等。容器在它看来其实就是一个做了限制的进程而已,它做计算还是针对整个内核的情况。但是,容器内的实际使用情况应该是根据容器中的相关限制来得出的,比如已经对容器做了 CPU 使用的限制,那么 top 命令得到的应该是在这个限制下,容器内进程的实际使用情况。

巨人的肩膀

  1. 极客时间.《深入剖析 Kubernetes》.张磊老师.“白话容器基础(二):隔离与限制”
  2. 极客时间.《趣谈Linux操作系统》
  3. 极客时间.《容器实战》