avatar🌌
TangentTangent Blog

技术博客 | Go & Kubernetes & Linux

技术博客 | Go & Kubernetes & Linux

runc 源码解读(三):从 SPEC.md 看容器资源控制

前言 ​

在上一篇文章中,我们解读了 SPEC.md 中的文件系统章节,了解了容器如何从零搭建起完整的文件系统环境。本篇继续深入 SPEC.md 的 Resources 章节,看看容器是如何通过 Cgroups 实现资源控制的。

如果说 Namespace 负责"你能看到什么",那么 Cgroups 就负责"你能用多少"。一个没有资源限制的容器和一个普通进程没有本质区别——它可以吃光宿主机的所有 CPU 和内存。Cgroups 正是解决这个问题的关键。

Cgroups:容器资源控制的核心 ​

SPEC.md 中关于资源控制的描述开门见山:

Cgroups are used to handle resource allocation for containers. This includes system resources like cpu, memory, and device access.

Cgroups(Control Groups)用于处理容器的资源分配,包括 CPU、内存和设备访问等系统资源。

SPEC.md 定义的 Cgroup 子系统 ​

SubsystemEnabled说明
devices1控制容器对宿主机设备的访问权限
memory1限制内存使用量,超限触发 OOM Kill
cpu1限制 CPU 使用率(带宽控制)
cpuacct1统计 CPU 使用时间
cpuset1绑定特定 CPU 核心和 NUMA 节点
blkio1限制块设备(磁盘)I/O 带宽
perf_event1允许对 cgroup 内进程进行性能监控
freezer1批量暂停/恢复容器内所有进程
hugetlb1限制大页内存的使用量
pids1限制容器内最大进程数(防止 Fork 炸弹)

操作系统背景:上表是 Cgroup v1 的子系统划分方式——每个子系统独立挂载在 /sys/fs/cgroup/ 下的不同目录中。在现代 Linux 系统(Ubuntu 22.04+、Fedora 31+ 等)中,已经全面切换到 Cgroup v2,采用统一层级(Unified Hierarchy)架构,所有控制器合并到一棵目录树中。后文的实战演示都基于 Cgroup v2。

Cgroup v1 vs v2:架构差异 ​

在 Cgroup v1 中,每个子系统是独立的层级:

/sys/fs/cgroup/
├── cpu/
│   └── docker/<容器ID>/
├── memory/
│   └── docker/<容器ID>/
├── blkio/
│   └── docker/<容器ID>/
└── freezer/
    └── docker/<容器ID>/

在 Cgroup v2 中,所有控制器合并为统一层级:

/sys/fs/cgroup/
└── system.slice/
    └── docker-<容器ID>.scope/
        ├── cgroup.controllers    # 可用的控制器列表
        ├── cgroup.subtree_control # 子树启用的控制器
        ├── cpu.max               # CPU 带宽限制
        ├── memory.max            # 内存上限
        ├── memory.current        # 当前内存使用量
        ├── io.max                # I/O 带宽限制
        ├── pids.max              # 最大进程数
        └── cgroup.freeze         # 冻结控制(替代 freezer 子系统)

Cgroup v2 的核心优势是一致性:一个进程只属于一个 cgroup 节点,所有资源限制在同一个位置管理,避免了 v1 中进程在不同子系统层级中可能不一致的问题。

统计数据收集 ​

All cgroup subsystem are joined so that statistics can be collected from each of the subsystems.

SPEC.md 要求容器加入所有 cgroup 子系统,目的之一是收集各维度的统计数据。当你执行 docker stats 时,看到的 CPU 使用率、内存占用、网络 I/O 等指标,全部来自 cgroup 文件系统中的统计文件。

为什么不用 /proc 做容器监控? ​

一个常见的疑问是:/proc 下不是也有系统监控数据吗?为什么容器非要用 cgroup?

答案是:/proc 提供的是全局视角(宿主机),cgroup 提供的是局部视角(容器)。

