搞懂Cgroup:Docker容器资源限制的底层原理与实操指南
搞Docker的人早晚会撞上Cgroup这个词尤其是做容器资源限制、排查容器CPU飙高、内存泄漏的时候。Cgroup全称Control Group是Linux内核用来控制进程组资源的底层机制Docker容器跑起来之后CPU、内存、磁盘IO这些能不能限额全靠它。平时你敲的--cpus、--memory、--pids-limit这些参数最终落到底层就是在改Cgroup的文件。这篇文章我就把Cgroup的来龙去脉、子系统、Docker里的常用限制方法、还有我实际压测和排障时踩过的坑一次性讲透适合那些已经会跑docker run、但没细究过资源限制原理的人新手也能顺着看明白。1. 先搞清楚Cgroup到底在管什么1.1 一场资源争夺战引发的机制想象一下一台服务器上有好几个进程在同时跑一个在疯狂吃CPU做计算一个在往内存里塞数据一个在拼命读写磁盘。如果没有约束它们会互相抢资源最后谁都跑不快严重的时候整个机器直接卡死连SSH都敲不进去。Linux很早就面临这个问题。早期的做法是nice调整进程优先级但优先级只能影响CPU调度的“先后”没法对内存、IO做精细化限制。后来又有了资源限制ulimit但它只能限制单个进程管不住一组进程的总和更没法做到“控制多个进程共同占用”这种边界。Cgroup就是为了解决这类资源隔离与限制问题出现的。它可以把一组进程放进一个“控制组”里然后对这个组统一设置CPU、内存、磁盘IO、进程数、设备访问等维度的上限。打个不太严谨的比方进程是一个个员工Cgroup是部门管理制度员工可以自由干活但整个部门的预算、用电、办公面积都被框死在某个额度内。1.2 一句话总结Cgroup的工作方式Cgroup的核心机制说起来很简单内核把进程分组每个组对应一个目录目录里有各种控制文件往文件里写数字就是设限制组里的进程会立刻受到约束。比如CPU限制在Cgroup目录下有一个cpu.maxv2或cpu.cfs_quota_usv1写入50000 100000意思就是这个组内的所有进程在每100毫秒的周期内最多只能跑50毫秒的CPU时间换算下来就是用半个CPU核心。内存限制更直白把一个数字写进memory.limit_in_bytes组内进程用了超过这个上限的内存就会触发内核的内存回收或OOM严重时直接杀掉组内进程。整个机制分成两部分一个是“怎么分组”通过目录层级来体现另一个是“怎么限制”通过子系统controller和参数文件来实现。进程放进Cgroup后所有线程和子进程也会自动跟着这个组走这也是为什么容器里跑的进程、起的线程、子进程统统都被Docker给“圈”在同一个控制组里的原因。1.3 Docker为什么要拿Cgroup当底座这个问题很多人没想过。Docker号称“隔离环境”但你细想一下容器和虚拟机最大的区别在于虚拟机有完整的独立内核而容器只是宿主机上的普通进程只是Namespace做了隔离。Namespace负责“看得到什么”比如独立的hostname、独立的PID、独立的文件系统。但它管不了“能用多少资源”。一个容器里的进程发起疯狂计算如果内核不拦它它能把宿主机的所有CPU全部占满其他容器跟着遭殃。所以Docker必须在内核层面做一层资源管控这正好就是Cgroup的专长。Docker每启动一个容器就会在Cgroup文件系统下创建对应的控制组目录然后把容器内主进程的PID写进tasks或cgroup.procs之后你再设置--cpus2、--memory1g本质就是在写那些控制文件。理解了这层关系以后再看Docker资源限制相关的配置和报错思路就会清晰很多。2. 子系统与关键参数逐项拆开看2.1 了解Cgroup v1常见子系统Cgroup从诞生到现在经历了v1和v2两个大版本早期的v1里不同的资源类型被拆成多个独立的子系统每个子系统负责一种资源管控。常见的有下面这几个子系统管什么关键文件v1cpuCPU时间配额和调度权重cpu.cfs_quota_us、cpu.cfs_period_us、cpu.sharescpuacctCPU使用统计cpuacct.usagecpusetCPU核心绑定和内存节点绑定cpuset.cpus、cpuset.memsmemory内存上限、swap、OOM控制memory.limit_in_bytes、memory.swappiness、memory.oom_controlblkio块设备IO带宽限制blkio.throttle.read_bps_device、blkio.throttle.write_iops_devicedevices设备访问白名单和黑名单devices.list、devices.allowfreezer冻结一组进程freezer.statepids限制组内进程总数pids.maxnet_cls给网络包打标便于限速net_cls.classidv1的问题在于各个子系统各自为政目录结构混乱。比如同一个容器你要看CPU限制在/sys/fs/cgroup/cpu/docker/下要看内存限制又得去/sys/fs/cgroup/memory/docker/这种分散管理给运维和排查带来了不小的负担。2.2 CPU限制配额、份额与绑核CPU限制在Cgroup里有三个容易混淆的维度分别是配额、份额和绑核。配额Quota是最常用的。在v1里由cpu.cfs_period_us和cpu.cfs_quota_us两个文件配合使用前者是周期长度默认100000微秒100毫秒后者是在一个周期内这个组最多能跑多少微秒。比如配额值50000就相当于0.5个CPU。在v2里这两个文件合并成了cpu.max格式为“配额 周期”。份额Shares是另一个概念它管的是CPU忙不过来的时候怎么分。cpu.shares默认是1024数值越大在CPU竞争阶段能拿到的比例越高。注意它并不是“上限”而是“权重”。如果其他组很空闲你的组就算份额低也可以把闲置CPU用起来反过来如果所有组都打满份额高的组会分到更多时间片。绑核Cpuset是最硬的一种方式直接指定这个组只能跑在某个或某几个CPU核心上。比如在双路服务器上把容器绑到CPU核心0和1再配合NUMA节点可以有效避免跨NUMA访存带来的性能损耗。但它也牺牲了调度灵活性如果绑定的核心被其他高优先级任务占用容器性能会明显波动。实操中这三者经常一起用先拿Cpuset把容器“圈”在固定的几个核上再通过配额限制它最多用多少比例再用份额保证多个容器之间有优先级顺序。Docker里面对应的参数是--cpuset-cpus、--cpus和--cpu-shares。2.3 内存限制上限、swap和OOM内存限制比CPU更敏感因为内存一旦失控不光影响本容器还可能拖垮宿主机。Cgroup内核对内存的管控包含几个关键文件memory.limit_in_bytes是内存硬上限memory.soft_limit_in_bytes是个软上限后者不是硬性约束而是内核在内存紧张时优先回收该组的匿名页。memory.swappiness控制这个组对swap的“偏好程度”取值0到100值越小越不积极换出到swap。当你给一个Cgroup设置好memory.limit_in_bytes后组内进程如果持续申请内存超过上限内核会先尝试回收这个组自己的文件页回收不了就触发OOM。v1里有个memory.oom_control文件写0表示开启OOM Killer写1表示关闭并在超限时挂起进程。v2里对应的是memory.oom.group这是一个比v1更“合理”的设计如果组内某个进程触发OOM整个控制组内的所有进程一起被杀掉而不是只杀某一个这样能避免一个进程被杀后其他兄弟进程还在继续制造垃圾数据。Docker的--memory和--memory-swap参数换算关系也要弄明白。--memory设置的是物理内存上限--memory-swap设置的是“物理内存加swap”的总上限。比如说--memory512m --memory-swap1g意思是物理内存最多用512MBswap最多再用512MB超出这个总预算就会被干掉。如果只给--memory512m不给swapDocker默认会把swap总上限也设为物理内存的两倍等于允许容器额外使用512MB的swap。这个细节很容易被忽视生产环境里如果不想容器用swap最好显式写--memory-swap512m把swap预算压到0。2.4 磁盘IO限制与进程数限制磁盘IO限制在Cgroup v1里主要由blkio子系统承担但它的效果一直不如CPU和内存那么理想因为块设备的IO路径很长而且不同文件系统和存储设备的行为差异很大。blkio支持两种限制方式一种是限带宽一种是限IOPS。比如限制读速率的文件是blkio.throttle.read_bps_device格式是“主设备号:次设备号 每秒字节数”。Docker里面对应的命令是--device-read-bps、--device-write-bps、--device-read-iops和--device-write-iops使用前先通过lsblk看清楚设备号再操作。进程数限制方面v1的pids子系统和v2里的pids.max都挺好用。之前遇到过某容器里有个程序疯狂fork子进程直接把宿主机上的PID耗尽连新进程都起不来。后来给容器加了--pids-limit200只要进程数超过200再fork直接失败宿主机就安全了。这个参数在应对循环fork型故障时非常关键强烈建议所有容器都加上。2.5 Cgroup v2的变化以及Docker的新写法新一点的系统已经普及Cgroup v2它最大的变化是把所有的控制器统一挂载到一个/sys/fs/cgroup目录下不再像v1那样每个子系统一个目录树。CPU、内存、IO、PIDs等限制文件全放到同一个目录里看和改都方便很多。v2里CPU限制文件变成了cpu.max内存限制文件变成了memory.maxIO限制文件集中在io.max进程数还是pids.max。注意v2里没有单独的cpuacct了CPU统计功能合并进了cpu.stat。Docker 20.10之后的版本基本都支持v2容器内看到的Cgroup路径也随之变化。系统是v1还是v2通过stat -fc %T /sys/fs/cgroup/命令看返回结果如果是cgroup2fs就是v2如果是tmpfs则是v1。3. Docker里的Cgroup实操记录3.1 先确认你机器上的Cgroup版本动手之前先确认环境避免后面看的路径对不上。在一台装了较新内核的系统上我一般执行stat -fc %T /sys/fs/cgroup/如果输出cgroup2fs说明是Cgroup v2后面的实操都以v2为主但我会同时把v1的路径和参数标出来。再看一下Docker的Cgroup驱动是否与systemd匹配docker info | grep -i cgroup正常情况下会显示Cgroup Driver: systemd。如果显示的cgroupfs容器启动时会直接管理Cgroup目录这种配置在某些容器编排平台下会引发驱动不一致的冲突最好在/etc/docker/daemon.json里统一成systemd后再重启Docker。确认好环境后启动一个带限制的测试容器docker run -d --name cg_demo --cpus1 --memory512m nginx:latest然后找到这个容器对应的Cgroup路径。v2下通常在ls /sys/fs/cgroup/system.slice/docker-$(docker inspect -f {{.Id}} cg_demo).scope/你会看到一堆cpu.max、memory.max、pids.max这样的文件这就是Docker替容器创建的Cgroup控制目录。直接读取cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f {{.Id}} cg_demo).scope/cpu.max cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f {{.Id}} cg_demo).scope/memory.maxcpu.max内容一般是100000 100000对应一个CPU核心memory.max内容可能是536870912也就是512MB乘以1024再乘以1024的结果。3.2 CPU限制用压力工具验证配额参数写了实际跑起来是什么效果这里用一个常见的压力工具stress-ng来做验证。先启动一个限制为0.5核的容器docker run --rm -it --cpus0.5 ubuntu:22.04 bash容器里安装压力工具apt update apt install -y stress-ng然后在容器里跑4个CPU压力线程stress-ng --cpu 4 --timeout 10同时宿主机上开一个新终端用top观察CPU占用。正常情况下这个容器整体CPU不会超过50%。这是因为CFS配额把容器所有线程加起来的CPU时间严格限制在0.5核以内4个压力线程轮流争抢那50%的配额谁也没有能力突破。如果换成--cpus2重试容器整体CPU就会稳定接近200%也就是两个满核。这里特别说明一下Docker参数里的--cpus是十进制数内核是按微秒配额来做的--cpus0.5在v2的cpu.max里面对应50000 100000在v1的cpu.cfs_quota_us里面对应50000周期还是100000。3.3 内存限制用OOM现场理解机制内存限制的实操比CPU更容易看到“现场”因为超限就直接杀进程。继续用一个干净的Ubuntu容器限制256MB内存同时把swap也限制成和内存一样docker run --rm -it -m 256m --memory-swap256m ubuntu:22.04 bash容器里跑一个一次性占用400MB内存的程序apt update apt install -y stress-ng stress-ng --vm 1 --vm-bytes 400M --timeout 5你会看到容器里的进程直接被杀掉终端返回一个类似Killed的结果这就是Cgroup的OOM Killer在起作用。在宿主机上查看内核日志dmesg | tail -20日志里会有一行类似“Memory cgroup out of memory”的记录同时标明被杀的进程PID和名字这就是Cgroup OOM杀进程的完整现场。这个实验说明一个很重要的现象容器内存超限后宿主机本身不会直接卡死内核会在Cgroup粒度内先做回收回收不了就杀进程不会让某个容器把整个宿主机的内存耗尽。但你如果给容器设置了swap情况会复杂一些进程可以先把内存换出到swap物理内存暂时保住了但大量换出换入会导致性能急剧下降这种性能劣化比OOM杀进程还难排查。3.4 手动写Cgroup文件把参数换算吃透看完了Docker命令自动创建的Cgroup建议再手动创建一个Cgroup把机制彻底吃透。Cgroup v2下创建一个控制组非常简单mkdir /sys/fs/cgroup/test_cg创建后目录下会自动生成默认的控制文件。先设置CPU配额echo 25000 100000 /sys/fs/cgroup/test_cg/cpu.max意思就是每100毫秒周期最多跑25毫秒CPU相当于0.25核。再设置内存上限echo 134217728 /sys/fs/cgroup/test_cg/memory.max也就是128MB。接着把一个测试进程放进这个组echo $$ /sys/fs/cgroup/test_cg/cgroup.procs把当前shell的PID写进去之后这个shell以及它后续启动的所有子进程都会受这个Cgroup的约束。这个操作跟Docker把容器主进程PID写进Cgroup是同一回事。测试结束后移除控制组# 先把当前shell移出去 echo $$ /sys/fs/cgroup/cgroup.procs rmdir /sys/fs/cgroup/test_cg需要注意rmdir前如果组里还有进程操作会失败并提示Device or resource busy。这一点在实际写运维脚本时非常关键修改Cgroup前一定先把进程迁出去。4. 常见问题与排查技巧实录4.1 高频问题速查表在Docker和Cgroup打交道的过程中下面这些问题出现的频率最高我用一张表做个速查现象可能原因解决思路容器里free -m显示的内存和--memory对不上/proc/meminfo没做隔离容器看到的是宿主机内存用memory.max配合memory.current确认真实限制或借助自定义proc方案设置--cpus4但压测始终只有3.8核绑核和配额叠加或超线程核心被占用检查cpuset.cpus、cpu.max观察宿主机负载容器频繁被杀但代码没发现明显泄漏可能是swap被耗尽OOM发生在内存swap总预算上检查memory.swap.max调低或限制swap写Cgroup文件报Device or resource busy目录下有进程未迁出先迁移进程再rmdirpids.max生效了但进程还在增长fork子进程时未继承Cgroup确认进程是否通过cgroup.procs正确加入大流量并发下容器吞吐上不去IO限制影响了tmpfs或页缓存回收检查io.max限制必要时调大--device-*-bps4.2 容器里看到的内存总比limit多正常吗这是个几乎每个人都会问的问题。容器里执行free -m看到的内存总量比--memory设的值大很多就误以为限制没生效。其实这不一定是bug而是因为Docker默认没有对/proc/meminfo做隔离容器里的free读到的依然是宿主机的内存整体视图。判断限制是否生效正确的方法是直接看Cgroup文件。容器内执行cat /sys/fs/cgroup/memory.max cat /sys/fs/cgroup/memory.current如果memory.max是536870912而memory.current在这个值以内说明限制生效了只是常规工具看不到。从Cgroup视角去判断资源使用情况才是可靠的做法完全依赖容器内的top/free容易走弯路。4.3 Cgroup文件删不掉与权限受限的解决办法维护Cgroup目录时最常踩的坑就是删目录报“busy”。这通常是目录里还挂着进程。v2下要把进程迁出去可以先把当前shell的PID写回根Cgroupecho $$ /sys/fs/cgroup/cgroup.procs然后清空这个控制组里的所有进程# 如果组里还有别的进程先杀掉或迁走 for p in $(cat /sys/fs/cgroup/xxx/cgroup.procs); do echo $p /sys/fs/cgroup/cgroup.procs done再rmdir就能成功。如果是普通用户登录系统可能连建目录的权限都没有操作Cgroup需要root权限或者给用户授权对应的Cgroup子目录权限。实际生产环境一般不推荐手动去改Docker创建的Cgroup目录因为Docker自己会管理外部手动改容易造成状态不一致。4.4 用Cgroup视角排查容器CPU飙高排查容器CPU飙高时很多人一上来就docker stats看占用率高就焦虑。其实docker stats显示的CPU百分比是容器相对宿主机的整体使用率面对瞬时尖峰、多核并行、以及被其他容器抢占的情况只看这个数字很难定位问题。我自己的排查顺序是这样的先看哪个容器占用高然后进容器的Cgroup目录看cpu.stat的usage值再对照cpu.max看是否已经顶到配额。看单核还是多核跑满可以用容器内mpstat -P ALL 1确认。如果容器已经打完配额再往下看进程是CPU密集还是IO等待引起的调度放大这时候才决定是加配额还是优化代码。这套顺序的本质是先确认到底是被限制还是没被限制再决定要不要调参。很多线上问题出在“限制设得太宽导致互相干扰”而不是“限制太紧导致性能不足”。还有一个技巧如果CPU长期跑在90%以上而且Cgroup的cpu.stat里的throttled_time数值增长很快说明频繁被打断调度这时候调大配额比优化代码更立竿见影。但如果是跑批任务干脆把--cpuset-cpus绑到独立核心上减少调度噪声的影响实测对延迟敏感型任务很有效。最后再分享一个我自己的习惯给任何容器设置资源限制时我都会额外看一眼--memory-swap和--pids-limit这两个容易被忽略的参数。前者决定容器在被杀之前还能用什么代价来腾挪内存后者决定一个异常进程到底能拖出多大的雪球。这两项配好再遇到资源类故障的时候你至少不会站在宿主机旁边眼睁睁看着内存被耗尽、PID被打满。Cgroup这层东西埋在Linux内核里平时看不见摸不着但它就是撑着Docker资源隔离的那根脊梁把它搞明白了容器出了资源问题才能有方向去查。