Skip to main content
RunBook Academy

Docker & ContainersXVI Β· PerformanceStorage drivers

Storage drivers and image layout

Intermediate⏱ ~26 mindocker

What you'll learn

  • Explain the on-disk layout overlay2 builds under /var/lib/docker/overlay2
  • Measure copy-up cost and predict which workloads pay it
  • Verify the backing filesystem supports d_type and know what happens when it does not
  • State which storage drivers still exist in current Docker Engine and which were removed
  • Plan a driver change, including what happens to existing images

Prerequisites

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-12

Not yet marked complete on this device.

The storage driver is the implementation behind every image pull, every container start, and every byte a container writes to its own filesystem. overlay2 is the default on Linux and has been for years. Knowing what it does well, what it does badly, and which of your problems it is not responsible for is the difference between debugging a performance issue and guessing at one.

What is still here, and what is gone

This table is more useful than it looks, because half the storage-driver advice on the internet recommends drivers that no longer exist in the engine.

DriverStatus in current Engine
overlay2Supported. The classic default on Linux.
containerd snapshottersDefault for Engine 29.0 and later on fresh installs.
fuse-overlayfsSupported, for rootless Docker on hosts where overlay2 cannot run rootless.
btrfsSupported on a Btrfs backing filesystem.
zfsSupported on a ZFS backing filesystem.
vfsSupported, for testing. No copy-on-write; every layer is a full copy.
aufsRemoved in v24.0. Deprecated in v19.03.
overlay (v1)Removed in v24.0. Deprecated in v18.09.
devicemapperRemoved in v25.0. Deprecated in v18.09, disabled by default in v23.0.

Three practical consequences:

β€œFall back to devicemapper” is no longer advice. It is not in the engine. A host that cannot run overlay2 runs vfs, which has no copy-on-write at all β€” every container gets a full physical copy of the entire image β€” or it does not run current Docker.

An upgrade across a removal boundary is a data event. Upgrading a host that was still on devicemapper to Engine 25.0 leaves the daemon unable to read any of its images. The images are still on disk; nothing can open them. Check docker info before an engine upgrade, not after.

Engine 29 changed the default. New installations use the containerd snapshotter rather than the classic overlay2 graph driver. If a lesson, runbook or script of yours reasons about /var/lib/docker/overlay2 paths, it is reasoning about the classic layout, which is still what you get on an upgraded 28.x host and on 29.x hosts that were upgraded rather than reinstalled.

The on-disk layout

Read-only / Safewhat am I running
docker info --format 'driver={{.Driver}}'
docker info | sed -n '/Storage Driver/,/^ [A-Z]/p'
Read-only / Safedocker info storage section
$ docker info
Storage Driver: overlay2
Backing Filesystem: xfs
Supports d_type: true
Using metacopy: false
Native Overlay Diff: true
userxattr: false

Illustrative output

Each layer is a directory under /var/lib/docker/overlay2/:

/var/lib/docker/overlay2/
β”œβ”€β”€ l/                          # short symlinks to every diff dir
β”‚   └── ZLNGY6...  -> ../<layer-id>/diff
└── <layer-id>/
    β”œβ”€β”€ diff/                   # this layer's actual files
    β”œβ”€β”€ link                    # this layer's short name, as used in l/
    β”œβ”€β”€ lower                   # colon-separated list of parent layers
    β”œβ”€β”€ merged/                 # the unified view, mounted only while running
    └── work/                   # OverlayFS scratch space for copy-up

The l/ directory of two-character-ish symlinks looks like an odd design choice until you know why it exists: the mount syscall takes its options as a single page of text, and an image with forty layers produces a lowerdir= argument of forty 64-character paths, which overflows it. The short symlinks keep the mount options inside the limit.

Read-only / Safea container's actual mount
CONTAINER=web

docker inspect --format '{{.GraphDriver.Name}}' "$CONTAINER"
docker inspect --format '{{.GraphDriver.Data.UpperDir}}' "$CONTAINER"

# The same thing as the kernel sees it.
MERGED=$(docker inspect --format '{{.GraphDriver.Data.MergedDir}}' "$CONTAINER")
findmnt -no SOURCE,FSTYPE,OPTIONS "$MERGED" | tr ',' '\n' | head -20

GraphDriver.Data.UpperDir is the single most useful path in container debugging. It is the container’s writable layer, on the host, readable with ordinary tools while the container runs. Everything the container has created or modified since it started is in there, and du -sh on it answers β€œhow much has this container written into its own filesystem” directly.

What copy-up costs

Reads of unmodified files are nearly free: OverlayFS looks up the path in upper, misses, and falls through to the lower layers. One extra directory lookup, then a normal read from the backing filesystem.

The first write to a file that lives in a lower layer triggers a copy-up: the entire file is copied from the lower layer to upper before the write is applied. Not the modified block β€” the whole file.

OperationCost
Read a file never modifiedNear-native
First write to a 4 KB config fileCopy 4 KB, then write. Negligible.
First write to a 2 GB database file in the imageCopy 2 GB, then write 8 KB
Subsequent writes to the same fileNear-native; it is already in upper
Delete a file from a lower layerWhiteout entry in upper. Cheap.
Create a new fileStraight into upper. Native speed.

The third row is the one that ends up in an incident report. A container appends one record to a large file that shipped inside the image, and the first append takes as long as copying the whole file.

