CGroup v1 && v2全解

概念解析

CGroup,全称Control Group,是Linux内核提供的一种可以限制,记录,隔离进程组所使用资源的机制。它最初由Google提出,后被整合进Linux内核。它的功能包括:

  • 资源限制(Resource limiting):CGroup可以对进程组使用的资源总额进行限制。如对特定的进程进行内存使用上限限制,当超出上限时,会触发OOM。
  • 优先级分配(Prioritization):通过分配的CPU时间片数量及硬盘IO带宽大小,实际上就相当于控制了进程运行的优先级。
  • 资源统计(Accounting):CGroup可以统计系统的资源使用量,如CPU使用时长、内存用量等等,这个功能非常适用于计费。
  • 进程控制(Control):CGroup可以对进程组执行挂起、恢复等操作。

首先我们明确几个概念:

  • Task:系统中的一个进程。
  • Subsystem:一个已经实现的资源控制器(Resource Controller)。
  • Hirerarchy:文件系统中的一个目录,或者说CGroup树中的一个节点。

然后我们需要知道CGroup是存在两个不同版本的:V1和V2

三种CGroup模式

传统模式(Legacy)

传统的CGroup v1模式。在CGroup v1中,原理上允许在每个目录上挂载任意数量的控制器,但是,每个控制器只能有一个实例(只能挂载一次),这种方式看上去很灵活,但是实际上并不是很有用。

  • 分开挂载会导致用户在多个非常相似的层级结构上重复进行非常相似的操作,这很浪费精力而且没什么必要。
  • 到底哪些控制器会挂载在一起这件事本身是无法确定的,所以控制器必须假定在与其他控制器完全正交的环境下进行开发,这潜在地阻碍了控制器之间的协作。

虽然如此,大部分发行版的实践还是创建一个tmpfs并挂载到/sys/fs/cgroup/,然后把/proc/cgroups中暴露出的所有支持的控制器分别以挂载在该目录下以该控制器命名的,分散的,互相正交的目录下:

legacy-cgroup.png

为了进行管理,systemd也会挂载自己的根CGroup文件系统到/sys/fs/cgroup/systemd

统一模式(Unified)

systemd主导且大力推行的纯CGroup v2模式,在这种模式下,所有的资源控制器都被集成到一个唯一的,类型为cgroup2的,/sys/fs/cgroup根目录下:

unified-cgroup.png

在这种模式下,这个cgroup2文件系统本身就是systemd控制的。

混合模式(Hybrid)

处于传统与统一模式之间的模式。这个模式大部分与传统模式一致,但是会额外将CGroup v2文件系统挂载到/sys/fs/cgroup/unified,不过需要注意,该模式下并没有使用CGroup v2的单一控制器,仍然使用分散的资源控制器。

必须强调:传统模式和混合模式仅仅只是权宜之计,应当尽可能地抛弃。

我们分别针对不同的模式进行分析:

CGroup v1

在CGroup v1中,对进程的控制需要通过在每个控制器的根CGroup目录中创建子目录实现。子目录在创建时,会自动地继承该控制器的根CGroup目录中的任何配置文件,就像这样:

legacy-cgroup-inherit.png

所有控制器的根CGroup目录下,都会包含以下共同文件:

  • tasks:该CGroup目录当前正在管理的所有进程PID。
  • cgroup.procs:纳入该CGroup目录管理的所有进程PID。这些进程的子进程也会被自动地加入tasks。该文件可以修改权限以进行权限下放。
  • cgroup.clone_children:该文件的内容要么是0,要么是1。设为1时,子CGroup会继承父CGroup的配置。默认情况下,创建嵌套的子CGroup目录时,会采用默认值。
  • cgroup.sane_behavior:该文件没有实际功能,也不可修改,它的存在仅仅表明该CGroup使用了Sane Behavior模式。
  • notify_on_release:该文件的内容要么是0,要么是1。设为1时,当该CGroup中最后一个进程退出时,会执行release_agent脚本。
  • release_agent:执行清理动作的辅助脚本。

不同子系统(资源控制器)的特定配置项分别有:

CPU控制器cpu

  • cpu.shares:相对CPU权重,默认值为1024,调大会增加分配的CPU时间,反之减少。
  • cpu.cfs_period_us:完全公平调度器(CFS)的评估周期,单位为微秒,取值范围为1毫秒到1秒。默认值为100000。
  • cpu.cfs_quota_us:在cpu.cfs_period_us的周期内,允许使用的最大CPU时间,单位也是微秒。默认值为-1,表示不限制。
  • cpu.stat:只读文件,记录了CPU的使用情况,包含三个字段:
    • nr_periods:经过了多少个CFS评估周期。
    • nr_throttled:被节流的次数。
    • throttled_time:被节流的总时长,单位为纳秒。

CPU/内存节点控制器cpuset

  • cpuset.cpus:允许使用的CPU节点,连续的节点间可以使用-连接。
  • cpuset.mems:允许使用的内存节点,连续的节点间可以使用-连接。
  • cpuset.cpu_exclusive:在同一时间内,是否不允许其他CGroup的进程与其共享同一CPU节点。
  • cpuset.mem_exclusive:在同一时间内,是否不允许其他CGroup的进程与其共享同一内存节点。
  • cpuset.sched_load_balance:是否允许在允许使用的CPU节点上进行负载均衡。默认为0。
  • cpuset.memory_spread_page:是否将内核的页缓存均衡到允许使用的内存节点上。默认为0。
  • cpuset.memory_spread_slab:是否将内核的Slab缓存均衡到允许使用的内存节点上。默认为0。
  • cpuset.memory_migrate:是否允许进行内存迁移。默认为0。
  • cpuset.memory_pressure_enabled:是否监测内存压力指数。默认为0。
  • cpuset.memory_pressure:内存压力指数。

