Docker Interview Prep

A comprehensive collection of Docker interview questions and answers, ranging from fundamentals to production-grade best practices.

Topics covered:

  • Core concepts (containers vs VMs, architecture)
  • Dockerfile and image building
  • Container lifecycle and management
  • Networking (bridge, host, overlay)
  • Storage (volumes, bind mounts)
  • Docker Compose
  • Security best practices
  • Orchestration and production patterns

Q: What is the difference between Containers and Virtual Machines?

Answer:

This is the most common Docker interview opener. Both are technologies for isolating applications, but they work at fundamentally different levels.

Virtual Machines (VMs)

A VM runs a complete guest Operating System on top of a hypervisor (e.g., VMware, VirtualBox, KVM). Each VM includes its own kernel, system libraries, and binaries.

Containers

A container shares the host machine's OS kernel and isolates only the application's user-space processes using Linux kernel features like namespaces (process isolation) and cgroups (resource limits).

Key Differences

FeatureContainerVirtual Machine
Isolation levelProcess-level (shares host kernel)Hardware-level (full guest OS)
Startup timeMillisecondsMinutes
SizeMegabytes (just the app + deps)Gigabytes (full OS image)
PerformanceNear-native (no hypervisor overhead)Slower (hardware emulation layer)
DensityRun hundreds on a single hostRun tens on a single host
OS supportLinux containers on Linux host only*Any OS on any host
SecurityWeaker isolation (shared kernel)Stronger isolation (separate kernels)

[!NOTE] *Docker Desktop on macOS/Windows actually runs a lightweight Linux VM under the hood (using HyperKit or WSL2) to provide the Linux kernel that containers need.

When to Use Which?

  • Containers: Microservices, CI/CD pipelines, dev environments, anything where speed and density matter.
  • VMs: When you need full OS-level isolation (e.g., running Windows apps alongside Linux), or when security boundaries are critical (multi-tenant hosting).

Q: Explain the Docker Architecture.

Answer:

Docker uses a client-server architecture with three main components:

1. Docker Client (docker CLI)

The command-line interface that you interact with. When you run a command like docker run, the client sends it as an API request to the Docker daemon. The client can communicate with the daemon locally (via a Unix socket) or remotely (via TCP).

2. Docker Daemon (dockerd)

The background service (server) that does all the heavy lifting. It manages:

  • Building images
  • Running containers
  • Pulling/pushing images from registries
  • Managing networks and volumes

The daemon exposes a REST API that the client talks to.

3. Docker Registry (e.g., Docker Hub)

A storage and distribution system for Docker images. When you docker pull nginx, the daemon fetches the image from Docker Hub (the default public registry). Companies also run private registries (e.g., AWS ECR, GCR, Harbor).

How They Work Together

┌──────────────┐       REST API       ┌──────────────────┐
│ Docker Client │ ──────────────────▶  │  Docker Daemon   │
│  (docker CLI) │                      │   (dockerd)      │
└──────────────┘                      │                  │
                                       │  ┌────────────┐ │
                                       │  │ Containers  │ │
                                       │  ├────────────┤ │
                                       │  │   Images    │ │
                                       │  ├────────────┤ │
                                       │  │  Volumes    │ │
                                       │  ├────────────┤ │
                                       │  │  Networks   │ │
                                       │  └────────────┘ │
                                       └────────┬─────────┘
                                                │
                                       ┌────────▼─────────┐
                                       │  Docker Registry  │
                                       │  (Docker Hub,     │
                                       │   ECR, GCR, etc.) │
                                       └──────────────────┘

Under the Hood: containerd & runc

The Docker daemon doesn't actually run containers directly. It delegates to:

  1. containerd: A high-level container runtime that manages the full container lifecycle (image transfer, storage, execution).
  2. runc: A low-level OCI-compliant runtime that actually creates and runs containers using Linux kernel features (namespaces, cgroups).

[!TIP] When discussing Docker architecture in interviews, mentioning containerd and runc shows a deeper understanding. Kubernetes, for example, talks directly to containerd (not Docker) since Docker was deprecated as a Kubernetes runtime in v1.24.

Q: What is the difference between a Docker Image and a Container?

Answer:

This is a deceptively simple but critical distinction:

Docker Image

An image is a read-only template that contains the application code, runtime, libraries, environment variables, and configuration files needed to run an application. Think of it as a class in OOP or a blueprint for a house.

Images are built in layers. Each instruction in a Dockerfile (e.g., RUN, COPY, ADD) creates a new layer. Layers are stacked on top of each other and are cached, which makes rebuilds extremely fast.

Docker Container

A container is a running instance of an image. Think of it as an object instantiated from a class, or a house built from a blueprint. You can create multiple containers from the same image.

When a container starts, Docker adds a thin writable layer on top of the read-only image layers. All file changes (new files, modifications, deletions) happen in this writable layer.

Analogy

Image  = Class definition (immutable blueprint)
Container = Object/Instance (running process with mutable state)

One Image → Many Containers (just like one Class → many Objects)

Key Differences

FeatureImageContainer
StateImmutable (read-only)Mutable (has a writable layer)
Stored asLayers on diskRunning process + writable layer
Created bydocker build or docker pulldocker run or docker create
PersistencePersists until explicitly deletedEphemeral by default (data lost on removal)
SharingPushed to registriesCannot be pushed (must be docker commit'd into an image first)

Common Follow-Up: What is docker commit?

You can take a running container's writable layer and freeze it into a new image:

docker commit <container_id> my-custom-image:v1

[!CAUTION] Using docker commit in production is considered bad practice. Always use a Dockerfile for reproducible, version-controlled image builds.

Q: What are Linux namespaces and cgroups, and how do they make containers?

Answer:

Containers are not a single Linux feature — they are an application of two unrelated kernel mechanisms: namespaces (isolation) and cgroups (resource limits). Docker is mostly glue around these.

The Two Halves

MechanismProvidesWithout it
NamespacesEach container sees its own PIDs, network, mounts, users, etc.All processes share one global view
cgroups (control groups)CPU, memory, I/O limits per group of processesOne container can starve the host

A container = a process tree wrapped in a set of namespaces + placed in a set of cgroups.

The Namespaces

┌────────────────────────────────────────────────────────────┐
│  Namespace  │  Isolates                                    │
├────────────────────────────────────────────────────────────┤
│  pid        │  Process IDs (container sees its own PID 1)  │
│  net        │  Network interfaces, routes, iptables, ports │
│  mnt        │  Filesystem mounts (chroot on steroids)      │
│  uts        │  hostname, domainname                        │
│  ipc        │  SysV IPC, POSIX message queues              │
│  user       │  UID/GID mappings (root in container ≠ root) │
│  cgroup     │  cgroup root view                            │
│  time       │  CLOCK_MONOTONIC offset (newer)              │
└────────────────────────────────────────────────────────────┘

You can poke at them on any Linux box:

ls -l /proc/$$/ns/
# lrwxrwxrwx 1 user user 0 ... net -> 'net:[4026531992]'
# lrwxrwxrwx 1 user user 0 ... pid -> 'pid:[4026531836]'

Two processes with the same inode number after the colon share that namespace.

Inside a container, ps aux shows only the container's processes — because the pid namespace doesn't expose the host's processes at all. From the host, you can see them by inode:

sudo lsns -t pid

cgroups

cgroups v2 (unified hierarchy, default on modern distros) lets you cap resources per group:

# Memory cap example for a Docker container
docker run --memory=512m --cpus=1.5 nginx

Translates to writes under /sys/fs/cgroup/...:

memory.max = 536870912
cpu.max    = 150000 100000     # 1.5 cores

If a container exceeds memory.max, the kernel OOM-kills a process inside the container's cgroup — typically PID 1 — and the container exits with 137 (128 + SIGKILL 9).

Putting It Together: How docker run Works

1. docker CLI -> dockerd -> containerd -> runc
2. runc reads OCI spec (config.json), then:
   a. clone(CLONE_NEW{PID,NET,MNT,UTS,IPC,USER,CGROUP})  <- create namespaces
   b. mount the image rootfs (overlay2), pivot_root      <- new filesystem view
   c. write cgroup limits to /sys/fs/cgroup/...           <- enforce resources
   d. apply seccomp + AppArmor/SELinux profiles           <- syscall filtering
   e. drop capabilities                                   <- least privilege
   f. exec the container's ENTRYPOINT                     <- run user process

Note: there is no "container" kernel object. After step (f), it's just a Linux process — distinguished only by which namespaces and cgroups it belongs to.

Why This Matters for Interviews

  • "Why is the container so much lighter than a VM?" — No guest kernel, no hypervisor. A container is a normal process; the kernel is shared.
  • "What does --privileged actually do?" — Disables most isolation: gives all capabilities, doesn't drop user namespace, mounts /dev, removes the default AppArmor/seccomp profiles. Dangerous.
  • "How does the container's PID 1 get its responsibilities?" — PID namespace makes the entrypoint PID 1, which means it inherits zombie reaping and signal handling duties. (See: tini, PID 1 problem.)
  • "How do containers on the same host talk to each other?" — Their net namespaces are connected via a virtual bridge (docker0) and pairs of veth devices.

Common Misconceptions

BeliefReality
"Containers are mini-VMs"They share the host kernel; no isolation boundary as strong as a VM
"Docker invented containers"Namespaces existed before Docker (LXC, Solaris zones, FreeBSD jails); Docker made the UX usable
"Containers are secure by default"They're a better default than running everything as root, but a kernel exploit escapes the namespace boundary

[!NOTE] A useful mental model: a container is just a process with strange glasses on. Namespaces are the glasses; cgroups are the diet.

Q: What is the difference between CMD and ENTRYPOINT in a Dockerfile?

Answer:

Both CMD and ENTRYPOINT define what command runs when a container starts, but they behave very differently when users pass arguments at runtime.

CMD — The Default Command (Easily Overridden)

CMD sets the default command and/or arguments for the container. However, it is completely replaced if the user provides a command when running the container.

FROM ubuntu
CMD ["echo", "Hello from CMD"]
docker run myimage
# Output: Hello from CMD

docker run myimage echo "I replaced CMD"
# Output: I replaced CMD  (CMD was completely overridden)

ENTRYPOINT — The Fixed Executable (Not Easily Overridden)

ENTRYPOINT sets the main executable for the container. User-provided arguments are appended to the entrypoint, not used to replace it.

FROM ubuntu
ENTRYPOINT ["echo", "Hello from"]
docker run myimage
# Output: Hello from

docker run myimage "Docker World"
# Output: Hello from Docker World  (argument was appended)

The Power Combo: ENTRYPOINT + CMD

The most common production pattern is using them together. ENTRYPOINT defines the fixed executable, and CMD provides default arguments that can be overridden.

FROM python:3.11-slim
ENTRYPOINT ["python"]
CMD ["app.py"]
docker run myimage
# Runs: python app.py (default)

docker run myimage test.py
# Runs: python test.py (CMD overridden, ENTRYPOINT kept)

Summary

FeatureCMDENTRYPOINT
PurposeDefault command/argsFixed executable
Override behaviorCompletely replaced by docker run argsArgs are appended to it
Best forDefault argumentsThe main process

[!TIP] To override ENTRYPOINT at runtime, you must explicitly use the --entrypoint flag: docker run --entrypoint /bin/bash myimage

Q: What is the difference between COPY and ADD in a Dockerfile?

Answer:

Both instructions copy files from the build context into the image, but ADD has extra (often unwanted) functionality.

COPY — Simple File Copy

Does exactly one thing: copies files or directories from the build context into the image filesystem. It's transparent and predictable.

COPY requirements.txt /app/
COPY src/ /app/src/

ADD — Copy with Extras

ADD does everything COPY does, plus two additional features:

  1. Auto-extracts compressed archives (.tar, .tar.gz, .tgz, .bz2, .xz) into the destination directory.
  2. Fetches files from remote URLs (like wget).
# Auto-extracts the tarball into /app/
ADD app.tar.gz /app/

# Downloads a file from the internet
ADD https://example.com/config.json /etc/app/config.json

Why You Should Almost Always Use COPY

[!WARNING] The Docker official best practices guide explicitly recommends using COPY over ADD in almost all cases.

Reasons:

  1. Predictability: COPY has no hidden side effects. With ADD, a developer might not realize their .tar.gz file will be auto-extracted. If you want the archive as-is (e.g., to extract it manually later), ADD will silently break your intent.
  2. Security: ADD from a URL does not verify SSL certificates and doesn't support authentication. Use RUN curl or RUN wget instead for better control.
  3. Cache invalidation: Remote URL fetches with ADD can cause unpredictable cache behavior since Docker cannot know if the remote file has changed.

Rule of Thumb

  • Use COPY for all local file copies (99% of cases).
  • Use ADD only when you explicitly need tar auto-extraction.
  • Use RUN curl or RUN wget for downloading remote files.

Q: What are Multi-Stage Builds and why are they important?

Answer:

Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each FROM starts a new "stage" of the build. You can selectively copy artifacts from one stage to another, leaving behind everything you don't need in the final image.

The Problem Without Multi-Stage Builds

In a typical build, you need compilers, build tools, and dev dependencies to compile your application. If you use a single stage, all of those tools end up in your final production image, making it bloated and insecure.

# ❌ Single-stage: Final image includes Go compiler, source code, build tools
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
# Final image size: ~800MB (includes entire Go toolchain!)

The Solution: Multi-Stage Build

# Stage 1: Build (named "builder")
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Stage 2: Production (tiny final image)
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]
# Final image size: ~15MB (just the binary + Alpine!)

