对照组 v2 — Linux 内核文档

内容

日期:

2015年10月

作者:

Tejun Heo <[email protected]>

这是关于 cgroup v2 的设计、接口和约定的权威文档。它描述了 cgroup 的所有用户空间可见方面,包括核心和特定控制器的行为。所有未来的更改必须反映在此文档中。v1 的文档可在 Documentation/admin-guide/cgroup-v1/index.rst 下获得。

“cgroup”代表“控制组”,并且从不大写。单数形式用于指代整个特性,也用作修饰语,如“cgroup 控制器”。当明确指多个单独的控制组时,使用复数形式“cgroups”。

cgroup 是一种机制,用于以层次化的方式组织进程,并以受控和可配置的方式在层次结构中分配系统资源。

cgroup主要由两个部分组成 - 核心和控制器。cgroup核心主要负责以层次结构组织进程。cgroup控制器通常负责沿着层次结构分配特定类型的系统资源,尽管也有一些实用控制器用于其他目的,而不是资源分配。

cgroups 形成一个树状结构,系统中的每个进程只属于一个 cgroup。一个进程的所有线程都属于同一个 cgroup。在创建时,所有进程都被放入父进程当时所属的 cgroup。一个进程可以迁移到另一个 cgroup。进程的迁移不会影响已经存在的子进程。

根据某些结构约束,可以选择性地在 cgroup 上启用或禁用控制器。所有控制器的行为都是层次性的 - 如果在一个 cgroup 上启用了一个控制器,它会影响属于该 cgroup 的所有进程,这些进程组成了该 cgroup 的包含子层次结构。当在嵌套 cgroup 上启用控制器时,它总是进一步限制资源分配。层次结构中靠近根部设置的限制不能从更远的地方被覆盖。

与 v1 不同,cgroup v2 只有单一层次结构。可以使用以下挂载命令挂载 cgroup v2 层次结构:

# mount -t cgroup2 none $MOUNT_POINT

cgroup2 文件系统的魔术数字是 0x63677270 (“cgrp”)。所有支持 v2 的控制器且未绑定到 v1 层次结构的,都会自动绑定到 v2 层次结构并显示在根目录。未在 v2 层次结构中处于活动使用状态的控制器可以绑定到其他层次结构。这允许以完全向后兼容的方式将 v2 层次结构与传统的 v1 多层次结构混合。

控制器只有在不再被当前层次引用后才能在层次之间移动。由于每个 cgroup 控制器的状态是异步销毁的,并且控制器可能会有残留引用,因此在前一个层次的最终卸载后,控制器可能不会立即出现在 v2 层次中。同样,控制器应完全禁用才能从统一层次中移出,禁用的控制器可能需要一些时间才能在其他层次中可用;此外,由于控制器之间的依赖关系,其他控制器也可能需要被禁用。

虽然在开发和手动配置中很有用,但在生产环境中强烈不建议在 v2 和其他层次之间动态移动控制器。建议在系统启动后开始使用控制器之前,先决定层次结构和控制器关联。

在过渡到 v2 的过程中,系统管理软件可能仍会自动挂载 v1 cgroup 文件系统,从而在手动干预之前劫持所有控制器。为了使测试和实验更容易,内核参数 cgroup_no_v1= 允许禁用 v1 中的控制器,并使它们在 v2 中始终可用。

cgroup v2 目前支持以下挂载选项。

nsdelegate

将 cgroup 命名空间视为委托边界。此选项是系统范围的,只能在挂载时设置或通过从初始化命名空间重新挂载进行修改。非初始化命名空间挂载时忽略挂载选项。有关详细信息,请参阅委托部分。

favordynmods

以牺牲热路径操作(如分叉和退出)的成本,减少动态 cgroup 修改(如任务迁移和控制器开/关)的延迟。创建 cgroup、启用控制器,然后使用 CLONE_INTO_CGROUP 进行填充的静态使用模式不受此选项的影响。

memory_localevents

仅为当前 cgroup 填充 memory.events 数据,而不包括任何子树。这是遗留行为,默认行为是在没有此选项的情况下包括子树计数。此选项是系统范围的,只能在挂载时设置或通过从初始化命名空间重新挂载进行修改。非初始化命名空间挂载时忽略挂载选项。

memory_recursiveprot

递归地将 memory.min 和 memory.low 保护应用于整个子树,而无需显式向下传播到叶子 cgroup。这允许保护整个子树之间的相互关系,同时保留这些子树内的自由竞争。这应该是默认行为,但作为挂载选项以避免回归依赖于原始语义的设置(例如,在更高树级别指定虚假的高“绕过”保护值)。

memory_hugetlb_accounting

将 HugeTLB 内存使用计入 cgroup 的整体内存使用情况,以便于内存控制器(用于统计报告和内存保护)。这是一个新的行为,可能会回归现有设置,因此必须通过此挂载选项显式选择。

需要注意的几点:

  • 内存控制器中没有 HugeTLB 池管理。预分配的池不属于任何人。具体来说,当新的 HugeTLB 页分配到池时,从内存控制器的角度来看,它并不被计入。只有在实际使用时(例如在页面错误时)才会计入 cgroup。当配置硬限制时,主机内存超分配管理必须考虑这一点。一般来说,HugeTLB 池管理应通过其他机制(如 HugeTLB 控制器)进行。

  • 未能将 HugeTLB 页计入内存控制器会导致 SIGBUS。这可能发生,即使 HugeTLB 池仍有可用页面(但 cgroup 限制已达到且回收尝试失败)。

  • 将 HugeTLB 内存计入内存控制器会影响内存保护和回收动态。任何用户空间调优(例如低、最小限制)都需要考虑这一点。

  • 在未选择此选项时使用的 HugeTLB 页面将不会被内存控制器跟踪(即使 cgroup v2 后来被重新挂载)。

pids_localevents

此选项恢复 pids.events:max 的 v1 类似行为,即仅计入本地(在 cgroup 内部)分叉失败。没有此选项时,pids.events.max 代表 cgroup 子树中的任何 pids.max 强制执行。

最初,只有根 cgroup 存在,所有进程都属于该 cgroup。可以通过创建子目录来创建子 cgroup:

给定的 cgroup 可能有多个子 cgroup 形成树状结构。每个 cgroup 都有一个可读写的接口文件 "cgroup.procs"。读取时,它按行列出所有属于该 cgroup 的进程的 PID。PID 没有排序,如果进程被移动到另一个 cgroup 然后又返回,或者在读取时 PID 被回收,则同一个 PID 可能会出现多次。

一个进程可以通过将其 PID 写入目标 cgroup 的 "cgroup.procs" 文件来迁移到 cgroup 中。一次写入(2) 调用只能迁移一个进程。如果一个进程由多个线程组成,写入任何线程的 PID 将迁移该进程的所有线程。

当一个进程分叉一个子进程时,新进程会在分叉进程进行操作时所属的 cgroup 中诞生。退出后,进程会保持与其在退出时所属的 cgroup 关联,直到被收割;然而,僵尸进程不会出现在 “cgroup.procs” 中,因此无法移动到另一个 cgroup。

一个没有任何子项或活动进程的 cgroup 可以通过删除目录来销毁。请注意,一个没有任何子项且仅与僵尸进程关联的 cgroup 被视为空的,可以被移除:

“/proc/$PID/cgroup” 列出了一个进程的 cgroup 会员资格。如果系统中使用的是传统 cgroup,则此文件可能包含多行,每行对应一个层次结构。cgroup v2 的条目始终采用格式 “0::$PATH”:

# cat /proc/842/cgroup ... 0::/test-cgroup/test-cgroup-nested

如果进程变成僵尸并且与之关联的 cgroup 随后被删除,则在路径后附加 “(已删除)”:

# cat /proc/842/cgroup ... 0::/test-cgroup/test-cgroup-nested (已删除)

cgroup v2 支持一部分控制器的线程粒度,以支持需要在一组进程的线程之间进行层次资源分配的用例。默认情况下,进程的所有线程都属于同一个 cgroup,这也作为资源域来承载不特定于某个进程或线程的资源消耗。线程模式允许线程在子树中分布,同时仍然保持它们的共同资源域。

支持线程模式的控制器称为线程控制器。那些不支持的称为域控制器。

将一个 cgroup 标记为线程型使其作为线程型 cgroup 加入其父级的资源域。父级可能是另一个线程型 cgroup,其资源域在层次结构中更高。线程子树的根,即最近的非线程型祖先,被称为线程域或线程根,并作为整个子树的资源域。

在一个线程子树内部,进程的线程可以放在不同的 cgroups 中,并且不受内部进程限制的约束 - 线程控制器可以在非叶子 cgroups 上启用,无论它们是否包含线程。

由于线程域 cgroup 托管子树的所有域资源消耗,因此无论其中是否有进程,它都被视为具有内部资源消耗,并且不能有未线程化的子 cgroup。因为根 cgroup 不受内部进程约束的限制,它可以同时作为线程域和域 cgroup 的父级。

当前的操作模式或cgroup类型显示在“cgroup.type”文件中,该文件指示cgroup是一个普通域、作为线程子树的域,还是一个线程cgroup。

在创建时,cgroup 始终是一个域 cgroup,可以通过将 "threaded" 写入 "cgroup.type" 文件来使其变为线程化。该操作是单向的:

# echo 线程 > cgroup.type

一旦被线程化,cgroup 就无法再次成为域。要启用线程模式,必须满足以下条件。

  • 因为 cgroup 将加入父级的资源域。父级必须是一个有效的(线程)域或一个线程 cgroup。
  • 当父域是一个无线程域时,它不能有任何启用的域控制器或填充的域子项。根域不受此要求的限制。

从拓扑上看,cgroup 可能处于无效状态。请考虑以下拓扑:

A (线程域) - B (线程) - C (域,刚创建)

C 被创建为一个域,但没有连接到可以托管子域的父级。C 在转变为线程 cgroup 之前无法使用。在这些情况下,“cgroup.type” 文件将报告 “domain (invalid)”。由于拓扑无效而失败的操作使用 EOPNOTSUPP 作为 errno。

当其子 cgroup 变为线程或在 cgroup 中存在进程时,域 cgroup 会转变为线程域,前提是在线程控制器在 "cgroup.subtree_control" 文件中被启用。 当条件消除时,线程域会恢复为普通域。

当读取时,“cgroup.threads”包含了cgroup中所有线程的线程ID列表。除了操作是按线程而不是按进程进行外,“cgroup.threads”的格式和行为与“cgroup.procs”相同。虽然可以在任何cgroup中写入“cgroup.threads”,但由于它只能在同一线程域内移动线程,因此其操作被限制在每个线程子树内。

线程域 cgroup 作为整个子树的资源域,尽管线程可以分散在子树中,但所有进程都被视为在线程域 cgroup 中。线程域 cgroup 中的 "cgroup.procs" 包含子树中所有进程的 PID,并且在子树本身中不可读。然而,"cgroup.procs" 可以从子树中的任何地方写入,以将匹配进程的所有线程迁移到 cgroup 中。

只有线程控制器可以在线程子树中启用。当线程控制器在线程子树中启用时,它仅计算和控制与 cgroup 及其后代中的线程相关的资源消耗。所有不与特定线程绑定的消耗都属于线程域 cgroup。

因为线程子树不受任何内部过程约束的限制,线程控制器必须能够处理非叶子 cgroup 及其子 cgroup 之间的线程竞争。每个线程控制器定义了如何处理这种竞争。

目前,以下控制器是多线程的,可以在多线程 cgroup 中启用:

  • cpu
  • cpuset
  • perf_event
  • pids

每个非根 cgroup 都有一个 “cgroup.events” 文件,其中包含 “populated” 字段,指示 cgroup 的子层次中是否有活动进程。如果 cgroup 及其后代中没有活动进程,则其值为 0;否则为 1。当值发生变化时,会触发 poll 和 [id]notify 事件。这可以用于,例如,在给定子层次的所有进程退出后启动清理操作。populated 状态的更新和通知是递归的。考虑以下子层次,其中括号中的数字表示每个 cgroup 中的进程数量:

A(4) - B(0) - C(1) \ D(0)

A、B 和 C 的“已填充”字段将为 1,而 D 的为 0。在 C 中的一个进程退出后,B 和 C 的“已填充”字段将翻转为“0”,并将在两个 cgroup 的“cgroup.events”文件上生成文件修改事件。

每个 cgroup 都有一个 "cgroup.controllers" 文件,其中列出了可供该 cgroup 启用的所有控制器:

# cat cgroup.controllers cpu io 内存

默认情况下没有启用控制器。可以通过写入“cgroup.subtree_control”文件来启用和禁用控制器:

# echo "+cpu +memory -io" > cgroup.subtree_control

只有在“cgroup.controllers”中列出的控制器才能被启用。当如上所述指定多个操作时,要么全部成功,要么全部失败。如果对同一控制器指定多个操作,则最后一个操作有效。

在 cgroup 中启用控制器表示将控制目标资源在其直接子项之间的分配。考虑以下子层次结构。启用的控制器列在括号中:

A(cpu,内存) - B(内存) - C() \ D()

由于 A 启用了 "cpu" 和 "memory",A 将控制 CPU 周期和内存的分配给其子进程,在这种情况下是 B。由于 B 启用了 "memory" 但没有启用 "CPU",C 和 D 将在 CPU 周期上自由竞争,但它们对 B 可用内存的分配将受到控制。

作为一个控制器,它调节目标资源分配给 cgroup 的子项,启用它会在子 cgroups 中创建控制器的接口文件。在上面的例子中,在 B 上启用 "cpu" 会在 C 和 D 中创建以 "cpu." 为前缀的控制器接口文件。同样,从 B 中禁用 "memory" 会从 C 和 D 中移除以 "memory." 为前缀的控制器接口文件。这意味着控制器接口文件 - 任何不以 "cgroup." 开头的文件都是由父级拥有,而不是 cgroup 本身。

资源是自上而下分配的,cgroup 只能在资源从父级分配给它后进一步分配资源。这意味着所有非根的 “cgroup.subtree_control” 文件只能包含在父级的 “cgroup.subtree_control” 文件中启用的控制器。只有在父级启用了控制器的情况下,控制器才能被启用,并且如果一个或多个子级启用了控制器,则该控制器不能被禁用。

非根 cgroup 只能在没有自己的进程时,将域资源分配给其子级。换句话说,只有不包含任何进程的域 cgroup 才能在其 "cgroup.subtree_control" 文件中启用域控制器。

这保证了,当域控制器查看启用该功能的层次结构部分时,进程始终只在叶子节点上。这排除了子 cgroups 与父级内部进程竞争的情况。

根 cgroup 不受此限制。根包含无法与其他 cgroup 关联的进程和匿名资源消耗,并且需要大多数控制器的特殊处理。根 cgroup 中资源消耗的管理取决于每个控制器(有关此主题的更多信息,请参阅控制器章节中的非规范信息部分)。

