Cgroups 资源限制

Cgroups(Control Groups)是 Linux 内核提供的资源限制机制——控制进程组能用多少 CPU、内存、IO 等资源

阶段一 · 03 · 返回阶段大纲

与 Namespace 的关系

上一篇讲的 Namespace 解决的是"看到什么"(隔离视图),本篇讲的 Cgroups 解决的是"用多少"(资源限制)。两者结合就是容器的核心:一个既看不到外界、又不能无限消耗资源的进程。

类比:Namespace 像给每个租户一个独立的房间(看不到别人),Cgroups 则像给每个房间装了电表和水表——超出额度就断供,防止一个租户用光整栋楼的水电。

核心概念

术语表

概念说明类比
cgroup 一组进程的集合,绑定了一组资源限制参数 一个"资源配额账户"
subsystem / 控制器 具体的资源控制器,如 cpu、memory、io 等 电表、水表、燃气表……每种资源一个计量器
hierarchy(层级) cgroup 的树形组织结构,子 cgroup 继承父级的限制 公司的预算层级——部门预算 ≤ 公司总预算
task cgroup 中的进程(由 PID 标识) 账户里的"消费者"

主要控制器一览

控制器控制资源容器场景
cpuCPU 时间片分配限制容器 CPU 使用比例
cpuset绑定特定 CPU 核心将容器绑定到指定 CPU(减少调度开销)
memory内存使用上限防止容器 OOM 影响宿主机
io(v2)/ blkio(v1)块设备 IO 限制限制容器磁盘读写速率
pids进程数量限制防止 fork 炸弹
devices设备访问控制控制容器能访问哪些设备
freezer进程挂起 / 恢复容器暂停功能(docker pause

Cgroups v1 vs v2

查看当前系统版本

# 判断系统使用 v1 还是 v2
stat -fc %T /sys/fs/cgroup/
# "cgroup2fs" → v2
# "tmpfs"     → v1

# v2 下查看可用控制器
cat /sys/fs/cgroup/cgroup.controllers
# 输出示例: cpuset cpu io memory hugetlb pids rdma misc

核心差异

特性Cgroups v1Cgroups v2
层级结构 每个控制器独立一棵树 统一层级——所有控制器在同一棵树上
挂载点 /sys/fs/cgroup/cpu//sys/fs/cgroup/memory/……分散 /sys/fs/cgroup/ 一个入口
进程归属 一个进程可属于不同控制器层级的不同 cgroup 一个进程只能属于一个 cgroup(叶子节点)
线程控制 不支持 支持线程级别的资源控制
压力监控 支持 PSI(Pressure Stall Information)
状态 逐步淘汰 推荐使用,主流发行版默认

v2 为什么要统一层级?

v1 中每个控制器独立一棵树,一个进程可能在 cpu 树的 A 组、在 memory 树的 B 组——管理混乱、容易冲突。v2 强制统一:一个进程只属于一个 cgroup 节点,该节点上可以同时启用多个控制器。简单、清晰、不矛盾。

实操:用 Cgroups v2 限制资源

Cgroups 的操作全部通过读写文件完成——这就是第 01 篇提到的"一切皆文件"思想的典型应用。创建 cgroup = 创建目录,设置限制 = 写文件,加入进程 = 把 PID 写进文件。

CPU 限制

# 创建一个 cgroup(就是创建一个目录)
sudo mkdir /sys/fs/cgroup/my-container

# 启用 cpu 控制器
echo "+cpu" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

# 设置 CPU 限制:每 100ms 周期内最多使用 20ms CPU(即 20%)
echo "20000 100000" | sudo tee /sys/fs/cgroup/my-container/cpu.max
# 格式: $MAX $PERIOD(单位:微秒)
# CPU 使用率 = MAX / PERIOD = 20000 / 100000 = 20%

# 将当前 shell 加入该 cgroup
echo $$ | sudo tee /sys/fs/cgroup/my-container/cgroup.procs

# 运行 CPU 密集型任务,观察被限制在 ~20%
dd if=/dev/zero of=/dev/null &
top  # 查看 dd 的 CPU 使用率

# 清理
kill %1
echo $$ | sudo tee /sys/fs/cgroup/cgroup.procs  # 移回根 cgroup
sudo rmdir /sys/fs/cgroup/my-container

cpu.max 的格式

$MAX $PERIOD:在每个 $PERIOD 微秒的周期内,该 cgroup 最多使用 $MAX 微秒的 CPU 时间。例如 "50000 100000" = 50%,"100000 100000" = 100%(一个核),"200000 100000" = 200%(两个核)。设为 "max 100000" 表示不限制。

内存限制

sudo mkdir /sys/fs/cgroup/mem-test
echo "+memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

# 硬限制:最多使用 100MB,超出触发 OOM Killer
echo "104857600" | sudo tee /sys/fs/cgroup/mem-test/memory.max

# 软限制:超过 50MB 时内核优先回收该 cgroup 的内存
echo "52428800" | sudo tee /sys/fs/cgroup/mem-test/memory.high

# 查看当前内存使用量
cat /sys/fs/cgroup/mem-test/memory.current

# 查看详细内存统计
cat /sys/fs/cgroup/mem-test/memory.stat
文件作用超限后果
memory.max硬上限触发 OOM Killer 杀进程
memory.high软上限内核积极回收内存,进程被节流但不会被杀
memory.low最低保障低于此值时内核尽量不回收该 cgroup 的内存

类比手机套餐:memory.high 像流量软限(超了降速但还能用),memory.max 像硬限(超了直接断网)。

IO 限制

sudo mkdir /sys/fs/cgroup/io-test
echo "+io" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

# 查看磁盘设备号(Major:Minor)
lsblk
# 假设主磁盘是 sda,设备号 8:0

# 限制该 cgroup 对 sda 的读写速率各为 1MB/s
echo "8:0 rbps=1048576 wbps=1048576" | sudo tee /sys/fs/cgroup/io-test/io.max
# rbps = read bytes per second
# wbps = write bytes per second

PID 数量限制

sudo mkdir /sys/fs/cgroup/pid-test
echo "+pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

# 限制最多 20 个进程(防止 fork 炸弹)
echo "20" | sudo tee /sys/fs/cgroup/pid-test/pids.max

# 查看当前进程数
cat /sys/fs/cgroup/pid-test/pids.current

fork 炸弹是什么?

一个恶意或失控的程序不断 fork 子进程,指数级增长直到耗尽系统资源。经典的 Bash fork 炸弹::(){ :|:& };:(千万不要运行)。pids.max 就是容器的最后一道防线。

Docker 中的 Cgroups 应用

Docker 的 --cpus--memory 等参数,底层就是创建 cgroup 并写入对应的限制文件:

# Docker 运行容器时指定资源限制
docker run -d \
  --name my-app \
  --cpus="0.5" \
  --memory="256m" \
  --memory-swap="512m" \
  --pids-limit=100 \
  nginx

# --cpus="0.5"       → cpu.max = "50000 100000"(50% 的一个核)
# --memory="256m"    → memory.max = 268435456
# --pids-limit=100   → pids.max = 100
# 查看 Docker 为容器创建的 cgroup 配置
# 路径通常是:/sys/fs/cgroup/system.slice/docker-<container-id>.scope/

# 通过 docker inspect 查看资源限制
docker inspect my-app --format '{{.HostConfig.NanoCpus}}'
docker inspect my-app --format '{{.HostConfig.Memory}}'

# 直接读取 cgroup 文件验证
CONTAINER_ID=$(docker inspect my-app --format '{{.Id}}')
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/memory.max

核心理解

Docker 的资源限制参数没有任何魔法——它只是把你传入的值换算后写入 /sys/fs/cgroup/ 下的文件。你完全可以手动做同样的事。Docker 只是一层自动化封装。

深入理解

陷阱:容器内 free 看到的内存是宿主机的

如果一个容器的 memory.max 被设为 256MB,容器内执行 free -h 看到的内存是多少?

答案:看到的是宿主机的全部内存(如 16GB),而不是 256MB。

原因:free 读取的是 /proc/meminfo,而 Linux 没有 Memory Namespace——/proc/meminfo 永远反映宿主机物理内存总量,不感知 Cgroups 的限制。

机制解决的问题影响 /proc/meminfo?
Namespace看到什么(视图)没有 Memory NS,不影响
Cgroups用多少(配额)不影响,进程照样看到宿主机全量

真实的坑

早期 Java 应用在容器里读 /proc/meminfo 决定 JVM 堆大小,按宿主机 16GB 的 75% 分配了 12GB 堆——但容器配额只有 256MB,瞬间 OOM 被杀。JDK 10+ 才修复此问题(主动读取 cgroup 文件获取真实限额)。社区方案:LXCFS 可以劫持容器的 /proc/meminfo,让它返回 cgroup 感知的值。

cgroup "文件"的本质:不是文件,是 API

你可能会问:内核是不是实时去"读取"cpu.max 这个文件来做调度?

不是。这些"文件"根本不存在于磁盘上。/sys/fs/cgroup/ 是虚拟文件系统,只是内核暴露给用户态的通信接口

你的操作                             内核内部
──────────                          ─────────
echo "20000 100000" > cpu.max
         │
         ▼
    VFS 层拦截写操作
         │
         ▼
    触发内核回调函数
    cpu_max_write()
         │
         ▼
    值存入内核内存中的数据结构          ← 存在内存里,不在文件里
    (task_group.cfs_bandwidth)
         │
         ▼
    CPU 调度器每次调度时
    直接读内存中的数据结构做判断        ← 不是去"读文件"

类比银行柜台:你填表(写文件)交给柜员(内核),柜员录入系统(内存数据结构)。之后银行做业务查的是系统记录,不是翻你那张纸。你再来查询时(读文件),柜员从系统现查现报。

重启会丢失吗?

会。但这不是问题——重建 cgroup 是上层服务的职责:

场景谁负责重建 cgroup?
Docker 容器dockerd 启动时重新创建
systemd 服务systemd 为每个 .service 重建
K8s Podkubelet 启动时重新配置
手动测试丢了就丢了,本来就是临时的

设计哲学:内核只提供机制,不负责持久化。"重启后恢复到什么状态"是上层应用的职责——Docker 把容器配置存在 /var/lib/docker/,启动时重新"申报"给内核。你手动 echo 写的配置之所以会丢,是因为没有上层服务帮你记住并重放。

小结

容器三件套到此两件就位

Namespace(第 02 篇)= 看到什么(视图隔离)
Cgroups(本篇)= 用多少(资源限制)
rootfs(第 04 篇 OverlayFS)= 文件从哪来(分层文件系统)

三者合一 = 容器。一个既看不到外界、又不能无限消耗资源、还有独立文件系统的进程。

本篇要点回顾

要点一句话概括
Cgroups 本质通过文件系统接口(/sys/fs/cgroup/)控制进程组的资源配额
操作方式创建目录 = 建 cgroup,写文件 = 设限制,写 PID = 加入进程
v1 vs v2v2 统一层级、一个入口、推荐使用;v1 分散混乱、逐步淘汰
四大限制CPU(cpu.max)/ 内存(memory.max/high)/ IO(io.max)/ PID 数(pids.max)
Docker 集成--cpus / --memory 等参数只是自动写 cgroup 文件,无魔法

动手练习

  1. stat -fc %T /sys/fs/cgroup/ 确认你系统的 Cgroups 版本
  2. 手动创建一个 cgroup,限制 CPU 为 30%,运行 dd if=/dev/zero of=/dev/null 验证限制生效
  3. docker run --memory="64m" ubuntu stress --vm 1 --vm-bytes 128M 观察 OOM Killer 触发
  4. 运行一个 Docker 容器,找到其对应的 cgroup 目录,手动读取 cpu.maxmemory.max 文件
  5. 思考题:如果一个容器的 memory.max 被设为 256MB,容器内用 free 命令看到的内存是多少?为什么?(提示:和 Namespace 有关)