☰
深入理解cgroup:从内核资源控制到Docker参数全解析
2026/10/16 7:32:45 网站建设 项目流程

前阵子线上容器没限制资源,来了个内存密集型的任务,直接把宿主机拖到不可用,同节点上其他服务全部跟着雪崩。排查到最后,发现锅并不在业务代码,而在容器最底层那套“不设防”的资源管理机制上。也就是今天要写的 cgroup。

Docker 本身并不做资源隔离,它只是把 Linux 内核提供的 namespace 和 cgroup 封装成了命令行参数。namespace 负责让容器“看不见”别人,cgroup 负责让容器“不能多用”宿主机的 CPU、内存、磁盘 IO 和进程数。很多人用 Docker 几年,跑过无数--memory、--cpus,但未必清楚这些参数最终写进了内核的哪个文件、经历了什么换算。这篇文章就把 cgroup 从内核接口到 Docker 参数的整个链路说透,适合已经会跑 Docker、想深入理解底层原理、或者在排查资源问题时无从下手的开发者。

1. 为什么容器必须“有天花板”—— cgroup 解决的痛点

1.1 一个没加限制的容器如何拖垮整台机器

我一开始对资源限制也不上心,想着“反正容器里面就是几个进程,能占多少资源”。直到有一次,同事在测试环境跑了一个数据清洗任务,镜像里直接while true疯狂写内存和 CPU,宿主机 64G 内存很快就没了,内核开始频繁 swap,整个节点上的 Java 服务全部卡死,连docker stop都执行了半天才把容器杀掉。

这中间的根源在于:普通进程只是“孤儿”一样运行在宿主机内核里,如果没有 cgroup 约束,它和宿主机上的其他进程共享全部资源。容器里的进程和宿主机进程在内核眼中没有任何区别,谁抢到算谁的。一个失控容器把整台机器搞挂,不是“理论上可能”,而是“迟早会发生”。

cgroup 就是干这个的:它从内核层面给一组进程划定了资源使用上限,让容器有了“天花板”。有了天花板,极端情况下最多是容器自己被 OOM 杀掉或者 CPU 被 throttle,不会波及同节点的邻居。

1.2 cgroup 与 namespace:一个管“看不见”,一个管“不能用”

很多人把 Docker 的隔离机制混在一起讲,但 namespace 和 cgroup 的分工其实很清楚:

  • namespace 解决“可见性”问题。容器里的进程看不到宿主机的其他进程(PID namespace)、看不到宿主机网卡(net namespace)、看不到宿主机目录(mount namespace)。它制造了一种“容器里只有我自己”的假象。
  • cgroup 解决“资源份额”问题。它不管你看不看得见,只管你这组进程最多能用多少 CPU、多少内存、多少IO和多少 PID。

打个比方:一台服务器就像一栋楼,namespace 是给每个租户一套独立的房间,让租户以为整栋楼只有自己一家;cgroup 则是物业的限电规则——你房间里可以随便装电器,但电表跳闸的上限由物业定。没有 cgroup,楼上楼下共用一条总电闸,谁家电暖器猛,整栋楼都跳闸。

这也是理解后续一切内容的基础:你在 Dockerfile 里写的--cpus、--memory,最终都是通过 libcontainer 把数字写进 cgroup 对应的内核接口文件。Docker 只是翻译官,cgroup 才是执法者。

2. cgroup 真正是怎么运作的:一张藏在 /sys/fs/cgroup 里的表

2.1 v1 与 v2 的管理模型差异—— 从多棵树到一棵树

Linux 的 cgroup 经历过两代实现。cgroup v1 是早期版本,每个资源类型(CPU、内存、IO)各挂载一棵独立的树,进程可以同时加入到多棵树的不同节点里。这意味着一个容器进程,在/sys/fs/cgroup/cpu/xxx里有一个路径,在/sys/fs/cgroup/memory/xxx里又有另一个路径,两棵树互不相干。优点是实现简单,缺点是资源之间的协同管理非常麻烦,而且父子 cgroup 之间很难做层级一致的管理。

cgroup v2 从 4.5 内核开始引入,现在主流发行版默认用的基本都是 v2。v2 把之前分散的多棵树合成为一棵统一的层级树,所有控制器(controller)都挂在这棵树上。整个/sys/fs/cgroup目录就是一个根节点,下面每个子目录就是一个 cgroup,任何进程都只能从根节点沿着一条路径找到自己的控制组。

