Docker¶
Prerequisites: none strictly required — helpful to have seen a Dockerfile before.
← Cloud | Next: Infrastructure as Code →
Why This Exists¶
"It works on my machine" is a statement about environments, not code. Docker's entire value proposition is collapsing "works on my machine" into "works," by packaging the process with everything it assumes exists — a specific libc, a specific set of env vars, a specific file at /etc/config.yml — into one artifact that runs identically on a laptop, a CI runner, and a production node.
The interview-relevant failure mode is treating Docker as "a lightweight VM." It is not. An image is a blueprint; a container is a running instance of that blueprint — the same relationship a class has to an object. Get that model right and layer caching, multi-stage builds, and "why is my container 1.2GB" all follow logically instead of needing to be memorized.
Mental model
A container is not a VM. A VM virtualizes hardware and boots a full kernel; a container is a normal Linux process with three kernel features drawn around it — namespaces (its own view of PIDs, network, mounts, hostname), cgroups (CPU/memory limits), and a union filesystem (layered image). That's why a container starts in milliseconds, not seconds, and why "the container's kernel" isn't a real thing — it shares the host kernel.
Containers vs. Virtual Machines¶
flowchart TB
subgraph VM["Virtual Machines"]
direction TB
HW1[Hardware] --> HYP[Hypervisor]
HYP --> OS1[Guest OS] & OS2[Guest OS]
OS1 --> A1[App A]
OS2 --> A2[App B]
end
subgraph DK["Containers"]
direction TB
HW2[Hardware] --> HOST[Host OS + kernel]
HOST --> ENG[Container engine]
ENG --> C1[Container A]
ENG --> C2[Container B]
end | Virtual Machine | Container | |
|---|---|---|
| Isolation unit | Full OS + kernel | Process + namespaces |
| Boot time | Seconds–minutes | Milliseconds |
| Overhead | GBs per guest OS | MBs per image layer diff |
| Kernel | Own kernel | Shares the host kernel |
| Isolation strength | Hardware-enforced (stronger) | Kernel-enforced (weaker — a kernel exploit crosses containers) |
That last row is the honest trade-off: containers are cheaper and faster because they give up the hardware isolation boundary. This is why multi-tenant, security-sensitive workloads sometimes still reach for VMs (or gVisor/Firecracker-style microVMs) around the container.
Image Layers and the Build Cache¶
An image is a stack of read-only layers, each one a diff from the layer below, plus a thin writable layer added at container-start (copy-on-write). Docker caches each instruction's layer by hashing the instruction and its inputs — a cache hit skips re-execution entirely.
flowchart TB
L1["FROM python:3.12-slim"] --> L2["COPY requirements.txt ."]
L2 --> L3["RUN pip install -r requirements.txt"]
L3 --> L4["COPY . ."]
L4 --> L5["CMD [...]"]
L5 --> RW["Writable layer (per-container)"]
style RW fill:#e65100,color:#fff The instruction that determines your CI build time is instruction order, not instruction count. COPY . . before pip install means any source change — even a comment — invalidates every layer below it, forcing a full dependency reinstall on every build. Put things that change rarely (dependency manifests) before things that change often (source code):
# Bad: any code change busts the pip-install cache
COPY . .
RUN pip install -r requirements.txt
# Good: cache survives until requirements.txt itself changes
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Production trap
apt-get update && apt-get install -y curl cached from three months ago silently reinstalls a three-month-old curl — the cache doesn't know a CVE shipped. Pin versions, and periodically force a --no-cache rebuild rather than trusting a stale cached layer forever.
Multi-Stage Builds¶
The image you build with (compiler, dev headers, test framework) is rarely the image you should ship. Multi-stage builds let one Dockerfile use a fat builder stage and copy only the compiled artifact into a minimal runtime stage:
# Stage 1: builder — has the full Go toolchain
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# Stage 2: runtime — no compiler, no source, no shell if you want it minimal
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
This single change typically drops image size from ~900MB (full Go toolchain) to under 20MB (static binary + nothing else), and it shrinks the attack surface: no shell, no package manager, no source code in the shipped artifact.
Networking¶
| Mode | What it is | When |
|---|---|---|
| bridge (default) | Private virtual network on the host; containers get an internal IP | Single-host local dev |
| host | Container shares the host's network namespace directly | Max performance, no port mapping, weaker isolation |
| overlay | Virtual network spanning multiple hosts (Swarm/K8s CNI) | Multi-node clusters |
| none | No networking | Batch jobs that need isolation, not connectivity |
The concept that surprises people moving from localhost-based dev: on a user-defined bridge network, Docker runs an embedded DNS server, so containers reach each other by service name, not by IP. docker-compose.yml with services api and db — api connects to db:5432, not to an IP it has to discover. This is the same mental model Kubernetes Services extend later (see Kubernetes) — name-based service discovery, not IP-based.
Storage: Containers Are Not Where Data Lives¶
The writable layer survives docker stop / docker start — the same container instance keeps its filesystem across a stop-start cycle. It does not survive docker rm, and it does not survive the far more common case of replacing a container: a new deploy that runs docker run (or a fresh Compose/Kubernetes rollout) creates a brand-new container with a brand-new, empty writable layer, even though nothing was ever explicitly "deleted." Anything that must outlive a container being removed or replaced needs to live outside it:
| Mechanism | Backed by | Use case |
|---|---|---|
| Volume | Docker-managed area on host (or a plugin: EFS, NFS) | Databases, anything that should survive docker rm or a redeploy — the recommended default for anything Docker itself should manage the lifecycle of |
| Bind mount | An arbitrary host path | Local dev — live-reload source into the container. Also survives docker rm and a redeploy, since the data lives at a host path that exists independently of any container — the difference from a volume is who manages the path (you, vs. Docker), not whether the data persists |
| tmpfs | Host RAM, never written to disk | Secrets you don't want touching disk at all |
Misconception that causes real outages
A container getting OOM-killed and restarted by itself does not lose data on the writable layer — the container is still the same container, just stopped and started again. The data loss shows up one step later, at the next deploy: the OOM incident prompts a redeploy to "fix" it, that redeploy replaces the container, and only then does anyone discover the database's data was never on a volume or a bind mount — it was sitting on the writable layer the whole time. Conversely, docker system prune --volumes specifically targets unused anonymous volumes — the unnamed volumes Docker auto-creates for a container's VOLUME declarations when you don't explicitly name/mount one — not volumes generally, and not every named volume just because it isn't currently attached. It does not touch a volume mounted by an existing container, even a stopped one. The real danger case is still real, just narrower than "any unused volume": an anonymous volume that looks disposable because its container was already removed (a redeploy that created a fresh container without reattaching the old anonymous volume) — prune will happily delete that orphaned-but-still-wanted data. Named volumes you explicitly created for a database are not swept up by this command the same way — but don't rely on that distinction from memory during an incident; read the flags, and know which containers currently reference which volumes (named or anonymous) before running prune on a node with production data.
Security Baseline¶
- Run as non-root (
USERin the Dockerfile) — by default (no user namespace remapping), a container escape as root is a host-root escape, because container root and host root are the same UID 0. This is specifically what user namespace remapping (userns-remap, or Docker/Podman's rootless mode) closes: it maps container UID 0 to an unprivileged UID on the host, so a container-root escape lands as an unprivileged host user instead of host root. That mapping is opt-in and not Docker's default configuration — don't assume it's in place; verify it explicitly, and treat "run as non-root inside the container" as the baseline mitigation you control regardless of whether namespace remapping is also configured. - Minimal base images (distroless / alpine / scratch) — fewer packages, fewer CVEs, smaller attack surface.
- Scan before you ship, not after (Trivy, Grype, or your registry's built-in scanner) — a CI gate, not a dashboard nobody reads.
- Never bake secrets into layers.
ENV API_KEY=...or aCOPY .envis in the image history forever, even if a later layer deletes the file —docker historyanddocker save | tar -xOboth recover it. Inject secrets at runtime (mounted file, env var from a secret manager) instead. - Pin tags to digests in production, not
:latest.myapp:latestis not a version — it is a pointer that moves, and "which commit is actually running" becomes unanswerable during an incident.
Trade-offs¶
| Choice | Win | Cost |
|---|---|---|
| Alpine base | Small image | musl libc breaks some glibc-compiled binaries; harder debugging (no shell tools) |
| Distroless | Smallest attack surface | No shell — docker exec ... sh for debugging doesn't work |
| Multi-stage build | Small, secure runtime image | Slightly more complex Dockerfile, two build contexts to reason about |
| Host networking | No NAT overhead | Container can bind host ports directly; loses network isolation |
:latest tag | Convenient locally | Unreproducible in production; "what's actually deployed" becomes a guess |
Interview Questions¶
Q: What's the difference between an image and a container?
"An image is an immutable, layered filesystem plus metadata — a blueprint. A container is a running process using that image as its root filesystem, with a thin writable layer on top. One image can back many running containers, the same way one class backs many objects. Stopping a container doesn't delete the image; deleting the image requires no running containers reference it."
Q: Your CI build takes 6 minutes and most of it is pip install. How do you fix it?
"First check instruction order — if COPY . . happens before the install step, every commit invalidates the dependency layer regardless of whether dependencies changed. Split it: copy only the manifest, install, then copy source. Second, use a registry-backed build cache (--cache-from) so cold CI runners don't start from zero. Third, consider a multi-stage build so the install-heavy stage isn't even in the shipped image, only its output."
Q: A security review flags that your production images are built FROM base images with 40+ known CVEs. How do you fix this org-wide, not per-team?
"Per-team fixes rot — someone bumps a base image today and it's stale again in a month. I'd publish a small set of golden base images (distroless or minimal, one per language runtime), owned by a platform team, rebuilt on a schedule and on upstream CVE alerts, with an admission policy or CI gate that blocks non-golden bases from reaching prod. Then it's one team's job to keep N base images current instead of every team's job to remember. I'd also add SBOM generation at build time so 'are we affected by CVE-X' is a query, not a fire drill."
Key Takeaways¶
Remember
- Image = blueprint, container = running instance; containers share the host kernel, they don't virtualize hardware
- Layer order determines cache efficiency — put what changes least at the top of the
Dockerfile - Multi-stage builds ship the artifact, not the toolchain that built it
- Containers reach each other by service name via embedded DNS, not by IP
- Data outlives a container only if it's on a volume or a bind mount, not the writable layer — the writable layer dies with the container, both of the others live independently of it
- Never bake secrets into a layer —
docker historyremembers everything - Pin to a digest in production;
:latestis not a version
Previous: Cloud | Next: Infrastructure as Code