🔥 本文专栏:Docker
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录:
真正决定你能走多远的,不是你有没有走过弯路,而是你是否愿意在看清弯路之后,继续往前走。
思维导图
text
Namespace
↓
给进程提供隔离后的资源视图
↓
解决"进程能够看到什么"
↓
但底层 CPU / 内存 / IO 仍然共享
↓
不同容器之间仍然会发生真实的资源竞争
↓
某个容器异常高负载
↓
影响同一宿主机上的其他服务
↓
业务调用链中的某个服务处理能力下降
↓
整体 RT / 吞吐量受到影响
↓
需要给不同工作负载建立资源使用边界
↓
CGroup
↓
将一批进程组织为一个逻辑组
↓
Controller
├── CPU Controller
├── Memory Controller
├── IO Controller
├── PIDs Controller
└── ...
↓
Linux Kernel 真正执行资源统计、限制与调度规则
↓
cgroup2 伪文件系统
↓
/sys/fs/cgroup
↓
目录 = CGroup 节点
伪文件 = 内核控制 / 状态接口
↓
cgroup v2 统一层级
↓
Memory Controller 实战
↓
stress 制造压力
pidstat / memory.current 观察
memory.max 设置硬上限
引入:Namespace 已经隔离了,为什么还需要 CGroup
在前面对 Namespace 的学习中,我们已经建立了一个很关键的认识:
Docker 并不是自己重新实现一套操作系统,更不是自己创建一套新的硬件资源,而是组织 Linux 内核原本就提供的能力,为普通进程构造出一个相对独立的运行环境。
如果继续使用此前的"VR 眼镜"模型,可以将多个容器进程想象成:
text
同一栋真实房子
↓
同一个 Linux Host
房子中的不同人
↓
不同 Linux 进程
每个人戴着不同的 VR 眼镜
↓
不同 Namespace
VR 眼镜展示:
PID 视图
Network 视图
Mount 视图
IPC 视图
UTS 视图
...
对于每一个人来说,他只能看到 VR 眼镜为自己展示的世界。
但是无论眼前的画面有多么独立:
text
真实供电系统
真实供水系统
真实房屋结构
仍然属于同一栋房子。
对应到容器中就是:
text
Container-A
Container-B
Container-C
↓
共享 Host Linux Kernel
↓
共享真实 CPU / 内存 / 磁盘 / 网卡
因此 Namespace 解决的是:
text
"你能够看到什么"
却没有解决:
text
"你到底能够使用多少"
而后一个问题,就是 CGroup 出现的核心原因。
一、重新理解 Linux 中的进程:不仅仅存在于一个"全局世界"中
1. 以前看进程,容易只看到 task_struct
在最开始学习 Linux 进程时,我们通常建立的是:
text
一个进程
↓
task_struct
↓
虚拟地址空间
文件描述符表
信号
调度信息
...
然后所有进程似乎都存在于一个统一的全局 Linux 世界中:
text
Process-A
Process-B
Process-C
↓
Linux Kernel
↓
CPU / Memory / IO
这个认识本身没有错,但是学习 Namespace 和 CGroup 以后,还需要再补两个非常重要的维度。
一个进程除了"它是谁"以外,还需要回答:
text
它处在哪一套资源视图中?
它处在哪一套资源控制规则中?
因此可以进一步抽象成:
text
Linux Process
│
├── Namespace 归属
│ ↓
│ 决定"能够看到什么"
│
└── CGroup 归属
↓
决定"资源应该如何使用"
这里需要注意,并不是说:
每个进程默认都拥有一套完全独立的 Namespace 和 CGroup。
而是:
每个进程都会归属于某些 Namespace,也会归属于某个 CGroup;多个进程完全可以共享同一套 Namespace 和同一个 CGroup。
2. fork 不仅会继承常见进程上下文,也会继承 Namespace 与 CGroup 归属
以前理解 fork() 时,我们重点关注:
text
父进程
↓
fork()
↓
子进程
虚拟地址空间
文件描述符
信号状态
...
认识 Namespace 以后,还需要加入:
text
父进程
├── Namespace-A
└── CGroup-X
↓
fork()
↓
子进程
├── Namespace-A
└── CGroup-X
也就是说,在默认情况下:
子进程会继承父进程当前所在的 Namespace,也会继承父进程当前所在的 CGroup。
这也是后面做 CGroup 实验时一个非常实用的机制。
例如:
text
先把 bash 放入 memtest CGroup
↓
bash 再启动 stress
↓
stress 继承 bash 的 CGroup
↓
stress 创建的 worker 继续继承
↓
整棵子进程链自然受到同一个 CGroup 控制
这样就不需要把每一个后续创建出来的进程,再手动一个一个写入 cgroup.procs。
二、clone、unshare 与 setns:它们都在围绕 Namespace 做什么
前面通过 unshare 手动组织 Namespace 时,很容易形成:
text
Namespace
≈ unshare
但实际上 Linux 还提供了 clone()、setns() 等接口。
三者解决的问题并不完全相同。
1. clone:创建一个新 task 的同时决定共享关系
此前在线程部分已经接触过 clone()。
Linux 站在更底层的视角看:
text
clone()
↓
创建一个新的 task
↓
通过 CLONE_* flags
决定新 task 与父 task 共享哪些资源
例如线程之所以和传统进程不同,很大程度就在于共享程度不同。
大量共享:
text
地址空间
文件描述符
信号处理
...
就更接近我们平时说的"线程"。
而 Namespace 相关 flag:
text
CLONE_NEWNET
CLONE_NEWNS
CLONE_NEWPID
CLONE_NEWUTS
...
则可以让新创建出来的 task 一出生就处于新的 Namespace 中。
所以可以粗略理解:
text
clone()
= 创建新人时
就决定它要戴哪一副 VR 眼镜
2. unshare:当前进程主动脱离原来的共享关系
unshare() 更适合表达:
text
进程已经存在
↓
不想再继续共享原来的某些 Namespace
↓
给自己建立新的资源视图
因此在手动模拟容器隔离环境时:
bash
unshare ...
非常直观。
例如:
text
当前 launcher
↓
unshare(...)
↓
进入新的 Mount / Network / UTS 等 Namespace
↓
fork()
↓
子进程继承这些 Namespace
PID Namespace 比较特殊:
text
unshare(CLONE_NEWPID)
↓
当前调用者自己不会立刻成为新 PID Namespace 中的 PID 1
↓
之后创建的子进程进入新的 PID Namespace
↓
子进程成为其中的 PID 1
这也是为什么经常会看到:
bash
unshare --pid --fork ...
3. setns:不是创建新的,而是进入一个已经存在的 Namespace
setns() 的目标又不一样:
text
已经存在 Namespace-A
↓
另一个进程
↓
setns()
↓
进入 Namespace-A
因此可以用 VR 模型统一记忆:
text
clone
→ 创建一个新人,同时决定他的资源视图
unshare
→ 人已经存在,给自己创建新的资源视图
setns
→ 人已经存在,进入别人已经存在的资源视图
真实的容器运行时会根据需要组合这些内核能力,而不是固定只有某一个 API。
三、为什么普通的 Linux 调度还不够:CGroup 出现的真正动机
1. 资源竞争本身当然是正常的
这里最容易产生一个疑问:
多个进程竞争 CPU、内存本来就是操作系统的正常行为,为什么还要额外显式限制?
的确,在没有容器的时候,我们直接:
bash
./serverA
./serverB
./serverC
也不会给每个进程都手工规定:
text
你只能用 20% CPU
你只能用 500MB 内存
因为 Linux 本身就会:
text
调度 runnable task
分配 CPU 时间
管理物理内存
处理 IO
CPU 调度也会结合调度策略、权重和优先级完成相对公平的资源分配。
所以:
CGroup 并不是因为"资源竞争是不正常的"。
恰恰相反,资源竞争一直存在,而且非常正常。
2. 真正的问题是:我们需要人为定义"业务边界"
当多个微服务容器同时位于一台宿主机上时:
text
Host
├── Container-A:订单服务
├── Container-B:用户服务
├── Container-C:日志服务
└── Container-D:推荐服务
从 Namespace 的视角看,它们彼此隔离。
但是从业务上看,它们可能组成同一条调用链:
text
Request
↓
Service-A
↓
Service-B
↓
Service-C
↓
Database
从底层资源上看,它们同样还在竞争:
text
同一组 CPU
同一块物理内存
同一套磁盘 IO 能力
假设日志服务突然出现异常:
text
Container-C
↓
CPU 消耗暴涨
↓
和 A / B / D 竞争 CPU
↓
B 获得的 CPU 时间减少
↓
B 单次请求处理时间增加
↓
上游 A 等待 B
↓
整条请求链 RT 上升
所以容器之间虽然:
text
部署独立
视图独立
生命周期独立
但是并不意味着:
text
业务依赖完全独立
底层资源完全独立
这里就会出现典型的:
text
Noisy Neighbor
吵闹邻居
一个工作负载异常消耗共享资源,就可能拖累同节点上的其他服务。
3. CGroup 不是消灭竞争,而是给竞争增加规则
因此 CGroup 真正解决的是:
在 Linux 正常的资源竞争之上,再增加一层"业务希望如何分配资源"的规则。
例如:
text
订单服务
→ CPU 不能超过某个额度
日志服务
→ 内存不能无限增长
核心服务
→ CPU 竞争时希望拥有更高权重
所以可以形成:
text
普通 Linux 内核
→ 解决"这些 task 怎么调度、怎么分配资源"
CGroup
→ 解决"管理员希望这一组 task 应该怎样参与资源竞争"
换句话说:
CGroup 的目的不是取消 Linux 调度,而是给一组进程建立明确的资源使用边界和竞争策略。
四、CGroup 到底是什么:不是进程,也不是一份普通配置文件
第一次接触 CGroup 时,很容易产生疑问:
text
CGroup 到底是什么?
是一个进程?
一个文件?
一个服务?
都不是。
可以先将 CGroup 定义为:
Linux 内核中的一套"进程分组 + 资源控制"机制。
一个具体的 CGroup,可以看成一个逻辑进程组:
text
CGroup-A
├── PID 1001
├── PID 1002
└── PID 1003
同时它还关联一组资源规则:
text
CPU
→ 怎么使用
Memory
→ 最多使用多少
IO
→ 怎么参与竞争
PIDs
→ 最多创建多少进程
因此它描述的是:
text
谁属于这个组
+
这个组应该遵守什么资源规则
真正负责执行的仍然是 Linux 内核。
五、Controller:CGroup 如何真正控制不同类型的资源
1. CGroup 负责"组织谁",Controller 负责"控制什么"
CGroup 自己首先回答:
text
哪些进程属于这一组?
而不同资源的具体控制逻辑则由 Controller 负责。
例如:
text
CPU Controller
→ CPU 使用与竞争规则
Memory Controller
→ 内存统计、保护、限制
IO Controller
→ IO 控制
PIDs Controller
→ 进程数量控制
Cpuset Controller
→ CPU / NUMA 节点约束
所以可以先建立:
text
CGroup
= 一组进程
Controller
= 针对某类资源的内核控制逻辑
2. Controller 本质上还是内核中的处理模块
例如 Memory Controller。
用户最终可能配置:
text
memory.max = 100 MiB
这里并不是 Docker 自己在外面盯着:
text
"这个进程是不是超过 100 MiB 了?"
而是:
text
用户配置规则
↓
Memory Controller 接收规则
↓
内核维护这个 CGroup 的内存状态
↓
进程真正申请 / 使用内存
↓
内核根据 Controller 状态执行回收、限制、OOM 等逻辑
因此:
Docker / 用户态负责组织和配置规则,Linux Kernel 才是最终的执行者。
Namespace 也是同样的逻辑:
text
Namespace
→ 内核提供资源视图隔离能力
CGroup
→ 内核提供资源使用控制能力
Docker
→ 组织这些能力形成容器
六、为什么 CGroup 看起来像"文件系统"
CGroup 本身在内核里,但是用户态必须有办法:
text
创建一个组
查看组里有哪些进程
修改 CPU 配额
修改内存上限
读取资源统计
Linux 为此提供了:
text
cgroup / cgroup2 文件系统
这是一类伪文件系统。
1. 什么叫伪文件系统
普通 ext4 文件:
text
a.txt
↓
文件数据
↓
Page Cache
↓
最终对应磁盘数据块
而伪文件系统中的"文件",重点通常不是持久化存储数据。
例如:
text
/proc
→ procfs
→ 暴露进程和内核状态
/sys
→ sysfs
→ 暴露设备、驱动、内核对象
/sys/fs/cgroup
→ cgroup2
→ 暴露 CGroup 状态与控制接口
所以:
伪文件依然可以使用
open/read/write等 VFS 统一接口操作,但是它背后通常对应的是内核对象和内核逻辑,而不是磁盘中的普通文件内容。
2. CGroup 状态并不是"保存在这些文件里面"
这一点非常关键。
不能理解成:
text
cpu.max 文件
↓
真的把 "50000 100000" 存下来
↓
以后内核再打开这个文件读取
真正情况是:
text
内核中维护 CGroup 对象与 Controller 状态
↑↓
cgroup2 伪文件作为用户态接口
所以:
text
memory.max
memory.current
cgroup.procs
都是"窗口"。
例如:
text
memory.current
→ 读取内核当前统计到的内存状态
memory.max
→ 修改内核中的 Memory Controller 配置
cgroup.procs
→ 查看 / 修改进程与 CGroup 的归属关系
七、从 write() 到 Controller:向伪文件写数据到底发生了什么
假设执行:
bash
echo 104857600 | sudo tee /sys/fs/cgroup/memtest/memory.max
这里可以把整个过程完整串起来。
text
echo
↓
向 stdout 写入 "104857600"
↓
pipe
↓
tee 从 stdin 读取
↓
tee write(memory.max)
↓
系统调用进入内核态
↓
VFS 根据 fd 知道目标属于 cgroup2
↓
调用 cgroup2 对应文件的写处理逻辑
↓
识别 memory.max 属于 Memory Controller 的控制接口
↓
解析 104857600
↓
修改内核中 memtest 的 Memory Controller 状态
因此这里不是:
text
先写普通文件
↓
以后 Controller 再读取文件
而是:
当前这一次
write()本身,就是在调用内核暴露出来的控制入口。
这就是为什么:
text
"Everything is a file"
在 Linux 中非常有价值。
用户态继续使用熟悉的:
text
open
read
write
mkdir
但是背后可以操作完全不同的内核对象。
八、挂载点:为什么 cgroup2 出现在 /sys/fs/cgroup
前面学习挂载时已经知道:
挂载点可以看成进入另一个文件系统的入口。
这里需要注意:
text
访问挂载点仍然走 VFS
并不是"碰到挂载点以后绕过 VFS"。
例如:
text
cgroup2
↓
mount
↓
/sys/fs/cgroup
访问:
bash
cat /sys/fs/cgroup/cgroup.controllers
大致会经历:
text
VFS 解析 /
↓
解析 sys
↓
解析 fs
↓
到达 /sys/fs/cgroup
↓
发现这是 mount point
↓
切换到挂载在这里的 cgroup2 文件系统
↓
继续解析 cgroup.controllers
所以:
text
/sys/fs/cgroup
就是用户态进入 cgroup2 世界的入口。
需要特别注意:
虽然路径长在
/sys下面,但是/sys/fs/cgroup自己已经是一个独立的 cgroup2 挂载点,并不能简单认为它只是普通 sysfs 目录。
九、确认当前机器的 CGroup 环境
在真正做资源限制之前,先确认三件事:
text
当前使用 v1 还是 v2
CGroup 挂载在哪里
有哪些 Controller
1. 查看 CGroup 文件系统版本
bash
stat -fc %T /sys/fs/cgroup
本机输出:
text
cgroup2fs
说明:
text
当前环境
→ CGroup v2
2. 查看 CGroup 挂载点
bash
mount | grep cgroup
得到:
text
cgroup2 on /sys/fs/cgroup type cgroup2 (...)
因此:
text
文件系统类型:cgroup2
挂载点:/sys/fs/cgroup
随后查看根目录:
bash
ls /sys/fs/cgroup