检查当前系统用的是 v1 还是 v2,一条命令就够:

stat -fc %T /sys/fs/cgroup/

输出cgroup2fs就是 v2,输出tmpfs就是 v1。

2.2 从打游戏类比理解 cgroup.procs、tasks 和控制器文件

每个 cgroup 目录下都有两类文件:一类是成员管理文件,一类是控制器参数文件。

成员管理文件核心就两个:

  • cgroup.procs:列出/控制这个组里的进程(线程组),写入 PID 就能把整个进程的所有线程都移进来。
  • cgroup.tasks(v1)或cgroup.threads(v2):精确到线程级别。

控制器参数文件则按资源类型分布。v2 的根目录下有一个cgroup.controllers文件,列出当前可用的控制器;还有一个cgroup.subtree_control文件,决定哪些控制器下放到子 cgroup 里去。想要某个子目录里的 CPU 限制生效,得先在父目录的cgroup.subtree_control里加上cpu。

我习惯把这理解成游戏里的“组队系统”:cgroup.procs是队伍成员名单,cgroup.subtree_control是队长权限(决定给哪个子队开放哪些功能),cpu.max、memory.max就是队伍的装备和血量上限。

2.3 文件系统即接口:新建一个控制组只需要 mkdir

cgroup 最反直觉也最优雅的点在于:它是通过文件系统操作来管理的。新建一个控制组不是调内核 API,而是直接mkdir:

mkdir /sys/fs/cgroup/demo echo $$ > /sys/fs/cgroup/demo/cgroup.procs echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max

这三条命令完成的事,等价于把当前 shell 进程放进一个新的 cgroup,并限制它最多使用 0.5 个 CPU 核心。不需要任何第三方工具,纯手写就能操作 cgroup。这也是“文件系统即接口”设计理念的典型代表。

正因如此,任何能在文件系统上执行的操作——读文件、写文件、创建目录、删除目录——都能对内核中的资源控制产生影响。这也是容器逃逸类漏洞里频繁提到的“挂载 cgroup 后写入 release_agent”的根源所在。

理解这一切之后再看 Docker 的行为就清晰多了:docker run启动容器时,runc 会在 cgroup 树里创建一个以容器 ID 命名的子目录,把容器主进程 PID 写进cgroup.procs,再把资源参数转换成对应控制器文件的内容。整个过程本质上就是上面那三条命令的自动化。

3. Docker 资源参数与 cgroup 文件的对应关系

3.1 docker run 里 5 个高频参数到底写进了哪些文件

很多人只知道参数名,却不知道参数最终落在哪个内核文件里。我整理了一份高频对照表,把 v1、v2 和 Docker 参数的关系一次说清楚。

Docker 参数作用cgroup v1 文件cgroup v2 文件
--cpus=1限制最多使用 1 个 CPU 核心cpu/cpu.cfs_period_us + cpu/cpu.cfs_quota_uscpu.max
--cpu-shares=512CPU 相对权重cpu/cpu.sharescpu.weight(换算值)
--memory=256m内存硬上限memory/memory.limit_in_bytesmemory.max
--memory-swap=512m内存+交换分区总上限memory/memory.memsw.limit_in_bytesmemory.swap.max + memory.max
--pids-limit=100进程数上限pids/pids.maxpids.max

--cpus参数落地时不会直接出现一个叫 cpus 的文件,而是拆成两个:cpu.max里的quota和period。在 v2 下,文件内容格式是“quota period”,比如限制 1 核就是:

cat /sys/fs/cgroup/.../cpu.max 100000 100000

100000 是 period,单位是微秒(100ms),quota 也是 100000 微秒,意思是每 100ms 周期内最多执行 100ms,正好占满 1 核。如果限制 0.5 核,就是50000 100000,即每 100ms 周期最多执行 50ms。

--cpu-shares则不一样,它是“权重”而不是“上限”。v2 下会换算成cpu.weight,范围是 1 到 10000。Docker 默认的 1024 shares 并不会直接等于 1024 weight,而是经过一套归一化换算,实际换算出来的值大概在 40 左右。这里不必死记公式,但要记住:shares 只在系统 CPU 竞争时才发挥分配作用,如果宿主机根本没有别的进程抢 CPU,设置了 shares 的容器可以跑到任意高。

