结论先行
K8s 的 resources.limits 只覆盖 CPU 和内存,磁盘 I/O 是容器世界里最后一条「无人限流」的公共资源。两个容器共享同一块云盘时,批处理服务可以把 IOPS 吃光,在线服务被「饿」到写延迟翻 15 倍,而 CPU、内存、df 全绿。cgroup v2 的 io.max(绝对限额)与 io.weight(相对权重)是内核层面唯一的两个旋钮,但 K8s 至今没有把它们暴露成任何资源声明字段——要落下去,只能靠 privileged init 容器或 DaemonSet 直接写 cgroup 文件。本文给一套可复现的做法,外加四个反直觉的坑。
一、现象:凌晨批处理窗口,在线服务写延迟从 10ms 顶到 170ms
环境:6 节点(c7.2xlarge,K8s 1.28.9,containerd 1.7.13,内核 5.15.0-91,cgroup v2),云盘 ESSD PL1(IOPS 上限 5000 / 204 MB/s),同一块盘挂给两个 Pod:
order-online:在线订单服务,4C/8G,稳态随机写 IOPS 约 300~500;batch-sync:凌晨 02:00–04:00 跑全量对账同步,单流 4MB 顺序写为主,峰值 IOPS 3800~4500。
症状(每周复现,持续约 90 分钟):
order-online写路径 P50 稳定 8ms,P99 从 10ms 跳到 130~180ms,慢请求几乎全是落库写;- 节点
iowait冲到 65~75%,但 CPU 利用率只有 40%、内存 working set 没动; iostat -x 1看整盘:%util顶到 99%+、await从 2ms 涨到 40ms——盘在排队;- 按「CPU / 内存 / GC / 连接池」常规排查全空,最后把
batch-sync的 I/O 曲线与在线 P99 尖峰叠在一起,时间轴完全对齐。
一句话根因:块设备是节点级共享资源,K8s 对它没有任何配额概念,谁写得凶谁赢。
二、根因:io controller 的两个旋钮,K8s 一个都没接
cgroup v2 的 io controller 提供两个正交的机制:
io.max——绝对限额。按块设备维度限rbps/wbps(读写带宽,字节/秒)和riops/wiops(读写 IOPS)。语法是「MAJ:MIN 参数 值」,多设备用空格拼接。超限的 I/O 在内核队列里排队,不是报错——所以应用侧只表现为「变慢」,和 CFS 配额把 CPU 切成周期等待是同一个模型:限流是排队,不是拒绝。io.weight——相对权重(1~10000,默认 100)。当设备被争用时,内核按权重比例分配带宽;设备不忙时权重完全不生效。
关键背景:K8s 的 resources API 没有 io 字段,kubelet 也不写这两个文件。这意味着「给批处理容器限 I/O」这件事,必须在节点侧、在 cgroup 文件层面做。
三、做法:privileged init 容器写 io.max,5 分钟可复现
以把 batch-sync 限制为「写带宽 ≤ 80MB/s、写 IOPS ≤ 800」为例:
1)确认 cgroup 版本与设备号
# 节点上
stat -fc %T /sys/fs/cgroup/ # cgroup2fs = v2
lsblk -ndo NAME,MAJ:MIN,SIZE /dev/nvme0n1
# 输出例如 nvme0n1 259:0 100G → MAJ:MIN = 259:0
2)给批处理 Pod 加 privileged init 容器(在应用容器启动前把限额写进本 Pod 的 cgroup)
initContainers:
- name: io-limit
image: busybox:1.36
securityContext:
privileged: true
command: ["sh", "-c",
# 容器 cgroup 路径由 cgroup 层级决定;K8s 1.28 + cgroup v2 下即 /sys/fs/cgroup/kubepods/burstable/pod<uid>/
"echo '259:0 wbps 83886080 wiops 800' > /sys/fs/cgroup/io.max && cat /sys/fs/cgroup/io.max"]
注意:init 与 app 容器共享同一个 Pod cgroup,init 里写的 io.max 对 app 容器同样生效——这正是我们想要的。
3)验证限额真的生效
# 容器内跑 fio 打满,确认速率被压在 80MB/s 附近
fio --name=t --rw=write --bs=4M --size=2G --numjobs=4 --runtime=30 --group_reporting
# 节点侧看该 cgroup 的累计量(v2 的 io.stat 按 MAJ:MIN 分列)
cat /sys/fs/cgroup/kubepods/burstable/pod<uid>/io.stat
4)监控补齐(这次事故最大的教训:我们看的是整盘 iostat,看不到 per-cgroup 排队)
- 采集
io.stat的rbytes/wbytes/rios/wios,按 cgroup 算每容器 IOPS 曲线; - 告警规则建议:整盘
await > 10ms持续 60s 且 某单容器 IOPS 占比 > 60%。
结果:限额生效后,批处理窗口内 order-online P99 从 130~180ms 回落到 12~15ms,iowait 峰值从 72% 降到 30% 左右;批处理任务总耗时从 86 分钟拉长到 102 分钟——牺牲的是批任务的墙钟时间,保护的是在线 P99,这笔账是划算的。
四、踩过的坑:四个反直觉点
io.weight只在争用时生效。把在线服务设成 weight=1000、批处理设 100,空闲时在线服务照样吃满整盘——权重不是保障,是「打架时的分赃比例」。要硬隔离只能用io.max。- v1 / v2 文件名完全不同。cgroup v1 是
blkio.throttle.write_bps_device(259:0 83886080格式),v2 是io.max(259:0 wbps 83886080格式)。脚本不判版本直接写,一半节点静默失败——我们第一批 3 台老节点就是 v1,脚本里加stat -fc %T分支后才全量生效。 io.max限的是块设备,文件系统层不感知。ext4/xfs 的writeback脏页回刷也走这条限流,所以你会看到「应用没在写,延迟却高」——那是 page cache 回刷被限流排队了,别急着怀疑应用。- 监控盲区:
node_exporter的 disk 指标是整盘视角,kubectl top根本没有 I/O 列。事故里我们盯着 CPU/内存看了两天,最后靠iostat全量曲线对齐时间轴才定位。per-cgroup 的io.stat必须自己加采集。
五、结论与可复用清单
- 共享盘上「在线 + 批处理」混部,默认给批处理容器设
io.max(硬上限)而不是io.weight(软权重); - 落地路径:privileged init 容器写本 Pod cgroup 的
io.max,语法MAJ:MIN 参数 值; - 限额值参考:让批处理在目标时长内跑完所需的带宽 × 1.2 余量,别拍脑袋;
- 验证三件套:
fio压测看速率封顶、io.stat看累计量、业务 P99 看尖峰消失; - 监控:per-cgroup IOPS 采集 + 「整盘 await 高且单容器占比高」的复合告警;
- 与 CPU 配额(
cpu.max)、内存(memory.max)同属「内核预算线」模型:限流是排队不是拒绝,监控全绿不等于没被限。