在容器内执行 free -m 时,它读取的是 /proc/meminfo,显示的是宿主机(或 Docker 虚拟机)的物理总内存,而不是容器被限制的内存量。例如,你用 docker run -m 100m 限制了容器只能用 100MB 内存,但 free -m 可能显示 16GB——这是因为 /proc/meminfo 并未被 cgroup 感知。

bash
docker run -d --name mem_test -m 100m alpine tail -f /dev/null

docker exec mem_test free -m

docker stats --no-stream mem_test

docker rm -f mem_test

现代的 Java(10+)和 Node.js 运行时已经做了适配——检测到自己运行在容器中时,会主动读取 cgroup 文件而非 /proc/meminfo 来确定可用内存,避免因过度分配堆内存而被 OOM Kill。

Freezer:容器的暂停键 ​

Freezer does not expose any stats but is joined so that containers can be paused and resumed.

Freezer 是一个特殊的子系统。它不提供统计数据,唯一的作用是批量暂停和恢复容器内的所有进程。

为什么不用 kill -STOP? ​

Linux 原生的 kill -STOP(SIGSTOP 信号)也能暂停进程,但 Freezer 在两个方面更优:

  • 不可拦截:应用程序可以捕获信号,但无法感知或阻止 Freezer 的内核级冻结。进程被唤醒后,以为时间只过了一瞬
  • 原子操作:kill 逐个发送信号,存在时间差——父进程被暂停时子进程可能还在跑。Freezer 在内核层面将整个 cgroup 作为一个整体冻结,没有漏网之鱼

实战:用 Cgroup v2 冻结和解冻进程 ​

以下实验需要两个终端窗口,且系统使用 Cgroup v2(可通过 mount | grep cgroup2 确认)。

终端 A:运行目标进程

bash
bash -c 'while true; do echo "进程运行中... $(date +%T)"; sleep 2; done' &
PID=$!
echo "目标进程 PID: $PID"

终端 B:冻结与解冻操作

bash
sudo mkdir -p /sys/fs/cgroup/my_freezer

echo $PID | sudo tee /sys/fs/cgroup/my_freezer/cgroup.procs

echo 1 | sudo tee /sys/fs/cgroup/my_freezer/cgroup.freeze

cat /sys/fs/cgroup/my_freezer/cgroup.freeze

echo 0 | sudo tee /sys/fs/cgroup/my_freezer/cgroup.freeze

kill -9 $PID
sudo rmdir /sys/fs/cgroup/my_freezer

在 Cgroup v2 中,Freezer 不再是独立的子系统,而是通过统一层级中的 cgroup.freeze 文件控制——写入 1 冻结,写入 0 解冻。这比 v1 中写入 FROZEN/THAWED 字符串更简洁直观。

docker pause 和 docker unpause 的底层就是这个机制。在 Kubernetes 中,虽然没有直接的 pause 命令,但 CRIU(Checkpoint/Restore In Userspace)利用 Freezer 实现容器的热迁移:先冻结容器、保存内存快照、传输到另一台机器、再恢复运行。

内存控制:OOM Kill 的底层真相 ​

Memory 子系统是容器资源控制中最常被感知的部分——当容器的内存超过限制时,内核的 OOM Killer 会直接终止进程。这就是 Kubernetes 中 OOMKilled 状态的底层来源。

实战:用 Cgroup v2 触发 OOM Kill ​

终端 A:运行一个不断吃内存的进程

bash
python3 -c "
import time
a = []
while True:
    a.append(' ' * 1024 * 1024)
    print(f'已分配 {len(a)} MB 内存...')
    time.sleep(0.5)
" &
PID=$!
echo "贪吃进程 PID: $PID"

终端 B:设置 50MB 内存上限

bash
sudo mkdir -p /sys/fs/cgroup/my_mem_limit

cat /sys/fs/cgroup/cgroup.subtree_control

echo 52428800 | sudo tee /sys/fs/cgroup/my_mem_limit/memory.max