CPU统计控制器cpuacct

该控制器的CGroup下的配置文件全部为只读。

  • cpuacct.stat:所有任务消耗的用户态和内核态CPU时间。
  • cpuacct.usage:消耗的总CPU时间。
  • cpuacct.usage_sys:消耗在内核态的总CPU时间。
  • cpuacct.usage_user:消耗在用户态的总CPU时间。
  • cpuacct.percpu:消耗的每个核心上的CPU时间。
  • cpuacct.percpu_sys:在内核态消耗的每个核心上的CPU时间。
  • cpuacct.percpu_user:在用户态消耗的每个核心上的CPU时间。
  • cpuacct.usage_all:消耗的每个核心上的用户态和内核态CPU时间。

内存控制器memory

  • memory.usage_in_bytes:当前的内存用量。
  • memory.stat:内存的使用情况。
  • memory.force_empty:不可读文件,任何写入都会触发强制触发换页。
  • memory.swappiness:设置Swappiness。
  • memory.numa_stat:NUMA节点的统计信息。
  • memory.max_usage_in_bytes:历史最高内存用量。
  • memory.soft_limit_in_bytes:设置内存软限制,打破该限制会导致记录。默认为内存最大值。
  • memory.limit_in_bytes:设置内存硬限制,打破该限制会立刻触发OOM Killer。默认为内存最大值。
  • memory.failcnt:打破限制的次数。
  • memory.oom_control:OOM情况记录,包含三个字段:
    • oom_kill_disable:是否禁用OOM Killer,默认为0。
    • under_oom:只读字段,是否正在触发OOM。
    • oom_kill:只读字段,触发OOM Kill的次数。
  • memory.memsw.max_usage_in_bytesmemory.memsw.soft_limit_in_bytesmemory.memsw.limit_in_bytesmemory.memsw.failcnt:与memory.*对应的配置项相同,但是用于内存+交换空间的总和。
  • memory.kmem.max_usage_in_bytesmemory.kmem.soft_limit_in_bytesmemory.kmem.limit_in_bytesmemory.kmem.failcnt:与memory.*对应的配置项相同,但是用于内核内存。

进程控制器pids

这个控制器的根CGroup目录下没有配置文件,必须创建子CGroup目录后才能生成,你可以想一下为什么。

  • pids.max:该CGroup中允许的最大进程数量。默认为max表示不设限制。
  • pids.current:只读文件,当前该CGroups中的进程数。
  • pids.peak:只读文件,该CGroups中的最大进程数。
  • pids.events:只读文件,记录fork()调用失败的次数。

块设备IO控制器blkio

  • blkio.reset_stats:不可读文件,任何写入都会触发重置IO统计。
  • blkio.throttle.io_serviced:在具体设备中的IO操作数,格式为主设备号:从设备号 操作 数量
  • blkio.throttle.io_service_bytes:在具体设备中的IO操作字节数,格式为主设备号:从设备号 操作 字节数
  • blkio.throttle.read_iops_device:在具体设备中每秒执行读操作数的上限,格式为主设备号:从设备号 次数
  • blkio.throttle.read_bps_device:在具体设备中每秒执行读操作字节的上限,格式为主设备号:从设备号 字节数
  • blkio.throttle.write_iops_device:在具体设备中每秒执行写操作数的上限,格式为主设备号:从设备号 次数
  • blkio.throttle.write_bps_device:在具体设备中每秒执行写操作字节的上限,格式为主设备号:从设备号 字节数

挂载CGroup v1

systemd内置了挂载CGroup的功能且对其高度依赖,有两个内核目录行参数可以修改systemd挂载CGroup的行为:

  • systemd.unified_cgroup_hierarchy:是否允许回退到CGroup v1,设为no表示允许,设为yes表示不允许。如果Linux内核版本低于4.5,那么完全不支持CGroup v2,该内核命令行参数也就没有意义。
  • systemd.legacy_systemd_cgroup_controller:是否允许混合使用CGroup控制器,该配置项在前一个配置项为no时才有意义,设为no表示不允许,即强制要求使用CGroup v1。

OpenRC内置了名为cgroups的CGroup挂载脚本。

SysVinit需要编写额外的init脚本:

1
2
# Devuan
apt install cgroupfs-mount

手动挂载:

1
2
3
4
5
6
7
8
9
10
11
# CGroup v1
# 统一挂载,这会把所有的控制器全部挂载到该目录下
mount -t cgroup none /sys/fs/cgroup

# 正交挂载
mount -t tmpfs cgroup /sys/fs/cgroup
# 检查/proc/cgroups获取所有支持的控制器
mount -t cgroup -o 控制器 none /sys/fs/cgroup/控制器名

# CGroup v2
mount -t cgroup2 none /sys/fs/cgroup

cgroup-tools

cgroup-tools实际上是一组操作CGroup目录的工具。安装:

1
2
# Devuan
apt install cgroup-tools

