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 所有的控制——创建分组、限制资源、统计用量——全都是通过 mkdir、echo(写文件)、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)的库,里面有 cgcreate、cgdelete 这样的工具,本质上它们就是帮你去封装那些文件读写操作。
但这有个大坑:控制器是唯一的。
整个内核对于「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 层会拦截这个操作,并在该目录下自动生成四个标准的控制文件。无论你是哪个控制器,这四个文件是标配:
notify_on_release: 一个布尔开关。如果设为 1,当这个 cgroup 里最后一个进程退出(被释放)时,内核会去执行release_agent脚本。cgroup.event_control: 配合eventfd()系统调用使用。它允许你在用户空间写程序来监控 cgroup 的事件(比如超过阈值),而不是傻傻地轮询。tasks: 这是最常用的文件。它记录了属于这个组的进程 PID 列表。把一个 PIDecho进去,就把那个进程抓进了这个组。- 代码里由
cgroup_attach_task()处理。 - 反过来,看一个进程属于哪个组,可以
cat /proc/<pid>/cgroup。
- 代码里由
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_bytes、memory.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 echo 进 tasks,它发出的包经过 eth1 时就会自动被打上优先级 4 的标签。不用改一行应用代码。
5.4.2 cls_cgroup:给流量打标签用于流量控制
net_prio 是改 QoS 层面的优先级,而 cls_cgroup 是配合 tc(Traffic Control)工具用的。
它允许你给一个 cgroup 打上一个 classid(比如 1:10),然后在 tc 的规则里匹配这个 ID,进行带宽限制或整形。
实战:
-
创建并挂载控制器:
mkdir /sys/fs/cgroup/net_clsmount -t cgroup -onet_cls none /sys/fs/cgroup/net_clsmkdir /sys/fs/cgroup/net_cls/0 -
设置 classid(
0x100001代表10:1):echo 0x100001 > /sys/fs/cgroup/net_cls/0/net_cls.classid -
用
tc命令配置 HTB(Hierarchical Token Bucket)规则:# 创建根队列tc qdisc add dev eth0 root handle 10: htb# 创建一个速率为 40mbit 的 classtc 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,但都属于现代内核网络的高级话题。
下一章,我们就去踩踩这两个坑。