跳到主要内容

14.5 Cgroups:当隔离遇上资源争夺

命名空间解决了一个问题:「眼不见为净」。 在上一节里,我们用各种 Namespace 把进程关进了逻辑上的「单独隔间」,它们看不见宿主机的进程,看不见别人的网络栈,甚至以为自己就是 PID 1。

但这种隔离有个致命的漏洞:物理资源是共享的。 如果某个容器里的进程突然发疯,开始疯狂占用 CPU,或者把内存吃光,宿主机还是会卡死,OOM Killer 还是会出来「杀人」——它不管你是不是在容器里,只看内存超没超。

这就是为什么我们需要 Cgroups(Control Groups)

如果说 Namespace 是隔间的墙壁,那 Cgroups 就是墙壁上的电表和限流器。它不仅能让你看到谁在用电,还能在谁用电超标时直接拉闸。

这一节,我们要拆解 Linux 内核里这个最复杂、但也最强大的资源管理框架。别指望它能像 Namespace 那样优雅,因为「限制资源」这件事,本质上就是充满了妥协和琐碎规则的苦活。


5.1 Cgroups:起源与设计哲学

Cgroups 的故事始于 2006 年,由 Google 的工程师 Paul Menage 和 Rohit Seth 发起。最开始它的名字非常直白,就叫 Process Containers(进程容器)。后来因为「容器」这个词被用得太泛滥,为了避免混淆,才改名为 Control Groups

从 2.6.24 版本进入内核主线以来,它已经成了现代 Linux 基础设施的基石。不管是取代了 SysV init 的 systemd,还是我们在前面提到的 Linux Containers (LXC),或者是 Google 内部的 lmctfy,甚至 libvirt,全都是骑在 Cgroups 这头大象背上跳舞的。

5.1.1 一切皆文件

Cgroups 的设计有一个非常鲜明的 Unix 哲学:既然我会操作文件系统,那我为什么还需要新的系统调用?

不同于 Namespace 那样引入了一堆 clone() 的 flag,Cgroups 没有引入任何新的系统调用。它实现了一个全新的虚拟文件系统(VFS)类型,名字就叫 cgroup

这意味着,你对 Cgroups 所有的控制——创建分组、限制资源、统计用量——全都是通过 mkdirecho(写文件)、cat(读文件)这些文件系统操作来完成的。

内核里定义了这个文件系统的类型:

static struct file_system_type cgroup_fs_type = {
.name = "cgroup",
.mount = cgroup_mount,
.kill_sb = cgroup_kill_sb,
};

这就好比你把资源管理的控制台,直接做成了一块可以挂载的硬盘。这种设计极其巧妙,但也带来了一个副作用:它的接口太原始了

5.1.2 协调的困境

早期的 Cgroups 提供了一个叫 libcgroup(libcg)的库,里面有 cgcreatecgdelete 这样的工具,本质上它们就是帮你去封装那些文件读写操作。

但这有个大坑:控制器是唯一的

整个内核对于「CPU」这种资源,只有一个 CPU 控制器。如果 systemd 想管 CPU,libvirt 也想管 CPU,大家直接去操作底层的 cgroup 文件系统,那最后就是「谁覆盖谁」的问题。这就像两个人抢着写同一个配置文件,最后谁也不知道生效的是哪一版。

这也是为什么现在的趋势是:别直接动文件,让 systemd 或者其他高层守护进程来管。你向守护进程声明需求,它负责协调,避免打架。


5.2 深入内核:Cgroups 是怎么跑起来的

Cgroups 的内核实现复杂得令人发指,但我们可以把它拆解成几个关键积木。

5.2.1 核心数据结构

首先,内核引入了一个叫 cgroup_subsys 的结构体。它代表一个子系统(或者叫控制器,Controller)。

你想管 CPU?有个 cpu_cgroup_subsys。 你想管内存?有个 mem_cgroup_subsys。 你想管设备权限?有个 devices_subsys

每个子系统都是独立的,但它们都挂载在同一个 cgroup 框架下。下面是部分控制器的清单,你感受一下它的覆盖范围:

  • mem_cgroup_subsys (mm/memcontrol.c): 内存限制,OOM Killer 控制。
  • cpuset_subsys (kernel/cpuset.c): 绑定 CPU 和 NUMA 节点。
  • devices_subsys (security/device_cgroup.c): 设备节点(/dev)读写权限控制。
  • freezer_subsys (kernel/cgroup_freezer.c): 冻结/解冻进程。
  • net_prio_subsys (net/core/netprio_cgroup.c): 网络流量优先级。
  • blkio_subsys (block/blk-cgroup.c): 块设备 I/O 限制。

除了子系统,还有一个核心结构叫 cgroup。它代表一个具体的控制组——也就是你用 mkdir 建出来的那个目录。

每个进程(task_struct)里,多了一个叫 cgroups 的指针,它指向一个 css_set 对象。这个对象里存了一堆指针,分别指向该进程关联的各个子系统状态。

