前置阅读:建议先阅读 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 v1 | cgroup 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-quota | CFS 底层参数(一般交给 –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-shares | CFS quota / weight | 单线程吃不满 | 按并发线程评估 |
| 内存 | --memory / --memory-swap | cgroup 上限 + OOM | JVM 不感知 cgroup | MaxRAMPercentage + 预留 |
| IO | --device-*-bps/iops | io/blkio 控制器 | 忘记指定设备 | 绑定数据盘 |
| 换页 | --memory-swappiness | swap 倾向 | 热数据被换出 | DB 设 0 |
| 调度 | requests/limits | 调度承诺 + 封顶 | 承诺高于供给 | 均值 requests、峰值 limits |
容器资源治理的成熟度分三步:第一步是"设了限额"(硬性约束,杜绝噪声邻居);第二步是"应用感知 cgroup"(运行时不再与内核误判资源);第三步是"监控与预留"(把限额与请求变成容量模型)。当你能回答"这个容器 CPU 能吃满多少、内存峰值在哪、OOM 时该牺牲谁",资源管理就从"玄学"变成了可预测的工程。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。