这里已经能看到两类东西。
一类是:
text
cgroup.controllers
cgroup.procs
cgroup.subtree_control
cpu.stat
memory.stat
io.stat
...
它们属于接口文件。
另一类:
text
system.slice
user.slice
init.scope
...
则对应子 CGroup。
十、CGroup 为什么是一棵树:什么又叫"子 CGroup"
CGroup 并不是一堆完全平铺的组,而是按照层级组成一棵树。
例如:
text
Root CGroup
├── serviceA
│ ├── worker
│ └── logger
└── serviceB
其中:
text
serviceA
→ Root 的子 CGroup
worker / logger
→ serviceA 的子 CGroup
这样就可以表达更细的管理关系:
text
serviceA
→ 整体接受一层资源规则
worker
→ 在 serviceA 下面继续细分
logger
→ 同样继承父层级约束,再建立自己的控制
对应到 cgroup2 文件系统:
text
一个目录
→ 一个 CGroup
目录下面继续创建目录
→ 创建子 CGroup
例如:
bash
sudo mkdir /sys/fs/cgroup/memtest
表面上只是:
text
mkdir
但是因为当前位置属于 cgroup2,所以背后实际上是:
text
VFS
↓
cgroup2 mkdir 处理逻辑
↓
内核创建一个新的 CGroup 节点
↓
memtest
十一、为什么 mkdir 一个目录以后,里面会"凭空多出一堆文件"
创建:
bash
sudo mkdir /sys/fs/cgroup/memtest
以后再执行:
bash
ls /sys/fs/cgroup/memtest | grep memory
可以看到:
text
memory.current
memory.events
memory.high
memory.low
memory.max
memory.min
memory.stat
memory.swap.current
...

