容器资源限制与 cgroup 深入:CPU/内存/IO 配额与 OOM

深入 Linux cgroup v1/v2 机制,系统讲解 Docker 的 CPU(--cpus/--cpu-shares)、内存(--memory/--memory-swap)、IO(--blkio)配额原理,剖析 OOM 与 oom-score-adj、swappiness 与 page cache 的影响,并给出预留 vs 限制(requests/limits)思路与资源监控告警实践。

前置阅读:建议先阅读 Docker 容器监控与日志实践 与 Docker 容器排障与调试实战。

关键概念:容器资源限制的根基是 Linux cgroups(控制组)——内核把进程组关进"配额笼子",分别约束 CPU 份额、内存上限、IO 带宽与设备访问。docker run --cpus/--memory/--blkio 只是 cgroup 参数的友好封装;理解 cgroup v1/v2 与 OOM 判定,才能正确设置"预留 vs 限制"并避免误伤业务。


1. cgroup:内核级的配额笼子

1.1 cgroup 是什么

cgroup 把一组进程按资源维度隔离并限流:
  cpu     → 权重/上限(CFS 配额)
  memory  → 上限/软限制/swap
  io      → 读写带宽/IOPS
  pids    → 进程数上限
  devices → 设备访问白名单

容器 = 一个独立的 cgroup 子树
  docker run --cpus=2 → 写入 cgroup 的 cpu.max/cfs_quota_us
  docker run --memory=1g → 写入 cgroup 的 memory.max/limit_in_bytes
# 查看当前宿主机的 cgroup 版本(v1 或 v2)
stat -fc %T /sys/fs/cgroup
# tmpfs  → cgroup v1(老默认)
# cgroup2fs → cgroup v2(现代主流)

# 查看 Docker 是否启用了 cgroup v2
docker info | grep -i cgroup
# Cgroup Version: 2

1.2 cgroup v1 与 v2 的区别

维度cgroup v1cgroup v2
层级每个控制器一个树单一统一树(unified hierarchy)
内存与 CPU分属不同层级同一树统一管理
内存回收无 memory.high 语义(有 soft_limit)有 memory.high 软限制 + memory.max 硬限制
是否支持 memory.swap.max部分原生支持
线程模型进程级支持线程级(cgroup.threads)
现代发行版RHEL7 时代默认系统d 主流发行版默认

一句话:除非你还在跑 RHEL7/CentOS7,否则默认按 cgroup v2 来思考——它在内存分层(high/max)、IO 权重与容器友好度上全面优于 v1。


2. CPU 配额:–cpus 与 –cpu-shares

2.1 硬上限 vs 权重

参数语义是否硬性典型值
--cpus=2最多用 2 个核(quota)硬性按容量规划
--cpuset-cpus=0-3只允许在指定 CPU 上跑亲和性低延迟/实时任务
--cpu-shares=512相对权重(竞争时按比例分)软性默认 1024
--cpu-period/--cpu-quotaCFS 底层参数(一般交给 –cpus)硬性100000us 周期
# 限制最多 2 核(cgroup v2 等价于 cpu.max 200000)
docker run --cpus=2 --rm ubuntu nproc --all   # 宿主机核数
docker run --cpus=2 --rm busybox sh -c "nproc"

# 权重示例:web 512 / worker 1024,争抢时 worker 拿 2 倍
docker run -d --cpu-shares=512 --name web myapp
docker run -d --cpu-shares=1024 --name worker myapp

# 查看容器 cgroup cpu 配置
docker inspect -f '{{.HostConfig.NanoCpus}} {{.HostConfig.CpuShares}}' web

2.2 验证 CPU 限制真的生效

# 容器内烧 CPU,观察是否被限在 2 核
docker run --rm --cpus=2 debian bash -c \
  "apt-get update && apt-get install -y stress-ng && stress-ng --cpu 4 --timeout 30s"
# 另开终端:
docker stats <cid>   # 应看到 CPU% 被限制在 200% 左右
注意:--cpus=2 限制的是"同时占用的核数",不是"只给 2 个核"
  单线程应用永远吃不满 --cpus=8
  多线程应用即使配额 8 也会被限到 800%

3. 内存配额与 OOM

3.1 内存参数矩阵

参数含义说明
--memory=1g硬上限超出即触发回收/OOM
--memory-reservation=512m软限制低于硬限制的温柔建议
--memory-swap=2g总上限(内存+swap)单独设 swap 需同时设 memory
--memory-swappiness=0禁用换页倾向0-100,越高越倾向 swap
--oom-kill-disable=true关闭 OOM 杀进程谨慎:可能挂死容器
# 限制内存 1G,swap 总上限 2G
docker run -d --memory=1g --memory-swap=2g myapp