echo 0 | sudo tee /sys/fs/cgroup/my_mem_limit/memory.swap.max

echo $PID | sudo tee /sys/fs/cgroup/my_mem_limit/cgroup.procs

sudo rmdir /sys/fs/cgroup/my_mem_limit

对比 Cgroup v1 和 v2 在内存控制上的接口差异:

功能Cgroup v1Cgroup v2
设置内存上限memory.limit_in_bytesmemory.max
查看当前内存使用memory.usage_in_bytesmemory.current
设置 swap 上限memory.memsw.limit_in_bytesmemory.swap.max
关闭 swap 倾向memory.swappinessmemory.swap.max 设为 0
内存统计详情memory.statmemory.stat

启动同步:防止进程逃逸 Cgroup ​

SPEC.md 中最关键的一段描述了容器启动时的多进程同步机制:

The parent process of the container’s init must place the init pid inside the correct cgroups before the initialization begins. This is done so that no processes or threads escape the cgroups. This sync is done via a pipe (specified in the runtime section below) that the container’s init process will block waiting for the parent to finish setup.

这段话揭示了一个严峻的**竞态条件(Race Condition)**问题:

危险场景:如果 runc(父进程)先让 init 进程跑起来,再把它加入 cgroup——在这个时间差内,init 进程可能已经 fork 出子进程。这些子进程诞生于 cgroup 配置完成之前,因此不受任何资源限制,成功"逃逸"出沙箱。

runc 的解决方案:通过 pipe(管道)实现父子进程的精确同步:

runc (父进程)                         init (子进程)
     |                                     |
     |--- 创建 pipe (FD 3) ------>         |
     |--- fork() 创建 init 进程 --->       |
     |                                     |--- 读取 pipe(阻塞等待)
     |--- 将 init PID 写入 cgroup.procs    |
     |--- 写入 uid/gid 映射                |
     |--- 关闭 pipe 写端 -------->         |
     |                                     |--- pipe 读到 EOF,解除阻塞
     |                                     |--- 执行文件系统挂载、pivot_root
     |                                     |--- exec 用户进程

具体步骤:

  1. 创建管道:runc 创建一个 IPC pipe,通过文件描述符 3 传递给 init 进程
  2. 创建 init 进程:runc fork 出容器的 init 进程
  3. init 阻塞:init 进程启动后立即读取 pipe,由于没有数据,进入阻塞等待
  4. 配置 cgroup:runc 趁 init 阻塞的间隙,将其 PID 写入 cgroup 的 cgroup.procs 文件
  5. 发送放行信号:runc 关闭 pipe 写端,init 读到 EOF 解除阻塞
  6. init 开始执行:init 进程正式执行用户指定的命令

通过这种"管道阻塞"的同步机制,runc 保证了:没有任何一个容器进程可以在 cgroup 规则生效之前执行哪怕一行应用层代码。

CPU 控制:带宽限制与核心绑定 ​

虽然 SPEC.md 没有展开 CPU 控制的细节,但 CPU 是容器资源管理中最重要的维度之一。Cgroup 通过两种机制控制 CPU:

CPU 带宽控制(cpu 控制器) ​

在 Cgroup v2 中,cpu.max 文件控制 CPU 带宽,格式为 $MAX $PERIOD(单位微秒):

bash
echo "50000 100000" | sudo tee /sys/fs/cgroup/my_cgroup/cpu.max

echo "max 100000" | sudo tee /sys/fs/cgroup/my_cgroup/cpu.max

这对应 Docker 的 --cpus 参数:docker run --cpus=0.5 实际上就是设置 cpu.max 为 50000 100000。

CPU 核心绑定(cpuset 控制器) ​

cpuset.cpus 文件将进程绑定到特定的 CPU 核心:

bash
echo "0-1" | sudo tee /sys/fs/cgroup/my_cgroup/cpuset.cpus

echo "0" | sudo tee /sys/fs/cgroup/my_cgroup/cpuset.cpus

