Docker
Docker is the toolchain that turned Linux containers from a kernel feature experts could wield into a workflow anyone could learn in an afternoon: one file format for describing an image (the Dockerfile), one layered, content-addressed format for storing it, one CLI for building, running, and shipping it, and a registry protocol for moving it between machines. Containers & orchestration covers why containers beat VMs at isolation speed and density at all — this page assumes that context and goes deep on the tool most people actually mean when they say "containers": how an image is really a stack of layers, why the build cache invalidates the way it does, the Dockerfile habits that separate a lean production image from a bloated one, and where Docker Engine itself sits relative to containerd, runc, and the rest of the runtime landscape it helped standardize.
Building an image is like stacking clear transparency sheets on an overhead projector. The first sheet has the base picture; the next sheet adds a few new lines on top of it; the sheet after that adds a few more. Stack them up and you see one complete picture, but each sheet is still its own separate piece of plastic underneath. If you only need to change the top sheet tomorrow, you don't redraw the first three — you reuse the exact same sheets and just swap the last one. That's a Docker image: not one giant flat picture, but a stack of small, reusable layers, and most of what makes Docker fast or slow to build comes down to how good it is at knowing which sheets it can reuse and which one it actually has to redraw.
What Docker is and the problem it solves
☺ Like you're 10: Linux could already fence off processes before Docker existed — Docker's real invention was making that fencing easy to describe, easy to build, and easy to hand to someone else.
The isolation primitives a container actually runs on — namespaces and cgroups — are Linux kernel features that predate Docker by years, and tools like LXC already exposed them before 2013. What LXC didn't have was a workflow: a standard way to describe an image as a sequence of build steps, a standard on-disk format for that image, a registry protocol for distributing it, and one CLI command to go from source code to a running container. Docker, released in March 2013 by a company then called dotCloud (soon renamed Docker, Inc.), supplied all four at once, and that combination — not the isolation itself — is what made containers a mainstream default rather than a specialist's tool.
"Docker" today names a family of components, not one program, and conflating them is the source of a lot of confusion later on this page: Docker Engine is the local build-and-run stack (covered in the architecture section below); the Docker CLI (docker) is the client you actually type commands into; Docker Compose defines and runs a multi-container application from one YAML file, the standard tool for the local inner loop; Buildx/BuildKit is the modern build engine (its own section below); Docker Desktop packages all of the above plus a Linux VM for macOS and Windows developers; and Docker Hub is Docker, Inc.'s public image registry, one of many registries an image can live in alongside Amazon ECR, Google Artifact Registry, or GitHub Container Registry.
Docker's other lasting contribution was giving that format away. In 2015 Docker co-founded the Open Container Initiative (OCI) under the Linux Foundation and donated the image and runtime specifications that became its foundation, which is why an image built with docker build today runs unmodified under Podman, containerd, or a Kubernetes node that has never had Docker Engine installed on it — the format is a public standard, not a Docker-only file.
Where Docker fits in the delivery pipeline
☺ Like you're 10: Docker's job happens right after code is tested and right before something else takes the finished box and puts it to work.
In the CI/CD pipeline, a build stage runs docker build (or docker buildx build) against a Dockerfile checked into the repository, tags the resulting image, and pushes it to a registry — the same artifact-storage role covered generally in build & artifact management. Whatever CI product is running that stage — Jenkins, GitHub Actions, GitLab CI/CD, or CircleCI — is invoking the same docker CLI a developer would type locally; nothing about Docker itself changes between a laptop and a CI runner except who's typing the command. Increasingly that stage also runs an image scanner (Docker Scout, Trivy, or Grype) before the push, catching known-vulnerable base-image packages ahead of the registry rather than after — see Shift-Left Security and Supply-Chain Security & SBOM for the fuller pattern, including generating a software bill of materials for the image at the same step.
Downstream, whatever pulls that pushed image and actually runs it is a separate concern from Docker entirely — a single docker run on one host, a Compose stack for local development, or, at real scale, an orchestrator that schedules many copies across many machines, which is exactly the territory containers & orchestration and Kubernetes cover. Docker builds and ships the box; it does not decide how many copies of the box run or where.
Architecture: Docker Engine, containerd, and runc
☺ Like you're 10: Typing one Docker command actually wakes up a small chain of programs, each handing the job to a more specialized one below it, until the last one just starts a process.
The docker CLI is a thin client. It talks to a background daemon, dockerd, over a REST API — by default a Unix socket at /var/run/docker.sock on Linux — and it's dockerd that owns networking, volumes, Compose orchestration, and driving image builds through BuildKit. But dockerd doesn't create containers itself either: it delegates that to containerd, a smaller, CRI-compliant daemon Docker spun out and donated to the CNCF in 2017 (graduated as an independent CNCF project in 2019), which handles image pulling and storage and the container lifecycle over its own gRPC API. For each running container, containerd launches a small, dedicated containerd-shim-runc-v2 process, whose entire job is to keep that one container's process alive and reportable even if containerd or dockerd restarts — and the shim in turn calls runc, the low-level OCI runtime that does the actual work of creating namespaces and cgroups and executing the container's process inside them, per the OCI runtime-spec Docker helped write.
That last branch in the diagram matters more than it looks: Kubernetes never actually needed Docker Engine, only a compatible way to run OCI images. From 2016 (Kubernetes 1.5) the Container Runtime Interface (CRI) let kubelet talk to any compliant runtime, and Kubernetes shipped a translation shim, "dockershim," so it could keep talking to full Docker Engine installs through that interface. That shim was deprecated starting in Kubernetes 1.20 and removed outright in Kubernetes 1.24 (2022) — headlines at the time read as "Kubernetes drops Docker support," which overstated it: what was dropped was Docker Engine as the node-level runtime, not the image format. A container built with docker build is an OCI image either way, and it runs identically under containerd or CRI-O, which is exactly what most Kubernetes nodes use directly today.
Images and layers: how the build cache actually invalidates
☺ Like you're 10: Docker decides whether to reuse an old sheet or redraw a new one by checking, in order, every sheet before it — one different sheet and every sheet after it has to be redrawn too.
Every instruction that changes the filesystem — RUN, COPY, ADD — produces a new, immutable, content-addressed layer: a tar diff identified by a SHA-256 digest. Instructions like ENV, LABEL, and CMD only change image metadata and don't add a filesystem layer at all. At run time, a union filesystem — overlay2 on Linux — stacks every read-only image layer plus one thin, writable "container layer" on top, and presents the result as one merged filesystem. That writable layer is where anything the running container writes actually lands; it's discarded (unless committed) the moment the container is removed, which is why containers are meant to be disposable and any state worth keeping belongs in a volume.
Docker's classic build cache decides whether to reuse or rebuild each layer by checking, per instruction, whether the parent layer's digest and the instruction text (with any build args substituted) match a cached result exactly — and for COPY/ADD specifically, whether the checksum of the files being copied also matches. The moment one instruction misses the cache, every instruction after it misses too, because each layer's cache key includes its parent's digest as an input. That single fact is the entire reason instruction order in a Dockerfile matters as much as it does: copying a dependency manifest and installing dependencies before copying the rest of the source means an application-code change only invalidates the last couple of layers, not the whole build.
BuildKit — the build engine Docker Engine has used by default since version 23.0 (2023), and what docker buildx build always uses — improves on that model rather than replacing it: it executes independent stages of a multi-stage build in parallel, skips stages nothing in the final image depends on, and supports importing and exporting the build cache to a remote registry (--cache-from / --cache-to), which is what makes fast incremental builds possible on stateless, ephemeral CI runners that start with no local cache at all. BuildKit also introduces cache mounts (--mount=type=cache) — a directory, like a package manager's download cache, that persists across builds in BuildKit's own cache store without ever being committed into an image layer.
Multi-architecture images extend the same layer model one level up: a docker buildx build --platform linux/amd64,linux/arm64 produces one image per architecture, then publishes them under a single tag as an OCI image index (a "manifest list") — the client pulling myimage:1.0 automatically gets the manifest that matches its own CPU architecture, with no separate tag to remember per platform.
Dockerfile best practices: multi-stage builds and fewer layers
☺ Like you're 10: Build with the messy toolbox in one room, then carry only the finished part into a clean room for the final photo — and clean up a mess in the same step you made it, not later.
A multi-stage build uses more than one FROM in a single Dockerfile, each starting a new, independent stage, so a heavy build toolchain — compilers, package managers, source code — never has to ship in the image that actually runs in production. Only files explicitly copied forward with COPY --from= survive into the next stage.
# Dockerfile — multi-stage build FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download # cached separately from source — see the caching section above COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /out/checkout ./cmd/checkout FROM gcr.io/distroless/static-debian12:nonroot AS final COPY --from=builder /out/checkout /checkout USER nonroot:nonroot # never run as root by default ENTRYPOINT ["/checkout"]
The final stage here has no shell, no package manager, and no compiler in it at all — just the statically linked binary and a non-root user, which shrinks both the image (megabytes instead of the builder stage's several hundred) and the attack surface a compromised process could exploit. This is the same principle covered generally in Immutable Infrastructure & Golden Images: ship exactly what's needed to run, nothing that was only needed to build.
Minimizing layer count matters for a subtler reason than tidiness: layers are purely additive. A file removed in a later layer isn't actually gone from the image — the image still contains the earlier layer with the file in it, plus a "whiteout" marker in the later layer that hides it from the merged view at run time. Both layers still get pulled, pushed, and stored. The fix is to install, use, and clean up within the same RUN instruction:
# Bad — three layers; the apt cache from layer 1 is still in the image after layer 3 "deletes" it
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Good — one layer; the cache never becomes a permanent part of any layer at all
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*The additive-layers rule is a security issue as much as a size one. A secret set with ENV SECRET=... or written to a file and then removed in a later layer is still recoverable from the image — docker history shows every instruction's metadata, and unpacking the layer tarballs with docker save exposes the raw file contents, even though a running container built from that image will never show the file. Never bake a credential into any layer, even temporarily. Use BuildKit's --mount=type=secret instead, which mounts the secret into the build only for the instruction that needs it and never writes it to a layer at all: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci.
A handful of smaller habits round out a Dockerfile that survives review: keep a .dockerignore next to it so the build context — everything sent to the daemon before the first instruction even runs — doesn't include .git, node_modules, or a stray .env file; pin base images to a specific tag, or better, a digest (node:20.11.1-slim@sha256:...), since an unpinned :latest can silently change what a "reproducible" build actually produces from one day to the next; and prefer COPY over ADD unless you specifically need ADD's automatic tar-extraction or its ability to fetch a remote URL, both of which are easy to trigger by accident.
# .dockerignore — keeps the build context small and keeps secrets out of it entirely .git node_modules dist/ *.md .env Dockerfile .dockerignore
Day-to-day commands
☺ Like you're 10: A handful of verbs cover almost everything: build it, look at it, run it, watch it, ship it, clean it up.
# build and inspect
$ docker build -t registry.internal/checkout:1.4.2 .
$ docker buildx build --platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=registry.internal/checkout:buildcache \
--cache-to type=registry,ref=registry.internal/checkout:buildcache,mode=max \
-t registry.internal/checkout:1.4.2 --push .
$ docker history registry.internal/checkout:1.4.2 # every layer, in build order, with its size
$ docker inspect registry.internal/checkout:1.4.2 # full image config as JSON
# run and interact
$ docker run -d --name checkout -p 3000:3000 --restart unless-stopped registry.internal/checkout:1.4.2
$ docker exec -it checkout sh # a shell inside a running container
$ docker logs -f checkout # follow its stdout/stderr
$ docker ps # what's running right now
$ docker ps -a # including stopped containers
# ship
$ docker tag registry.internal/checkout:1.4.2 registry.internal/checkout:latest
$ docker push registry.internal/checkout:1.4.2
$ docker pull registry.internal/checkout:1.4.2
# clean up
$ docker system df # how much disk images/containers/cache are using
$ docker image prune # remove dangling (untagged) images only
$ docker system prune -a --volumes # remove everything unused — read the warning first
# local multi-service dev — see The Inner Loop & Developer Experience
$ docker compose up -d
$ docker compose logs -f apiGotchas and failure modes
☺ Like you're 10: A few habits that look harmless on day one — mounting the wrong socket, never cleaning up, trusting a tag instead of a specific version — turn into real trouble by month three.
Mounting /var/run/docker.sock into a container — a common shortcut for "let this container manage other containers" — hands that container root-equivalent control of the host, not just of Docker. Anything that can talk to the Docker API can start a new container with the host's root filesystem bind-mounted in, which is a straightforward container-to-host escape, not a theoretical one. If a workload genuinely needs to build or launch containers from inside a container, treat that requirement as seriously as handing out a host root shell, and look at rootless alternatives (Podman, sysbox, or a dedicated build service like Kaniko) before reaching for the socket mount.
Disk fills up quietly. Every build leaves behind intermediate layers, and every stopped-but-not-removed container, dangling image, and unreferenced build-cache entry sits on disk until something prunes it. docker system df shows where the space actually went; docker system prune and BuildKit's own cache garbage collection (configurable via buildkitd.toml) are the fix — CI runners with no scheduled prune step are the most common place this bites, because they build constantly and nobody's watching disk usage until a build fails with "no space left on device."
An unpinned base tag makes "reproducible" builds lie. FROM node:20 can resolve to a different actual image next month as upstream ships patch releases under that same tag, so a rebuild from identical source code can produce a genuinely different image — different base-layer CVEs, different behavior — without a single line of your own Dockerfile changing. Pin to a digest for anything that needs to be reproducible on purpose, and re-pin deliberately, on a schedule, rather than floating forever.
Build any small image, then run docker history <image> and read every line — notice how much of the image's total size sits in one particular RUN. Then add a throwaway line like RUN echo "not-a-real-secret" > /tmp/leftover.txt followed by RUN rm /tmp/leftover.txt as two separate instructions, rebuild, and run docker save <image> -o out.tar && tar -tvf out.tar. You'll find the file's layer is still in the tarball. Now merge both lines into one RUN, rebuild, and repeat — the difference is the additive-layers lesson made physical instead of theoretical.
The container runtime landscape: Docker Engine vs. the alternatives
☺ Like you're 10: Other tools can build or run the exact same kind of box Docker builds — they just trade away different pieces of Docker's convenience for something else they care about more.
Because the image format is an open OCI standard, "which tool built this image" stopped being a lock-in question years ago. The real question is which tool you want doing the building and running day to day, and that comes down to a genuine trade between convenience and what each option gives up.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Docker Engine | Root-owned background daemon (dockerd) fronting containerd + runc, one CLI for build, run, network, volumes, and Compose | Local development and most CI runners — a single, well-documented tool that does build and run | A root daemon is real attack surface (see the socket-mount warning above); heavier footprint than a bare runtime |
| containerd | Lean, embeddable, CRI-compliant runtime with no built-in image-building step | Kubernetes nodes and anywhere you only need to run OCI images, not build them | No docker build equivalent of its own — needs BuildKit or another builder in front of it; its native CLIs (ctr, or the friendlier nerdctl) are less polished for humans than docker |
| CRI-O | Purpose-built, minimal CRI runtime, built for Kubernetes and nothing else | Security- or compliance-conscious clusters wanting the smallest possible node runtime surface | Kubernetes-only design — no generic local dev workflow |
| Podman | Daemonless, rootless-by-default, largely Docker-CLI-compatible | Shared hosts or security-hardened workstations where a root-owned daemon is a non-starter | Tooling built around docker.sock assumptions doesn't always translate directly; Compose parity has historically trailed Docker's own, so verify current support before depending on it |
The practical rule most teams land on: Docker Engine for local development and CI, because it's the one tool that both builds and runs with the least ceremony; containerd or CRI-O directly on Kubernetes nodes, because that's what kubelet already talks to and Docker Engine there would just be an unused extra hop; and Podman specifically when a root daemon is the objection someone can't get past any other way. None of that changes what a Dockerfile looks like or how the build cache behaves — the concepts on this page travel with the image regardless of which of these actually runs it.
Put the build-stage habits from this page into practice in Capstone Part 1 — Pipeline Foundation, then go find and fix a scanning gap in Drill — Secure a Vulnerable Pipeline. If you're weighing a vendor credential on top of this material, Mirantis's Docker Certified Associate exam covers this exact ground — but Mirantis discontinued new DCA registrations some time ago, so confirm current availability on Mirantis's own site before planning around it rather than trusting a fixed date printed here.
Foxy: I changed one line of application code and the build still took four minutes. Isn't caching supposed to fix that?
Benny the Beaver: Show me your Dockerfile order. ...There it is — you're copying the whole repo before installing dependencies. Every code change reruns the install step, because the cache key for that layer includes a checksum of everything you just copied in.
Foxy: So copy the manifest first, install, then copy the rest?
Benny the Beaver: Exactly. Now a source change only invalidates the last two layers, not the whole build.
Gizmo: Or just mount docker.sock into the build container so it can spin up whatever it needs. One line, saves you an afternoon. 🤑
Timmy the Turtle: That "one line" hands the container root on the host, Gizmo. Anything that can reach that socket can mount the host's filesystem into a new container it starts itself. Not shipping that.
Olly the Octopus: And once Benny's image is actually built and pushed, it's mine — I don't care which tool made it, only that it's a valid OCI image I can schedule.
1. What did Docker actually invent in 2013, given that Linux namespaces, cgroups, and even LXC already existed? 2. Trace one docker run call through the chain of processes that ultimately starts it, naming all four components. 3. Why does changing one early instruction in a Dockerfile invalidate every layer after it, not just that one layer? 4. A file is written in one RUN instruction and deleted in a later one. Is it actually gone from the image? Why does that matter for secrets specifically? 5. What specifically changed about Docker's relationship to Kubernetes in v1.24, and what stayed exactly the same? 6. Name one concrete scenario where Podman's daemonless, rootless model is worth its Docker-compatibility gaps.
Check your answers
- Not the isolation primitives themselves — those are the kernel's. Docker invented the standardized workflow around them: a build-step file format (the Dockerfile), a layered on-disk image format, one CLI to build/run/ship, and a registry protocol for distribution, all bundled into one product.
dockerCLI sends a REST API call over a Unix socket to dockerd, which hands container lifecycle work to containerd over gRPC, which starts a containerd-shim-runc-v2 process for that container, which calls runc to create the namespaces and cgroups and exec the actual process.- Because each layer's build-cache key includes its parent layer's digest as an input. Once one instruction's output changes, every layer built on top of it has a different parent digest, so the cache key no longer matches for any of them, cascading the rebuild forward through the rest of the file.
- No — layers are purely additive. The earlier layer still contains the file; the later layer only adds a "whiteout" marker hiding it from the merged view at run time, but both layers are still stored, pulled, and pushed. For secrets this means anything ever written to any layer is recoverable via
docker historyor by unpacking the image's layer tarballs withdocker save, even if a later instruction deletes it — so secrets belong in a BuildKit--mount=type=secret, never in a layer at all, even temporarily. - Kubernetes removed "dockershim," the CRI-compliant translation layer that let
kubelettalk to full Docker Engine — so Docker Engine is no longer present on a modern Kubernetes node. What stayed the same is the image format: an image built withdocker buildis a standard OCI image and runs identically under containerd or CRI-O, which is what nodes now talk to directly. - Any scenario where a root-owned background daemon is the actual blocker — for example a shared, security-hardened build host or workstation where running anything as root is against policy, and Podman's daemonless, rootless-by-default model satisfies that constraint that Docker Engine structurally can't.