Docker 核心技术:Linux Cgroups

大家好,我是费益洲。Linux Cgroups 作为 Docker 的技术核心之一,主要作用就是限制、控制和统计进程的系统资源 (如 CPU、内存、磁盘 I/O 等)。容器的本质其实就是 Linux 的一个进程,限制、控制和统计容器的系统资源,其实就是限制、控制和统计进程的系统资源,本文将从 Linux 内核源码的层面,谈谈如何通过 Cgroups 实现限制系统资源。

本文中的的内核源码版本为linux-5.10.1,具体的源码可以自行下载查看,本文只列举关键代码。

🔗 内核源码官方地址:www.kernel.org,linux-5.10.1 源码下载地址:linux-5.10.1.tar.xz

概念

Cgroups 的全称是 Control Groups,是 Linux 内核提供的一种机制,用于限制、控制和统计一组进程所使用的物理资源。它最早由 Google 工程师在 2006 年发起,最初称为"进程容器"(Process Containers),后来在 2007 年更名为 Cgroups,并在 2008 年合并到 Linux 2.6.24 内核中,2016 年 Linux 4.5 内核引入第二代(cgroup v2)。

特性 cgroup v1 cgroup v2
设计 多层级树,子系统独立管理 单一层级树,统一资源管理
​​ 内存 memory 子系统独立 整合内存、swap、内核内存
CPU cpu 与 cpuacct 分离 统一通过 cpu 控制权重和上限
启动 旧版内核 Linux 4.5+ 内核

与 Cgroups 相关的关键概念如下:

  1. 层级结构(Hierarchy)

    • 树形组织,子级 cgroup 进程继承父级 cgroup 的限制(如/sys/fs/cgroup/memory/father/childchild 初始继承 father 的限制)
  2. 子系统(Subsystem)

    • 每个子系统管理一类资源,具体可以通过ls -al /sys/fs/cgroup/mygroup查看,常用的子系统包括:

      子系统 功能
      blkio 限制块设备 I/O 带宽(如磁盘读写)
      cpu 控制 cpu 时间分配
      cpuacct 统计 CPU 使用情况
      devices 控制设备访问权限(如禁止容器访问磁盘)
      freezer 挂起或恢复进程
      memory 限制内存使用量,统计内存消耗
      net_cls 标记网络数据包,配合 tc 实现网络限速
      pids 限制进程数
  3. 任务(Task)

    • 进程或线程,可加入多个 cgroup(每个子系统层级仅属一个 cgroup)
  4. 文件系统接口

    • 通过虚拟文件系统(挂载于 /sys/fs/cgroup)配置参数:
    bash 复制代码
    # 限制内存为 1GB
    echo 1G > /sys/fs/cgroup/memory/mygroup/memory.limit_in_bytes
    # 将进程加入 cgroup
    echo 1234 > /sys/fs/cgroup/memory/mygroup/cgroup.procs
    • 子级文件系统接口可以通过mkdir命令在父级文件系统接口目录下创建,并会自动创建并继承父级文件系统接口的配置
    bash 复制代码
    [root@master01 ~]# cd /sys/fs/cgroup/memory
    [root@master01 memory]# mkdir mygroup
    [root@master01 memory]# ls mygroup/
    cgroup.clone_children           memory.kmem.tcp.failcnt             memory.numa_stat
    cgroup.event_control            memory.kmem.tcp.limit_in_bytes      memory.oom_control
    cgroup.kill                     memory.kmem.tcp.max_usage_in_bytes  memory.pressure_level
    cgroup.procs                    memory.kmem.tcp.usage_in_bytes      memory.qos_level
    memory.events                   memory.kmem.usage_in_bytes          memory.reclaim
    memory.events.local             memory.ksm                          memory.soft_limit_in_bytes
    memory.failcnt                  memory.limit_in_bytes               memory.stat
    memory.flag_stat                memory.low                          memory.swapfile
    memory.force_empty              memory.max_usage_in_bytes           memory.swap.max
    memory.force_swapin             memory.memfs_files_info             memory.swappiness
    memory.high                     memory.memsw.failcnt                memory.usage_in_bytes
    memory.high_async_ratio         memory.memsw.limit_in_bytes         memory.use_hierarchy
    memory.kmem.failcnt             memory.memsw.max_usage_in_bytes     memory.wb_blkio_ino
    memory.kmem.limit_in_bytes      memory.memsw.usage_in_bytes         notify_on_release
    memory.kmem.max_usage_in_bytes  memory.min                          tasks
    memory.kmem.slabinfo            memory.move_charge_at_immigrate