3.2 用一个真实容器去验证文件内容

用一条简单命令拉起容器:

docker run -dit --name cg-demo --cpus=1 --memory=256m --pids-limit=100 alpine:3.20 sleep 300

想知道这个容器对应宿主机上的哪个 cgroup 路径,用下面这条命令最直接:

docker inspect --format '{{.State.Pid}}' cg-demo

拿到容器主进程 PID 后,查看它的 cgroup 归属:

cat /proc/<PID>/cgroup

输出类似:

0::/system.slice/docker-xxxxxxxx.scope

这条路径说明当前 Docker 的 cgroup 驱动是 systemd 模式。如果 Docker 配置的是 cgroupfs 驱动,路径一般是/docker/<container-id>。

然后进容器里直接读自己头顶上的限制文件:

docker exec cg-demo cat /sys/fs/cgroup/cpu.max docker exec cg-demo cat /sys/fs/cgroup/memory.max docker exec cg-demo cat /sys/fs/cgroup/pids.max

v2 模式下,容器里/sys/fs/cgroup挂载的就是容器自身所在的控制组,所以不需要再拼路径,直接读文件名就行。这三个文件分别输出100000 100000、268435456、100,和参数完全对得上。

3.3 容易被忽视的 --memory-swap 换算规则

--memory-swap是很多人踩坑的地方。它的语义不是“额外的交换分区上限”,而是“内存加交换分区的总上限”。

比如:

docker run -d --memory=256m --memory-swap=512m nginx

容器里 cgroup 的配置结果是:内存上限 256m,交换分区上限 256m,合计 512m。如果想让容器完全没有交换分区的机会,把--memory-swap设成和--memory一样大即可:

docker run -d --memory=256m --memory-swap=256m nginx

这时候 v2 下的memory.swap.max是 0,说明不允许使用任何 swap。

生产环境里最常见的错误是把--memory-swap设成-1(不限),结果本来只想限制 256m 内存,却因为 swap 无限,把宿主机磁盘缓存盘反复读写,整机 IO 被打满。建议线上环境明确指定 swap 上限,或者干脆设成和内存一样。

4. 动手实测:让 CPU 和内存限制从文档走进现实

4.1 压测前的准备

看一百遍理论不如亲手压一次。我会用一个带stress-ng的最小镜像来模拟负载。Dockerfile 很简单:

FROM alpine:3.20 RUN apk add --no-cache stress-ng CMD ["sleep", "infinity"]

构建好镜像后分别跑几个场景,观察限制是否真实生效。

为了对比效果,先不限制资源启动一个容器,后面再启动受限容器,两者做对照。不管用什么镜像,有个前提:stress-ng 安装了,且容器有权限执行 CPU 和内存压测。

4.2 CPU 压测:--cpus=1 时 cpu.max 变成 1

在宿主机上启动一个压测容器:

docker run -dit --name cpu-limit --cpus=1 stress-ng-img stress-ng --cpu 4 --timeout 30s

虽然stress-ng启动了 4 个 CPU 压测进程,但--cpus=1已经把配额卡死了。宿主机上用top或htop观察,能看到这些进程的总 CPU 占用率在 100% 附近波动,不会冲上去。对应的内核证据是容器里的cpu.max文件:

docker exec cpu-limit cat /sys/fs/cgroup/cpu.max 100000 100000

如果把我手动创建的 demo 控制组的cpu.max改成50000 100000,再用--cpus=0.5起容器,同样能看到 4 个压测进程抢着 50% CPU 的奇观——进程数量再多也没用,quota 就是那个水龙头。

4.3 内存压测:面对 OOM 的直接翻车现场

内存限制的验证比 CPU 更直观,因为超过硬上限会直接触发内核 OOM kill。

启动受限容器:

docker run -dit --name mem-limit --memory=256m stress-ng-img stress-ng --vm 1 --vm-bytes 512M --timeout 20s

容器很快会退出,退出状态码为 137,表示被 SIGKILL。查看状态:

docker inspect mem-limit --format '{{.State.OOMKilled}} / {{.State.ExitCode}}' true / 137

宿主机内核日志里也能看到 OOM 记录:

journalctl -k | grep -i oom | tail