请注意,如果在 cgroup 的 "cgroup.subtree_control" 中没有启用的控制器,则该限制不会造成障碍。这一点很重要,因为否则将无法创建已填充 cgroup 的子项。要控制 cgroup 的资源分配,cgroup 必须创建子项并在启用其 "cgroup.subtree_control" 文件中的控制器之前将所有进程转移到子项。

cgroup 可以通过两种方式进行委派。首先,通过授予用户对目录及其 “cgroup.procs”、 “cgroup.threads” 和 “cgroup.subtree_control” 文件的写入权限,将其委派给特权较低的用户。其次,如果设置了 “nsdelegate” 挂载选项,则在命名空间创建时自动委派给 cgroup 命名空间。

因为给定目录中的资源控制接口文件控制着父进程资源的分配,因此不应允许委托者对其进行写入。对于第一种方法,通过不授予对这些文件的访问权限来实现。对于第二种方法,应该通过至少挂载命名空间的方式将命名空间外的文件隐藏起来,并且内核拒绝从 cgroup 命名空间内部对命名空间根目录下的所有文件进行写入,除了在 “/sys/kernel/cgroup/delegate” 中列出的文件(包括 “cgroup.procs”、 “cgroup.threads”、 “cgroup.subtree_control”等)。

最终结果对于两种委托类型是等效的。一旦被委托,用户可以在目录下构建子层次结构,按照自己的意愿组织其中的流程,并进一步分配从父级接收到的资源。所有资源控制器的限制和其他设置都是层次化的,无论在委托的子层次结构中发生什么,都无法逃脱父级施加的资源限制。

目前,cgroup 对委托子层次中的 cgroup 数量或嵌套深度没有任何限制;然而,这在未来可能会被明确限制。

委托的子层次是被包含的,因为过程不能被受托人移入或移出子层次。

对于委派给特权较低的用户,这通过要求以下条件来实现:一个具有非根用户有效身份(euid)的进程必须将目标进程迁移到一个控制组(cgroup),方法是将其进程ID写入“cgroup.procs”文件。

  • 作者必须对“cgroup.procs”文件具有写入权限。
  • 作者必须对源和目标 cgroup 的共同祖先的 “cgroup.procs” 文件具有写入权限。

上述两个约束确保了,尽管被委托者可以在委托的子层次中自由迁移进程,但它不能从子层次外部引入或推送出去。

例如,假设 cgroups C0 和 C1 已经被委派给用户 U0,用户 U0 在 C0 下创建了 C00 和 C01,在 C1 下创建了 C10,如下所示,并且 C0 和 C1 下的所有进程都属于 U0:

~~~~~~~~~~~~~ - C0 - C00 ~ cgroup ~ \ C01 ~ 层次 ~

假设 U0 想要将当前在 C10 中的进程的 PID 写入 "C00/cgroup.procs"。U0 对该文件具有写入权限;然而,源 cgroup C10 和目标 cgroup C00 的共同祖先位于委托点之上,U0 将无法对其 "cgroup.procs" 文件进行写入,因此写入将被拒绝,错误代码为 -EACCES。

对于委派到命名空间,包含性是通过要求源 cgroup 和目标 cgroup 都可以从尝试迁移的进程的命名空间中访问来实现的。如果其中任何一个不可访问,则迁移将被拒绝,返回 -ENOENT。

跨 cgroups 迁移一个进程是一个相对昂贵的操作,状态资源如内存不会与进程一起移动。这是一个明确的设计决策,因为在迁移和各种热路径之间,通常存在固有的权衡,涉及同步成本。

因此,频繁地在 cgroups 之间迁移进程以施加不同的资源限制是不被鼓励的。工作负载应在启动时根据系统的逻辑和资源结构分配给一个 cgroup。可以通过接口文件更改控制器配置来动态调整资源分配。

cgroup及其子cgroup的接口文件占用同一目录,并且可以创建与接口文件冲突的子cgroup。

所有 cgroup 核心接口文件都以 "cgroup." 为前缀,每个控制器的接口文件都以控制器名称和一个点为前缀。控制器的名称由小写字母和 '' 组成,但永远不会以 '' 开头,以便可以用作避免冲突的前缀字符。此外,接口文件名不会以通常用于分类工作负载的术语开头或结尾,例如 job、service、slice、unit 或 workload。

cgroup 并不会防止名称冲突,避免名称冲突是用户的责任。

cgroup 控制器根据资源类型和预期使用案例实现几种资源分配方案。本节描述了主要的使用方案及其预期行为。

父母的资源通过将所有活跃孩子的权重相加来分配,并根据其权重与总和的比例给予每个孩子相应的份额。由于只有能够在此时利用资源的孩子参与分配,因此这是节省工作的。由于其动态特性,这种模型通常用于无状态资源。

所有权重在范围 [1, 10000] 内,默认值为 100。这允许在足够细的粒度下,在两个方向上具有对称的乘法偏差,同时保持在直观范围内。

只要重量在范围内,所有配置组合都是有效的,没有理由拒绝配置更改或处理迁移。

“cpu.weight” 按比例分配 CPU 周期给活动子进程,是此类型的一个例子。

一个孩子只能消耗配置的资源量。限制可以被超额承诺——孩子的限制总和可以超过父母可用的资源量。

限制在范围 [0, max] 并默认为 “max”,这意味着不执行任何操作.

由于限制可能被过度承诺,所有配置组合都是有效的,没有理由拒绝配置更改或处理迁移。

“io.max” 限制了 cgroup 在 IO 设备上可以消耗的最大 BPS 和/或 IOPS,是此类的一个例子。

cgroup 在配置的资源量之内受到保护,只要其所有祖先的使用量都在其保护水平之下。保护可以是硬性保证或尽力而为的软性边界。保护也可以是超额承诺,在这种情况下,只有在子级中,父级可用的量才受到保护。

保护范围为 [0, max],默认为 0,即无操作.

由于保护措施可能过度承诺,所有配置组合都是有效的,没有理由拒绝配置更改或流程迁移。

“memory.low” 实现了尽力而为的内存保护,并且是这种类型的一个例子。

cgroup 被专门分配了一定数量的有限资源。分配不能超额承诺 - 子项的分配总和不能超过父项可用的资源量。

分配范围为 [0, max],默认为 0,即没有资源。

由于分配不能过度承诺,一些配置组合是无效的,应被拒绝。此外,如果资源是执行过程所必需的,过程迁移可能会被拒绝。

“cpu.rt.max” 硬性分配实时切片,是这种类型的一个例子。

所有接口文件应尽可能采用以下格式之一:

换行分隔的值 (一次只能写一个值)

  值0\n
  值1\n
  ...

空格分隔值 (当为只读或可以一次写入多个值时)

  VAL0 VAL1 ...\n

平键

  键0 值0\n
  键1 值1\n
  ...

嵌套键

  键0 子键0=值00 子键1=值01...
  键1 子键0=值10 子键1=值11...
  ...

对于可写文件,写入的格式通常应与读取匹配;然而,控制器可能允许省略后面的字段或为最常见的用例实现受限的快捷方式。

