上一篇中,我们了解了 Docker 背后使用的资源隔离技术 namespace,通过系统调用构建一个相对隔离的 shell 环境,也可以称之为一个简单的“容器”。本文我们则要开始讲解另一个强大的内核工具——cgroups。他不仅可以限制被 namespace 隔离起来的资源,还可以为资源设置权重、计算使用量、操控进程启停等等。在介绍完基本概念后,我们将详细讲解 Docker 中使用到的 cgroups 内容。希望通过本文,让读者对 Docker 有更深入的了解。
cgroups(Control Groups)最初叫 Process Container,由 Google 工程师(Paul Menage 和 Rohit Seth)于 2006 年提出,后来因为 Container 有多重含义容易引起误解,就在 2007 年更名为 Control Groups,并被整合进 Linux 内核。顾名思义就是把进程放到一个组里面统一加以控制。官方的定义如下{![引自:https://www.kernel.org/doc/Documentation/cgroups/cgroups.txt]}。
cgroups 是 Linux 内核提供的一种机制,这种机制可以根据特定的行为,把一系列系统任务及其子任务整合(或分隔)到按资源划分等级的不同组内,从而为系统资源管理提供一个统一的框架。
通俗的来说,cgroups 可以限制、记录、隔离进程组所使用的物理资源(包括:CPU、memory、IO 等),为容器实现虚拟化提供了基本保证,是构建 Docker 等一系列虚拟化管理工具的基石。
对开发者来说,cgroups 有如下四个有趣的特点:
本质上来说,cgroups 是内核附加在程序上的一系列钩子(hooks),通过程序运行时对资源的调度触发相应的钩子以达到资源追踪和限制的目的。
实现 cgroups 的主要目的是为不同用户层面的资源管理,提供一个统一化的接口。从单个进程的资源控制到操作系统层面的虚拟化。Cgroups 提供了以下四大功能{![参照自:http://en.wikipedia.org/wiki/Cgroups]}。
过去有一段时间,内核开发者甚至把 namespace 也作为一个 cgroups 的 subsystem 加入进来,也就是说 cgroups 曾经甚至还包含了资源隔离的能力。但是资源隔离会给 cgroups 带来许多问题,如 PID 在循环出现的时候 cgroup 却出现了命名冲突、cgroup 创建后进入新的 namespace 导致脱离了控制等等{![详见:https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=a77aea92010acf54ad785047234418d5d68772e2]},所以在 2011 年就被移除了。
大家在 namespace 技术的讲解中已经了解到,传统的 Unix 进程管理,实际上是先启动init
进程作为根节点,再由init
节点创建子进程作为子节点,而每个子节点由可以创建新的子节点,如此往复,形成一个树状结构。而 cgroups 也是类似的树状结构,子节点都从父节点继承属性。
它们最大的不同在于,系统中 cgroup 构成的 hierarchy 可以允许存在多个。如果进程模型是由init
作为根节点构成的一棵树的话,那么 cgroups 的模型则是由多个 hierarchy 构成的森林。这样做的目的也很好理解,如果只有一个 hierarchy,那么所有的 task 都要受到绑定其上的 subsystem 的限制,会给那些不需要这些限制的 task 造成麻烦。
了解了 cgroups 的组织结构,我们再来了解 cgroup、task、subsystem 以及 hierarchy 四者间的相互关系及其基本规则{![参照自:https://access.redhat.com/documentation/en-US/RedHatEnterpriseLinux/6/html/ResourceManagementGuide/sec-RelationshipsBetweenSubsystemsHierarchiesControlGroupsandTasks.html]}。
规则 1: 同一个 hierarchy 可以附加一个或多个 subsystem。如下图 1,cpu 和 memory 的 subsystem 附加到了一个 hierarchy。
图 1 同一个 hierarchy 可以附加一个或多个 subsystem
规则 2: 一个 subsystem 可以附加到多个 hierarchy,当且仅当这些 hierarchy 只有这唯一一个 subsystem。如下图 2,小圈中的数字表示 subsystem 附加的时间顺序,CPU subsystem 附加到 hierarchy A 的同时不能再附加到 hierarchy B,因为 hierarchy B 已经附加了 memory subsystem。如果 hierarchy B 与 hierarchy A 状态相同,没有附加过 memory subsystem,那么 CPU subsystem 同时附加到两个 hierarchy 是可以的。
图 2 一个已经附加在某个 hierarchy 上的 subsystem 不能附加到其他含有别的 subsystem 的 hierarchy 上
规则 3: 系统每次新建一个 hierarchy 时,该系统上的所有 task 默认构成了这个新建的 hierarchy 的初始化 cgroup,这个 cgroup 也称为 root cgroup。对于你创建的每个 hierarchy,task 只能存在于其中一个 cgroup 中,即一个 task 不能存在于同一个 hierarchy 的不同 cgroup 中,但是一个 task 可以存在在不同 hierarchy 中的多个 cgroup 中。如果操作时把一个 task 添加到同一个 hierarchy 中的另一个 cgroup 中,则会从第一个 cgroup 中移除。在下图 3 中可以看到,httpd
进程已经加入到 hierarchy A 中的/cg1
而不能加入同一个 hierarchy 中的/cg2
,但是可以加入 hierarchy B 中的/cg3
。实际上不允许加入同一个 hierarchy 中的其他 cgroup 野生为了防止出现矛盾,如 CPU subsystem 为/cg1
分配了 30%,而为/cg2
分配了 50%,此时如果httpd
在这两个 cgroup 中,就会出现矛盾。
图 3 一个 task 不能属于同一个 hierarchy 的不同 cgroup
规则 4: 进程(task)在 fork 自身时创建的子任务(child task)默认与原 task 在同一个 cgroup 中,但是 child task 允许被移动到不同的 cgroup 中。即 fork 完成后,父子进程间是完全独立的。如下图 4 中,小圈中的数字表示 task 出现的时间顺序,当httpd
刚 fork 出另一个httpd
时,在同一个 hierarchy 中的同一个 cgroup 中。但是随后如果 PID 为 4840 的httpd
需要移动到其他 cgroup 也是可以的,因为父子任务间已经独立。总结起来就是:初始化时子任务与父任务在同一个 cgroup,但是这种关系随后可以改变。
图 4 刚 fork 出的子进程在初始状态与其父进程处于同一个 cgroup
subsystem 实际上就是 cgroups 的资源控制系统,每种 subsystem 独立地控制一种资源,目前 Docker 使用如下八种 subsystem,还有一种net_cls
subsystem 在内核中已经广泛实现,但是 Docker 尚未使用。他们的用途分别如下。
cgroups 的实现本质上是给系统进程挂上钩子(hooks),当 task 运行的过程中涉及到某个资源时就会触发钩子上所附带的 subsystem 进行检测,最终根据资源类别的不同使用对应的技术进行资源限制和优先级分配。那么这些钩子又是怎样附加到进程上的呢?下面我们将对照结构体的图表一步步分析,请放心,描述代码的内容并不多。
(点击放大图像)
图 5 cgroups 相关结构体一览
Linux 中管理 task 进程的数据结构为task_struct
(包含所有进程管理的信息),其中与 cgroup 相关的字段主要有两个,一个是css_set *cgroups
,表示指向css_set
(包含进程相关的 cgroups 信息)的指针,一个 task 只对应一个css_set
结构,但是一个css_set
可以被多个 task 使用。另一个字段是list_head cg_list
,是一个链表的头指针,这个链表包含了所有的链到同一个css_set
的 task 进程(在图中使用的回环箭头,均表示可以通过该字段找到所有同类结构,获得信息)。
每个css_set
结构中都包含了一个指向cgroup_subsys_state
(包含进程与一个特定子系统相关的信息)的指针数组。cgroup_subsys_state
则指向了cgroup
结构(包含一个 cgroup 的所有信息),通过这种方式间接的把一个进程和 cgroup 联系了起来,如下图 6。
图 6 从 task 结构开始找到 cgroup 结构
另一方面,cgroup
结构体中有一个list_head css_sets
字段,它是一个头指针,指向由cg_cgroup_link
(包含 cgroup 与 task 之间多对多关系的信息,后文还会再解释)形成的链表。由此获得的每一个cg_cgroup_link
都包含了一个指向css_set *cg
字段,指向了每一个 task 的css_set
。css_set
结构中则包含tasks
头指针,指向所有链到此css_set
的 task 进程构成的链表。至此,我们就明白如何查看在同一个 cgroup 中的 task 有哪些了,如下图 7。
图 7 cglink 多对多双向查询
细心的读者可能已经发现,css_set
中也有指向所有cg_cgroup_link
构成链表的头指针,通过这种方式也能定位到所有的 cgroup,这种方式与图 1 中所示的方式得到的结果是相同的。
那么为什么要使用cg_cgroup_link
结构体呢?因为 task 与 cgroup 之间是多对多的关系。熟悉数据库的读者很容易理解,在数据库中,如果两张表是多对多的关系,那么如果不加入第三张关系表,就必须为一个字段的不同添加许多行记录,导致大量冗余。通过从主表和副表各拿一个主键新建一张关系表,可以提高数据查询的灵活性和效率。
而一个 task 可能处于不同的 cgroup,只要这些 cgroup 在不同的 hierarchy 中,并且每个 hierarchy 挂载的子系统不同;另一方面,一个 cgroup 中可以有多个 task,这是显而易见的,但是这些 task 因为可能还存在在别的 cgroup 中,所以它们对应的css_set
也不尽相同,所以一个 cgroup 也可以对应多个·css_set
。
在系统运行之初,内核的主函数就会对root cgroups
和css_set
进行初始化,每次 task 进行 fork/exit 时,都会附加(attach)/ 分离(detach)对应的css_set
。
综上所述,添加cg_cgroup_link
主要是出于性能方面的考虑,一是节省了task_struct
结构体占用的内存,二是提升了进程fork()/exit()
的速度。
图 8 css_set 与 hashtable 关系
当 task 从一个 cgroup 中移动到另一个时,它会得到一个新的css_set
指针。如果所要加入的 cgroup 与现有的 cgroup 子系统相同,那么就重复使用现有的css_set
,否则就分配一个新css_set
。所有的css_set
通过一个哈希表进行存放和查询,如上图 8 中所示,hlist_node hlist
就指向了css_set_table
这个 hash 表。
同时,为了让 cgroups 便于用户理解和使用,也为了用精简的内核代码为 cgroup 提供熟悉的权限和命名空间管理,内核开发者们按照 Linux 虚拟文件系统转换器(VFS:Virtual Filesystem Switch)的接口实现了一套名为cgroup
的文件系统,非常巧妙地用来表示 cgroups 的 hierarchy 概念,把各个 subsystem 的实现都封装到文件系统的各项操作中。有兴趣的读者可以在网上搜索并阅读VFS的相关内容,在此就不赘述了。
定义子系统的结构体是cgroup_subsys
,在图 9 中可以看到,cgroup_subsys
中定义了一组函数的接口,让各个子系统自己去实现,类似的思想还被用在了cgroup_subsys_state
中,cgroup_subsys_state
并没有定义控制信息,只是定义了各个子系统都需要用到的公共信息,由各个子系统各自按需去定义自己的控制信息结构体,最终在自定义的结构体中把cgroup_subsys_state
包含进去,然后内核通过container_of
(这个宏可以通过一个结构体的成员找到结构体自身)等宏定义来获取对应的结构体。
图 9 cgroup 子系统结构体
了解了 cgroups 实现的代码结构以后,再来看用户层在使用 cgroups 时的限制,会更加清晰。
在实际的使用过程中,你需要通过挂载(mount)cgroup
文件系统新建一个层级结构,挂载时指定要绑定的子系统,缺省情况下默认绑定系统所有子系统。把 cgroup 文件系统挂载(mount)上以后,你就可以像操作文件一样对 cgroups 的 hierarchy 层级进行浏览和操作管理(包括权限管理、子文件管理等等)。除了 cgroup 文件系统以外,内核没有为 cgroups 的访问和操作添加任何系统调用。
如果新建的层级结构要绑定的子系统与目前已经存在的层级结构完全相同,那么新的挂载会重用原来已经存在的那一套(指向相同的 css_set)。否则如果要绑定的子系统已经被别的层级绑定,就会返回挂载失败的错误。如果一切顺利,挂载完成后层级就被激活并与相应子系统关联起来,可以开始使用了。
目前无法将一个新的子系统绑定到激活的层级上,或者从一个激活的层级中解除某个子系统的绑定。
当一个顶层的 cgroup 文件系统被卸载(umount)时,如果其中创建后代 cgroup 目录,那么就算上层的 cgroup 被卸载了,层级也是激活状态,其后代 cgoup 中的配置依旧有效。只有递归式的卸载层级中的所有 cgoup,那个层级才会被真正删除。
层级激活后,/proc
目录下的每个 task PID 文件夹下都会新添加一个名为cgroup
的文件,列出 task 所在的层级,对其进行控制的子系统及对应 cgroup 文件系统的路径。
一个 cgroup 创建完成,不管绑定了何种子系统,其目录下都会生成以下几个文件,用来描述 cgroup 的相应信息。同样,把相应信息写入这些配置文件就可以生效,内容如下。
tasks
:这个文件中罗列了所有在该 cgroup 中 task 的 PID。该文件并不保证 task 的 PID 有序,把一个 task 的 PID 写到这个文件中就意味着把这个 task 加入这个 cgroup 中。cgroup.procs
:这个文件罗列所有在该 cgroup 中的线程组 ID。该文件并不保证线程组 ID 有序和无重复。写一个线程组 ID 到这个文件就意味着把这个组中所有的线程加到这个 cgroup 中。notify_on_release
:填 0 或 1,表示是否在 cgroup 中最后一个 task 退出时通知运行release agent
,默认情况下是 0,表示不运行。release_agent
:指定 release agent 执行脚本的文件路径(该文件在最顶层 cgroup 目录中存在),在这个脚本通常用于自动化umount
无用的 cgroup。除了上述几个通用的文件以外,绑定特定子系统的目录下也会有其他的文件进行子系统的参数配置。
在创建的 hierarchy 中创建文件夹,就类似于 fork 中一个后代 cgroup,后代 cgroup 中默认继承原有 cgroup 中的配置属性,但是你可以根据需求对配置参数进行调整。这样就把一个大的 cgroup 系统分割成一个个嵌套的、可动态变化的“软分区”。
本节主要针对 Ubuntu14.04 版本系统进行介绍,其他 Linux 发行版命令略有不同,原理是一样的。不安装 cgroups 工具库也可以使用 cgroups,安装它只是为了更方便的在用户态对 cgroups 进行管理,同时也方便初学者理解和使用,本节对 cgroups 的操作和使用都基于这个工具库。
apt-get install cgroup-bin
安装的过程会自动创建/cgroup
目录,如果没有自动创建也不用担心,使用 mkdir /cgroup
手动创建即可。在这个目录下你就可以挂载各类子系统。安装完成后,你就可以使用lssubsys
(罗列所有的 subsystem 挂载情况)等命令。
说明:也许你在其他文章中看到的 cgroups 工具库教程,会在 /etc 目录下生成一些初始化脚本和配置文件,默认的 cgroup 配置文件为/etc/cgconfig.conf
,但是因为存在使 LXC 无法运行的 bug,所以在新版本中把这个配置移除了,详见:https://bugs.launchpad.net/ubuntu/+source/libcgroup/+bug/1096771。
在挂载子系统之前,可能你要先检查下目前子系统的挂载状态,如果子系统已经挂载,根据第 4 节中讲的规则 2,你就无法把子系统挂载到新的 hierarchy,此时就需要先删除相应 hierarchy 或卸载对应子系统后再挂载。
lscgroup
lssubsys -a
lssubsys –m
lssubsys –m memory
在组织结构与规则一节中我们提到了 hierarchy 层级和 subsystem 子系统的关系,我们知道使用 cgroup 的最佳方式是:为想要管理的每个或每组资源创建单独的 cgroup 层级结构。而创建 hierarchy 并不神秘,实际上就是做一个标记,通过挂载一个 tmpfs{![基于内存的临时文件系统,详见:http://en.wikipedia.org/wiki/Tmpfs]}文件系统,并给一个好的名字就可以了,系统默认挂载的 cgroup 就会进行如下操作。
mount -t tmpfs cgroups /sys/fs/cgroup
其中-t
即指定挂载的文件系统类型,其后的cgroups
是会出现在mount
展示的结果中用于标识,可以选择一个有用的名字命名,最后的目录则表示文件的挂载点位置。
挂载完成tmpfs
后就可以通过mkdir
命令创建相应的文件夹。
mkdir /sys/fs/cgroup/cg1
再把子系统挂载到相应层级上,挂载子系统也使用 mount 命令,语法如下。
mount -t cgroup -o subsystems name /cgroup/name
其中 subsystems 是使用,
(逗号)分开的子系统列表,name 是层级名称。具体我们以挂载 cpu 和 memory 的子系统为例,命令如下。
mount –t cgroup –o cpu,memory cpu_and_mem /sys/fs/cgroup/cg1
从mount
命令开始,-t
后面跟的是挂载的文件系统类型,即cgroup
文件系统。-o
后面跟要挂载的子系统种类如cpu
、memory
,用逗号隔开,其后的cpu_and_mem
不被 cgroup 代码的解释,但会出现在 /proc/mounts 里,可以使用任何有用的标识字符串。最后的参数则表示挂载点的目录位置。
说明:如果挂载时提示mount: agent already mounted or /cgroup busy
,则表示子系统已经挂载,需要先卸载原先的挂载点,通过第二条中描述的命令可以定位挂载点。
目前cgroup
文件系统虽然支持重新挂载,但是官方不建议使用,重新挂载虽然可以改变绑定的子系统和release agent
,但是它要求对应的 hierarchy 是空的并且 release_agent 会被传统的fsnotify
(内核默认的文件系统通知)代替,这就导致重新挂载很难生效,未来重新挂载的功能可能会移除。你可以通过卸载,再挂载的方式处理这样的需求。
卸载 cgroup 非常简单,你可以通过cgdelete
命令,也可以通过rmdir
,以刚挂载的 cg1 为例,命令如下。
rmdir /sys/fs/cgroup/cg1
rmdir 执行成功的必要条件是 cg1 下层没有创建其它 cgroup,cg1 中没有添加任何 task,并且它也没有被别的 cgroup 所引用。
cgdelete cpu,memory:/ 使用cgdelete
命令可以递归的删除 cgroup 及其命令下的后代 cgroup,并且如果 cgroup 中有 task,那么 task 会自动移到上一层没有被删除的 cgroup 中,如果所有的 cgroup 都被删除了,那 task 就不被 cgroups 控制。但是一旦再次创建一个新的 cgroup,所有进程都会被放进新的 cgroup 中。
设置 cgroups 参数非常简单,直接对之前创建的 cgroup 对应文件夹下的文件写入即可,举例如下。
echo 0-1 > /sys/fs/cgroup/cg1/cpuset.cpus
使用cgset
命令也可以进行参数设置,对应上述允许使用 0 和 1cpu 的命令为:
cgset -r cpuset.cpus=0-1 cpu,memory:/
通过文件操作进行添加 echo [PID] > /path/to/cgroup/tasks
上述命令就是把进程 ID 打印到 tasks 中,如果 tasks 文件中已经有进程,需要使用">>"
向后添加。
通过cgclassify
将进程添加到 cgroup cgclassify -g subsystems:path_to_cgroup pidlist
这个命令中,subsystems
指的就是子系统(如果使用 man 命令查看,可能也会使用 controllers 表示),如果 mount 了多个,就是用","
隔开的子系统名字作为名称,类似cgset
命令。
通过cgexec
直接在 cgroup 中启动并执行进程 cgexec -g subsystems:path_to_cgroup command arguments
command
和arguments
就表示要在 cgroup 中执行的命令和参数。cgexec
常用于执行临时的任务。
与文件的权限管理类似,通过chown
就可以对 cgroup 文件系统进行权限管理。
chown uid:gid /path/to/cgroup
uid 和 gid 分别表示所属的用户和用户组。
限额类 限额类是主要有两种策略,一种是基于完全公平队列调度(CFQ:Completely Fair Queuing )的按权重分配各个 cgroup 所能占用总体资源的百分比,好处是当资源空闲时可以充分利用,但只能用于最底层节点 cgroup 的配置;另一种则是设定资源使用上限,这种限额在各个层次的 cgroup 都可以配置,但这种限制较为生硬,并且容器之间依然会出现资源的竞争。
device_types:node_numbers weight
,空格前的参数段指定设备,weight
参数与blkio.weight
相同并覆盖原有的通用分配比。{![查看一个设备的device_types:node_numbers
可以使用:ls -l /dev/DEV
,看到的用逗号分隔的两个数字就是。有的文章也称之为major_number:minor_number
。]}device_types:node_numbers bytes_per_second
。device_types:node_numbers bytes_per_second
。device_types:node_numbers operations_per_second
。device_types:node_numbers operations_per_second
device_types:node_numbers operation operations_per_second
device_types:node_numbers operation bytes_per_second
统计与监控 以下内容都是只读的状态报告,通过这些统计项更好地统计、监控进程的 io 情况。
device_types:node_numbers milliseconds
读取信息即可,以下类似。device_types:node_numbers operation number
device_types:node_numbers sector_count
device_types:node_numbers operation bytes
number operation
device_types:node_numbers operation time
number operation
device_types:node_numbers operation time
CPU 资源的控制也有两种策略,一种是完全公平调度 (CFS:Completely Fair Scheduler)策略,提供了限额和按比例分配两种方式进行资源控制;另一种是实时调度(Real-Time Scheduler)策略,针对实时进程按周期分配固定的运行时间。配置时间都以微秒(µs)为单位,文件名中用us
表示。
CFS 调度策略下的配置
cfs_quota_us
配合使用。cfs_quota_us
是cfs_period_us
的两倍,就表示在两个核上完全使用。数值范围为 1000 - 1000,000(微秒)。nr_periods
(表示经历了几个cfs_period_us
周期)、nr_throttled
(表示 task 被限制的次数)及throttled_time
(表示 task 被限制的总时长)。RT 调度策略下的配置 实时调度策略与公平调度策略中的按周期分配时间的方法类似,也是在周期内分配一个固定的运行时间。
这个子系统的配置是cpu
子系统的补充,提供 CPU 资源用量的统计,时间单位都是纳秒。
为 task 分配独立 CPU 资源的子系统,参数较多,这里只选讲两个必须配置的参数,同时 Docker 中目前也只用到这两个。
0-2,16
代表 0、1、2 和 16 这 4 个 CPU。memory node
,格式同上type device_types:node_numbers access type
;type
有三种类型:b(块设备)、c(字符设备)、a(全部设备);access
也有三种方式:r(读)、w(写)、m(创建)。只有一个属性,表示进程的状态,把 task 放到 freezer 所在的 cgroup,再把 state 改为 FROZEN,就可以暂停进程。不允许在 cgroup 处于 FROZEN 状态时加入进程。 * **freezer.state **,包括如下三种状态: - FROZEN 停止 - FREEZING 正在停止,这个是只读状态,不能写入这个值。 - THAWED 恢复
限额类
k
、m
、g
三种,填-1
则代表无限制。报警与自动控制
0
表示开启,当 cgroup 中的进程使用资源超过界限时立即杀死进程,1
表示不启用。默认情况下,包含 memory 子系统的 cgroup 都启用。当oom_control
不启用时,实际使用内存超过界限时进程会被暂停直到有空闲的内存资源。统计与监控类
memory.limit_in_bytes
设定的限制值的次数本文由浅入深的讲解了 cgroups 的方方面面,从 cgroups 是什么,到 cgroups 该怎么用,最后对大量的 cgroup 子系统配置参数进行了梳理。可以看到,内核对 cgroups 的支持已经较为完善,但是依旧有许多工作需要完善。如网络方面目前是通过 TC(Traffic Controller)来控制,未来需要统一整合;资源限制并没有解决资源竞争,在各自限制之内的进程依旧存在资源竞争,优先级调度方面依旧有很大的改进空间。希望通过本文帮助大家了解 cgroups,让更多人参与到社区的贡献中。