# 禁用容器内 OOM 杀死(容器级 OOM 只杀 cgroup 内进程)
docker run -d --memory=1g --oom-kill-disable=true myapp

3.2 OOM 判定的完整链路

内存超限 → 内核触发该 cgroup 回收
  回收不动 → 触发 cgroup OOM-killer
  在 cgroup 内挑选进程杀死(按 oom_score 高低)

oom_score = 内存占用 + oom_score_adj(加权)
  Docker 默认把容器进程 oom_score_adj 设为较高值
  --oom-score-adj 可覆盖(-1000 ~ 1000)
# 查看容器进程的 oom_score_adj
docker inspect -f '{{.State.Pid}}' myapp > /tmp/pid
cat /proc/$(cat /tmp/pid)/oom_score_adj

# 显式给某个容器调高被杀优先级(低内存服务先牺牲)
docker run -d --memory=1g --oom-score-adj=500 myapp

# 查看内核 OOM 日志(容器被杀的证据)
journalctl -k | grep -i "killed process" | tail

3.3 JVM 与容器内存的经典坑

JVM 默认按"宿主物理内存"计算堆 → 在 32G 宿主机上跑 1G 容器
  JVM 堆会申请远超容器上限 → 容器先被杀(OOM),而应用其实正常

修复:-XX:MaxRAMPercentage=75.0(按 cgroup 内存的 75% 算堆)
  Java 10+ 默认 -XX:+UseContainerSupport,但要显式给比例
FROM eclipse-temurin:17-jre-alpine
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]

一句话:内存限制的关键不是"设了没设",而是应用是否感知 cgroup——JVM 这类按物理内存估堆的运行时,不配 MaxRAMPercentage,设了限额也会先被 OOM 杀掉。


4. IO 配额:–blkio 与 io 控制器

4.1 IO 限制参数

参数语义示例
--device-read-bps读带宽上限--device-read-bps /dev/sda:10mb
--device-write-bps写带宽上限--device-write-bps /dev/sda:10mb
--device-read-iops读 IOPS 上限--device-read-iops /dev/sda:1000
--device-write-iops写 IOPS 上限--device-write-iops /dev/sda:1000
--blkio-weight=500相对权重(争抢时按比例)默认 500
# 限制写吞吐到 20MB/s、写 IOPS 1000
docker run -d --device-write-bps /dev/sda:20mb \
  --device-write-iops /dev/sda:1000 myapp

# 验证:容器内 dd 观察写入速率
docker exec -it <cid> dd if=/dev/zero of=/tmp/test bs=1M count=100
IO 限制要点:
  - 需明确指定 /dev/sda 等块设备路径(overlay 数据在 /var/lib/docker 所在盘)
  - cgroup v1 用 blkio,v2 统一为 io 控制器
  - 读缓存未命中时才计带宽;命中的读不限速

5. swappiness 与 page cache

5.1 容器内 swappiness

--memory-swappiness 控制 cgroup 的换页倾向:值越小,越倾向于回收匿名内存而非换到 swap;数据库类应用常设为 0,避免热数据被换出:

# 数据库容器:不换页(保持热数据在内存)
docker run -d --memory=4g --memory-swap=4g \
  --memory-swappiness=0 mysql:8.4
注意:
  swappiness=0 不代表绝不 swap,只是"不主动换出匿名页"
  当 cgroup 内存接近硬上限仍会触发换出
  设置 memory-swap=memory(swap 为 0)更彻底

5.2 page cache 与可回收内存

容器的 page cache(文件缓存)算进 --memory 统计
  读文件多 → RSS 低但 cache 高 → 接近上限时内核先回收 cache
  回收 cache 不够才换页 / OOM

排查:docker stats 的 MEM USAGE 含 cache
  用 --memory 上限时预留 20-30% 给 page cache
  或用 memory.max 之外的 memory.high 做软水位(v2)
# 查看容器内存细分(含 cache 与匿名内存)
docker exec -it <cid> cat /sys/fs/cgroup/memory.stat
# v2 字段:anon / file / kernel_stack / slab / ...
docker exec -it <cid> cat /sys/fs/cgroup/memory.current

6. 预留 vs 限制:requests/limits 的思想

6.1 四个象限的语义