对于平面和嵌套键文件,一次只能写入单个键的值。对于嵌套键文件,子键对可以按任意顺序指定,并且不必指定所有对。

  • 单个功能的设置应包含在一个文件中。
  • 根 cgroup 应该免于资源控制,因此不应有资源控制接口文件。
  • 默认时间单位是微秒。如果使用不同的单位,必须明确附加单位后缀。
  • 部件每量应使用至少有两位小数的百分比小数 - 例如 13.40.
  • 如果一个控制器实现基于权重的资源分配,它的接口文件应该命名为“weight”,范围应为 [1, 10000],默认值为100。选择这些值是为了在两个方向上保持足够且对称的偏差,同时保持直观(默认值为100%)。
  • 如果一个控制器实现了绝对资源保证和/或限制,接口文件应分别命名为“min”和“max”。如果一个控制器实现了尽力而为的资源保证和/或限制,接口文件应分别命名为“low”和“high”。

在上述四个控制文件中,特殊标记“max”应被用来表示向上的无穷大,适用于读取和写入。

  • 如果一个设置有可配置的默认值和特定的键值覆盖,默认条目应使用“default”作为键,并作为文件中的第一个条目出现。

默认值可以通过写入 "default $VAL" 或 "$VAL" 来更新。

在写入以更新特定的覆盖时,可以使用“default”作为值以指示移除该覆盖。当读取时,值为“default”的覆盖条目不得出现。

例如,一个由主:次设备编号和整数值键入的设置可能如下所示:

cat cgroup-example-interface-file

default 150
8:0 300
默认值可以通过以下方式更新:

echo 125 > cgroup-example-interface-file

或者:

echo "默认 125" > cgroup-example-interface-file

可以通过以下方式设置覆盖:

echo "8:16 170" > cgroup-example-interface-file

并由以下人员清除:

echo "8:0 默认" > cgroup-example-interface-file

# cat cgroup-example-interface-file
默认 125
8:16 170
  • 对于频率不是很高的事件,应创建一个接口文件“events”,列出事件键值对。每当发生可通知事件时,应在该文件上生成文件修改事件。

所有 cgroup 核心文件都以 "cgroup." 为前缀。

“cpu”控制器调节CPU周期的分配。该控制器实现了正常调度策略的权重和绝对带宽限制模型,以及实时调度策略的绝对带宽分配模型。

在上述所有模型中,周期分布仅在时间基础上定义,并未考虑任务执行的频率。 (可选的)利用率限制支持允许向schedutil cpufreq调度器提示CPU应始终提供的最低期望频率,以及CPU不应超过的最高期望频率。

警告:cgroup2 目前尚不支持对实时进程的控制。对于启用了 CONFIG tRT tGROUP tSCHED 选项以进行实时进程组调度的内核,只有当所有 RT 进程都在根 cgroup 中时,才能启用 cpu 控制器。如果禁用 CONFIG tRT tGROUP tSCHED,则此限制不适用。请注意,系统管理软件可能已经在系统启动过程中将 RT 进程放置在非根 cgroup 中,这些进程可能需要在启用 CONFIG tRT tGROUP tSCHED 启用的内核之前移动到根 cgroup 中,以便启用 cpu 控制器。

所有时间持续都是以微秒为单位。

“内存”控制器调节内存的分配。内存是有状态的,并实现了限制和保护模型。由于内存使用与回收压力之间的交织以及内存的有状态特性,分配模型相对复杂。

虽然并不是完全防水,但给定 cgroup 的所有主要内存使用情况都被跟踪,以便可以在合理的范围内计算和控制总内存消耗。目前,跟踪以下类型的内存使用情况。

  • 用户空间内存 - 页面缓存和匿名内存。
  • 内核数据结构,如目录项和索引节点。
  • TCP 套接字缓冲区。

上述列表可能会在未来扩展以提供更好的覆盖。

所有内存量以字节为单位。如果写入的值未对齐到 PAGE_SIZE,则在读取时该值可能会向上舍入到最接近的 PAGE_SIZE 的倍数。

以下嵌套键已定义。

“memory.high” 是控制内存使用的主要机制。在高限制上过度分配(高限制的总和 > 可用内存)并让全局内存压力根据使用情况分配内存是一种可行的策略。

因为超出高限制不会触发OOM杀手,而是限制违规的cgroup,管理代理有充足的机会监控并采取适当的措施,例如授予更多内存或终止工作负载。

确定一个 cgroup 是否有足够的内存并不是一件简单的事情,因为内存使用情况并不能表明工作负载是否能从更多内存中受益。例如,一个将从网络接收的数据写入文件的工作负载可以使用所有可用内存,但也可以在少量内存下高效运行。衡量内存压力 - 工作负载因缺乏内存而受到的影响程度 - 是确定工作负载是否需要更多内存的必要条件;不幸的是,内存压力监控机制尚未实现。

一个内存区域被分配给实例化它的 cgroup,并在该区域被释放之前一直保持分配给该 cgroup。将一个进程迁移到不同的 cgroup 并不会将它在之前的 cgroup 中实例化的内存使用情况移动到新的 cgroup。

内存区域可以被属于不同 cgroup 的进程使用。该区域将被计入哪个 cgroup 是不确定的;然而,随着时间的推移,该内存区域很可能最终会归入一个具有足够内存配额的 cgroup,以避免高回收压力。

如果一个控制组清理了大量内存,而这些内存预计会被其他控制组反复访问,那么使用 POSIX FADV DONTNEED 来放弃属于受影响文件的内存区域的所有权可能是有意义的,以确保正确的内存所有权。

“io”控制器调节IO资源的分配。该控制器实现了基于权重和绝对带宽或IOPS限制的分配;然而,基于权重的分配仅在使用cfq-iosched时可用,并且对于blk-mq设备,两种方案均不可用。

页面缓存通过缓冲写入和共享内存映射被污染,并通过写回机制异步写入到后端文件系统。写回机制位于内存和IO域之间,通过平衡脏数据和写入IO来调节脏内存的比例。

io 控制器与内存控制器协同工作,实现页面缓存写回 IO 的控制。内存控制器定义了脏内存比例计算和维护的内存域,而 io 控制器定义了为内存域写出脏页面的 io 域。系统范围和每个 cgroup 的脏内存状态都被检查,并执行两者中更严格的一个。

cgroup 写回需要底层文件系统的显式支持。目前,cgroup 写回在 ext2、ext4、btrfs、f2fs 和 xfs 上实现。在其他文件系统上,所有写回 IO 都归属于根 cgroup。

在内存和回写管理中存在固有的差异,这影响了 cgroup 所有权的跟踪。内存是按页跟踪的,而回写是按 inode 跟踪的。为了回写的目的,一个 inode 被分配给一个 cgroup,所有来自该 inode 的写入脏页的 IO 请求都归属于该 cgroup。

由于内存的 cgroup 拥有权是按页面跟踪的,因此可能存在与 inode 关联的 cgroup 不同的页面。这些被称为外部页面。写回机制不断跟踪外部页面,如果某个特定的外部 cgroup 在一定时间内成为多数,则将 inode 的拥有权切换到该 cgroup。

虽然这个模型对于大多数情况下足够用,其中一个给定的 inode 主要被单个 cgroup 污染,即使主要写入的 cgroup 随时间变化,但对于多个 cgroup 同时写入单个 inode 的用例支持不佳。在这种情况下,IO 的很大一部分可能会被错误归因。由于内存控制器在第一次使用时分配页面所有权,并且在页面释放之前不会更新,即使写回严格遵循页面所有权,多个 cgroup 污染重叠区域也不会按预期工作。建议避免这种使用模式。

影响写回行为的 sysctl 控制参数应用于 cgroup 写回如下。

vm.dirtyackground atio, vm.dirty atio

