← All maps Containers in 3D

A visual guide · containers, images, BuildKit, devcontainers, CI/CD

Containers, from the kernel up

One page, eleven pictures. Scroll and the model on the right changes with the text. Drag it to look around. By the end you will be able to explain every word in a Dockerfile, why a build was slow, how BuildKit's cache decides, what a dev container really is, and how CI ships the same image to every machine.

Chapter 1

A container is not a small virtual machine

A virtual machine pretends to be a whole computer. A hypervisor fakes the hardware, and each VM boots its own operating system with its own kernel. Three VMs means three kernels, three boots, and gigabytes of duplicated OS.

A container is far less. It is an ordinary process running on the host's one kernel, wearing blinkers. The kernel shows it a private view of the system and limits what it may use. There is no second kernel and no boot, which is why a container starts in milliseconds.

That one fact explains a lot. A Linux container needs a Linux kernel. On a Mac or Windows laptop, Docker Desktop quietly runs one small Linux VM, and all your containers share that kernel. And the CPU architecture leaks through: an arm64 laptop runs arm64 images natively and emulates amd64 ones, slowly.

The stack under docker run: the docker CLI talks to the dockerd daemon, which hands the work to containerd, which uses runc to ask the kernel for the isolated process. All of them follow the OCI standards, which is why images built by Docker run under Kubernetes, Podman, or anything else.

RememberA VM virtualises hardware. A container virtualises the operating system's view. Same kernel, fenced-off process.

Chapter 2

Two kernel features do all the work

Namespaces decide what a process can see. Each namespace gives it a private copy of one part of the system:

  • pid: it sees itself as process 1, and none of the host's processes.
  • net: its own network interfaces, IP address and ports.
  • mnt: its own filesystem tree, the image's files instead of the host's.
  • uts, ipc, user: its own hostname, shared memory, and user IDs.

Cgroups decide what a process can use. Control groups cap CPU, memory, process count and disk I/O. Pass --memory=512m and the kernel kills the container when it goes over. That is the famous OOMKilled, exit code 137. Watch the bars in the model: one of them hits its limit.

Add capabilities (root inside is a reduced root), seccomp (a list of allowed system calls) and the filesystem from the next chapter, and that is the whole trick. Nothing magic, just kernel features combined.

docker run --rm -it --memory=256m --cpus=0.5 alpine sh
/ # ps        # only your shell and ps: the pid namespace at work
/ # hostname  # a random container id: the uts namespace
RememberNamespaces = walls you cannot see through. Cgroups = a meter with a hard limit. A container is a process with both.

Chapter 3

An image is a stack of read-only layers, plus a note

An image is the files a container starts from. It is not one big file but an ordered stack of layers. Each layer is a tar archive of the changes one build step made: files added, changed, or deleted.

Only three Dockerfile instructions create a filesystem layer: RUN, COPY and ADD. The rest (ENV, CMD, ENTRYPOINT, WORKDIR, USER, EXPOSE, LABEL) only edit the image's config, a small JSON note about how to run it. In the model it is the ghost plate on top, zero bytes.

Each layer is named by the sha256 digest of its content, so it is content-addressed: identical content gives an identical name. Two images built on the same base share those base layers on disk and in the registry, stored once.

The trap: layers only ever add. Delete a file in a later step and it still sits in the earlier layer, hidden by a marker called a whiteout. The image does not get smaller. Clean up in the same RUN that made the mess:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*      # same layer, so it really shrinks

Measured (examples/01-layers): a RUN dd … 20 MB layer followed by RUN rm. docker history still shows 21 MB for the first step and 0 B for the delete.

RememberImage = layers (RUN, COPY, ADD) + config (everything else). Layers only stack up; a later delete never shrinks an earlier layer.

Chapter 4

A container is the image plus one thin writable layer

When you docker run an image, nothing is copied. A union filesystem (OverlayFS on Linux) stacks the image's read-only layers and lays one empty writable layer on top. The process sees a single merged filesystem.

Ten containers from one image means one shared stack and ten thin writable layers. That is why starting another container is nearly free.

