18. Docker
Docker
TL;DR
- Docker packages an application and its dependencies into an isolated, portable unit (a container) built from a layered, immutable image.
- Multi-stage builds are the single biggest lever on image size and attack surface — build tooling never has to ship in the production image.
- Containers get their own network and filesystem namespaces by default:
localhostinside a container is the container, not the host, and anything written to the container’s writable layer disappears when the container is removed. - Docker Compose orchestrates multi-container setups declaratively but
depends_ononly sequences container start order, not application-level readiness — that gap causes a large share of “flaky on startup” bugs. - Running containers as a non-root user is defense in depth on top of, not a substitute for, container isolation.
Core Concepts
Images and layers
A Docker image is a stack of read-only layers, each corresponding to a Dockerfile instruction that changes the filesystem (RUN, COPY, ADD). Layers are content-addressed and cached — an unchanged layer is reused across builds, and unchanged layers are also shared across different images that derive from the same base. A running container adds one thin writable layer on top of the image’s read-only layers (copy-on-write); the image itself never changes at runtime.
Multi-stage builds
The most common, highest-impact image-size mistake is shipping build-time tooling — a full compiler toolchain, source code, dev dependencies — in the production image because everything ran in a single build stage. A multi-stage build separates build and runtime concerns into distinct stages in one Dockerfile:
- Stage 1 (
builder) uses a full toolchain image (e.g.golang:1.22) to compile the artifact. - Stage 2 (final) starts from a minimal or distroless base (no shell, no package manager) and copies only the compiled output via
COPY --from=builder.
The compiler, source code, and intermediate build artifacts never exist in the shipped image. This routinely takes a compiled-language service from 900MB down to under 50MB, and it reduces attack surface: less software present in the final image means fewer components a container-escape or dependency vulnerability can exploit.
Networking
Containers get their own isolated network namespace by default — separate network interfaces, routing table, and port space from the host and from other containers. Two containers on the same custom bridge network resolve each other by container or service name via Docker’s embedded DNS. The most common point of confusion moving from local development into containers: localhost inside a container refers to the container itself, not the host machine. Reaching a service running directly on the host requires the host’s actual address, or Docker’s host-gateway special DNS entry — never localhost.
Volumes and persistence
Anything written inside a container’s own writable filesystem layer is lost the moment that specific container instance is removed — the writable layer is tied to the container’s lifecycle, not the application’s data lifecycle. A volume (Docker-managed, stored under Docker’s data directory) or a bind mount (an arbitrary host path) stores data outside that ephemeral layer, in a location that persists independently of any single container instance. This is required for anything stateful — a database, uploaded files, application logs that must survive a redeploy.
Compose orchestration and the readiness gap
Docker Compose declares multi-container topologies (services, networks, volumes) in one YAML file for local development and simple multi-container deployments. The gap that catches nearly every team the first time a database dependency is added: depends_on controls container start order, not actual readiness. The database container starting does not mean the database process inside it has finished initializing — an application container that starts immediately after can still fail to connect. A proper healthcheck combined with depends_on: condition: service_healthy (or an application-level retry/backoff loop) waits for actual readiness instead of assuming start order implies it.
Non-root by default
Container isolation reduces, but does not eliminate, the impact of a container-escape vulnerability or a misconfigured shared resource such as a mounted volume. A process running as root inside a compromised container carries root-level capabilities if the isolation boundary is ever breached. Running as a dedicated non-root user (USER appuser in the Dockerfile) is a cheap, additional defense-in-depth layer — not redundant with container isolation, on top of it.
Docker vs Docker Compose vs Kubernetes
| Aspect | Docker CLI | Docker Compose | Kubernetes |
|---|---|---|---|
| Scope | Single container lifecycle | Multi-container app on one host | Multi-container, multi-host cluster |
| Config format | Imperative CLI flags | Declarative YAML | Declarative YAML (manifests) |
| Networking | Manual --network wiring |
Automatic per-project bridge network + DNS | Cluster-wide service discovery, DNS, overlay networking |
| Scaling | Manual, one container at a time | docker compose up --scale (basic) |
Native horizontal scaling, autoscaling |
| Self-healing | None | None | Built-in (restarts, rescheduling, health-based routing) |
| Typical use | Ad hoc container runs, debugging | Local dev, small single-host deployments | Production, multi-service, multi-node workloads |
Command / Configuration Reference
# Build and run
docker build -t myapp:1.0 . # build image from Dockerfile in current dir
docker run -d --name myapp -p 8080:80 myapp:1.0 # run detached, map host:container port
docker exec -it myapp sh # shell into a running container
docker logs -f myapp # stream container logs
# Images and layers
docker image ls # list local images
docker history myapp:1.0 # inspect layer-by-layer size contribution
docker system prune -a # remove unused images, containers, networks
# Networking
docker network ls # list networks
docker network create mynet # custom bridge network with DNS resolution
docker run --network mynet --name db postgres:16 # containers on mynet resolve "db" by name
# Volumes
docker volume create pgdata # named, Docker-managed volume
docker run -v pgdata:/var/lib/postgresql/data postgres:16 # persist DB data outside writable layer
docker run -v $(pwd)/config:/etc/app/config:ro myapp # bind mount, read-only
# Multi-stage build — Go example
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o app .
FROM gcr.io/distroless/base-debian12
COPY --from=builder /src/app /app
USER 1000 # non-root
ENTRYPOINT ["/app"]
# docker-compose.yml — healthcheck-gated dependency
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
app:
build: .
depends_on:
db:
condition: service_healthy
Common Pitfalls
- Pitfall: Production image ships a full compiler toolchain and source code. Why: The Dockerfile builds and runs the application in a single stage. Fix: Use a multi-stage build; copy only the compiled artifact into a minimal or distroless final stage.
- Pitfall: Application fails to connect to its database on startup despite
depends_onbeing configured. Why:depends_ononly guarantees the dependency container has started, not that the process inside it is ready to accept connections. Fix: Add a healthcheck and usedepends_on: condition: service_healthy, or implement an application-level retry loop. - Pitfall: Data disappears every time a container is recreated. Why: Data was written to the container’s own writable layer, which is deleted with the container. Fix: Store stateful data in a named volume or bind mount.
- Pitfall: Two containers on the same host cannot reach a service via
localhost. Why: Each container has its own network namespace;localhostresolves to the container itself. Fix: Use the container/service name for container-to-container traffic, orhost-gateway/ the host’s real address for container-to-host traffic. - Pitfall: Container process runs as root with no justification. Why: Root is the Dockerfile default unless overridden. Fix: Add
USER <non-root-uid>to the Dockerfile as standard practice, reserving root only for cases with a documented need.
Interview Questions
- A Go service’s production image is 900MB. Walk through the fix. — Tests whether multi-stage builds are the immediate, correct diagnosis.
- An application container fails to connect to its database on startup despite
depends_onbeing configured. What’s actually happening? — Tests understanding of the start-order-versus-readiness gap. - Why should a container process run as non-root even though the container already provides isolation? — Tests defense-in-depth reasoning versus treating it as redundant caution.
- Explain what happens to data written inside a container that has no volumes attached, after that container is removed. — Tests understanding of the writable layer’s lifecycle.
- Why can’t a container reach a host-run service via
localhost? — Tests understanding of network namespace isolation.