这些比率同样适用于 cgroup 写回,受内存控制器施加的限制和系统范围内的干净内存的可用内存限制。

vm.dirtyackgroundytes, vm.dirtyytes

对于 cgroup 写回,这个比率是根据总可用内存计算的,并以与 vm.dirtyackground atio 相同的方式应用。

这是一个用于 IO 工作负载保护的 cgroup v2 控制器。您提供一个具有延迟目标的组,如果平均延迟超过该目标,控制器将限制任何具有低于受保护工作负载的延迟目标的同伴。

限制仅在层级的对等级别应用。这意味着在下面的图中,只有组 A、B 和 C 会相互影响,而组 D 和 F 会相互影响。组 G 不会影响任何人:

      \[root\]

/ |
A B C / \ | D F G

因此,理想的配置方式是在组 A、B 和 C 中设置 io.latency。通常,您不希望设置低于设备支持的延迟值。进行实验以找到最适合您工作负载的值。从高于设备预期延迟的值开始,并观察工作负载组中 io.stat 的 avg_lat 值,以了解您在正常操作期间看到的延迟。使用 avg_lat 值作为您实际设置的基础,设置比 io.stat 中的值高 10-15%。

io.latency 是节省工作的;只要每个人都达到了他们的延迟目标,控制器就不会做任何事情。一旦某个组开始未能达到其目标,它就会开始限制任何目标高于自身的同伴组。这种限制有两种形式:

  • 队列深度限制。这是一个组允许拥有的未完成 IO 的数量。我们将相对快速地限制,从没有限制开始,一直到一次 1 个 IO。
  • 人工延迟引入。有某些类型的 IO 无法进行节流,否则可能会对更高优先级的组产生不利影响。这包括交换和元数据 IO。这些类型的 IO 被允许正常发生,然而它们会被“计入”发起组。如果发起组正在被节流,您将看到 io.stat 中的 use_delay 和 delay 字段增加。延迟值是添加到在该组中运行的任何进程的微秒数。由于如果发生大量交换或元数据 IO,这个数字可能会变得相当大,因此我们将单个延迟事件限制为一次 1 秒。

一旦受害群体再次开始达到其延迟目标,它将开始解除对之前被限制的任何对等组的限制。如果受害群体简单地停止进行 IO,全球计数器将适当地解除限制。

io.latency

这与其他控制器采用类似的格式。

“MAJOR:MINOR target=<目标时间(微秒)>”

io.stat

如果控制器被启用,您将在 io.stat 中看到额外的统计信息,除了正常的统计信息。

depth

这是该组的当前队列深度。

avg_lat

这是一个指数移动平均,衰减率为 1/exp,受采样间隔的限制。衰减率间隔可以通过将 io.stat 中的 win 值乘以基于 win 值的相应样本数量来计算。

win

采样窗口大小(毫秒)。这是评估事件之间的最小持续时间。窗口仅在 IO 活动时经过。空闲期会延长最近的窗口.

一个单一属性控制 I/O 优先级 cgroup 策略的行为,即 io.prio.class 属性。该属性接受以下值:

无变化

不要修改 I/O 优先级类别。

提升为实时

对于具有非实时 I/O 优先级类别的请求,将其更改为实时。同时将这些请求的优先级级别更改为 4。不要修改具有实时优先级类别的请求的 I/O 优先级。

限制为后台

对于没有 I/O 优先级类别或具有实时 I/O 优先级类别的请求,将其更改为后台。同时将这些请求的优先级级别更改为 0。不要修改具有空闲优先级类别的请求的 I/O 优先级类别。

空闲

将所有请求的 I/O 优先级类别更改为空闲,这是最低的 I/O 优先级类别。

无到实时

已弃用。只是提升为实时的别名。

以下数值与 I/O 优先级策略相关联:

无变化

0

提升到 rt

1

限制为

2

闲置

3

每个 I/O 优先级类别对应的数值如下:

IOPRIO\_CLASS\_NONE

0

IOPRIO\_CLASS\_RT (实时)

1

IOPRIO\_CLASS\_BE (最佳努力)

2

IOPRIO\_CLASS\_IDLE

3

为请求设置 I/O 优先级类别的算法如下:

  • 如果 I/O 优先级类策略是提升到实时, 将请求的 I/O 优先级类更改为 IOPRIO\_CLASS\_RT,并将请求的 I/O 优先级级别更改为 4.
  • 如果 I/O 优先级类策略不是 promote-to-rt,请将 I/O 优先级类策略转换为数字,然后将请求的 I/O 优先级类更改为 I/O 优先级类策略数字和数值 I/O 优先级类中的最大值。

进程号控制器用于在达到指定限制后,允许一个 cgroup 停止任何新的任务被 fork() 或 clone()。

cgroup 中的任务数量可以以其他控制器无法防止的方式被耗尽,因此需要其自己的控制器。例如,分叉炸弹可能会在达到内存限制之前耗尽任务数量。

请注意,此控制器中使用的 PID 指的是 TID,即内核使用的进程 ID。

pids.max

一个可读写的单值文件,存在于非根 cgroups 中。默认值为“max”。

进程数量的硬限制。

pids.current

一个只读的单值文件,存在于非根 cgroups 中。

当前在 cgroup 及其后代中的进程数量。

pids.peak

一个只读的单值文件,存在于非根 cgroups 中。

cgroup 及其后代中进程数量曾达到的最大值。

pids.events

一个只读的平面键值文件,存在于非根 cgroups 中。除非另有说明,否则该文件中的值变化会生成文件修改事件。定义了以下条目。

max

cgroup 的进程总数达到 pids.max 限制的次数(另见 pids_localevents)。

pids.events.local

类似于 pids.events,但文件中的字段是本地于 cgroup 的,即不是层次结构的。该文件生成的文件修改事件仅反映本地事件。

组织操作不受 cgroup 策略的阻碍,因此 pids.current 可以大于 pids.max。这可以通过将限制设置为小于 pids.current,或者将足够的进程附加到 cgroup,使得 pids.current 大于 pids.max 来实现。然而,通过 fork() 或 clone() 违反 cgroup PID 策略是不可能的。如果创建新进程会导致违反 cgroup 策略,这些操作将返回 -EAGAIN。

“cpuset”控制器提供了一种机制,用于限制任务的CPU和内存节点放置,仅限于任务当前cgroup中cpuset接口文件中指定的资源。这在大型NUMA系统上尤其有价值,在这些系统上,合理地将作业放置在适当大小的子集上,并仔细安排处理器和内存,以减少跨节点内存访问和争用,可以提高整体系统性能。

“cpuset”控制器是层次化的。这意味着控制器不能使用其父级中不允许的CPU或内存节点。

设备控制器管理对设备文件的访问。它包括新设备文件的创建(使用 mknod)和对现有设备文件的访问。

Cgroup v2 设备控制器没有接口文件,并且是在 cgroup BPF 之上实现的。为了控制对设备文件的访问,用户可以创建类型为 BPF_PROG_TYPE_CGROUP_DEVICE 的 bpf 程序,并将其附加到带有 BPF_CGROUP_DEVICE 标志的 cgroup 上。在尝试访问设备文件时,相应的 BPF 程序将被执行,具体取决于返回值,该尝试将成功或以 -EPERM 失败。

一个 BPF_PROG_TYPE_CGROUP_DEVICE 程序接受一个指向 bpf_cgroup_dev_ctx 结构的指针,该结构描述了设备访问尝试:访问类型(mknod/read/write)和设备(类型、主设备号和次设备号)。如果程序返回 0,则尝试失败并返回 -EPERM, 否则成功。