这些文件不是用户手动创建出来的。
而是:
text
mkdir memtest
↓
内核创建 CGroup 节点
↓
根据父 CGroup 向下开放的 Controller
↓
自动为这个 CGroup 暴露对应的控制 / 状态接口
所以:
在 cgroup2 中,目录代表 CGroup 节点,目录中的特殊文件则是内核自动暴露出来的 Controller 接口。
十二、cgroup.controllers 与 cgroup.subtree_control
在根 CGroup 中:
bash
cat /sys/fs/cgroup/cgroup.controllers
本机输出:
text
cpuset cpu io memory hugetlb pids rdma misc
可以理解为:
当前这个 CGroup 有哪些 Controller 能力可以向下提供。
而:
bash
cat /sys/fs/cgroup/cgroup.subtree_control
表示:
当前这个 CGroup 实际已经给子 CGroup 启用了哪些 Controller。
所以先建立:
text
cgroup.controllers
→ 我有哪些 Controller 可以提供
cgroup.subtree_control
→ 我已经向子节点开放了哪些 Controller
本机二者都包含:
text
memory
因此在根目录下面创建 memtest 后,能够看到完整的 memory.* 接口。
十三、CGroup v1 与 v2:为什么它们的目录不一样
CGroup v1 与 v2 在组织方式上有一个非常重要的差异。
1. v1:不同 Controller 可以维护不同层级
v1 常见结构可以理解成:
text
/sys/fs/cgroup/
├── cpu/
│ ├── groupA
│ └── groupB
│
├── memory/
│ ├── groupX
│ └── groupY
│
└── blkio/
关键不只是"最外面多了 cpu、memory 目录",而是:
CPU Controller 与 Memory Controller 可以拥有不同的 CGroup 层级关系。
因此同一个 PID 可能:
text
CPU 维度
→ 属于 groupA
Memory 维度
→ 属于 groupX
2. v2:所有 Controller 共用统一层级
v2 则统一成:
text
/sys/fs/cgroup/
├── groupA
├── groupB
└── groupC
然后一个 groupA 中同时存在:
text
cgroup.procs
cpu.*
memory.*
io.*
pids.*
...
所以 v2 的核心思路更加直观:
先确定"这一批进程属于哪个逻辑组",再在这个组上从 CPU、Memory、IO 等多个资源维度施加规则。
可以压缩成:
text
v1
→ 不同 Controller 可以拥有不同的 CGroup 树
v2
→ 所有 Controller 共用一棵统一的 CGroup 树
十四、做实验之前:pidstat 用来"观察资源"
在验证 CGroup 之前,需要先有一个能够观察进程资源消耗的工具。
pidstat 就很适合。
常见选项:
text
pidstat -u
→ CPU
pidstat -r
→ Memory
pidstat -d
→ Disk IO
例如:
bash
pidstat -u 1
表示:
text
每隔 1 秒
统计一次进程 CPU 使用情况
也可以只观察某一个 PID:
bash
pidstat -u -p 1234 1
1. pidstat -u 中几个常见字段
本机输出如下:

重点字段:
text
UID
→ 进程所属用户
PID
→ 进程 ID
%usr
→ 用户态 CPU 时间比例
%system
→ 内核态 CPU 时间比例
%guest
→ 当前 task 用于执行虚拟机 Guest 代码的 CPU 时间比例
%wait
→ task 已经 runnable,但是等待 CPU 调度的时间比例
%CPU
→ 总体 CPU 使用率
CPU
→ 最近运行该 task 的逻辑 CPU 编号
Command
→ 进程名称
这里 %guest 很容易误解。
它不是:
text
"当前进程运行在虚拟机里,所以这里就有值"
更准确应该理解为:
当前 task 有多少 CPU 时间是在代表虚拟机执行 Guest vCPU 相关代码。
因此,即使当前 Ubuntu 本身运行在某个虚拟机中,其中普通的:
text
redis-server
mysqld
看到 %guest = 0 也是正常的。
十五、stress:主动制造 CPU、内存和 IO 压力
只有观察工具还不够。
如果一个服务当前只有:
text
0.1% CPU
几十 MB 内存
那么很难直观看到 CGroup 限制前后的差异。
因此可以使用:
text
stress
主动制造压力。
常见选项:
text
--cpu N
→ 启动 N 个 CPU worker
→ 持续执行计算
→ 制造 CPU 压力
--vm N
→ 启动 N 个 VM worker
→ 申请、访问内存
→ 制造内存压力
--io N
→ 启动 N 个 IO worker
→ 主要通过 sync 等操作制造写回压力
--hdd N
→ 启动 N 个 HDD worker
→ 创建临时文件、写入、删除
→ 制造文件 IO 压力
例如:
bash
stress --cpu 1
表示启动一个 CPU worker,不断执行计算。
需要注意:
classic
stress的 worker 是通过子进程工作的,并不是"固定绑定某一个 CPU 的线程"。
是否运行在 CPU0、CPU1,仍然由 Linux 调度器决定,除非另外做 CPU affinity / cpuset 等绑定。
1. sync 到底是什么
Linux 普通文件写入通常会经历:
text
write()
↓
Page Cache
↓
页面被修改
↓
Dirty Page
↓
后续由内核写回磁盘
而 sync() 的作用可以先理解成:
要求内核推进文件系统脏数据与元数据到底层存储设备的同步。
所以:
text
stress --io N
会反复制造这类写回压力。
这里还需要区分:
text
--io
→ 重点是 sync / writeback 压力
--hdd
→ 更直接地做临时文件写入等文件 IO
十六、Memory Controller 实战:创建 memtest CGroup
现在正式验证内存控制。
先创建:
bash
sudo mkdir /sys/fs/cgroup/memtest
此时逻辑关系变成:
text
Root CGroup
├── system.slice
├── user.slice
├── ...
└── memtest
memtest 就是我们自己创建的一个逻辑进程组。
十七、把进程放进 CGroup:cgroup.procs
假设有一个 PID:
text
2939131
执行:
bash
echo 2939131 | sudo tee /sys/fs/cgroup/memtest/cgroup.procs
这里:
text
echo
↓
输出 PID
↓
pipe
↓
tee
↓
write(cgroup.procs)
↓
内核修改进程的 CGroup 归属
然后:
bash
cat /sys/fs/cgroup/memtest/cgroup.procs
就可以看到这个组中有哪些进程。
1. 一个非常容易踩到的坑:先申请内存,再迁移进程
第一次实验时,如果:
text
先启动 stress
↓
stress 已经申请约 200 MiB
↓
再把 stress 迁移到 memtest
可能出现:
text
cgroup.procs
→ 已经能够看到 stress
memory.current
→ 却接近 0
原因在于:
进程迁移 CGroup,并不意味着已经产生的所有内存 charge 都会随着进程一起重新记账到新 CGroup。
可以先用一个形象的模型理解:
text
人已经搬家
但是之前产生的账单还记在原来的地方
所以为了让实验最干净,更合适的顺序是:
text
先把父进程放入目标 CGroup
↓
再从这个父进程启动 stress
↓
stress 继承父进程 CGroup
↓
之后新申请的内存自然记账到 memtest
十八、先放 bash,再启动 stress:利用 fork 继承 CGroup
先:
bash
sudo bash
然后在这个 root bash 中:
bash
echo $$ > /sys/fs/cgroup/memtest/cgroup.procs
这里:
text
$$
→ 当前 shell 的 PID
所以现在:
text
root bash
→ memtest
接着在这个 bash 中运行:
bash
stress --vm 1 --vm-bytes 200M