How It Works

  1. Stage 1 ("builder"): Uses the full golang image (800MB+) to compile the Go binary.
  2. Stage 2: Starts fresh from a tiny alpine image (5MB) and copies only the compiled binary from the builder stage using COPY --from=builder.
  3. The final image contains nothing from stage 1 except the single file you explicitly copied.

Real-World Node.js Example

# Stage 1: Install dependencies and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

Benefits

  • Dramatically smaller images (often 10-50x reduction).
  • Better security (no compilers, source code, or dev tools in production).
  • Faster deployments (smaller images push/pull faster).
  • Single Dockerfile (no need for separate Dockerfile.dev and Dockerfile.prod).

[!TIP] You can also copy from external images without defining them as a stage: COPY --from=nginx:latest /etc/nginx/nginx.conf /etc/nginx/

Q: How does Docker Layer Caching work? How do you optimize a Dockerfile?

Answer:

Understanding layer caching is essential for building images fast and keeping them small.

How Layers Work

Every instruction in a Dockerfile (FROM, RUN, COPY, ADD, ENV, etc.) creates a new layer. Layers are stacked, read-only, and cached. When you rebuild an image, Docker checks each instruction:

  • If the instruction and its inputs haven't changed, Docker reuses the cached layer (instant).
  • If anything has changed, Docker invalidates that layer and all layers after it (the cache "busts").

The Cache Busting Problem

# ❌ Bad order: Cache busts on EVERY code change
FROM node:20-alpine
WORKDIR /app
COPY . .                    # Any code change invalidates THIS layer
RUN npm install             # ...which forces this to re-run (slow!)
CMD ["node", "index.js"]

Every time you change a single line of code, COPY . . changes, which invalidates the cache for npm install. You end up reinstalling all dependencies from scratch on every build.

The Fix: Order by Change Frequency

# ✅ Good order: Dependencies cached separately from code
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # Changes rarely
RUN npm ci                               # Cached unless package.json changes
COPY . .                                 # Code changes only bust THIS layer
CMD ["node", "index.js"]

Now, if you only change application code, Docker reuses the cached npm ci layer and only re-runs COPY . . — saving minutes on every build.

Optimization Best Practices

1. Use .dockerignore Just like .gitignore, a .dockerignore file prevents unnecessary files from being sent to the Docker build context.

node_modules
.git
*.md
.env
dist

2. Combine RUN commands Each RUN instruction creates a new layer. Combine related commands to reduce layer count and image size.

# ❌ Bad: 3 layers (including cached apt lists)
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ Good: 1 layer, cleanup in same step
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

3. Use specific base image tags

# ❌ Bad: `latest` changes unpredictably, breaks cache
FROM node:latest

# ✅ Good: Pinned version, reproducible
FROM node:20.11-alpine3.18

4. Use multi-stage builds (see the previous chapter).

5. Prefer Alpine or Distroless base images

  • node:20 → ~900MB
  • node:20-alpine → ~130MB
  • node:20-slim → ~200MB

Q: What is BuildKit, and how does it differ from the legacy Docker builder?

Answer:

BuildKit is the modern Docker image builder, default since Docker 23.0. It replaces the old "classic" builder with a parallel, content-addressable, plugin-friendly engine. Skipping its features is the difference between a 20-minute and a 2-minute CI build.

Why It Exists

The legacy builder:

  • Processed a Dockerfile strictly top to bottom, one stage at a time.
  • Could not run independent stages in parallel.
  • Cache was layer-hash–based and easy to invalidate by accident.
  • No way to mount build-time secrets without baking them into layers.
  • No native cross-platform builds.

BuildKit fixes all of these.

What BuildKit Adds

FeatureSyntaxWhy
Parallel stages(automatic)Multi-stage with independent stages builds them concurrently
Cache mountsRUN --mount=type=cache,target=/root/.npm npm ciPersist package caches across builds without bloating image
Secret mountsRUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ciUse credentials at build time, never written to a layer
SSH mountsRUN --mount=type=ssh git clone git@...Clone private repos without baking keys
Bind mounts (from context)RUN --mount=type=bind,source=.,target=/src ...Avoid COPY overhead for scratch use
Distributed cache--cache-to type=registry,ref=...Share cache across CI runners via a registry
Multi-platformdocker buildx build --platform linux/amd64,linux/arm64Single command, multi-arch manifest

Enabling It

Default in Docker 23+. Otherwise:

export DOCKER_BUILDKIT=1
# Or use buildx (the BuildKit CLI front-end)
docker buildx build .

For multi-platform or remote builds, create a builder:

docker buildx create --name multi --use --bootstrap
docker buildx build --platform linux/amd64,linux/arm64 -t me/app:1.0 --push .

Cache Mounts: The Highest-ROI Feature

Without cache mount, a small package.json change re-downloads every npm dep:

COPY package*.json ./
RUN npm ci          # ~90s on a cold cache
COPY . .
RUN npm run build

With:

# syntax=docker/dockerfile:1.7
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci          # ~5s warm: tarballs reused
COPY . .
RUN --mount=type=cache,target=/app/.next/cache \
    npm run build

Note the first line: # syntax=docker/dockerfile:1.7 opts into newer Dockerfile features.

Secret Mounts

Pre-BuildKit pattern (insecure — secret ends up in image history):

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && npm ci

Even with multi-stage cleanup, the layer's history records the ARG. BuildKit way:

docker build --secret id=npmrc,src=$HOME/.npmrc .
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

The file exists only during the RUN. Never persisted in any layer.

Distributed Cache for CI

Local layer cache is gone every time a CI runner is freshly provisioned. Push cache to a registry:

docker buildx build \
  --cache-from type=registry,ref=ghcr.io/me/app:buildcache \
  --cache-to   type=registry,ref=ghcr.io/me/app:buildcache,mode=max \
  -t ghcr.io/me/app:$SHA --push .

mode=max exports cache for every layer (not just final stages). Bigger cache image, faster subsequent builds.

Multi-Platform Builds

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t me/app:1.0 --push .

BuildKit emulates non-native architectures via QEMU. Native cross-compilation is faster:

FROM --platform=$BUILDPLATFORM golang:1.22 AS build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /app

$BUILDPLATFORM = the builder's arch (fast native build); $TARGETPLATFORM = the image's arch.

BuildKit vs Legacy Comparison

AspectLegacyBuildKit
Parallel stagesNoYes
Build cachePer-layer, in-imageContent-addressable, mountable
SecretsVia ARG (leaks)Native mount type
Multi-platformNoYes (buildx)
Dockerfile featuresFrozenPer-syntax= directive — versioned
Output formatsOnly imageImage, OCI tarball, local files, registry direct

Interview Follow-ups

  • "Why is my COPY . . invalidating cache on every build?" — Because the context changed. Use .dockerignore to exclude irrelevant files; use --mount=type=bind for transient access.
  • "What is docker buildx?" — The Docker CLI plugin that drives BuildKit. Default builder in newer Docker.
  • "How do I see what BuildKit is actually doing?"--progress=plain instead of the default TTY view; pair with BUILDKIT_PROGRESS=plain.

Q: Tags vs digests — why should production images be pinned by digest?

Answer:

Tags are mutable labels; digests are immutable content hashes. Deploying nginx:1.25 today and nginx:1.25 next week can give you two different images with the same name. Pinning by digest is the difference between deterministic builds and a supply-chain incident waiting to happen.

What Each One Is

nginx:1.25                                       <-- tag
nginx@sha256:9b6a3f51c4e6e9a7...c9                <-- digest
nginx:1.25@sha256:9b6a3f51c4e6e9a7...c9           <-- both (recommended)
  • Tag: a pointer in the registry to a manifest. Mutable. The registry operator (or anyone with push access) can move it.
  • Digest: the SHA-256 of the image manifest. Content-addressable. Two identical manifests anywhere in the world produce the same digest.

Why Tags Are Dangerous

Day 1: nginx:1.25 → digest A
Day 2: nginx:1.25 → digest A (still — patch release pending)
Day 8: nginx:1.25 → digest B (1.25.4 silently replaces 1.25.3)

Your CI:
  Day 1 deploy works.
  Day 8 deploy ships a new binary into prod with no code change in your repo.