Change a file that lives in the image and copy-on-write kicks in. The file is first copied up into that container's writable layer, then changed there. The image underneath is never touched, and other containers never see the change. Watch the small cube.

Delete the container and its writable layer is gone, with everything the process wrote. That is the point: containers are disposable. Data that must outlive them goes in a volume (storage Docker manages) or a bind mount (a host folder mounted in).

RememberImage = the read-only recipe result, shared. Container = that plus a thin, disposable scratch layer. Keep state in volumes.

Chapter 5

The build cache: one rule, and it cascades

Before running a step, the builder checks whether it has done exactly this before. The cache key for a step is built from:

  • the cache key of the step before it (the parent),
  • the instruction text itself,
  • for COPY and ADD, a checksum of the files being copied.

Because every key includes the parent, one miss invalidates every step after it. That single rule is why order matters. Put what changes rarely (the OS, the dependency list) at the top, and what changes on every commit (your source) at the bottom.

Try it on the model. With the good order, a source edit rebuilds only the last copy. Switch to the bad order, where COPY . . comes before npm ci, and the same one-line edit reinstalls every dependency.

Press a button to rebuild.

Two gotchas. A RUN key is the text of the command, so RUN apt-get update stays cached for months while the world moves on. And the build context (the folder you send to the builder) must leave out junk with a .dockerignore, or an unrelated file change breaks COPY . ..

Measured (examples/02-cache-order, one small dependency): after a one-line source edit, the naive order took 2.3 s and reran npm ci; the good order took 0.3 s. With a real dependency tree the gap becomes minutes.

RememberCache key = parent + instruction + copied files. First miss → everything below rebuilds. Rarely-changing things go up top.

Chapter 6

BuildKit: the build is a graph, not a list

BuildKit is the build engine behind docker build (the default since Docker 23). buildx is the CLI plugin for driving it. BuildKit does not run your Dockerfile top to bottom. It first turns it into a dependency graph (called LLB), then solves the graph:

  • Independent steps run in parallel. Two stages that don't depend on each other build at the same time.
  • Unneeded stages are skipped. If the target never uses the test stage, it never runs.
  • Cache mounts keep a folder between builds without putting it in the image: RUN --mount=type=cache,target=/root/.npm npm ci. Even a cache miss is fast, because downloads are reused.
  • Secret mounts hand a token to one step and leave no trace in any layer: RUN --mount=type=secret,id=npmrc ….

BuildKit can also export and import its cache, which is what makes CI builds fast on brand-new machines:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/app:buildcache \
  --cache-to   type=registry,ref=registry.example.com/app:buildcache,mode=max \
  -t registry.example.com/app:1.4.0 --push .

mode=min exports only the layers of the final image. mode=max also exports every intermediate stage, so builder stages hit the cache too. Other backends: type=gha (GitHub Actions cache), type=local, type=s3, and type=inline (stored inside the image, min only).

RememberBuildKit = graph solver: parallel steps, skipped stages, cache mounts for speed, secret mounts for safety, and a cache you can ship between machines.

Chapter 7

Multi-stage builds: build fat, ship thin

To build software you need compilers, headers, package caches. To run it you need almost none of that. A multi-stage build puts several FROM lines in one Dockerfile. Each starts a fresh stage. The last stage (or the one you pick with --target) becomes the image. Earlier stages are scaffolding, and only what you copy out with COPY --from survives.

FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .

FROM scratch                       # an empty image: no shell, no libc
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]

Measured (examples/03-multi-stage): the same Go server is 902.9 MB as a single golang:1.22 stage and 4.6 MB from scratch. That is 196 times smaller.

A smaller image is not just faster to pull. It has less attack surface and fewer packages for a vulnerability scanner to flag. The same trick gives you dev and prod images from one file: a dev stage with tools, and a runtime stage without.

RememberMany FROMs, one Dockerfile. Only the last stage ships; COPY --from carries the results across. Build fat, ship thin.

Chapter 8

Registries: tags move, digests never do

