Docker & ContainersXV · Resource ControlsBlock I/O
I/O controls and block device limits
What you'll learn
- Configure block I/O bandwidth and IOPS limits
- Identify which host device a limit must name
- Explain why a write limit appears to have no effect
- Diagnose I/O bottleneck symptoms
Prerequisites
None — start here.
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-09
Block I/O limits cap the bandwidth or the operation rate a container may use against a specific block device. They are the least reliable of the four resource controls in this part, not because the kernel does them badly, but because three things have to line up before they do anything at all — the right device, the right kind of I/O, and the right scheduler.
Getting a limit that appears to work and does not is the normal outcome of a first attempt. This lesson is mostly about how to tell.
Setting the limits
docker run -d --name backup \
--device-read-bps /dev/sda:20mb \
--device-write-bps /dev/sda:10mb \
myorg/backup:1.0.0docker run -d --name backup \
--device-read-iops /dev/sda:1000 \
--device-write-iops /dev/sda:500 \
myorg/backup:1.0.0docker run -d --name batch --blkio-weight 250 myorg/batch:1.0.0All four --device-* flags accept the same
<host-device-path>:<value> shape and may be repeated for several
devices.
The device you name is the host device
$ PID=$(docker inspect --format '{{.State.Pid}}' backup); CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup"); cat /sys/fs/cgroup"$CG"/io.max; lsblk -o NAME,MAJ:MIN | head8:0 rbps=20971520 wbps=10485760 riops=max wiops=max
NAME MAJ:MIN
sda 8:0
├─sda1 8:1
└─sda2 8:2
nvme0n1 259:0Illustrative output
8:0 is sda. If /var/lib/docker is on nvme0n1 (259:0), that
limit governs nothing the container does. An empty io.max means no
limit was applied at all.
Why the write limit “doesn’t work”
This is the second, larger trap, and it has a clean demonstration.
$ docker exec backup dd if=/dev/zero of=/data/testfile bs=1M count=512512+0 records in
512+0 records out
536870912 bytes (537 MB, 512 MiB) copied, 0.41 s, 1.3 GB/sIllustrative output
dd did not write to the disk. It wrote to page cache, and
returned as soon as the data was in memory. The actual disk writes
happen later, asynchronously, performed by kernel writeback threads.
The block-layer throttle sits below page cache and never saw the
write() calls at all.
Repeat with direct I/O, which bypasses page cache:
$ docker exec backup dd if=/dev/zero of=/data/testfile bs=1M count=512 oflag=direct512+0 records in
512+0 records out
536870912 bytes (537 MB, 512 MiB) copied, 51.4 s, 10.4 MB/sIllustrative output
Always verify an I/O limit with oflag=direct (or
iflag=direct for reads). A buffered test proves nothing, and it
is the reason so many teams conclude the flags are broken.
That leaves the real question: does the limit apply to a normal application’s buffered writes, once writeback flushes them? It depends on cgroup writeback, which requires two things:
- The memory and io controllers both enabled on the cgroup, so the kernel can attribute a dirty page to the cgroup that dirtied it.
- A filesystem that implements it. The kernel documentation lists ext2, ext4, btrfs, f2fs and xfs. On any other filesystem, “all writeback IOs are attributed to the root cgroup” — meaning your container’s write limit governs nothing that goes through page cache.
PID=$(docker inspect --format '{{.State.Pid}}' backup)
CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup")
cat /sys/fs/cgroup"$CG"/cgroup.controllers
findmnt -no FSTYPE,SOURCE /var/lib/docker--blkio-weight may write a value that nothing reads
--blkio-weight is a proportional share, not a cap: under
contention, cgroups get device time in proportion to their weights.
runc converts the Docker range 10–1000 into the cgroup v2 range
1–10000 with 1 + (weight - 10) * 9999 / 990, so --blkio-weight 500 becomes io.weight 4950.
The catch is that io.weight has to be implemented by something
in the block layer, and on many hosts it is not:
- BFQ implements proportional weighting directly. If
/sys/block/<dev>/queue/schedulershows[bfq], weights work. - blk-iocost (
CONFIG_BLK_CGROUP_IOCOST) implementsio.weightindependently of the scheduler, but only onceio.cost.qosandio.cost.modelhave been configured on the root cgroup. Out of the box they usually have not been. none, the default for NVMe, does no scheduling at all. With iocost unconfigured,io.weightis a number in a file that nothing consults.
for d in /sys/block/sd* /sys/block/nvme*; do
[ -e "$d/queue/scheduler" ] || continue
printf '%-14s %s\n' "$(basename "$d")" "$(cat "$d/queue/scheduler")"
done
cat /sys/fs/cgroup/io.cost.qos 2>/dev/null || echo 'iocost not configured'If weights are inert on your hardware, use --device-write-bps and
--device-write-iops instead. A hard cap is less elegant than a
weight — it wastes capacity when the device is idle — but it
demonstrably does something, which the weight may not.
When a limit is the right answer, and when it is not
Helps:
- A backup, export or analytics job on the same spindle as a
latency-sensitive database. Cap the batch job and the database’s
awaitstops spiking. - Multi-tenant hosts on shared storage, where one tenant’s
rsynccan saturate the array for everyone. - Containing the blast radius of a runaway loop that writes in a tight cycle.
Hurts:
- The primary workload. A database wants every IOP the device can give it; capping it converts spare capacity into latency.
- Fast NVMe with abundant headroom. If the device is not the bottleneck, the limit is the bottleneck.
- Anything bursty with a hard deadline. A cap smooths the burst into a long tail, which is exactly wrong for a job that must finish inside a window.
The default should be no limit on the primary workload, a limit on the noisy one. Limiting everything is a common and expensive overcorrection.
Diagnosing an I/O bottleneck
PID=$(docker inspect --format '{{.State.Pid}}' backup)
CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup")
cat /sys/fs/cgroup"$CG"/io.statiostat -x 1 3
cat /proc/pressure/io
docker stats --no-stream --format 'table {{.Name}}\t{{.BlockIO}}'%util near 100 with rising await means the device is saturated.
For an SSD, await above about 10 ms is a warning; for a spinning
disk, above 50 ms. /proc/pressure/io gives the number that
actually correlates with user-visible pain: some avg10 is the
percentage of the last ten seconds during which at least one task
was stalled on I/O.
Note that iostat tells you the device is busy but not who made
it busy. io.stat per container is the attribution step, and it is
the one that ends the argument about which workload to limit.
Knowledge check
Knowledge check · 5 questions
Q1. `--device-write-bps /dev/sda:10mb` is set, and `dd if=/dev/zero of=/data/f bs=1M count=512` inside the container reports 1.3 GB/s. What is the most likely explanation?
Q2. Which device does `--device-read-bps /dev/sda:1mb` apply the limit to?
Q3. A host uses NVMe with the `none` I/O scheduler and no iocost configuration. What does `--blkio-weight 500` achieve?
Q4. Which situations make an I/O limit a good idea? Select all that apply.
Q5. Which cgroup file gives per-container I/O attribution that `iostat` cannot, and what is it keyed by?
Passing score: 75%. Answers are checked in this browser.