这时发生:
text
bash
→ 已属于 memtest
↓
fork / exec
↓
stress
→ 继承 memtest
↓
stress fork VM worker
↓
worker 继续继承 memtest
↓
开始申请 / 访问内存
↓
Memory Controller 将内存记账到 memtest
这一步正好把此前学过的 fork 继承行为和 CGroup 串起来。
十九、memory.current:查看这个 CGroup 当前用了多少内存
另一个终端执行:
bash
cat /sys/fs/cgroup/memtest/memory.current
本机得到:
text
192802816

换算:
text
192802816 Bytes
≈ 183.9 MiB
说明:
stress 后续产生的内存已经正确记账到了 memtest。
这里虽然传入:
bash
--vm-bytes 200M
但是某一个时间点的 memory.current 不要求严格等于 200 MiB。
它会受到:
text
实际触页情况
回收
进程自身其他内存
内核记账口径
stress 的申请 / 释放行为
等影响。
此时再看:
bash
cat /sys/fs/cgroup/memtest/memory.max
默认:
text
max
表示:
当前还没有人为设置 Memory Controller 的硬上限。
二十、memory.max:真正给这一组进程设置内存硬上限
现在设置:
text
100 MiB
由于:
text
100 MiB
= 100 × 1024 × 1024
= 104857600 Bytes
所以可以写:
bash
echo 104857600 | sudo tee /sys/fs/cgroup/memtest/memory.max
也可以让 Shell 自己计算:
bash
echo $((100*1024*1024)) | sudo tee /sys/fs/cgroup/memtest/memory.max
其中:
text
$((...))
→ Shell 算术展开
Shell 会先计算表达式,再把结果交给 echo。
1. 限制后的实际结果
设置以后:
bash
cat /sys/fs/cgroup/memtest/memory.current
得到:
text
104693760
换算以后约:
text
99.84 MiB
已经非常接近:
text
memory.max = 100 MiB
同时:
bash
cat /sys/fs/cgroup/memtest/memory.events
得到类似:
text
low 0
high 0
max 6723
oom 0
oom_kill 0
oom_group_kill 0