The same is true even for "version" tags:

  • 1.25 (floats over patch releases)
  • 1 (floats over minor releases)
  • latest (floats over everything; pretend it doesn't exist)
  • Internal teams routinely overwrite :prod, :stable, :v1 tags.

Pinning by Digest

FROM nginx:1.25.3@sha256:9b6a3f51c4e6e9a7b5e9b8c1d2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
  • The tag is documentation for humans.
  • The digest is what the daemon resolves and verifies.
  • Even if someone retags 1.25.3 to point at malware, your build is unaffected — the digest mismatch fails the pull.

Where Digests Live

docker pull nginx:1.25
# 1.25: Pulling from library/nginx
# Digest: sha256:9b6a3f51c4e6e9a7b5e9b8c1d2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0

docker inspect --format='{{index .RepoDigests 0}}' nginx:1.25
# nginx@sha256:9b6a3f51c4...

Get it from a registry without pulling:

docker buildx imagetools inspect nginx:1.25
# Or:
crane digest nginx:1.25

What This Means for Workflows

SituationUse
Top of Dockerfile (FROM)Digest pin
Compose / Helm / K8s manifestsDigest pin (image: app@sha256:...)
Local dev iterationTag is fine
Internal "rolling" base imagesTag with imagePullPolicy: Always is acceptable if controlled
Reproducible builds (SBOM, supply chain)Digest pin everywhere

Multi-Arch Quirk

For a multi-platform image, the digest of nginx:1.25 refers to a manifest list (the index), not a single platform's image. Pulling on amd64 resolves to a different layer digest than on arm64, but the top-level manifest digest is the same across platforms — which is what you want when pinning.

docker buildx imagetools inspect nginx:1.25 --raw
# Shows the manifest list with per-platform digests inside.

Automation: Digest Pinning Tools

  • renovate, dependabot — open PRs to bump pinned digests while keeping a human-readable tag.
  • docker buildx imagetools inspect to fetch the current digest.
  • For Helm/K8s: kbld, kustomize edit set image, or your CD's templating.

Example renovate config:

{
  "docker": {
    "pinDigests": true
  }
}

Push-Side: Why Digests Help Deployments Too

A K8s Deployment referencing app:prod won't redeploy when you push a new image to that tag — same image, same spec hash, nothing to roll. Tooling either:

  1. Pushes by digest (app@sha256:...) — changes the spec, triggers rollout.
  2. Or uses imagePullPolicy: Always and bumps a label/annotation.

Digest-by-default makes (1) automatic.

Common Pitfalls

PitfallFix
Mixing :latest with retention policies in CIAlways tag with immutable identifier (git SHA) AND push to a moving tag if needed
Digest pin in FROM, but base image not in registry yetCI must pull from the same registry the digest was computed against
Pinning to a digest that gets garbage-collectedUse an immutable retention policy in the registry, or mirror to your own
Believing latest is "the latest"It's whatever the last push named latest, nothing more

[!NOTE] Digests are SHA-256 over the manifest, not the image bytes. Changing a label or build-arg metadata changes the digest even if the actual filesystem layers are identical. That's a feature: it lets you audit what was built, not just what bytes it contained.

Interview Follow-ups

  • "Can two different images have the same digest?" — No (collision resistance of SHA-256). The point of content addressing.
  • "What's the difference between manifest digest and image ID?" — Manifest digest is registry-side, over the manifest JSON. Image ID is local-daemon-side, over the config JSON. They're related but distinct.
  • "What is Notary / Cosign?" — Tools to sign image digests; verification happens against the digest, not the tag, for exactly this reason.

Q: What is the build context, and why does .dockerignore matter so much?

Answer:

The build context is the directory tree sent from your client to the Docker daemon when you run docker build. Misunderstanding it is the #1 cause of slow builds, accidental secrets in images, and "why did my cache invalidate?" mysteries.

What Gets Sent

docker build -t app .
                    ^ this dot is the build context

Docker tars up every file under . (minus .dockerignore matches) and streams it to the daemon — even files you never COPY. The daemon can only see files in this context; it cannot reach back to your filesystem.

Client                          Daemon
  │   build context: 480 MB       │
  │ ────────────────────────────► │  starts evaluating Dockerfile
  │                               │  COPY package.json → finds it in context

A 5 GB context (often: node_modules, target/, .git, dataset files) wastes:

  • Network/IPC: every build re-sends the tarball.
  • Cache: a single file change in the context can invalidate COPY layers.
  • Disk: BuildKit caches the context.

.dockerignore

Same syntax as .gitignore. Lives next to the Dockerfile (or wherever the context root is).

# Build artifacts
node_modules
dist
target
build
*.pyc
__pycache__

# Dev files
.git
.gitignore
.github
.vscode
.idea
*.md
README*

# Secrets — most important
.env
.env.*
*.pem
*.key
id_rsa*

# Tests/coverage
coverage
test-results
.pytest_cache

# OS
.DS_Store
Thumbs.db

# Allow-list pattern: ignore everything, then include
*
!src/
!package.json
!package-lock.json

Verify what gets sent:

docker build --progress=plain .
# Look for "transferring context" size

# Or:
tar czf /tmp/ctx.tgz --exclude-from .dockerignore .
ls -lh /tmp/ctx.tgz

COPY and Cache Invalidation

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Layer cache for COPY package.json is invalidated only if either file changed. Layer for COPY . . is invalidated if any file in the context changed — including README.md, tests/, .git. Without .dockerignore filtering, you re-run npm run build whenever any commit touches the repo.

The Secrets Trap

.env, .npmrc with auth tokens, SSH keys — if they're in the context and someone COPYs them, they're in the image forever as a layer. Even removing them in a later layer leaves them in the history.

COPY . .                     # ← copies .env if not ignored
RUN rm .env                  # ← .env still exists in the previous layer

.dockerignore is the first line of defense. The second is BuildKit secret mounts (--mount=type=secret), which never become part of any layer.

Allow-list Pattern

Default-deny is safer than default-allow. Instead of listing what to ignore, list what to include:

# Ignore everything
*

# Then explicitly include
!src/
!public/
!package.json
!package-lock.json
!tsconfig.json
!Dockerfile

Adding a new file to the repo? You must opt it in. Less convenient, but no surprises.

Negation Subtlety

node_modules
!node_modules/some-package

This doesn't work like .gitignore for directory contents. Docker stops at the parent — once node_modules/ is excluded, individual files inside can't be re-included. You'd have to re-include the directory and then exclude what you don't want.

Multi-stage Builds and Context

The context is shared across all stages. The FROM ... AS build stage and the final stage see the same context. There's no "per-stage" filtering — .dockerignore is global.

Remote Context

You can build from a URL or git repo:

docker build https://github.com/me/app.git#main
docker build https://github.com/me/app.git#main:subdir

The repo is cloned server-side; .dockerignore still applies.

Context-less Build (-f -)

echo "FROM scratch" | docker build -

No context at all — no COPY possible. Useful for trivial images.

Common Mistakes

MistakeFix
.git in context (10s of MB on big repos)Add to .dockerignore
node_modules in context, then RUN npm ci reinstalls it anywayAlways ignore — wastes network + cache
Secrets in context, removed in later RUNUse --mount=type=secret or never put them in context
.dockerignore not in the same directory as -f Dockerfile in newer DockerUse <Dockerfile>.dockerignore (file-specific)
Different .dockerignore semantics from .gitignore for directoriesTest with docker build --no-cache --progress=plain .

Per-Dockerfile Ignore (BuildKit)

my-app/
├── Dockerfile
├── Dockerfile.dockerignore          ← scoped to Dockerfile
├── Dockerfile.dev
└── Dockerfile.dev.dockerignore      ← scoped to Dockerfile.dev

Lets monorepos define different ignores per service. Falls back to root .dockerignore if no per-file one exists.

Measuring Impact

Before:

=> transferring context: 287MB    0.4s
=> CACHED [ 2/12] COPY package.json ./
=> [ 11/12] COPY . .              ← invalidated by .git change
=> [ 12/12] RUN npm run build     ← 90s

After a proper .dockerignore:

=> transferring context: 1.2MB    0.05s
=> CACHED [ 2/12] COPY package.json ./
=> CACHED [11/12] COPY . .
=> CACHED [12/12] RUN npm run build

[!NOTE] Treat .dockerignore as a security artifact, not just an optimization. A 3-line .dockerignore matching .env* and *.key prevents a whole class of credential leaks.

Interview Follow-ups

  • "Why doesn't Docker just send only what COPY references?" — Because COPY patterns are computed at build time and can use globs. Pre-filtering would change semantics. Also, build args and conditional logic can change what's referenced.
  • "What about docker build --secret?" — BuildKit-only. Mounts a file or env var for one RUN step; the file never becomes part of any layer.
  • "Symbolic links in the context?" — Resolved client-side. A symlink pointing outside the context will fail or be ignored, depending on settings.

Q: How do you build multi-arch (amd64 + arm64) Docker images correctly?

Answer:

Apple Silicon, AWS Graviton, and Raspberry Pi all run arm64. CI runners and most cloud servers run amd64. Shipping one image that works everywhere means building a multi-platform manifest: a single tag that points to per-architecture image variants.

The Manifest List

me/app:1.0
  ├── linux/amd64   →  sha256:aaaa...
  ├── linux/arm64   →  sha256:bbbb...
  └── linux/arm/v7  →  sha256:cccc...

When a daemon pulls me/app:1.0, the registry returns the manifest list. The daemon picks the variant matching its platform.

Building with docker buildx

# Create a builder that supports multiple platforms
docker buildx create --name multi --use --bootstrap

# Build and push
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t me/app:1.0 \
  --push .

--push is required for multi-platform — local Docker can hold only one platform at a time. The manifest list is assembled in the registry.

How It Builds Non-Native

The builder runs the Dockerfile for each platform. Three strategies:

1. QEMU emulation (default, simplest).

docker run --privileged --rm tonistiigi/binfmt --install all

Registers QEMU as the binfmt handler for non-native ELFs. Now docker buildx can "run" an arm64 binary on an amd64 host. Slow — 3–10× native.

2. Native cross-compilation (fast, requires support in the language).

FROM --platform=$BUILDPLATFORM golang:1.22 AS build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -o /app ./cmd/server

FROM --platform=$TARGETPLATFORM gcr.io/distroless/static
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Key tricks:

  • --platform=$BUILDPLATFORM on the build stage → runs natively (fast).
  • Use GOOS/GOARCH to cross-compile to the target.
  • --platform=$TARGETPLATFORM on the final stage → copies into the right arch base.

This is 5–10× faster than QEMU for Go, Rust (with cross), and C/C++ where cross-compilers exist.

3. Native builders (one builder per platform, federated).

docker buildx create --name multi --node arm-node --platform linux/arm64 \
  ssh://user@arm-runner
docker buildx create --append --name multi --node amd-node --platform linux/amd64
docker buildx use multi

Each platform builds on its own native host. Fastest, but requires extra infrastructure.

Common Variables

$TARGETPLATFORM     linux/arm64
$TARGETOS           linux
$TARGETARCH         arm64
$TARGETVARIANT      v8         (for arm)
$BUILDPLATFORM      linux/amd64  (host doing the build)
$BUILDOS            linux
$BUILDARCH          amd64

Dockerfile Patterns

Pure interpreter (Python, Ruby, Node):

FROM python:3.12-slim
COPY requirements.txt .
RUN pip install -r requirements.txt        # pip resolves arch from wheels
COPY . .
CMD ["python", "app.py"]

Most wheels publish both arm64 and amd64 since Python 3.10+. If a wheel doesn't, pip falls back to source build — slow under QEMU.

Go (cross-compile, fastest):

FROM --platform=$BUILDPLATFORM golang:1.22 AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /app

FROM scratch
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Rust (cross-compile via cross):

FROM --platform=$BUILDPLATFORM rust:1 AS build
ARG TARGETARCH
RUN case "$TARGETARCH" in \
      "arm64") rustup target add aarch64-unknown-linux-musl;; \
      "amd64") rustup target add x86_64-unknown-linux-musl;; \
    esac
COPY . .
RUN cargo build --release --target $(rustc -vV | sed -n 's|host: ||p')

In practice, use the cross crate to avoid handwriting all this.

Java: bytecode is platform-independent, but the base JRE isn't. Pick a multi-platform base:

FROM eclipse-temurin:21-jre
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

eclipse-temurin publishes both architectures — buildx selects each.

Output Without Pushing

docker buildx build --platform linux/amd64,linux/arm64 \
  -t me/app --output type=oci,dest=app.tar .

Useful for air-gapped environments. docker buildx imagetools can later load it.

Verifying

docker buildx imagetools inspect me/app:1.0
# Lists each platform's digest, OS, architecture

# Pull only one:
docker pull --platform linux/arm64 me/app:1.0

CI: GitHub Actions Example

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ghcr.io/me/app:${{ github.sha }}
    cache-from: type=gha
    cache-to:   type=gha,mode=max

Note: native arm64 runners (runs-on: ubuntu-24.04-arm) are now available — pair with matrix builds + buildx imagetools create to assemble a manifest list without QEMU.

Native Matrix Build (Fastest)

strategy:
  matrix:
    include:
      - runner: ubuntu-24.04
        platform: linux/amd64
      - runner: ubuntu-24.04-arm
        platform: linux/arm64

# Each job builds and pushes a per-arch digest tag
# Final job assembles manifest list:

- run: |
    docker buildx imagetools create \
      -t ghcr.io/me/app:${{ github.sha }} \
      ghcr.io/me/app:${{ github.sha }}-amd64 \
      ghcr.io/me/app:${{ github.sha }}-arm64

Native builds are 5–10× faster than QEMU; this pattern is the production CI standard now.

Common Mistakes

MistakeFix
Building one arch, pushing, deploying to arm64 clusterEmpty manifest list = "no matching manifest" error
QEMU build everything, 30-min CIUse cross-compile or native runners
RUN step uses arch-specific download URLUse $TARGETARCH to pick the URL
Forgetting --push (or --load for single-platform)Multi-platform images can't sit in local cache
Stale binfmt registration after host upgradeRe-run tonistiigi/binfmt --install all

