ARTICLE DETAIL

资讯详情

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

Cgroup原理与实战:从CPU内存限制到容器资源隔离

Cgroup原理与实战:从CPU内存限制到容器资源隔离 1. 从一次线上事故说起为什么每个后端都要懂Cgroup去年冬天我们一个跑了半年多的推荐服务突然开始频繁触发告警。现象很诡异宿主机上总共64核、256G内存上面跑了十几个容器出问题的那个容器CPU使用率一直卡在200%左右上不去内存倒是一路飙到8G后被系统干掉然后自动重启循环往复。运维第一反应是“机器资源不够了”准备扩容。我拦了一下登上宿主机看了一眼/sys/fs/cgroup下面的数据发现宿主机整体负载其实只有30%出头内存也还剩一大半。问题根本不在机器而在于这个容器的Cgroup配置——CPU quota被死死限制在2核内存limit设成了8G而业务本身在高峰期就是需要更多资源。这件事让我意识到CgroupControl Group控制组这个东西虽然天天在用但真正理解它的人并不多。很多人对它的认知停留在“Docker能限制CPU和内存”这个层面至于限制是怎么生效的、参数怎么算、出了问题怎么排查基本靠猜。而一旦线上出问题猜错的代价就是服务雪崩。Cgroup是Linux内核提供的一套机制用来对进程组进行资源限制、优先级控制、资源统计和任务隔离。它是容器技术Docker、containerd、Kubernetes资源控制的底层基石。你写的每一条docker run --cpus2 --memory4g最终都会翻译成Cgroup的配置文件写入内核。理解Cgroup不只是为了面试更是为了在容器资源出问题时能第一时间定位到是配置问题、内核问题还是业务问题。这篇文章适合所有跟容器打交道的后端开发、运维和SRE。不管你是刚接触Docker的新手还是已经管着几百个Pod的老手我都会从原理讲到实操从参数计算讲到故障排查把Cgroup这套东西彻底讲透。读完你至少能做到三件事看懂Cgroup的目录结构和关键文件、自己算出合理的CPU和内存配额、在容器被OOM或CPU被限流时快速找到根因。2. Cgroup整体设计与核心思路拆解2.1 Cgroup到底是什么从“进程组”这个角度理解很多人第一次接触Cgroup看到一堆/sys/fs/cgroup/cpu/、/sys/fs/cgroup/memory/的目录就懵了。其实换个角度想就很简单Linux里每个进程都有一个PIDCgroup做的事情就是把这些PID分组然后对每一组施加统一的资源策略。打个比方一个公司里有几百号员工进程老板内核想管理他们。如果一个个管太累。于是老板把员工分成部门Cgroup每个部门设定预算市场部每月差旅费上限5万内存限制技术部每天加班不超过2小时CPU配额。部门内部怎么分配部门自己决定但总额不能超。这就是Cgroup的核心思想——分组管理层级控制。Cgroup在Linux内核里是以树形结构组织的。根节点是/sys/fs/cgroup/下面可以挂子Cgroup子Cgroup再挂子Cgroup形成一棵树。每个Cgroup节点上可以挂载多个子系统subsystem也叫控制器controller比如cpu、memory、blkio、pids、cpuset等。每个控制器负责一类资源的限制和统计。这里有个关键点一个进程可以同时属于多个Cgroup每个Cgroup对应一个控制器。比如一个进程可以同时在CPU Cgroup的A组里又在内存Cgroup的B组里。这种设计让资源控制非常灵活但也容易让人混淆。Docker在创建容器时会为容器创建一个Cgroup子树然后把容器的所有进程放进去同时配置cpu、memory、blkio等多个控制器。2.2 为什么是树形结构层级继承与资源分配的逻辑Cgroup的树形结构不是随便设计的它解决了一个核心问题资源的分层分配。假设你有一台32核、128G的机器上面要跑三个业务线搜索、推荐、广告。你可以先在根Cgroup下建三个子Cgroup分别给它们分配CPU和内存。比如搜索给12核、48G推荐给12核、48G广告给8核、32G。然后推荐业务线内部又分在线服务和离线训练你可以在推荐的Cgroup下再建两个子Cgroup把12核再拆成8核和4核。这种层级结构让资源分配像切蛋糕一样一层层切下去每一层都有明确的边界。更重要的是子Cgroup的资源限制不能超过父Cgroup的限制。如果父Cgroup只有12核子Cgroup就算配置了16核实际能用的也不会超过12核。这个特性在Kubernetes里用得非常多Node节点是一个Cgroup上面的Pod是子CgroupPod里的容器又是子Cgroup。Kubelet通过这种层级关系确保所有Pod的资源总和不会超过Node的容量。这里有个容易踩的坑很多人以为在子Cgroup里设置了cpu.cfs_quota_us就一定能拿到那么多CPU但如果父Cgroup的配额已经用完了子Cgroup再要也没用。我见过一个案例某个团队在Pod里设置了--cpus4但Node节点的kube-reserved和system-reserved占掉了大量CPU导致Pod实际只能用到2核多一点。排查了半天才发现是父级Cgroup的限制。2.3 Cgroup v1与v2两代架构的差异与选型建议Cgroup发展到现在有两个大版本v1和v2。v1从2008年左右进入内核v2从2016年正式合入内核主线。两者的核心差异在于控制器的组织方式。v1的问题是每个控制器各自为政有自己独立的挂载点和目录树。比如cpu控制器挂在/sys/fs/cgroup/cpu/memory控制器挂在/sys/fs/cgroup/memory/blkio挂在/sys/fs/cgroup/blkio/。这意味着一个进程在不同控制器下的Cgroup路径可能完全不同管理起来非常混乱。而且v1的控制器之间缺乏协调比如内存回收和IO控制可能互相打架。v2把所有控制器统一挂载到一棵树上通常是/sys/fs/cgroup/unified/或/sys/fs/cgroup/。每个Cgroup节点下有一个cgroup.controllers文件列出当前节点可用的控制器有一个cgroup.subtree_control文件控制哪些控制器会下发给子节点。这种设计让资源控制更加一致和可预测。目前主流发行版的情况是Ubuntu 22.04、Debian 11、Fedora 31默认使用v2CentOS 7、RHEL 7默认使用v1CentOS 8、RHEL 8开始支持v2但默认还是v1。Docker从20.10版本开始支持v2Kubernetes从1.25版本开始正式支持v2。如果你现在新起集群建议直接用v2因为v1已经进入维护模式新特性都在v2上。怎么判断当前系统用的是哪个版本执行mount | grep cgroup如果看到cgroup2就是v2看到cgroup且有多行不同控制器就是v1。或者看/sys/fs/cgroup/下面有没有cgroup.controllers文件有就是v2。注意v1和v2可以共存但同一个控制器不能同时被v1和v2使用。如果你在v2系统上跑老版本Docker可能会遇到控制器被v1占用导致v2不可用的情况。排查时先看/proc/cgroups里每个控制器的hierarchy值0表示未使用非0表示被哪个版本占用。3. CPU资源限制从配额计算到实际限流3.1 CPU配额的核心参数cfs_quota_us与cfs_period_usCgroup对CPU的限制主要靠两个参数cpu.cfs_period_us和cpu.cfs_quota_us。这两个参数配合工作实现的是带宽控制而不是简单的“只能用几个核”。cfs_period_us定义了一个调度周期单位是微秒默认值是100000也就是100毫秒。cfs_quota_us定义在这个周期内这个Cgroup最多能使用多少微秒的CPU时间。如果cfs_quota_us设为200000周期是100000那就意味着每个周期内可以用200000微秒的CPU时间相当于2个核的算力。计算公式很简单CPU核数 cfs_quota_us / cfs_period_us。比如你想限制容器只能用1.5个核可以设cfs_quota_us150000cfs_period_us100000。Docker的--cpus1.5最终就是翻译成这个配置。这里有个细节cfs_quota_us可以设为-1表示不限制。默认情况下新建的Cgroup的quota就是-1也就是不限。只有当你显式设置了quota限制才会生效。另一个参数是cpu.shares它控制的是相对权重而不是绝对限制。默认值是1024。如果两个Cgroup的shares分别是1024和512那么在CPU资源紧张时前者能拿到的CPU时间是后者的两倍。但如果CPU资源不紧张shares不会限制任何东西大家都能随便用。很多人把shares当成硬限制这是不对的。3.2 实操手动创建一个CPU受限的Cgroup光说不练假把式。我们手动创建一个Cgroup限制它只能用0.5个核然后跑一个死循环程序验证效果。首先确认系统是v1还是v2。如果是v1操作如下# 创建Cgroup目录 mkdir /sys/fs/cgroup/cpu/mygroup # 设置配额0.5核 50000/100000 echo 50000 /sys/fs/cgroup/cpu/mygroup/cpu.cfs_quota_us echo 100000 /sys/fs/cgroup/cpu/mygroup/cpu.cfs_period_us # 启动一个死循环进程 while true; do :; done # 假设PID是12345 # 把进程加入Cgroup echo 12345 /sys/fs/cgroup/cpu/mygroup/tasks # 查看CPU使用率 top -p 12345你会看到这个进程的CPU使用率稳定在50%左右不会超过。这就是quota在起作用。如果是v2系统操作略有不同# 创建Cgroup目录 mkdir /sys/fs/cgroup/mygroup # 启用cpu控制器 echo cpu /sys/fs/cgroup/cgroup.subtree_control # 设置配额 echo 50000 /sys/fs/cgroup/mygroup/cpu.max # 注意v2的格式是quota period所以也可以直接写50000 100000v2把quota和period合并到了一个文件cpu.max里格式是$QUOTA $PERIOD。如果要取消限制写max 100000。3.3 CPU限流的实际影响与排查方法CPU被限流最直接的表现就是进程的CPU使用率上不去但系统整体负载不高。这时候你要看两个指标cpu.stat里的nr_throttled和throttled_time。nr_throttled表示这个Cgroup被限流的次数throttled_time表示总共被限流了多少纳秒。如果这两个值在持续增长说明Cgroup的CPU配额不够用了。我遇到过一种情况某个Java服务在容器里跑平时CPU使用率在1.5核左右配置了2核的quota。但每次Full GC的时候CPU会瞬间飙到4核以上结果被Cgroup限流导致GC线程被暂停GC时间从200ms拉长到2秒进而引发更多请求堆积形成恶性循环。这种问题的解法不是简单加CPU而是要分析GC日志调整堆大小和GC策略让GC时的CPU峰值降下来。排查CPU限流的步骤进入容器的Cgroup目录查看cpu.stat确认nr_throttled是否在增长。查看cpu.cfs_quota_us和cpu.cfs_period_us确认配额是否符合预期。用top或pidstat看进程的实际CPU使用率判断是业务本身需要更多CPU还是配置不合理。如果是业务峰值导致考虑调整quota或优化业务如果是配置错误修正配置。实操心得cfs_period_us不建议改得太小。有些人为了“更精细”地控制CPU把period设成1000010ms结果导致频繁的调度切换上下文切换开销反而变大。默认的100ms是经过验证的平衡点除非有特殊需求否则不要动。4. 内存限制从OOM到内存回收的完整链路4.1 内存Cgroup的核心文件与限制逻辑内存Cgroup的文件比CPU多得多因为内存管理本身就复杂。最核心的几个文件是memory.limit_in_bytesv1或memory.maxv2内存硬限制超过就触发OOM。memory.soft_limit_in_bytesv1或memory.highv2内存软限制超过后内核会尝试回收内存但不一定会杀进程。memory.usage_in_bytesv1或memory.currentv2当前内存使用量。memory.stat详细的内存统计信息包括cache、rss、swap等。memory.oom_controlv1控制OOM行为可以设置是否杀进程。内存限制的生效逻辑是这样的当Cgroup的内存使用量达到memory.limit_in_bytes时内核会先尝试回收内存主要是page cache。如果回收后还是不够就会触发OOM killer杀掉这个Cgroup里的某个进程。注意是杀掉Cgroup内的进程而不是影响其他Cgroup。这里有个非常重要的细节page cache也算在内存使用量里。很多人发现容器内存使用量很高但实际业务用的RSS并不大就是因为文件读写产生的page cache被计入了。这部分内存在宿主机内存紧张时可以被回收但在Cgroup层面它确实占用了配额。4.2 实操限制内存并观察OOM行为我们创建一个限制为100M的Cgroup然后跑一个不断分配内存的程序观察OOM。v1系统mkdir /sys/fs/cgroup/memory/mygroup echo 104857600 /sys/fs/cgroup/memory/mygroup/memory.limit_in_bytes # 写一个简单的内存分配程序 cat /tmp/memtest.c EOF #include stdlib.h #include string.h #include stdio.h int main() { while (1) { void *p malloc(10 * 1024 * 1024); if (p) memset(p, 0, 10 * 1024 * 1024); printf(allocated 10MB\n); sleep(1); } } EOF gcc -o /tmp/memtest /tmp/memtest.c # 运行并加入Cgroup /tmp/memtest echo $! /sys/fs/cgroup/memory/mygroup/tasks跑一会儿你就会看到进程被killdmesg里会有OOM的记录显示是哪个Cgroup触发的。v2系统mkdir /sys/fs/cgroup/mygroup echo memory /sys/fs/cgroup/cgroup.subtree_control echo 104857600 /sys/fs/cgroup/mygroup/memory.maxv2的OOM行为可以通过memory.oom.group控制设为1表示整个Cgroup一起被杀设为0表示只杀触发OOM的那个进程。4.3 内存回收与软限制memory.high的妙用硬限制的问题是“一刀切”超过就杀进程太粗暴。v2引入了memory.high作为软限制当内存使用超过memory.high但还没到memory.max时内核会强制回收内存并且限制分配速度给进程一个“缓冲期”。这个机制在实际生产中非常有用。比如你可以把memory.high设为memory.max的80%这样当内存使用到80%时内核开始积极回收page cache同时让进程的分配变慢给业务一个自我调整的机会。如果业务能扛过去就不会触发OOM如果扛不过去再到memory.max才杀进程。v1没有memory.high但有memory.soft_limit_in_bytes效果类似但不如v2的memory.high强制。v1的软限制更多是给内核一个回收倾向不会强制限制分配速度。注意事项memory.high设得太低会导致频繁的内存回收CPU开销会上升。我一般建议设在memory.max的80%到90%之间具体要看业务的内存增长曲线。如果业务内存增长很平缓可以设低一点如果增长很陡设太低反而会导致回收跟不上。4.4 容器内存限制的常见坑与排查容器内存问题最常见的表现是OOMKilled。Kubernetes里Pod的容器被OOM杀掉后状态会显示OOMKilled退出码是137。但很多人不知道的是OOM可能发生在两个层面容器Cgroup层面和宿主机层面。容器Cgroup层面的OOM是容器自己的内存超过memory.max触发的只影响这个容器。宿主机层面的OOM是整台机器的内存不够了内核从所有Cgroup里挑一个“最该杀”的进程杀掉。后者的影响范围更大可能杀掉任意容器的进程。排查OOM的步骤看dmesg或/var/log/messages搜索oom-kill确认是哪个Cgroup触发的。看容器的memory.max和memory.current确认限制和实际使用量。看memory.stat里的rss和cache判断是业务内存泄漏还是page cache占用。如果是page cache占用过高考虑在容器里定期执行echo 1 /proc/sys/vm/drop_caches注意这会影响性能或者调整业务的文件读写模式。我踩过的一个坑某个服务在容器里跑内存限制4G但实际RSS只有1Gpage cache却有3G。原因是服务在启动时读取了大量配置文件和小文件这些文件的内容被缓存了。后来通过调整memory.max到6G并优化文件读取方式用流式读取代替一次性读取问题才解决。5. 容器资源控制Docker与Kubernetes如何翻译Cgroup配置5.1 Docker run背后的Cgroup操作当你执行docker run --cpus2 --memory4g nginx时Docker做了这些事情在/sys/fs/cgroup/cpu/docker/container-id/下创建Cgroup目录。写入cpu.cfs_quota_us200000和cpu.cfs_period_us100000。在/sys/fs/cgroup/memory/docker/container-id/下创建Cgroup目录。写入memory.limit_in_bytes4294967296。启动容器进程把PID写入对应的tasks文件。你可以用docker inspect container-id看到这些配置在HostConfig字段里。但更直接的方式是找到容器的Cgroup路径直接看内核文件。对于v1系统路径通常是/sys/fs/cgroup/cpu/docker/full-container-id/。Docker还支持更细粒度的控制比如--cpu-shares对应cpu.shares--cpuset-cpus对应cpuset.cpus--blkio-weight对应blkio.weight。这些参数在docker run --help里都能查到。有一个容易忽略的点Docker默认不会限制容器的CPU和内存。如果你不指定--cpus和--memory容器可以使用宿主机的全部资源。这在生产环境是危险的一个容器出问题可能拖垮整台机器。所以生产环境一定要显式设置资源限制。5.2 Kubernetes的QoS与Cgroup层级Kubernetes在Cgroup的使用上比Docker更复杂因为它要管理Pod级别的资源还要实现QoS服务质量分级。Kubernetes把Pod分为三个QoS等级Guaranteed所有容器的requests和limits都相等且都设置了。这种Pod的Cgroup配置最严格资源最有保障。Burstable至少一个容器设置了requests或limits但不满足Guaranteed条件。这种Pod的资源有一定保障但可能被其他Pod挤占。BestEffort所有容器都没有设置requests和limits。这种Pod优先级最低资源紧张时最先被牺牲。在Cgroup层级上Kubernetes会为每个QoS等级创建不同的父Cgroup。比如kubepods.slice/kubepods-burstable.slice/下面挂Burstable的Podkubepods.slice/kubepods-besteffort.slice/下面挂BestEffort的Pod。当宿主机内存紧张时内核会优先从BestEffort的Cgroup里回收内存然后是Burstable最后才是Guaranteed。这个机制在v2里通过memory.high和memory.min等参数实现得更精细。比如Guaranteed Pod的memory.min会设成它的requests值确保这部分内存不会被回收。Burstable Pod的memory.min可能只设成requests的一部分或者不设。5.3 资源配额计算如何给容器设置合理的CPU和内存设置资源限制不是拍脑袋要有依据。我的经验是分三步第一步压测获取基线。在测试环境用生产级别的流量压测记录CPU和内存的峰值。CPU看P99使用率内存看稳定后的RSS加上一定的buffer。第二步考虑业务特性。如果是延迟敏感型服务CPU quota要留足余量避免被限流。如果是批处理任务可以卡得紧一点。内存方面Java服务要特别注意堆外内存和GC开销通常需要比堆大小多留30%到50%。第三步设置requests和limits。requests是调度依据limits是硬限制。我一般建议requests设为压测峰值的70%limits设为压测峰值的120%到150%。这样既能保证调度时拿到足够资源又能在突发流量时有缓冲。举个例子一个Go服务压测下来CPU峰值1.2核内存峰值800M。那么可以设requests.cpu800mlimits.cpu1500mrequests.memory600Milimits.memory1Gi。这样在Node资源紧张时调度器会按800m来算但实际运行可以跑到1.5核。内存方面600Mi的requests保证基本调度1Gi的limits防止内存泄漏拖垮Node。实操心得Kubernetes的--cpus和--memory在v1和v2下的翻译方式不同。v1下--cpus1.5会写成cpu.cfs_quota_us150000v2下会写成cpu.max150000 100000。如果你在排查时发现文件内容对不上先确认Cgroup版本。6. 常见问题与排查技巧实录6.1 CPU限流问题速查表现象可能原因排查方法解决方案容器CPU使用率上不去但宿主机负载低Cgroup quota限制查看cpu.stat的nr_throttled调大cfs_quota_us或优化业务容器CPU使用率波动大频繁被限流period设置过小查看cfs_period_us恢复默认100000多容器争抢CPU某个容器被饿死shares设置不合理查看各容器的cpu.shares调整shares比例容器只能用特定核cpuset限制查看cpuset.cpus调整cpuset配置6.2 内存OOM问题速查表现象可能原因排查方法解决方案容器被OOMKilled内存超过limit查看memory.max和memory.current调大limit或修复内存泄漏容器内存使用量高但RSS低page cache占用查看memory.stat的cache调整文件读写方式或调大limit宿主机OOM随机杀进程宿主机内存不足查看dmesg的oom记录增加宿主机内存或调整Cgroup层级内存回收导致CPU飙升memory.high设太低查看memory.high和memory.stat调高memory.high6.3 独家避坑技巧技巧一用systemd-cgtop实时监控Cgroup资源。这个命令可以像top一样实时显示各个Cgroup的CPU、内存、IO使用情况比手动看文件方便得多。在v2系统上尤其好用。技巧二容器内看不到Cgroup限制检查/proc/self/cgroup。有时候容器里的进程看到的Cgroup路径和宿主机上不一样这是因为容器做了mount namespace隔离。在容器里执行cat /proc/self/cgroup可以看到当前进程所属的Cgroup路径再对应到宿主机的实际路径。技巧三Java服务的Cgroup感知。老版本JDK8u191之前不感知Cgroup限制会按宿主机的CPU和内存来设置默认的线程池和堆大小。这会导致容器里跑Java服务时堆设得过大频繁OOM。解决方案是升级JDK或显式设置-Xmx和-XX:ActiveProcessorCount。技巧四用cgget和cgset命令行工具。这两个工具可以方便地读写Cgroup参数不用手动echo到文件。比如cgget -g cpu:/mygroup可以查看mygroup的所有CPU参数。在v1系统上特别实用。技巧五Kubernetes的--system-reserved和--kube-reserved。这两个参数决定了给系统进程和Kubernetes组件预留多少资源。如果设得太小系统进程可能和业务Pod抢资源设得太大业务可用资源就少了。我一般建议system-reserved至少留1核和1G内存kube-reserved根据集群规模调整。6.4 一个真实的排查案例最后分享一个我实际遇到的案例。某天收到告警说一个Pod频繁重启状态是OOMKilled。但奇怪的是这个Pod的内存limit是2G而监控显示它的内存使用量一直在1.5G左右远没到limit。我登上Node找到这个Pod的Cgroup目录查看memory.stat发现rss只有800M但cache有900M加起来1.7G。再仔细看cache里大部分是file类型的page cache。原来这个Pod挂载了一个hostPath里面有个大文件被频繁读取page cache一直涨。虽然page cache可以被回收但回收速度跟不上分配速度最终触发了OOM。解决方案有两个一是把hostPath改成emptyDir让page cache在Pod删除时自动清理二是在容器里定期执行echo 1 /proc/sys/vm/drop_caches。我们选了第一个方案问题解决。这个案例告诉我们排查OOM不能只看总使用量一定要看memory.stat的细分项。RSS高通常是业务问题cache高通常是IO问题swap高说明内存压力已经很大了。7. 从Cgroup到资源隔离的完整认知Cgroup只是Linux资源隔离的一部分。完整的资源隔离还包括Namespace命名空间和Seccomp安全计算模式。Namespace负责视图隔离让容器里的进程看不到外面的进程、网络、文件系统Cgroup负责资源隔离限制容器能用多少CPU、内存、IOSeccomp负责系统调用过滤限制容器能执行哪些系统调用。这三者配合才构成了容器的完整隔离能力。很多人把Docker等同于“轻量级虚拟机”其实不准确。虚拟机是硬件级别的隔离每个虚拟机有独立的内核容器是操作系统级别的隔离共享同一个内核靠Namespace和Cgroup实现隔离。所以容器的安全性不如虚拟机但启动速度和资源开销远优于虚拟机。理解Cgroup的边界也很重要。Cgroup能限制CPU、内存、IO、进程数等但不能限制网络带宽需要tc、不能限制磁盘容量需要quota、不能限制GPU需要device cgroup。在实际生产中往往需要多种机制配合使用。我在实际使用中的体会是Cgroup的配置没有“标准答案”只有“适合当前业务的答案”。同一个服务在不同的流量模型、不同的硬件配置、不同的SLA要求下最优的Cgroup参数可能完全不同。所以不要迷信网上的“最佳实践”一定要结合自己的压测数据和监控指标来调优。调优的过程也不是一劳永逸的业务在变流量在变Cgroup参数也要跟着变。定期review资源配额应该成为每个SRE的例行工作。
返回列表