Service impact possiblemeasure copy-up on your host
cat > /tmp/copyup.Dockerfile <<'EOF'
FROM alpine:3.20
RUN dd if=/dev/zero of=/big.dat bs=1M count=512 2>/dev/null
EOF

docker build -q -t copyup-demo -f /tmp/copyup.Dockerfile /tmp

docker run --rm copyup-demo sh -c '
echo "--- first write (triggers copy-up of 512 MB) ---"
time dd if=/dev/zero of=/big.dat bs=1M count=1 conv=notrunc 2>/dev/null
echo "--- second write (already in upper) ---"
time dd if=/dev/zero of=/big.dat bs=1M count=1 conv=notrunc 2>/dev/null
'
Read-only / Safethe difference
$ docker run --rm copyup-demo sh -c '...'
--- first write (triggers copy-up of 512 MB) ---
real	0m 2.41s
user	0m 0.00s
sys	0m 0.71s
--- second write (already in upper) ---
real	0m 0.01s
user	0m 0.00s
sys	0m 0.00s

Illustrative output

Two hundred times slower for an identical operation. That ratio is the whole argument for volumes.

The backing filesystem requirement

overlay2 needs the underlying filesystem to distinguish file types in directory entries β€” d_type support. On ext4 this is always present. On XFS it depends on how the filesystem was formatted, and this is the trap.

XFS created with ftype=0 β€” the default for mkfs.xfs on some older distributions, and the layout on plenty of long-lived hosts β€” does not provide d_type. overlay2 on such a filesystem does not refuse to start. It runs, and misbehaves: whiteouts do not work correctly, so deleted files can reappear, and layer extraction can produce a filesystem that does not match the image.

Read-only / Safecheck d_type
docker info | grep -E 'Backing Filesystem|Supports d_type'

# If the backing filesystem is xfs, confirm at the filesystem level too.
DEV=$(findmnt -no SOURCE --target /var/lib/docker)
sudo xfs_info "$DEV" 2>/dev/null | grep -o 'ftype=[01]' \
|| echo 'not xfs, or xfs_info unavailable'

ftype=1 and Supports d_type: true is the only acceptable result. ftype=0 cannot be changed in place: XFS cannot be reformatted online and there is no xfs_admin flag for it. The fix is to back up, recreate the filesystem with mkfs.xfs -n ftype=1, and restore β€” which is why you check this when you build the host, not when you are debugging phantom files six months later.

Choosing, and changing

For essentially every host: leave it alone.

  • ext4 or XFS with ftype=1, kernel 4.0+: overlay2. No reason to change.
  • Btrfs or ZFS root filesystem: the matching driver, if you want the filesystem’s own snapshot tooling to see container layers. overlay2 on top of them also works and is simpler.
  • Rootless Docker where overlay2 will not run: fuse-overlayfs.
  • Fresh Engine 29 install: you already have the containerd snapshotter and should keep it.
  • Anything else: you are on vfs, which is a correctness fallback rather than a production choice, and the real fix is the host.
Data-loss riskpre-change inventory
OUT=/var/tmp/pre-driver-change-$(date +%F)
mkdir -p "$OUT"

docker image ls --digests --format '{{.Repository}}:{{.Tag}} {{.Digest}}' > "$OUT/images.txt"
docker ps -a --format '{{.Names}} {{.Image}} {{.Status}}'                  > "$OUT/containers.txt"
docker volume ls --format '{{.Name}}'                                      > "$OUT/volumes.txt"
docker info --format '{{.Driver}}'                                         > "$OUT/driver.txt"

echo "copy $OUT off this host before proceeding"

Verification that can fail

After any change, or as a periodic health check:

Read-only / Safestorage health assertions
FAIL=0

DRIVER=$(docker info --format '{{.Driver}}')
echo "driver: $DRIVER"
case "$DRIVER" in
vfs) echo 'FAIL: vfs has no copy-on-write; every container is a full image copy'; FAIL=1 ;;
esac

if docker info 2>/dev/null | grep -q 'Supports d_type: false'; then
echo 'FAIL: backing filesystem lacks d_type; overlay2 behaviour is undefined'
FAIL=1
fi

# Any warning the daemon emits about storage is worth failing on.
docker info 2>&1 | sed -n '/^WARNING/p'

exit "$FAIL"

And to check the thing that actually causes storage-driver performance complaints β€” workloads writing into the writable layer instead of a volume:

Read-only / Safewho is writing to their own layer
$ docker ps -s --format 'table {{.Names}}\t{{.Size}}'
NAMES        SIZE
nginx        1.09kB (virtual 48.3MB)
api          32.8kB (virtual 214MB)
worker       4.1kB (virtual 214MB)
legacy-db    8.4GB (virtual 8.72GB)

Illustrative output

legacy-db is the finding. Eight gigabytes in a writable layer means every one of those bytes is on the Docker partition, unbacked-up, deleted with the container, and paying copy-up on any file that came from the image. That is not a storage driver problem, and switching drivers will not help it.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. A container appends 8 KB to a 2 GB file that shipped inside its image. The first append is slow, subsequent ones are fast. Why?

  2. Q2. Your host runs Docker Engine 25.0 and `docker info` reports `Storage Driver: vfs`. What does that tell you?

  3. Q3. `docker info` shows `Backing Filesystem: xfs` and `Supports d_type: false`. What is the correct response?

  4. Q4. Which of these storage drivers have been removed from Docker Engine? Select all that apply.

  5. Q5. Fifty containers started from the same base image share a single page cache entry per file in the shared lower layers.

Passing score: 75%. Answers are checked in this browser.