磁盘限速与 I/O 延时:容器里磁盘读写为什么不稳定?写文件延时为什么波动大? 📅 2026/7/24 13:42:12 磁盘限速与 I/O 延时容器里磁盘读写为什么不稳定写文件延时为什么波动大实验环境Ubuntu 24.04 / 内核 6.8.0-106-generic / Cgroup v2 / Docker 29.1.3overlayfs/ 华为云 FlexusX 实例 8C16G本文全部限速实验基于 Cgroup v2 的io.max文末另附与 Cgroup v1blkio的差异说明。一、引子一个时快时慢的数据库某业务线反馈“我们的 MySQL 跑在容器里平时写入 5ms 以内但每隔一阵就会冒出几百毫秒的毛刺监控里磁盘%util也没到 100%宿主机iostat看起来挺闲。这延时是哪来的”这类问题在容器里特别常见根因往往不在磁盘够不够快而在容器和磁盘之间那几层抽象你write()写进去的到底是内存page cache还是磁盘容器被限速了没限速是只限 direct I/O还是把 buffered 写回也限了脏页dirty page堆积到阈值时被内核强制刷盘那一波抖动谁买单容器的内存配额吃紧时page cache 被回收又会怎样牵连 I/O本篇用真机命令 真实输出把 Cgroup v2 的磁盘限速、buffered I/O vs direct I/O、脏页抖动、以及内存与 I/O 的纠缠一层层扒开。二、复现 1用 Cgroup v2io.max给容器限速2.1 先找到盘的身份证major:minorCgroup v2 的io.max是按块设备限速的设备用major:minor表示。先看实验机的盘lsblk# NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS# loop0 7:0 0 1G 0 loop /root/xfsmnt# vda 253:0 0 40G 0 disk# └─vda1 253:1 0 40G 0 part /df--outputsource /var/lib/docker# /dev/vda1 ← Docker 数据根在这块盘上这里有个极易踩的坑/var/lib/docker在分区/dev/vda1253:1上但 Cgroup v2 的 I/O 记账是按整块盘vda253:0做的不是分区。后面我们会用实验证明这一点——写253:1会直接报No such device。2.2 容器 cgroup 在哪本机用 systemd 管 cgroupDocker 容器落在一棵system.slice/docker-id.scope树下新版 Docker 29 containerd 也是如此# 取某个运行容器的 cgroup 路径CG$(dockerinspect iotest--format{{.State.Pid}}|xargs-I{}cat/proc/{}/cgroup|tail-1|cut-d:-f3)echo/sys/fs/cgroup$CG# /sys/fs/cgroup/system.slice/docker-d3b61...e2d.scope# 根 cgroup 的控制器里确实包含 iocat/sys/fs/cgroup/cgroup.subtree_control# cpuset cpu io memory hugetlb pids rdma miscio.max这个文件就是 Cgroup v2 的磁盘限速入口格式是major:minor rbps wbps riops wiops读/写 字节每秒、读/写 IOPS。2.3 限速前 vs 限速后direct I/O先测未限速时容器内dd oflagdirect直写、绕过 page cache写 100MB 的速度dockerexeciotestbash-csync; dd if/dev/zero of/tmp/io_test.bin bs1M count100 oflagdirect 21 | tail -1# 104857600 bytes (105 MB, 100 MiB) copied, 0.21957 s, 478 MB/s然后写io.max把整盘253:0的写带宽限到 10MB/s10485760字节/秒CGPATH/sys/fs/cgroup/system.slice/docker-d3b61...e2d.scopeecho253:0 wbps10485760$CGPATH/io.maxcat$CGPATH/io.max# 253:0 rbpsmax wbps10485760 riopsmax wiopsmax再次在容器内写 100MBdockerexeciotestbash-csync; dd if/dev/zero of/tmp/io_test.bin bs1M count100 oflagdirect 21 | tail -1# 104857600 bytes (105 MB, 100 MiB) copied, 9.95768 s, 10.5 MB/s ← 被精准限到 ~10MB/s解除限速后立刻恢复echo253:0 wbpsmax$CGPATH/io.maxdockerexeciotestbash-cdd if/dev/zero of/tmp/io_test.bin bs1M count100 oflagdirect 21 | tail -1# 104857600 bytes ... 1.09194 s, 96.0 MB/s ← 恢复高速结论io.max对 direct I/O 的限速精准生效——478 MB/s → 10.5 MB/s → 解除后 96 MB/s。2.4 坑用分区号 253:1 会直接报错前面说过 Cgroup v2 按整盘记账。如果你写成分区号echo253:1 wbps10485760$CGPATH/io.max# bash: echo: write error: No such device报No such device。这就是下面 Docker--device-write-bps也会踩的同一个坑。三、复现 2--device-write-bps的分区陷阱Docker 提供了原生参数--device-write-bps底层其实就是帮你往io.max写。但传分区设备会翻车dockerrun--rm--device-write-bps /dev/vda1:10mb ubuntu:24.04bash-cdd if/dev/zero of/tmp/d.bin bs1M count100 oflagdirect 21 | tail -1# docker: Error response from daemon: ... failed to write 253:1 wbps10485760:# write /sys/fs/cgroup/.../io.max: no such device原因Docker 把/dev/vda1解析成253:1分区号而 Cgroup v2 的io.max只认整盘253:0于是写不进去。改成传整块盘就通了dockerrun--rm--device-write-bps /dev/vda:10mb ubuntu:24.04bash-cdd if/dev/zero of/tmp/d.bin bs1M count100 oflagdirect 21 | tail -1# 104857600 bytes (105 MB, 100 MiB) copied, 9.94699 s, 10.5 MB/s ← 限速生效生产经验在云上常见的/dev/vda1、/dev/sda1这类盘分区结构里想用--device-write-bps限速传整块盘设备如/dev/vda、/dev/sda而不是分区否则直接报错、容器起不来。四、原理深挖为什么 buffered I/O 的限速看不见上面用oflagdirect测的都是 direct I/O。如果把oflagdirect去掉情况完全不同。先看一组对照均写 100MB# 限速前dockerexeciotestbash-csync; dd if/dev/zero of/tmp/d.bin bs1M count100 oflagdirect 21 | tail -1# 104857600 bytes ... 0.21957 s, 478 MB/s ← directdockerexeciotestbash-csync; dd if/dev/zero of/tmp/b.bin bs1M count100 21 | tail -1# 104857600 bytes ... 0.0648572 s, 1.6 GB/s ← buffered先进 page cache飞快# 限速 10MB/s 后dockerexeciotestbash-c... oflagdirect ...# 10.5 MB/s ← direct 被限dockerexeciotestbash-c... 无 oflagdirect ...# 1.6 GB/s ← buffered 的 dd 命令本身没被限buffered那段dd命令仍显示 1.6 GB/s——这是因为 buffered 写只是把数据拷进内核的 page cache 就返回了dd根本没等数据落盘。真正的磁盘写回writeback由内核的 flusher 线程异步完成不在dd进程的上下文里。那 Cgroup v2 到底限不限 buffered 写回限。这正是它相对 Cgroup v1blkio的最大升级。我们用一个干净的实验证明容器内 buffered 写 300MB然后sync强制内核把脏页刷盘看sync耗时。# 限速 10MB/secho253:0 wbps10485760$CGPATH/io.maxdockerexeciotestbash-cdd if/dev/zero of/tmp/bw.bin bs1M count300 21 | tail -1dockerexeciotestbash-ctime sync# real 0m30.017s ← 300MB / 10MB/s 30s写回被精准限速# 解除限速echo253:0 wbpsmax$CGPATH/io.maxdockerexeciotestbash-cdd if/dev/zero of/tmp/bw.bin bs1M count300 21 | tail -1dockerexeciotestbash-ctime sync# real 0m1.241s ← 无限制1.2 秒写完铁证限速下sync300MB 写回耗时30.017 秒恰好 300MB ÷ 10MB/s无限制仅1.241 秒。这证明Cgroup v2 的io.max真正把 buffered 写回也限速了。对比一下内核机制差异Cgroup v1 (blkio) Cgroup v2 (io.max wb) ───────────────── ───────────────────────── 写路径: 写路径: 应用 write() 应用 write() → 弄脏 page │ │ ├─ direct: 直接发 bio ──▶ 块层限 ├─ direct: 直接发 bio ──▶ io.max 限 └─ buffered: 先进 page cache └─ buffered: 脏页归属容器的 写回由 flusher 做 memcg → 关联 blkcg blkio 看不到这个 cgroup → writeback 线程以容器身份 发 bio ──▶ io.max 限 ✓v1 的blkio只能在块层拦截直接下发的 biobuffered 写回由内核 flusher 线程发起它不属于应用所在的 cgroup所以 v1 限不住 buffered 写。v2 通过cgroup writeback 机制inode → address_space → wb → memcg/blkcg关联把谁弄脏的页记到对应的 cgroup写回时自然也受该 cgroup 的io.max约束。五、复现 3写文件延时为什么飘脏页抖动实测既然 buffered 写先进 page cache那应用看到的写延时就几乎为 0——直到某一刻突然飙高。元凶是脏页回写阈值。先看本机参数forpindirty_background_ratio dirty_ratio dirty_expire_centisecs dirty_writeback_centisecs;doprintf%-28s $p;cat/proc/sys/vm/$pdone# dirty_background_ratio 10 ← 脏页占可用内存 10% 时后台开始异步刷盘# dirty_ratio 20 ← 脏页占 20% 时应用 write() 被阻塞直到刷出一些# dirty_expire_centisecs 3000 ← 脏页最长存活 30s 必刷# dirty_writeback_centisecs 500 ← 每 5s 唤醒一次 flusherfree-h|head-2# Mem: 14Gi ... available 13Gi本机 16G 内存、可用约 13G于是后台刷盘阈值约 1.3GB、硬限约 2.6GB。当容器持续写、脏页堆到阈值内核就会集中把一大波脏页刷到磁盘——这一波writeback 会把磁盘打满正在write()的进程被卡住等刷盘写延时瞬间飙高。我们用iostat -x 1在宿主机上观察容器持续写 2GB 时磁盘的实际表现# 容器内: dd if/dev/zero of/tmp/bigw.bin bs1M count2048# 宿主机同时: iostat -x -y 1 16 (仅截取 vda 行)iostat-x-y116|grep^vda # vda w/s139 wkB/s33108 w_await 1.99 ms aqu-sz 0.28 %util 4.30# vda w/s178 wkB/s159884 w_await 5.47 ms aqu-sz 0.97 %util72.10# vda w/s121 wkB/s122988 w_await642.93 ms aqu-sz77.79 %util 9.90 ← 抖动开始# vda w/s135 wkB/s123228 w_await685.64 ms aqu-sz92.59 %util98.70# vda w/s288 wkB/s122724 w_await322.72 ms aqu-sz92.94 %util98.80# vda w/s282 wkB/s122744 w_await332.83 ms aqu-sz93.86 %util75.70# vda w/s122 wkB/s122880 w_await431.87 ms aqu-sz52.69 %util98.50# ... (中间若干行 w_await 在 200~730ms 间剧烈波动)# vda w/s135 wkB/s122856 w_await699.03 ms aqu-sz94.37 %util98.80# vda w/s129 wkB/s118196 w_await702.66 ms aqu-sz91.21 %util50.19注意这几个指标的变化w_await平均写等待从平稳期的1.99ms一路飙到642 / 685 / 729ms——写延时高了300 倍以上aqu-sz平均队列深度从 0.28 堆到90——大量写请求在排队%util设备繁忙度多次顶到98~99%——磁盘被打满。这就是写文件延时为什么波动大的设备侧真相平时写都落在 page cache延时极低一旦脏页越过阈值触发集中 writeback磁盘瞬间饱和后续写请求排队等待延时尖刺就来了。而且因为多个容器/进程共享同一块盘谁的脏页先越线谁就把整块盘的%util顶满拖累所有邻居吵闹的邻居问题。六、复现 4fio 看写延迟分布用 fio 在容器内做 4K 顺序写的延迟分布psync 引擎贴近普通应用的write()语义runtime20秒dockerexeciotest fio--namewlat--filename/tmp/fio_w.bin--rwwrite--bs4k\--size512m--numjobs1--iodepth1--ioenginepsync--runtime20--time_based\--group_reporting--output-formatjson|python3-c...提取 clat 百分位...# bw 121.6 MB/s# clat p50 0.0031 ms# clat p90 0.0033 ms# clat p99 0.0053 ms# clat p99.9 0.0127 ms# clat p99.990.0479 ms# mean 0.0029 ms# max 44.728 ms要点绝大多数写p50~p99.9延迟 0.05ms——因为都进了 page cache没真落盘但max高达 44.7ms——少数写恰逢 writeback 或锁竞争被卡了一下这与第五节iostat看到的平时 2ms、尖刺 700ms完全自洽应用层看到的 clat 低是因为 page cache 这层挡在前面设备层的真实写回延时w_await 尖刺被这一层掩盖了。排查写延时飘时不能只看应用的write()耗时必须去设备层iostat/biolatency看。七、内存与 I/O 的纠缠page cache 也是容器内存的一部分很多人忽略的一点容器里读写的 page cache会计入该容器的 memory cgroup 配额。也就是说容器内进程 read()/write() 产生的 page cache │ ▼ 计入容器 memory cgroupmemory.current / memory.stat 的 cache 项 │ ├─ 内存充裕page cache 随意缓存读命中率高 → I/O 快 └─ 内存紧张接近 memory.max ├─ 内核回收 page cachedrop / 写回脏页 ├─ 回收时要先把脏页刷盘 → 触发 writeback → 写延时抖动 └─ 缓存命中率下降 → 更多真 I/O → 盘更忙 → 更抖所以内存配额给太小会间接导致磁盘 I/O 变慢且抖动——页面缓存被反复回收读变慢、脏页被迫频繁写回。容器磁盘 I/O 不稳定有时病根在 memory cgroup不在 io cgroup。排查时两个配额要一起看# 容器的内存快照看 cache 占比dockerstats --no-stream容器# 或 cat /sys/fs/cgroup/.../memory.current# 容器的 I/O 限速cat/sys/fs/cgroup/system.slice/docker-id.scope/io.maxcat/sys/fs/cgroup/system.slice/docker-id.scope/io.stat# 看实际读写字节数八、生产排查思路一步一步来当容器磁盘 I/O 慢/抖时按这个顺序定位是不是被限速了cat 容器cgroup/io.max看wbps是否被设了上限对比限速前后dd oflagdirect的速度差。是不是 buffered 写回在作妖宿主机iostat -x 1看目标盘的w_await、aqu-sz、%util同时watch -d grep dirty /proc/vmstat看nr_dirty是否贴近阈值。是不是脏页阈值太低导致频繁刷盘看dirty_background_ratio/dirty_ratio写密集场景可适当调高需要宿主机权限。是不是内存配额太小page cache 被回收看容器memory.current与memory.max的差距、memory.stat里cache的大小和回收事件。是不是吵闹的邻居同一块盘上别的容器在狂写用io.stat看各 cgroup 的wbytes把%util顶满了——用io.max给邻居限个速。限速/降抖的落地手段用--device-write-bps /dev/vda:10mb注意传整块盘或后台写容器 cgroup 的io.max做 IOPS/带宽兜底高频写路径挂volume/--tmpfs绕开 overlay 可写层见前两篇给容器合理的内存配额让 page cache 有空间减少回收抖动日志务必--log-opt max-size轮转见上篇避免无限增长触发后台刷盘风暴监控/var/lib/docker所在盘的%util、w_await、可用空间设告警。九、Cgroup v1blkiovs v2io.max差异速查维度Cgroup v1blkioCgroup v2io.max限速文件blkio.throttle.read_bps_device等io.max统一major:minor rbps/wbps/riops/wiops设备寻址major:minor同样按整盘major:minor同样按整盘分区号报No such devicedirect I/O能限能限buffered 写回限不住写回线程不在应用 cgroup能限cgroup writeback 关联 memcg→blkcgIOPS 限制有有*iops权重非硬限blkio.weightio.weight统计blkio.io_service_bytes等io.stat含 rbytes/wbytes/rios/wios一句话要限buffered 写必须用 Cgroup v2 的io.maxv1 的blkio对应用层write()进 page cache 后再异步刷盘的场景基本无效。十、扩展怎么用io.stat把 I/O 算到容器头上限速和排查都依赖看清每个容器到底读写了多少。Cgroup v2 在容器 cgroup 下提供io.stat按设备累计cat/sys/fs/cgroup/system.slice/docker-id.scope/io.stat# 253:0 rbytes0 wbytes230686720 rios0 wios1760 dbytes0 dios0字段含义rbytes/wbytes是累计读/写字节rios/wios是读/写 IO 次数dbytes/dios是丢掉的discard字节/次数。设备号253:0就是前面反复强调的整盘——再次印证 Cgroup v2 按整盘记账。排查谁在狂写时把每个容器 cgroup 的wbytes拉出来排个序最大头就是吵闹邻居forcin/sys/fs/cgroup/system.slice/docker-*.scope;dowb$(awk/253:0/{print $3}$c/io.stat2/dev/null)echo$wb$cdone|sort-n|tail-5吵闹邻居的完整隔离 recipe单靠io.max硬限速有时太生硬把邻居限死了它自己业务也受损。更平滑的做法是硬限 权重组合io.max给每个容器设wbps上限防止单个容器把盘打满兜底对应 v1 的 throttleio.weight当多容器争用同一块盘时按权重分配带宽比例调度不是硬限。默认权重 100给重要业务设高、给批处理设低。# 重要业务容器拿更高权重echo253:0 rioweight200 wioweight200$CGPATH/io.weight# 同时设硬上限兜底echo253:0 wbps52428800$CGPATH/io.max# 50MB/s 封顶注意io.max的单位字节/秒如10485760 10MB/s而io.weight是无量纲权重。riops/wiops还能按 IOPS 限对大量小随机写特别有用——这类负载即便带宽不高也会把磁盘队列和%util顶满。最后提醒本文所有限速都作用在整块盘 253:0 上。如果宿主机有多块盘、且把不同容器的数据根或 volume 分布到不同盘隔离粒度可以更细——但凡共用一块盘就该默认上io.max兜底。十一、direct 还是 buffered写给应用开发的建议绕了一大圈最后落到开发该怎么写这件最实际的事用 buffered默认的场景绝大多数业务应用。内核的 page cache 会替你扛住大量读、合并小写吞吐高、CPU 省。代价是写延时看不见地飘脏页回写那一波以及内存会被 cache 占用——这恰好呼应第七节内存与 I/O 纠缠。主动用O_DIRECT/oflagdirect的场景数据库MySQL、RocksDB 这类自带缓存和写策略的、自己管理 buffer 的高性能存储引擎。它们不想被内核 page cache “再缓存一层”宁可自己直接对接块设备延时更可控、也更容易被io.max这类机制精准限速。混合负载的坑同一进程里既 buffered 写日志、又 direct 写数据文件时buffered 那部分仍会触发脏页回写可能连累direct 路径的延时观测——排查时别只盯着 direct 那一个文件。用 eBPF 看 block 层真实延迟进阶应用层write()快不代表块设备快。iostat看的是聚合值想看清每次 bio 的延迟分布可以用 eBPF 工具如biolatency/biosnoop来自 bcc 工具集# 按毫秒桶统计块设备读写的延迟直方图每 1 秒输出一次biolatency-m1# 它能看到 page cache 背后、真正落到磁盘的那批 I/O 的延迟分布# 往往就是 iostat 里 w_await 尖刺的来源。这类工具直接在内核 tracepoint 上采样能看到应用以为 0ms、磁盘实际 700ms的那段被 page cache 藏起来的延迟——是定位写延时飘的终极利器。十二、速查清单容器磁盘限速一键排错把全文命令压成一张卡片贴在你的值班室lsblk|grepdisk# 找整盘设备号如 vda 253:0限速用它别用分区号dockerinspectc--format{{.State.Pid}}|xargs-I{}cat/proc/{}/cgroup|tail-1# 拿到容器 cgroup 路径 → /sys/fs/cgroup/system.slice/docker-id.scopeecho253:0 wbps10485760cgroup/io.max# 限整盘写带宽 10MB/secho253:0 wbpsmaxcgroup/io.max# 解除iostat-x1# 看 w_await / aqu-sz / %util抖动全在这儿catcgroup/io.stat# 看该容器累计 rbytes/wbytes定位吵闹邻居sysctlvm.dirty_ratio vm.dirty_background_ratio# 脏页阈值写回抖动的开关dockerstats /dockersystemdf# 容器内存与磁盘占用总览记住最反直觉的一条Cgroup v2 的 I/O 限速按整块盘的 major:minor 记账253:0写分区号253:1会直接No such deviceDocker 的--device-write-bps也要传整块盘/dev/vda而不是分区/dev/vda1。十三、小结与思考题本篇我们真实复现并验证了io.max对 direct I/O 限速精准478 MB/s → 10.5 MB/s限 10MB/s→ 解除后 96 MB/s上述三组数字全部来自同一块实验机磁盘且限速前后用dd oflagdirect同口径对比排除了 page cache 的干扰关键坑Cgroup v2 按整盘记账写分区号253:1会报No such deviceDocker--device-write-bps /dev/vda1同样翻车要传整块盘/dev/vdav2 比 v1 强在用time sync300MB 写回 30.017s vs 无限制 1.241s证明了io.max能限buffered 写回而 v1blkio做不到脏页抖动实测iostat显示写回期间w_await从 2ms 飙到729ms、aqu-sz堆到 90、%util顶到 99%——这就是写延时飘的设备侧真相fio 延迟分布p50~p99.9 均 0.05ms但max达 44.7ms印证 page cache 掩盖了真实写回延时该 fio 在容器内以psync引擎直连同一块 vda 盘实测与应用层write()语义一致内存与 I/O 纠缠page cache 计入容器 memory cgroup内存配额过小会因缓存回收间接引发 I/O 抖动。思考题为什么dd用oflagdirect时限速立刻生效、速度稳定而去掉oflagdirect时dd命令本身看不出被限限速到底作用在哪一刻本节用time sync证明了 v2 能限 buffered 写回。如果容器里是边写边读的随机小 I/O 混合负载写回抖动还会以什么形式暴露某容器内存配额设为 512M 但磁盘 I/O 持续抖动你怀疑是 page cache 被频繁回收。请设计一条排查命令链来证实或证伪这个假设提示结合memory.stat的cache/pgfault/pgscan与io.stat的wbytes联动观察若pgscan高且cache上不去基本可判定是回收引发的写回抖动。实验环境与命令均在本机 Ubuntu 24.04 / 内核 6.8 / Docker 29.1.3 / Cgroup v2 真机执行输出为原始截取fioruntime≤ 20s单次写盘 ≤ 2GB 并即时清理磁盘占用峰值远低于 5GB 上限。作为容器存储三部曲的收官篇它和前两篇OverlayFS 视图、Quota 空间隔离一起覆盖了看得见视图、管得住空间、限得了速度的完整容器磁盘能力。