⚠️ 不要直接修改根目录(/sys/fs/cgroup)下的子系统配置

Cgroups 的生命周期和回收策略

Cgroups 的创建过程

进程结构体task_struct的定义在文件linux-5.10.1/include/linux/sched.h中,与 Cgroups 相关的关键数据结构如下所示:

c 复制代码
struct task_struct {
// ...(省略部分代码)

	/* Control Group info protected by css_set_lock: */
	struct css_set __rcu		*cgroups;
	/* cg_list protected by css_set_lock and tsk->alloc_lock: */
	struct list_head		cg_list;

// ...(省略部分代码)
}
  • css_set:
    • 包含进程组共享的子系统状态数组(subsys[CGROUP_SUBSYS_COUNT])
    • 通过 tasks 链表关联所有绑定至此的进程
  • list_head: 链入 css_set 的 tasks 链表

接下来从进程创建的过程,来说明进程的创建过程中,创建 Cgroups 的过程。创建进程的系统调用函数有三个:fork()、vfork()、clone()。当调用 fork()、vfork()、clone()时,最终都会调用同一个函数 kernel_clone(),和 Cgroups 创建关联的关键函数调用是 copy_process()

c 复制代码
pid_t kernel_clone(struct kernel_clone_args *args)
{
	// ...(省略部分代码)

	// line 2456
	p = copy_process(NULL, trace, NUMA_NO_NODE, args);

	// ...(省略部分代码)
}

copy_process()函数和 Cgroups 创建关联的关键函数调用是有三个,cgroup_fork()、cgroup_can_fork()、cgroup_post_fork():

c 复制代码
static __latent_entropy struct task_struct *copy_process(
					struct pid *pid,
					int trace,
					int node,
					struct kernel_clone_args *args)
{
    // ...(省略部分代码)

    // line 2028
    cgroup_fork(p);

    // ...(省略部分代码)

    // line 2191
    retval = cgroup_can_fork(p, args);
	if (retval)
		goto bad_fork_put_pidfd;

    // ...(省略部分代码)

    // line 2304
    cgroup_post_fork(p, args);

    // ...(省略部分代码)
}

这三个函数都定义在linux-5.10.1/kernel/cgroup/cgroup.c,具体的函数定义和主要逻辑如下所示:

  1. cgroup_fork()
c 复制代码
void cgroup_fork(struct task_struct *child)
{
	RCU_INIT_POINTER(child->cgroups, &init_css_set);
	INIT_LIST_HEAD(&child->cg_list);
}

主要功能

  • 初始化子进程的 cgroups 指针为 init_css_set(临时默认值)
  • 初始化 child->cg_list 为空链表,表示尚未绑定具体 cgroup
  1. cgroup_can_fork()
c 复制代码
int cgroup_can_fork(struct task_struct *child, struct kernel_clone_args *kargs)
{
	struct cgroup_subsys *ss;
	int i, j, ret;

	ret = cgroup_css_set_fork(kargs);
	if (ret)
		return ret;

	do_each_subsys_mask(ss, i, have_canfork_callback) {
		ret = ss->can_fork(child, kargs->cset);
		if (ret)
			goto out_revert;
	} while_each_subsys_mask();

	return 0;

out_revert:
	for_each_subsys(ss, j) {
		if (j >= i)
			break;
		if (ss->cancel_fork)
			ss->cancel_fork(child, kargs->cset);
	}

	cgroup_css_set_put_fork(kargs);

	return ret;
}