A registry stores images. Its address reads like registry.example.com/team/app:1.4.0: a registry host, a repository (team/app), and a tag (1.4.0). With no host, Docker assumes Docker Hub, so node:20 really means docker.io/library/node:20.

Inside the registry, everything is a content-addressed blob:

  • Layers: the compressed tarballs.
  • Config: the JSON note (env, cmd, user, history).
  • Manifest: the list "this config plus these layers", for one platform.
  • Image index (manifest list): "for linux/amd64 use this manifest, for linux/arm64 use that one". This is how one tag serves every CPU.

A tag is just a movable sticky note on an index or manifest. A digest (app@sha256:9c1e…) is the content's fingerprint and can never point anywhere else. Push a new build and watch latest jump while 1.4.0 stays put.

Pull walks the same tree downward: tag → index → the manifest for your platform → the config and layers you don't already have. Push sends only missing blobs, which is why you see Layer already exists.

RememberTag = a nickname that can move. Digest = a fingerprint that cannot. Pin by digest when you need yesterday's build to stay yesterday's build.

Chapter 9

A dev container is your editor's other half, in an image

A dev container is a container you develop inside. The image holds the whole toolchain: compilers, linters, CLIs, the right versions. Your editor runs a small server inside it. The editor window stays on your laptop, but every terminal, build and extension runs in the container. "Works on my machine" becomes "works in this image", and everyone, CI included, uses the same image.

It is described by .devcontainer/devcontainer.json, an open spec from containers.dev that VS Code, Codespaces, JetBrains and the devcontainer CLI all read:

  • What to run: an image, a build (Dockerfile + context), or a dockerComposeFile for several containers.
  • Features: reusable install scripts (ghcr.io/devcontainers/features/…) layered on top of the image.
  • Mounts: your project folder is bind-mounted into /workspaces/<name>. Same files, live, both ways.
  • Who and where: remoteUser, forwardPorts, runArgs, and editor customizations.

Lifecycle hooks run in a fixed order. Watch the marker: initializeCommand runs on the host before anything else (logins, checks, picking an image). Then, inside the container: onCreateCommand and updateContentCommand (once, at creation), postCreateCommand (after creation, with your secrets and user), postStartCommand (every start), postAttachCommand (every time the editor attaches).

For a big toolchain, don't build the image on every laptop. Prebuild it in CI, push it, and point devcontainer.json at the tag. Opening the project is then a pull, not a forty-minute build.

RememberDev container = editor UI on the host, everything else in the image, project bind-mounted in. initialize on the host; create, start, attach inside.

Chapter 10

CI/CD: build once, run everywhere, never twice

In CI, images play two roles. They are a product you build and ship. And they are the environment your jobs run in: the same dev image, so a test that passes in CI passes on your laptop.

A well-run image pipeline looks like the model:

  1. Compute a tag from the inputs. Hash every file that goes into the image (the Dockerfile, the scripts it copies, its build args). Same inputs, same tag. This is a content-addressed tag.
  2. Ask the registry. If the tag exists, someone already built exactly this, so skip the build. Most runs end here in seconds.
  3. Otherwise build with BuildKit, importing the shared registry cache so only changed layers do real work.
  4. Scan, describe, sign. A vulnerability scanner (Trivy, Grype), an SBOM (the list of every package inside), provenance (how it was built), and a signature (cosign).
  5. Push, then every job and every laptop pulls the same digest.

How jobs use the image: GitHub Actions can run a whole job in it (container: image: …), start helper services: like a database next to it, or a script can wrap commands as docker run -v "$PWD":/work image make test. Releasing is often promotion: the exact digest that passed testing is copied to a release registry, never rebuilt. And a retention policy deletes old tags so the registry doesn't grow forever.

Why a content tag instead of the git commit? A commit tag rebuilds the image for every commit, even when no image input changed. A content tag rebuilds only when something that goes into the image changed.

RememberHash the inputs → tag. Tag exists? Skip. Else build with shared cache, scan, sign, push. Everyone runs the same digest.

Chapter 11

The whole picture, in one breath

