资讯动态

深入理解cgroup:从内核资源控制到Docker参数全解析

发布时间:2026/10/16 7:32:52 来源:尧图企业网站定制
前阵子线上容器没限制资源来了个内存密集型的任务直接把宿主机拖到不可用同节点上其他服务全部跟着雪崩。排查到最后发现锅并不在业务代码而在容器最底层那套“不设防”的资源管理机制上。也就是今天要写的 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.tasksv1或cgroup.threadsv2精确到线程级别。控制器参数文件则按资源类型分布。v2 的根目录下有一个cgroup.controllers文件列出当前可用的控制器还有一个cgroup.subtree_control文件决定哪些控制器下放到子 cgroup 里去。想要某个子目录里的 CPU 限制生效得先在父目录的cgroup.subtree_control里加上cpu。我习惯把这理解成游戏里的“组队系统”cgroup.procs是队伍成员名单cgroup.subtree_control是队长权限决定给哪个子队开放哪些功能cpu.max、memory.max就是队伍的装备和血量上限。2.3 文件系统即接口新建一个控制组只需要 mkdircgroup 最反直觉也最优雅的点在于它是通过文件系统操作来管理的。新建一个控制组不是调内核 API而是直接mkdirmkdir /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 文件--cpus1限制最多使用 1 个 CPU 核心cpu/cpu.cfs_period_us cpu/cpu.cfs_quota_uscpu.max--cpu-shares512CPU 相对权重cpu/cpu.sharescpu.weight换算值--memory256m内存硬上限memory/memory.limit_in_bytesmemory.max--memory-swap512m内存交换分区总上限memory/memory.memsw.limit_in_bytesmemory.swap.max memory.max--pids-limit100进程数上限pids/pids.maxpids.max--cpus参数落地时不会直接出现一个叫 cpus 的文件而是拆成两个cpu.max里的quota和period。在 v2 下文件内容格式是“quota period”比如限制 1 核就是cat /sys/fs/cgroup/.../cpu.max 100000 100000100000 是 period单位是微秒100msquota 也是 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 --cpus1 --memory256m --pids-limit100 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.maxv2 模式下容器里/sys/fs/cgroup挂载的就是容器自身所在的控制组所以不需要再拼路径直接读文件名就行。这三个文件分别输出100000 100000、268435456、100和参数完全对得上。3.3 容易被忽视的 --memory-swap 换算规则--memory-swap是很多人踩坑的地方。它的语义不是“额外的交换分区上限”而是“内存加交换分区的总上限”。比如docker run -d --memory256m --memory-swap512m nginx容器里 cgroup 的配置结果是内存上限 256m交换分区上限 256m合计 512m。如果想让容器完全没有交换分区的机会把--memory-swap设成和--memory一样大即可docker run -d --memory256m --memory-swap256m 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 压测--cpus1 时 cpu.max 变成 1在宿主机上启动一个压测容器docker run -dit --name cpu-limit --cpus1 stress-ng-img stress-ng --cpu 4 --timeout 30s虽然stress-ng启动了 4 个 CPU 压测进程但--cpus1已经把配额卡死了。宿主机上用top或htop观察能看到这些进程的总 CPU 占用率在 100% 附近波动不会冲上去。对应的内核证据是容器里的cpu.max文件docker exec cpu-limit cat /sys/fs/cgroup/cpu.max 100000 100000如果把我手动创建的 demo 控制组的cpu.max改成50000 100000再用--cpus0.5起容器同样能看到 4 个压测进程抢着 50% CPU 的奇观——进程数量再多也没用quota 就是那个水龙头。4.3 内存压测面对 OOM 的直接翻车现场内存限制的验证比 CPU 更直观因为超过硬上限会直接触发内核 OOM kill。启动受限容器docker run -dit --name mem-limit --memory256m 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-limit50 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 到 262144v2 的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 强制切出的比如--cpus1意味着每 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问题就出在给这个服务设置了--cpus0.8quota 太小。业务某段时间内有突发请求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。排查步骤要分两层看memory.max与memory.current的差距确定是不是真的打到了硬顶。看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 里或者容器里有进程逃逸出了 namespacecgroup 目录因为仍有引用而无法完全清理。遇到这种残留第一反应不该是手动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 参数讲透的文章能让你下次遇到资源问题时少走几个弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