[!NOTE] Cross-compile when your language supports it. QEMU is a fine fallback but never an optimization. Match the build pattern to the language; don't put one Dockerfile through QEMU when GOOS=... is one env var away.

Interview Follow-ups

  • "Why was the manifest list invented?" — Tag = single SHA was a constraint. Manifest list (a.k.a. OCI Image Index) lets one tag refer to a fan-out of per-arch images.
  • "Does this affect image size?" — No — a client only pulls its platform's variant. Total registry storage is sum of all platforms.
  • "What's --load?" — Single-platform alternative to --push: loads the built image into local Docker. Multi-platform --load is not supported because local Docker can't hold a manifest list.

Q: What are the different Container States and the Container Lifecycle?

Answer:

A Docker container goes through several states during its lifecycle. Understanding these is important for debugging and orchestration.

Container States

docker create     docker start     (process exits)     docker rm
    │                  │                  │                │
    ▼                  ▼                  ▼                ▼
 CREATED ──────▶ RUNNING ──────▶ EXITED ──────▶ REMOVED
                    │    ▲
          docker    │    │  docker
          pause     │    │  unpause
                    ▼    │
                  PAUSED
  1. Created: Container exists but hasn't started yet (docker create).
  2. Running: The container's main process (PID 1) is actively executing (docker start or docker run).
  3. Paused: The container's processes are frozen (using cgroup freezer). Memory state is preserved (docker pause).
  4. Exited: The main process has finished or crashed. The writable layer is still on disk.
  5. Removed: The container and its writable layer are deleted (docker rm).

Key Commands

# Create + Start in one command
docker run -d --name myapp nginx

# View running containers
docker ps

# View ALL containers (including stopped ones)
docker ps -a

# Stop gracefully (sends SIGTERM, then SIGKILL after grace period)
docker stop myapp

# Stop immediately (sends SIGKILL)
docker kill myapp

# Remove a stopped container
docker rm myapp

# Force remove a running container
docker rm -f myapp

# Remove ALL stopped containers
docker container prune

The PID 1 Problem

The container's main process is always PID 1. When PID 1 exits, the entire container stops, regardless of whether other processes are still running inside it.

[!IMPORTANT] This is why docker run should always run the main application process as the foreground command (not as a background daemon). If your entrypoint script runs the app with & (background) and then exits, the container will immediately stop.

Q: What is the difference between docker exec and docker attach?

Answer:

Both commands let you interact with a running container, but they connect to very different things.

docker attach

Attaches your terminal's stdin/stdout/stderr to the container's main process (PID 1). You are essentially watching and interacting with the same process that docker run started.

docker run -d --name myapp python app.py
docker attach myapp
# You are now connected to the stdout of `python app.py`

Danger: If you press Ctrl+C while attached, it sends SIGINT to PID 1, which stops the container entirely.

[!WARNING] Use Ctrl+P then Ctrl+Q to detach from a container without killing it. This is the "detach sequence."

docker exec

Starts a brand new, separate process inside the running container. The new process runs alongside PID 1 without affecting it.

docker run -d --name myapp nginx
docker exec -it myapp /bin/bash
# Opens a new bash shell inside the container
# Exiting this shell does NOT stop the container

Key Differences

Featuredocker attachdocker exec
Connects toPID 1 (main process)A new, separate process
Use caseViewing main process outputDebugging, running ad-hoc commands
Ctrl+CStops the containerOnly kills the exec'd process
Multiple terminalsAll see the same PID 1 outputEach gets an independent process

When to Use Which?

  • docker exec (99% of the time): Debugging, inspecting files, running one-off commands, opening a shell.
  • docker attach: Rare. Useful when you need to interact with the stdin of an interactive main process (e.g., a REPL).

Q: What are the different Docker Restart Policies?

Answer:

Restart policies control whether a container is automatically restarted when it exits or when the Docker daemon restarts.

Available Policies

docker run --restart <policy> myimage
PolicyBehavior
noNever restart the container (default).
on-failure[:max-retries]Restart only if the container exits with a non-zero exit code. Optionally limit the number of retries.
alwaysAlways restart the container, regardless of exit code. Also restarts when the Docker daemon starts.
unless-stoppedSame as always, but does not restart if the container was manually stopped before the daemon restart.

Examples

# Restart up to 5 times on failure
docker run --restart on-failure:5 myapp

# Always keep the container running (survives daemon restarts)
docker run --restart always nginx

# Same as always, but respects manual stops
docker run --restart unless-stopped nginx

always vs unless-stopped

The subtle but critical difference:

  1. You run a container with --restart always.
  2. You manually docker stop it.
  3. The Docker daemon restarts (e.g., server reboot).
  4. Result: The container starts again (because the policy is always).

With unless-stopped:

  1. You run a container with --restart unless-stopped.
  2. You manually docker stop it.
  3. The Docker daemon restarts.
  4. Result: The container stays stopped (it respects your manual stop).

[!TIP] For production services, prefer unless-stopped. It auto-recovers from crashes while still respecting your intent when you explicitly stop a container for maintenance.

In Docker Compose

services:
  web:
    image: nginx
    restart: unless-stopped

Q: What are the different Docker Network Types?

Answer:

Docker provides several built-in network drivers. Understanding them is crucial for designing multi-container applications.

1. Bridge Network (Default)

The default network type for standalone containers. Docker creates a virtual bridge (docker0) on the host and assigns each container a private IP address within that bridge's subnet.

# Containers on the default bridge can communicate via IP, 
# but NOT by container name (no automatic DNS).
docker run -d --name app1 nginx
docker run -d --name app2 nginx
# app2 cannot reach app1 via http://app1 (only by IP)

# Custom bridge networks DO support DNS resolution:
docker network create mynet
docker run -d --name app1 --network mynet nginx
docker run -d --name app2 --network mynet nginx
# Now app2 CAN reach app1 via http://app1 ✅

[!IMPORTANT] Always use custom bridge networks instead of the default bridge. Custom bridges provide automatic DNS resolution, better isolation, and the ability to connect/disconnect containers dynamically.

2. Host Network

Removes network isolation entirely. The container shares the host's network stack directly. No port mapping is needed — the container's ports are the host's ports.

docker run --network host nginx
# nginx is now accessible on the host's port 80 directly

Pros: Best network performance (no NAT overhead). Cons: Port conflicts if multiple containers use the same port. Not available on Docker Desktop (macOS/Windows).

3. Overlay Network

Enables communication between containers running on different Docker hosts (across machines). Used in Docker Swarm and Kubernetes environments.

docker network create -d overlay my-overlay

Uses VXLAN tunneling under the hood to encapsulate container traffic across physical network boundaries.

4. None Network

Completely disables networking for the container. The container only has a loopback interface.

docker run --network none myapp
# No external network access at all

Use case: Security-sensitive batch processing where no network communication should be possible.

Summary

DriverScopeDNSUse Case
bridgeSingle hostCustom onlyDefault for standalone containers
hostSingle hostN/AMax performance, no isolation needed
overlayMulti-hostYesSwarm/K8s clusters
noneN/AN/ASecurity, isolated batch jobs

Q: What is the difference between -p (publish) and EXPOSE in Docker?

Answer:

This is a commonly misunderstood distinction.

EXPOSE (Dockerfile instruction)

EXPOSE is purely documentation. It tells other developers and tools (like Docker Compose) which ports the application inside the container listens on. It does NOT actually publish or open any ports.

FROM node:20-alpine
WORKDIR /app
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

Even with EXPOSE 3000, you cannot access the container on port 3000 from the host unless you explicitly publish it with -p.

-p / --publish (Runtime flag)

This is what actually creates a port mapping between the host machine and the container. It sets up iptables rules to forward traffic.

# Map host port 8080 to container port 3000
docker run -p 8080:3000 myapp

# Map to all interfaces on a random host port
docker run -p 3000 myapp   # Docker picks a random host port

# Bind to a specific host interface
docker run -p 127.0.0.1:8080:3000 myapp  # Only accessible from localhost

-P (Publish All)

The uppercase -P flag automatically publishes all ports that were declared with EXPOSE, mapping each to a random high port on the host.

docker run -P myapp
# If EXPOSE 3000 was in the Dockerfile, 
# Docker maps a random host port → container 3000

docker port myapp
# 3000/tcp -> 0.0.0.0:32768

Summary

FeatureEXPOSE-p / --publish
WhereDockerfiledocker run command
PurposeDocumentation onlyActually opens/maps the port
EffectNone on networkingCreates host↔container port forwarding
Required?NoYes, for external access

Q: How does Container DNS and Service Discovery work in Docker?

Answer:

Docker has a built-in DNS server that enables containers to discover and communicate with each other by name instead of IP address.

How It Works

When you create a custom bridge network, Docker runs an embedded DNS server at 127.0.0.11. Every container on that network automatically registers its container name as a DNS hostname.

docker network create backend
docker run -d --name api --network backend myapi
docker run -d --name db --network backend postgres

# Inside the "api" container:
ping db           # Resolves to the postgres container's IP ✅
curl http://db:5432  # Works by name ✅

Default Bridge vs Custom Bridge

FeatureDefault BridgeCustom Bridge
DNS resolution by name❌ No✅ Yes
Container isolationShared with all default containersIsolated per network
Legacy --link needed?Yes (deprecated)No

[!WARNING] The --link flag is deprecated. Always use custom bridge networks for container-to-container communication.

Network Aliases

You can give a container multiple DNS names using --network-alias:

docker run -d --name postgres-primary \
    --network backend \
    --network-alias db \
    --network-alias database \
    postgres

# Other containers can reach it via "postgres-primary", "db", OR "database"

Docker Compose — Automatic Service Discovery

In Docker Compose, each service name automatically becomes a DNS hostname on the shared network.

services:
  api:
    build: ./api
    depends_on:
      - db
  db:
    image: postgres:16

Inside the api container, db resolves to the Postgres container. No manual network configuration needed.

Round-Robin DNS (Load Balancing)

If multiple containers share the same network alias, Docker's DNS returns all their IPs in a round-robin fashion:

docker run -d --network backend --network-alias worker myworker
docker run -d --network backend --network-alias worker myworker
docker run -d --network backend --network-alias worker myworker

# Resolving "worker" returns all 3 IPs, rotating order each time

Q: What is the difference between Volumes, Bind Mounts, and tmpfs?

Answer:

Docker provides three mechanisms for persisting data or sharing files between the host and containers.

1. Volumes (Managed by Docker)

Volumes are the preferred mechanism for persisting data. Docker fully manages them — they're stored in a dedicated directory on the host (/var/lib/docker/volumes/) and are completely abstracted from the host filesystem.

# Create a named volume
docker volume create mydata

# Use it
docker run -v mydata:/app/data myapp
# or (more explicit long syntax):
docker run --mount type=volume,source=mydata,target=/app/data myapp

2. Bind Mounts (Host Path → Container Path)

A bind mount maps a specific file or directory on the host directly into the container. The host and container see the exact same files in real-time.

# Mount current directory into the container
docker run -v $(pwd):/app myapp
# or:
docker run --mount type=bind,source=$(pwd),target=/app myapp

3. tmpfs Mounts (In-Memory Only)

Data is stored in the host's RAM only. It is never written to disk and is lost when the container stops. Useful for sensitive data that should not persist.

docker run --tmpfs /app/secrets myapp
# or:
docker run --mount type=tmpfs,target=/app/secrets myapp

Comparison

FeatureVolumeBind Mounttmpfs
Stored onDocker-managed area on diskAny host pathHost RAM
Managed byDocker CLI (docker volume)You (host filesystem)Kernel
PortableYes (works across environments)No (depends on host path)No
PerformanceExcellentExcellentFastest (RAM)
Persists after stop✅ Yes✅ Yes (on host)❌ No
Pre-populated✅ Yes (from image)❌ No (overwrites)❌ No
Use caseDatabase storage, app dataDev hot-reload, config filesSecrets, temp caches