它提供的工具有:

  • cgcreate -g 控制器:/CGroup目录 -a 拥有者:拥有组:创建CGroup目录。
  • cgdelete -g 控制器:/CGroup目录 [-r]:删除CGroup目录。
  • lscgroup:列出CGroup目录情况。
  • cgexec -g 控制器:/CGroup目录 程序 [参数]:在指定CGroup目录下运行程序。
  • cgclassify -g 控制器:/CGroup目录 PID:将指定进程移动到指定CGroup目录下。

CGroup v2

CGroup v2的组织结构和CGroup v1完全不同,它不再允许资源控制器独立挂载在分散的目录(并使用这些目录作为自己的根CGroup目录),转而强制采用一个单一的根CGroup目录(根节点),根CGroup目录下直接就包含了所有类型控制器的配置项。当然,创建子CGroup目录时也会继承上级所有控制器的默认配置项,不属于任何控制器的配置文件包括:

  • cgroup.controllers:包含的所有控制器类型。
  • cgroup.procs:该CGroup目录当前正在管理的所有进程PID。
  • cgroup.threads:该CGroup目录当前正在管理的所有线程ID。
  • cgroup.stat:该CGroup目录的统计信息,包括:
    • nr_descendants:直接子CGroup的数量。
    • nr_dying_descendants:濒临删除的直接子CGroup的数量。
  • cgroup.subtree_control:允许子CGroup目录使用的所有控制器类型。
  • cgroup.max.depth:允许子CGroup目录的最大深度,默认为max
  • cgroup.max.descendants:允许子CGroup目录的最大数量,默认为max
  • cgroup.pressure:是否启用PSI统计功能。

以下配置文件不会出现在根CGroup目录:

  • cgroup.type:该CGroup的类型,包括:
    • domain:正常的CGroup节点。
    • domain threaded:包含一系列类型为threaded的CGroup子节点的父节点。
    • threaded:线程CGroup节点,该CGroup目录不控制进程,而是控制一系列线程,用于实现线程级控制。
  • cgroup.events:只读文件,记录CGroup事件,字段包括:
    • populated:该CGroup及其子CGroup中是否包含任何存活进程。
    • frozen:该CGroup是否被冻结。
  • cgroup.freeze:是否冻结该CGroup。默认为0。
  • cgroup.kill:只写文件,任何写入都会导致该CGroup及其子CGroup立刻被杀死。

其他控制器

参考Control Group v2

CPU控制器cpu
  • cpu.stat:CPU统计情况,包含三个字段。
    • usage_usec:累计使用的CPU时间。单位为微秒。
    • user_usec:累计在用户态使用的CPU时间。单位为微秒。
    • system_usec:累计在内核态使用的CPU时间。单位为微秒。
    • nr_periods:经过了多少个CFS评估周期。
    • nr_throttled:被节流的次数。
    • throttled_usec:被节流的总时长,单位为微秒。
    • nr_bursts:发生Burst的总次数。
    • burst_usec:Burst状态下累计使用的CPU时间。
  • cpu.pressure:CPU的PSI统计信息。
  • cpu.max:最大CPU带宽。
  • cpu.max.burt:最大CPU突发带宽。
  • cpu.idle:是否为该CGroup设置低调度优先级。
  • cpu.weight:相对CPU权重,取值范围在1-10000,如果cpu.idle的值为1,那么该文件的值会被设为0。
  • cpu.weight.nice:CPU的Nice值,这是cpu.weight的一种替代接口,用类似于Nice的方式进行配置。
CPU/内存节点控制器cpuset

这个控制器的配置文件不会出现在用户级的CGroup目录中。

  • cpuset.cpus:允许使用的CPU节点,连续的节点间可以使用-连接。
  • cpuset.cpus.effective:目前正在使用的CPU节点,它应当是cpuset.cpus的子集,值会受到CPU热插拔事件影响。
  • cpuset.cpus.partitions:可选值有rootmember,是否将当前的CGroup放在一个独立的域中进行调度,如果设为root,那么该CGroup的cpuset.cpus会从上一级的cpuset.cpus.effective去掉。
  • cpuset.mems:允许使用的内存节点,连续的节点间可以使用-连接。
  • cpuset.mems.effective:目前正在使用的内存节点,它应当是cpuset.mems的子集,值会受到内存节点热插拔事件影响。
