Docker 容器核心原理:Cgroup 资源限制与 rootfs 隔离机制详解
Docker 容器核心原理:Cgroup 资源限制与 rootfs 隔离机制详解
一、开篇引言
写 [[有关docker Namespace原理]] 的时候,我在结尾留了个 flag:“顺便把 Cgroups 单独写一篇”。本来以为怎么也得等下学期,结果这周云原生课讲容器资源限制,老师直接把 Cgroup 和 rootfs 一起串进来了。趁热打铁,把这两块整理成这篇。
先回顾上篇的结论:Namespace 管"看得见",它只负责改写进程的视野,不管"用多少"。 当时老师抛了个让我一愣的问题:
- 容器里明明有自己的一套
/,为什么在容器里乱删文件、改配置,宿主机一点事没有? - 为什么
docker run -m 256m就能把容器内存卡死在 256M?这个限制到底是哪来的?
Namespace 给不了这两个答案。前者靠的是 rootfs(根文件系统隔离),后者靠的是 Cgroup(资源限制)。
老规矩,环境还是我那台 CentOS 7 虚拟机,Docker 用默认的 overlay2 存储驱动。这次实验密度比上次还高——要反复 docker run、docker rm、翻 /sys/fs/cgroup/,快照打得更勤了。
二、先串一遍:Namespace 管"看得见",Cgroup 管"用得着",rootfs 管"落在哪"
在展开之前,先把三件事的分工理清楚,后面就不容易混:
| 机制 | 管什么 | 直观表现 |
|---|---|---|
| Namespace | 隔离"视图":进程、网络、挂载、主机名、用户、IPC | 容器里 ps 看不到宿主机进程,hostname 独立 |
| Cgroup | 限制"用量":CPU、内存、磁盘 IO、进程数 | docker run -m 256m,内存超了直接 OOM |
| rootfs | 隔离"文件":容器有自己的根文件系统 | 容器里 / 是镜像层叠出来的,不是宿主机的 / |
一句话:Namespace 换眼睛,Cgroup 卡饭量,rootfs 换地盘。三者合起来,才是一个"像独立小机器"的容器。
这篇重点讲后两个。Namespace 的细节回看 [[有关docker Namespace原理]],两篇笔记是配套的。
三、rootfs:容器里的那个 “/” 到底是谁
3.1 容器里的 / 为什么跟宿主机不一样
第一次 docker run -it centos:7 bash 的时候,我 ls / 发现目录结构跟宿主机像又不像,当时天真地以为是"复制了一份 CentOS 装进去"。后来才知道完全不是那么回事——容器里的 / 是多个只读层 + 一个可写层联合挂载出来的"拼接视图",这个根文件系统就叫 rootfs。
一个直接的验证,在容器里看根目录挂载在什么文件系统上:
docker run -it centos:7 bash
# 容器内:
mount | grep overlay
# overlay on / type overlay (rw,lowerdir=...,upperdir=...,workdir=...)
看到 overlay 类型 + lowerdir/upperdir 就明白了:容器根目录不是一块真实磁盘,而是 overlay2 拼出来的联合视图。
对比 chroot:chroot 只是把某个目录"当成"根目录(就是 [[7.30 控制启动过程]] 里那笔旧账);而容器的
/是 rootfs,底层是联合挂载,比 chroot 彻底得多。容器实际是 Mount namespace + rootfs 切换(pivot_root)一整套,这也跟 [[有关docker Namespace原理]] 里说的对上了。
3.2 镜像为什么能"一张镜像,跑出 N 个容器"
Docker 镜像由多层只读层堆叠组成。Dockerfile 里每执行一条会产生变更的指令(FROM、RUN、COPY、ADD),就多出一层:
FROM centos:7 # 基础层:操作系统文件
RUN yum install -y nginx # 新层:只记录"装了 nginx 新增/变化的文件"
COPY index.html /usr/share/nginx/html/ # 新层:只记录这一个文件
关键点:每一层只保存和上一层相比的"差异",不是把整个系统重新复制一遍。这一点 [[8.4 张炜炜 容器知识点问答]] 里强调过很多次。
而 docker run 创建容器时,会在所有只读层上面再加一层可写的容器层。于是:
- 所有容器共享镜像的只读层 → 磁盘上只存一份 → 省空间
- 每个容器有自己的可写层 → 各写各的,互不干扰
- 容器删除 → 只销毁那层可写层,镜像纹丝不动
验证命令:
docker history centos:7 # 看镜像的层历史
docker run -it --name cow centos:7 bash
# 容器里写点东西、删点东西,然后回宿主机:
docker diff cow # 容器和镜像之间的差异
# A /xxx 新增 C /xxx 修改 D /xxx 删除
3.3 UnionFS 和 Overlay2:联合挂载是怎么拼出这个 “/” 的
这块 [[8.4 张炜炜 容器知识点问答]] 已经写得很细了,我简单串一遍:
- UnionFS 是一套"设计思想":把多个目录层堆叠、联合挂载成一个统一视图,支持只读层 + 可写层、CoW 写时复制、whiteout 删除标记。它不是某个具体软件。
- Overlay2 是 Linux 内核按这套思想实现的具体文件系统,也是 Docker 现在的默认存储驱动。它把层分成
lowerdir(多个只读层)+upperdir(可写层)+merged(联合视图,也就是容器里的/)。
读文件时从顶层往下找,找到就返回。三个核心机制:
① 写时复制(CoW)
下层只读层的文件不能直接改。要改时,Overlay2 先把文件复制到可写层,再在可写层上改,下层原文件保持不动。
② whiteout 删除标记
下层只读文件没法真的删,删除时在可写层生成一个 .wh. 开头的"遮蔽文件",把下层那个文件在联合视图里"挡住"。用 docker diff 看就是 D /path/to/file。
③ 层共享
多个容器共用同一套只读层,各用各的可写层。这是容器"轻"的关键来源之一。
记忆:UnionFS 是理论,Overlay2 是实现,Docker 只是调用内核 overlay2 的"调度员"。
宿主机上想看实际的层目录(需要 root):
sudo ls /var/lib/docker/overlay2/
# 一堆随机 id 的目录,每个目录对应一个层或容器
3.4 rootfs 的临时性:容器删了,写的东西就没了
学 rootfs 之后我做的第一个"翻车"实验:
docker run -it --name tmp centos:7 bash
# 容器里
echo "hello" > /data.txt
exit
docker rm tmp
# 再起一个容器,/data.txt 已经没了
因为可写层的生命周期跟容器绑定,容器一删,可写层连带里面的数据一起销毁。这不是 bug,是设计。
那要持久化怎么办?引出数据卷 Volume:
docker volume create mydata
docker run -it -v mydata:/data centos:7 bash
# 容器内写 /data,删了容器再重建,数据还在
一句话:rootfs 保证容器"干净",Volume 负责容器"留痕"。 镜像层是只读共享的,容器层是临时的,想跨容器存活的数据要放 Volume。
四、Cgroup:容器凭什么"饭量"被卡住
4.1 背景:Namespace 管不住资源
先回到上篇那个"局限性":Namespace 只改视野,不限制用量。一个容器可以疯狂 fork,把宿主机内存吃光,其它容器全部遭殃。这就是 Cgroup(Control Group,控制组) 要解决的问题。
Cgroup 是 Linux 内核原生的资源管控机制,能限制、统计、隔离一组进程的 CPU、内存、磁盘 IO、进程数等资源。Docker 里每个容器,本质上就是"一个控制组"。
4.2 Cgroup 核心子系统
Docker 里最常用这几个子系统:
| 子系统 | 管什么 | 对应 Docker 参数 |
|---|---|---|
| cpu | CPU 使用上限、权重 | --cpus 1、--cpu-shares 512 |
| memory | 内存、swap 上限 | --memory 256m、--memory-swap 256m |
| blkio | 磁盘读写速度 | --device-write-bps /dev/sda:10mb |
| pids | 进程数量上限 | --pids-limit 100 |
| cpuset | 绑定哪几颗 CPU 核 | --cpuset-cpus 0-1 |
记法:cpu / memory / blkio 管"三大件",pids 管"生多少进程",cpuset 管"用哪颗核"。
4.3 手动造一个控制组:跟着 [[8.5 张炜炜 容器问答题2]] 做一遍
不用 Docker,直接在内核层面手搓一个内存限制,最能看清 Cgroup 的原理。cgroup v1 下,每个子系统对应 /sys/fs/cgroup/ 下的一个目录:
# 1. 在 memory 子系统下新建一个控制组目录
sudo mkdir /sys/fs/cgroup/memory/mycg
# 2. 把当前 shell 的 PID 放进去
echo $$ | sudo tee /sys/fs/cgroup/memory/mycg/cgroup.procs
# 3. 写内存上限(256M)
echo 256M | sudo tee /sys/fs/cgroup/memory/mycg/memory.limit_in_bytes
# 4. 现在这个 shell 及其子进程,内存超过 256M 就会被 OOM
目录结构一目了然:
/sys/fs/cgroup/memory/
├─ cgroup.procs ← 根控制组,全局默认
├─ memory.limit_in_bytes ← 全局限制(别乱改!)
└─ mycg/ ← 我们自己建的控制组
├─ cgroup.procs ← 放进来管理的进程 PID
├─ memory.limit_in_bytes ← 只对 mycg 内进程生效
└─ memory.swappiness ← 只对 mycg 内进程生效
原理就是三件事:建目录 = 建分组,写 PID = 绑进程,写参数 = 设上限。内核在调度、分配资源时,按控制组里的配置来执行。
4.4 Docker 是怎么用 Cgroup 的
docker run 时给资源参数,底层就是让容器进程加入对应的控制组:
docker run -d --name demo \
--cpus 1 \
--memory 256m \
--memory-swap 256m \
--pids-limit 100 \
nginx
--cpus 1:最多用 1 颗 CPU--memory 256m:物理内存上限 256M--memory-swap 256m:和 memory 一样 → 禁用 swap(这个见下面的坑)--pids-limit 100:最多 100 个进程
跑起来之后,去 /sys/fs/cgroup/ 下找这个容器对应的目录:
# 拿容器完整 ID
docker inspect --format '{{.Id}}' demo
# 容器对应的内存限制文件(cgroup v1)
cat /sys/fs/cgroup/memory/docker/<完整ID>/memory.limit_in_bytes
# 262144000 ← 就是 256M
# 实时看资源占用
docker stats
到这一步,"容器内存到底限制在哪"就有据可查了——不是 Docker 虚拟出来的假数字,而是
/sys/fs/cgroup/下真实存在的内核配置。
五、rootfs + Cgroup 协同:容器的一生
把整条链路串起来,一个容器从生到死:
docker run
→ 1. 复用镜像只读层
→ 2. 创建容器可写层 + rootfs 联合挂载(拼出容器里的 /)
→ 3. clone 创建容器进程,进新的 namespace(换眼睛)
→ 4. 在 /sys/fs/cgroup/ 下建控制组,写入资源限制(卡饭量)
→ 5. 运行:rootfs 隔离文件读写,cgroup 实时管控资源占用
→ 6. docker rm:销毁可写层(数据没了),回收控制组
创建时各管各的,运行时各司其职,销毁时一起回收。这也对应着 [[8.5 张炜炜 容器问答题2]] 里"手工创建类容器对象"那套流程的落地版。
六、再谈容器 vs 虚拟机
上次写 Namespace 时对比过一次"隔离层次",这次从 rootfs + Cgroup 的角度再补一刀:
| 维度 | 容器 | 虚拟机 |
|---|---|---|
| 文件系统 | rootfs 分层(只读层共享 + 可写层),轻量 | 完整独立磁盘镜像,每个 VM 一份 |
| 资源隔离 | Cgroup 内核级软限制 | Hypervisor 硬件级硬隔离 |
| 内核 | 共享宿主机内核 | 每个 VM 有独立 Guest OS 内核 |
| 启动速度 | 秒级(本质是起进程) | 分钟级(要引导整个系统) |
| 资源开销 | 小 | 大 |
一句话:容器是"省着用的共享宿舍"——rootfs 让大家用同一套教材,Cgroup 让大家各领各的伙食费;虚拟机是"一人一套独栋别墅"。省资源是有代价的,隔离强度天然不如虚拟机。
七、常见坑合集(考试/生产都用得上)
| 坑 | 我的翻车经历 | 正确做法 |
|---|---|---|
容器里 free -m 还是宿主机内存 | 以为 --memory 没生效 | 限制是内核在分配时强制的,/proc/meminfo 并没有被隔离(除非上 lxcfs),用 docker stats 看真实占用 |
--memory 设了但没设 --memory-swap | 内存超了容器疯狂用 swap,磁盘 IO 被拖垮 | swap 是"内存+swap"的总量,不设默认还有 swap 额度;把它设成和 --memory 一样就等于禁 swap |
| cgroup v1 / v2 路径对不上 | 在 CentOS 7 上找 memory.max 死活找不到 | CentOS 7 是 v1:/sys/fs/cgroup/memory/...;新版系统是 v2 统一层级:/sys/fs/cgroup/.../memory.max |
| 往根控制组写限制 | 直接在 /sys/fs/cgroup/memory/memory.limit_in_bytes 上写,差点把系统搞崩 | 先 mkdir 建子控制组,只改自己组 |
| echo 到 cgroup 文件报 Permission denied | 忘了 sudo | cgroup 文件要 root 写,加 sudo(或用 tee 避免 sudo echo 重定向问题) |
| 容器删了数据没了 | 以为可写层会一直保留 | 可写层跟容器同生共死,要持久化用 Volume |
| 在容器里删镜像文件"删不掉" | 删完 ls 它又出现 | 那是 whiteout 遮蔽,镜像只读层根本没动,用 docker diff 确认 |
| sudo echo 写不进 cgroup | sudo echo 1 > xx 报 Permission denied | 重定向是 shell 做的,sudo 只作用在 echo 上;写成 echo 1 | sudo tee xx |
八、总结 + 速记清单
容器 = Namespace(隔离视图) + Cgroup(限制资源) + rootfs(隔离文件系统)
镜像 = 多层只读层堆叠,每一层只记录差异
容器 = 只读层 + 一个可写层,联合挂载出容器里的 /
UnionFS 是思想,Overlay2 是实现,Docker 只是调用方
CoW:改下层文件 = 复制到上层再改;whiteout:删除 = 上层加遮蔽标记
Cgroup 三件套:建目录(分组)+ 写 PID(绑进程)+ 写参数(设上限)
docker run --cpus / --memory / --pids-limit → 对应 /sys/fs/cgroup/ 下真实配置
容器层跟容器同生死,持久化数据放 Volume
最后说两句
把 rootfs 和 Cgroup 弄明白之后,回头看 [[有关docker Namespace原理]] 的"局限性"一节,终于闭环了:光有 Namespace 的容器,只是"看得见独立世界、但随时可能吃垮宿主机"的裸奔进程;配上 rootfs 才有自己的地盘,配上 Cgroup 才不拖累别人。
还有没完全搞懂的:Cgroup v2 的统一层级(unified hierarchy)和 io.weight 那套新接口,我只知道大概;--cpus 和 --cpu-shares 到底是怎么折算成 cpu.cfs_quota_us / cpu.shares 的,还想找老师确认一下。这些留到下次。
这篇和 [[有关docker Namespace原理]] 算是把 Docker 隔离的"三板斧"凑齐了。下一篇打算写 Docker 网络——bridge 模式那套 veth 对、docker0 网桥、iptables 转发,[[8.5 张炜炜 容器问答题2]] 里已经有素材了。
2026年8月 · 写于云原生课 Cgroup 整理 · 虚拟机上被我 mkdir 出来又 rmdir 掉的控制组,可能比我还熟悉 /sys/fs/cgroup
更多推荐
所有评论(0)