Skip to main content
RunBook Academy

Docker & ContainersIV · ImagesLayers

Layers and the copy-on-write filesystem

Foundation⏱ ~24 min

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

Not yet marked complete on this device.

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.
Read-only / Safedisk reality
$ docker system df
TYPE            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.273GB

Deleting 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”.

Read-only / Safewhat is actually inside the layers?
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_key

Illustrative 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:

Read-only / Safedid that rm reclaim anything?
IMAGE=myorg/api:1.4.0
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" | head -12
0B	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 RUN to 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

  1. 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`?

  2. Q2. A single `RUN` chaining ten commands with `&&` produces one layer, not ten.

  3. Q3. Which command shows each image layer and its size?

  4. Q4. A production image is 1.8 GB. The biggest contributor is almost always:

  5. 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?

  6. 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.