内存控制器memory
  • memory.current:只读文件,记录当前该CGroup及其所有子CGroup使用的内存总量。
  • memory.min:最低内存保障值的硬限制。如果该CGroup的内存用量小于此处的保障值,那么该CGroup的内存将会拒绝被回收。
  • memory.low:最低内存保障值的软限制。如果该CGroup的内存用量小于此处的保障值,那么只要还能从其他地方回收内存,该CGroup的内存就会拒绝被回收。
  • memory.high:最高内存用量的软限制。如果该CGroup的内存用量打破此处的限制,进程的运行速度将会大打折扣,并且系统会尽可能地迅速回收该CGroup的内存。
  • memory.pressure:内存的PSI统计信息。
  • memory.max:最高内存用量的硬限制。如果该CGroup的内存用量打破此处的限制,将立刻导致进程触发OOM Killer。
  • memory.reclaim:只写文件,接受任何内存单位(如1G),写入后会触发该CGroup重新申请指定量的内存。
  • memory.peak:只读文件,记录该CGroup及其所有子CGroup的内存用量峰值。
  • memory.oom.group:该CGroup是否被OOM Killer视为一个整体,如果值为1,那么当触发OOM Killer时,该CGroup及其子CGroup中的所有进程总是被一起杀死。
  • memory.events:只读文件,记录该CGroup及其子CGroup触发的内存事件,字段包括:
    • low:触及memory.low限制的次数。
    • high:触及memory.high限制的次数。
    • max:触及memory.max限制的次数。
    • oom:触发OOM的次数。
    • oom_kill:被OOM Killer杀死的进程数。
    • oom_group_kill:被OOM Killer全部杀死的次数。
  • memory.events.local:只读文件,只属于当前层级的memory.events记录。
  • memory.stat:只读文件,内存统计情况,详略。
  • memory.numa_stat:只读文件,记录在每个NUMA内存节点上的内存用量。
  • memory.swap.current:只读文件,当前的交换空间用量。
  • memory.swap.peak:只读文件,记录该CGroup及其所有子CGroup的交换空间用量峰值。
  • memory.swap.high:交换空间的最大用量的软限制。超过该用量会导致进程的运行速度大打折扣。
  • memory.swap.max:交换空间的最大用量的硬限制。超过该用量会导致禁止进行换页。
  • memory.swap.events:只读文件,记录该CGroup及其子CGroup触发的交换空间事件,字段如下:
    • high:触及memory.swap.high限制的次数。
    • max:触及memory.swap.max限制的次数。
    • fail:被禁止换页的次数。
  • memory.zswap.current:只读文件,当前的ZSwap用量。
  • memory.zswap.max:ZSwap的最大用量的硬限制。超过该用量会导致禁止继续使用。
  • memory.zswap.writeback:是否允许ZSwap回写,设为0会禁止。
IO控制器io
  • io.stat:IO统计信息,包含以下字段:
    • rbytes:读取字节数。
    • wbytes:写入字节数。
    • rios:读取次数。
    • wios:写入次数。
    • dbytes:抛弃字节数。
    • dios:抛弃次数。
  • io.pressure:IO的PSI统计信息。
  • io.cost.qos:配置IO的QoS,详略。
  • io.cost.model:配置IO的消耗模型,详略。
  • io.weight:IO的相对优先级,格式为主设备号:从设备号 优先级,值的范围为1-10000。
  • io.max:最大IO速度,格式为主设备号:从设备号 rbps=最大每秒读取字节数 wbps=最大每秒写入字节数 riops=最大每秒读取次数 wiops=最大每秒写入次数
进程控制器pids

这个控制器的配置文件不会出现在根CGroup目录中。

  • pids.max:该CGroup中允许的最大进程数量。默认为max表示不设限制。
  • pids.current:只读文件,当前该CGroups中的进程数。
  • pids.peak:只读文件,该CGroups中的最大进程数。
  • pids.events:只读文件,记录fork()调用失败的次数。
大内存页控制器hugetlb

这个控制器的配置文件不会出现在根CGroup目录中。

  • hugetlb.页大小.current:当前的HugeTLB用量。
  • hugetlb.页大小.max:设置HugeTLB的最大用量的硬限制。
  • hugetlb.页大小.events:只读文件,包含以下字段:
    • max:触发HugeTLB最大用量限制的次数。
  • hugetlb.页大小.events.local:只读文件,只属于当前层级的hugetlb.页大小.events记录。
  • hugetlb.页大小.numa_stat:当前CGroup在每个NUMA节点上的用量统计信息。

两大规则

CGroup v2添加了两条最为重要的规则:

  1. 中间节点无进程规则:不允许在存在子CGroup的CGroup中直接添加进程,反之,不允许在存在进程的CGroup中创建子CGroup目录。一个CGroup要么是树的中间节点,要么是叶节点,如果它是中间节点,那么它不应该包含任何进程;如果它是叶节点,那么它不应该有子CGroup。(注意,这条规则有一点点小例外。根CGroup是特殊的,它允许同时存在进程和子节点——这在特定情况下用于维护内核线程。)
    • 如果使用CGroup v2,那么规则1通常由内核强制执行:只要你向CGroup中添加了一个进程,内核就会确保你无法违反规则(不能创建子CGroup目录)。在CGroup v1中,这条规则不存在,因此也就不会强制执行,尽管遵循这条规则也是件好事。
  2. 单一写者规则:每个CGroup只能有一个写者,即一个管理它的进程。一个特定的CGroup应当只有一个所有者,并且当它具备所有者时,这个所有权是专有的,不应该有任何其他进程同时进行写操作。这条规则确保了各种进程之间不会竞争CGroup控制权。
    • 规则2在CGroup v1和CGroup v2上都没有强制执行(毕竟这是UNIX,Root用户可以做任何事情),但是如果你无视它,那么会遇到很多麻烦,因为各种进程将不断竞争CGroup的所有权。

这两条规则有很多影响,例如,一个推论是:如果你的容器管理器直接尝试在系统的根CGroup中创建和管理CGroup,那么就违反了规则2,因为根CGroup是由systemd管理的,因此其他任何进程都不得操作

看来要分析CGroup v2,我们就不得不仔细看看systemd的资源控制机制了:

CGroup v2与systemd

所有的CGroup v2目录节点,实际上都会在systemd处暴露为一个单元,其中,根CGroup目录(即/sys/fs/cgroup这个目录)固定地被暴露为-.slice

