Docker 容器核心原理:Cgroup 资源限制与 rootfs 隔离机制详解

一、开篇引言

写 [[有关docker Namespace原理]] 的时候,我在结尾留了个 flag:“顺便把 Cgroups 单独写一篇”。本来以为怎么也得等下学期,结果这周云原生课讲容器资源限制,老师直接把 Cgroup 和 rootfs 一起串进来了。趁热打铁,把这两块整理成这篇。

先回顾上篇的结论:Namespace 管"看得见",它只负责改写进程的视野,不管"用多少"。 当时老师抛了个让我一愣的问题:

  • 容器里明明有自己的一套 /,为什么在容器里乱删文件、改配置,宿主机一点事没有?
  • 为什么 docker run -m 256m 就能把容器内存卡死在 256M?这个限制到底是哪来的?

Namespace 给不了这两个答案。前者靠的是 rootfs(根文件系统隔离),后者靠的是 Cgroup(资源限制)

老规矩,环境还是我那台 CentOS 7 虚拟机,Docker 用默认的 overlay2 存储驱动。这次实验密度比上次还高——要反复 docker rundocker 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 里每执行一条会产生变更的指令(FROMRUNCOPYADD),就多出一层:

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 参数
cpuCPU 使用上限、权重--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忘了 sudocgroup 文件要 root 写,加 sudo(或用 tee 避免 sudo echo 重定向问题)
容器删了数据没了以为可写层会一直保留可写层跟容器同生共死,要持久化用 Volume
在容器里删镜像文件"删不掉"删完 ls 它又出现那是 whiteout 遮蔽,镜像只读层根本没动,用 docker diff 确认
sudo echo 写不进 cgroupsudo 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

更多推荐