When to Use What?

  • Volumes: Production data (databases, uploads). Portable and manageable.
  • Bind Mounts: Local development (mount source code for hot-reload).
  • tmpfs: Storing sensitive info (tokens, keys) that should never hit disk.

[!IMPORTANT] Bind mounts can be dangerous in production because they give containers direct access to the host filesystem. A container running as root with a bind mount to / could access or modify any file on the host.

Q: How do you handle Data Persistence in Docker?

Answer:

Containers are ephemeral by default — all data written inside a container is lost when the container is removed. This is a key interview topic because production systems obviously need persistent data.

The Problem

docker run -d --name mydb postgres
# Write data to the database...
docker rm -f mydb
# 💀 All data is gone forever

Named volumes persist independently of container lifecycle. Even if the container is removed, the volume survives.

docker volume create postgres_data
docker run -d --name mydb \
    -v postgres_data:/var/lib/postgresql/data \
    postgres

# Remove the container
docker rm -f mydb

# Data is still safe in the volume!
docker run -d --name mydb-new \
    -v postgres_data:/var/lib/postgresql/data \
    postgres
# New container picks up right where the old one left off

Strategy 2: Docker Compose with Volumes

services:
  db:
    image: postgres:16
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: secret

volumes:
  postgres_data:  # Declared as a named volume

Strategy 3: Backup & Restore Volumes

# Backup a volume to a tar file
docker run --rm \
    -v postgres_data:/data \
    -v $(pwd):/backup \
    alpine tar czf /backup/db-backup.tar.gz -C /data .

# Restore from backup
docker run --rm \
    -v postgres_data:/data \
    -v $(pwd):/backup \
    alpine tar xzf /backup/db-backup.tar.gz -C /data

Strategy 4: External Storage Drivers

For production clusters, volume drivers allow Docker to store data on external systems:

  • AWS EBS volumes
  • NFS shares
  • GlusterFS / Ceph distributed filesystems
docker volume create --driver rexray/ebs myvolume

[!TIP] In interviews, mentioning backup strategies and external volume drivers shows production-level thinking beyond basic docker run -v.

Q: How does Docker layered storage actually work (overlay2, CoW)?

Answer:

Container images are stacks of read-only filesystem layers, unioned with a thin writable layer at runtime. The mechanism is copy-on-write (CoW) via a kernel storage driver — almost always overlay2 on modern Linux.

The Stack

        Container's view:
        ┌──────────────────────┐
        │  /  (unified view)   │
        └──────────────────────┘
                  ▲
                  │ overlay union
                  │
   ┌──────────────────────────┐    ←  upperdir  (writable, CoW)
   │  layer 4 (your changes)  │
   ├──────────────────────────┤    \
   │  layer 3 (COPY . .)      │     \
   ├──────────────────────────┤      ├  lowerdirs  (read-only)
   │  layer 2 (RUN npm ci)    │     /   ordered top → bottom
   ├──────────────────────────┤    /
   │  layer 1 (FROM node:20)  │
   └──────────────────────────┘

Each FROM/RUN/COPY/ADD in a Dockerfile creates one read-only layer. When the container starts, Docker mounts an overlay2 filesystem with these as lowerdirs and a fresh empty directory as upperdir.

Copy-on-Write

A read sees the file from whichever layer has it last (top wins). A write triggers CoW:

  1. Find the file in the lowerdirs.
  2. Copy it up to the upperdir.
  3. Mutate the copy.
echo "hi" >> /etc/config
   │
   ▼
overlay2 sees /etc/config in lowerdir
   │
   ▼
Copies entire file to upperdir, appends

CoW is cheap for small files, expensive for big ones (a dd if=/dev/zero of=/var/lib/big bs=1M count=1024 writes 1 GB to the upperdir even if the original was 1 GB).

Image Layer = Layer Tarball

/var/lib/docker/
├── image/overlay2/
│   ├── layerdb/sha256/<digest>/    <- metadata, diff IDs, parent chain
│   └── ...
├── overlay2/
│   ├── <layer_id>/diff/             <- actual files for this layer
│   ├── <layer_id>/link              <- short symlink name
│   ├── <layer_id>/lower             <- lower chain (multi-line)
│   └── <container_id>/merged/       <- live overlay mount point

Each layer's diff/ directory is a normal filesystem subtree containing only the files changed in that layer (additions and modifications) plus whiteout files (.wh.<name>) marking deletions.

Why Layer Order Matters

Cache invalidation is layer-by-layer, top-down. Change a layer, all layers above rebuild:

FROM node:20-alpine
WORKDIR /app
COPY . .                     # ❌ any source change invalidates the layer above
RUN npm ci

vs.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci                   # cached unless package.json changes
COPY . .                     # invalidates only this layer + below

Order from least likely to change at the top to most likely at the bottom.

Layer Size and Squash

A layer's size = bytes physically added by that step. RUN apt-get install foo && apt-get clean does not make the layer smaller — the layer still contains everything written before the clean (Docker can't see the temporal "clean up").

