DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Docker Engine 29's default image store lets --storage-opt size fail silently

Docker Engine 29 switches new installs from the classic overlay2 graphdriver to the containerd snapshotter as the default image store. I wanted to know what actually changes when that flag flips, so I ran the same host, the same images, and the same flags against both backends and compared what came out.

The interesting result wasn't a speed number, although I got one of those too. It was a container disk quota that stopped being enforced and didn't tell me.

The setup

I have root on a fresh Docker 29.3.1 install (overlay2 root filesystem is plain ext4, no project quota mount option). I can flip the backend with a daemon flag and restart:

{ "features": { "containerd-snapshotter": false } }
Enter fullscreen mode Exit fullscreen mode

docker info confirms which one is active:

Storage Driver: overlayfs
  driver-type: io.containerd.snapshotter.v1
Enter fullscreen mode Exit fullscreen mode

versus

Storage Driver: overlay2
Enter fullscreen mode Exit fullscreen mode

Finding 1: the quota flag stops erroring, and stops working

--storage-opt size=<limit> is supposed to cap a container's writable layer. It has a well-known precondition: it only works on overlay2 over XFS with the pquota mount option. My ext4 root doesn't have that. Under the classic graphdriver, Docker knows this and refuses:

$ docker run --rm --storage-opt size=100M alpine:3.20 echo ok
docker: Error response from daemon: --storage-opt is supported only for overlay over xfs with 'pquota' mount option
Enter fullscreen mode Exit fullscreen mode

Exit code 125, every time, three runs in a row. The container never starts. That's a correct, loud failure on a host that can't honour the option.

Under the containerd snapshotter — the Docker 29 default — the exact same command and the exact same filesystem behave differently:

$ docker run --rm --storage-opt size=100M alpine:3.20 sh -c \
    "dd if=/dev/zero of=/bigfile bs=1M count=300; ls -lh /bigfile"
300+0 records in
300+0 records out
314572800 bytes (300.0MB) copied, 1.81 s, 165.5MB/s
-rw-r--r--  1 root root  300.0M Sep 13 05:06 /bigfile
Enter fullscreen mode Exit fullscreen mode

Exit code 0. I asked for a 100MB limit and wrote 300MB with no resistance, no warning, no log line. I repeated it three times to be sure it wasn't a one-off race:

Backend --storage-opt size=100M, write 300MB Exit code
overlay2 (graphdriver) refuses to start 125, ×3
containerd snapshotter writes all 300MB 0, ×3

It gets worse. docker inspect still reports the option as if it applied:

$ docker inspect quotatest --format '{{.HostConfig.StorageOpt}}'
map[size:100M]
Enter fullscreen mode Exit fullscreen mode

If you have anything that checks container config to confirm a quota is set — a compliance script, an audit tool, a human reading docker inspect output — it will tell you the limit is in place. Nothing enforces it. This is the kind of thing that looks fine in every dashboard until a runaway container fills the disk that three other containers are also writing to.

I don't think this is a deliberate design choice so much as an unimplemented check: the containerd image store path doesn't currently validate that the backing filesystem supports the quota before accepting the option, where the graphdriver path does. Whatever the cause, the behaviour changed under a default flip, and nothing in the CLI tells you.

Finding 2: pulls are genuinely faster, independent of why

I pulled node:22 (8 layers, 1.64GB unpacked) from a clean state, alternating backends, always removing the local image first:

Backend Run 1 Run 2 Run 3 Average
containerd snapshotter 19.9s 17.7s 16.4s 18.0s
overlay2 (graphdriver) 28.9s 28.5s 28.1s 28.5s

That's roughly 37% faster on the new default, consistently, and the graphdriver runs happened later in my test sequence, so it isn't warm-cache bias in the snapshotter's favour — if anything a registry or proxy cache would have made the later runs faster, and they were the slow ones.

Finding 3: the concurrency knob barely matters, which wasn't what I expected

Docker 29's own release notes mention a bug, fixed in 29.7.0, where concurrent pull limits weren't honoured on the snapshotter path. I'm running 29.3.1, so I expected to see it: set max-concurrent-downloads to 1 and watch pulls slow down a lot more under the snapshotter than under the graphdriver.

That's not what happened. Setting the limit to 1 versus 8 moved the snapshotter average from 18.0s to 15.0s — about 17%. Under the graphdriver it moved from 28.5s to 27.5s — about 3.5%, close to noise:

Backend max-concurrent-downloads=1 max-concurrent-downloads=8
containerd snapshotter 18.0s 15.0s
overlay2 (graphdriver) 28.5s 27.5s