不同类型的管理对象对应着不同类型的单元,而这些单元的名称又对应着CGroup目录下的一个子目录,根CGroup目录下,包含以下单元的CGroup子目录层级结构(我们不在这里分析systemd各种单元的含义):

  • machine.slice/:所有通过systemd-machined.service注册管理的容器的父CGroup节点。(RunC的Rootful容器也使用该CGroup节点,顺带一提。)
  • user.slice/:所有通过systemd-logind.service管理的用户会话的父CGroup节点。
    • user-$UID.slice/:通过systemd-logind.service管理的指定UID的用户的所有会话的父CGroup节点。
      • session-$SESSION_ID.scope:叶节点,包含了通过systemd-logind.service创建的指定UID的用户的指定会话的所有进程。
      • user@$UID.service/:所有和用户级systemd实例的单元的父CGroup节点,以及用户级systemd实例本身对应的服务单元。
        • init.scope:叶节点,用于包含用户级别的systemd实例进程。
        • app.slice:通过用户级systemd实例创建的一般的用户级.service单元的父节点。
        • session.slice:通过用户级systemd实例创建的重要的用户级.service单元的父节点,这类服务单元内需要明确声明Slice=session.slice以加入该CGroup目录下。(“重要的”指:对会话稳定性有重大影响的服务,例如Display Manager、DBus实例等等。)
        • background.slice:通过用户级systemd实例创建的不重要的用户级.service单元的父节点,这类服务单元内需要明确声明Slice=background.slice以加入该CGroup目录下。
  • system.slice/:所有通过系统级systemd实例创建的.service.socket.mount单元的父节点。
  • init.scope/:叶节点,用于包含系统级别的systemd实例(即PID1)。

以上这些目录,都是由systemd创建的,因此如果我们希望进行资源控制,最符合规则2的方式是,告诉systemd对其进行控制。具体来说,是通过这一条命令:

1
systemctl [--user] set-property 单元名 属性=值
  • 将单元名设为系统服务单元,对系统服务进行资源控制。
  • 将单元名设为user-$UID.slice,对特定用户进行资源控制。
  • 添加--user选项,将单元名设为用户服务单元,对用户的特定服务进行资源控制。

如果我们希望创建一个带有特定CGroup限制的进程,除了手动创建一个用户级服务,一个较为简单的方法是使用systemd-run

1
systemd-run --unit=任务名 [--user] [--scope] [-G] [-p 属性=值] [-d|--working-directory=工作目录] [-E "Key=value"] [-r] 执行的命令
  • 创建的用户级单元默认会添加到/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service/app.slice目录下,如果希望在/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service目录下创建独立的CGroup目录进行管理,添加配置-p Slice=分片名.slice
  • -p 属性=值:配置Property,在这里进行CGroup相关的限制。

通过系统级systemd实例创建一个进程后,我们即可在之前所说的/sys/fs/cgroup/system.slice/目录下找到该.service或是.scope单元对应的CGroup目录。

通过用户级systemd实例创建一个进程后,我们即可在之前所说的/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service/app.slice/目录下找到该.service或是.scope单元对应的CGroup目录。

但是这样还是太不方便了,我们能不能摆脱systemd的限制,自己维护一个CGroup目录层级结构呢?答案是可以的。怎么实现呢?这就是通过systemd的委派功能:

委派(Delegation)

译自Control Group APIs and Delegation

容器管理器等程序往往会希望直接使用内核API来控制CGroup。只要遵循了适当的委派规则,这是完全可以支持的。委派是CGroup v2创造的一个概念,但之后也移植到了CGroup v1上。

委派意味着CGroup树的某些部分可能由不同的进程管理。只要清楚哪个进程管理树的哪一部分,每个进程都可以在其管理范围内随意操作。只有子树可以被委派(尽管任何决定请求子树的进程都可以进一步将子子树委派给其他进程,只要它们愿意的话)。

委派发生在特定的CGroup节点上:在systemd中,有一个Delegate=属性,可以为.service.scope单元设置。如果这样做,这个节点就会成为systemd的CGroup管理权的切换点:单元本身由systemd管理,即它的所有属性都是由systemd负责的,然而该单元的程序可以自由地在其CGroup目录中创建/删除子CGroup,这些子CGroup随后成为该程序的专有财产,systemd不会处理它们——换句话说,所有这些子CGroup的属性都可以由这个程序自由操作。

启用Delegate=属性会有以下几个影响:

  1. systemd绝不会再干预该CGroup目录下的子树,它不会修改子树中任何CGroup的属性,也不会在子树中创建任何目录。
  2. 如果该单元中指定了User=属性,那么该子树会被chown()至目标用户,以便他可以正确地创建目录。请注意,这仅在CGroup v2或是systemd的私有控制器目录下生效,它不会转移CGroup v1的其他控制器目录的所有权。
  3. 所有systemd安装的BPF IP过滤器程序都会加上BPF_F_ALLOW_MULTI,以允许用户程序可以安装附加的BPF程序。

在单元文件中,Delegate=属性一般采用布尔值。不过,从v236版本开始,它也可以接受空格分隔控制器名称。如果这样设置,那么systemd会将列出的控制器请求委派。不过需要注意,这只是一个请求。根据情况的不同,可能会导致实际上委派给服务的控制器数量不一致(例如,某些控制器在当前的内核上不可用或者被禁用)。如果设为布尔真值,那么所有可用的控制器都将被委派。

