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.
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
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.
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).
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.
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).
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.
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.
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.
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:
- 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.
- Ask the registry. If the tag exists, someone already built exactly this, so skip the build. Most runs end here in seconds.
- Otherwise build with BuildKit, importing the shared registry cache so only changed layers do real work.
- 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).
- 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.
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.
The lab lets you break a build cache, fix it, shrink an image, and push, with missions that check your work.