在需要低延迟的场景(如高频交易、实时音视频),CPU 绑核可以减少上下文切换和缓存失效带来的性能损耗。

I/O 控制:块设备带宽限制 ​

Cgroup v2 中的 io.max 文件控制块设备的 I/O 带宽,格式为 $MAJOR:$MINOR rbps=$BYTES wbps=$BYTES:

bash
lsblk -o NAME,MAJ:MIN

echo "8:0 rbps=10485760 wbps=10485760" | sudo tee /sys/fs/cgroup/my_cgroup/io.max

对比 v1 的 blkio 子系统,v2 的 io.max 将读写限制合并到了一个文件中,更加简洁。

PID 控制:防止 Fork 炸弹 ​

pids.max 文件限制 cgroup 内的最大进程数:

bash
echo 100 | sudo tee /sys/fs/cgroup/my_cgroup/pids.max

这是防御 Fork 炸弹(:(){ :|:& };:)的关键手段。没有这个限制,一个恶意容器可以通过无限 fork 耗尽宿主机的 PID 资源,导致整台机器无法创建新进程。

Docker 中的 Cgroup 实践 ​

以上的手动 cgroup 操作在实际场景中由容器运行时自动完成。Docker 提供了丰富的参数来设置资源限制:

bash
docker run -d \
  --name resource_demo \
  --cpus=1.5 \              # CPU 带宽:最多使用 1.5 个核心
  --cpuset-cpus="0,1" \     # CPU 绑核:只使用 CPU 0 和 1
  -m 256m \                 # 内存上限:256MB
  --memory-swap 256m \      # 关闭 swap(swap 上限 = 内存上限)
  --pids-limit 200 \        # 最大进程数:200
  --device-read-bps /dev/sda:10mb \  # 磁盘读带宽限制
  alpine tail -f /dev/null

CGROUP_PATH=$(docker inspect --format='{{.HostConfig.CgroupParent}}' resource_demo)
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format='{{.Id}}' resource_demo).scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format='{{.Id}}' resource_demo).scope/memory.max

docker rm -f resource_demo

容器资源控制的完整流程 ​

将以上所有内容串联起来,runc 进行容器资源控制的完整流程如下:

  1. 创建 cgroup 节点:在 /sys/fs/cgroup/ 的统一层级下创建容器专属的 cgroup 目录
  2. 启用控制器:通过 cgroup.subtree_control 启用需要的控制器(cpu、memory、io、pids 等)
  3. 配置资源限制:根据容器配置写入 cpu.max、memory.max、pids.max 等文件
  4. 同步等待:通过 pipe 阻塞 init 进程,确保 cgroup 配置完成后再放行
  5. 写入进程:将 init 进程的 PID 写入 cgroup.procs
  6. 放行 init:关闭 pipe,init 进程开始执行
  7. 持续监控:运行时通过 memory.current、cpu.stat 等文件持续收集统计数据

每一步的顺序都至关重要——资源限制必须在进程开始执行之前生效,否则就会出现"逃逸"窗口。

小结 ​

SPEC.md 的 Resources 章节定义了容器资源控制的两个核心:用什么控制(10 个 cgroup 子系统覆盖了 CPU、内存、I/O、设备、进程数等全部维度)和如何防止逃逸(通过 pipe 同步确保 cgroup 在进程执行前生效)。

容器并没有什么魔法——它就是普通的 Linux 进程,只是被 runc 巧妙地放进了设定了各种规则的 cgroup 目录里。理解了这一点,你再看到 Kubernetes 中的 resources.limits、OOMKilled 状态、或 docker stats 的输出时,就知道它们背后发生了什么。

在下一篇文章中,我们将继续解读 SPEC.md 中的 Security(安全能力集)和 Runtime(运行时与 init 进程)部分。

Reading runc Source Code (Part 3): Container Resource Control in SPEC.md
How to Properly Handle gRPC Error Handling
由 Valaxy v1.2.2 驱动|主题-Yunv1.2.2