
接手过 Linux 服务器的人大概率都遇到过这种场景数据库、前端、日志采集、监控 Agent 挤在同一台机器上某个模块突然来一个死循环或者内存泄漏整台机器跟着遭殃。早年的解法很粗暴——top看一眼找出那个不听话的进程直接kill。但这是治标不治本同一个错误明天还会再来一次。真正的解法是提前给每个模块划好边界让它再疯也影响不到邻居。这个边界的实现就是 Linux 内核里的 cgroups 机制。它和 namespace 一起构成了 Docker、Kubernetes 资源隔离的底座。如果你只知道“容器能限制资源”却说不清原理或者写过docker run --memory却不知道背后发生了什么这篇实战笔记应该能帮你把这块空白补上。下面我直接从实际运维的角度出发带你手动体验 cgroups 限制 CPU、内存的完整过程从传统的 v1 讲到现代的 v2最后落到 systemd 和容器场景里的日常玩法上。1. cgroups 到底是什么一台机器上的“资源分配器”1.1 从“杀进程”到“划边界”先想一个问题一台 64 核的服务器上同时跑着订单接口、定时任务、日志上报、监控采集。它们共享同一套 CPU、内存、磁盘 IO谁也没签协议说“我只用 10%”。当某个服务突发流量暴涨时如果操作系统不加干预它就会抢光资源其他服务全部排队最后你只能靠 SSH 进去手动杀进程。cgroups 解决的就是这个问题。它的全称是 control groups中文叫“控制组”从字面上理解就是“把进程分组对每一组做资源控制”。内核会为每个组维护一套资源使用统计并在组内进程试图超过配额时介入——要么限流要么回收内存要么直接杀死超额进程。管理员不用再关心单个进程在做什么只需要指定“这个组最高能用多少资源”。用合租房的例子来类比没有 cgroups 时路由器大家抢谁开视频谁占满带宽有 cgroups 之后相当于给每个租户限了速谁也别想独占谁也饿不死。这也是云厂商做“按量计费”和多租户隔离的基础——没有 cgroups 那一套限额逻辑云主机根本没法安全地把一台大机器切给几十个用户用。1.2 从 v1 到 v2一条走了十五年的演进路线如果你翻看 Linux 内核的历史cgroups 最早由 Google 的工程师在 2006 年前后提出2007 年合入内核 2.6.24。但早年那个实现后来被称为 cgroups v1有个非常别扭的设计每个资源控制器各自挂载一个独立的层级。什么意思比如 CPU 控制器挂载在/sys/fs/cgroup/cpu/内存控制器挂载在/sys/fs/cgroup/memory/blkio 挂载在/sys/fs/cgroup/blkio/。一个进程可以分别属于 CPU 层级里的组 A、内存层级里的组 B、blkio 层级里的组 C。这样一个进程在三个目录里各有一条记录管理起来很混乱而且不同控制器之间无法做协同限制——你说不清“这个容器一共用了多少 CPU 多少内存”整体是什么状态。2016 年内核 4.5 合入了 cgroups v2彻底换了一套思路所有控制器统一挂载在一个目录树下同一个进程只能属于同一个 cgroup控制器的启用与停用通过目录树上的cgroup.subtree_control文件动态控制。这样一来层级关系清晰了资源统计也统一了还引入了“内部进程约束”一个启用子层级的目录不能再直接跑进程等更严格的行为约定。现在主流发行版Ubuntu 22.04、Debian 12、RHEL 9、较新的 CentOS Stream都已经默认使用 cgroups v2但大量存量环境尤其是老版本系统上v1 依然存在。所以我后面会分两章分别讲读者在实操前先判断自己机器的版本。1.3 三个必须记住的基础概念要动手先认清三个词cgroup控制组一个目录、控制器controller负责限制某类资源比如 CPU、内存、pids、层级hierarchy由目录树组织起来的管理结构。在 Linux 上这一切都通过虚拟文件系统暴露位置固定在/sys/fs/cgroup/。通过下面两条命令可以立刻确认当前机器用的是 v1 还是 v2cat /proc/cgroups # 列出了内核编译的所有控制器 mount | grep cgroup # 看到 /sys/fs/cgroup 的挂载方式如果你执行cat /sys/fs/cgroup/cgroup.controllers能看到控制器列表并且/sys/fs/cgroup/下面直接是cgroup.controllers、cgroup.subtree_control这类文件那就是 v2。如果/sys/fs/cgroup/下面是一堆cpu/、memory/、blkio/目录并且每个目录里都有各自的控制文件那就是 v1 布局。这个判断很重要因为后面所有命令都依赖它。下面的实操我分两条路线讲。提示以下操作都需要 root 权限。建议在一台专用的测试机或虚拟机里做不要在现网业务机上直接试否则一旦把系统关键进程错误地移入限制组可能会把自己锁死在外面。2. cgroups v1 实战手动创建 cgroup 限制 CPU 与内存2.1 第 0 步确认 v1 环境并准备压力工具在传统 v1 环境下/sys/fs/cgroup/下应该能看到cpu/、cpuacct/、memory/、blkio/等目录。每个目录对应一个控制器目录里就是控制文件。为了做实验先安装一个常见的压测工具stress-ng在 Ubuntu/Debian 上直接apt install stress-ng在 RHEL 系用yum install stress-ng。然后启动两个吃 CPU 的进程stress-ng -c 2 -t 600 这是开两个 CPU 密集型的子进程持续跑 10 分钟。此时top或者pidstat看两个进程会各自吃掉一个核整个系统 CPU 使用率会很高。记住其中一个进程的 PID后面我们要把它关进“笼子”里。2.2 创建 CPU 限制组并把进程移进去现在动手创建第一个 cgroup。传统 v1 向/sys/fs/cgroup/cpu/目录下建一个子目录内核会自动生成一堆控制文件mkdir /sys/fs/cgroup/cpu/cpu_limit_test ls /sys/fs/cgroup/cpu/cpu_limit_test你会看到cpu.cfs_period_us、cpu.cfs_quota_us、cpu.shares、tasks等文件。cfs_period和cfs_quota组合起来就是内核的 CFS 调度器用来做配额限制的核心参数。理解这两个参数是理解整个 CPU 限制的关键cpu.cfs_period_us统计周期单位是微秒默认值 100000即 100ms。cpu.cfs_quota_us在一个周期内该 cgroup 内的进程最多可以运行多少微秒。默认 -1表示不限制。计算公式很简单CPU 上限 cfs_quota_us / cfs_period_us × 100%。如果配额是 50000周期是 100000那么每组最多跑 50%也就是半个核如果配额是 200000那就是最多 200%即两个核的算力。现在把上面那个 stress 进程的 PID 写进去先限制到 50% 看看效果echo 50000 /sys/fs/cgroup/cpu/cpu_limit_test/cpu.cfs_quota_us echo 100000 /sys/fs/cgroup/cpu/cpu_limit_test/cpu.cfs_period_us echo 12345 /sys/fs/cgroup/cpu/cpu_limit_test/tasks # 换成真实PID写入tasks文件就是把该进程或线程加入这个 cgroup。此时再用top观察会发现原来 100% 的 CPU 占用被压到了 50% 左右。进程还在正常跑但速度明显变慢——因为它每隔 100ms 只能运行 50ms。如果想让限制对进程创建的所有子进程也生效记住一个关键原则子进程会继承父进程所属的 cgroup。所以正确流程是先把父进程放进限制组再让它去 fork 子进程反过来如果子进程已经跑起来了再把父进程写进去子进程并不会自动跟着迁移。2.3 内存限制、OOM 与观察统计接下来做内存限制。v1 的内存控制器目录是/sys/fs/cgroup/memory/创建子目录后我们重点关注三个文件memory.limit_in_bytes硬上限单位字节。memory.usage_in_bytes当前用量。memory.failcnt触发超过限制的次数。memory.swappiness该组内页交换的倾向0 表示尽量不换出。先给一个进程限制 128MBmkdir /sys/fs/cgroup/memory/mem_limit_test echo $((128 * 1024 * 1024)) /sys/fs/cgroup/memory/mem_limit_test/memory.limit_in_bytes echo 12345 /sys/fs/cgroup/memory/mem_limit_test/tasks # 换成真实PID这里我特意用$((128 * 1024 * 1024))直接算字节数而不是写128M。虽然 v1 的内核支持K/M/G后缀但脚本里写完整字节数更不容易踩坑也方便后面做统计对比。然后让这个进程去申请更多内存比如用stress-ng分配 300MBstress-ng --vm 1 --vm-bytes 300M -t 60 此时观察两个地方。第一cat /sys/fs/cgroup/memory/mem_limit_test/memory.failcnt会发现计数开始增长说明内核尝试回收但失败了。第二dmesg里会出现类似这样的内核日志Memory cgroup out of memory: Killed process 12345 (stress-ng) total-vm:... anon-rss:...这里有个新手容易困惑的点OOM 只在进程尝试分配新内存时触发。如果进程已经把内存攥在手里内核不会立刻把旧页赶走而是先回收、再失败、最后杀进程。所以你看到usage_in_bytes会先顶到 128MB 附近过一会才出现一个进程被杀的记录。另外usage_in_bytes统计的是整个 cgroup 的总内存消耗包含进程的匿名内存和文件页缓存。你可能会发现 cgroup 内存用量很高但ps里每个进程的 RSS 都不大——那多出来的多半是读文件产生的 page cache不是内存泄漏。v1 模式下还强烈建议确认一个文件cat /sys/fs/cgroup/memory/mem_limit_test/memory.use_hierarchy如果这里是 0说明子 cgroup 的内存限制不会与父级累加统计嵌套限制可能失效。设为 1 可以保证整个子树的用量都被计入echo 1 /sys/fs/cgroup/memory/mem_limit_test/memory.use_hierarchy2.4 更多控制器速览与使用场景除了 CPU 和内存v1 还有几个常用控制器值得知道我把它们整理成了对照表控制器关键文件作用核心场景cpucpu.cfs_quota_us、cpu.shares限制 CPU 时间配额或按权重分配 CPU防止某服务吃满 CPUcpusetcpuset.cpus、cpuset.mems将进程绑定到指定 CPU 核心和内存节点低延迟业务核绑定、NUMA 优化memorymemory.limit_in_bytes、memory.usage_in_bytes限制内存用量触发 OOM防内存泄漏拖垮整机blkioblkio.weight、blkio.throttle.read_bps_device限制磁盘 IO 吞吐与权重多服务共享磁盘时防 IO 抢占pidspids.max限制 cgroup 内进程/线程总数防 fork 炸弹限制异常进程膨胀freezerfreezer.state暂停/恢复 cgroup 内所有进程容器 pause 操作的核心机制重点提一下cpu.shares和cpu.cfs_quota_us的区别很多人混为一谈。cfs_quota是“硬上限”到了就是不允许再跑cpu.shares是“相对权重”默认 1024表示 CPU 争抢时的优先级。比如容器 A 设 1024容器 B 设 2048那么当两个容器都在满负荷跑时A 只能拿到约 1/3 的 CPUB 拿到约 2/3但如果 B 空闲A 可以趁机用满整机 CPU——shares 不会限制峰值。实际生产中处理突发流量通常建议用cfs_quota定死上限用shares保障基础优先级。这段 v1 的实操看起来是老古董但很多老系统还在用它而且理解 v1 的控制文件命名能帮你更容易看懂 v2 里那些缩写文件是从哪儿来的。下一章进入现代默认的 v2。3. cgroups v2 上手统一层级下的资源管理实践3.1 v2 到底改了什么先看目录与文件在内核支持 v2 且 systemd 统一管理的机器上/sys/fs/cgroup/目录下直接就是cgroup.controllers、cgroup.subtree_control、cgroup.procs这样的文件不再有cpu/、memory/等子目录。先看一眼cat /sys/fs/cgroup/cgroup.controllers输出类似cpuset cpu io memory hugetlb pids rdma表示内核编译了哪些控制器。但需要注意控制器可用和控制器启用是两回事。要让一个控制器在某层目录生效必须向该目录的cgroup.subtree_control文件写入控制器名。这个“启用”动作也是 v2 与 v1 最大的操作差异。在 v1 里你直接进目录改文件就行在 v2 里你得先决定“我要在这一层开放哪些控制器”然后它们才会出现在子目录中。这样做的目的是把控制器的开启权限集中管理避免任何进程随便建个目录就能偷资源。3.2 手动配置 v2 的完整步骤下面我演示一套完整的操作创建一个demo组限制它使用 80% CPU、256MB 内存并观察实际表现。首先在根层级启用 CPU 和内存控制器echo cpu memory /sys/fs/cgroup/cgroup.subtree_control一次可以写多个控制器用空格分隔。写完再查看/sys/fs/cgroup/cgroup.controllers下面的子目录时demo目录里就会出现cpu.max、cpu.weight、memory.max、memory.high等文件。创建demo目录并配置参数mkdir /sys/fs/cgroup/demo # CPU总量限制在 80% echo 80000 100000 /sys/fs/cgroup/demo/cpu.max # 内存硬限制 256MB echo $((256 * 1024 * 1024)) /sys/fs/cgroup/demo/memory.max # 内存软限制 128MB echo $((128 * 1024 * 1024)) /sys/fs/cgroup/demo/memory.highcpu.max的格式是“配额 周期”和 v1 的cfs_quota/cfs_period本质一样只是合并到一个文件里了。80000 100000就是 80% CPU。memory.max是硬上限超过后内核会回收内存回收不了就直接 OOM。memory.high是软上限超过之后进程会发生节流throttle被限制分配内存的速度但不会立刻杀掉给缓冲时间。可以这么记high是提醒max是警察。接着把一个压力进程放进去。先在外部启动一个吃内存的进程再把它写入demo的cgroup.procsstress-ng --vm 2 --vm-bytes 400M -t 120 echo $! /tmp/vm.pid echo $(cat /tmp/vm.pid) /sys/fs/cgroup/demo/cgroup.procs十几个毫秒之后看两个统计文件cat /sys/fs/cgroup/demo/memory.events cat /sys/fs/cgroup/demo/cpu.statmemory.events会给出high、max、oom三个计数。cpu.stat里nr_periods是经历的周期数nr_throttled是被限流的周期数如果throttled_usec持续增加说明 CPU 限制确实在起作用。观察完清理现场记住要先把进程移出把 PID 写入根 cgroup 的cgroup.procs再删除目录。否则会报Device or resource busy。3.3 层级继承与内部进程约束v2 有个非常容易踩坑的规则叫no internal processes如果一个 cgroup 目录启用了subtree_control也就是它下面打算再建子 cgroup 分层管理那这个目录本身就不能直接放进程。举例说明mkdir /sys/fs/cgroup/outer # 如果想让 outer 下面再分 work1、work2 两个子组 echo cpu /sys/fs/cgroup/outer/cgroup.subtree_control mkdir /sys/fs/cgroup/outer/work1从这一刻起outer目录就不能再放进程了。你往outer/cgroup.procs里写 PID 会直接得到Device or resource busy。只能把进程放到outer/work1这类叶子结点里。设计意图很明确outer是管理节点负责协调下层的总量它本身不承担业务进程。另外要记住子 cgroup 会自动继承父级的限制。假设父目录限制内存 512MB子目录再限制 256MB那么子目录实际上限是 256MB但整个子树的总用量不会超过 512MB。这种累加关系在排查内存问题时特别有用你只要看最顶层 cgroup 的memory.current就能知道整棵树吃掉了多少。v2 还可以组合多个控制器做交叉验证比如我把pids和memory一起打开既能限制进程数量又能限制总内存这样对 fork 炸弹的防护效果会好很多。启用方式还是老套路echo pids /sys/fs/cgroup/cgroup.subtree_control echo 100 /sys/fs/cgroup/demo/pids.max4. systemd 与 cgroups日常工作里最省心的管理方式4.1 为什么优先用 systemd而不是直接改 cgroupfs手动操作 cgroupfs 能帮你理解原理但在现代发行版上真正的生产环境里我不建议直接去改/sys/fs/cgroup/。原因是 systemd 本身承担了 cgroup 管理者的角色。systemd 把每个 unit 对应到一组 cgroup目录结构类似于/sys/fs/cgroup/system.slice/myservice.service/服务启动时自动创建停止时自动清理。如果你绕过 systemd 手动建目录、写 PID会引发几种麻烦systemd 不知道这个目录的存在不会帮你清系统重启后所有手工配置全部丢失你在 systemd 管理的目录下面乱加子目录可能导致服务停止时目录清不掉报Device or resource busy。正确姿势是借助 systemd 的 unit 属性来声明资源限制。它能同时生效于当前运行的进程和未来的重启并且能在systemctl status里直观看到。4.2 常用 unit 属性与持久化配置给一个服务加限制推荐直接编辑 unit 文件。举个例子我想给nginx.service限制 CPU 50%、内存 512MB、最大进程数 128[Service] CPUQuota50% MemoryMax512M MemoryHigh256M TasksMax128改完执行systemctl daemon-reload systemctl restart nginx.service这些属性在 v2 下会直接翻译成 cgroup 控制文件。比如CPUQuota50%对应写cpu.max为50000 100000MemoryMax512M对应memory.maxTasksMax128对应pids.max。systemd 还会在服务状态里展示当前 cgroup 的用量用systemctl status nginx.service就能看到 CGroup 一栏。如果不想编辑文件也可以动态修改属性systemctl set-property nginx.service CPUQuota40% MemoryMax384M这个命令的效果是改配置并立刻生效。默认会写入持久化配置/etc/systemd/system/…如果想只临时生效、重启后还原加--runtime参数systemctl set-property --runtime nginx.service CPUQuota40%4.3 systemd-run一条命令做临时资源限制做实验、临时跑脚本最方便的工具是systemd-run。它能把任意命令包装成一个 transient scope 或 service并且支持-p直接透传属性。比如我要临时跑一个脚本限制它最多用 50% CPU 和 256MB 内存systemd-run --scope -p CPUQuota50% -p MemoryMax256M ./long_running.sh--scope表示前台运行日志直接打到当前终端如果想后台跑可以去掉--scope改为--unittestlimit然后用journalctl -u testlimit看日志。这个方式特别适合验证“给某个进程加限制后是否正常工作”确认没问题再固化到正式 unit 文件里。查看整体 cgroup 层级用systemd-cgls实时监控每个 cgroup 的资源用量用systemd-cgtop。这两个命令比ps、top更直观因为它们直接读 cgroup 的统计文件能按整组聚合。4.4 容器资源限制背后的 cgroup 映射你现在应该能看懂容器参数的底层行为了。docker run --cpus1 --memory512m本质上是 Docker 在容器对应的 cgroup 目录里写cpu.max或 v1 的cfs_quota和memory.max。Kubernetes 里的requests和limits同理limits对应的就是硬上限requests对应的更接近cpu.weight和内存保障。排查 Pod “被 OOM 杀死”“CPU 被限流”时先看容器 cgroup 的memory.events和cpu.stat通常比看应用日志快得多。5. 常见问题与排查技巧实录5.1 高频问题快查表碰到的问题多了总结一张表能帮你直接定位到方向现象常见原因处理方式写cgroup.procs报Device or resource busyv2 下该目录启用了subtree_control内部进程约束生效把进程放到更深的叶子目录或在父级停止-控制器内存限制没生效进程照样爆内存memory.max配置错层级或memory.high刚过线未触发 OOM先cat memory.events看计数确认限制文件路径页面缓存占满 cgroup 内存导致误杀memory.current包含 page cache区分anon与file缓存可以在回收时释放重启后手工 cgroup 配置消失直接写的 cgroupfs未落盘改用 systemd unit 属性持久化cpuset设置后进程挂不上v1 下忘记写cpuset.mems先写cpuset.mems如echo 0 cpuset.memsCPU quota 限制了但top仍然显示 100%top按核显示单核 100% 不等于占满整机看ps -o %cpu聚合值或systemd-cgtopoom计数疯狂增加但进程没死可能是内存回收的反复触发优先看memory.reclaim定位活跃进程评估是否该放大上限5.2 一次真实排查CPU 限流“没生效”现场复盘有一次我给一台测试机做实验stress-ng -c 4起了 4 个吃 CPU 的进程然后按文档写cgroup.procs限制到 200% CPU。结果top一看4 个进程还是各自 90% 多整机四个核全部满载感觉限了个寂寞。排查过程是这样的。先用cat /proc/各进程/cgroup检查每个进程到底在哪个组结果发现只有父进程在demo组里4 个真正干活的子进程全都在根 cgroup。原因是stress-ng启动时会立刻 fork 出 4 个子进程而我在 fork 完成之后才把父进程 PID 写进cgroup.procs。子进程在创建的那一瞬间继承的是当时的 cgroup根组后面父进程移动并不会带着它们一起走。正确的做法有两种要么先把父进程移入目标 cgroup再让它 fork 子进程子进程会自动继承限制要么等进程起来后把所有子进程的 PID 逐一写入cgroup.procs。前者更符合“先圈地再种树”的思维后者适合临时补救。确认全部 PID 写进去后再看cpu.stat里的nr_throttled数值一直在涨限制才真正生效。5.3 个人长期沉淀的避坑习惯最后分享几个我自己一直遵守的习惯算是这些年踩坑踩出来的经验。第一动手前先确认 cgroup 版本和挂载方式。在脚本里直接判断/sys/fs/cgroup/cgroup.controllers是否存在不是 v2 就走 v1 的逻辑不要假设环境。第二生产环境一律使用 systemd 管理绝不直接改 cgroupfs。即使在系统盘 cgroupfs 是只读挂载的容器里你想改也改不动正确路径永远是从上层的运行时Docker/K8s或 systemd 属性下手。第三每次设置限值前先读现状。别拍脑袋写上限。看一眼/proc/$pid/status里的VmRSS用systemd-cgtop看当前组内真实用量再定MemoryMax。我的习惯是给软限制留 20%~30% 余量给硬限制留 50% 余量给 OOM 留一步缓冲。第四改完配置后看事件计数而不是只看应用日志。CPU 限制有没有生效看cpu.stat里的nr_throttled内存限制有没有余量看memory.events里的oom计数。这些文件是内核给的实时答案比任何监控面板都直接。