主要功能

  • 遍历所有子系统,调用 ss->can_fork()回调函数(如 cpuset_can_fork()检查 CPU 和内存节点可用性)
  • 由 ss 判断是否可以创建新进程,如果答案是否,整个 fork 会失败。
  1. cgroup_post_fork()
c 复制代码
void cgroup_post_fork(struct task_struct *child,
		      struct kernel_clone_args *kargs)
	__releases(&cgroup_threadgroup_rwsem) __releases(&cgroup_mutex)
{
    // ...(省略部分代码)

    /* init tasks are special, only link regular threads */
	if (likely(child->pid)) {
		WARN_ON_ONCE(!list_empty(&child->cg_list));
		cset->nr_tasks++;
		css_set_move_task(child, NULL, cset, false);
	} else {
		put_css_set(cset);
		cset = NULL;
	}

    // ...(省略部分代码)
}

主要功能

  1. 若子进程有效(pid != NULL)
    • 获取父进程的 css_set(task_css_set(current))
    • 调用 css_set_move_task(child, NULL, cset, false)将子进程加入父进程的 css_set:
      • 增加 cset->nr_tasks 计数
      • 将 child->cg_list 链入 cset->tasks 链表,完成 cgroup 绑定
  2. 调用各子系统的 ss->fork()回调(如 cpuset_fork()复制父进程的 CPU 亲和性和内存策略)

通过 fork() → cgroup_fork()(设默认值) → cgroup_can_fork() → cgroup_post_fork()(继承父进程 css_set) → 进程正式加入父进程的 cgroup 组。

💡 内核在启动时会通过 cgroup_init_early() 和 cgroup_init() 构建全局 cgroup 框架,后续的进程都默认加入全局的 cgroup 组。

Cgroups 限制资源的实现

CPU 资源限制

限制进程的 CPU 使用率的根本原理是限制进程在 CPU 中占用的时间配额的占比。CPU 限制主要通过 ​​CFS(Completely Fair Scheduler)调度器实现,相关参数为:

  • cpu.cfs_period_us:定义资源分配的周期长度(单位:微秒),​​ 默认 100000μs

  • cpu.cfs_quota_us:定义在周期内允许进程组使用的最大 CPU 时间(单位:微秒),默认 -1,当为 -1 时,表示不限制 CPU 的使用率

两者的比值决定 CPU 使用率上限:

使用率上限 = cpu.cfs_quota_us / cpu.cfs_period_us

例如:

  • quota=50000 period=100000 → 上限 50%(单核)
  • quota=200000 period=100000 → 上限 200%(双核)

Memory 资源限制

memory 子系统通过内核级的内存资源跟踪与强制干预机制实现对进程组内存使用的精确限制,主要参数为:

  • memory.limit_in_bytes:硬性内存限制

    单位:字节,支持 K/M/G 后缀,如果设置为 -1,则表示解除 memory 限制

    功能:

    • 设置 cgroup 中所有进程可使用的物理内存上限
    • 当进程尝试分配超过此限制的内存时,内核会拒绝分配并可能触发 OOM Killer 终止进程
  • memory.soft_limit_in_bytes:软性内存限制

    单位:字节,支持 K/M/G 后缀,如果设置为 -1,则表示解除 memory 限制

    功能:

    • 设置内存使用的警戒线
    • 不强制阻止超限,但在系统全局内存紧张时,内核优先回收超限 cgroup 的内存(如 PageCache),使其用量向软限制值靠拢

    ⚠️ 软限制值必须小于硬限制值 memory.limit_in_bytes ,否则无效

此处只列举 memory.limit_in_bytes、memory.soft_limit_in_bytes 两个参数,其余参数同志们自行探索。

Cgroups 回收

cgroup 的回收也是由引用计数(refcount)​​ 来判断和执行的,具体的回收流程此处不再研究,感兴趣的通知可以自行查阅源码。回收的标准和原则就是:引用计数归零(即无任何进程关联、无子 cgroup、无文件描述符引用)。