A Dockerfile is a recipe. BuildKit solves it as a graph, reusing every step whose cache key (parent + instruction + copied files) it has seen. The result is an image: read-only, content-addressed layers plus a config, described by a manifest, grouped per platform in an index, and pushed to a registry under a movable tag and a permanent digest.

That one image then serves three places. On your laptop as a dev container, with the editor attached and the code bind-mounted. In CI, as the environment every job runs in. And, in its slim multi-stage form, in production. Wherever it runs, a container is the same thing: a process on a shared kernel, fenced by namespaces, metered by cgroups, with one thin writable layer it throws away.

RememberRecipe → graph → layers → registry → one digest everywhere → a fenced-off process. That's the whole story.
Now make it stick.
The lab lets you break a build cache, fix it, shrink an image, and push, with missions that check your work.
Open the build lab →
1/11 VM vs containerdrag to orbit
The 3D models need WebGL and a connection to cdn.jsdelivr.net. The text works on its own.

The kinds of images you will meet

KindWhat it isUse it forWatch out
scratchLiterally empty. No shell, no libc, no files.Static binaries (Go, Rust).No shell to debug with; add CA certificates yourself if you need TLS.
distrolessJust a language runtime and its libraries, no package manager or shell.Production runtimes.Debug with :debug variants or ephemeral debug containers.
alpineA ~8 MB Linux using musl libc and apk.Tiny utility images.musl is not glibc: some prebuilt binaries and Python wheels break or get slow.
-slimDebian or Ubuntu with the extras removed.A safe default runtime base.You may need to apt-get install a few libraries.
full / languagenode:20, python:3.12, golang:1.22: runtime plus compilers and common tools.Builder stages, CI.Large (around 1 GB). Rarely ship these.
dev imageA full toolchain: compilers, debuggers, linters, CLIs, shells.Dev containers and CI jobs.Can reach tens of GB; prebuild, cache and tag by content.
CI imageOften the dev image itself, sometimes a trimmed variant.Job environments.Keep it identical to dev, or "passes locally, fails in CI" returns.
golden / baseAn organisation's own approved base, rebuilt and patched centrally.The FROM of everything else.When it changes, everything above it rebuilds. That is the cascade again.
multi-archNot a kind of image but an index covering several platforms.Mixed amd64 / arm64 fleets.Build with buildx --platform; emulation (QEMU) is slow, native builders are fast.
dind"Docker in Docker": an image that runs its own Docker daemon.CI jobs that must build images.Needs privileged mode; mounting the host's docker.sock or a remote BuildKit is the common alternative.

Every Dockerfile instruction, and what it costs

InstructionLayer?What its cache key looks atTip
FROM img AS namestarts a stagethe resolved base image digestPin img:tag@sha256:… for reproducible builds.
RUN cmdyesthe command text + parentChain and clean up in one RUN. The text is the key, so the network is never consulted.
COPY src dstyeschecksums of the copied filesCopy dependency manifests first, source last.
COPY --from=stageyesthe files from that stageAlso works with any image: --from=alpine:3.20.
ADDyesfiles, or the remote contentAuto-extracts tarballs and fetches URLs; prefer COPY unless you need that.
ARGnosteps that use it see its valueBuild-time only. Changing a used ARG busts the cache from where it's used.
ENVno (config)part of later keysPersists into the running container. Never put secrets here: they are visible in the image.
WORKDIRtinypathCreates the folder; use it instead of RUN cd.
USERno (config)part of later keysDon't run as root in production.
ENTRYPOINT / CMDno (config)noneENTRYPOINT is the program, CMD its default arguments. Use the JSON (exec) form so signals reach your process.
EXPOSE, LABEL, HEALTHCHECK, STOPSIGNALno (config)noneEXPOSE is documentation; -p actually publishes.

Your debugging toolkit