这就像进程手里拿了一串钥匙,每把钥匙对应一个资源控制器。

5.2.2 挂载与初始化

当 Cgroups 子系统初始化时(cgroup_init),它不仅注册了文件系统,还在 /sys/fs 下面创建了一个默认的入口:

kobject_create_and_add("cgroup", fs_kobj); // 生成 /sys/fs/cgroup

这通常是系统启动时自动发生的。当然,你可以在其他地方挂载 cgroup 文件系统,后面会讲怎么做。

内核里有一个全局数组 subsys(在 3.11 内核后改名为 cgroup_subsys),里面装了所有注册进去的控制器。想看看你系统里开了哪些控制器?

cat /proc/cgroups

这比查文档快多了。

5.2.3 当你 mkdir 时发生了什么

当你在一个 cgroup 挂载点下执行 mkdir my_group 时,内核不只是创建了目录。VFS 层会拦截这个操作,并在该目录下自动生成四个标准的控制文件。无论你是哪个控制器,这四个文件是标配:

  1. notify_on_release: 一个布尔开关。如果设为 1,当这个 cgroup 里最后一个进程退出(被释放)时,内核会去执行 release_agent 脚本。
  2. cgroup.event_control: 配合 eventfd() 系统调用使用。它允许你在用户空间写程序来监控 cgroup 的事件(比如超过阈值),而不是傻傻地轮询。
  3. tasks: 这是最常用的文件。它记录了属于这个组的进程 PID 列表。把一个 PID echo 进去,就把那个进程抓进了这个组。
    • 代码里由 cgroup_attach_task() 处理。
    • 反过来,看一个进程属于哪个组,可以 cat /proc/<pid>/cgroup
  4. cgroup.procs: 和 tasks 类似,但它操作的是线程组 ID(TGID)。它的粒度是进程级(一个进程的所有线程必须在一起),而 tasks 允许把同一个进程的不同线程扔进不同的 cgroup(虽然这很少见)。

除了这四个,还有一个特殊的文件只存在于顶层 cgroup(root),叫 release_agent

它的值是一个可执行文件的路径。当某个子 cgroup 启用了 notify_on_release 并且最后一个进程退出时,内核会调用 call_usermodehelper() 来执行这个脚本。 注意:这个机制很昂贵,每次都要起一个新进程。所以现在的 systemd 虽然还在用,但大家都尽量避免频繁触发。

5.2.4 控制器怎么添加自己的参数

每个控制器(比如 memory)不仅要用上面那四个通用文件,还得有自己的特定参数,比如 memory.limit_in_bytes

这是通过定义一个 cftype 结构体数组来实现的。每个控制器把自己的 base_cftypes 指向这个数组。

以内存控制器为例(mm/memcontrol.c):

static struct cftype mem_cgroup_files[] = {
{
.name = "usage_in_bytes",
.read = mem_cgroup_read,
...
},
...
};

struct cgroup_subsys mem_cgroup_subsys = {
.name = "memory",
...
.base_cftypes = mem_cgroup_files,
};

这样,当你挂载 memory controller 并创建子目录时,内核就会自动在这个目录下生成 memory.usage_in_bytesmemory.limit_in_bytes 等文件。读这些文件就是调用对应的 .read 函数,写就是调用 .write 函数。


5.3 实战演练:上手 Cgroups

光看结构容易晕,我们来动动手。你会发现它真的只是一堆文件操作。

5.3.1 设备控制器:不让进程乱碰硬件

假设你想禁止某个进程访问 /dev/null(这虽然是个奇怪的例子,但很能说明问题)。

首先,你得挂载 devices 控制器(如果 systemd 没帮你挂好的话):

mkdir -p /sys/fs/cgroup/devices
mount -t cgroup -o devices devices /sys/fs/cgroup/devices

然后创建一个新组:

mkdir /sys/fs/cgroup/devices/0

进入这个目录,你会发现多了三个文件:

  • devices.deny: 黑名单。
  • devices.allow: 白名单。
  • devices.list: 当前规则。

查看默认规则,通常是全开:

cat /sys/fs/cgroup/devices/0/devices.list
# 输出:a *:* rwm
# a = all (所有设备), *:* = 主次设备号全匹配, rwm = 读/写/创建

把当前 shell 进程加到这个组:

echo $$ > /sys/fs/cgroup/devices/0/tasks

现在,我们要禁止一切

echo a > /sys/fs/cgroup/devices/0/devices.deny

试着写一下 /dev/null

echo "test" > /dev/null
# -bash: /dev/null: Operation not permitted

炸了。现在连 /dev/null 都摸不得了。 再把它加回来:

echo a > /sys/fs/cgroup/devices/0/devices.allow
echo "test" > /dev/null
# 成功

这就是容器安全的基础:你可以让容器里的进程以为自己有 root 权限,但在 Cgroup 层面把设备访问权限全部掐断,它就算想改 /dev/sda 也做不到。

5.3.2 内存控制器:驯服 OOM Killer