💡 示例 ​​:删除一个 cgroup 需先移除所有进程(echo $$ > /sys/fs/cgroup/cgroup.procs),再删除子 cgroup,最后 rmdir 其目录。若未清空进程直接删除,内核因引用计数 >0 而拒绝操作

Go 通过 Cgroups 限制进程的资源

在之前测试了 Namespace 的 Go 代码的基础上,做出修改,使用工具 stress 对进程进行压力测试,来验证 Cgroups 的有效性,Go 代码如下:

go 复制代码
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path"
	"strconv"
	"strings"
	"syscall"
)

const cgroupMemoryPath = "/sys/fs/cgroup/memory"

func main() {

	if strings.EqualFold(os.Args[0], "/proc/self/exe") {
		fmt.Printf("current pid: %d\n", syscall.Getpid())
		cmd := exec.Command("sh", "-c", `stress --vm-bytes 2048m --vm-keep -m 1 --vm-hang 1`)
		cmd.SysProcAttr = &syscall.SysProcAttr{}
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		if err := cmd.Run(); err != nil {
			fmt.Printf("Internal Error: %s\n", err.Error())
			os.Exit(1)
		}
	}

	cmd := exec.Command("/proc/self/exe")
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID |
			syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Start(); err != nil {
		fmt.Printf("Error: %s\n", err.Error())
		os.Exit(1)
	} else {
		fmt.Printf("process id: %d\n", cmd.Process.Pid)
		// create child memory subsystem
		os.Mkdir(path.Join(cgroupMemoryPath, "mygroup"), 0755)
		// add process pid to child memory subsystem
		os.WriteFile(path.Join(cgroupMemoryPath, "mygroup", "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
		// limit memory usage
		os.WriteFile(path.Join(cgroupMemoryPath, "mygroup", "memory.limit_in_bytes"), []byte("1024m"), 0644)
	}
	cmd.Process.Wait()
}

💡 宿主机需要提前安装好 stress

在宿主机运行代码后,输出如下:

bash 复制代码
[root@master01 test]# go run main.go
process id: 623769
current pid: 1
stress: info: [7] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
stress: FAIL: [7] (415) <-- worker 8 got signal 9
stress: WARN: [7] (417) now reaping child worker processes
stress: FAIL: [7] (421) kill error: No such process
stress: FAIL: [7] (451) failed run completed in 0s
Internal Error: exit status 1

现在对 Go 代码和报错进行分析:

  • 代码的主要功能是创建一个隔离了系统资源的新进程(通过/proc/self/exe重新执行自身),并在新的 Namespace 中运行工具 stress。同时还创建了一个名为mygroup的 cgroup ,将新进程加入其中,还限制了mygroup的内存使用上限为1024MB。而 stress 工具的参数是--vm-bytes 2048m,即尝试为该进程分配2048MB的内存。
  • 报错信息中,stress 工具在运行过程中被终止,并显示signal 9。继续分析后面的报错信息,表明了 stress 工具是在尝试尝试分配内存后被强制 kill。

根据以上信息,我们可以推导出以下原因:

  1. cgroup 内存限制生效:我们在 cgroup 中设置了内存限制为 1024MB(memory.limit_in_bytes=1024m)。而 stress 命令试图分配 2048MB 内存(--vm-bytes 2048m),这显然超过了 1024MB 的限制
  2. OOM Killer 触发:当进程使用的内存超过 cgroup 设置的限制时,内核的 OOM Killer 会被触发,并发送 SIGKILL 信号(信号 9)终止该进程。这正是错误信息中提到的 signal 9 的来源

接下来,通过以下两种方案来进行反向验证:

  • 将 cgroup 的内存限制调整为 2048MB 以上,使其能够容纳 stress 命令的内存需求
  • 将 stress 命令的内存分配参数(--vm-bytes)调整到 1024MB 以内,使其在限制范围内运行

我们使用第二种方案进行验证,将 stress 的内存分配修改为 512MB,执行代码,输出如下:

bash 复制代码
[root@master01 test]# go run main.go
process id: 676092
current pid: 1
stress: info: [7] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd

此时未报错,在新的宿主机命令终端中查看进程 676092 的实际内存占用,top -p 676092

bash 复制代码
top - 11:45:48 up 19:58,  2 users,  load average: 0.22, 0.53, 0.57
Tasks:   1 total,   0 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s):  2.3 us,  0.8 sy,  0.0 ni, 96.6 id,  0.0 wa,  0.3 hi,  0.1 si,  0.0 st
MiB Mem :  23525.9 total,   8590.1 free,   6495.6 used,   9348.4 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.  17030.4 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 676092 root      20   0  527756 524460    276 S   0.3   2.2   0:00.34 stress

从上面的输出可以看出,此时进程的内存占用是 512 / 23525.9 ≈ 0.0218,四舍五入转换成百分比即为 2.2%。而且此时程序并未报错,则再次验证了 Cgroup 的有效性。

🔖 cgroup.procs 和 tasks

通过查看目录/sys/fs/cgroup/memory/mygroup可以看到:

bash 复制代码
[root@master01 ~]# ls /sys/fs/cgroup/memory/mygroup
cgroup.clone_children           memory.kmem.tcp.failcnt             memory.numa_stat
cgroup.event_control            memory.kmem.tcp.limit_in_bytes      memory.oom_control
cgroup.kill                     memory.kmem.tcp.max_usage_in_bytes  memory.pressure_level
cgroup.procs                    memory.kmem.tcp.usage_in_bytes      memory.qos_level
memory.events                   memory.kmem.usage_in_bytes          memory.reclaim
memory.events.local             memory.ksm                          memory.soft_limit_in_bytes
memory.failcnt                  memory.limit_in_bytes               memory.stat
memory.flag_stat                memory.low                          memory.swapfile
memory.force_empty              memory.max_usage_in_bytes           memory.swap.max
memory.force_swapin             memory.memfs_files_info             memory.swappiness
memory.high                     memory.memsw.failcnt                memory.usage_in_bytes
memory.high_async_ratio         memory.memsw.limit_in_bytes         memory.use_hierarchy
memory.kmem.failcnt             memory.memsw.max_usage_in_bytes     memory.wb_blkio_ino
memory.kmem.limit_in_bytes      memory.memsw.usage_in_bytes         notify_on_release
memory.kmem.max_usage_in_bytes  memory.min                          tasks
memory.kmem.slabinfo            memory.move_charge_at_immigrate

里面包含了两个关键文件 cgroup.procstasks。在 Linux cgroups 机制中,cgroup.procs 和 tasks 文件均用于管理控制组(cgroup)中的进程,但两者在操作对象、功能和设计定位上存在显著区别。以下是详细对比:

特性 cgroup.procs tasks
操作对象 线程组 ID(TGID) 线程 ID(TID)
功能范围 管理整个进程组(包含所有线程) 管理单个线程
写入效果 写入 TGID 会将进程的所有线程加入 cgroup 写入 TID 仅加入单个线程,不涉及同组其他线程
相关推荐
喵手几秒前
反射机制:你真的了解它的“能力”吗?
java·后端·java ee
用户466537015052 分钟前
git代码压缩合并
后端·github
武大打工仔5 分钟前
从零开始手搓一个MVC框架
后端
开心猴爷11 分钟前
移动端网页调试实战 Cookie 丢失问题的排查与优化
后端
用户57240561411 分钟前
解析Json
后端
舒一笑12 分钟前
Mac 上安装并使用 frpc(FRP 内网穿透客户端)指南
后端·网络协议·程序员
每天学习一丢丢18 分钟前
Spring Boot + Vue 项目用宝塔面板部署指南
vue.js·spring boot·后端
邹小邹18 分钟前
Go 1.25 强势来袭:GC 速度飙升、并发测试神器上线,内存检测更精准!
后端·go
lichenyang45322 分钟前
管理项目服务器连接数据库
数据库·后端
生无谓24 分钟前
在Windows系统上安装多个JDK版本并切换
后端