搞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.max(v2)或cpu.cfs_quota_us(v1),写入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,之后你再设置--cpus=2、--memory=1g,本质就是在写那些控制文件。
理解了这层关系,以后再看Docker资源限制相关的配置和报错,思路就会清晰很多。
2. 子系统与关键参数逐项拆开看
2.1 了解Cgroup v1常见子系统
Cgroup从诞生到现在经历了v1和v2两个大版本,早期的v1里,不同的资源类型被拆成多个独立的子系统,每个子系统负责一种资源管控。常见的有下面这几个:
| 子系统 | 管什么 | 关键文件(v1) |
|---|---|---|
| cpu | CPU时间配额和调度权重 | cpu.cfs_quota_us、cpu.cfs_period_us、cpu.shares |
| cpuacct | CPU使用统计 | cpuacct.usage |
| cpuset | CPU核心绑定和内存节点绑定 | cpuset.cpus、cpuset.mems |
| memory | 内存上限、swap、OOM控制 | memory.limit_in_bytes、memory.swappiness、memory.oom_control |
| blkio | 块设备IO带宽限制 | blkio.throttle.read_bps_device、blkio.throttle.write_iops_device |
| devices | 设备访问白名单和黑名单 | devices.list、devices.allow |
| freezer | 冻结一组进程 | freezer.state |
| pids | 限制组内进程总数 | pids.max |
| net_cls | 给网络包打标,便于限速 | net_cls.classid |
v1的问题在于各个子系统各自为政,目录结构混乱。比如同一个容器,你要看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”的总上限。比如说--memory=512m --memory-swap=1g,意思是物理内存最多用512MB,swap最多再用512MB,超出这个总预算就会被干掉。如果只给--memory=512m不给swap,Docker默认会把swap总上限也设为物理内存的两倍,等于允许容器额外使用512MB的swap。这个细节很容易被忽视,生产环境里如果不想容器用swap,最好显式写--memory-swap=512m,把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-limit=200,只要进程数超过200,再fork直接失败,宿主机就安全了。这个参数在应对循环fork型故障时非常关键,强烈建议所有容器都加上。
2.5 Cgroup v2的变化,以及Docker的新写法
新一点的系统已经普及Cgroup v2,它最大的变化是把所有的控制器统一挂载到一个/sys/fs/cgroup目录下,不再像v1那样每个子系统一个目录树。CPU、内存、IO、PIDs等限制文件全放到同一个目录里,看和改都方便很多。
v2里CPU限制文件变成了cpu.max,内存限制文件变成了memory.max,IO限制文件集中在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 --cpus=1 --memory=512m 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 --cpus=0.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%的配额,谁也没有能力突破。
如果换成--cpus=2重试,容器整体CPU就会稳定接近200%,也就是两个满核。这里特别说明一下:Docker参数里的--cpus是十进制数,内核是按微秒配额来做的,--cpus=0.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-swap=256m 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方案 |
设置--cpus=4但压测始终只有3.8核 | 绑核和配额叠加,或超线程核心被占用 | 检查cpuset.cpus、cpu.max,观察宿主机负载 |
| 容器频繁被杀但代码没发现明显泄漏 | 可能是swap被耗尽,OOM发生在内存+swap总预算上 | 检查memory.swap.max,调低或限制swap |
写Cgroup文件报Device or resource busy | 目录下有进程未迁出 | 先迁移进程再rmdir |
pids.max生效了但进程还在增长 | fork子进程时未继承Cgroup? | 确认进程是否通过cgroup.procs正确加入 |
| 大流量并发下容器吞吐上不去 | IO限制影响了tmpfs或页缓存回收 | 检查io.max限制,必要时调大--device-*-bps |
4.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写回根Cgroup:
echo $$ > /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资源隔离的那根脊梁,把它搞明白了,容器出了资源问题才能有方向去查。