这里有个非常实用的观察点:容器里跑free -m看到的往往还是宿主机的总内存,因为/proc/meminfo是宿主机的视图,并没有感知 cgroup 限制。要看容器真实内存使用,应该看 cgroup 文件:

docker exec mem-limit cat /sys/fs/cgroup/memory.current

这个文件才是容器自己这组进程当前实际占用的内存。很多人看容器内存“怎么才用了几十兆”,其实是被free欺骗了,正确姿势是看memory.current和memory.max的比值。

4.4 pids 限制:进程数超限的报错信息

pids 控制器是最容易理解的一个。启动容器:

docker run -dit --name pids-limit --pids-limit=50 stress-ng-img

然后试着在容器里大量创建进程:

docker exec pids-limit sh -c "stress-ng --fork 100 --timeout 10s"

会看到类似Resource temporarily unavailable的报错,因为 cgroup 的 pids 控制器已经在进程创建层面直接拒绝了 fork。对应文件pids.max的内容是50,而pids.current会显示当前活跃 PID 数。

这个参数的实战价值在于防御 fork 炸弹,也可以避免容器里线程池爆炸式增长拖垮宿主机。不过要注意一个细节:每个线程都会占一个 PID 配额,Java 应用动辄几百线程,--pids-limit设得太小反而会误伤正常业务。

5. cgroup v2 给容器化带来的几个重要变化

5.1 delegation 与 subtree_control:谁有资格修改控制文件

v2 和 v1 一个很核心的差异是层级权限模型。v1 下,只要进程有目录写权限就能改自己的 cgroup 配置,非特权容器在特定情况下能利用这个特性逃逸管理。v2 通过cgroup.subtree_control和 delegation 机制把权限收敛了:一个 cgroup 能否把 CPU、内存等控制器“下放”给子目录,由父目录的subtree_control决定。

这对容器编排的影响是结构性的:Kubernetes 这类容器编排系统在把容器放进某个 cgroup 时,必须注意控制器是否已经正确下放,否则容器内的子进程再想嵌套 cgroup 就会失败。我自己手动给 pod 创建子 cgroup 时就遇到过这个问题,明明写了cpu.max,但/sys/fs/cgroup/cpu.max一直是默认值,排查半天发现是父 cgroup 的subtree_control没加cpu。

5.2 v2 下的 CPU 权重换算与 quota 调优

v1 的cpu.shares取值范围是 2 到 262144,v2 的cpu.weight范围是 1 到 10000。Docker 在 v2 环境下会把--cpu-shares做一次缩放到cpu.weight,默认 1024 shares 换算出来的 weight 并不等于 1024,大概只有 40 左右。这个换算让很多从 v1 迁移到 v2 的集群发现“我的 shares 设置不生效了”,其实是落在了不同的文件、不同的数值域里。

生产环境我更建议直接使用--cpus设硬上限,--cpu-shares只用于保证相对权重。两者的语义完全不同:

  • --cpus是配额制,到达上限后强制 throttle。
  • --cpu-shares是权重制,只在 CPU 竞争时影响分配比例,不设硬顶。

对延迟敏感的服务,尤其要谨慎使用过小的 quota。因为 quota 是按 period 强制切出的,比如--cpus=1意味着每 100ms 只能执行 100ms,听起来没问题,但如果 period 内进程突然忙了一段时间,只要 quota 用完,后面必须等下一周期才能继续跑,延迟抖动就出现了。时延敏感业务可以适当调大 period,或者干脆用 shares 权重模式,避免周期性硬切。

5.3 systemd cgroup driver 带来的路径变化

v2 环境下,systemd 已经承担了大量 cgroup 管理职责,Docker 的 cgroup 驱动通常也配置为systemd。这时候容器 cgroup 路径不是/docker/<id>,而是/system.slice/docker-<id>.scope,整个路径被纳入了 systemd 的 unit 管理视图。

这带来的实操影响是:直接 cd 到/sys/fs/cgroup/docker/下面去找容器目录的习惯要改。想找容器对应的目录,最稳的办法还是先拿容器 PID,再看/proc/<pid>/cgroup,这比凭记忆猜测路径靠谱得多。

另外,在 v2 + systemd 的组合下卸载和删除容器后,systemd 会自动清理 scope 目录,但偶尔也会出现残留。这类残留通常与僵尸进程、未关闭的文件描述符有关,排查思路会在下一节详细讲。

6. 排障实录:Cgroup 在生产环境给我上的三堂课

