
在现代计算中,可预测地管理和隔离系统资源并非奢侈品,而是稳定性、安全性和性能的基石。在 Linux 操作系统的核心,存在着一个专为此目的而设计的强大而优雅的机制:控制组(Control Groups),简称 cgroups。虽然 cgroups 常常与容器联系在一起,但它们是一种更基础的资源治理工具,旨在解决在共享环境中防止进程独占 CPU、内存和 I/O 的关键挑战。本文将揭开 cgroups 的神秘面纱,展示其高效背后的架构原则。
接下来的章节将引导您深入了解这项强大的技术。首先,在“原理与机制”中,我们将剖析 cgroups 的核心设计,探讨它们如何与命名空间和 seccomp 协同工作以创建健壮的隔离,并考察关键控制器中复杂的算法。随后,在“应用与跨学科联系”中,我们将看到这些底层机制如何赋能高层应用,从驯服单台服务器上的“吵闹邻居”到编排整个全球云。
要真正领会控制组 (cgroups) 的强大与优雅,我们不能从容器或复杂的云平台入手,而必须深入探究像 Linux 这样的现代操作系统的核心。在这里,我们发现了一种既强大又简单的基础设计哲学:机制与策略的分离。想象一下内核——操作系统的特权核心——就像一位总工程师。这位工程师不决定一栋建筑应如何使用;相反,他提供一套强大、通用的工具和材料。他安装管道、电线、断路器和门锁。这些就是机制。如何使用这些工具则由租户——用户空间应用程序——来决定。他们设定恒温器的温度,决定插入哪些电器,以及选择谁能拿到钥匙。这就是策略。
控制组是内核最通用的机制之一。它们本身并不是一种容器化技术。更确切地说,容器运行时是一个用户空间应用程序,它巧妙地利用 cgroup 机制(以及其他机制)来强制执行其容器化策略。理解这一区别是洞悉现代系统管理和隔离进程方式内在之美与统一性的第一步。
当我们谈论“容器”时,我们实际上是在谈论由内核提供的三种不同、正交的机制共同作用的效果。每种机制都回答了一个不同的基本问题,它们共同构成了现代操作系统级虚拟化的支柱。
第一个支柱回答了这个问题:我能看到什么?
命名空间旨在为进程创建一个私有的、虚拟化的系统视图。一个处于 PID(进程 ID)命名空间内的进程可能认为自己是至关重要的“1 号进程”,即所有其他进程的祖先,尽管从主机系统的角度来看,它只是一个有着较大编号 ID 的普通进程。类似地,网络命名空间为进程提供了自己私有的网络接口和路由表集合,使其看起来仿佛拥有自己专用的网络栈。
可以把命名空间想象成给公寓楼里的每个租户一套私有的、重新编号的电话分机目录,或者一套自己贴好标签的网络插口。它改变了他们对资源的感知和命名,通过阻止他们看到( وبالتالي,无法与之交互)邻居的资源来创建隔离。然而,这并不能限制他们可以打多少电话或发送多少数据。那是我们第二个支柱的工作。
第二个,也是核心的支柱,回答了这个问题:我能使用什么?
这就是 cgroups 的领域。如果说命名空间建造了虚拟的墙壁,那么 cgroups 则安装了水电表和断路器。它们是内核用于计量和限制一组进程总资源消耗的机制。想要确保一组进程使用的 CPU 不超过 20%,消耗的内存不超过 1 GiB,或不独占磁盘 I/O 吗?Cgroups 就是为此而生的工具。
它们作用于真实的、底层的内核资源,完全独立于由命名空间创建的虚拟化视图。一个进程在其自己的命名空间中可能是 PID 1,但它的 CPU 和内存使用情况仍然受到 cgroup 控制器的严密跟踪,该控制器将其视为全局内核中的另一个任务。
第三个支柱回答了一个更微妙的问题:我能请求什么?
进程通过发起系统调用与内核交互——这些是请求特权操作的指令,比如打开文件或发送网络数据包。安全计算模式 (seccomp) 充当一个过滤器,允许进程定义一个它被允许发起的严格的系统调用列表。任何试图发起被禁止的调用的行为都会被内核拦截,并可能导致进程被终止。
这就像在每个租户的门上贴一张“房屋规则”清单。他们只能向大楼管理员(内核)提出特定的、预先批准的请求。这极大地减少了内核的“攻击面”,限制了被攻陷的应用程序可能造成的潜在损害。
这三大支柱共同作用,由在特权硬件状态(Ring 0)下运行的内核强制执行,提供了一个健壮的隔离框架。命名空间提供了私有机器的幻象,cgroups 强制执行资源使用的物理限制,而 seccomp 则限制了被隔离进程可以采取的行动。
Cgroups 的真正威力在于其具体的控制器,每一个都是为管理特定资源而精心设计的算法。通过考察其中几个,我们可以发现资源管理中一些出人意料的深刻原理。
想象你有一个单核 CPU 和两个 cgroup, 和 。CPU 控制器的 cpu.max 接口允许你使用配额和周期来设置硬上限。假设周期是 。如果你给 和 各自 的配额,你就是在告诉内核,每个组中的进程在每 的窗口内最多只能使用 的 CPU 时间。
现在,假设两个组都对 CPU 需求很高,总是有工作要做。它们都会运行,在总共经过 后,两者的配额都将耗尽。然后内核会对它们进行节流——即使它们还有工作要做,也被禁止运行。在周期的剩余 内会发生什么?CPU 完全处于空闲状态。
这是一个被称为非工作保守(non-work-conserving)的有趣特性。有工作需要完成,也有可用的 CPU 来执行,但僵化的策略阻止了它。这就像告诉两个工人,在一个 8 小时轮班中每人只能工作 3 小时;工厂在最后两个小时将一片寂静。然而,如果我们加入第三个组 ,其配额不受限制,它就成了“拾荒者”。在 和 被节流后, 可以自由地消耗剩余的 的 CPU 时间,使系统再次变为工作保守(work-conserving),并将 CPU 利用率推至 100%。这揭示了一个根本性的权衡:硬限制提供了可预测的隔离,但可能导致资源浪费,这个问题必须由整个系统设计来管理。
内存控制器或许是最复杂的,它在保护和压力之间进行着精妙的平衡。一个常见的误解是,每个容器都有自己私有的内存空间,包括自己对共享文件的副本。现实远比这优雅。内核为文件数据维护着一个单一的、统一的页面缓存。如果两个容器 和 读取同一个大文件,内核只将该文件的每个页面加载到内存中一次。内存 cgroup 控制器只是将该页面的成本计入首先触发读取的 cgroup。当 读取相同的数据时,它会获得一次“免费”的缓存命中,而它自己的内存使用量不会增加。这是内核在整个系统范围内最大化效率的一个绝佳例子。
该控制器提供了一个层次化的限制体系来管理这个共享资源:
memory.low:这是一种“尽力而为”的保护,一条沙子里的线。当系统面临内存压力时,内核会首先尝试从内存使用量高于此线的 cgroup 中回收内存。这是一种“请先从别处回收”的请求。memory.high:这是一个软限制,一个节流阀。当一个 cgroup 的使用量超过这个值时,内核会专门对该 cgroup 施加压力,减慢其分配速度并尝试回收其内存,可能会将其数据交换到磁盘。即使整个系统还有大量空闲内存,这种情况也可能发生。memory.max:这是硬限制。如果一个 cgroup 试图超过这个限制,并且内核无法足够快地从中回收内存,那么可怕的内存不足(OOM)查杀器就会被调用,终止该 cgroup 内的进程以维护系统稳定。这些保护措施的层次化特性才是其真正美妙之处。想象一个父 cgroup 为其子 cgroup 提供了 的 memory.low 保护。它的三个子 cgroup 、 和 总共请求了 。内核并不会恐慌;它像一个公平的家长一样行事。它根据每个子 cgroup 的请求,按比例分配可用的 保护。当一个系统范围的回收请求到来时,内核首先回收任何使用量超过这些新计算出的有效保护的内存。只有当这还不足以满足需求时,它才会开始侵犯保护区,再次根据它们的保护水平按比例选择牺牲者。这种算法上的公平性确保了即使在极端压力下,资源也能被优雅且可预测地管理。
一些控制器,如 cpuset,管理的不是你获得多少资源,而是哪一个。cpuset 控制器允许你将一个 cgroup 的进程“钉”到一组特定的 CPU 核心上。这似乎是进行性能调优的好工具,可以防止你的应用程序在核心之间被来回切换。但这种僵化的分区隐藏着一个微妙而危险的陷阱:队头阻塞(head-of-line blocking)。
考虑一个有两颗 CPU, 和 的系统。我们将两个 CPU 密集型的 cgroup, 和 ,钉在 上。我们将第三个 cgroup,,钉在 上。 中的任务工作了一段时间然后进入睡眠状态。当它睡眠时会发生什么?CPU 完全变为空闲。与此同时,在 CPU 上, 和 的任务陷入了激烈的争夺,每个任务只能得到该 CPU 一半的时间。从全局来看,系统有空闲容量,而 和 相对于其理想份额正处于饥饿状态,但僵化的 cpuset 分区阻止了它们迁移到空闲的 以利用它。它们被卡在一个繁忙资源的队头,无法切换到一个空闲的资源。这完美地阐释了系统设计中的一个深刻真理:僵化的分区会损害全局效率和公平性。
这就引出了一个最终的、深刻的设计问题。作为系统管理员,你可以安全地将这些强大的控制器旋钮中的哪些交给非特权用户来管理他们自己的应用程序?答案揭示了控制器本身性质上的一个根本分歧。
管理数量的控制器——如 cpu.max、memory.max、io.max 和 pids.max——通常可以安全地委托。这是因为它们的效果总是受限于父 cgroup 的限制。租户无法通过写入文件来为自己获取比管理员最初分配给其父 cgroup 更多的资源。这就像给租户一个他们自己的内部保险丝盒;他们可以管理自己房间的电路,但无法绕过整个公寓的主断路器。
相比之下,管理位置或对全局池的访问的控制器——如 cpuset 和 hugetlb(用于大内存页)——本质上不安全,不能委托给非特权、不受信任的租户。正如我们所见,允许租户通过 cpuset 控制 CPU 放置,会让他们能够戏耍调度器并获得不公平的 CPU 时间份额,从而破坏与其他租户的公平性。它允许他们绕过 cpu.weight 公平性机制。这些控制器是不可组合的;它们的效果不能被整齐地包含在内。
这种区别并非偶然;它是一条深刻的架构原则。Cgroups 提供了一个分层的控制系统,允许管理员在灵活性与安全和公平的铁律保证之间取得平衡,揭示了支撑现代多租户计算机系统受控混乱状态下的优雅而统一的逻辑。
理解了控制组的原理和机制后,人们可能会想:这些仅仅是系统管理员的巧妙技巧,还是代表了更深层次的东西?答案是,cgroups 是现代计算的基石之一。它们是那种沉默、不起眼的机制,使得从您喜爱的网络服务的响应性能到全球云的宏观结构都成为可能。让我们踏上一段旅程,看看这个关于资源计量和限制的简单理念如何绽放出丰富的应用图景,横跨性能工程、安全以及分布式系统的宏大挑战。
我们的旅程始于一台服务器,它是随处可见的资源争用挑战的缩影。想象一台服务器运行两种任务:白天,它处理交互式用户请求,速度至关重要;夜晚,它运行一个繁重的批处理作业,比如压缩一个大型数据库。没有任何控制,批处理作业很容易窃取资源,使交互式服务变得迟缓。我们如何执行一项明智的策略呢?
这就是性能工程艺术与 cgroups 科学相遇的地方。我们可以将交互式任务放在一个 cgroup 中,将批处理作业放在另一个中。通过对交互式工作负载进行建模,或许可以利用排队论的原理,我们可以计算出满足特定性能目标(例如将平均响应时间保持在 秒以下)所需的确切 CPU 时间。然后,cgroup CPU 控制器就成为我们实施这一策略的工具,允许我们为交互式组分配精确的处理能力配额,从而保证其性能,而批处理作业则使用剩余的资源。Cgroups 将一个模糊的业务目标——“网站必须快”——转化为一个具体、可强制执行的数字。
这种公平原则超越了 CPU。考虑一下“吵闹邻居”问题,这是多租户环境中的一个经典难题。一个容器可能会开始执行大量的磁盘操作,使存储设备饱和,导致所有其他容器的 I/O 资源饥饿。在这里,cgroups 再次提供了解决方案。I/O 控制器允许我们为不同的组分配权重。通过应用加权公平队列模型,我们可以为关键应用程序分配更高的权重,确保无论吵闹的邻居行为多么激进,它们都能获得公平的磁盘吞吐量份额。我们甚至可以设计一个策略,为我们的重要服务保证特定的吞吐量,将混乱的自由竞争转变为一个可预测且有序的系统。
但如果一个程序不仅仅是吵闹,而是恶意的呢?Cgroups 构成了关键的防线。想一想“fork 炸弹”,一个简单但恶劣的程序,它除了创建自己的副本外什么也不做,以指数方式消耗进程槽和内存,直到整个系统陷入停顿并崩溃。将不受信任的代码放入 cgroup 中,并使用 pids.max 控制器对其可以创建的进程数量设置严格限制,就能立即拆除这颗炸弹。
威胁可能更微妙。攻击者可能会编写一个程序,分配大量内存然后快速访问所有这些内存,迫使操作系统进入“交换抖动”状态,即操作系统把所有时间都花在 RAM 和磁盘之间移动数据上。这能让一台强大的服务器瘫痪。如果允许攻击者使用交换空间,一个简单的 cgroup 内存限制可能不足够。真正稳健的解决方案是使用 cgroups 创建一个密闭的牢笼:我们设置一个硬内存限制 (memory.max),并且至关重要的是,将交换限制设为零 (memory.swap.max=0)。现在,当攻击者的内存使用达到其极限时,它无处可去。内核不会让整个系统不稳定,而是简单地终止其 cgroup 内的违规进程,使系统的其余部分不受伤害。
这个单机堡垒的最后一层是 devices 控制器。一个容器应该只能与其绝对需要的设备交互。它没有理由从 /dev/sda 读取原始磁盘块或通过 /dev/kmem 访问内核内存。devices 控制器充当一个严格的守门人。通过遵循“默认拒绝”策略,并且只将少数几个基本设备列入白名单——比如用于丢弃输出的 /dev/null 或用于加密的 /dev/urandom——我们在硬件层面强制执行最小权限原则,从而极大地缩小了容器的攻击面。
在单台机器上,cgroups 提供了秩序和安全。但当我们放大到数据中心的规模,一个由像 Kubernetes 这样的容器编排器管理的世界时,它们的真正威力才显现出来。编排器的主要工作是玩一场宏伟、持续的俄罗斯方块游戏:它接收成千上万个容器,每个都有自己的 CPU 和内存需求,并试图将它们装配到一组节点集群上。
这整个宏伟的事业都建立在一个单一、根本性的契约之上:调度器的决策必须是可强制执行的。当编排器将一个容器放置在一个节点上,并承诺给它 个 CPU 核心和 GiB 内存时,必须有某种东西来确保这个承诺得到遵守。那个“东西”就是 cgroups。定义调度器装箱问题的约束——例如,一个节点上分配给所有容器的 CPU 总和不能超过该节点的能力——不仅仅是抽象的数学;它们是对该节点上 cgroup 控制器将要强制执行的内容的直接建模。Cgroups 提供了使集群编排的整个抽象成为可能的基准真相。
这种联系使我们能够将高层的业务策略转化为底层的现实。一个组织可能会为其应用程序定义优先级类别:“白金”、“黄金”、“白银”。当它们竞争时,“白金” pod 应始终比“黄金” pod 获得更多的 CPU 时间。编排器的开发者必须创建一个从这些抽象类别到具体操作系统参数的映射。这变成了一个有趣的应用数学练习:找到一个将优先级映射到 cgroup cpu.weight 值的函数。该函数必须是单调的(更高优先级意味着更高权重),但它也必须满足公平性界限,确保高优先级作业不会完全饿死低优先级作业。例如,我们可能要求一个“白银” pod 在与单个“黄金” pod 竞争时,总能获得至少(比如说)30% 的 CPU。这个约束对权重之间的差距设置了数学上的限制。
这种编排不仅用于运行应用程序;它对系统自身的生命周期也至关重要。当一台机器启动时,数十个服务必须以正确的顺序和正确的优先级启动。现代的 init 系统,如 systemd,广泛使用 cgroups 来管理这个复杂的舞蹈。它们将关键服务(如存储和网络)放在一个高优先级的“启动关键”切片中,而将可延迟的后台服务放在另一个切片中。通过调整 cgroup 控制器——为关键切片提供高 CPU 和 I/O 权重,用 memory.low 保护其内存工作集,并用 memory.high 温和地节流非关键服务——操作员可以确保最快、最可靠的启动过程。
cgroups 的影响范围甚至更广,延伸到硬件和分布式计算的前沿。当一个容器需要访问标准 Linux 内核不管理的资源,比如图形处理单元(GPU)时,会发生什么?标准的内存和 CPU cgroups 对 GPU 的 VRAM 及其流式多处理器是盲目的。
这就是 cgroup 模型作为一个更大生态系统一部分的灵活性所在。获得访问权需要一个合作之舞。一个了解 GPU 的专用容器运行时,必须将 NVIDIA 设备文件(例如 /dev/nvidia0)暴露到容器的命名空间中。然后,devices cgroup 控制器必须被配置为允许访问这些特定的字符设备。虽然标准 cgroups 无法限制容器的 VRAM 使用,但像 Multi-Instance GPU(MIG)这样的先进硬件功能可以将一个物理 GPU 分割成隔离的硬件实例。然后,专用运行时可以只将一个实例暴露给一个容器,提供一种与 cgroup 框架互补的强大隔离形式。
也许 cgroups 最令人叹为观止的应用是在一个混乱的分布式世界中执行全局规则。想象一个云提供商想要为一个客户强制执行一个全局 CPU 限制——比如,他们在全球数千台机器上的总使用量不得超过 个核心。没有任何一台机器能单独强制执行这个规则。这是一个分布式系统问题。解决方案是操作系统级机制与共识理论的美妙结合。一个使用像 Raft 这样的协议的复制状态机,充当中央大脑,对如何将全局 核心的预算分配给各个节点做出权威决策。
但如果一个节点因网络分区而暂时与网络断开连接怎么办?中央大脑可能会宣布它“死亡”,撤销其(比如说) 个核心的配额,并将其重新分配给另一个节点。被分区的节点仍然存活,它不会知道这一点,并将继续执行其 核心的限制。如果新节点立即开始使用其更大的配额,全局限制就会被违反。解决方案是以有时限的租约形式授予资源。中央大脑使用共识机制,向一个节点提交一个只在特定时间窗口内有效的租约,例如从时间 到 。要重新分配配额,它必须等待旧租约在所有地方都过期。关键的是,为了安全起见,它必须考虑到机器之间的时钟偏斜。新的租约只能在一个大于 的时间开始,其中 是最大时钟偏斜。这个保护带保证了在时钟慢的分区节点上,旧租约在实时上已经过期,然后新租约才能在时钟快的节点上开始。在每台机器上,本地 cgroup 控制器是其当前有效租约所定义配额的最终、忠实的执行者。同样的逻辑,即使用 cgroups 来执行全局优化的策略,也适用于管理其他共享资源,比如系统的页面缓存,我们必须在容器间的公平性与系统整体效率的目标之间取得平衡。
从一个简单的分区工具,cgroups 已经成为表达和执行资源策略的通用语言。它们是容器化、云编排和大规模分布式系统这些宏伟建筑得以建立的基石。它们提供了控制的基本原语,使我们能够以先前难以想象的规模构建可靠、安全和高效的系统。