LearnDevOps
DevOps06. Containers·EXPERT·7 min read

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: localhost inside 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_on only 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_on being configured. Why: depends_on only guarantees the dependency container has started, not that the process inside it is ready to accept connections. Fix: Add a healthcheck and use depends_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; localhost resolves to the container itself. Fix: Use the container/service name for container-to-container traffic, or host-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_on being 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.
🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
Knowledge check
5 scenario questions on this topic
Take the quiz →
Related in 06. Containers
17. Container Fundamentals
EXPERT
19. Image Optimization
EXPERT
20. Container Runtimes
EXPERT