组合含义适用
有 requests、无 limits只保证调度配额,不封顶实验性服务
有 limits、无 requests只限峰值,不保证调度不推荐(饿死风险)
requests = limits确定型资源关键服务(推荐)
requests < limits调度从低、峰值可冲高批处理/可弹性服务

6.2 在 Docker 与 K8s 的对应

# Docker 侧:--memory-reservation 近似 requests
docker run -d --memory=2g --memory-reservation=1g myapp
# K8s 侧(此思想直接映射)
resources:
  requests:            # 调度保证(预留)
    cpu: "1"
    memory: "1Gi"
  limits:              # 硬限制
    cpu: "2"
    memory: "2Gi"
关键结论:
  requests 决定"能不能跑在这台机器上"(调度)
  limits 决定"跑起来后最多能占用多少"(封顶)
  两者都设且合理,才能避免:调度进来了,却因邻居超限而 OOM

一句话:预留(requests)是给调度器的承诺,限制(limits)是给内核的封顶——先按峰值设 limits,再按均值设 requests,避免"承诺 4G 却只给 1G"。


7. 资源监控与配额告警

7.1 docker stats 与实时查看

# 实时查看所有容器资源
docker stats --no-stream

# 单容器 CPU/内存/网络明细
docker stats --no-stream --format \
  "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}"

# 容器内自己看 cgroup
docker exec -it <cid> cat /proc/1/cgroup

7.2 cAdvisor + Prometheus 告警

# docker-compose 监控栈片段
services:
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:v0.49.1
    privileged: true
    ports: ["9100:8080"]
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
      - /dev/disk/:/dev/disk:ro
# 容器 CPU 使用率超过 limits 的 85% 持续 5 分钟
sum by (container_name) (
  rate(container_cpu_usage_seconds_total{container_name!=""}[5m])
) / on (container_name) group_left container_spec_cpu_quota
  > 0.85

# 容器内存超过 limits 的 90%
(container_memory_usage_bytes / container_spec_memory_limit_bytes) * 100 > 90
# 通过 Docker API 获取容器资源(脚本告警用)
docker inspect -f '{{.State.Status}}' myapp
docker stats --no-stream --format "{{.Name}} {{.MemPerc}}" \
  | awk '{if ($2+0 > 90) print $1, "内存超限"}'

7.3 配额告警清单

□ CPU 利用率贴近 limits(>85%)持续 5m → 扩容或提额
□ 内存接近 limits(>90%)→ 排查泄漏 / 提高 requests
□ 反复 OOM(日志 killed process)→ 应用侧修复 + 预留抖动
□ IO 打满(iostat 观察)→ 拆分存储 / 限 IO
□ 容器无 limits → 普查并补齐(防噪声邻居)

8. 最佳实践清单

□ 所有生产容器必须设 --cpus 与 --memory(并配 requests/limits)
□ JVM/Python(GC-heavy)/Node 等运行时显式感知 cgroup
□ 数据库/缓存容器:swappiness=0、swap=0、预留 page cache
□ 内存限制留 20-30% 余量(cache + 突发)
□ requests 按均值、limits 按峰值,避免承诺过高
□ 监控 CPU/内存逼近 limits,OOM 事件进告警
□ 排障时先看 oom_score_adj 与 memory.stat,再下结论
□ 用 cgroup v2 发行版(systemd 生态),统一 memory.high/max

9. 总结

资源核心参数机制常见坑推荐实践
CPU--cpus / --cpu-sharesCFS quota / weight单线程吃不满按并发线程评估
内存--memory / --memory-swapcgroup 上限 + OOMJVM 不感知 cgroupMaxRAMPercentage + 预留
IO--device-*-bps/iopsio/blkio 控制器忘记指定设备绑定数据盘
换页--memory-swappinessswap 倾向热数据被换出DB 设 0
调度requests/limits调度承诺 + 封顶承诺高于供给均值 requests、峰值 limits

容器资源治理的成熟度分三步:第一步是"设了限额"(硬性约束,杜绝噪声邻居);第二步是"应用感知 cgroup"(运行时不再与内核误判资源);第三步是"监控与预留"(把限额与请求变成容量模型)。当你能回答"这个容器 CPU 能吃满多少、内存峰值在哪、OOM 时该牺牲谁",资源管理就从"玄学"变成了可预测的工程。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. Docker 容器排障与调试实战:退出码、OOM、exec 与调试工具链
  2. Docker 镜像供应链安全:SBOM、签名、扫描与 SLSA 合规
  3. Docker 容器监控与日志实践:cgroups 资源限制、Prometheus 与日志驱动