BPF_PROG_TYPE_CGROUP_DEVICE 程序的一个示例可以在内核源代码树的 tools/testing/selftests/bpf/progs/dev_cgroup.c 中找到。

“rdma”控制器调节RDMA资源的分配和核算。

rdma.max

一个读写的嵌套键文件,存在于除根以外的所有 cgroups 中,描述了 RDMA/IB 设备当前配置的资源限制。

行以设备名称为键,且没有排序。每行包含以空格分隔的资源名称及其可分配的配置限制。

定义了以下嵌套键。

hca_handle

最大 HCA 句柄数量

hca_object

最大 HCA 对象数量

以下是 mlx4 和 ocrdma 设备的示例:

mlx4_0 hca_handle=2 hca_object=2000 ocrdma1 hca_handle=3 hca_object=max

rdma.current

一个只读文件,描述当前资源使用情况。它存在于除根以外的所有 cgroup 中。

以下是 mlx4 和 ocrdma 设备的示例:

mlx4_0 hca_handle=1 hca_object=20 ocrdma1 hca_handle=1 hca_object=23

HugeTLB 控制器允许限制每个控制组的 HugeTLB 使用,并在页面错误期间强制执行控制器限制。

hugetlb.<hugepagesize>.current

显示“hugepagesize”hugetlb的当前使用情况。它存在于所有cgroup中,除了root。

hugetlb.<hugepagesize>.max

设置/显示“hugepagesize”hugetlb使用的硬限制。默认值为“max”。它存在于所有cgroup中,除了root。

hugetlb.<hugepagesize>.events

一个只读的平面键文件,存在于非root cgroups中。

max

由于HugeTLB限制而导致的分配失败次数

hugetlb.<hugepagesize>.events.local

类似于hugetlb.<hugepagesize>.events,但文件中的字段是本地于cgroup的,即不是层次结构的。此文件上生成的修改事件仅反映本地事件。

hugetlb.<hugepagesize>.numa_stat

类似于memory.numa_stat,它显示此cgroup中<hugepagesize>的hugetlb页面的numa信息。仅包括正在使用的活动hugetlb页面。每个节点的值以字节为单位。

杂项 cgroup 提供了资源限制和跟踪机制,用于那些无法像其他 cgroup 资源一样抽象的标量资源。控制器由 CONFIG\_CGROUP\_MISC 配置选项启用。

可以通过 include/linux/misc_cgroup.h 文件中的 enum misc_res_type{} 将资源添加到控制器,并通过 kernel/cgroup/misc.c 文件中的 misc_res_name[] 设置相应的名称。资源的提供者必须在使用资源之前,通过调用 misc_cg_set_capacity() 设置其容量。

一旦设置了容量,就可以使用充电和取消充电 API 更新资源使用情况。与杂项控制器交互的所有 API 都在 include/linux/misc extunderscore cgroup.h 中。

杂项控制器提供3个接口文件。如果注册了两个杂项资源(res_a 和 res_b),那么:

misc.capacity

一个只在根 cgroup 中显示的只读平面键文件。它显示了平台上可用的各种标量资源及其数量:

$ cat misc.capacity res_a 50 res_b 10

misc.current

一个在所有 cgroup 中显示的只读平面键文件。它显示了 cgroup 及其子项中资源的当前使用情况。:

$ cat misc.current res_a 3 res_b 0

misc.peak

一个在所有 cgroup 中显示的只读平面键文件。它显示了 cgroup 及其子项中资源的历史最大使用情况。:

$ cat misc.peak res_a 10 res_b 8

misc.max

一个在非根 cgroup 中显示的读写平面键文件。允许的资源最大使用量在 cgroup 及其子项中。:

$ cat misc.max res_a max res_b 4

限制可以通过以下方式设置:

# echo res_a 1 > misc.max

限制可以设置为 max:

# echo res_a max > misc.max

限制可以设置高于 misc.capacity 文件中的容量值。

misc.events

一个存在于非根 cgroup 中的只读平面键文件。定义了以下条目。除非另有说明,否则此文件中的值更改会生成文件修改事件。此文件中的所有字段都是层次结构的。

max

cgroup 的资源使用量即将超过最大边界的次数。

misc.events.local

类似于 misc.events,但文件中的字段是 cgroup 本地的,即不是层次结构的。此文件生成的文件修改事件仅反映本地事件。

杂项标量资源首先被计入其使用的 cgroup,并在该资源被释放之前一直计入该 cgroup。将进程迁移到不同的 cgroup 不会将费用转移到进程迁移到的目标 cgroup。

perf_event 控制器,如果没有挂载在传统层次结构上,将自动在 v2 层次结构上启用,以便 perf 事件始终可以通过 cgroup v2 路径进行过滤。控制器仍然可以在 v2 层次结构填充后移动到传统层次结构。

本节包含的信息不被视为稳定内核 API 的一部分,因此可能会发生变化。

在根 cgroup 中分配 CPU 周期时,该 cgroup 中的每个线程都被视为在根 cgroup 的一个单独子 cgroup 中托管。这个子 cgroup 的权重取决于其线程的优先级等级。

有关此映射的详细信息,请参见 kernel/sched/core.c 文件中的 sched_prio_to_weight 数组(该数组中的值应适当缩放,以便中性 - nice 0 - 值为 100 而不是 1024)。

根 cgroup 进程托管在一个隐式叶子子节点中。在分配 IO 资源时,这个隐式子节点被视为根 cgroup 的一个正常子 cgroup,权重值为 200。

cgroup 命名空间提供了一种机制来虚拟化 “/proc/$PID/cgroup” 文件和 cgroup 挂载的视图。可以使用 CLONE NEWCGROUP 克隆标志与 clone(2) 和 unshare(2) 一起创建新的 cgroup 命名空间。在 cgroup 命名空间内运行的进程将其 “/proc/$PID/cgroup” 输出限制为 cgroupns 根。cgroupns 根是创建 cgroup 命名空间时进程的 cgroup。

没有 cgroup 命名空间时,“/proc/$PID/cgroup” 文件显示进程的 cgroup 的完整路径。在一个容器设置中,一组 cgroups 和命名空间旨在隔离进程,“/proc/$PID/cgroup” 文件可能会向隔离的进程泄露潜在的系统级信息。例如:

# cat /proc/self/cgroup 0::/batchjobs/container_id1

路径 ‘/batchjobs/container_id1’ 可以被视为系统数据,不应暴露给隔离的进程。可以使用 cgroup 命名空间来限制该路径的可见性。例如,在创建 cgroup 命名空间之前,可以看到:

# ls -l /proc/self/ns/cgroup lrwxrwxrwx 1 root root 0 2014-07-15 10:37 /proc/self/ns/cgroup -> cgroup:[4026531835]

cat /proc/self/cgroup

0::/batchjobs/container_id1

取消共享新命名空间后,视图发生变化:

# ls -l /proc/self/ns/cgroup lrwxrwxrwx 1 root root 0 2014-07-15 10:35 /proc/self/ns/cgroup -> cgroup:[4026532183]

cat /proc/self/cgroup

0::/

当多线程进程中的某个线程取消共享其 cgroup 命名空间时,新的 cgroupns 会应用于整个进程(所有线程)。对于 v2 层次结构来说,这是自然的;然而,对于遗留层次结构,这可能是意外的。

cgroup 命名空间只要内部有进程或挂载在使用,就会保持活跃。当最后一个使用消失时,cgroup 命名空间被销毁。cgroupns 根和实际的 cgroups 保持不变。