QuestionCommand
Why did this step not hit the cache?docker build --progress=plain . (look for CACHED)
Which layer is huge?docker history --no-trunc img, or the dive tool to browse layers
What exactly is in the config?docker image inspect img
What does the registry have?docker buildx imagetools inspect img:tag (shows the index and per-platform manifests)
Is the build context too big?the first lines of a build: transferring context: 412MB → fix .dockerignore
How big is my build cache?docker buildx du; clean with docker builder prune
Start completely freshdocker build --no-cache .
Get a shell in a running containerdocker exec -it <name> sh
Why did it die?docker logs <name>; docker inspect <name> --format '{{.State.ExitCode}} {{.State.OOMKilled}}'

Ten traps everyone hits once

  1. COPY . . too early. Every edit reinstalls dependencies. Copy the manifests first.
  2. No .dockerignore. node_modules, .git and build output go into the context, slow every build, and break the cache.
  3. Deleting in a later layer. The bytes are still in the image. Clean up in the same RUN.
  4. apt-get update on its own line. It stays cached and goes stale; then the next install fails. Always update && install together.
  5. Secrets in ENV or ARG. They end up in the image config and history. Use --mount=type=secret.
  6. Shipping the builder. A 1 GB image for a 10 MB binary. Use multi-stage.
  7. Trusting latest. It moves. Pin a version, or a digest when it matters.
  8. Shell-form ENTRYPOINT. ENTRYPOINT node app.js runs under sh, which swallows SIGTERM, so stops take ten seconds and then a kill. Use the JSON form, or --init.
  9. Writing state into the container. It disappears with docker rm. Use a volume.
  10. Wrong architecture. An amd64 image on an arm64 Mac runs under emulation, much slower, sometimes crashing. Build multi-arch.

Glossary: every word, one line each

OCI
Open Container Initiative: the standards for the image format, the runtime, and the registry API. Why images are portable between tools.
image
Read-only layers plus a config. The template a container starts from.
container
A running (or stopped) process made from an image, with namespaces, cgroups and a writable layer.
layer
A tar archive of the filesystem changes made by one RUN, COPY or ADD.
digest
sha256:… of some content. Same content, same digest, forever.
diff ID
The digest of a layer's uncompressed tar, listed in the config. The registry digest is of the compressed blob.
config
The image's JSON: env, cmd, entrypoint, user, workdir, history, and the list of layer diff IDs.
manifest
"This config plus these layers", for one platform.
image index
Also called a manifest list: maps platforms (linux/amd64, linux/arm64) to manifests.
tag
A movable human name for a manifest or index: :1.4.0, :latest.
repository
A named collection of tags in a registry: team/app.
registry
A server that stores and serves blobs and manifests (Docker Hub, GHCR, ECR, Artifactory, Harbor).
pull-through cache
A registry that mirrors another on demand, to save bandwidth and avoid rate limits.
namespace (kernel)
A private view of one system resource: pid, net, mnt, uts, ipc, user, cgroup.
cgroup
A kernel mechanism that limits and measures CPU, memory, I/O and process count.
capabilities / seccomp
Fine-grained root powers / an allow-list of system calls. Both narrow what a container's root can do.
OverlayFS
The union filesystem that merges read-only lower layers with a writable upper layer.
copy-on-write
A file from a lower layer is copied up to the writable layer on its first modification.
whiteout
A marker in an upper layer that hides a file from a lower layer. Hides it; doesn't remove it.
volume
Docker-managed storage that outlives containers.
bind mount
A host folder mounted into a container. Same files, both sides.
Dockerfile
The recipe: a list of instructions that a frontend turns into a build graph.
build context
The set of files sent to the builder, usually the folder you pass to docker build.
.dockerignore
Patterns kept out of the build context.
base image
What your FROM names; the bottom of your stack.
stage
One FROM section of a Dockerfile.
multi-stage build
Several stages in one file; only the target stage becomes the image.
target
The stage you ask for with --target; the last one by default.
BuildKit
The modern build engine: graph solving, parallelism, cache mounts, secrets, cache export.
buildx
The Docker CLI plugin for BuildKit: builders, multi-platform builds, cache flags, bake.
LLB
Low-Level Build definition, BuildKit's internal graph format. The Dockerfile is one frontend for it.
builder / driver
Where BuildKit runs: inside dockerd, in its own container, in Kubernetes, or a remote server.
cache key
Parent key + instruction + (for COPY/ADD) file checksums. The thing a cache hit matches on.
cache hit / miss
Hit: reuse the stored layer (CACHED). Miss: run it, and everything after it.
cache export / import
--cache-to / --cache-from: ship the build cache between machines.
mode=min / mode=max
Export only the final image's layers / also every intermediate stage.
inline cache
Cache metadata stored inside the pushed image itself (min mode only).
cache mount
RUN --mount=type=cache: a folder kept between builds, never in the image.
secret mount
RUN --mount=type=secret: a file available to one step only, never stored.
ENTRYPOINT vs CMD
The program vs its default arguments. docker run img args replaces CMD.
exec form
The JSON array form ["node","app.js"]: runs directly as PID 1, receives signals.
PID 1 / init
The first process in a container; it must reap zombies and forward signals. --init adds tini to do it.
platform
OS + CPU architecture, like linux/arm64.
QEMU emulation
Running or building another architecture by emulating its CPU. Works, slowly.
dockerd / containerd / runc
The Docker daemon / the container lifecycle manager / the tool that makes the kernel calls.
devcontainer.json
The spec file describing a development container: image or build, features, mounts, hooks, editor settings.
feature
A reusable, versioned install script added to a dev container image.
lifecycle hooks
initialize (host) → onCreate → updateContent → postCreate → postStart → postAttach.
remoteUser
The user the editor and its terminals run as inside the dev container.
prebuild
Building and pushing the dev image ahead of time in CI, so opening a project is just a pull.
content-addressed tag
A tag computed by hashing the image's inputs; it changes only when an input changes.
promotion
Copying an already-tested digest to a release location, without rebuilding.
SBOM
Software Bill of Materials: the list of every package and version inside an image.
provenance / attestation
Signed metadata about how and from what an image was built.
signing
A cryptographic signature on a digest (cosign, Notation), so consumers can verify who built it.
vulnerability scan
Matching an image's packages against known CVEs (Trivy, Grype). Often a gate in CI.
retention policy
Rules that delete old tags and untagged manifests from a registry.
dangling image
An image with no tag, usually an old build whose tag moved. docker image prune removes it.
Docker-in-Docker
Running a Docker daemon inside a container, typically to build images in CI.
Compose
A YAML file describing several containers that run together (app, database, cache).
rootless
Running the daemon or BuildKit without root privileges on the host.