让我们强调一点:委派只在.scope.service单元上可用。它不适用于.slice单元。为什么?因为切片单元是我们CGroup树的中间节点,我们可以自由地将.service.scope单元附加到它们上。如果我们允许在.slice单元上进行委派,那么这将意味着systemd和用户自己的进程都有权在切片单元下创建/删除CGroup,这与单一写者规则相冲突。

因此,如果用户希望进行原始CGroup内核级别访问,需要专门分配一个.service.scope单元(或者使用现成的为自己的程序编写的服务单元),并为它开启委派。

Service Manager会在启用委派的CGroup目录上设置user.delegate扩展属性(通过getxattr(2)及相关调用),并将其值设置为字符1(这在未启用委派的CGroup上不存在)。这可以被服务程序用来确定CGroup子树是否已经委派给它们。请注意,这只在5.6及更高版本的Linux内核以及systemd >= 251的版本中支持。

  • 警告:如果你为服务开启了委派,并且该服务设置了ExecStartPost=ExecReload=ExecStop=ExecStopPost=,那么这些命令将在服务CGroup的.control/子CGroup中执行。这是必要的,因为通过开启委派,systemd必须假定现在你的服务单元的CGroup是一个中间CGroup,这意味着它不会直接包含任何进程。因此,如果你的服务设置了这四个设置中的任何一个,你必须意识到可能会出现由Service Manager管理的.control/子CGroup。这也意味着你的服务代码应该在使用systemd-notify通知Service Manager启动就绪时已经把自己移动到了CGroup树的更深层次,以便在Service Manager可能开始执行ExecStartPost=时,服务的主CGroup肯定是一个中间节点。从systemd 254版本开始,你也可以使用DelegateSubgroup=让Service Manager立即将你的初始服务进程放入一个子组中。
  • 另外注意,如果你打算使用“线程化”CGroup——在Linux 4.14中添加的特性——那么你应该在为其开启委派的主CGroup的后两级进行。为什么?首先,第一级目录是为了确保systemd可以正确地创建.control子组,如上所述。但是这个子组不能是线程化的,因为这将意味着.control也必须是线程化的——这是线程化CGroup的要求:要么CGroup及其所有同级CGroup都是线程化的,要么都不是——但systemd显然期望.control子组是一个常规CGroup。因此,你必须在它之下嵌套创建第二个CGroup,然后这个CGroup才可以是线程化的。

实际上,用户级systemd实例的服务单元本身就带有Delegate=属性:

1
2
3
4
5
# /lib/systemd/system/user@.service
...
...
Delegate=pids memory cpu
...

这意味着/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service/目录下的pidsmemorycpu控制器是委派给用户级systemd实例进行管理的。正因如此,用户级systemd实例才能在用户的.service.scope单元中存在Slice=属性时,为其在/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service/目录下创建合适的.slice子目录。

举例来说,Docker和Podman的底层工具RunC在以Rootless模式运行时,它会将容器进程向systemd注册为.scope单元,并且配置Slice=user.slice,这样,用户级systemd实例就会创建/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service/user.slice/目录,并将容器的.scope单元并统一安置在该目录下。

podman-user-slice.png

三种场景

我们假定你是一个容器管理器的开发者,正在考虑怎么处理CGroup,以使其在使用systemd的系统上运行。那么你有三种选择:

  1. 接受集成。对于这个选项,你可以将每个容器注册为一个systemd的.service单元(即让systemd负责调用执行器的二进制程序)或一个systemd的.scope单元(即由你的管理器负责执行二进制程序,但之后向systemd注册)。在这种模式下,管理员可以使用systemd的资源管理和内省命令对这些容器进行管理。通过为这些作用域或服务启用Delegate=,你可以在容器中运行依赖CGroup的程序,例如嵌套的systemd实例。这个选项又有两种子选项:
    1. 你可以通过直接通过D-Bus与systemd交流来临时地注册.service.scope单元。在这种情况下,systemd将只负责管理单元,不做其他事情。
    2. 你可以通过systemd-machined.service(也是通过 D-Bus)注册.service.scope单元。这个守护进程基本上只是上一种选项中相同操作的代理。这样做的主要好处是:你可以让系统知道你注册的是容器,这将启用某些额外的集成功能。例如,如果容器内运行了systemd,journalctl -M可以用来直接查看容器的日志,或者systemctl -M可以用来直接在容器内部执行systemd操作。此外,像ps这样的工具可以显示一个进程属于哪个容器(试试执行ps -eo pid,comm,machine),甚至gnome-system-monitor也存在支持。
  2. “孤岛”选项。如果你只关心你自己的CGroup树,并且希望尽可能少与systemd打交道,对与系统其余部分的集成完全不感兴趣,那么这是一个合适的选项。对此,你所要做的就是为你的主守护进程启用Delegate=。然后找出systemd将你的守护进程放在哪个CGroup中:然后,你就可以在其下自由创建子CGroup。但不要忘记中间节点无进程的规则:在你的子CGroup中启动进程之前,你必须将你的主守护进程移出该CGroup(并移入该CGroup的一个子CGroup)。
  3. “领地”选项。你坚持要保留你的守护进程所在的CGroup位置,并且不在其单元上打开委派。那么,当你启动第一个被管理的进程(例如容器)时,你需要向systemd注册一个新的.scope单元,并且该.scope单元需要启用Delegate=,并将该进程移入该.scope单元;随后创建的所有被管理的进程也都应该移动到这个.scope单元中。这样处理后,从systemd的角度来看,只有两个单元:你的管理进程的.service单元和包含所有被管理的进程的大.scope单元。

