Docker & ContainersXVII Β· LoggingFailure lab
Failure lab β unbounded logs fill the disk
What you'll learn
- Reproduce unbounded log growth in a controlled, bounded way
- Measure the growth rate and calculate time-to-full
- Demonstrate that `rm` on an open log file frees no space and `truncate` does
- Prove that a daemon-level rotation change leaves the existing container uncapped
- Verify the fix with an assertion that fails when the fix is absent
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
The scenario is real and it is the one you will actually meet: somebody
deploys a service with the log level left at DEBUG, including request
bodies. Twenty-four hours later /var/lib/docker is at 100% and every
workload on the host is failing for reasons that look unrelated to logging.
This lab reproduces it deliberately. Every stage ends in a check whose output tells you whether the stage worked, rather than whether the command ran.
Stage 0 β establish the baseline
You cannot detect growth without a starting point. Record one.
df -h /var/lib/docker | tee /var/tmp/loglab-baseline.txt
sudo du -sh /var/lib/docker/containers 2>/dev/null | tee -a /var/tmp/loglab-baseline.txt
docker info --format 'driver={{.LoggingDriver}}'If docker info already reports something other than json-file, or if you
have log-opts in daemon.json, move them aside first β otherwise Stage 1
will not reproduce the fault, and a lab that cannot fail teaches nothing.
Stage 1 β reproduce
Start a container that logs continuously with no rotation configured. Note
that no --log-opt is given: this is the stock configuration, and that is the
finding.
docker run -d --name logbomb --log-driver json-file alpine:3.20 sh -c '
i=0
while :; do
i=$((i+1))
echo "$(date -Iseconds) level=debug seq=$i msg=request_completed path=/api/v1/orders status=200 duration_ms=42 body=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
done'
docker ps --filter name=logbomb --format 'table {{.Names}}\t{{.Status}}'Stage 2 β measure, do not guess
The instinct at this point is to watch df -h until something happens. That
wastes the most useful thirty seconds of the incident. Measure the rate
instead, and turn it into a time-to-full figure you can act on.
CONTAINER=logbomb
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
LOGFILE="/var/lib/docker/containers/$CID/$CID-json.log"
A=$(sudo stat -c %s "$LOGFILE")
sleep 30
B=$(sudo stat -c %s "$LOGFILE")
RATE=$(( (B - A) / 30 ))
echo "growth: $RATE bytes/sec ($(( RATE * 86400 / 1024 / 1024 )) MB/day)"
FREE=$(df --output=avail -B1 /var/lib/docker | tail -1)
echo "free: $((FREE / 1024 / 1024)) MB"
echo "time to full: $(( FREE / (RATE > 0 ? RATE : 1) / 3600 )) hours"$ bash /var/tmp/loglab-rate.shgrowth: 412719 bytes/sec (33966 MB/day)
free: 38402 MB
time to full: 25 hoursIllustrative output
That single number β hours to full β is what turns a vague βthe disk is fillingβ into a decision about whether you have time to find the root cause or must reclaim space right now.
sudo du -sh /var/lib/docker/containers/* | sort -h | tail -5Stage 3 β confirm the diagnosis before acting
Two questions, two commands. Both answers must be consistent before you touch anything.
CONTAINER=logbomb
# Q1: what log configuration is this container actually running with?
docker inspect --format \
'{{.Name}} driver={{.HostConfig.LogConfig.Type}} opts={{.HostConfig.LogConfig.Config}}' \
"$CONTAINER"
# Q2: does the file size match the consumption you measured?
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
sudo ls -lh /var/lib/docker/containers/"$CID"/"$CID"-json.log*An opts=map[] in the first answer is the diagnosis: the container has no
rotation whatsoever. If the map is populated and the file is still enormous,
you have a different problem β a single log line larger than max-size, or a
different consumer entirely β and the remedy below is the wrong one.
Stage 4 β the recovery that does not work, and the one that does
This stage exists because the intuitive command is the wrong one, and the reason is not obvious from anything Docker prints.
CONTAINER=logbomb
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
LOGFILE="/var/lib/docker/containers/$CID/$CID-json.log"
BEFORE=$(df --output=avail -B1 /var/lib/docker | tail -1)
sudo rm -f "$LOGFILE"
sleep 5
AFTER=$(df --output=avail -B1 /var/lib/docker | tail -1)
echo "freed by rm: $(( (AFTER - BEFORE) / 1024 / 1024 )) MB"
# The inode is gone from the directory but not from the filesystem.
DOCKERD=$(pgrep -x dockerd | head -1)
sudo ls -l /proc/"$DOCKERD"/fd 2>/dev/null | grep -F 'json.log' | grep -F '(deleted)'$ bash /var/tmp/loglab-rm.shfreed by rm: 0 MB
l-wx------ 1 root root 64 Aug 12 09:41 27 -> /var/lib/docker/containers/9c7e.../9c7e...-json.log (deleted)Illustrative output
Zero megabytes freed, and the file descriptor is still open on a deleted
inode. The container keeps logging into it, the space keeps growing, and you
can no longer measure it with du on the path because the path no longer
exists. You have made the incident harder to diagnose without making it
smaller.
Now do it correctly. Note the evidence capture first β the log is the only record of whatever caused the incident, and this step destroys it.
CONTAINER=logbomb
# The rm above removed the path, so recreate the container to restore a sane
# state before demonstrating the correct recovery.
docker rm -f "$CONTAINER" > /dev/null
docker run -d --name "$CONTAINER" --log-driver json-file alpine:3.20 sh -c '
while :; do echo "$(date -Iseconds) level=debug msg=filler"; done'
sleep 20
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
LOGFILE="/var/lib/docker/containers/$CID/$CID-json.log"
sudo tail -c 2M "$LOGFILE" > /var/tmp/loglab-evidence.json
BEFORE=$(df --output=avail -B1 /var/lib/docker | tail -1)
sudo truncate -s 0 "$LOGFILE"
AFTER=$(df --output=avail -B1 /var/lib/docker | tail -1)
echo "freed by truncate: $(( (AFTER - BEFORE) / 1024 / 1024 )) MB"
docker logs --tail 2 "$CONTAINER"The last line is the assertion that matters: docker logs still returns
output after the truncation, which proves the daemonβs descriptor survived and
the container is logging normally.
Stage 5 β fix it, and verify the fix
- Set rotation at the daemon level. Add log-opts with max-size and max-file to /etc/docker/daemon.json. Every value must be a quoted string.
- Validate the JSON before it reaches the daemon. A malformed daemon.json means the daemon will not start, on a host full of running containers.
- Reload rather than restart. A reload re-reads the configuration without disturbing running containers.
- Observe that the running container is still uncapped. This is the step everyone skips, and it is why the incident recurs.
- Recreate the workload. Remove and run again, or use compose with force-recreate. A restart is not sufficient because it reuses the same container object.
- Run the audit and confirm every container reports a populated options map.
- Fix the application. Rotation bounds the damage; it does not stop a service logging request bodies at DEBUG in production.
sudo tee /etc/docker/daemon.json.new > /dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
EOF
sudo python3 -m json.tool /etc/docker/daemon.json.new > /dev/null \
&& sudo mv /etc/docker/daemon.json.new /etc/docker/daemon.json \
&& sudo systemctl reload dockerNow the step that proves the point. The container is still running, the daemon has reloaded, and nothing about this container changed:
$ docker inspect --format '{{.HostConfig.LogConfig.Config}}' logbombmap[]Illustrative output
Recreate, then assert. This script exits non-zero when the fix has not landed, which is what makes it a verification rather than a demonstration:
docker rm -f logbomb > /dev/null
docker run -d --name logbomb alpine:3.20 sh -c 'while :; do echo filler; done' > /dev/null
FAIL=0
for C in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$C")
OPTS=$(docker inspect --format '{{.HostConfig.LogConfig.Config}}' "$C")
DRIVER=$(docker inspect --format '{{.HostConfig.LogConfig.Type}}' "$C")
case "$DRIVER" in
journald|local|none) continue ;;
esac
case "$OPTS" in
*max-size*) echo "ok $NAME $OPTS" ;;
*) echo "FAIL $NAME has no max-size"; FAIL=1 ;;
esac
done
exit "$FAIL"local and journald are excluded deliberately: local is bounded by its own
defaults and journald is bounded by journald.conf, so demanding a
max-size on them would produce a false failure. A check that cries wolf gets
turned off, and then it protects nothing.
Confirm the ceiling actually holds by letting the container run past it:
CID=$(docker inspect --format '{{.Id}}' logbomb)
sleep 300
TOTAL=$(sudo du -cb /var/lib/docker/containers/"$CID"/"$CID"-json.log* | tail -1 | cut -f1)
echo "total log bytes: $TOTAL (ceiling: $((10 * 1024 * 1024 * 3)))"
[ "$TOTAL" -le $((10 * 1024 * 1024 * 3 + 1048576)) ] \
&& echo 'PASS: rotation is bounding the log' \
|| echo 'FAIL: log exceeded the configured ceiling'Stage 6 β tear down
docker rm -f logbomb 2>/dev/null || true
rm -f /var/tmp/loglab-evidence.json /var/tmp/loglab-baseline.txt
df -h /var/lib/dockerThe rm-deleted inode from Stage 4 was released when that container was
removed. Confirm with df that free space matches your Stage 0 baseline; if
it does not, some descriptor is still open and a daemon restart will release
it.
Knowledge check
Knowledge check Β· 5 questions
Q1. A running container has a 5 GB json-file log and the disk is critical. What is the fastest safe way to reclaim the space?
Q2. You run `rm` on a running container log file and `df` reports no change. Why?
Q3. Which command finds the real disk consumer when container logs are the cause?
Q4. You add max-size and max-file to daemon.json and reload. Which statements are true? Select all that apply.
Q5. `docker logs --tail 0 -f CONTAINER` reduces the disk space the container log consumes.
Passing score: 75%. Answers are checked in this browser.