Docker & ContainersXXXI · TroubleshootingStorage
Storage failures — out of disk, permission denied
What you'll learn
- Diagnose disk-full failures
- Diagnose permission-denied failures
- Recognise storage-driver issues
- Separate byte exhaustion from inode exhaustion from a full data root
- Attribute consumption to logs, images, volumes or build cache before pruning
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
Storage failures in Docker come in three flavours: out of disk, permission denied, and storage-driver-specific issues. Each has a distinct diagnostic.
What makes them expensive is that the error messages collapse several
causes into one string. ENOSPC — “no space left on device” — is what
the kernel returns when bytes run out, when inodes run out, when a
filesystem quota is hit, and when a tmpfs mount reaches its size limit.
Three of those four show plenty of free space in df -h.
Out of disk: four causes, one message
# 1. Bytes, on the filesystem holding the data root
DATA_ROOT=$(docker info --format '{{.DockerRootDir}}')
df -h "$DATA_ROOT"
# 2. Inodes on the same filesystem
df -i "$DATA_ROOT"
# 3. Inside the container: its own mounts may be full while the host is not
docker exec web df -h
docker exec web df -i
# 4. Quotas, if the filesystem enforces them
sudo repquota -a 2>/dev/null | head
sudo xfs_quota -x -c 'report -h' 2>/dev/null | headIllustrative output of the case people miss:
Filesystem Size Used Avail Use% Mounted on
/dev/vda2 196G 118G 69G 64% /var/lib/docker
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda2 12845056 12845056 0 100% /var/lib/docker
Sixty-four percent full and every write fails. overlay2 creates a
directory tree per layer, and images built from many small files consume
inodes far faster than bytes. Node.js images with a deep node_modules
tree are the classic producer.
Attribute the consumption before pruning
# Category totals, with per-object detail
docker system df
docker system df -v
# Container log files, largest first. json-file logs live beside the
# container's state in the data root.
DATA_ROOT=$(docker info --format '{{.DockerRootDir}}')
sudo du -h "$DATA_ROOT"/containers/*/*-json.log 2>/dev/null | sort -h | tail -10
# The biggest directories in the data root, one level down
sudo du -xh --max-depth=1 "$DATA_ROOT" | sort -h | tail
# Writable layer size per container (the -s flag is the point)
docker ps -as --format 'table {{.Names}}\t{{.Size}}'docker system df reports TYPE, TOTAL, ACTIVE, SIZE and
RECLAIMABLE. The RECLAIMABLE column is the number to read: it tells
you what a prune would actually free, so you can decide whether the prune
is worth it before running it. A host with 118 GB used and 4 GB
reclaimable does not have a pruning problem — it has a sizing problem, and
pruning will buy an hour.
docker ps -as is the one people forget. The SIZE column shows the
container’s writable layer, which is data written inside the container
rather than to a volume. A container with a 30 GB writable layer is an
application logging to a file path instead of stdout, or writing
temporary data that a volume should be absorbing. The writable layer dies
with the container, so this is also silent data loss waiting to happen.
# What would be freed, by category, before deciding
docker system df
# Narrowest first: build cache only. Almost always safe and often enough.
docker builder prune --filter 'until=168h'
# Then dangling images only (no -a): layers no tag points at
docker image prune
# Stopped containers, listed before removal
docker ps -a --filter status=exited --format 'table {{.Names}}\t{{.Status}}\t{{.Size}}'
docker container prune --filter 'until=24h'
# Only if the above was not enough, and only after checking what you
# still need to be able to roll back to:
# docker image prune -a --filter 'until=720h'Escalating narrowest-first is the whole technique. Build cache is
regenerable by definition. Dangling images are unreferenced by definition.
-a is the only step that can take something you needed, so it is the
last one and it gets a --filter until= so it cannot take last week’s
release.
The log rotation default is the one that bites
The json-file driver’s defaults are worth stating exactly, because they
are the opposite of what most operators assume:
| Option | Default | Consequence |
|---|---|---|
max-size | -1 (unlimited) | A container’s log grows without bound, forever |
max-file | 1 | And is “only effective when max-size is also set” |
compress | false | Rotated files are not compressed |
So out of the box there is no rotation at all, and setting max-file
alone does nothing. A chatty container on a long-lived host will fill any
disk you give it. This is the most common single cause of a Docker host
running out of bytes.
sudo tee /etc/docker/daemon.json >/dev/null <<'JSON'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "5",
"compress": "true"
}
}
JSON
sudo dockerd --validate --config-file=/etc/docker/daemon.json
sudo systemctl reload docker
# Verify the daemon took it
docker info --format 'driver={{.LoggingDriver}}'
# Verify a NEW container inherits it (an existing one will not)
docker run --rm -d --name logtest alpine:3.20 sh -c 'while :; do echo x; sleep 1; done'
docker inspect logtest --format '{{.HostConfig.LogConfig}}'
docker rm -f logtestThe verification step matters because the change is not retroactive. Containers created before the daemon change keep their original log configuration until they are re-created, so a host can be “fixed” and still be filling up from the one container nobody restarted.
Permission denied: five causes
docker exec web touch /data/test
# touch: cannot touch '/data/test': Permission denied
| Cause | Discriminating check |
|---|---|
| UID mismatch | docker exec C id against stat -c '%u:%g %a' /host/path |
| Mounted read-only | docker inspect C --format '{{json .Mounts}}' — look for "RW":false |
| Read-only root filesystem | docker inspect C --format '{{.HostConfig.ReadonlyRootfs}}' |
| SELinux label | ls -Z /host/path, plus ausearch -m AVC -ts recent |
| AppArmor denial | journalctl -k --since '5 min ago' | grep -i apparmor |
CONTAINER=web
HOSTPATH=/srv/myapp-data
# Who is the process inside?
docker exec "$CONTAINER" id
# Who owns the directory on the host, numerically?
stat -c 'uid=%u gid=%g mode=%a %n' "$HOSTPATH"
# And what does the container see on the mount point?
docker exec "$CONTAINER" stat -c 'uid=%u gid=%g mode=%a %n' /data
# Every mount, with its read-write flag and propagation
docker inspect "$CONTAINER" --format '{{json .Mounts}}' | python3 -m json.toolCompare numeric IDs, not names. A bind mount carries the host’s
numeric ownership into the container, and the container’s /etc/passwd
maps those numbers to entirely different names — or to nothing at all.
ls -l inside the container showing an owner of 1000 where the host
shows deploy is the same user; ls -l showing appuser in both places
proves nothing, because they may be different numbers.
Storage driver issues
docker info --format 'driver={{.Driver}} root={{.DockerRootDir}}'
docker info | grep -A5 'Storage Driver'
mount | grep -w overlay | head
Common issues:
- Backing filesystem out of space. overlay2 uses the host’s filesystem for layers. If that filesystem is full, no new layers can be created — and this is reported as a pull or build failure rather than as a disk error.
d_typenot supported. The driver “is supported onxfsbacking filesystems, but only withd_type=trueenabled”. An XFS filesystem formatted withftype=0cannot host overlay2 correctly. Check withxfs_info /var/lib/docker | grep ftype;ftype=0requires a reformat, not a remount.- Inode exhaustion. Covered above;
df -i. - The data root is on a filesystem you did not intend. A host
provisioned with a small root partition and a large
/datawill fill the root the first time somebody pulls a big image. Move the data root deliberately with"data-root"indaemon.jsonrather than discovering the layout during an incident.
Diagnosing OverlayFS for one container
docker inspect web --format '{{json .GraphDriver.Data}}' | python3 -m json.tool
Illustrative output:
{
"LowerDir": "/var/lib/docker/overlay2/9f3...c1/diff:/var/lib/docker/overlay2/4a2...7e/diff",
"MergedDir": "/var/lib/docker/overlay2/b71...aa/merged",
"UpperDir": "/var/lib/docker/overlay2/b71...aa/diff",
"WorkDir": "/var/lib/docker/overlay2/b71...aa/work"
}
LowerDir is a colon-separated stack of image layers, ordered topmost
first. UpperDir is where every write the container has made lives —
du -sh on it is the true size of the writable layer. MergedDir is
populated only while the container runs; on a stopped container it is an
empty directory, which is normal and not corruption.
If a path listed in LowerDir does not exist on disk, the image’s layers
are genuinely damaged. Re-pull the image; if the pull is a no-op because
the daemon believes it already has the layers, remove the image first
with docker image rm and pull again.
Verification
#!/usr/bin/env bash
set -euo pipefail
CONTAINER=web
DATA_ROOT=$(docker info --format '{{.DockerRootDir}}')
# Bytes and inodes both under 85%
for flag in -h -i; do
pct=$(df $flag --output=pcent "$DATA_ROOT" | tail -1 | tr -dc '0-9')
echo "df $flag: $pct percent"
[ "$pct" -lt 85 ] || { echo "FAIL: df $flag at $pct percent" >&2; exit 1; }
done
# The container can actually write where it needs to
docker exec "$CONTAINER" sh -c 'touch /data/.writetest && rm /data/.writetest' \
|| { echo 'FAIL: container cannot write to /data' >&2; exit 1; }
# Log rotation is in force for this container
maxsize=$(docker inspect "$CONTAINER" \
--format '{{index .HostConfig.LogConfig.Config "max-size"}}')
[ -n "$maxsize" ] && [ "$maxsize" != '<no value>' ] \
|| { echo 'FAIL: no max-size on this container, logs are unbounded' >&2; exit 1; }
echo OKThe write test is the part that can fail for the right reason. Checking
df alone verifies the host; touching a file inside the container
verifies the whole path including the mount flags, the UID and the LSM
label.
Knowledge check
Knowledge check · 7 questions
Q1. `docker system df` shows:
Q2. Writes are failing with ENOSPC. `df -h` shows the data root at 64%. What should you check next?
Q3. A container log has grown to 40 GB and filled the disk. Deleting the file with `rm` frees nothing. Why, and what works?
Q4. Which statements about the json-file logging driver defaults are correct? Select all that apply.
Q5. A container gets "permission denied" writing to a bind mount. Which checks distinguish the possible causes? Select all that apply.
Q6. `docker system prune -a` is safe to run blindly in production.
Q7. On a stopped container an empty `MergedDir` is normal, because the unified view exists only while the container runs.
Passing score: 75%. Answers are checked in this browser.