顺便说一句,如果你出于任何原因认为:“我讨厌D-Bus,我永远不会调用任何D-Bus API,谢谢再见。”,那么1号和3号选项就都不可用了,因为它们需要通过D-Bus让你的程序与systemd通信。但是在这种情况下,你仍然有2号选项,因为你可以简单地在.service单元文件中设置Delegate=,然后你就拥有了自己的CGroup子树。事实上,2号选项是唯一允许你完全忽略systemd存在的选项:你只需要遵循单一写者规则,即只使用你启动时所在的CGroup节点及其下面的所有内容。

  • 话虽如此,如果你真的有“那么”不喜欢D-Bus和systemd,也许更好的办法是转变态度,拓宽一下视野。谢谢。

控制器支持

systemd支持多种资源控制器(但不是全部)。具体来说,支持的有:

  • CGroup v1:cpucpuacctblkiomemorydevicespids
  • CGroup v2:cpuiomemorypids

我们的目标是原生支持所有添加到内核的CGroup v2控制器。然而,对于CGroup v1,我们将不再添加对任何其他控制器的支持。这意味着systemd目前不支持,而且永远也不会支持在CGroup v1上管理以下控制器:freezercpusetnet_clsperf_eventnet_priohugetlb

  • 为什么不支持?要么是它们的API语义或实现并不真正可用,要么是它们很明显在CGroup v2上没有未来,我们不会为明显没有未来的东西添加新代码。

实际上这意味着所有这些未支持的CGroup v1控制器都可以被直接使用:systemd不会管理它们,因此也不会负责将它们委派给你的程序(当然,systemd仍然会挂载它们的层级结构,不过仅仅是因为它会自动挂载内核中发现的所有可用的控制器)。如果你决定使用它们,没问题,但是systemd不会给你提供任何帮助(当然,也不会干涉你)。你可能会希望为其他租户中保持在其他控制器中一致的CGroup层级结构,但这是你与其他租户之间的事情,systemd并不关心。在这些不受支持的控制器中复制CGroup层级结构意味着需要在它们中保持完整的CGroup路径一致,因此也包括.slice目录,否则这些层级结构最终会变得正交,这并不是我们想要的。

还有一点:systemd会在它负责管理的层级结构中清理你的痕迹:如果你的进程停止,那么它的CGroup目录也会被移除。你可以得到这样的保证:每当你的.service.scope单元启动时,你都会得到一个初始化的CGroup子树。然而,在systemd不进行管理的层级结构中情况并非如此。这意味着你的程序必须准备好处理它们残留的——之前运行留下的——CGroup目录,并且要特别小心——因为它们可能仍然包含着不再有效的设置。

请注意,这里有一个特殊的不对称性:如果你的systemd版本在CGroup v1上不支持某个特定的控制器,你仍然可以通过直接操作其层级结构并根据需要复制CGroup树来使用它进行委派(如上所建议的)。然而,在CGroup v2上情况不同:单独挂载的层级结构不可用,委派总是必须通过systemd本身进行。这意味着:当你更新内核并且添加了一个新的、迄今为止未见过的控制器,如果你想使用它进行委派,那么你也需要更新systemd到一个能够理解它的版本。

作为容器载荷的systemd

systemd完全可以作为容器载荷的 PID 1 运行。但是请注意,systemd无条件地需要对CGroup树的写权限,因此你需要将一个子树委派给它。除此之外,你不需要进行太多特别的操作:只需在委派的CGroup子树的根节点调用systemd作为 PID 1,它会自己弄清楚其他的情况:它会确定它正在运行的CGroup子树并占有它。systemd不会干涉它被调用的子树之外的任何CGroup。因此,使用CLONE_NEWCGROUP是可选的(但是,这么做当然是明智的)。

但这里有一个特别的不对称性需要注意:systemd会尝试完全占有你传递给它的根CGroup目录,也就是说,它不仅会在其下创建/删除子CGroup,还会尝试管理这个根CGroup自己的属性。另一方面,如上所述,当将CGroup树委派给其他进程时,它只能传递创建/删除子CGroup的权限,但是会保持管理委派的CGroup树的顶级属性。换句话说:systemd在接受被委派的CGroup树时是贪婪的,同时在将它们委派给其他进程时也是贪婪的:它会坚持在两种情况下管理这些特定CGroup的属性。因此,一个本身是宿主systemd的载荷并希望运行自己的systemd作为其容器载荷的容器管理器,需要在层级结构之间插入一个额外的层级,以便宿主上的systemd和容器中的systemd不会为属性而竞争。话虽如此,由于中间节点不得包含进程的规则,你可能无论如何都不得不这样做,请参见下文。

当systemd作为容器载荷运行时,它会使用它具有写权限的所有层级结构。对于传统模式,你需要至少提供/sys/fs/cgroup/systemd/,所有其他层级结构都是可选的。对于混合模式,你需要添加/sys/fs/cgroup/unified/。最后,对于统一模式,你只需要提供/sys/fs/cgroup/本身。

总结

