Docker & ContainersII · Linux InternalsOverlayFS
OverlayFS — the filesystem underneath every container
What you'll learn
- Explain the upper/lower/work/merged OverlayFS model
- Mount an OverlayFS manually with mount(8)
- Recognise copy-up semantics and their performance implications
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
A container’s root filesystem looks like a normal Linux tree. It isn’t. It is an OverlayFS composition of multiple read-only image layers plus one read-write upper layer that captures every change.
Understanding OverlayFS is the difference between “my container is
1.2 GB” being an accurate size and a misleading one. And between
“why is apt-get install slow the second time” being a mystery
and a known consequence of copy-up.
The four directories
flowchart TB
L1["Lower (image layer 1)"] --> M[Merged]
L2["Lower (image layer 2)"] --> M
L3["Lower (image layer 3 - Ubuntu)"] --> M
W[Work] --> M
U["Upper (container read-write)"] --> M
W -.-> U
OverlayFS combines three (or more) directories into one visible filesystem:
- lower — read-only image layers, stacked. The lowest is the bottom of the image; the highest is closest to the container’s root.
- upper — read-write. Every change the container makes lands
here. This is what
docker diffreports. - work — internal scratch directory OverlayFS uses during
copy-up. Must be on the same filesystem as
upper. - merged — the resulting view. What the container sees.
The visible filesystem is the merged view. From inside the
container, the layered nature is invisible. From the host, it is
visible via /var/lib/docker/overlay2/<id>/.
Mounting OverlayFS manually
This is the single most useful debugging tool for understanding containers. You can do everything OverlayFS does without Docker.
mkdir -p /tmp/lower1 /tmp/lower2 /tmp/upper /tmp/work /tmp/merged
echo 'from lower1' > /tmp/lower1/file.txt
echo 'from lower2' > /tmp/lower2/file.txt
mount -t overlay overlay -o lowerdir=/tmp/lower2:/tmp/lower1,upperdir=/tmp/upper,workdir=/tmp/work /tmp/merged
cat /tmp/merged/file.txt
echo 'new content' > /tmp/merged/file.txt
cat /tmp/upper/file.txt
umount /tmp/merged$ mkdir -p /tmp/lower1 /tmp/lower2 /tmp/upper /tmp/work /tmp/merged
echo 'from lower1' > /tmp/lower1/file.txt
echo 'from lower2' > /tmp/lower2/file.txt
mount -t overlay overlay -o lowerdir=/tmp/lower2:/tmp/lower1,upperdir=/tmp/upper,workdir=/tmp/work /tmp/merged
echo '--- merged view ---'
cat /tmp/merged/file.txt
echo '--- writing through merged ---'
echo 'overwritten' > /tmp/merged/file.txt
echo '--- upper dir after write ---'
ls /tmp/upper/
cat /tmp/upper/file.txt
echo '--- lower1 unchanged ---'
cat /tmp/lower1/file.txt
umount /tmp/merged--- merged view ---
from lower2
--- writing through merged ---
--- upper dir after write ---
file.txt
overwritten
--- lower1 unchanged ---
from lower1What this means for Docker images
A Docker image is a list of layers. Each FROM, RUN, COPY, or
ADD instruction produces a new layer. When a container starts:
- All image layers become lower directories.
- A new empty upper directory is created.
- A work directory is created.
- The merged view is mounted at the container’s root.
When the container deletes a file that exists in a lower layer, the deletion is recorded as a “whiteout” file in the upper layer. The file still exists in the lower; it’s just invisible in the merged view.
CONTAINER=web
docker diff "$CONTAINER" | head -20
echo '--- size of the writable layer ---'
docker ps --size --filter "name=$CONTAINER" --format '{{.Names}}\t{{.Size}}'C /usr
C /usr/local
A /usr/local/etc
A /usr/local/etc/redis
A /usr/local/etc/redis/redis.conf
web 4.02GB (virtual 4.31GB)Illustrative output
Read the prefixes: A is a file that only ever existed in the upper
layer, which costs exactly what it contains. C is a copy-up, which
costs the size of the original. D is a whiteout, which costs
nothing but does not reclaim anything either. The docker ps --size
figure is the writable layer alone — the number before “virtual” —
and it is the one that should be near zero on a well-behaved
container.
The three fixes, in the order you should reach for them:
- Put mutable data in a volume. A volume is a bind of a host directory into the container; it is not part of the overlay, so writes go straight to the filesystem with no copy-up at any size. This is the answer for databases, indexes and anything the application opens read-write.
- Do the mutation at build time. If the file only needs its header rewritten once, rewrite it in the Dockerfile so the layer ships in its final state. The copy then happens once during the build instead of once per container per start.
- **Do not
chown -Rimage content at runtime.** Set ownership in the Dockerfile withCOPY --chown=, which bakes the right UID into the layer and removes the reason for the recursive copy entirely.
Inspecting a running container’s layers from the host
CONTAINER=web
docker inspect --format '{{json .GraphDriver}}' "$CONTAINER" | jq .
UPPER=$(docker inspect --format '{{.GraphDriver.Data.UpperDir}}' "$CONTAINER")
echo "upper: $UPPER"
sudo du -sh "$UPPER"{
"Data": {
"LowerDir": "/var/lib/docker/overlay2/2f9c.../diff:/var/lib/docker/overlay2/8ab1.../diff",
"MergedDir": "/var/lib/docker/overlay2/61de.../merged",
"UpperDir": "/var/lib/docker/overlay2/61de.../diff",
"WorkDir": "/var/lib/docker/overlay2/61de.../work"
},
"Name": "overlay2"
}
upper: /var/lib/docker/overlay2/61de.../diff
4.0G /var/lib/docker/overlay2/61de.../diffIllustrative output
CONTAINER=web
UPPER=$(docker inspect --format '{{.GraphDriver.Data.UpperDir}}' "$CONTAINER")
sudo find "$UPPER" -type f -printf '%s\t%p\n' 2>/dev/null | sort -rn | head -10 | numfmt --field=1 --to=iec4.0G /var/lib/docker/overlay2/61de.../diff/opt/model/index.db
12M /var/lib/docker/overlay2/61de.../diff/var/log/app/current
4.0K /var/lib/docker/overlay2/61de.../diff/etc/app/config.yamlIllustrative output
A 4 GB file at the top of that list, with the same path present in
the image, is a copy-up and not a leak. docker image inspect the
base image and look for the same path: if it is there, no amount of
log rotation will help, and the file belongs in a volume.
docker image inspect nginx --format '{{.Size}}' | numfmt --to=iec
docker image history nginx --no-trunc --format '{{.CreatedBy}} {{.Size}}' | head -10Performance characteristics
- Reads of unchanged files: one extra indirection (look up in upper, fall through to lower). Negligible cost.
- Writes to unchanged files: copy-up from lower to upper, then write. This is the cost most operators underestimate.
- Writes to already-copied files: write to upper only. Cheap.
- Deletes of files in lower: whiteout file in upper. Cheap.
- Many small writes to a fresh container: every file gets
copy-up before the write. This is why
apt-get installis slow on first run after pulling a new layer.
For workloads that write a lot of small files, tmpfs (which is
not OverlayFS) or a direct bind-mount to a fast filesystem may be
faster.
Knowledge check
Knowledge check · 5 questions
Q1. Which OverlayFS directory captures every change a running container makes?
Q2. When a container writes to a file for the first time, OverlayFS modifies the lower layer in place.
Q3. Why does the upper and work directory have to be on the same filesystem?
Q4. A container writes 8 bytes to a 4 GB file that came from the image. How much data does OverlayFS move, and how much does the writable layer grow?
Q5. Which of these trigger a full copy-up of a file that came from an image layer? Select all that apply.
Passing score: 75%. Answers are checked in this browser.