这里最关键的是:
text
max 6723
可以理解为:
这个 CGroup 已经大量触碰到
memory.max边界,内核不断执行与该硬限制相关的控制与回收路径。
但是:
text
oom 0
oom_kill 0
说明当前没有真正进入 CGroup OOM,也没有因为 OOM 把 stress 杀掉。
此时 pgrep stress 仍然能看到进程 PID,也进一步说明 stress 仍然存活。
所以整个过程就是:
text
stress 尝试制造约 200 MiB 内存压力
↓
Memory Controller 持续记账
↓
memory.current 接近 100 MiB
↓
触碰 memory.max
↓
内核进行 reclaim / 限制
↓
实际内存使用被压在上限附近
这里还需要知道:
text
memory.swap.current
memory.swap.max
用于观察和控制 swap 相关状态。
因此如果想进一步确认内存压力去了哪里,还可以查看:
bash
cat /sys/fs/cgroup/memtest/memory.swap.current
cat /sys/fs/cgroup/memtest/memory.swap.max
二十一、CPU Controller:和 Memory Controller 是同一套思路
认识完 Memory Controller 以后,CPU Controller 就不需要再重新建立一套模型了。
整个实验骨架完全一样:
text
创建一个 CGroup
↓
把 bash 放进去
↓
从 bash 启动 stress
↓
stress 继承 bash 的 CGroup
↓
stress --cpu N 制造 CPU 压力
↓
pidstat -u 1 观察
↓
修改 cpu.* 控制接口
↓
再次观察 CPU 使用情况
CPU Controller 中首先需要认识两个不同维度:
text
cpu.max
→ CPU bandwidth / 配额上限
cpu.weight
→ CPU 竞争时的相对权重
其中 cpu.max 的基本格式是:
text
QUOTA PERIOD
例如:
text
50000 100000
可以先理解成:
text
每一个 100000 μs 的周期内
这一组进程最多获得 50000 μs CPU 时间
也就是大约:
text
0.5 个逻辑 CPU 的计算额度
这里需要特别注意:
CPU 控制不是把某个真实 CPU 核心"物理切一半"给这个 CGroup,而是内核调度器根据 CPU Controller 的配额,在时间维度上控制这一组 task 能获得多少 CPU 时间。
因此这与此前的统一认知完全一致:
text
底层 CPU 仍然共享
↓
内核统一调度
↓
CGroup 给调度增加资源规则
二十二、从 Namespace + CGroup 重新建立完整的 Linux 进程心智模型
学习到这里,对 Linux 进程的理解就不能再停留在:
text
一个 PID
+
一个 task_struct
+
一份虚拟地址空间
还应该加入:
text
Linux Process
│
┌─────────────┴─────────────┐
↓ ↓
Namespace CGroup
↓ ↓
资源视图归属 资源控制归属
↓ ↓
"我能够看到什么" "我应该怎么用资源"
│ │
└─────────────┬─────────────┘
↓
Linux Kernel
↓
CPU / Memory / IO / ...
因此:
text
Namespace
→ 隔离视图
CGroup
→ 建立资源边界
rootfs
→ 组织用户态文件系统环境
Linux Kernel
→ 真正创建、调度、分配和限制资源
Docker / OCI Runtime
→ 组织这些内核能力形成容器
这也说明一个非常重要的问题:
容器本身并不是 Linux 内核中的某一种全新"进程类型"。
对于内核来说,它最终仍然是在管理普通 task。
只不过这些 task:
text
处于特定 Namespace
+
属于特定 CGroup
+
使用特定 rootfs / mount 视图
于是从用户态看起来:
text
像拥有一个独立运行环境
二十三、回到最开始的问题:CGroup 到底解决了什么
现在再回头看最开始的问题:
既然 Linux 本身就会进行 CPU 调度和内存管理,为什么还需要 CGroup?
答案已经比较清楚了。
Linux 本身解决:
text
系统有哪些 task
↓
怎么调度
怎么分配内存
怎么进行 IO
而 CGroup 进一步允许管理员表达:
text
这些 task 属于同一个业务单元
↓
它们整体最多能使用多少资源
↓
发生竞争时应该获得怎样的权重
↓
如何进行资源统计和限制
所以 CGroup 的核心并不是:
text
"让资源不再竞争"
而是:
把原本完全交给系统统一竞争的资源使用模式,变成"以进程组为单位、按照人为定义的策略参与竞争"。
最终,Namespace 和 CGroup 正好形成容器的两个互补维度:
text
Namespace
→ 隔离"能看到什么"
CGroup
→ 控制"能使用多少"
而它们真正的执行者,始终都是:
text
Linux Kernel
这也正是理解 Docker 底层时最值得建立的一条主线:
text
Docker 不是重新造一个操作系统
↓
而是在 Linux 内核原有能力之上
↓
组织进程的资源视图
+
组织进程的资源约束
+
组织用户态运行环境
↓
最终形成 Container