在CGroup v2上开发容器管理器时,应该做的事:

  1. 如果你选择了上面的实现选项1.1或1.2,那么你的每个容器都将拥有自己的systemd单元,因此可能在下一级有子CGroup。通常在该单元中运行的第一个进程将是某种类型的执行器程序(Executor),它会派生出容器的载荷进程。在这种情况下,请不要忘记两级委派:首先,systemd将一个CGroup子树委派给你的执行器进程。然后你的执行器应该将子树进一步委派给容器载荷进程——哦,对了,由于中间节点不得包含进程的规则,你的执行器也需要将自己迁移到其被委派的CGroup的子CGroup中。因此,你可能会想采取双管齐下的方法:在你启动的CGroup下面,你可能想要创建一个名为supervisor/的子CGroup,你的容器管理器在其中运行,然后为每个容器创建一个该CGroup的同级CGroup,可能称为payload-xyz/。(可以参考RunC
  2. 不要忘记你创建的CGroup必须使用适合作为UNIX文件名的名称,并且它们位于与各种内核属性文件相同的命名空间中。因此,如果你希望允许用户任意命名,那么你可能需要对一些名称进行转义(例如,你应该不会希望仅仅因为用户创建了一个名为tasks的容器,就创建一个名为tasks的CGroup,因为tasks毕竟是CGroup v1中的一个魔法属性,你的mkdir()将会因为发生EEXIST而失败。在systemd中,我们通过为可能与内核属性名称冲突的名称添加下划线前缀来进行转义。你可能也会想这样做,但这归根结底取决于你的决定。只是要做好,而且要小心。

不应该做的事情:

  1. 永远不要在systemd管理的任意CGroup下创建你自己的CGroup,即你没有在其中设置Delegate=的CGroup。特别是:不要在根CGroup下创建你自己的CGroup,那是systemd所有的。如果忽视这一点,你将影响systemd的工作,systemd也会影响你的工作。获取自己的委派子树,你可以在那里创建任意多的CGroup。认真地说,如果你尝试直接在CGroup根目录中创建CGroup,那么你所做的一切都只是自找麻烦
  2. 不要尝试在切片单元中设置Delegate=特别是不要在-.slice中设置。这种行为不受支持,而且将产生错误。
  3. 永远不要写入systemd创建的任何CGroup的属性,那是systemd的私有信息。你可以随意操纵你在自己委派的子树中创建的CGroup的属性,但是systemd自己的CGroup树对你来说是禁区。不过,你可以随意读取这些属性,这是完全可以的。
  4. 当委派子树给运行systemd的容器载荷,且未使用CLONE_NEWCGROUP时,不要只把宿主的CGroup树中的这个子树绑定挂载到容器中。CGroup API中有一个部分是/proc/$PID/cgroup,它会报告每个进程的CGroup路径,因此/sys/fs/cgroup/下的任何路径都需要与负载进程的/proc/$PID/cgroup报告的内容相匹配。你应该做的是将CGroup树的上层部分挂载为只读(甚至可以用tmpfs替换中间部分,但要小心——不能破坏上面讨论的statfs()检测逻辑),只要委派子树的路径仍然可以按原样访问。
  5. 目前,将.slice/.scope/.service单元的命名与它们的CGroup路径之间进行映射的算法没有被视为systemd的公共API,并且可能在未来的版本中发生变化。这意味着:最好避免在你的程序中实现将CGroup路径转换为.slice/.scope/.service单元名称的本地逻辑,反之亦然——这可能在未来会被破坏。相反,请使用适当的D-Bus API调用,以便systemd为你进行转换。
    • 具体来说:每个单元对象都有一个ControlGroup属性来获取单元的CGroup。GetUnitByControlGroup()方法可用于获取CGroup的单元。
  6. 在将CGroup v1控制器委派给权限较低的容器之前要三思。这是不安全的,这么做基本上等同于允许你的容器冻结宿主系统,甚至于更糟。委派是CGroup v2的强项,在CGroup v2中,将委派边界视为权限边界是安全的。

附:打破规则2

假定我们无视规则2,希望直接手动创建一个CGroup目录对我们在会话中创建的某个进程进行控制,这个进程是由我们手动创建的,因此它应当位于/sys/fs/cgroup/user.slice/user-$UID.slice/session-$SESSION_ID.scope/目录下,那么CGroup目录应当建在哪里呢?

肯定不能是/sys/fs/cgroup/user.slice/user-$UID.slice/session-$SESSION_ID.scope/目录下,因为根据规则1,这个CGroup目录中包含了进程,因此不能包含子CGroup目录。那么就只剩下/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service目录了。

我们可以在该目录下创建一个.slice后缀的目录对session-$SESSION_ID.scope下的进程进行CGroup控制。只要这个进程是长时间运行的,那么这个会话的.scope单元session-$SESSION_ID.scope就会随着这个进程的存在而存在,而user@$UID.service也会随着这个.scope单元的存在而存在。

当该进程终止时,这个会话的.scope单元session-$SESSION_ID.scope就会随之销毁,如果这是该用户的最后一个会话,那么user@$UID.service也会随之销毁。

当然,这种方式打破了规则2,因为/sys/fs/cgroup/user.slice/user-$UID.slice/user@$UID.service/目录实际上是由用户级systemd实例管理的,符合规则的做法应该是如之前所述,创建用户级.scope.service单元封装运行,并通过在用户级单元中添加Slice=属性请求用户级systemd实例创建独立的.slice目录。