ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Docker容器CPU限制原理与实践:从CFS调度到--cpus参数全解析

Docker容器CPU限制原理与实践:从CFS调度到--cpus参数全解析 先讲个我自己踩过的坑。前些年接手一套容器化部署的业务系统宿主机4核16G上面跑着MySQL、Redis和三四个Java服务。有个Java服务因为定时任务传入的某组参数异常在方法里发生了死循环CPU直接被打到接近400%MySQL查询从3ms变成几秒页面超时告警一片。当时领导问我为什么一个容器能把整台宿主机拖垮我也只能先重启再查日志。后来补课才知道如果创建容器时不显式限定CPUDocker容器对CPU的使用是没有上限的——它可以和宿主机上的所有进程一起去抢时间片而且在CFS调度器里它未必吃亏。这篇文章就把Docker容器限定CPU这件事从头梳理一遍从底层调度原理到四种参数再到验证和踩坑希望能帮你避免我当年的尴尬。1. 不设上限的代价一次 CPU 占满事故与底层调度逻辑1.1 一次4核打满导致 MySQL 查询雪崩的真实排查先还原一下当时的场景。宿主机4核容器列表里有MySQL、Redis、业务服务A、业务服务B。下午2点多业务服务A的一个定时任务开始执行代码里的某个循环因为边界条件写错从正常跑完变成了死循环。这个服务当时没有加任何CPU限制于是它瞬间吃满了4个核中几乎所有可用时间片。我当时的排查链路是这样的宿主机执行top看到一个Java进程CPU占用在350%~400%之间跳动SysRq和IO都还好但LOAD值已经飙到8左右。执行docker stats确认这个高CPU进程来自哪个容器CPU%列显示业务服务A容器已经接近400%。进容器看进程再用jstack抓线程栈定位到具体的任务线程发现反复在同一段代码里执行。止血操作是重启容器然后线上手动清掉定时任务。事后复盘时我想明白了一件事如果当时给这个容器加了CPU上限即使代码死循环它最多也就吃满1个或0.5个核MySQL、Redis以及另一个业务服务不会被挤到几乎无法运行。资源限制并不能消灭烂代码但它能硬生生地把“一个组件的故障”限制在“一个组件内部”这是生产环境里非常朴素也非常重要的设计思路。1.2 容器 CPU 限制的数学本质你限制的是“时间片”而非“核数量”很多人第一次接触容器CPU限制时会以为--cpus1就是“只让容器用1个核”。这个理解不算错但不精确。Linux内核的CFS完全公平调度器不是把物理核心钉死分配给某个进程而是把每个CPU核心的时间切成长度很小的时间片在多个进程之间轮转分配。以默认调度周期100ms为例一个4核宿主机在一个周期内总共可以给出400ms的CPU时间每核100ms。假设你设置容器的CPU配额为50ms那么这个容器在每个100ms周期内最多只能获得50ms的CPU时间也就是最多使用“0.5个核心”的吞吐。这个限制是内核按“时间片总额”来计算的不是限制“只能用0号核”或者“只能用1号核”。打个比方食堂有限时供餐一个周期100秒你拿到的饭票总额是50秒你可以在1号窗口打饭也可以在3号窗口打饭但总打饭时间不能超过50秒。--cpus、--cpu-quota这些参数限定的其实是“总饭票额度”而不是限定“在哪个窗口排队”。理解了这一点很多后续问题就顺了为什么设置0.5核后容器有多个线程时还能并行跑因为多线程可以同时在多个核上获得时间片但总量仍被50ms/100ms的配额限制住。为什么设置2核后一个单线程程序仍然最多只能跑满一个核因为单线程在一个时刻只能在一个核上执行时间片又受配额约束2个核的配额通常不会帮单线程突破物理上限。1.3 cgroup内核与容器之间的“资源合同”Docker能实现CPU限制底层靠的是Linux内核的cgroup机制。cgroup的全称是Control Groups它负责管理一个进程组可以使用的CPU、内存、磁盘IO等资源。Docker启动容器时会为容器创建独立的cgroup并把容器内进程放进这个组里然后向cgroup里写入对应的限制值。可以把namespace理解为“视野隔离”它让容器里的进程看不见宿主机的完整环境cgroup则是“资源隔离”它让内核按合同约束容器能使用的资源额度。有意思的是namespace的限制是可以被应用感知甚至绕过的但cgroup的CPU配额是由内核调度器强制执行应用层面完全无法绕过——即使容器内是root用户也一样。这也是为什么你可以在生产环境放心地用CPU限制来保护整体稳定性。所以“Docker容器不限定CPU会怎样”这个问题的准确答案是容器进程默认加入宿主机根cgroup没有任何CPU上限可以和所有其他进程平等争抢CPU时间片。当容器内出现异常负载时它就会像一台失控的抽水机把所有CPU资源都吸走。2. Docker 提供的 CPU 限定四件套机制与适用场景2.1 --cpu-shares软性权重只在竞争时起作用--cpu-shares是Docker里最容易被误解的参数。它的默认值是1024含义不是“容器使用1024%的CPU”而是“容器在CPU竞争时的相对权重”。举一个最简单的例子。宿主机上有A、B两个容器都处于满负载状态A设置--cpu-shares1024B设置--cpu-shares512。当两个容器都在疯狂使用CPU时内核会按2:1的比例分配CPU时间A大约能拿到三分之二的CPUB拿到三分之一。但关键在于**如果宿主机上只有一个容器在忙碌它无论设多低的shares都能用满整个宿主机的CPU。**比如宿主机8核只跑一个容器--cpu-shares2也不会限制它只能用0.01核——因为它没有竞争者没有争抢当然也就没有分配压力。所以在生产环境里--cpu-shares更适合用来表达“优先级”而不是“上限”。比如你希望数据库容器在CPU被其他容器抢占时更有优势可以给数据库容器设置一个更高的shares值比如2048给离线批处理任务设置512。这样平时CPU空闲时大家都不受限制一旦发生竞争数据库容器能抢到更多时间片。2.2 --cpu-period 与 --cpu-quotaCFS 硬性配额的精髓如果目标是“无论发生什么这个容器最多只能用0.5个核”那就要用--cpu-period和--cpu-quota。这两个参数分别对应CFS scheduler的调度周期和每个周期内的配额。Docker的默认调度周期是100000单位是微秒也就是100ms。--cpu-quota的默认值是-1表示不限制。当你把--cpu-quota设置成一个具体数字时比如50000表示每个100ms周期内容器最多能使用50000微秒的CPU时间也就是0.5个核。如果配额配成200000相当于每100ms周期最多用200ms CPU时间也就是2个核。注意这个数值可以大于调度周期因为多个核可以并行提供时间片配额总量可以通过周期长度来表示多核吞吐。生产实践里我更推荐直接用--cpus参数它就是把period和quota这两个参数封装成了一个更直观的“核数”表达。但理解这两个底层参数很重要因为后续排查、读cgroup文件、看监控指标时你看到的都是这些原始数值。2.3 --cpuset-cpus物理绑核与缓存友好--cpuset-cpus和前面两个参数思路不同它做的是物理绑核。比如--cpuset-cpus0,1表示把容器内的进程绑定到宿主机物理核心0和核心1上容器只能在这两个核上运行。绑核的价值主要体现在两类场景。一类是性能敏感型业务比如实时音视频处理、高频交易系统绑定固定的核可以避免频繁在不同的核之间切换降低上下文切换开销同时提高CPU Cache命中率。另一类是NUMA架构下的内存访问优化绑核之后内存可以尽量从本地NUMA节点分配避免跨节点访问的延迟这个场景通常会配合--cpuset-mems一起使用。但绑核也有明显的副作用如果绑定的核心已经被其他进程占满容器也不能去使用其他空闲核因为绑核限制是硬性的。生产环境里如果宿主机上同时还有别的负载绑核前一定要算好核心分配否则容易出现“一个核堵车其他核闲逛”的尴尬局面。2.4 --cpus自动换算的“软核数”--cpus是Docker后来提供的简化参数我日常用得最多。设置--cpus1.5Docker会自动把它换算成--cpu-period100000和--cpu-quota150000也就是限制容器最多使用1.5个核。这个参数的好处是直观你不用去理解period和quota到底怎么配。它本质上仍然是CFS配额控制所以“容器内看到的核数不会变”这一点后面细说。需要特别注意--cpus和--cpu-period/--cpu-quota不要混用。如果同时指定旧版本Docker的做法是以--cpu-quota/--cpu-period为准--cpus可能被忽略不同版本行为不完全一致容易产生“我以为限制了其实没生效”的错觉。规范做法是同一时刻只使用一种配额表达方式。2.5 四件套选型建议与组合策略我把四种方式放进一张表里做个对照参数限制类型是否硬性典型场景--cpu-shares权重比例软性仅竞争时生效关键服务保优先级--cpu-period/--cpu-quota时间片配额硬性精确控制CPU用量--cpuset-cpus物理绑核硬性性能敏感型业务--cpus时间片配额简写硬性日常通用限制实际生产里我给核心业务容器通常是--cpus硬限制配一个合理的上限比如2核同时再配--cpu-shares2048保证它在发生CPU竞争时优先级更高。给离线任务或批处理容器则是--cpus1甚至更低同时--cpu-shares256降低它对在线业务的干扰。如果是部署在NUMA架构的高性能机器上才考虑再叠加--cpuset-cpus进行绑核。3. 从命令行到编排文件CPU 限制的完整配置实操3.1 docker run 的不同限定写法先看docker run直接创建容器时的几种常见命令# 方式一直接用 --cpus 限制最多用 1.5 核 docker run -d --name app --cpus1.5 myapp:latest # 方式二用 quota/period 表达同样含义0.5核 docker run -d --name worker \ --cpu-period100000 \ --cpu-quota50000 \ worker:latest # 方式三权重 硬上限组合 docker run -d --name db \ --cpus2 \ --cpu-shares2048 \ mysql:8.0 # 方式四绑核 权重 docker run -d --name batch \ --cpuset-cpus2,3 \ --cpu-shares256 \ batch:latest这里解释一下方式四--cpuset-cpus2,3先把容器进程限定在物理核心2和3上--cpu-shares256在这个范围内参与和其他容器的CPU竞争。两者不冲突因为一个是限定范围一个是在范围内的权重。创建容器时如果既想限制上限又希望容器内应用能感知到CPU配额比如Java的G1GC线程数、并发线程池大小的自动配置就需要依赖JVM或运行时自身对cgroup配额的支持。JDK 10以后默认开启了UseContainerSupport能正确识别--cpus限制Go运行时如果你用的是协程密集型的程序建议引入automaxprocs这类库否则runtime.GOMAXPROCS默认仍会取机器核数。3.2 docker compose 的 CPU 限制配置如果你用Docker Compose管理多容器应用推荐在Compose文件里统一声明资源限制这样服务每次启动都会自动带上配额不用依赖人工记忆命令参数。services: app: image: myapp:latest deploy: resources: limits: cpus: 1.5 memory: 1G reservations: cpus: 0.25 memory: 256M这里deploy.resources.limits.cpus是硬上限deploy.resources.reservations.cpus是预留资源。在Docker Compose v2.x版本下即使不跑Swarm模式docker compose up也会遵守这个CPU限制。老一点的docker-composev1版本对deploy.resources的支持不完全一致建议统一用新版Compose v2避免出现“明明写了配置却不生效”的问题。Compose文件里还可以写cpu_count、cpu_percent这类旧版字段但它们不是Compose Spec里的标准字段新版本已经不太推荐能和deploy.resources统一就尽量统一。3.3 不重启容器动态调整docker update 的边界服务在跑发现某个容器CPU占用持续偏高能不能不重启就调整可以docker update就是干这个的。# 动态把 CPU 限制从 2 核降到 0.8 核 docker update --cpus0.8 container-name # 动态调整 CPU 权重 docker update --cpu-shares512 container-name # 动态设置绑核范围谨慎使用 docker update --cpuset-cpus0,1 container-namedocker update对CPU相关参数的支持比较完善--cpus、--cpu-shares、--cpu-quota、--cpu-period基本都能调整。但有几个细节要注意一是绑核范围调整时要考虑已在运行的线程迁移问题如果把cpuset从0-3改成0-1正在运行的线程可能被内核强制迁出旧的核这个过程虽然不影响进程存活但可能带来短暂的性能抖动二是在容器运行时调整配额要观察业务是否有影响尤其数据库这类延迟敏感型服务突然把CPU从4核降到1核连接池、慢查询、主从延迟都可能跟着出问题。3.4 容器内部看到的 CPU 核数会误导你的线程池配置这是一个非常隐蔽、又非常常见的坑。假设宿主机32核你创建一个--cpus2的容器进入容器执行nproc或者lscpu很可能会看到32取决于内核版本和cpuset配置。因为--cpus只是限制CPU时间片配额并不会修改容器内/proc/cpuinfo对核数的展示。带来的直接问题是容器内的Java服务、Node.js、Go程序在启动时会根据availableProcessors()或nproc来设置线程池大小。一个只分配了2核CPU配额的容器应用却可能启动64个甚至更多工作线程。线程一多线程上下文切换开销变大响应速度不升反降。解决方案有两类。一类是在应用层做配置比如Java应用通过-XX:ActiveProcessorCount2指定活跃处理器数或在线程池初始化时明确传参。另一类是用运行时库自动识别比如Go服务的automaxprocs。如果容器同时配置了--cpuset-cpus容器内的核数展示会正确变成绑定的核数这时候nproc的结果是可信的。4. 如何验证限制真的生效压测与 cgroup 数据解读4.1 用 stress-ng 把容器打满直观观察 CPU%配置写完心里还是没底那就实测。可以用stress-ng在容器里制造一个高CPU负载然后观察配额是否真的生效。# 创建一个受限于 0.5 核的测试容器并保持运行 docker run -d --name stress_test --cpus0.5 ubuntu:22.04 sleep 600 # 进入容器安装压测工具 docker exec -it stress_test apt-get update docker exec -it stress_test apt-get install -y stress-ng # 在容器内启动4个CPU压力线程 docker exec -it stress_test stress-ng --cpu 4 --timeout 120s此时在宿主机执行top你会看到stress_test容器里的进程CPU占用大约在50%左右而不是400%。如果执行docker statsCPU%列也会稳定在50%附近。这就是--cpus0.5真实生效的直接证据。如果不用stress-ng也可以用最土的办法在一个容器里跑yes /dev/null 再开几个同样的进程效果也差不多。只是stress-ng能控制线程数更容易模拟多核打满的场景。4.2 定位容器 cgroup 路径并检查配额文件docker stats只能看到表象。要确认配额是否精确生效最好直接去cgroup文件系统里看原始数据。首先拿到容器的完整ID和主进程PID:docker inspect -f {{.Id}} stress_test docker inspect -f {{.State.Pid}} stress_test然后根据宿主机内核使用的是cgroup v1还是cgroup v2进入对应路径cgroup v1路径/sys/fs/cgroup/cpu,cpuacct/docker/container-id/cgroup v2路径/sys/fs/cgroup/system.slice/docker-container-id.scope/或/sys/fs/cgroup/docker/container-id/如果不确定路径可以用这个命令看容器进程挂在哪个cgroup下cat /proc/$(docker inspect -f {{.State.Pid}} stress_test)/cgroup在cgroup v1的目录里重点看这几个文件cat cpu.cfs_period_us cat cpu.cfs_quota_us cat cpu.shares如果是--cpus0.5且Docker把它换算成period100000、quota50000那么这两个文件会分别输出100000和50000。cpu.shares则显示默认的1024。4.3 读限流计数配额被触发后到底发生了什么光看配额文件还不够还需要确认容器是否真的被限流了。cgroup里有一个cpu.stat文件记录了限流统计信息。在cgroup v1下执行cat cpu.stat会得到类似这样的输出nr_periods 1000 nr_throttled 800 throttled_time 50000000000nr_periodsCPU调度周期总数。nr_throttled被限流的周期数也就是有多少个周期内容器尝试使用超过配额的时间片。throttled_time被限流的总时长单位是纳秒。如果nr_throttled和throttled_time都在快速上涨说明容器正处于“想用更多CPU却用不了”的状态配额正在起作用。如果这两个值一直是0或长期不增长说明容器的CPU使用量还没有触达配额那就不算“受限状态”也是正常的。在cgroup v2下cpu.stat的字段略有不同常见的有usage_usec 12345678 user_usec 10000000 system_usec 2345678 nr_periods 1000 nr_throttled 800 throttled_usec 50000000核心看nr_throttled和throttled_usec就够。4.4 监控与告警的落地建议把docker stats当生产监控手段肯定不够它只能看实时值不能追溯历史趋势。生产环境建议用cAdvisor或Prometheus的容器指标采集把CPU使用率、限流计数这些数据落到时序数据库。需要重点关注的指标有container_cpu_usage_seconds_total容器累计CPU使用时间可以换算成实时使用率。container_cpu_cfs_throttled_periods_totalCPU被限流的周期累计数。container_cpu_cfs_periods_total总执行周期数。如果限流比例持续偏高比如被限流周期数占总周期数的50%以上说明容器的CPU配额已经严重不足业务性能可能会受影响。这时候要么扩容实例要么提高配额而不是继续把容器捂在低配额下硬抗。5. 几个容易踩的坑经验总结与生产配置建议5.1 误把 --cpu-shares 当硬上限这是被问得最多的问题。很多人给容器设置了--cpu-shares256以为CPU最多只能用四分之一结果容器跑满整个宿主机于是怀疑Docker出了bug。问题不在Docker在于理解。--cpu-shares只在CPU竞争时按比例生效。如果宿主机CPU资源充足没有其他容器和宿主进程在抢时间片设多低的shares都拦不住这个容器吃满CPU。想要硬性限制必须用--cpus或--cpu-quota。我刚入门时也用错过。当时给一个后台任务容器设了--cpu-shares128以为很安全结果半夜任务批量跑起来宿主机CPU被打到100%线上数据库跟着遭殃。后来补上了--cpus1才真正把这个风险锁死。这个教训我一直记到现在。5.2 quota 限制的是“总时间”线程并发下的响应变化--cpus1并不等于“1个核一直可用”它的意思是每个100ms周期内最多使用100ms的CPU时间。如果容器内有8个线程全部处于可运行状态它们会在这100ms内抢占这100ms的额度每个线程分到的时间就很少线程调度延迟明显增大。这会导致一种现象配额没有改业务CPU使用率也不算高但响应时间变长、接口毛刺变多。原因就是配额限制了CPU时间总量却没有限制“线程数”和“并发”。在高并发线程的Java应用中尤其明显。应对思路是限制CPU配额的同时控制容器内的核心线程数别让线程池瞎调度。比如Java应用配合-XX:ActiveProcessorCount2和线程池setCorePoolSize让业务层的并发度和CPU配额相匹配。5.3 cgroup v2 和 Docker Desktop 的环境差异生产环境大多是Linuxcgroup v1/v2的差异相对可控。但如果你在macOS或Windows上用Docker Desktop做开发会多一层“虚拟机”的隔阂。Docker Desktop基于轻量级虚拟机运行Docker引擎你可以在Docker Desktop的Settings - Resources里设置虚拟机能使用的总CPU核数。即使你后来通过--cpus0.5给某个容器限了CPU这个限制也是发生在VM内部的VM本身不能再从宿主机获得超过总配额限制的CPU。所以开发环境下如果发现容器CPU表现和预期不一致先检查Docker Desktop的Resources设置再检查容器本身的cgroup限制。这两层是叠加关系只调一层容易产生误判。另外Docker Engine版本太老、宿主机内核太老也会影响CPU配额的行为方式。我建议生产环境至少使用Linux 4.15以上内核、Docker 20.10以上版本cgroup v2的兼容性会好很多。5.4 给集群做 CPU 预算别让所有容器配额总和远超宿主机CPU限制是每个容器单独配的Docker不会自动检查“所有容器的配额总和是否超过宿主机核数”。所以部署时要有一笔账宿主机8核你给A容器配4核、B容器配3核、C容器配2核、D容器配2核总和11核远超物理核数。这种情况下如果所有容器同时高峰期运行CPU会变成竞争状态这时候--cpu-shares就会重新成为决定性因素。配置时我的经验是给宿主机系统预留15%~20%的CPU余量用于内核、监控agent、SSH、日志采集等。所有在线业务容器的--cpus配额总和控制在宿主机核数的70%~80%以内。离线任务单独划区或者使用--cpuset-cpus绑定到指定核心避免干扰在线业务。关键容器额外提高--cpu-shares保证激烈竞争时仍然有优先级。这套思路本质上是在做“容量预算”和内存规划是一个道理只不过CPU超卖不像内存超卖那样容易触发OOM它的代价是延迟劣化这种劣化往往更加隐蔽。5.5 资源限制的边界它替代不了代码排查最后必须强调一句CPU限制是故障隔离手段不是性能优化手段。如果业务代码本身有死循环、无底线的重试、频繁Full GC限CPU只是让这些问题的破坏半径变小不会让问题消失。我经历的那次事故事后给容器加了限制但这只是止住了血。真正的根因是那段死循环代码的边界条件写错了。加了限制之后服务确实不会拖垮宿主机了但容器内CPU被打满是事实用户体验依然会有损。所以正确姿势是“一手治标一手治本”线上先加资源限制稳住整体稳定性线下立刻排查代码、修复逻辑、补测试用例。两件事都要做缺一不可。如果你正在给一套存量容器系统补CPU限制我的建议是从核心业务容器开始先定硬上限再压测验证再叠加shares优先级最后把监控和告警配上。这样即使后续某个容器再次出问题你也不用像我当年一样大半夜被告警叫醒一边心悸一边猜是哪个容器在“偷走”整台宿主机的CPU。
返回列表