# ❌ 200 MB layer even after clean
RUN apt-get update && apt-get install -y curl
RUN apt-get clean && rm -rf /var/lib/apt/lists/*

# ✅ Same step, single layer, no orphan files
RUN apt-get update \
 && apt-get install -y curl \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*

Both apt-get update and the install must be in the same RUN because the cache from update is gigantic.

docker build --squash (experimental) collapses all your image's layers into one — saves bytes but kills layer cache for downstream builds.

Storage Drivers

DriverLinux requirementStatus
overlay2kernel ≥ 4.0Default; recommended
fuse-overlayfsuserspace overlayRootless Docker
btrfsbtrfs filesystemNiche; snapshots free
zfszfs filesystemNiche; snapshots free
devicemapperblock-levelDeprecated
aufsaufs-patched kernelLegacy, mostly Debian/Ubuntu old
vfsnoneNo CoW, slow, last-resort

Check yours:

docker info | grep "Storage Driver"
# Storage Driver: overlay2

RUN That Writes Big Files Is Bad Practice

A 2 GB tarball downloaded, extracted, deleted — all in one RUN — still costs 2 GB+ during the build (in the intermediate filesystem) and may produce a layer that includes the temporary file if the rm doesn't happen in the same step. With BuildKit, use cache mounts:

# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/tmp/dl \
    curl -o /tmp/dl/foo.tar.gz https://... \
 && tar xf /tmp/dl/foo.tar.gz -C /opt

Cache mount lives outside any layer.

Examining Layers

docker history myimage:tag --no-trunc
# Shows each layer's command and size

docker save myimage:tag | tar -xv -C /tmp/extract
# Image tarball contains layer tarballs; useful for forensics

# Or use dive (third-party):
dive myimage:tag    # interactive layer explorer

Performance Implications

PatternCost
Reading a file from a deep layerNegligible (file lookup walks chain)
First write to a fileCoW copy (file-sized I/O burst)
Writing many small filesEach is a small CoW; usually fine
Writing to /proc, /sysNot in overlay; kernel-managed
Database directories in the container FSSlow + ephemeral — use a volume

[!NOTE] Rule: data that changes lives on a volume, not in the container filesystem. The CoW model is optimized for read-mostly workloads; write-heavy paths (DB files, log files, build caches) bypass it via mounts.

Common Mistakes

MistakeFix
One RUN per apt-get install lineCombine into one to avoid intermediate layer bloat
`RUN curl ...RUN tar ...
Writing logs/DB files inside container FSUse -v volume
Believing --squash is a free winIt breaks the cache for everyone downstream
Counting du -sh inside containerReports usage of the whole overlay; not layer sizes

Interview Follow-ups

  • "What's a whiteout file?" — A special marker file overlay uses to represent deletion in a higher layer. Looks like .wh.<filename>.
  • "Why is overlay2 better than aufs?" — Mainline kernel support, simpler design, page-cache sharing between containers using the same lowerdirs.
  • "Why is vfs so slow?" — No CoW. Each new layer is a full copy of the previous. Used only when overlay isn't available (some CI sandboxes).

Q: What is Docker Compose and when would you use it?

Answer:

Docker Compose is a tool for defining and running multi-container applications using a single YAML configuration file (docker-compose.yml or compose.yaml). Instead of running multiple docker run commands with complex flags, you declare everything in one file and spin up the entire stack with a single command.

Without Compose (Painful)

docker network create myapp
docker volume create db_data
docker run -d --name db --network myapp -v db_data:/var/lib/postgresql/data \
    -e POSTGRES_PASSWORD=secret postgres:16
docker run -d --name redis --network myapp redis:7
docker run -d --name api --network myapp -p 3000:3000 \
    -e DATABASE_URL=postgres://db:5432 \
    -e REDIS_URL=redis://redis:6379 myapi

With Compose (Clean)

# compose.yaml
services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://db:5432/mydb
      REDIS_URL: redis://redis:6379
    depends_on:
      - db
      - redis

  db:
    image: postgres:16
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: secret

  redis:
    image: redis:7-alpine

volumes:
  db_data:
# Start everything
docker compose up -d

# View logs
docker compose logs -f api

# Stop and remove everything
docker compose down

# Stop, remove, AND delete volumes (nuclear option)
docker compose down -v

Key Features

  • Automatic networking: All services in a compose.yaml automatically join a shared network and can reach each other by service name.
  • Volume management: Volumes are declared and managed alongside services.
  • Build integration: You can specify build: context directly instead of pre-building images.
  • Profiles: Group services into profiles for conditional startup.
  • Override files: Use compose.override.yaml for environment-specific config.

When to Use Compose

  • Local development: Spin up your full stack (API + DB + cache + queue) in one command.
  • CI/CD: Run integration tests against real services.
  • Single-host production: Small deployments that don't need Kubernetes.

[!NOTE] Docker Compose is not an orchestration tool. It runs containers on a single host. For multi-host orchestration, use Docker Swarm or Kubernetes.

Q: What is the difference between depends_on and health checks in Docker Compose?

Answer:

This is a subtle but extremely important question. depends_on controls startup order but does NOT wait for a service to be ready.

depends_on (Startup Order Only)

By default, depends_on only guarantees that the dependent container has started (i.e., docker run has been called). It does NOT wait for the application inside to be fully initialized and accepting connections.

services:
  api:
    build: ./api
    depends_on:
      - db   # db container STARTS first, but may not be ready yet!
  db:
    image: postgres:16

The Problem: Postgres takes several seconds to initialize. Your API container starts immediately after the Postgres container starts, but the database isn't accepting connections yet. The API crashes with "connection refused."

Health Checks (Readiness Verification)

A health check defines a command that Docker runs periodically to determine if a container is actually healthy (i.e., the application inside is ready).

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

The Solution: depends_on + condition

Combine both to make a service wait until its dependency is truly healthy:

services:
  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy  # Wait until db passes health check
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

Now the api service will not start until the db service passes its health check.

Health Check Conditions

ConditionMeaning
service_startedDefault. Container has started (same as basic depends_on).
service_healthyContainer's health check is passing.
service_completed_successfullyContainer ran and exited with code 0 (for init/migration containers).

[!TIP] The service_completed_successfully condition is perfect for running database migrations before starting the API:

migrate:
  image: myapp
  command: npm run migrate
api:
  depends_on:
    migrate:
      condition: service_completed_successfully

Q: How do you manage Environment Variables and Secrets in Docker?

Answer:

Environment variables are the primary way to configure containerized applications. However, sensitive data (passwords, API keys) requires special handling.

1. Inline Environment Variables

Pass variables directly in docker run:

docker run -e DATABASE_URL=postgres://localhost:5432/mydb myapp

In Compose:

services:
  api:
    environment:
      - DATABASE_URL=postgres://db:5432/mydb
      - NODE_ENV=production

2. .env Files

Store variables in a file and load them:

docker run --env-file .env myapp

In Compose, .env in the project root is automatically loaded for variable substitution:

# .env
POSTGRES_PASSWORD=supersecret
DB_PORT=5432

# compose.yaml
services:
  db:
    image: postgres:16
    ports:
      - "${DB_PORT}:5432"
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

3. env_file directive

Load variables from a specific file into the container's environment:

services:
  api:
    env_file:
      - ./config/.env.production

[!CAUTION] Never commit .env files with real secrets to version control. Add them to .gitignore and .dockerignore.

4. Docker Secrets (Swarm Mode)

For true secret management, Docker Swarm provides encrypted secrets that are mounted as files inside containers (never exposed as environment variables or stored in image layers).

echo "supersecretpassword" | docker secret create db_password -

# Use in a Swarm service
docker service create \
    --secret db_password \
    --name myapp myimage
# Secret is available at /run/secrets/db_password inside the container

In Compose (with Swarm):

services:
  api:
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Security Hierarchy (Least to Most Secure)

  1. ❌ Hardcoded in Dockerfile (ENV PASSWORD=secret) — visible in image layers.
  2. ⚠️ -e flag or environment: in Compose — visible in docker inspect.
  3. --env-file — secrets in a gitignored file, but still visible in docker inspect.
  4. ✅✅ Docker Secrets — encrypted at rest, mounted as tmpfs, not in docker inspect.
  5. ✅✅✅ External vault (HashiCorp Vault, AWS Secrets Manager) — most secure for production.

Q: Why should containers run as a non-root user?

Answer:

By default, the process inside a Docker container runs as root (UID 0). This is a significant security risk because if an attacker breaks out of the container (a container escape), they gain root access to the host machine.

The Risk

# ❌ Default: runs as root
FROM node:20-alpine
WORKDIR /app
COPY . .
CMD ["node", "index.js"]
# Inside the container: whoami → root

If someone exploits a vulnerability in your Node.js app, they have root-level access inside the container. Combined with a kernel exploit, this could mean root on the host.

The Fix: Create and Use a Non-Root User

FROM node:20-alpine
WORKDIR /app

# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Install deps as root (needs permissions)
COPY package*.json ./
RUN npm ci --only=production

# Copy app code
COPY . .

# Change ownership of app files to the non-root user
RUN chown -R appuser:appgroup /app

# Switch to non-root user for all subsequent commands
USER appuser

EXPOSE 3000
CMD ["node", "index.js"]
# Inside the container: whoami → appuser

Other Approaches

1. Use --user at runtime:

docker run --user 1000:1000 myapp

2. Use official base images that already set a non-root user: Many official images (like node) include a pre-created user:

FROM node:20-alpine
USER node  # Built-in non-root user

3. Read-only filesystem:

docker run --read-only --tmpfs /tmp myapp

This prevents any writes to the container filesystem, further limiting attack surface.

Additional Hardening

# Drop all Linux capabilities, add back only what's needed
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myapp

# Prevent privilege escalation
docker run --security-opt no-new-privileges myapp

[!TIP] In Kubernetes, you enforce this via securityContext in the Pod spec:

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  readOnlyRootFilesystem: true

Q: How do you scan Docker images for vulnerabilities and minimize the attack surface?

Answer:

Container image security is a critical production concern. Vulnerabilities in base images, dependencies, or OS packages can be exploited.

1. Image Scanning Tools

Scan images for known CVEs (Common Vulnerabilities and Exposures):

# Docker Scout (built into Docker Desktop)
docker scout cves myimage:latest

# Trivy (open-source, most popular)
trivy image myimage:latest

# Snyk
snyk container test myimage:latest

# Grype (by Anchore)
grype myimage:latest

2. Minimizing the Attack Surface

Use minimal base images:

# ❌ Full OS (~900MB, thousands of packages)
FROM node:20

# ✅ Alpine (~130MB, minimal packages)
FROM node:20-alpine

# ✅✅ Distroless (~20MB, no shell, no package manager)
FROM gcr.io/distroless/nodejs20-debian12

Why Distroless? Distroless images contain only the application runtime. There's no shell (/bin/sh), no package manager, no utilities. If an attacker gets inside the container, they can't run curl, wget, or even ls.

Multi-stage builds (see the Images chapter) are essential for keeping build tools out of production images.

3. Never Use latest Tag

# ❌ Bad: "latest" could change at any time
FROM node:latest

# ✅ Good: Pinned digest for reproducibility
FROM node:20.11-alpine3.18@sha256:abc123...

4. Scan in CI/CD Pipeline

# GitHub Actions example
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myimage:${{ github.sha }}
    severity: CRITICAL,HIGH
    exit-code: 1  # Fail the build if critical vulnerabilities found

5. Don't Store Secrets in Images

# ❌ TERRIBLE: Secret is baked into a layer permanently
COPY .env /app/.env
ENV API_KEY=sk-abc123

# ✅ Pass secrets at runtime
docker run -e API_KEY=$API_KEY myimage

[!CAUTION] Even if you delete a secret in a later Dockerfile layer, it still exists in the earlier layer and can be extracted with docker history or by inspecting the image layers directly.

Checklist

  • Use minimal base images (Alpine, Distroless, or Scratch)
  • Run as non-root user
  • Scan images in CI with Trivy or Scout
  • Pin base image versions (avoid latest)
  • No secrets in image layers
  • Use multi-stage builds
  • Drop unnecessary Linux capabilities
  • Use read-only filesystem where possible

Q: Distroless vs Alpine vs slim — which base image, and why?

Answer:

Choice of base image is the single biggest lever for image size, attack surface, and build complexity. The defaults (ubuntu, python, node) are convenient but huge. Smaller bases pay back in pull times, CVE counts, and supply-chain auditability.

The Spectrum

ubuntu:24.04        ~ 78 MB      full distro, package manager, shell, libc
debian:bookworm     ~ 124 MB     similar
python:3.12         ~ 1 GB       Debian + Python + dev tools
python:3.12-slim    ~ 130 MB     Debian + Python, no dev tools
alpine:3.20         ~ 7 MB       musl libc, busybox, apk
distroless/static   ~ 2 MB       libc only (for static binaries)
distroless/cc       ~ 17 MB      glibc + libssl
distroless/java21   ~ 230 MB     JRE only
scratch             0 bytes      empty — bring your own everything

What "Distroless" Actually Is

Google's distroless images contain only what your application needs to run:

  • A libc (or static linkage and no libc).
  • A few support libraries (libssl, zoneinfo, CA certs).
  • No shell. No package manager. No coreutils. No cat, ls, bash.

Implication: you can't docker exec ... sh. The lack of shell is a feature — many CVEs require an attacker to chain a shell after exploitation.

Multi-Stage Build with Distroless

Go binary:

FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Final image: ~12 MB total. No CVEs in the base. No shell.

Java 21:

FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY . .
RUN ./mvnw -DskipTests package && jar xf target/app.jar

FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=build /src/BOOT-INF/lib /app/lib
COPY --from=build /src/BOOT-INF/classes /app/classes
COPY --from=build /src/META-INF /app/META-INF
USER nonroot
ENTRYPOINT ["java", "-cp", "/app:/app/lib/*", "org.springframework.boot.loader.launch.JarLauncher"]

Distroless vs Alpine

AspectAlpineDistroless
Size~7 MB base, larger with deps2–25 MB
libcmuslglibc (most flavors)
Package managerapknone
Shell/bin/sh (busybox)none
DNS quirksmusl's resolver differs from glibcmatches glibc
CGO / native libsmusl rebuild often neededworks as-is
Image rebuild for security updaterebuild + apk upgraderebuild from upstream distroless

The musl trap: Python wheels, Node native modules, JIT runtimes are usually published for glibc. On Alpine they either fail or fall back to slow pure-Python/JS. If you use Alpine and your build has native deps, expect to compile from source.

Scratch (Empty Image)

The smallest possible base. No libc, no zoneinfo, no CA certs. Works for fully static binaries:

FROM scratch
COPY app /app
COPY ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]

Caveats:

  • TLS: you must copy /etc/ssl/certs/ca-certificates.crt.
  • Timezones: must copy /usr/share/zoneinfo.
  • DNS: musl/glibc-static handle this; pure CGO_DISABLED Go is fine.
  • No /tmp: create it explicitly if your app needs it.

Comparing CVE Surface

Snyk/Trivy scans on a Go web service:

ubuntu:24.04           120 known CVEs in base packages
debian:bookworm-slim    35 CVEs
alpine:3.20              4 CVEs
distroless/static        0 CVEs

CVE-free isn't the same as bug-free, but a smaller base means fewer paths an attacker can exploit if they get RCE.

Debugging Without a Shell

The pain point: you can't kubectl exec -it pod -- sh into a distroless container.

Options:

  1. Build a :debug variant. Distroless ships gcr.io/distroless/static-debian12:debug with busybox + sh.
  2. Ephemeral debug containers (Kubernetes 1.25+):
    kubectl debug -it mypod --image=busybox --target=mycontainer
    
    Attaches a debug sidecar sharing process namespace.
  3. Live debugging from a different container in the same pod, sharing processNamespaceShareProcessNamespace: true.

Choosing for Your Runtime

RuntimeRecommended base
Go (static, CGO_ENABLED=0)distroless/static:nonroot or scratch
Rust (musl static)scratch or alpine
Rust (glibc)distroless/cc
Pythonpython:3.12-slim (distroless Python lacks pip)
Nodenode:20-alpine or gcr.io/distroless/nodejs20-debian12
Javaeclipse-temurin:21-jre-alpine or gcr.io/distroless/java21
Anything FFI-heavydebian:slim over Alpine

Common Mistakes

MistakeFix
Using full python:3.12 in production-slim minimum; better, distroless
Alpine for a Python app with native wheelsUse python:3.12-slim (Debian glibc)
Distroless + dynamic linking (CGO without static flag)Either link statically or use distroless/cc
Single-stage build leaving compiler in imageMulti-stage; final stage from minimal base
Forgetting CA certs in scratchCopy /etc/ssl/certs/ca-certificates.crt

[!NOTE] Smaller images are not only faster to pull — they're cheaper to scan, faster to load into Kubernetes, and reduce the blast radius of any kernel exploit. A 1 GB image is a regression you should justify.

Interview Follow-ups

  • "Why does Alpine make Python apps slow sometimes?" — musl's malloc and DNS resolver differ; native wheels (numpy, cryptography) fall back to pure-Python implementations.
  • "How does distroless get security updates?" — Google rebuilds the base regularly; you rebuild your image to pick up new digests. No in-image apt upgrade.
  • "Can you sign distroless images?" — Yes, with cosign. Google publishes signed manifests.

Q: Non-root container vs rootless Docker — what's the difference?

Answer:

These are two independent security controls people often confuse:

  • Non-root container: the process inside the container runs as a non-zero UID.
  • Rootless Docker: the Docker daemon itself runs as a non-root user.

You can run either, both, or neither. They protect against different threats.

Non-Root Container

FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["node", "server.js"]

Inside the container, the process is app, UID 1000. Not root. So even if your app is exploited:

  • Can't bind ports < 1024 (unless granted CAP_NET_BIND_SERVICE).
  • Can't chmod files it doesn't own.
  • Can't modify /etc/passwd, install packages, etc.

Threat it mitigates: in-container privilege misuse. Doesn't protect against kernel exploits — if you escape the container as app, the host still maps that UID somewhere.

Rootless Docker

The Docker daemon (dockerd) and containerd themselves run as a non-root user using user namespaces. The host sees dockerd as alice, but the daemon manages namespaces that look like UID 0 to processes inside containers.

Install / enable:

dockerd-rootless-setuptool.sh install
systemctl --user start docker
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock

Now docker run alpine id shows uid=0(root) inside the container — but on the host, that process is actually running as alice mapped through /etc/subuid and /etc/subgid.

Threat it mitigates: container escape → root on host. Even with a kernel CVE, an attacker only gets alice-level privileges.

How They Combine

DaemonContainer UIDResult
Rootroot (UID 0)Worst: any escape = host root
Rootnon-rootApp-level mistakes contained; escape = host root
RootlessrootContainer "root" maps to your user on host
Rootlessnon-rootDefense in depth — recommended

--user and USER

docker run --user 1000:1000 myimage          # ad-hoc
USER 1000:1000          # numeric — works without /etc/passwd entry

Numeric form is safer for distroless images that lack /etc/passwd. Kubernetes preference:

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  fsGroup: 1000             # files in mounts owned by this group

runAsNonRoot: true makes the kubelet refuse to start a container whose image declares USER 0 or doesn't declare a USER. Cheap insurance.

Port Binding Below 1024

A non-root container can't bind :80/:443. Three fixes:

  1. Bind to a high port and map: container listens on :8080, host publishes 80:8080. Most common.
  2. Grant the capability: docker run --cap-add NET_BIND_SERVICE (or setcap 'cap_net_bind_service=+ep' on the binary).
  3. Sysctl on Linux lowers the privileged port range to 0 (host-wide; not always advisable).

File Permissions in Volumes

A common headache:

docker run --user 1000 -v $(pwd):/app myimage
# Inside, files owned by host UID 1000 — works if host user is 1000.

If host UID doesn't match container UID, writes fail with EACCES. Two fixes:

  • Match UIDs (--user $(id -u):$(id -g)).
  • --volume named volume + entrypoint that chowns on first start.

Rootless Docker bypasses this because user namespace mapping handles it transparently.

Capabilities

Linux capabilities split root's privileges into ~40 buckets. Docker drops most by default:

Default kept:  CHOWN  DAC_OVERRIDE  FSETID  FOWNER  MKNOD
               NET_RAW  SETGID  SETUID  SETFCAP  SETPCAP
               NET_BIND_SERVICE  SYS_CHROOT  KILL  AUDIT_WRITE
Default dropped: SYS_ADMIN, SYS_PTRACE, NET_ADMIN, ...

Tighten further:

docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myimage

In K8s:

securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]

Other Hardening Flags

docker run \
  --read-only \                        # rootfs read-only; mount tmpfs for writable paths
  --tmpfs /tmp:rw,size=64m \
  --security-opt no-new-privileges \   # blocks setuid escalation
  --pids-limit 200 \                   # fork-bomb mitigation
  --memory 512m --cpus 1 \             # resource caps
  myimage

no-new-privileges is particularly cheap and high-impact — it disables setuid binary escalation inside the container.

Rootless Limitations

  • No --net=host (no privileged port use without setcap on the daemon binary).
  • No overlayfs by default on some kernels — falls back to fuse-overlayfs (slower).
  • Cgroups v2 required for proper resource limits.
  • Docker Compose works, but docker swarm is limited.

Threat Model Comparison

ThreatNon-root containerRootless daemonBoth
App RCE → file tamper inside containerPartial (only files non-root owns)No helpSame
App RCE → spawn local processLimited capabilitiesLimited host blastBest
Container escape via kernel CVEHost rootHost user onlyHost user only
Daemon compromiseHost rootHost user onlyHost user only
Image with malicious entrypointRuns as image's USERSameSame

[!NOTE] The two settings stack. If you're picking only one for a production deployment, non-root container delivers more bang per setup minute. Add rootless when you can — it's the bigger blast-radius reducer.

Interview Follow-ups

  • "What is setuid and how does no-new-privileges help?"setuid binaries gain their owner's privileges on exec. no-new-privileges makes the kernel ignore that — closing a common escalation path post-RCE.
  • "How does K8s runAsNonRoot differ from runAsUser: 1000?"runAsNonRoot rejects images that would run as 0. runAsUser overrides the image's USER directive.
  • "User namespaces gotchas in production?" — File ownership in shared volumes is the most common headache; the UID inside doesn't match outside. K8s fsGroup smooths this for emptyDir/CSI, not hostPath.

Q: How do Health Checks work in Docker?

Answer:

A health check is a command that Docker runs periodically inside a container to determine if the application is healthy and functioning correctly.

Defining Health Checks

In a Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY . .

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:3000/health || exit 1

CMD ["node", "index.js"]

In Docker Compose:

services:
  api:
    build: .
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      start_period: 10s
      retries: 3

Health Check Parameters

ParameterDefaultDescription
interval30sTime between health checks
timeout30sMax time to wait for the check to complete
start_period0sGrace period for the container to initialize
retries3Number of consecutive failures before marking unhealthy

Health Check States

StateMeaning
startingContainer just launched, within start_period
healthyHealth check command exited with code 0
unhealthyHealth check failed retries times consecutively
# Check container health
docker inspect --format='{{.State.Health.Status}}' mycontainer
# Output: healthy

docker ps
# CONTAINER ID   STATUS
# abc123         Up 5 min (healthy)

Common Health Check Commands

# HTTP endpoint check (requires curl in the image)
HEALTHCHECK CMD curl -f http://localhost:3000/health || exit 1

# TCP port check (no curl needed)
HEALTHCHECK CMD nc -z localhost 3000 || exit 1

# PostgreSQL readiness
HEALTHCHECK CMD pg_isready -U postgres || exit 1

# Redis ping
HEALTHCHECK CMD redis-cli ping || exit 1

[!TIP] For Alpine-based images that don't have curl, use wget:

HEALTHCHECK CMD wget --spider -q http://localhost:3000/health || exit 1

Or for images without any HTTP tools, use a simple Node.js script or a compiled binary health checker.

Q: What are Docker Logging Best Practices?

Answer:

Proper logging is essential for debugging, monitoring, and auditing containerized applications.

The Golden Rule: Log to stdout/stderr

Docker captures everything written to the container's stdout and stderr streams. Applications should NOT write logs to files inside the container.

# ❌ Bad: Logs trapped inside the container filesystem
CMD ["node", "index.js", ">>", "/var/log/app.log"]

# ✅ Good: Logs go to stdout (Docker captures them)
CMD ["node", "index.js"]

Why stdout/stderr?

  1. docker logs only shows stdout/stderr output.
  2. Log drivers can only capture stdout/stderr.
  3. Files inside the container are lost when the container is removed.
  4. Centralized logging systems (ELK, Datadog, CloudWatch) integrate with Docker's log drivers, not container files.

Docker Log Drivers

Docker supports pluggable logging drivers that determine where container logs are sent:

# View current log driver
docker info --format '{{.LoggingDriver}}'

# Run a container with a specific driver
docker run --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3 myapp
DriverDestination
json-fileLocal JSON files (default)
syslogSyslog daemon
fluentdFluentd collector
awslogsAWS CloudWatch
gcplogsGoogle Cloud Logging
splunkSplunk HTTP Event Collector
noneDiscard all logs

Log Rotation (Critical!)

The default json-file driver has no size limit. Logs will grow until they fill the disk.

// /etc/docker/daemon.json
{
    "log-driver": "json-file",
    "log-opts": {
        "max-size": "10m",
        "max-file": "3"
    }
}

In Compose:

services:
  api:
    image: myapp
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

[!CAUTION] Forgetting log rotation is one of the most common causes of production outages in Docker environments. A single chatty container can fill up the host's disk in hours.

Useful Commands

# View logs
docker logs mycontainer

# Follow logs (like tail -f)
docker logs -f mycontainer

# Show last 100 lines
docker logs --tail 100 mycontainer

# Show logs since a timestamp
docker logs --since 2024-01-01T00:00:00 mycontainer

Q: When would you use Docker (Compose/Swarm) vs Kubernetes?

Answer:

This is a high-level architecture question that interviewers use to gauge your understanding of container orchestration.

Docker Compose

  • Scope: Single host only.
  • Use case: Local development, CI/CD test environments, small single-server deployments.
  • Complexity: Minimal. A single YAML file.
  • Scaling: docker compose up --scale web=3 (basic, no load balancer).
  • Networking: Automatic service discovery on the same host.

Docker Swarm

  • Scope: Multi-host cluster (built into Docker Engine).
  • Use case: Simple production setups, small teams that want orchestration without Kubernetes complexity.
  • Features: Service discovery, load balancing, rolling updates, secrets management.
  • Scaling: docker service scale web=10 (across multiple nodes).
  • Learning curve: Low (if you know Docker, you know 80% of Swarm).

Kubernetes (K8s)

  • Scope: Multi-host cluster (industry standard for container orchestration).
  • Use case: Large-scale production, microservices, multi-team environments.
  • Features: Everything Swarm has, plus: auto-scaling (HPA/VPA), self-healing, RBAC, custom resource definitions (CRDs), Ingress controllers, service mesh support, advanced scheduling.
  • Scaling: Handles thousands of nodes and hundreds of thousands of pods.
  • Learning curve: Steep. Requires understanding of Pods, Deployments, Services, ConfigMaps, etc.

Comparison Table

FeatureComposeSwarmKubernetes
Multi-host
Auto-scaling✅ (HPA)
Self-healing✅ (basic)✅ (advanced)
Rolling updates
Load balancing✅ (built-in)✅ (Service + Ingress)
SecretsFile-based✅ (encrypted)✅ (encrypted)
Community/EcosystemN/ADecliningDominant
Setup complexityMinutesHoursDays

When to Use What?

  • Compose: You're developing locally or running a small app on a single server.
  • Swarm: You need multi-host orchestration but want something simpler than Kubernetes. (Note: Swarm adoption is declining; most teams go straight to K8s.)
  • Kubernetes: You need production-grade orchestration, auto-scaling, advanced networking, or you're operating at scale.

[!NOTE] In interviews, it's perfectly acceptable to say: "We used Docker Compose for local dev and Kubernetes for production." This shows practical understanding of using the right tool for the right environment.

Q: How do CPU and memory limits work in Docker, and what is exit code 137?

Answer:

Container resource limits are enforced by cgroups, not Docker itself. Misunderstanding them is the most common cause of mysterious container kills, "noisy neighbors," and CPU throttling that doesn't show in top.

Memory Limits

docker run --memory=512m --memory-swap=512m myapp
  • --memory sets the hard cap. Exceed it → kernel OOM-killer fires inside the container's cgroup.
  • --memory-swap is the total of RAM + swap. Set it equal to --memory to disable swap.
  • --memory-reservation is a soft limit; only enforced under host memory pressure.

When the cap is hit:

container process tries malloc → kernel sees cgroup memory.max exceeded
                              → OOM killer scores cgroup processes
                              → SIGKILL the largest
                              → exit code 137 (128 + 9)
                              → Docker shows "OOMKilled: true"

Diagnose:

docker inspect mycontainer | grep -i oom
dmesg | grep -i "killed process"

[!NOTE] The OOM kill is synchronous and uncatchable. Your app gets no chance to flush, drain, or page someone. Plan capacity so OOMs are an anomaly, not a tuning strategy.

CPU Limits — the Two Flavors

Two independent knobs, often confused:

1. CPU shares (relative weight).

docker run --cpu-shares=512 myapp   # default is 1024

Only matters under contention. If the host is idle, a 512-share container can still use 100% CPU. Useful for prioritization, not for hard caps.

2. CPU quota (absolute cap, "CFS bandwidth control").

docker run --cpus=1.5 myapp
# Equivalent to:
# --cpu-period=100000 --cpu-quota=150000

Hard cap. The container's cgroup gets 150 ms of CPU per 100 ms wall-clock window. Once exhausted, all threads in the cgroup are throttled until the next window.

The CFS Throttling Trap

A container limited to 1 CPU running a 16-thread Java app:

  • Each request needs 200 ms CPU spread across threads.
  • All 16 threads run in parallel, burn the 100 ms quota in ~7 ms.
  • Throttled for 93 ms doing nothing.
  • p99 latency tanks even though average CPU usage looks low (~7%!).

Diagnose:

cat /sys/fs/cgroup/cpu.stat
# nr_throttled    <-- non-zero is suspicious
# throttled_usec  <-- time spent throttled

Fixes:

  • Reduce thread count to match --cpus (or set runtime flags: -XX:ActiveProcessorCount, GOMAXPROCS).
  • Increase --cpu-period to give larger windows (rarely needed).
  • Raise the limit. CFS throttling is brutal on bursty workloads.

Memory: JVM/Runtime Awareness

A common failure mode: --memory=2g with a JVM that defaults heap to "25% of physical RAM" — JVM sees the host's 64 GB, sets heap to 16 GB, container OOMs immediately on warm-up.

Modern runtimes (Java 11+, Node, Go) detect cgroup limits if you use cgroups v2 and don't disable container support. Defensive flags:

# JVM
-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport

# Node
NODE_OPTIONS="--max-old-space-size=1536"   # in MB, leave room for off-heap

# Go
GOMEMLIMIT=1500MiB   # Go 1.19+

What the Numbers Actually Mean

Host: 8 cores, 16 GB RAM

docker run --cpus=2 --memory=4g myapp
   ↓
cgroup writes:
  cpu.max:    200000 100000   (2 cores worth per 100 ms)
  memory.max: 4294967296

Inside container:
  nproc                 → 8     (sees host CPUs, not the limit!)
  cat /proc/meminfo     → host total RAM

The container cannot tell it's limited via the usual files — that's why runtimes must read /sys/fs/cgroup/... directly. Tools that don't are why you see ancient bugs about JVM choosing wrong heap sizes.

Decision Table

SymptomLikely causeFix
Exit code 137, OOMKilled: trueHard memory cap exceededRaise --memory or fix leak
Exit code 137, OOMKilled: falseHost OOM killed something, took your container with itRaise host capacity; set memory limits everywhere
Container slow, CPU usage lowCFS throttlingMatch thread/runtime concurrency to --cpus
Container slow during bursts onlyQuota period too shortSame fix, or raise quota
--cpu-shares not enforcingOnly fires under contention; expectedUse --cpus for hard caps

Interview Follow-ups

  • "What's the difference between requests and limits in Kubernetes?" — Requests = scheduler hint and cpu.weight; limits = cpu.max / memory.max. Memory request != memory cap. Limit > request can produce surprises during contention.
  • "Why is OOMKilled different from a regular crash?" — Sent by the kernel based on cgroup accounting; no signal handler will catch it.
  • "Should you set memory limit = request?" — In K8s, yes if you want Guaranteed QoS class. Reduces preemption risk.

Q: What is the PID 1 problem in Docker, and how do you handle SIGTERM correctly?

Answer:

In a container, your process runs as PID 1 — the same special status as init on a normal Linux system. PID 1 carries kernel-enforced responsibilities most programs do not handle, and the result is signals ignored and zombie processes accumulating. This is the "PID 1 problem."

The Two Specific Issues

1. Signal handling is opt-in for PID 1.

The kernel does not deliver signals to PID 1 unless that process has explicitly installed a handler. For every other PID, the default action runs (e.g., SIGTERM → terminate). This means:

docker run --rm myapp
^C
# ...waits for stop timeout (10s default), then SIGKILL

docker stop sends SIGTERM. If your binary doesn't handle it (and you ran it as PID 1), nothing happens. Docker waits, then sends SIGKILL.

2. Zombie reaping.

When a child process exits, it stays as a zombie until its parent calls wait(). Normally init reaps orphans. In a container, your process is init — and if you never call wait() on grandchildren, zombies accumulate until the PID table fills.

When You Hit This

  • Shell-form CMD: CMD node server.jsnode becomes PID 1.
    • Older Node didn't install a SIGTERM handler. Container won't stop gracefully.
  • Wrapper scripts: CMD ["./start.sh"] and start.sh does exec node server.js.
    • exec replaces the shell, so node does become PID 1 — but inherits whatever signal mask the shell set up.
  • Forking servers without proper reaping.

Fix 1: Use exec Form and Handle Signals

# Bad — shell form: actually runs `/bin/sh -c "node server.js"`
# sh becomes PID 1 and doesn't forward SIGTERM.
CMD node server.js

# Good — exec form: node is PID 1 directly.
CMD ["node", "server.js"]

And in the app:

process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
});

Fix 2: Add an Init Process

Docker ships an option:

docker run --init myapp

That injects tini as PID 1. tini forwards signals to your child and reaps zombies. Equivalent in Compose:

services:
  app:
    image: myapp
    init: true

Or bake it in:

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Kubernetes has no --init flag; either bake tini in or write a real PID 1.

Graceful Shutdown in Practice

docker stop / k8s rolling update
        │
        ▼
   SIGTERM → PID 1
        │
        ▼  (your handler runs)
   ┌────────────────────────┐
   │ 1. Stop accepting new  │
   │    connections         │
   │ 2. Drain in-flight     │
   │    requests            │
   │ 3. Flush metrics/logs  │
   │ 4. Close DB pools      │
   │ 5. exit(0)             │
   └────────────────────────┘
        │
        ▼
   After grace period (10s default) → SIGKILL

Configure the grace period:

docker stop --time=30 mycontainer
# Kubernetes
terminationGracePeriodSeconds: 30

Common Mistakes

MistakeResult
CMD npm startnpm forks node, doesn't forward SIGTERM. 10s stalls on every stop.
Catching SIGTERM but not closing keep-alive connectionsPod stuck in Terminating for full grace period
Long-lived requests vs short grace periodMid-flight requests get SIGKILLed
preStop hook for cleanup, but app exits immediately on SIGTERMCleanup races shutdown

Detecting Zombies

Inside container:

ps -ef | awk '$8 == "Z" { print }'
# Or
cat /proc/PID/status | grep State
# State: Z (zombie)

If you see them, your PID 1 isn't reaping. Add --init or tini.

[!NOTE] Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds, then SIGKILL. The endpoints controller removes the pod from the Service concurrently with sending SIGTERM, not before — so you may receive new connections for a brief window after SIGTERM. The standard fix is a preStop sleep (3–5s) before your app starts draining.

Interview Follow-ups

  • "What exit code does SIGKILL produce?" — 137 (128 + 9). SIGTERM-caught-and-clean-exit = 0; uncaught SIGTERM = 143.
  • "Why does docker stop wait 10 seconds?" — Default grace period, configurable via --time or STOPSIGNAL/STOPTIMEOUT.
  • "Difference between --init and tini in the image?" — Functionally similar. --init is convenient but runtime-dependent (some K8s setups don't enable it). Baking tini guarantees behavior.

Q: How do you design good container health checks (liveness vs readiness vs startup)?

Answer:

Health checks decide whether a container gets traffic, gets restarted, or gets killed. Conflating the three kinds — startup, readiness, liveness — is the most common cause of restart loops, traffic served before warmup, and outages masked as transient flaps.

The Three Kinds (Kubernetes Terminology)

KindQuestionWhat happens on fail
startupHas the app finished booting?Wait longer; only after success do liveness/readiness start
readinessShould this pod receive traffic right now?Remove from Service endpoints; pod stays alive
livenessIs the process broken beyond recovery?Kill the pod; restart

Docker (standalone / Compose) only has one: HEALTHCHECK. Cluster orchestrators (K8s, ECS) have all three.

Docker HEALTHCHECK

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD curl -fsS http://localhost:8080/health || exit 1

Flags:

  • --interval: between checks (default 30s).
  • --timeout: max time per check (default 30s).
  • --start-period: grace period for startup; failures here don't count (default 0s).
  • --retries: consecutive failures before unhealthy (default 3).

Compose binds Docker's healthcheck to depends_on:

services:
  db:
    image: postgres
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10
  app:
    image: myapp
    depends_on:
      db:
        condition: service_healthy

service_healthy waits for db to be healthy before starting app. Eliminates the "wait-for-it.sh" pattern.

Why Three Probes in K8s

Spring Boot example:

  • Startup: app takes 30s to load DB schema, warm caches.
  • Ready: must be able to reach DB and Kafka.
  • Live: process must not be deadlocked.

Without a startup probe, you'd set liveness timeout high enough to cover boot (60s) — but then a real deadlock takes 60s to detect. With a startup probe, you split: boot waits at the startup probe, then liveness goes tight (5s timeout).

Kubernetes Example

containers:
- name: app
  image: myapp:1.2
  startupProbe:
    httpGet: { path: /actuator/health/liveness, port: 8080 }
    periodSeconds: 5
    failureThreshold: 30        # 30 × 5s = 150s total grace
  readinessProbe:
    httpGet: { path: /actuator/health/readiness, port: 8080 }
    periodSeconds: 5
    failureThreshold: 3
  livenessProbe:
    httpGet: { path: /actuator/health/liveness, port: 8080 }
    periodSeconds: 10
    failureThreshold: 3
    timeoutSeconds: 2

What Each Endpoint Should Check

Liveness — minimal. Only fails if the process is truly broken.

  • Process responds (the act of replying is the test).
  • Not stuck in an infinite loop / deadlock.
  • Do NOT check downstreams. If the DB is down, killing every replica makes it worse.

Readiness — full check.

  • Can talk to DB, queue, cache.
  • Migrations applied.
  • Warmup done.
  • During shutdown, return failure before the app starts rejecting connections (gives load balancer time to drain).

Startup — same as readiness, but longer grace window.

Spring Boot Built-Ins

management:
  endpoint:
    health:
      probes:
        enabled: true

Endpoints exposed:

  • GET /actuator/health/liveness
  • GET /actuator/health/readiness

ApplicationAvailability API lets your code emit events that change the state:

@Component
class Listener {
    @EventListener
    void onDbDown(DbDownEvent e) {
        AvailabilityChangeEvent.publish(ctx, ReadinessState.REFUSING_TRAFFIC);
    }
}

Choosing Check Type

Check commandWhen
curl localhost:port/healthHTTP services
pg_isready / mongo --eval pingDatastores with built-in probes
nc -z localhost 6379TCP-only services
Custom scriptComposite checks

For non-HTTP containers, a TCP probe is fine — connection accept = container alive.

Graceful Shutdown + Readiness

SIGTERM received
   │
   ▼ readiness probe should start failing  ← critical
   │
   │  ↓ load balancer removes pod from endpoints
   │  ↓ in-flight requests drain
   │
   ▼ stop accepting new connections
   ▼ flush metrics
   ▼ exit(0)

Spring Boot:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Combined with terminationGracePeriodSeconds: 45 in K8s.

Anti-Patterns

PatternWhy bad
Liveness probe checks DBDB outage = restart loop of all pods → makes recovery slower
Same endpoint for liveness + readinessLiveness false-positives on transient downstream issues
Startup probe → liveness with no startup probe → tight livenessPod killed before it finishes booting
failureThreshold: 1 on livenessOne transient blip = restart. Use ≥ 3
Health endpoint requires authProbes can't authenticate; expose unauthenticated or use exec probe
Heavy work in health handler (joins, full diagnostics)Slow handler → probe timeout → false failure

Probes vs Application Layer

In a service mesh (Istio, Linkerd), readiness controls routing. In bare K8s, it controls Service endpoint membership. Either way, readiness affects traffic; liveness affects life.

Compose depends_on Conditions

depends_on:
  db:
    condition: service_healthy        # waits for healthcheck pass
  migrator:
    condition: service_completed_successfully   # waits for one-shot exit 0

The second pattern is excellent for DB migrations: a one-shot migrator service runs flyway migrate, exits 0, then app starts.

Diagnostics

# Check container health
docker ps --format "table {{.Names}}\t{{.Status}}"
# myapp   Up 5 minutes (healthy)

# Last few health probe results
docker inspect --format='{{json .State.Health}}' myapp | jq

# K8s:
kubectl describe pod myapp           # Events show probe failures
kubectl logs myapp                   # See what the app reports

[!NOTE] A good rule: liveness should be the narrowest probe (just "am I responding?"). Readiness should be the fullest (can I actually serve a request end-to-end?). The asymmetry prevents restart loops while still gating traffic correctly.

Interview Follow-ups

  • "What if a backend dependency is intermittent?" — Readiness fails → traffic stops → recovers when it comes back. Liveness never failed → no restart. Exactly the desired behavior.
  • "How do you handle multi-container pods?" — Each container has its own probes. Pod is Ready only when all containers are ready.
  • "exec vs httpGet vs tcpSocket probes?"exec forks a process inside the container (expensive, but useful for CLI-only checks). httpGet is cheapest and most informative. tcpSocket is a minimalist liveness check.