1. 为什么要在自制容器时先啃下 Cgroups 这块硬骨头很多人第一次接触容器技术注意力几乎全被镜像、网络、联合文件系统这些看得见的东西吸走了唯独对 Cgroups 提不起兴趣——它不像 namespace 那样能让你ps一下就看到隔离效果也不像 overlayfs 那样能直观感受到分层复用的巧妙。可一旦你真动手从零写一个迷你容器很快就会撞上同一堵墙进程是隔离了但它想占多少 CPU 就占多少想申请多少内存就申请多少一个死循环就能把宿主机拖垮。这时候你才会意识到namespace 负责看不见Cgroups 负责用不爆两者缺一不可。这篇内容就是围绕从零自制 docker系列里 Cgroups 这一环展开的。核心要解决的事情很具体搞清楚 Linux Cgroups 到底是什么、它在cgroup文件系统里以什么样的组件形态存在、怎么手动挂载并操作这些组件、以及在 Ubuntu 22.04 这种主流发行版上Docker 又是怎么使用 Cgroups 的。适合的读者是那些已经能跑起一个带 namespace 隔离的最小容器、现在想给它加上资源限制的人也适合单纯想弄明白容器资源限制到底靠什么实现的后端或运维同学。哪怕你之前完全没碰过 Cgroups只要会用命令行、看得懂/proc下的东西跟着走一遍也能落地。我先把结论摆前面Cgroups 不是某个神秘的内核开关它就是一套挂载在文件系统上的层级结构你对它的所有操作本质上都是对目录和文件的读写。理解了这句话后面的一切都会顺理成章。2. Cgroups 的整体设计与组件拆解2.1 一句话讲清 Cgroups 的本质用文件系统管理资源Cgroups 全称 Control Groups中文一般叫控制组。它的设计哲学非常Unix——不发明新系统调用让你去学而是把内核里的资源控制能力暴露成一个伪文件系统你mkdir一个目录就创建了一个控制组往目录里的文件echo一个值就设置了限制cat一下文件就读取了当前状态。这种一切皆文件的做法好处是任何语言、任何脚本都能操作不需要专门的库。打个生活化的比方把宿主机想成一栋写字楼Cgroups 就是物业给每个租户装的独立水电表加限流阀。你租下一间办公室创建一个 cgroup物业就给你配好电表cpu 控制器、水表memory 控制器、门禁配额pids 控制器。你想限制这间办公室最多用多少电不用去找物业改线路直接在自己那块表上拧一下写文件就行。整栋楼的总闸还是物业管着但每间房用多少房客自己说了算。这个类比里有个关键点值得强调Cgroups 管的是用量不是可见性。进程依然能看到整台机器的 CPU 核数和内存总量只是它实际能消耗的被卡住了。这正是它和 namespace 的分工所在也是为什么自制容器必须两者配合。2.2 四大核心组件hierarchy、subsystem、cgroup、task要真正读懂cgroup文件系统里的目录结构得先把四个概念分清楚它们是理解后面所有操作的钥匙。hierarchy层级一棵挂载好的 cgroup 树。你可以挂多棵树每棵树管一类或几类资源。树是树状结构父节点的限制对子节点生效子节点不能突破父节点的天花板。subsystem子系统/控制器具体管某种资源的那套逻辑比如cpu管 CPU 时间分配、memory管内存用量、pids管进程数量、blkio管块设备 IO。一个子系统可以挂到某棵树上挂上之后这棵树就具备了该控制能力。cgroup控制组树上的一个节点对应文件系统里的一个目录。资源限制就设在这个目录下的文件里。task任务也就是进程更准确地说是线程。把进程的 PID 写进某个 cgroup 的cgroup.procs文件这个进程就归属该控制组受该组限制约束。它们的关系可以这样串起来一棵 hierarchy 上挂着若干 subsystem树上的每个节点是一个 cgroup每个 cgroup 里装着若干 task。这里有个历史遗留的坑必须提前说——早期内核只支持 v1一棵树只能挂一个子系统想同时管 CPU 和内存就得挂两棵树后来有了 v2一棵树可以挂多个子系统结构更统一。Ubuntu 22.04 默认是混合模式hybridv1 和 v2 同时存在这也是新手最容易看花眼的地方后面会专门讲怎么分辨。2.3 v1 与 v2 的差异以及为什么 Ubuntu 22.04 上你会看到两套目录如果你在 Ubuntu 22.04 上直接mount | grep cgroup大概率会看到一长串输出既有/sys/fs/cgroup/cpu,cpuacct这种带逗号的 v1 挂载点又有/sys/fs/cgroup/unified这种 v2 的挂载点。这不是系统出问题了而是混合模式的正常表现。v1 的问题在于一个控制器一棵树导致进程在不同树上的归属可能不一致管理起来很乱。v2 的核心改进是统一层级所有控制器挂在一棵树上进程在树上的位置唯一父子关系清晰限制的继承和分配也更符合直觉。v2 还引入了cgroup.controllers、cgroup.subtree_control这类文件来动态开关控制器比 v1 灵活得多。对我们自制容器来说实际影响是如果你照着老教程操作 v1 的路径在纯 v2 环境里会找不到对应文件。所以动手前第一件事永远是确认当前系统用的是哪套。判断方法很简单看/sys/fs/cgroup下有没有cgroup.controllers这个文件——有就是 v2 已经挂上了再看有没有一堆以控制器命名的子目录有就是 v1 也在。这个确认动作我建议养成习惯换一台机器就先看一眼能省掉大量文件不存在的困惑。2.4 方案选型为什么自制容器阶段建议先手动挂载再交给运行时有人会问既然 Docker 已经帮我们管好 Cgroups 了为什么还要手动挂载、手动写文件我的看法是自制容器的意义就在于把黑盒拆开。你手动挂一次、手动限制一次才能真正理解 Docker 启动容器时那些--memory、--cpus参数背后发生了什么。等你把手动流程走通了再回头看 Docker 的行为会有一种原来就这么回事的通透感。具体到选型我建议分两步走先用一个独立的挂载点比如/tmp/my_cgroup手动挂一棵 v1 的树做实验因为 v1 的目录结构直观、每个控制器一个目录适合理解概念等概念清楚了再去操作系统默认的 v2 层级理解统一模型。这样由分到合认知曲线最平缓。直接在系统默认层级上乱写文件是有风险的可能影响其他进程所以实验阶段务必用独立挂载点这一点后面还会反复强调。3. 核心细节解析与实操要点3.1 挂载 cgroup 文件系统一条 mount 命令背后的门道手动挂载一棵 cgroup 树命令本身很短sudo mkdir -p /tmp/my_cgroup sudo mount -t cgroup -o cpu,cpuacct mycg /tmp/my_cgroup拆开看每个部分。-t cgroup指定文件系统类型是 cgroup。-o cpu,cpuacct是挂载选项意思是这棵树挂载cpu和cpuacct两个子系统——注意 v1 里cpu负责限制、cpuacct负责统计通常成对出现。mycg是这棵树的名字随便起它只是标识。最后的/tmp/my_cgroup是挂载点。挂载成功后ls /tmp/my_cgroup你会看到一堆文件tasks、cgroup.procs、cpu.cfs_quota_us、cpu.cfs_period_us、cpu.shares等等。这些就是控制接口。这里有个极易踩的坑如果你挂载时指定的子系统已经被别处挂载过mount 会报Device or resource busy。因为 v1 里同一个子系统在同一时刻只能挂在一棵树上。解决办法是换一个没被占用的子系统组合或者干脆用 v2。我第一次实验时就卡在这折腾了半天才发现是cpu已经被系统默认层级占用了。注意实验用的挂载点建议放在/tmp或专门的实验目录下卸载时sudo umount /tmp/my_cgroup即可不会污染系统默认的 cgroup 层级。千万不要往/sys/fs/cgroup下面手动挂东西。3.2 创建控制组与迁移进程mkdir 和 echo 就是全部挂好树之后创建一个控制组就是建目录sudo mkdir /tmp/my_cgroup/demo ls /tmp/my_cgroup/demo新目录里会自动生成一整套控制文件这是 cgroup 文件系统的特性——目录即控制组文件由内核自动填充。接着把当前 shell 挪进去echo $$ | sudo tee /tmp/my_cgroup/demo/cgroup.procs$$是当前 shell 的 PID。写进cgroup.procs后这个 shell 以及它后续 fork 出来的子进程都归属demo这个控制组。这里要区分两个文件cgroup.procs写的是进程组 IDTGID会把整个进程的所有线程一起迁移tasks写的是线程 IDTID只迁移单个线程。做容器资源限制时基本都用cgroup.procs因为你要限制的是整个进程。迁移完成后可以验证cat /tmp/my_cgroup/demo/cgroup.procs应该能看到你的 shell PID。实操心得迁移进程时最好在一个独立的 shell 里操作别把当前终端的主 shell 随便挪进实验组否则一旦设了很紧的限制连你自己敲命令都会被卡住想改回来都费劲。我一般开两个终端一个专门用来被限制另一个在外面负责调整参数和救援。3.3 CPU 限制的两个关键参数quota 与 period 怎么算CPU 限制是 Cgroups 里最常被问的部分核心就两个文件cpu.cfs_period_us和cpu.cfs_quota_us。前者是调度周期微秒后者是一个周期内允许使用的 CPU 时间微秒。两者的比值就是这颗 CPU 的份额。默认period是 100000 微秒100msquota是 -1不限制。假设你想让这个控制组最多用 0.5 个核算法是quota 0.5 × period 50000。想用 2 个核就是quota 200000。命令echo 50000 | sudo tee /tmp/my_cgroup/demo/cpu.cfs_quota_us echo 100000 | sudo tee /tmp/my_cgroup/demo/cpu.cfs_period_us设完之后在这个组里跑一个死循环while true; do :; done再用top看CPU 占用会被压在 50% 左右。为什么是左右而不是精确 50%因为 CFS 调度器是按周期结算的一个周期内用超了会被 throttled节流下个周期再放行所以短时间采样会有波动但长期平均是准的。理解这一点很重要否则你会误以为限制没生效。还有个cpu.shares容易被误解。它不是上限而是权重——当多个控制组争抢 CPU 时按 shares 比例分配。默认 1024。如果只有你一个组在跑shares 设成 1 也能吃满 CPU。上限用 quota权重用 shares别搞混这是新手最常见的概念混淆。3.4 内存限制memory.limit_in_bytes 与 OOM 的连锁反应内存限制对应memory.limit_in_bytes单位是字节。想限制 100MBecho 104857600 | sudo tee /tmp/my_cgroup/demo/memory.limit_in_bytes设完之后组内进程申请内存超过这个值会触发内核的 OOM killer把组内某个进程杀掉。这里有个非常关键的细节OOM 杀的是组内进程不是随便杀这正是 Cgroups 内存控制的价值——把爆炸范围圈在容器里不波及宿主机上的其他服务。但要注意memory.limit_in_bytes统计的不只是进程的堆内存还包括页缓存page cache。有时候你发现进程明明没申请多少内存却被 OOM 了很可能是它读写文件产生的页缓存把额度吃满了。v1 里可以用memory.memsw.limit_in_bytes同时限制内存加 swapv2 里则是memory.max和memory.swap.max分开。实操建议给容器设内存限制时留出 10%~20% 的余量给页缓存和内核开销别卡得死死的否则容易出现看起来没超却频繁被杀的诡异现象。3.5 pids 限制防止 fork 炸弹的最后一道闸pids控制器管的是控制组内能创建的进程/线程总数对应文件pids.max。这个控制器在自制容器里特别值得加因为它能挡住 fork 炸弹——一个进程疯狂 fork 子进程耗尽系统 PID 的场景。sudo mkdir /tmp/my_cgroup/pids_demo echo 50 | sudo tee /tmp/my_cgroup/pids_demo/pids.max设成 50 后组内进程总数到 50 就 fork 失败返回EAGAIN。注意pids.max统计的是任务数线程也算一个多线程程序很容易就占掉几十个名额所以设值时要考虑应用本身的线程模型别设得太小导致正常程序起不来。这个控制器在 v1 里是独立子系统在 v2 里是统一层级下的一个控制器用法一致。4. 实操过程与核心环节实现4.1 完整实验流程从挂载到验证一条龙把前面的碎片串成一个完整可复现的流程。以下操作在 Ubuntu 22.04 上实测需要 root 权限。第一步确认当前 cgroup 版本mount | grep cgroup ls /sys/fs/cgroup | head如果看到cgroup.controllers说明 v2 已挂载看到一堆控制器目录说明 v1 也在。混合模式下两者共存。第二步挂载一棵独立的实验树选一个没被占用的子系统组合比如memory如果没被占用sudo mkdir -p /tmp/cg_exp sudo mount -t cgroup -o memory cgexp /tmp/cg_exp如果报 busy换pids或blkio试试或者直接跳到 v2 操作。第三步建控制组并设限制sudo mkdir /tmp/cg_exp/box echo 104857600 | sudo tee /tmp/cg_exp/box/memory.limit_in_bytes第四步把测试进程迁入。开一个终端跑内存申请程序记下 PID然后echo PID | sudo tee /tmp/cg_exp/box/cgroup.procs第五步验证。用cat /tmp/cg_exp/box/memory.usage_in_bytes看实时用量用cat /tmp/cg_exp/box/memory.failcnt看超限次数。如果进程被杀dmesg里会有 OOM 记录。第六步清理sudo rmdir /tmp/cg_exp/box sudo umount /tmp/cg_exp整个流程里最容易出错的是第二步的 busy 和第六步的 rmdir 失败。rmdir 失败通常是因为组里还有进程得先把进程迁回根组写进/tmp/cg_exp/cgroup.procs再删目录。这个先清空再删除的顺序和删普通目录要先删文件是一个道理。4.2 在 v2 统一层级上操作subtree_control 的开关逻辑v2 的操作路径和 v1 差别不小值得单独走一遍。v2 的根在/sys/fs/cgroup先看根上挂了哪些控制器cat /sys/fs/cgroup/cgroup.controllers输出可能是cpuset cpu io memory pids之类。但注意控制器在根上可用不代表子层级自动启用。要让子 cgroup 能用某个控制器得在父级的cgroup.subtree_control里显式打开echo memory pids | sudo tee /sys/fs/cgroup/cgroup.subtree_control这个号表示启用-表示关闭。启用后在根下建子目录子目录里才会出现memory.max、pids.max这些文件。这是 v2 和 v1 最大的操作差异v1 建目录就有全套文件v2 得先授权控制器往下传。很多人第一次在 v2 上建了目录发现没有memory.max就是漏了这一步。设限制的写法也变了v2 用memory.max而不是memory.limit_in_bytessudo mkdir /sys/fs/cgroup/expbox echo 104857600 | sudo tee /sys/fs/cgroup/expbox/memory.max echo 50000 | sudo tee /sys/fs/cgroup/expbox/cpu.max注意cpu.max的格式是quota period两个值写在一个文件里比如50000 100000和 v1 分两个文件不同。这种格式差异在迁移脚本时要特别小心。4.3 Docker 在 Ubuntu 22.04 上如何使用 Cgroups理解了手动操作再看 Docker 就通透了。Docker 启动容器时会在系统 cgroup 层级下为每个容器建一个控制组路径通常是/sys/fs/cgroup/system.slice/docker-容器ID.scopesystemd 管理的系统上。你执行docker run --memory100m --cpus0.5Docker 做的事就是往这个 scope 目录下的memory.max和cpu.max写对应值。可以自己验证起一个带限制的容器然后找到它的 cgroup 路径读文件。docker run -d --name cgtest --memory100m --cpus0.5 alpine sleep 3600 docker inspect cgtest | grep -i cgroup拿到路径后cat里面的memory.max应该能看到104857600。这个反查动作特别有价值它把 Docker 的黑盒彻底打开了——你看到的每一个--memory参数最终都落成了文件系统里的一个数字。这里有个 Ubuntu 22.04 特有的注意点系统默认用 systemd 管理 cgroupDocker 的 cgroup driver 要和 systemd 一致否则会出现容器资源限制不生效或容器起不来的问题。检查/etc/docker/daemon.json里有没有exec-opts: [native.cgroupdriversystemd]新版 Docker 默认就是 systemd一般不用改但如果你从老环境迁移过来务必确认这一项。cgroup driver 不一致是容器资源限制设了等于没设的头号原因。4.4 参数选择与容量规划的实际考量设限制不是拍脑袋填数字得结合应用特性。CPU 方面如果应用是单线程的--cpus0.5意味着它最多用半个核响应会变慢但不会崩如果是多线程计算型得按并发度给足。内存方面Java 应用要特别注意堆外内存和元空间--memory设得比-Xmx大一些才安全否则 JVM 还没到堆上限就被 cgroup OOM 杀了日志里还看不到 Java 的 OOM 异常排查起来很迷惑。我一般的经验值是内存限制 应用峰值 RSS × 1.2 页缓存预留。CPU 限制则看 SLA延迟敏感的服务别把 quota 压太狠因为 throttling 带来的延迟抖动比平均利用率更伤体验。这些数字没有标准答案但留余量、看峰值、别卡死这三条原则是通用的。5. 常见问题与排查技巧实录5.1 挂载报 Device or resource busy 怎么办这是 v1 实验最高频的报错。根因是同一个子系统已经被别处挂载。排查思路先mount | grep cgroup看哪些子系统被占了然后换一个没被占用的组合挂载。如果所有常用子系统都被占说明系统已经全面用 v1 或混合模式管理此时更推荐直接在 v2 层级上做实验或者用unshare配合独立 mount namespace 隔离挂载环境。别去卸载系统默认的 cgroup 挂载点那会影响正在运行的服务。5.2 设了限制但进程照样吃满资源按可能性从高到低排查第一进程根本没迁进目标控制组cat cgroup.procs确认一下第二迁进去的是子进程但限制设在父组检查层级关系第三Docker 场景下 cgroup driver 不一致限制写到了错误的地方第四v2 下忘了在父级subtree_control启用控制器子目录里压根没有限制文件你写的可能是无效路径。这四条覆盖了我遇到过的绝大多数限制不生效案例。5.3 进程被 OOM 杀了但看不出原因先看memory.failcnt是否增长增长说明确实触发了内存限制。再看memory.usage_in_bytes被杀前的峰值。如果进程本身内存不大却被杀重点怀疑页缓存——大量文件读写会把 cache 算进额度。解决办法是适当调高限制或者在 v2 下用memory.high做软限制先触发回收而不是直接杀。另外容器里被杀时应用日志往往没有异常得去dmesg或宿主机日志找 OOM 记录这个信息差是新手最容易懵的地方。5.4 常见问题速查表现象可能原因排查动作解决方向mount 报 busy子系统已被占用mount | grep cgroup换子系统或改用 v2限制不生效进程未迁入/控制器未启用查cgroup.procs、subtree_control补迁移或启用控制器容器限制失效cgroup driver 不一致查 daemon.json统一为 systemd莫名被 OOM页缓存占额度查memory.failcnt调高限制或设软限制rmdir 失败组内仍有进程查cgroup.procs先迁出进程再删v2 下无限制文件父级未开 subtree_control查父级该文件echo xxx启用5.5 几条踩坑换来的实操心得第一条永远用独立挂载点做实验别碰/sys/fs/cgroup下的系统层级一次误操作可能让整台机器的服务异常。第二条迁移进程前先想好怎么撤把救援终端的 PID 记牢限制设太狠时能靠它改回来。第三条v1 和 v2 的文件名别混用memory.limit_in_bytes和memory.max是两个世界的东西脚本里写错一个字符就是文件不存在。第四条验证限制一定要用真实负载光看文件里的数字没意义跑个死循环或内存申请程序看top和failcnt的实际变化才算数。6. 把 Cgroups 接进自制容器的主循环回到从零自制 docker这条主线。当你把 Cgroups 玩明白之后给容器加资源限制的代码其实非常朴素在clone或unshare创建完 namespace、启动容器进程之前先建好对应的 cgroup 目录、写好限制文件再把即将执行的进程 PID 写进cgroup.procs。顺序很重要——先设限制再放进程进去否则进程在限制生效前可能已经跑飞了。具体到实现我一般封装一个setup_cgroup(pid, mem_limit, cpu_quota)的函数内部按 v2 的路径操作确保父级subtree_control已启用、建目录、写memory.max和cpu.max、最后写cgroup.procs。容器退出后清理函数负责把残留进程迁出并rmdir。这套逻辑和 Docker 内部做的事本质相同只是它做得更健壮、考虑了更多边界情况。一个容易被忽略的细节是父子层级的限制继承。如果你把容器 cgroup 建在某个已经有内存限制的父组下面容器的实际可用内存是两者中更严的那个。自制容器时如果发现限制值和预期对不上往上翻一层看父组的限制往往就找到答案了。这个继承机制是 Cgroups 设计上的精髓也是它比单纯给进程设个上限强大的地方——你可以按业务线、按租户层层分配资源形成一棵可控的资源树。走到这一步你的迷你容器已经具备了 namespace 的隔离和 Cgroups 的资源约束距离一个能用的容器就差镜像和网络了。而 Cgroups 这一环带来的认知收益远不止能限制资源这么简单——它让你真正理解了云环境里那些资源配额、超卖、隔离等级背后的底层机制。我自己在第一次手动把进程 CPU 压到 50% 的那一刻才算是真正摸到了容器技术的骨架。