【Docker入门系列】:从 Namespace 到 CGroup:一文理解容器资源控制、cgroup v2 与 Memory Controller 实战

🔥 本文专栏: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
相关推荐
可乐鸡翅yeah_1 小时前
HLS 自动化拨测与手动调试分工,流媒体线上监控体系建设实践
运维·自动化·测试用例·音视频·媒体·m3u8·音视频在线播放
玉&心1 小时前
通过helm在k8s上面一键部署前端和后端代码
云原生·容器·kubernetes·helm
爱和冰阔落1 小时前
【Linux】epoll 真的是 O(1) 吗?从阻塞 I/O、select/poll 到 LT/ET 与 Reactor 一次讲透
linux·运维·高并发·tcp
11路没有终点2 小时前
Docker 化测试环境:一致性交付
运维·docker·容器
风景的人生2 小时前
Linux虚拟机网络故障排查与解决方案
运维·网络·ssh
..Dauntless..2 小时前
【Linux】权限问题——拥有者、所属组和其他用户的协调
linux·运维·服务器
平行云2 小时前
实时云渲染信创架构解析:从GPU池化到全栈适配的技术演进
linux·unity·docker·ue5·webgl·数字孪生·实时云渲染
handler013 小时前
【Linux】信号:内核的“敲门声”
linux·运维·服务器·c++·c·信号·signal
lucybean013 小时前
食品加工行业污水处理曝气装置采购避坑与合规筛选指南
运维