cgroup 命名空间的“cgroupns 根”是调用 unshare(2) 的进程所在的 cgroup。例如,如果位于 /batchjobs/container_id1 cgroup 的进程调用 unshare,则 cgroup /batchjobs/container_id1 成为 cgroupns 根。对于 init_cgroup_ns,这是真正的根(‘/’)cgroup。

即使命名空间创建者进程后来移动到不同的 cgroup,cgroupns 根 cgroup 也不会改变:

# ~/unshare -c # 在某个 cgroup 中 unshare cgroupns # cat /proc/self/cgroup 0::/ # mkdir sub_cgrp_1 # echo 0 > sub_cgrp_1/cgroup.procs # cat /proc/self/cgroup 0::/sub_cgrp_1

每个进程获得其命名空间特定的“/proc/$PID/cgroup”视图

在 cgroup 命名空间内运行的进程只能在其根 cgroup 内看到 cgroup 路径(在 /proc/self/cgroup 中)。在一个未共享的 cgroupns 内:

# sleep 100000 & [1] 7353

echo 7353 > sub_cgrp_1/cgroup.procs

cat /proc/7353/cgroup

0::/sub_cgrp_1

从初始 cgroup 命名空间,真实的 cgroup 路径将可见:

$ cat /proc/7353/cgroup 0::/batchjobs/container_id1/sub_cgrp_1

从一个兄弟 cgroup 命名空间(即,根植于不同 cgroup 的命名空间)来看,相对于其自身 cgroup 命名空间根的 cgroup 路径将会显示。例如,如果 PID 7353 的 cgroup 命名空间根在 ‘/batchjobs/container_id2’,那么它将看到:

# cat /proc/7353/cgroup 0::/../container_id2/sub_cgrp_1

请注意,相对路径总是以‘/’开头,以指示它相对于调用者的cgroup命名空间根。

在 cgroup 命名空间内的进程可以在具有适当访问权限的情况下进出命名空间根。例如,从命名空间内部,cgroupns 根位于 /batchjobs/container_id1,并假设全局层次结构在 cgroupns 内仍然可访问:

# cat /proc/7353/cgroup 0::/sub_cgrp_1

echo 7353 > batchjobs/container_id2/cgroup.procs

cat /proc/7353/cgroup

0::/../container_id2

请注意,这种设置是不被鼓励的。cgroup 命名空间中的任务应该仅暴露给其自身的 cgroupns 层次结构。

setns(2) 允许在以下情况下切换到另一个 cgroup 命名空间:

  1. 该进程在其当前用户命名空间中具有 CAP\_SYS\_ADMIN 权限
  1. 该进程在目标 cgroup 命名空间的 userns 上具有 CAP\_SYS\_ADMIN 权限

附加到另一个 cgroup 命名空间时不会发生隐式的 cgroup 更改。预计有人会将附加进程移动到目标 cgroup 命名空间根下。

特定命名空间的 cgroup 层次结构可以由在非 init cgroup 命名空间中运行的进程挂载:

# mount -t cgroup2 none $MOUNT_POINT

这将以 cgroupns 根作为文件系统根挂载统一的 cgroup 层次结构。该进程需要对其用户和挂载命名空间具有 CAP_SYS_ADMIN 权限。

/proc/self/cgroup 文件的虚拟化结合通过命名空间私有的 cgroupfs 挂载限制 cgroup 层次结构的视图,提供了容器内适当隔离的 cgroup 视图。

本节包含在与 cgroup 交互时所需的内核编程信息。cgroup 核心和控制器不在此范围内。

一个文件系统可以通过更新 address_space_operations->writepage[s]() 来支持 cgroup 写回,以使用以下两个函数注释 bio。

