ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux cgroups详解:CPU/内存/IO资源隔离与限制实战

Linux cgroups详解:CPU/内存/IO资源隔离与限制实战 做运维这么多年我见过太多因为资源争抢搞出来的事故一台服务器上跑着十几个服务某个Java应用突然把内存吃满OOM Killer把旁边的数据库进程一剑封喉或者某个定时任务启动后CPU被打满线上接口的延迟直接从几十毫秒飙到十几秒。这些问题本质上都一样进程之间没有隔离资源是共享的谁都能抢。而cgroups就是Linux内核里解决这个问题的核心机制。它可以把进程分组并对每组进程的CPU、内存、I/O、PID等资源做精确限额既能限制下限资源保证也能限制上限资源封顶。这篇文章我会从原理讲到实战覆盖v1和v2的差异、三种常见用法、CPU/内存/磁盘IO的具体限制配置再给出一份我踩了无数次坑之后整理的排查清单和避坑心得。不管你是前端、后端还是运维只要服务器上跑着多进程、多容器这篇内容对你都有用。1. cgroups到底解决了什么问题1.1 没有resource control的日子在没有cgroups的时代Linux对进程的管理基本是靠nice值、ulimit这些零散手段。nice只能影响CPU调度的优先级没法限制内存占用ulimit可以限制单进程的资源但对于进程组、父子进程嵌套的场景无能为力。更致命的是这些手段都是单进程视角根本没法应对真实的业务场景——现实中你遇到的往往是一组进程协同干活比如Java服务的主进程加一堆子线程或者Nginx的worker进程再或者K8s里的一个Pod跑了多个容器。如果只能单进程限制父进程fork出来的子进程很容易绕开限制资源照样被打爆。容器化普及以后这个问题变得尤其尖锐。一个物理机上可能跑了几十个容器每个容器都有自己的进程树如果不做资源隔离一个容器发生内存泄漏可能会拖垮整台宿主机的所有业务。这时候就需要一个内核级的机制能按组来管资源而不是按单进程来管。cgroupsControl Groups就是这样诞生的。1.2 cgroups的核心设计思想cgroups的设计思路其实很朴素就是把进程按需求分组然后在组层面做资源配额、计量和限制。它有四个核心概念我必须先讲清楚后面所有操作都绕不开cgroup一个资源控制组它对应一组按某种规则归类的进程。你可以把它理解成资源盒子凡是放进这个盒子的进程都要遵守盒子的限制规则。subsystem子系统/控制器表示一个具体的资源控制维度。比如cpu管CPU时间分配、memory管内存使用量、blkio管块设备I/O、pids管进程数量。每个子系统的限制逻辑相互独立。hierarchy层级树cgroup可以组织成树状结构父节点可以包含子节点子节点可以继承父节点的限制也可以细化成更严的限制。这个树结构直接把分组管理从单层扩展成了多层。task任务在cgroups语境里一个task就是一个进程。往cgroup里写入PID就是把这个进程交给该组管理。你光记概念没用要理解cgroups到底牛在哪得对比一下传统方式ulimit是按单进程设的规则进程一fork就失效cgroups是按进程组设的规则只要进程还属于这个组规则就一直生效包括它后续fork出的所有子进程。这是本质区别。用生活化的比喻来说ulimit像是给一个快递员设置了隔天取件上限而cgroups是给整个站点设置了每日吞吐上限站点里无论是哪个快递员来取件、来了多少人总量都被站点的配额卡住。2. v1与v2怎么选区别在哪2.1 从v1到v2的关键变化说到cgroups绕不开版本问题。现在主流发行版Ubuntu 22.04、Debian 11、RHEL/CentOS 8默认用的都是cgroups v2但你线上肯定还能见到不少跑v1的老机器特别是用了老版本Docker或者沿用旧内核的节点。这两个版本的区别不只是实现细节而是整体架构的变化。v1最大的问题是一个进程可以属于多个hierarchy每个子系统各挂一棵独立的树。比如memory挂一棵树cpu挂另一棵树一个进程可以同时在这两棵树里出现。这个设计在当时看挺灵活但实际用起来管理逻辑混乱你想把一个进程绑定到某个cgroup得在不同hierarchy里分别操作而且多个hierarchy之间没法做统一的协调控制比如一个树分走了一半CPU而另一棵树没做任何限制资源竞争依然存在。更坑的是v1里面某些子系统还会产生令人迷惑的交互问题比如cpuacct和cpu明明是两回事却经常被混用。v2对这些问题做了大刀阔斧的改造。它强制要求所有控制器挂在统一的hierarchy下形成一个单一的树状结构。进程只会被放进这个统一的树里的某个节点所有控制器都对这个节点生效。这样管理上就清晰多了你创建一个cgroup然后往里面写入进程PID再分别配置cpu.max、memory.max、io.max一切都在同一棵树上完成。v2还有一个大改动是内存回收语义的变化。v1里memory.oom_control的作用在某些情况下不会很好地保护系统级内存而v2加入了memory.oom.group这种按组结算OOM的方式以及memory.reclaim手动回收接口对容器化场景更友好。同时v2取消了blkio控制器改名为io并且新增了对PID控制器pids纳入统一树。如果你要跑Kubernetes、Podman这些新容器运行时它们默认就建立在v2之上。我在实际操作中也确实感受到v2的命令行路径和内核接口都更规整排查问题的时候不用再像以前那样在/sys/fs/cgroup/memory/...和/sys/fs/cgroup/cpu/...之间来回横跳。2.2 如何确认当前系统用的是哪个版本动手前先搞清楚你的系统跑的是v1还是v2否则配置路径全对不上白忙一场。最简单的方法就是看挂载信息mount | grep cgroup如果你看到的结果是类似cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate)那说明是cgroups v2。如果看到的是cgroup on /sys/fs/cgroup type cgroup (rw,nosuid,nodev,noexec,relatime) cgroup on /sys/fs/cgroup/blkio type cgroup (rw,...) cgroup on /sys/fs/cgroup/cpu type cgroup (rw,...) cgroup on /sys/fs/cgroup/memory type cgroup (rw,...)一堆按子系统挂载的目录那就是v1。也可以看内核参数cgroup_no_v1或者直接执行stat -fc %T /sys/fs/cgroup输出cgroup2fs就是v2tmpfs可能就是v1。还有一个非常关键的动作检查当前是否有软件在强制使用v1。比如老版本的Docker、LXC它们启动时会尝试挂载v1的子系统目录这会导致系统把v2降级成v1。如果开了v2但Docker版本太老19.03之前的版本对v2支持很差你的宿主机可能处于一种名义上v2实际被hack到v1的半吊子状态这时候你就得升级Docker或者配置/etc/docker/daemon.json里的exec-opts: [native.cgroupdriversystemd]。总之先确认版本再开始这是我这几年总结出来的第一条经验。3. 上手实操cgroups的三种使用姿势3.1 直接操作sysfs挂载点cgroups其实并不复杂它的所有配置都暴露在/sys/fs/cgroup目录下以虚拟文件的形式存在。这意味着你可以像操作普通文件一样操作cgroup不需要任何额外工具。先看v2的目录结构ls /sys/fs/cgroup/你会看到一堆cgroup.开头的文件比如cgroup.controllers、cgroup.max.descendants、cpu.max、memory.max、io.max等。这些就是控制器的接口文件。创建一个cgroup非常简单mkdir /sys/fs/cgroup/demo目录创建好后进入该目录你会发现自动生成了cpu.max、memory.max、pids.max等控制文件。这其实是因为v2的subtree control机制——父cgroup这里是root里配置了cgroup.subtree_control允许子cgroup创建这些控制器文件。如果某个控制器文件没出现多半是cgroup.subtree_control没启用需要先启用。举个例子要启用CPU和内存控制器在root下执行echo cpu memory /sys/fs/cgroup/cgroup.subtree_control注意这个目录结构root cgroup默认是自动启用的控制器但你想要子cgroup拥有这些控制器必须先在这个父cgroup里开启对应权限。这是v2和v1很不一样的地方v1默认所有子系统都存在且可用v2则需要一层层声明。虽然是为了安全设计但确实容易让人卡住。创建一个限制内存的cgroupmkdir /sys/fs/cgroup/demo echo 100M /sys/fs/cgroup/demo/memory.max echo 12345 /sys/fs/cgroup/demo/cgroup.procs第一行创建组第二行设置内存上限为100MB第三行把PID为12345的进程放进这个组。就这么简单。如果你想测试效果可以写个脚本疯狂申请内存然后观察/sys/fs/cgroup/demo/memory.current的变化到100MB时进程会被OOM Kill或者进入回收流程取决于memory.oom.group配置。但直接操作sysfs有两个痛点一是忘记启用控制器时命令会静默失败非常坑二是路径和参数全靠记特殊符号、层级嵌套容易搞错。所以实际工作中我一般会配合工具来用但掌握了裸操作能帮你理解工具到底在背后做了什么。3.2 使用cgroup-tools管理如果你用的是Debian/Ubuntu系直接装一套libcgroup工具能省不少事apt install cgroup-tools这里有几个命令我日常最常用lscgroup查看当前cgroup的层级结构。cgcreate创建cgroup。cgset设置cgroup参数。cgclassify把进程移入cgroup。cgdelete删除cgroup。cgexec直接在指定cgroup下执行命令。比如创建一个包含CPU和内存控制器的组并设置内存上限cgcreate -g cpu,memory:testgroup cgset -r memory.max200M testgroup cgexec -g memory:testgroup ./my_app这比自己直接mkdir写裸路径要稳得多cgcreate会自动按当前系统的v2规范建好所有目录结构启用正确的控制器。还有一点cgset支持你用memory.limit_in_bytes这种旧命名来设置v2参数吗其实新版工具已经适配了v2命名直接写memory.max就行。老命令在v2下有些参数名要对应转换比如memory.limit_in_bytes对应memory.maxcpu.shares对应cpu.weight搞混了工具会直接报错。建议用cgget去看看实际支持的参数名。当你需要临时把一个正在运行的进程挪到某组里cgclassify很好使cgclassify -g cpu,memory:testgroup 12345这比手动改cgroup.procs更不容易出错因为它会自动处理进程线程组和权限校验。我平时在排查线上问题时经常用这个命令把可疑进程临时关进监控组里限制住它的资源占用等确认了再决定处理方案。3.3 配合systemd做资源控制现代Linux发行版里systemd已经深度集成了cgroups。所有systemd管理的服务都自动分配独立的cgroup路径一般是/sys/fs/cgroup/system.slice/你的服务名.service。这意味着你可以通过systemd的unit配置来设置资源限制而不必手动管理底层cgroup文件。这样配置一个服务限制内存和CPU[Service] MemoryMax512M MemoryHigh400M CPUQuota50% TasksMax100MemoryMax对应cgroups v2的memory.maxMemoryHigh对应memory.high软限制超过后系统会给回收压力CPUQuota50%表示最多占用单核的50%时间。TasksMax对应pids.max限制进程树总PID数量。配置生效后直接重启服务就行。systemd会把对应的限制写到服务自己的cgroup里而且它跟配置管理、依赖关系都打通了比手工操作sysfs更适合生产环境。如果你在写Init脚本或者Docker的--cgroup-parent配置配合systemd一起用也是最不容易出错的方式。这里面有个值得注意的地方systemd的unit配置文件和cgroups的底层参数并不是一一对应的systemd会选择CGroup类型对应的控制器。比如CPUWeight对应cpu.weight它其实是一个相对权重值默认是100你给一个服务设为200那它在同等CPU竞争时能获得的CPU时间大约是默认服务的两倍而不是一个绝对百分比。这点和CPUQuota完全不同别搞混了。短任务、突发型负载适合用CPUWeight长期高消耗的服务适合用CPUQuota或CPUQuotaPeriodSec配合控制。4. 实战场景CPU、内存、I/O限制4.1 CPU限制按时间片还是按权重CPU资源的限制在cgroups v2里主要涉及三个文件cpu.max、cpu.weight和cpuset.cpus。这三个文件控制的维度不一样很多人第一次接触时就懵了我把它们拆开讲。cpu.max是一个硬性配额格式是quota period。比如echo 50000 100000 /sys/fs/cgroup/demo/cpu.max表示在100000微秒100ms的周期内最多分配50000微秒50ms给该cgroup也就是使用50%的单核CPU。如果你想限制为2个核心那就是200000 100000。注意这个配额不是单核而是虚拟CPU时间所以200000表示最多使用2核的计算量。这个设置适合明确限定服务不能超过多少CPU的场景比如一台机器上跑着多个大计算任务你要保证它们平均分。cpu.weight则不是限制上限而是设置优先级。它的默认值是100取值范围1到10000。如果两个cgroup同时争抢CPU权重为200的组获得CPU时间大约是权重为100的组的两倍。这个和docker的--cpu-shares对应不过v1里叫cpu.sharesv2里叫cpu.weight换算关系是weight 1 ((shares - 2) * 9999) / 262142所以基本别指望直接在两者之间无损换算。它不设上限只在竞争时体现比例适合做服务质量优先级分配。cpuset.cpus用来绑定物理核心例如echo 0-1 /sys/fs/cgroup/demo/cpuset.cpus表示只能使用CPU 0和1。这个比较适合需要锁核的延迟敏感型服务比如DPDK、部分高性能数据库。但要注意一旦用cpuset.cpus锁核该组内进程就只会在这些核上调度如果这些核被其他任务占满即使别的核闲着也没用反而会造成资源浪费。我在生产环境遇过有人图省事把所有服务都绑到0号和1号核结果0、1号核跑得气喘吁吁其余核心全在摸鱼。所以cpuset是限制手段不是常规负载均衡手段慎用。实际操作中还有一个比较新鲜但很有用的文件叫cpu.max.burst部分内核版本可能没有它允许在配额之外额外借用一定量的CPU时间只要长期平均值不超过配额即可。配置后可以有效应对突发流量又不会让服务长期白嫖资源。4.2 内存限制回收、软限制和OOM控制内存是生产环境最容易失控的资源也是cgroups最值得用的地方。v2的内存控制器有四个关键文件memory.max、memory.high、memory.low、memory.oom.group。memory.max是硬限制单位可以是字节数字也可以用K、M、G后缀。比如echo 1G /sys/fs/cgroup/demo/memory.max当该组内存超过1G时内核会触发回收如果回收不了就会触发OOM Kill组内进程。注意这里的内存通常指anonfile缓存等和free -m看到的物理内存不完全等价。文件缓存其实是可以回收的所以有时候你看到组内memory.current超过了memory.max但没有立刻被OOM可能只是还来得及通过回收page cache来缓解。memory.high则是软限制一般设置为比memory.max低15%~20%比较好。当内存超过memory.high时内核会加大回收力度但不会马上OOM Kill给进程一个缩水的机会。这样能防止服务突然飙高内存而直接被杀掉。我在给Java服务设置cgroup时通常会把memory.high设为JVM最大堆的110%左右memory.max设为JVM最大堆的130%左右这样既给GC留空间又能兜底。再看memory.low它和memory.high相反它是保证值。当系统内存紧张时内存使用量低于memory.low的cgroup有更高优先级被保留系统会优先回收其他组的内存而不是它们。这个用来保护关键服务很有用。比如我把核心数据库的cgroup设个memory.low为4G那即使其他服务在抢内存内核也倾向于先回收它们的page cache而不会轻易动数据库的内存页。memory.oom.group是个v2特有的开关默认是0。如果设为1当组内某个进程触发OOM时内核会把整个cgroup的所有进程都杀掉而不是只杀罪魁祸首。这个行为太重要了。举个例子你有10个worker进程其中一个内存泄漏如果不开启oom.group内核只会杀掉那个泄漏的进程然后容器/服务继续以9个worker工作状态反而更诡异如果开启后一旦触发OOM整个组直接重启保证服务状态的确定性。K8s里很多运行时默认就是开启这个行为的目的就是为了让pod整体重启而不是半死不活。内存限制我踩过一个很典型的坑echo 500M memory.max结果进程还是被系统OOM Kill了查日志发现是cgroup内没有配置memory.swap.max。v2默认允许cgroup使用swap一旦物理内存不够进程会大量使用swap结果整台机器被拖到swap风暴。正确做法是把swap限制也设置好比如与内存相同或设为0echo 500M /sys/fs/cgroup/demo/memory.swap.max或者直接禁用该组的swapecho 0 /sys/fs/cgroup/demo/memory.swap.max4.3 磁盘I/O限制与PSI监控磁盘I/O的资源控制在v1时代是blkio在v2里改成了io控制器。使用前要先确认你的内核开启了io控制器cat /sys/fs/cgroup/cgroup.controllers如果有io就可以开始配置。v2的IO限制分两类io.max硬限制和io.weight权重。io.max的格式是echo 8:0 rbps1048576 wbps1048576 riops128 wiops128 /sys/fs/cgroup/demo/io.max其中8:0是磁盘设备号可以用lsblk查。rbps/wbps是读写速率限制字节/秒riops/wiops是每秒读写次数限制。设置后该组内的进程的块设备I/O就会被卡住。这个限制主要针对直接读写块设备、经过page cache的流量还需要另外看因为不是所有I/O都会经过块层限制。io.weight和CPU的weight类似是相对权重值默认100。它只在多个cgroup竞争时有效比如大量读操作同时发生时高权重的组能获得更多的I/O带宽。但由于现代存储设备NVMe SSD性能极高很多情况下I/O根本到瓶颈权重的意义就没那么明显。我建议先把io.max设好再用io.weight做精细化调节。除了限制监控也非常重要。Linux 4.15内核提供的PSIPressure Stall Information能帮助你判断资源是不是已经到了压力点。看看这个cat /sys/fs/cgroup/demo/cpu.pressure cat /sys/fs/cgroup/demo/memory.pressure cat /sys/fs/cgroup/demo/io.pressure输出类似some avg101.23 avg600.54 avg3000.21 total12345678 full avg100.10 avg600.05 avg3000.02 total1234567some表示至少有一个任务因为资源瓶颈而阻塞的时间占比full表示所有任务都被阻塞的时间占比。如果full很高说明该cgroup的资源严重不足需要扩配额或者迁移如果some高但full低说明资源有一定争抢但还能推进。这是我在判断该不该扩容时最直接的内核依据比盲目看监控面板的负载值靠谱得多。我实际中遇到过一个问题cgroup里配置了io.max但用fio测速发现没生效。查了半天发现是内核参数CONFIG_BLK_CGROUP_IOCOST没开或者说v2的io控制器需要bfq/mq-deadline这类I/O调度器配合。最新内核通常默认支持但老内核和特定块设备驱动下io.max的限制粒度会变粗。如果你发现配置不生效先用cat /sys/block/sda/queue/scheduler看下当前调度器换成none或mq-deadline试试。5. 监控与排查怎么知道限制生效了5.1 实时监控systemd-cgtop和更多命令配置好cgroups之后你不会马上观察到效果除非你实时监控。我用得最多的工具是systemd-cgtop它能按cgroup分组显示CPU、内存、I/O的实时消耗。效果有点像top但观察维度是cgroup而不是进程。systemd-cgtop输出会按Control Group路径、CPU%、Memory、Tasks排列。当你想快速知道某个服务的资源超没超直接看对应的system.slice/xxx.service那一行即可。对于systemd管理的服务来说这个命令比在top里翻来翻去找PID高效多了。如果要更细的进程级数据就用ps配合cgroup信息ps -eo pid,cgroup,comm | grep testgroup这会显示每个进程所属的cgroup路径。我在排查进程是不是真的放进了该cgroup时用这个命令比打开/sys/fs/cgroup/demo/cgroup.procs更快。除了这两个通用命令还要学会直接读cgroup统计文件。常用的四个memory.current当前内存用量。memory.stat详细统计包括page cache、anon、swap、kernel stack等。cpu.statusage_usec表示累计CPU时间user_usec和system_usec区隔用户态和内核态。io.stat每个设备的I/O统计。我一般会写个简单的shell脚本循环读取这些值记录到本地日志然后和业务监控对比。比如跑一个大计算任务时定时输出cpu.stat和memory.current确认限制确实在起作用。5.2 常见问题排查速查表这里列几个我在实际生产环境里反复遇到的问题以及对应的排查思路。整理成一张速查表够你应对90%的cgroups异常。现象可能原因排查/解决办法配置了memory.max但进程没被限制该cgroup没有enable内存控制器或者进程不在该组内检查cgroup.controllers是否包含memory检查cgroup.procs里的PID修改memory.max后立即报Invalid argument新值小于当前已使用内存先观察memory.current再设置一个大于当前使用量的值服务启动失败日志报cgroup: cannot create cgroupsystemd或容器没有相应控制器的权限查看服务unit配置确认Delegateyes或容器runtime的--cgroupns模式cpu.max设置50%但服务总是超配额是按单核计算的可能服务本身就是多线程确认cpu.max是每100ms的配额多核场景改成N*100000使用v1命令配置v2报错参数名不一致v1的memory.limit_in_bytes对应v2的memory.maxcpu.shares对应cpu.weight临时把进程移入cgroup进程还在跑但限制没生效父进程还在原cgroup子进程在移出的瞬间又被拉回去了把整个进程树都移进去或者用cgexec直接启动新进程使用systemd-cgtop看到某服务内存为0systemd服务没有使用cgroup v2的内存控制器确认服务unit文件中MemoryMax确实配置了且内核开启了cgroup v25.3 两大坑权限层级与OOM精度第一个坑v2严格限制了cgroup的层级深度和尾部使用。每个父cgroup可以通过cgroup.max.depth和cgroup.max.descendants控制子cgroup的最大层级和数量。默认值允许较深但如果某个中间层达到了上限你再往下创建目录就会报错。我在做多租户隔离时见过这种情况每个租户一个cgroup租户下面又挂了若干服务cgroup嵌套多了之后突然mkdir失败日志显示Max descendants reached。解法是调高上一级cgroup的cgroup.max.descendants或者调整层级设计别过度嵌套。第二个坑OOM Killer的精度问题。在v2下cgroup的OOM行为虽然比v1可控但你还是得注意memory.oom.group这个开关可能带来的突发全组击杀。特别是有持久性连接的长任务服务比如WebSocket服务器或者消息队列消费端一旦整个组被杀掉连接全部断掉业务影响面比单进程被杀大得多。我遇到过一次Kafka消费者组整个cgroup被OOM Kill直接导致消息堆积和集群分区再均衡。后来我把memory.high调低让它更早触发回收和背压而不是等到物理内存耗尽才让oom.group生效。这个经验值得记下来。6. 我个人的一些实操心得从我自己的运维经验来看cgroups最有价值的用法并不是卡死某个任务的资源而是做到有节奏地分配。第一永远不要在生产环境一开始就上极端限制。我建议先在测试环境用一个小任务跑一遍设置一个比正常峰值高20%的限制观察业务表现和cgroup的memory.pressure/cpu.pressure数据。如果压力数据飙升说明限制太紧了。宁可多花一小时做基线评估也不要上线后因为限制太狠把核心业务杀掉。第二cgroups和systemd是密切配合的很多生产问题其实出在systemd服务配置和cgroup控制器不匹配上。比如你写了一个unit文件配置了MemoryMax但忘了设置MemoryAccountingyes那systemd就不会把内存用量计入cgroup统计systemd-cgtop里看到的数值就是0。这类问题不算cgroups本身的锅但调试时很容易被误导建议在配置服务时顺带把CPUAccountingyes、MemoryAccountingyes、IOAccountingyes全部打开这样才能让数据真正反馈到cgroup层。第三维护一个cgroups配置基线特别重要。我在团队里建了一个简单的模板里面整理了我们所有核心服务的资源配比数据库用cpu.max硬限制memory.low保护缓存服务用cpu.weight做优先级消息队列用memory.high前移压力日志任务用io.max限制带宽。这套模板虽然简单但是大大减少了新服务随意分配资源导致的线上问题。最后再分享一个小技巧当你通过cgexec或直接写入cgroup.procs把进程移入某cgroup之后哪怕进程挂了只要这个cgroup目录存在它后面fork出的进程也会落到这个组里。这既是坑容易误伤后续进程也是宝可以做类似一次性沙箱的效果。我在排查问题时用过一个很实用的办法创建临时cgroup把可疑进程放进去观察cpu.pressure和memory.pressure没问题再把它移回默认组全程不影响业务。这个临时拘留的操作比直接kill进程稳妥得多推荐给你试试。
返回列表