一台 8 核 32G 的宿主机上挤了十几个容器跑了大半年相安无事某天其中一个做图片转码的服务遇上大批量任务内存一路涨到十几 G把整台机器的可用内存吃干抹净连临时登上去看情况的连接都被内核掐断最后只能硬重启。事后复盘大家的结论出奇一致得给容器加资源限制。于是运维在启动参数里补了一行-m 2g第二天这个服务开始每隔几个小时莫名其妙重启一次日志里干干净净什么线索都没有。这就是Docker 的资源控制最典型的两幅面孔不设限制一个容器能拖垮整台机器设了限制但没搞懂语义同样的服务会被自己设的限掐死。资源控制从来不是填个数字这么简单它牵扯到三件事——限制的执行者是谁、限制的语义是什么、限制的计量口径是什么。这三件事里任何一件没对齐参数写了也白写。下面这套内容适合已经把容器跑起来、但还没系统梳理过资源边界的同学也适合那些被 OOM 和 CPU 抢占折腾过几轮、想彻底把这块吃透的老手。1. 资源限制的真实执行者是 cgroupDocker 只负责翻译刚接触容器的人容易有个错觉以为限制是 Docker 守护进程在做事——它盯着容器的用量超了就踩一脚刹车。真实情况完全不是这样。Docker 把-m、--cpus这些参数翻译成内核 cgroup 子系统里的具体数值写进去之后所有的计量、统计、掐断动作全部由内核完成Docker 自己退到一边连监督的角色都不算。这个认知非常关键因为它直接决定了当你发现限制没生效时该往哪儿查不是去翻 Docker 的日志而是去看 cgroup 文件系统里到底写了什么值。理解这一点还有个附带好处你会明白为什么限制是硬的。内核在分配内存页、调度 CPU 时间片的时候本来就要查 cgroup 的记账数据限制就长在这条必经路径上绕不过去。唯一的例外是某些文件系统层的写入路径这个后面讲磁盘 IO 的时候会单独说。1.1 cgroup v1 和 v2 的目录结构差异决定了你的排查命令能不能跑通同一台机器上cat /sys/fs/cgroup/memory/docker/容器ID/memory.limit_in_bytes有输出换一台机器同样的命令报No such file or directory八成是两代 cgroup 混着用了。v1 是每个子系统一棵独立的树memory 归 memory、cpu 归 cpu路径分散在/sys/fs/cgroup/memory/、/sys/fs/cgroup/cpu/、/sys/fs/cgroup/blkio/下面v2 统一成一棵cgroup2树所有控制器挂在同一个层级里文件名也换了一套。先确认版本docker info | grep -i cgroup # Cgroup Version: 2v2 下容器对应的目录通常在/sys/fs/cgroup/system.slice/docker-完整ID.scope/文件名的对应关系大致是v1 文件v2 文件含义memory.limit_in_bytesmemory.max内存硬上限memory.soft_limit_in_bytesmemory.high内存软限节流memory.memsw.limit_in_bytesmemory.swap.maxswap 上限cpu.cfs_quota_us / cpu.cfs_period_uscpu.maxCPU 配额配额 周期一行cpu.sharescpu.weightCPU 相对权重1–10000blkio.throttle.read_bps_deviceio.maxIO 限速pids.maxpids.max进程数上限名称没变这个表建议直接存进笔记。我见过太多次排查到一半卡在文件不存在上其实只是把 v1 的路径套到了 v2 的机器上。另外cpu.shares到cpu.weight不是简单搬值Docker 在写入时会按比例把 2–262144 的 shares 区间映射到 1–10000 的 weight 区间所以你在 v2 机器上cat cpu.weight看到 39 这种小数字不用惊讶它对应的就是默认的 1024。1.2 三种限制语义权重、配额、硬上限混用就是事故源头cgroup 提供的限制手段归根到底就三类搞清楚它们的脾气后面所有参数都能自己对号入座。权重weight / shares是抢的时候按比例分只在资源紧张、有竞争的时候才起作用。机器闲着一个 shares2 的容器照样能用满所有核。它保证的是公平不是上限。配额quota是一个周期内最多用多少。CPU 的--cpus1.5本质就是每 100ms 最多用 150ms 的 CPU 时间这是真正意义上的上限。硬上限limit是超过就动手。内存的--memory属于这一类超了不是限速是直接杀。把权重当上限用是最常见的误区。有同学给核心服务设了--cpu-shares2048然后问为什么它还是会被隔壁的压测容器拖慢——因为压测容器可能开了 8 个线程在抢权重高只意味着单次分配时排前面不代表总量受控。要控总量只能用配额。1.3 参数写进去之后别信 docker run 的回显docker run成功返回不代表限制真的落地了。内核在某些情况下会拒绝写入或者静默降级比如宿主机没开 swap 时写--memory-swappiness就是无效动作比如老内核不支持某个控制器。养成一个习惯容器起来之后找到它的 cgroup 目录把关键文件 cat 一遍。CID$(docker inspect -f {{.Id}} my_container) cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max看到的值和你预期的一致这条限制才算真正挂上了。这一步花不了十秒钟能省掉后面几个小时的扯皮。2. CPU 控制shares、quota、cpuset 是三种完全不同的工具CPU 这块的参数看着多其实就三个维度分多少权重、最多用多少配额、允许在哪几个核上跑绑核。三者解决的是完全不同的问题选错了不是效果差一点而是根本解决不了你要解决的问题。下面把每个维度拆开讲包括它们的计算过程。2.1 --cpu-shares 的本质是排队顺序不是饭量默认值 1024。假设机器上有两个容器A 是 1024B 是 512当两个都在满负荷抢 CPU 时A 拿到的 CPU 时间大约是 B 的两倍。但只要 B 闲下来了A 就能用满全部核心权重不会拦住它。这个特性的实际价值在于保底不封顶的场景。比如一台机器上跑着一个在线服务和一批离线批处理任务在线服务平时用量很小但流量高峰时必须优先拿到 CPU。这时候给在线服务设--cpu-shares2048给批处理设--cpu-shares512高峰期在线服务能抢到四分之三以上的算力闲时批处理又能吃满剩下的空闲。如果用--cpus给在线服务设死上限反而浪费了它闲置时的余量。权重还有个隐蔽的坑它是按同级 cgroup 之间的相对值算的如果你在容器里又嵌套了 cgroup或者宿主机上还有 systemd 管理的其他 slice 在抢实际分配比例会和你想的不一样。生产上我一般建议把权重当成软优先级别指望它精确到小数点。2.2 --cpus 背后的 period 与 quota 换算--cpus1.5是一个语法糖Docker 会把它拆成--cpu-period100000 --cpu-quota150000。period 是统计周期单位微秒默认 100msquota 是一个周期内允许使用的总时间。150000/100000 1.5这就是1.5 个核的含义。手工算一遍的意义在于当你需要更细的粒度时知道该动哪个参数。比如某个服务对延迟极其敏感100ms 的周期意味着在最坏情况下它可能要等上接近 100ms 才能拿到下一轮配额表现就是周期性的卡顿。这时候可以把 period 调小到 1000010msquota 相应设成 15000总量不变但调度粒度细了十倍卡顿的毛刺会小很多。代价是调度器切换更频繁上下文切换开销上升所以别盲目调小10ms 已经是很激进的值了。还有一个反直觉的行为quota 被用完时容器不是被降速而是被完全冻结直到下一个周期开始。所以--cpus0.5的容器跑满时你会看到它跑一阵停一阵而不是匀速地慢。做压测的时候如果看到这种锯齿状的吞吐曲线别怀疑是网络问题先看看是不是 CPU 配额打满了。另外注意--cpus和--cpu-quota同时出现时后写的会覆盖前者别以为两个都生效。2.3 cpuset 绑核延迟敏感型服务的最后一招--cpuset-cpus2,3把容器死死摁在两个物理核上。和配额的区别是配额是时间上切绑核是空间上隔离。绑核之后容器不会在其他核上留下缓存污染也不会被其他核上的任务打断L1/L2 缓存的命中率明显更稳对高频交易、实时音视频这类场景提升很实在。代价也同样明显——资源利用率直线下降。绑了两个核哪怕容器只用 5% 的算力那两个核也不能给别人用。所以在通用业务上我一般不用绑核只在下面几种情况才考虑服务有明显的尾延迟问题P99 比 P50 高出好几倍且排查下来是缓存抖动宿主机上跑着多种负载需要做物理隔离防止互相干扰多路 CPU 机器上需要保证容器的内存访问落在同一个 NUMA 节点减少跨节点访问。NUMA 这一点容易被忽略。--cpuset-cpus绑了 CPU但内存分配不一定会跟着走需要配合--cpuset-mems把内存节点也绑上否则容器还是在做跨节点内存访问性能提升会打折。查 NUMA 拓扑用lscpu | grep NUMA或者numactl -H能看到每个节点上挂了哪些核。3. 内存硬限制、软限制和 swap 构成一个三角内存是容器资源控制里最容易出事的一块因为它没有降速这个中间态要么正常要么被杀。而且这里的参数之间存在联动关系单独看某一个参数的文档往往会理解错。我把它拆成三块硬限、软限、swap最后讲 OOM 现场的判断方法。3.1 --memory 限的是 cgroup 记账值不是 free 看到的数字-m 2g写进memory.max之后内核统计的不只是应用的堆内存还包括页缓存page cache、内核态的内存分配、tmpfs 占用的空间等等。这解释了一个高频疑问我在容器里free -m看到才用了 800M为什么被 OOM 杀了因为free里的 available 是宿主机视角的全局数据容器内的free在多数实现下读的还是宿主机的/proc/meminfo除非用了 lxcfs 之类的方案做隔离。判断容器真实用量要看memory.current和memory.statcat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.current cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.stat | grep -E anon|file|slabanon是匿名页也就是应用真正分配的内存file是页缓存这部分在内存紧张时可以被回收不算不可让的占用。如果anon离memory.max还很远但容器还是被杀那问题多半出在 slab 或者 tmpfs 上。我遇到过一次容器往/dev/shm里写临时文件/dev/shm默认只有 64MB 但它是 tmpfs写满之后继续写会直接吃容器内存额度最后 OOM。这类问题不看memory.stat根本定位不到。3.2 --memory-swap 的三种取值含义天差地别这个参数的语义是最容易搞混的因为它是一个总量而不是swap 的额外量。规则如下--memory--memory-swap实际效果2g不设置swap 上限默认等于 memory即可用 1g swap2g3g内存 2g swap 1g2g2g禁用 swap总量等于内存2g-1swap 不限制不推荐未设置任意值参数无意义不生效记住一句话就行swap 的可用量 memory-swap 减去 memory。所以想彻底关掉某个容器的 swap不是设--memory-swap0而是把它设成和--memory一样大。设成 0 在很多版本上会被当成非法值或者被忽略。生产上我的默认做法是禁用容器 swap。理由很朴素容器里的应用大多没针对 swap 做过调优一旦开始换页响应时间会出现数量级的抖动而且容器的可预测性会彻底丧失。宁可让它干脆利落地被 OOM 杀掉然后重启也不要让它挂在那儿半死不活地拖着。3.3 OOM 杀掉的是容器里的进程不是容器本身cgroup 的 OOM killer 触发时内核会在该 cgroup 内挑一个进程杀掉通常是占用内存最多的那个。这个进程死掉之后容器的主进程PID 1如果也挂了容器就退出如果主进程还在容器会带着残缺的状态继续跑——这种情况比直接退出更危险因为你可能几天之后才发现某个 worker 早就不在了。现场判断可以按下面几步走# 1. 看容器退出码137 128 9说明收到 SIGKILL docker inspect -f {{.State.ExitCode}} my_container # 2. 看内核日志里有没有 oom-kill 记录 dmesg -T | grep -i oom\|killed process # 3. 看 cgroup 自己的计数 cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.events # 关注 oom_kill 和 oom 两个计数memory.events这个文件在 v2 下非常好用oom计数表示触及上限的次数oom_kill表示真的动手杀了几次。这两个数字不一样就说明有过触及但没杀的情况通常是靠回收页缓存挺过去了说明限制设得偏紧但还没到临界点。还有一个必须提醒的点--oom-kill-disable这个参数强烈不建议在业务容器上用。它的效果是内存超限时不杀进程而是让分配失败——听起来温和实际上应用往往没处理好malloc返回 NULL 的情况表现是各种诡异的崩溃和内存踩踏排查难度比 OOM 高一个量级。只有在容器里跑的是绝对不能中断的数据库这类场景才考虑配合--memory的精确估算来使用。4. 磁盘 IO 与存储配额最容易设了等于没设的一块CPU 和内存的限制基本是可靠的磁盘这块就不一样了。相当一部分人配了--device-write-bps之后发现完全不生效原因通常不在参数本身而在于写入路径压根没走到被限速的那条路上。这个章节把机制说清楚。4.1 blkio 限速作用在块设备层不是文件系统层--device-write-bps /dev/sda:10mb这种写法限制的是内核对/dev/sda这个块设备的写入速率。如果你的容器写文件走的是 overlay2 存储驱动数据最终确实落到/dev/sda上但中间隔了文件系统的页缓存、回写机制、可能还有 LVM 或 RAID 层限速的效果会被缓冲吸收掉一大截。表现出来就是短时间的写入测不出限制长时间大文件顺序写才能看到限速生效。用dd验证的时候要注意方式docker run --rm -it --device-write-bps /dev/sda:10mb \ --mount typevolume,srciodata,dst/data \ alpine dd if/dev/zero of/data/test.bin bs1M count500 oflagdirectoflagdirect是关键绕过页缓存直接打到设备上才能真实反映限速值。不加这个参数测出来往往是几 GB/s看着像限制没生效其实是全写进缓存了。--blkio-weight是另一套机制取值 10–1000语义和 CPU 的 shares 一样是相对权重只在多个 cgroup 同时争抢同一个设备时才有意义。单容器场景下这个参数基本没有观察效果别拿它当限速用。4.2 可写层配额要看存储驱动脸色容器不断往自己的可写层写日志、写临时文件把宿主机磁盘撑爆这是运维的老大难。想用--storage-opt size10G做限制先确认存储驱动支不支持存储驱动支持 size 限制前置条件overlay2部分支持底层文件系统必须是 xfs 且挂载时开了 pquotadevicemapper支持需要 thin pool 模式btrfs / zfs支持用子卷/数据集配额实现vfs不支持—overlay2 现在是默认驱动但它在 ext4 上是不支持单容器配额的老实话很多人不知道。查一下底层文件系统df -T /var/lib/docker如果是 ext4那--storage-opt size这条路走不通。更实际的做法是把容易膨胀的目录日志、上传的临时文件、缓存挂成独立的 volume然后用 volume 所在的分区或者--mount配合 tmpfs 的 size 参数来做约束docker run -d \ --mount typetmpfs,dst/tmp,tmpfs-size512m,tmpfs-mode1777 \ my_apptmpfs 的tmpfs-size是硬约束写满就报No space left on device而且它占的是内存额度不是磁盘配合--memory一起用效果很好。当然前提是这些临时文件确实可以放内存里。4.3 日志驱动是磁盘膨胀的隐形推手还有一个和 IO 限制无关但后果一样的坑默认的json-file日志驱动不轮转一个疯狂打日志的应用能在几小时内把磁盘写满。这个问题不是靠 blkio 限速能解决的限速只会让它写得慢一点最终还是满。正确做法是在 daemon 层面配全局的日志上限{ log-driver: json-file, log-opts: { max-size: 50m, max-file: 3 } }这样单个容器最多占 150MB 日志空间。这个配置要写在/etc/docker/daemon.json里然后重启守护进程注意它只对新建的容器生效存量容器需要重建。我见过一次事故就是改了配置没重建容器出问题的偏偏是那个老容器。5. 那些不在 CPU 和内存清单上的维度资源控制不等于只看 CPU 和内存。有几个维度平时没人提出事的时候一样能让整台机器趴下而且它们的参数更隐蔽。5.1 --pids-limit 拦住 fork 炸弹容器默认能看到宿主机的全部 PID 空间理论上一个容器可以创建到系统上限的进程数。写错一个循环、某个库的递归调用、或者干脆是恶意的 fork 炸弹都能在几秒内把 PID 耗尽导致整台机器无法创建新进程。docker run -d --pids-limit 200 my_app200 这个值怎么定看应用正常运行时docker top 容器的进程数乘以 2 到 3 倍留余量。像 Nginx、PHP-FPM 这类按 worker 数起进程的服务进程数是可预估的JVM 应用虽然线程多但通常不算在 pids 里线程在 Linux 上也是任务会计入所以 JVM 的 pids 要给宽一点几百到一千都正常。超限时的报错是Resource temporarily unavailable应用层的表现可能是无法创建线程看着像内存不足实际是 pids 打满了。排查时cat /sys/fs/cgroup/system.slice/docker-$CID.scope/pids.current一看便知。5.2 文件句柄和共享内存这两个老熟人--ulimit nofile65536:65536这个参数几乎每个高并发服务都需要但默认值是 1024软和 4096硬很多镜像没改。表现就是连接数一上去就报Too many open files而ulimit -n在容器里看还是 1024。注意软硬限制的写法是软:硬只写一个数会被当成两个都设成这个值。另外这个限制是设置在容器主进程上的子进程会继承但如果应用自己调setrlimit往下调容器层面的设置也拦不住。--shm-size是另一个高频事故点。/dev/shm默认只有 64MB某些数据库、浏览器内核、Python 的 multiprocessing 都会往这里写写满之后行为非常诡异——不是报错而是各种超时和挂起。给个 256m 到 1g 通常是安全的但要注意它算在容器内存额度里--shm-size设大了--memory也得跟着放宽否则等于自己给自己挖坑。6. 从单机参数到编排配置那些写了不生效的场景单机docker run的参数好理解一旦上了 Compose 或者集群同一套语义的写法完全变了而且失效的时候往往静默无声。6.1 Compose 里 deploy.resources 在非 swarm 模式下是死的很多人这么写services: app: image: my_app deploy: resources: limits: cpus: 1.5 memory: 2G然后docker compose up起来之后一看 cgroup什么限制都没有。原因是deploy这一节本来是为 swarm 模式设计的普通的 compose 模式下会被直接忽略。有两个解法一是改用 2.x 风格的顶层字段这些字段在 compose v2 里依然有效services: app: image: my_app cpus: 1.5 mem_limit: 2g memswap_limit: 2g pids_limit: 200 ulimits: nofile: soft: 65536 hard: 65536二是加--compatibility参数启动Compose 会把deploy.resources自动翻译成上面这些字段。我倾向第一种写死更明确不依赖启动参数但如果同一份 compose 文件既要本地跑又要部署到集群那用--compatibility更省事。还有一种情况是mem_limit写在 v3 的 compose 文件里会直接报 schema 校验错误因为 v3 规范砍掉了这些顶层字段。检查一下文件头的version:字段如果是3开头的要么降回2.4要么就用deploy加--compatibility。6.2 集群里的 requests 和 limits 是两套东西到了集群编排层资源声明分成了 requests 和 limits 两个它们对应到 cgroup 的方式不一样limits对应 CPU 的 quota 和内存的 max是硬上限requests在 CPU 上对应 shares按比例换算在内存上主要用于调度决策和驱逐判断。这意味着一个很常见的误区给容器设了requests.memory1Gi就以为它至少有 1Gi 可用实际上如果limits.memory设得更高容器可以一直用到 limits 才被杀如果节点内存吃紧超过 requests 的部分最有可能先被驱逐。生产上我的习惯是内存的 requests 和 limits 设成相等换取稳定的 QoS 等级避免高峰期被优先干掉CPU 的 requests 设成日常用量limits 设成峰值的 1.5 到 2 倍留出突发空间。7. 验证怎么确认限制真的咬住了参数配完不算完得验证。验证分两层一层是 cgroup 里的值对不对另一层是压力下应用的实际表现符不符合预期。第一层是必要条件第二层才是真正说明问题的地方。7.1 三条命令做基础核对CID$(docker inspect -f {{.Id}} my_container) CGPATH/sys/fs/cgroup/system.slice/docker-$CID.scope # CPU第一列是 quota第二列是 periodmax 表示不限 cat $CGPATH/cpu.max # 内存max 和当前用量 cat $CGPATH/memory.max cat $CGPATH/memory.current # IO cat $CGPATH/io.maxcpu.max显示150000 100000就说明 1.5 核的限制挂上了。如果显示max 100000那就是没限制住回头检查是不是参数写错了位置或者被后写的参数覆盖了。顺带说一句docker stats的局限它显示的内存用量包含了页缓存所以经常出现用量接近 limit 但服务跑得好好的这种现象容易引起误判。要判断真实压力还是得看memory.stat里的anon或者干脆看应用自己的堆内存指标。7.2 压测时关注的是有没有被掐不是跑得多快CPU 限制的验证方法很简单容器里跑一个满负荷的死循环然后看宿主机的top。一个--cpus0.5的容器在top里应该稳定显示 50% 左右的单核占用如果跑到 100% 甚至更高限制就没生效。内存的验证要小心一点别直接在生产环境跑。用一个逐步申请内存的小脚本观察memory.events里oom_kill的计数变化以及容器是否在预期的大致位置退出。这个过程能帮你确认两件事限制的绝对值对不对以及应用在接近上限时的行为是否符合预期是有序退出还是直接消失。IO 的验证前面说过用dd加oflagdirect别用普通写。如果限了 10MB/s跑 500MB 的数据应该耗时 50 秒左右误差在 20% 以内都算正常因为块设备的调度有波动。8. 六个明明写了却没生效的现场最后把这几年踩过的坑集中列一下都是参数写得没错但结果不对的情况遇到类似症状可以先往这几个方向想。第一个容器里的应用看到的核数还是宿主机的。应用启动时读/proc/cpuinfo或者nproc拿到的是宿主机核数于是按 64 核去初始化线程池结果被--cpus4掐得死去活来。解决办法是用--cpuset-cpus真正绑定核心这样容器内看到的就是绑定的数量或者用环境变量把并发度显式传进去。JVM 在这方面好一些新版本会读 cgroup 的限制来自动计算默认的堆大小和 GC 线程数但前提是启动时加了-XX:UseContainerSupportJDK 10 之后默认开启。第二个JVM 堆设得比容器限制还大。容器限 2GJVM 的-Xmx写了 2G看起来合理实际上 JVM 除了堆还要用元空间、直接内存、线程栈、GC 自身的结构加起来轻松超出几百兆于是被 OOM。经验值是-Xmx设成容器内存的 60% 到 70%剩下的留给堆外。第三个--memory设得太小导致docker exec都进不去。容器内存打满时连 exec 启动一个 shell 进程都申请不到内存报错是OCI runtime exec failed。这时候只能从宿主机侧排查别在 exec 上浪费时间。第四个限制改了但容器没重建。资源参数是创建时写进 cgroup 的docker restart不会重新应用新参数。改完docker run或 compose 文件之后必须docker rm再重新创建。这个坑我踩过不止一次改了参数发现没变化折腾半天才想起来容器还是老的。第五个宿主机的内核版本太老某些控制器根本没编译进去。尤其是在一些精简发行版上blkio控制器可能不可用写进去的参数会被静默忽略。用cat /proc/cgroups看一眼每个控制器的 enabled 状态是 0 就说明没开。第六个多级 cgroup 的叠加效应。如果容器跑在一个已经被限制过的父 cgroup 里比如 systemd 的 slice实际生效的限制是取两者的更严格值。排查时如果发现容器限制比预期更狠往上翻一层看看父级设了什么。这个在多租户环境下特别容易碰到。资源控制这件事说白了就是给每个容器画一条自己的边界边界画在哪儿取决于业务形态边界有没有画上取决于你对 cgroup 的理解程度。我个人在实际操作中的体会是参数本身不难记难的是建立先验证再相信的习惯——每次配完限制花十秒 cat 一下 cgroup 文件比事后花两小时查 OOM 划算得多。
阅读完成 · 觉得有帮助?