wbc t ext{_init ext{_bio(@wbc, @bio)

应该为每个携带写回数据的 bio 调用,并将该 bio 与 inode 的所有者 cgroup 及相应的请求队列关联。必须在将队列(设备)与 bio 关联后并在提交之前调用此函数。

wbc ext{_account ext{_cgroup ext{_owner(@wbc, @page, @bytes)

应该为每个正在写出的数据段调用。虽然此函数并不关心在写回会话期间确切何时调用,但在将数据段添加到 bio 时调用它是最简单和最自然的。

通过注释写回 bio,可以通过在 ->s_iflags 中设置 SB_I_CGROUPWB 来为每个 super_block 启用 cgroup 支持。这允许选择性地禁用 cgroup 写回支持,这在某些文件系统特性(例如,日志数据模式)不兼容时非常有用。

wbc_init_bio() 将指定的 bio 绑定到其 cgroup。根据配置,bio 可能以较低的优先级执行,如果写回会话持有共享资源,例如日志条目,可能会导致优先级反转。这个问题没有简单的解决方案。文件系统可以通过跳过 wbc_init_bio() 并直接使用 bio_associate_blkg() 来尝试解决特定问题案例。

  • 不支持多个层级,包括命名层级。
  • 所有 v1 挂载选项不受支持。
  • “tasks”文件已被移除,且“cgroup.procs”未排序。
  • “cgroup.clone_children” 已被移除。
  • /proc/cgroups 对于 v2 是无意义的。请使用根目录下的 “cgroup.controllers” 或 “cgroup.stat” 文件。

cgroup v1 允许任意数量的层次结构,每个层次结构可以容纳任意数量的控制器。虽然这似乎提供了很高的灵活性,但在实践中并没有用处。

例如,由于每个控制器只有一个实例,因此像冷冻机这样的实用型控制器在所有层级中都可能有用,但只能在一个层级中使用。这个问题因控制器一旦填充层级后就无法移动到另一个层级而加剧。另一个问题是,绑定到一个层级的所有控制器都被迫对该层级有完全相同的视图。无法根据特定控制器的需要来改变粒度。

在实践中,这些问题严重限制了可以放在同一层级的控制器,大多数配置不得不将每个控制器放在自己的层级上。只有密切相关的控制器,例如 cpu 和 cpuacct 控制器,放在同一层级才有意义。这通常意味着用户空间最终管理多个相似的层级,每当需要进行层级管理操作时,都要在每个层级上重复相同的步骤。

此外,对多个层次的支持代价高昂。这极大地复杂化了 cgroup 核心实现,但更重要的是,对多个层次的支持限制了 cgroup 的一般使用方式以及控制器能够执行的操作。

没有对可能存在的层级数量设限,这意味着线程的 cgroup 会员资格无法用有限的长度来描述。键可以包含任意数量的条目,并且长度不受限制,这使得操作变得非常麻烦,并导致添加了仅用于识别会员资格的控制器,这反过来又加剧了层级数量激增的原始问题。

此外,由于控制器无法对其他控制器可能处于的层次结构的拓扑有任何期望,因此每个控制器必须假设所有其他控制器都连接到完全正交的层次结构。这使得控制器之间的合作变得不可能,或者至少非常繁琐。

在大多数使用案例中,将控制器放置在彼此完全正交的层次结构上并不是必要的。通常需要的是根据特定控制器具有不同的粒度级别。换句话说,从特定控制器的角度来看,层次结构可能会从叶子向根部折叠。例如,给定的配置可能不关心在某个级别之外内存是如何分配的,同时仍然希望控制 CPU 周期的分配。

cgroup v1 允许一个进程的线程属于不同的 cgroup。这对某些控制器来说没有意义,这些控制器最终实现了不同的方法来忽略这种情况,但更重要的是,它模糊了暴露给单个应用程序的 API 和系统管理接口之间的界限。

通常,过程中的知识仅对该过程本身可用;因此,与服务级别的过程组织不同,分类一个过程的线程需要拥有目标过程的应用程序的积极参与。

cgroup v1 有一个模糊定义的委托模型,这在与线程粒度结合时被滥用。cgroups 被委托给单个应用程序,以便它们可以创建和管理自己的子层次结构,并控制沿着它们的资源分配。这有效地将 cgroup 提升到了一个类似于系统调用的 API 的地位,向普通程序开放。

首先,cgroup 的接口从根本上来说是不足以以这种方式暴露的。为了让一个进程访问它自己的控制参数,它必须从 /proc/self/cgroup 中提取目标层次结构的路径,通过将控制参数的名称附加到路径上来构造路径,然后打开并读取和/或写入它。这不仅极其笨拙和不寻常,而且本质上是竞争的。没有常规的方法可以在所需步骤之间定义事务,也没有什么可以保证进程实际上会在它自己的子层次结构上操作。

cgroup 控制器实现了一些控制选项,这些选项永远不会被接受为公共 API,因为它们只是向系统管理伪文件系统添加控制选项。cgroup 最终拥有的接口选项没有得到适当的抽象或精炼,直接暴露了内核内部细节。这些选项通过不明确定义的委托机制暴露给单个应用程序,有效地滥用了 cgroup 作为实现公共 API 的捷径,而无需经过必要的审查。

这对用户空间和内核来说都是痛苦的。用户空间最终得到了行为不当和抽象不良的接口,而内核则无意中暴露并锁定在某些结构中。

cgroup v1 允许线程处于任何 cgroups,这造成了一个有趣的问题,即属于父 cgroup 及其子 cgroup 的线程竞争资源。这是个棘手的问题,因为两种不同类型的实体在竞争,并且没有明显的方法来解决它。不同的控制器执行不同的操作。

CPU 控制器将线程和 cgroups 视为等价物,并将 nice 等级映射到 cgroup 权重。这在某些情况下有效,但当子进程希望分配特定的 CPU 周期比例且内部线程数量波动时,这种方法就失效了——随着竞争实体数量的波动,比例不断变化。还有其他问题。nice 等级到权重的映射并不明显或普遍,并且还有其他一些调节选项根本不适用于线程。

io 控制器隐式地为每个 cgroup 创建了一个隐藏的叶子节点来承载线程。这个隐藏的叶子节点有自己所有的以 leaf_ 为前缀的控制项的副本。虽然这允许对内部线程进行等效控制,但也带来了严重的缺点。它总是增加了一层额外的嵌套,而这在其他情况下是不必要的,使得接口变得混乱,并显著复杂化了实现。

内存控制器没有办法控制内部任务和子 cgroups 之间发生的事情,行为也没有明确定义。曾经尝试添加临时行为和调节器,以便将行为调整到特定的工作负载,这将导致长期内极难解决的问题。

多个控制器在内部任务上挣扎,并提出了不同的处理方式;不幸的是,所有的方法都存在严重缺陷,而且,截然不同的行为使得 cgroup 整体高度不一致。

这显然是一个需要以统一方式从 cgroup 核心解决的问题。

cgroup v1 在没有监督的情况下发展,产生了大量的特性和不一致性。cgroup 核心方面的一个问题是如何通知一个空的 cgroup - 每个事件都会派生并执行一个用户空间辅助二进制文件。事件传递不是递归的,也不能委托。该机制的局限性还导致了内核事件传递过滤机制,进一步复杂化了接口。

控制器接口也存在问题。一个极端的例子是控制器完全忽视层次组织,将所有 cgroup 视为直接位于根 cgroup 之下。一些控制器向用户空间暴露了大量不一致的实现细节。

控制器之间也没有一致性。当创建一个新的 cgroup 时,一些控制器默认不施加额外限制,而其他控制器则在未明确配置之前不允许任何资源使用。同类型控制的配置选项使用了广泛不同的命名方案和格式。统计和信息选项的命名是任意的,即使在同一个控制器中也使用了不同的格式和单位。

cgroup v2 建立了适当的共同约定,并更新了控制器,以便它们暴露最小且一致的接口。

原始的下边界,软限制,被定义为默认未设置的限制。因此,全球回收优先的 cgroups 集合是选择加入,而不是选择退出。优化这些主要是负查找的成本如此之高,以至于尽管实现规模庞大,但甚至没有提供基本的期望行为。首先,软限制没有层次意义。所有配置的组都组织在一个全局的 rbtree 中,并被视为平等的同伴,无论它们在层次结构中的位置如何。这使得子树委托变得不可能。其次,软限制回收过程是如此激进,以至于不仅在系统中引入了高分配延迟,还由于过度回收影响了系统性能,甚至使得该功能变得自我挫败。

另一方面,memory.low 边界是一个自上而下分配的保留区。当 cgroup 在其有效低值内时,享有回收保护,这使得子树的委托成为可能。当 cgroup 超过其有效低值时,它还享有与其超额相称的回收压力。

原始的高边界,硬限制,被定义为一个严格的限制,不能动摇,即使必须调用 OOM 杀手。但这通常与充分利用可用内存的目标相悖。工作负载的内存消耗在运行时是变化的,这要求用户进行超额分配。但在严格的上限下进行超额分配需要对工作集大小有相当准确的预测,或者在限制上添加余量。由于工作集大小的估计困难且容易出错,错误的估计会导致 OOM 杀死,大多数用户倾向于在较宽松的限制上犯错,最终浪费宝贵的资源。

另一方面,memory.high 边界可以设置得更加保守。当达到该边界时,它通过强制直接回收来限制分配,以消耗多余的内存,但它从不调用 OOM 杀手。因此,选择过于激进的高边界不会终止进程,而是会导致逐渐的性能下降。用户可以监控这一点并进行调整,直到找到仍能提供可接受性能的最小内存占用。

在极端情况下,当有许多并发分配并且组内的回收进度完全崩溃时,高边界可能会被超过。但即便如此,通常还是更好从其他组或系统的剩余部分满足分配,而不是杀死该组。否则,memory.max 就是用来限制这种溢出并最终控制有缺陷或甚至恶意的应用程序。

组合的内存+交换空间的核算和限制被真正的交换空间控制所取代。

原始 cgroup 设计中结合内存+交换设施的主要论点是,全球或父级压力始终能够交换子组的所有匿名内存,无论子组自身(可能不受信任)的配置如何。然而,不受信任的组可以通过其他方式破坏交换,例如在紧密循环中引用其匿名内存,管理员在过度分配不受信任的作业时不能假设完全可交换性。

另一方面,对于受信任的作业,组合计数器并不是一个直观的用户空间接口,这与 cgroup 控制器应该核算和限制特定物理资源的理念相悖。交换空间是系统中与其他资源一样的资源,这就是为什么统一层次结构允许单独分配它。

总结
本文是关于cgroup v2的权威文档,详细描述了其设计、接口和约定。cgroup(控制组)是一种机制,用于以层次化的方式组织进程并分配系统资源。cgroup v2与v1不同,采用单一层次结构,所有进程只能属于一个cgroup。cgroup由核心和控制器组成,核心负责组织进程,控制器则负责资源分配。cgroup支持动态迁移进程和控制器,但在生产环境中不建议频繁变更。文中还介绍了cgroup v2的挂载选项、线程粒度支持以及如何创建和管理cgroup。cgroup v2的设计旨在提高资源管理的灵活性和效率,同时保持与v1的兼容性。