Check yourself

Why can a Linux container start in milliseconds when a VM takes seconds?

There is no kernel to boot. A container is a process on the already-running host kernel, put in namespaces and cgroups.

Which instructions create a filesystem layer?

RUN, COPY and ADD. Everything else only edits the image config.

You add RUN rm -rf /big after the step that created it. Does the image shrink?

No. The bytes remain in the earlier layer, hidden by a whiteout. Delete in the same RUN that created them.

You edit one line of source and npm reinstalls everything. What is wrong?

COPY . . comes before the install. Copy the package files, install, then copy the rest.

What three things make up a step's cache key?

The parent step's key, the instruction itself, and for COPY/ADD a checksum of the copied files.

What is the difference between mode=min and mode=max?

min exports only the final image's layers; max also exports intermediate stages, so builder stages hit the cache on another machine.

A teammate pulls app:latest and gets a different image than you did yesterday. Why, and how do you prevent it?

Tags move. Pin by digest (app@sha256:…) or use an immutable version tag.

Where does initializeCommand run, and why does that matter?

On the host, before the container exists. It's where you log in to a registry or compute which image tag to use. Everything else runs inside.

Why tag a CI image by a hash of its inputs instead of the git commit?

So it only rebuilds when an input changed. Most commits don't touch the image, so most runs find the tag and skip the build.

Your container is killed with exit code 137. What happened?

It got SIGKILL, most often because it went over its cgroup memory limit (OOMKilled). Check with docker inspect.

Read it once, then do it once.
Eight missions in the lab, and real Dockerfiles in examples/ that build on your machine.
Open the build lab →
Containers in 3D Last updated 16 October 2026