Docker & ContainersIV · ImagesLayers
Layers and the copy-on-write filesystem
What you'll learn
- Explain the layered model of an image
- Read and interpret `docker image inspect`
- Diagnose "image too big" problems
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-09
An image is not a tarball. It is an ordered list of immutable layers plus a manifest that describes how to compose them. The filesystem visible inside a container is the union of these layers, mounted via OverlayFS.
What an image is
flowchart TB
M[Manifest] --> C[Image config]
M --> L1[Layer 1: FROM ubuntu]
M --> L2[Layer 2: apt-get install nginx]
M --> L3[Layer 3: COPY nginx.conf]
M --> L4[Layer 4: ENTRYPOINT nginx]
Every image has:
- A manifest describing the image, including its size, creation date, and layer list.
- An image config describing the runtime configuration: env vars, entrypoint, command, working dir, exposed ports, labels.
- One or more layers. Each layer is a tar archive of the filesystem changes introduced by a Dockerfile instruction.
When a container is created, the image’s layers become the container’s lower OverlayFS layers; the container gets a new empty upper layer.
Inspecting an image
docker image inspect nginx:1.27
The output includes:
Id— the image’s content-addressable identifier.RepoDigests— the digest this image was pulled from.RootFS.Layers— the SHA256 of each layer.Config— the runtime configuration.Os,Architecture,Size— the platform details.Created— when the image was built.
Use jq for specific fields:
docker image inspect nginx:1.27 \
--format '{{json .RootFS.Layers}}' | jq '.[]' | head -5
Image size: virtual vs on-disk
A 1 GB image does not take 1 GB per container. Layers are shared between containers started from the same image.
docker image ls
# SIZE column shows the image's virtual size
docker system df
# Shows the actual on-disk size after shared-layer deduplication
docker system df gives the truth:
- Images — total size of all pulled images.
- Containers — size of writable layers.
- Local Volumes — total size of named volumes.
- Build Cache — BuildKit’s local cache.
$ docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 14 4 2.731GB 1.842GB (67%)
Containers 4 0 78.3MB 78.3MB (100%)
Local Volumes 8 1 312.4MB 156.2MB (50%)
Build Cache 92 0 1.273GB 1.273GBDeleting in a later layer deletes nothing
A layer records the changes an instruction made. There is no mechanism for a layer to reach backwards and modify an earlier one — they are immutable and content-addressed, and an earlier layer’s digest is baked into everything above it. So a layer cannot express “that file is gone”. It can only express “hide it”.
IMAGE=myorg/api:1.4.0
WORK=$(mktemp -d)
docker save "$IMAGE" | tar -C "$WORK" -x
find "$WORK" -type f ! -name '*.json' -print0 \
| xargs -0 -I{} sh -c 'tar -tf "{}" 2>/dev/null | sed "s|^|{} |"' \
| grep -E 'deploy_key|\.wh\.'
rm -rf "$WORK".../blobs/sha256/9f3a... app/deploy_key
.../blobs/sha256/c418... app/.wh.deploy_keyIllustrative output
Two lines, and together they are the whole finding: one layer holds the file, a later layer holds the whiteout that hides it. If the image were clean, the first line would not exist. That is a check whose output distinguishes “the scanner is wrong” from “the scanner is right and we have a key to rotate”, and it takes about ten seconds.
docker history gives the cheaper, coarser version of the same
signal — a rm step whose layer size is a few bytes has not
reclaimed anything:
IMAGE=myorg/api:1.4.0
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" | head -120B CMD ["/app/api"]
0B USER 10001
32B RUN /bin/sh -c rm -f /app/deploy_key # buildkit
0B RUN /bin/sh -c git clone git@github.com:myorg/private.git /src # buildkit
3.38kB COPY deploy_key /app/deploy_key # buildkit
77.8MB /bin/sh -c #(nop) ADD file:... in /Illustrative output
The rm layer is 32 bytes — the whiteout marker. The COPY layer
below it is still 3.38 kB, and that 3.38 kB is the key. Layer sizes
in docker history never go down, and a “cleanup” step that shows a
tiny positive size is a cleanup that did not happen.
The layer cache
Each layer is content-addressable: its identifier is the SHA256 of its tar archive. When you build an image, Docker checks whether each layer is already in the cache (by digest). If yes, the instruction that produced it is skipped (“layer already exists”).
This is why Dockerfile instruction order is critical:
- Place frequently-changing instructions late.
- Place rarely-changing instructions (apt-get update, package install) early.
- Combine related operations into a single
RUNto keep the layer count low.
A typical mistake:
# Bad — apt-get update cached for a long time, then invalidated
# when you change any later line.
RUN apt-get update
COPY app.py /app/
RUN pip install -r /app/requirements.txt
vs:
# Good — change the order so layer cache is invalidated only
# when the actual dependencies change.
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY app.py /app/
Knowledge check
Knowledge check · 6 questions
Q1. Two containers are started from the same image. The first writes to `/etc/app.conf` inside its writable layer. The second, started later, does not. What does the second container see at `/etc/app.conf`?
Q2. A single `RUN` chaining ten commands with `&&` produces one layer, not ten.
Q3. Which command shows each image layer and its size?
Q4. A production image is 1.8 GB. The biggest contributor is almost always:
Q5. A Dockerfile has `COPY deploy_key /app/deploy_key` and, three instructions later, `RUN rm -f /app/deploy_key`. `docker run image ls /app/deploy_key` reports no such file. Is the key in the published image?
Q6. In `docker history`, a cleanup step showing a size of a few bytes proves the cleanup reclaimed the space.
Passing score: 75%. Answers are checked in this browser.