6.1 案例一:CPU quota 被限制后频繁 throttle,业务接口变慢

有段时间线上一个服务经常出现接口 P99 延迟飙升,但 CPU 使用率看起来并不高,只有 30% 左右。查监控看容器 CPU 毫无规律地出现“锯齿形”波动,每过一个周期就掉下来一大截。

后来进容器看cpu.stat,发现nr_throttled和throttled_usec一直在涨:

cat /sys/fs/cgroup/cpu.stat nr_periods 120000 nr_throttled 85000 throttled_usec 680000000

问题就出在给这个服务设置了--cpus=0.8,quota 太小。业务某段时间内有突发请求,CPU 需求超过 0.8 核,一旦 quota 用尽,后续 CPU 执行被强制挂起,接口只能等待下一个执行周期。表面 CPU 平均不高,实际上是“超了就要被晾着”。

解决方法是把--cpus调大,同时给容器配置了--cpu-shares作为兜底,让突发流量在宿主机空闲时能借到 CPU,而不是被 quota 卡死。如果服务对延迟极其敏感,甚至可以去掉 quota、只保留 shares,做到“平时不抢邻居,忙时能借力”。

6.2 案例二:为什么容器内存明明没满,进程却被杀

另一个经典场景是 Java 容器被OOMKilled,但业务日志里 JVM 堆还没有达到最大堆值,看起来一切正常,却被内核杀掉了。

原因是 JVM 的堆只是进程内存的一部分:线程栈、元空间、DirectByteBuffer、Native memory、GC 开销,都不在堆内。--memory限制的是整个用户态进程所有内存总和,只要这组进程累计超过memory.max,内核就会尝试回收并最终 OOM kill。

排查步骤要分两层:

  1. 看memory.max与memory.current的差距,确定是不是真的打到了硬顶。
  2. 看memory.stat里的anon、file、slab,区分是匿名内存页还是页缓存。

之前排查一个服务频繁被杀的问题,发现memory.current离上限还有 20%,但 OOM 还是发生了。后来看memory.stat,发现问题是页缓存(page cache)被算进了内存用量,大量日志文件读写把 page cache 顶满了。处理方式是调整内核回收参数,让页缓存优先回收,而不是直接杀掉进程。

容器里看到 OOM 被杀别急着骂内核,先分清楚是业务内存真实超限,还是页缓存这种“可回收但不及时”的内存把额度占了。

6.3 案例三:删除容器后宿主机内存统计不释放

还有一种隐蔽的坑:容器已经docker rm了,docker stats里也看不到了,但宿主机/sys/fs/cgroup下对应目录迟迟不消失,内存统计一直残留。这种情况常见于有进程以“僵尸”状态留在了 cgroup 里,或者容器里有进程逃逸出了 namespace,cgroup 目录因为仍有引用而无法完全清理。

遇到这种残留,第一反应不该是手动rmdir,而是先查还有没有进程挂在里面:

cat /sys/fs/cgroup/.../cgroup.procs

如果 cgroup.procs 里还有 PID,说明有进程没走干净。把对应 PID 确认清楚、处理掉之后,再尝试清空和删除目录:

echo 0 > cgroup.kill # v2 内核 5.14+ 支持 rmdir /sys/fs/cgroup/...

这种情况在多租户、频繁启停容器的平台上尤其容易出现。节点上 cgroup 目录积累太多还可能导致内核内存碎片化,遇到别太惊讶,按“查进程 -> 清引用 -> 删目录”的顺序处理即可。

最后再分享一个小技巧

如果你完全没接触过 cgroup,建议不要直接在 Docker 上试,先自己在宿主机上手动建一个控制组,跑echo $$ > cgroup.procs,然后写cpu.max,开几个yes进程占用 CPU,再观察top里这些进程的 CPU 使用率变化。整个过程三分钟,但对“cgroup 到底是什么”的理解会一下子落地。

我自己现在排查容器资源问题时,基本路径是:docker inspect拿 PID,/proc/<pid>/cgroup看归属,然后直接进目录读cpu.max、memory.max、cpu.stat和memory.current。这套链路在任何发行版、任何容器运行时下都通用,比盯监控面板上的曲线可靠得多。希望这篇把 cgroup 从内核文件到 Docker 参数讲透的文章,能让你下次遇到资源问题时少走几个弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询