Neither backend shows the dramatic serialization I was looking for. My conclusion, from these numbers, is that unpacking the layers into the filesystem dominates the wall-clock time for a pull far more than the number of concurrent HTTP downloads does, at least on this network path. The backend-versus-backend gap in finding 2 is real and reproducible; the concurrency-setting gap inside either backend is not where the time goes.

Finding 4: the new mount type does what it says, mostly

Docker 29 also graduates --mount type=image out of experimental status — in 29.7.0, according to the release notes. On 29.3.1 it's still flagged:

$ docker run --rm --mount type=image,source=alpine:3.20,target=/mnt \
    busybox:latest ls /mnt
WARNING: Image mount is an experimental feature
bin  dev  etc  home  lib  ...
Enter fullscreen mode Exit fullscreen mode

It correctly refuses writes:

$ docker run --rm --mount type=image,source=alpine:3.20,target=/mnt \
    busybox:latest touch /mnt/testfile
touch: /mnt/testfile: Read-only file system
Enter fullscreen mode Exit fullscreen mode

Exit 1, as it should be — this is the one place where the new feature enforced a restriction I expected and the quota flag didn't. It also fails cleanly on a bad source image (No such image, exit 125) and on a missing target (field Target must not be empty). What I couldn't get working on this build was a subpath option to mount only part of an image; every spelling I tried (subpath=, src.subpath=) came back as an unrecognised option. That may simply not exist yet in 29.3.1's experimental implementation — I'm reporting what this specific version accepts, not what later docs promise.

Finding 5: dedup accounting still works

One thing I checked because I assumed it might have regressed, and it hadn't: docker system df -v still correctly attributes shared base layers under the snapshotter. python:3.12-slim and python:3.13-slim share a Debian base:

REPOSITORY   TAG         SIZE     SHARED SIZE   UNIQUE SIZE
python       3.12-slim   190MB    87.45MB       102.9MB
python       3.13-slim   189MB    87.45MB       101.7MB
Enter fullscreen mode Exit fullscreen mode

87.45MB shared, matching on both images, which is what you'd want to see if you're trying to estimate real disk cost from a fleet of related images. This is the one finding in this post that's a non-finding — I looked for a problem and didn't find one.

What I got wrong on the way

I set out to reproduce the "concurrent download limits not honoured" bug mentioned in the 29.7.0 changelog, expecting my max-concurrent-downloads=1 runs to be dramatically slower than =8 on the snapshotter. When the difference came out small on both backends, my first instinct was that my harness was broken — maybe the proxy in front of my registry was capping throughput regardless of client-side concurrency, hiding the effect. I re-ran with a completely fresh image (python:3.13-slim, untouched by earlier tests) to rule out any registry-side caching of my specific pull pattern, and got the same shape of result. The actual finding wasn't the one I went looking for: the backend swap (finding 2) explains far more of the wall-clock time than the concurrency setting does (finding 3). I'd rather report that than force a bug I couldn't actually reproduce on this version.

Run it yourself

# check which image store is active
docker info | grep -i "storage driver"

# flip to the classic graphdriver (needs root, restarts the daemon)
echo '{"features": {"containerd-snapshotter": false}}' | sudo tee /etc/docker/daemon.json
sudo pkill dockerd && sudo dockerd &

# the quota test — compare exit codes between backends
docker run --rm --storage-opt size=100M alpine:3.20 \
  sh -c "dd if=/dev/zero of=/bigfile bs=1M count=300; echo EXIT=\$?"

# flip back to the containerd snapshotter (Docker 29 default)
echo '{"features": {"containerd-snapshotter": true}}' | sudo tee /etc/docker/daemon.json
sudo pkill dockerd && sudo dockerd &

# pull timing, repeat 3x per backend, always from a clean state
docker rmi -f node:22
start=$(date +%s.%N); docker pull node:22 >/dev/null; end=$(date +%s.%N)
echo "$end - $start" | bc

# the read-only mount check
docker run --rm --mount type=image,source=alpine:3.20,target=/mnt \
  busybox:latest touch /mnt/testfile
Enter fullscreen mode Exit fullscreen mode

If you're running Docker 29 on ext4 or any filesystem without project quota support, and you rely on --storage-opt size anywhere — a shared build host, a CI runner, a multi-tenant container platform — go check which image store you're actually running, and don't trust docker inspect to tell you whether the limit is real. Run the write test above against your own host. It takes ten seconds and it either fills up or it doesn't.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.