内存控制器有两个经典用法:限制内存用量,或者关闭 OOM Killer。

创建组并把当前 shell 移进去:

mkdir /sys/fs/cgroup/memory/0
echo $$ > /sys/fs/cgroup/memory/0/tasks

场景一:禁止 OOM Killer 有时候你不希望内核杀进程,你宁愿让它卡死或者等待。可以关掉 OOM:

echo 1 > /sys/fs/cgroup/memory/0/memory.oom_control

场景二:硬限制内存 比如,给这个组只分配 20MB 内存:

echo 20M > /sys/fs/cgroup/memory/0/memory.limit_in_bytes

一旦里面的进程 malloc 超过这个数(算上页表开销),内核就会直接拒绝,或者触发 OOM(取决于配置)。这对于防止某个失控的 bug 程序吃光宿主机内存非常有效。


5.4 网络特供:Net_prio 与 Cls_cgroup

既然我们在讲网络章节,必须看看 Cgroups 里跟网络相关的两个控制器。它们证明了 Cgroups 的扩展性有多强。

5.4.1 net_prio:给流量贴优先级标签

背景:通常我们用 SO_PRIORITY 这个 socket 选项来设置数据包的优先级(比如让 VoIP 流量走快速通道)。但这要求改应用程序代码。

net_prio 的解法:在内核网络设备层,给每个网络设备(net_device)挂了一张表,叫 priomap

结构体定义如下:

struct netprio_map {
struct rcu_head rcu;
u32 priomap_len;
u32 priomap[]; // 这是一个数组,索引是 cgroup id
};

当进程发包时,dev_queue_xmit() 会根据进程所在的 cgroup ID,查这张表,把查到的优先级填到 skb->priority 里。

实战

假设你想让跑在 group 0 里的进程,走 eth1 出去时,优先级为 4。

mkdir /sys/fs/cgroup/net_prio
mount -t cgroup -onet_prio none /sys/fs/cgroup/net_prio
mkdir /sys/fs/cgroup/net_prio/0
# 格式:<interface_name> <priority>
echo "eth1 4" > /sys/fs/cgroup/net_prio/0/net_prio.ifpriomap

只要把进程 PID echotasks,它发出的包经过 eth1 时就会自动被打上优先级 4 的标签。不用改一行应用代码。

5.4.2 cls_cgroup:给流量打标签用于流量控制

net_prio 是改 QoS 层面的优先级,而 cls_cgroup 是配合 tc(Traffic Control)工具用的。

它允许你给一个 cgroup 打上一个 classid(比如 1:10),然后在 tc 的规则里匹配这个 ID,进行带宽限制或整形。

实战

  1. 创建并挂载控制器:

    mkdir /sys/fs/cgroup/net_cls
    mount -t cgroup -onet_cls none /sys/fs/cgroup/net_cls
    mkdir /sys/fs/cgroup/net_cls/0
  2. 设置 classid(0x100001 代表 10:1):

    echo 0x100001 > /sys/fs/cgroup/net_cls/0/net_cls.classid
  3. tc 命令配置 HTB(Hierarchical Token Bucket)规则:

    # 创建根队列
    tc qdisc add dev eth0 root handle 10: htb
    # 创建一个速率为 40mbit 的 class
    tc class add dev eth0 parent 10: classid 10:1 htb rate 40mbit
    # 添加过滤器:凡是带有 cgroup 标签 1:1 的包,都送到 class 10:1 去
    tc filter add dev eth0 parent 10: protocol ip prio 10 handle 1: cgroup

这样,你就实现了基于应用分组(而不是基于 IP 端口)的流量整形。


5.5 结语:关于挂载的那些琐碎事

最后提一下挂载选项。默认都在 /sys/fs/cgroup 下,但你也可以挂到别处。

命令行参数:

  • -o all: 挂载所有控制器。
  • -o none: 挂载个空框架。
  • -o release_agent=/path/to/script: 指定清理脚本。
  • -o noprefix: 去掉文件名前缀。默认情况下,cpuset 的文件叫 cpuset.mems,加了 noprefix 后就叫 mems。这主要是一些老工具为了省事用的。

重要提示: Cgroups 和 Namespaces 在技术上是正交(Orthogonal)的。 你可以有 Namespace 但没有 Cgroups(只有隔离,无限制)。 你也可以有 Cgroups 但没有 Namespace(只有资源限制,无隔离)。 历史上曾经尝试过做一个 ns cgroup 来管理 Namespace,但后来代码被删了——因为没必要强行把它们捏在一起。

好了,现在我们手里有了隔离的 Namespace,也有了限制的 Cgroups。容器的基础设施其实已经齐活了。 但内核里还有两个跟网络相关的零头值得一看:一个是 Busy Poll Sockets(为了极低延迟的优化),另一个是 PCI/Wake-on-LAN。这两个虽然不直接属于 Cgroups,但都属于现代内核网络的高级话题。

下一章,我们就去踩踩这两个坑。