阶段一 · Linux 与容器底层

资源限制的利器

与 Namespace 的关系

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

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

核心概念

术语表

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

主要控制器一览

控制器 控制资源 容器场景
cpu CPU 时间片分配 限制容器 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 v1 Cgroups 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 Pod kubelet 启动时重新配置
手动测试 丢了就丢了,本来就是临时的

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

小结

容器三件套到此-两件就位

  • Namespace(第 02 篇)= 看到什么(视图隔离)

  • Cgroups(本篇)= 用多少(资源限制)

  • rootfs(第 04 篇 OverlayFS)= 文件从哪来(分层文件系统)

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

本篇要点回顾

要点 一句话概括
Cgroups 本质 通过文件系统接口(/sys/fs/cgroup/)控制进程组的资源配额
操作方式 创建目录 = 建 cgroup,写文件 = 设限制,写 PID = 加入进程
v1 vs v2 v2 统一层级,一个入口,推荐使用;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 有关)