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
| Feature | Container | Virtual Machine |
|---|---|---|
| Isolation level | Process-level (shares host kernel) | Hardware-level (full guest OS) |
| Startup time | Milliseconds | Minutes |
| Size | Megabytes (just the app + deps) | Gigabytes (full OS image) |
| Performance | Near-native (no hypervisor overhead) | Slower (hardware emulation layer) |
| Density | Run hundreds on a single host | Run tens on a single host |
| OS support | Linux containers on Linux host only* | Any OS on any host |
| Security | Weaker 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:
- containerd: A high-level container runtime that manages the full container lifecycle (image transfer, storage, execution).
- 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
| Feature | Image | Container |
|---|---|---|
| State | Immutable (read-only) | Mutable (has a writable layer) |
| Stored as | Layers on disk | Running process + writable layer |
| Created by | docker build or docker pull | docker run or docker create |
| Persistence | Persists until explicitly deleted | Ephemeral by default (data lost on removal) |
| Sharing | Pushed to registries | Cannot 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 commitin production is considered bad practice. Always use aDockerfilefor 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
| Mechanism | Provides | Without it |
|---|---|---|
| Namespaces | Each 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 processes | One 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
--privilegedactually 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
netnamespaces are connected via a virtual bridge (docker0) and pairs ofvethdevices.
Common Misconceptions
| Belief | Reality |
|---|---|
| "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
| Feature | CMD | ENTRYPOINT |
|---|---|---|
| Purpose | Default command/args | Fixed executable |
| Override behavior | Completely replaced by docker run args | Args are appended to it |
| Best for | Default arguments | The main process |
[!TIP] To override
ENTRYPOINTat runtime, you must explicitly use the--entrypointflag: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:
- Auto-extracts compressed archives (
.tar,.tar.gz,.tgz,.bz2,.xz) into the destination directory. - 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
COPYoverADDin almost all cases.
Reasons:
- Predictability:
COPYhas no hidden side effects. WithADD, a developer might not realize their.tar.gzfile will be auto-extracted. If you want the archive as-is (e.g., to extract it manually later),ADDwill silently break your intent. - Security:
ADDfrom a URL does not verify SSL certificates and doesn't support authentication. UseRUN curlorRUN wgetinstead for better control. - Cache invalidation: Remote URL fetches with
ADDcan cause unpredictable cache behavior since Docker cannot know if the remote file has changed.
Rule of Thumb
- Use
COPYfor all local file copies (99% of cases). - Use
ADDonly when you explicitly need tar auto-extraction. - Use
RUN curlorRUN wgetfor 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
- Stage 1 ("builder"): Uses the full
golangimage (800MB+) to compile the Go binary. - Stage 2: Starts fresh from a tiny
alpineimage (5MB) and copies only the compiled binary from the builder stage usingCOPY --from=builder. - 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.devandDockerfile.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→ ~900MBnode:20-alpine→ ~130MBnode: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
| Feature | Syntax | Why |
|---|---|---|
| Parallel stages | (automatic) | Multi-stage with independent stages builds them concurrently |
| Cache mounts | RUN --mount=type=cache,target=/root/.npm npm ci | Persist package caches across builds without bloating image |
| Secret mounts | RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci | Use credentials at build time, never written to a layer |
| SSH mounts | RUN --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-platform | docker buildx build --platform linux/amd64,linux/arm64 | Single 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
| Aspect | Legacy | BuildKit |
|---|---|---|
| Parallel stages | No | Yes |
| Build cache | Per-layer, in-image | Content-addressable, mountable |
| Secrets | Via ARG (leaks) | Native mount type |
| Multi-platform | No | Yes (buildx) |
| Dockerfile features | Frozen | Per-syntax= directive — versioned |
| Output formats | Only image | Image, OCI tarball, local files, registry direct |
Interview Follow-ups
- "Why is my
COPY . .invalidating cache on every build?" — Because the context changed. Use.dockerignoreto exclude irrelevant files; use--mount=type=bindfor 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=plaininstead of the default TTY view; pair withBUILDKIT_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,:v1tags.
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.3to 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
| Situation | Use |
|---|---|
Top of Dockerfile (FROM) | Digest pin |
| Compose / Helm / K8s manifests | Digest pin (image: app@sha256:...) |
| Local dev iteration | Tag is fine |
| Internal "rolling" base images | Tag 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 inspectto 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:
- Pushes by digest (
app@sha256:...) — changes the spec, triggers rollout. - Or uses
imagePullPolicy: Alwaysand bumps a label/annotation.
Digest-by-default makes (1) automatic.
Common Pitfalls
| Pitfall | Fix |
|---|---|
Mixing :latest with retention policies in CI | Always tag with immutable identifier (git SHA) AND push to a moving tag if needed |
Digest pin in FROM, but base image not in registry yet | CI must pull from the same registry the digest was computed against |
| Pinning to a digest that gets garbage-collected | Use 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
COPYlayers. - 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
| Mistake | Fix |
|---|---|
.git in context (10s of MB on big repos) | Add to .dockerignore |
node_modules in context, then RUN npm ci reinstalls it anyway | Always ignore — wastes network + cache |
| Secrets in context, removed in later RUN | Use --mount=type=secret or never put them in context |
.dockerignore not in the same directory as -f Dockerfile in newer Docker | Use <Dockerfile>.dockerignore (file-specific) |
Different .dockerignore semantics from .gitignore for directories | Test 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
.dockerignoreas a security artifact, not just an optimization. A 3-line.dockerignorematching.env*and*.keyprevents a whole class of credential leaks.
Interview Follow-ups
- "Why doesn't Docker just send only what
COPYreferences?" — BecauseCOPYpatterns 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 oneRUNstep; 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=$BUILDPLATFORMon the build stage → runs natively (fast).- Use
GOOS/GOARCHto cross-compile to the target. --platform=$TARGETPLATFORMon 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
| Mistake | Fix |
|---|---|
| Building one arch, pushing, deploying to arm64 cluster | Empty manifest list = "no matching manifest" error |
| QEMU build everything, 30-min CI | Use cross-compile or native runners |
RUN step uses arch-specific download URL | Use $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 upgrade | Re-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--loadis 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
- Created: Container exists but hasn't started yet (
docker create). - Running: The container's main process (PID 1) is actively executing (
docker startordocker run). - Paused: The container's processes are frozen (using
cgroup freezer). Memory state is preserved (docker pause). - Exited: The main process has finished or crashed. The writable layer is still on disk.
- 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 runshould 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+PthenCtrl+Qto 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
| Feature | docker attach | docker exec |
|---|---|---|
| Connects to | PID 1 (main process) | A new, separate process |
| Use case | Viewing main process output | Debugging, running ad-hoc commands |
| Ctrl+C | Stops the container | Only kills the exec'd process |
| Multiple terminals | All see the same PID 1 output | Each 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
| Policy | Behavior |
|---|---|
no | Never 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. |
always | Always restart the container, regardless of exit code. Also restarts when the Docker daemon starts. |
unless-stopped | Same 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:
- You run a container with
--restart always. - You manually
docker stopit. - The Docker daemon restarts (e.g., server reboot).
- Result: The container starts again (because the policy is
always).
With unless-stopped:
- You run a container with
--restart unless-stopped. - You manually
docker stopit. - The Docker daemon restarts.
- 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
| Driver | Scope | DNS | Use Case |
|---|---|---|---|
bridge | Single host | Custom only | Default for standalone containers |
host | Single host | N/A | Max performance, no isolation needed |
overlay | Multi-host | Yes | Swarm/K8s clusters |
none | N/A | N/A | Security, 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
| Feature | EXPOSE | -p / --publish |
|---|---|---|
| Where | Dockerfile | docker run command |
| Purpose | Documentation only | Actually opens/maps the port |
| Effect | None on networking | Creates host↔container port forwarding |
| Required? | No | Yes, 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
| Feature | Default Bridge | Custom Bridge |
|---|---|---|
| DNS resolution by name | ❌ No | ✅ Yes |
| Container isolation | Shared with all default containers | Isolated per network |
Legacy --link needed? | Yes (deprecated) | No |
[!WARNING] The
--linkflag 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
| Feature | Volume | Bind Mount | tmpfs |
|---|---|---|---|
| Stored on | Docker-managed area on disk | Any host path | Host RAM |
| Managed by | Docker CLI (docker volume) | You (host filesystem) | Kernel |
| Portable | Yes (works across environments) | No (depends on host path) | No |
| Performance | Excellent | Excellent | Fastest (RAM) |
| Persists after stop | ✅ Yes | ✅ Yes (on host) | ❌ No |
| Pre-populated | ✅ Yes (from image) | ❌ No (overwrites) | ❌ No |
| Use case | Database storage, app data | Dev hot-reload, config files | Secrets, 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
Strategy 1: Named Volumes (Recommended)
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:
- Find the file in the lowerdirs.
- Copy it up to the upperdir.
- 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
| Driver | Linux requirement | Status |
|---|---|---|
overlay2 | kernel ≥ 4.0 | Default; recommended |
fuse-overlayfs | userspace overlay | Rootless Docker |
btrfs | btrfs filesystem | Niche; snapshots free |
zfs | zfs filesystem | Niche; snapshots free |
devicemapper | block-level | Deprecated |
aufs | aufs-patched kernel | Legacy, mostly Debian/Ubuntu old |
vfs | none | No 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
| Pattern | Cost |
|---|---|
| Reading a file from a deep layer | Negligible (file lookup walks chain) |
| First write to a file | CoW copy (file-sized I/O burst) |
| Writing many small files | Each is a small CoW; usually fine |
Writing to /proc, /sys | Not in overlay; kernel-managed |
| Database directories in the container FS | Slow + 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
| Mistake | Fix |
|---|---|
One RUN per apt-get install line | Combine into one to avoid intermediate layer bloat |
| `RUN curl ... | RUN tar ... |
| Writing logs/DB files inside container FS | Use -v volume |
Believing --squash is a free win | It breaks the cache for everyone downstream |
Counting du -sh inside container | Reports 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
overlay2better thanaufs?" — Mainline kernel support, simpler design, page-cache sharing between containers using the same lowerdirs. - "Why is
vfsso 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.yamlautomatically 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.yamlfor 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
| Condition | Meaning |
|---|---|
service_started | Default. Container has started (same as basic depends_on). |
service_healthy | Container's health check is passing. |
service_completed_successfully | Container ran and exited with code 0 (for init/migration containers). |
[!TIP] The
service_completed_successfullycondition 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
.envfiles with real secrets to version control. Add them to.gitignoreand.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)
- ❌ Hardcoded in Dockerfile (
ENV PASSWORD=secret) — visible in image layers. - ⚠️
-eflag orenvironment:in Compose — visible indocker inspect. - ✅
--env-file— secrets in a gitignored file, but still visible indocker inspect. - ✅✅ Docker Secrets — encrypted at rest, mounted as tmpfs, not in
docker inspect. - ✅✅✅ 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
securityContextin 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 historyor 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
| Aspect | Alpine | Distroless |
|---|---|---|
| Size | ~7 MB base, larger with deps | 2–25 MB |
| libc | musl | glibc (most flavors) |
| Package manager | apk | none |
| Shell | /bin/sh (busybox) | none |
| DNS quirks | musl's resolver differs from glibc | matches glibc |
| CGO / native libs | musl rebuild often needed | works as-is |
| Image rebuild for security update | rebuild + apk upgrade | rebuild 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:
- Build a
:debugvariant. Distroless shipsgcr.io/distroless/static-debian12:debugwith busybox +sh. - Ephemeral debug containers (Kubernetes 1.25+):
Attaches a debug sidecar sharing process namespace.kubectl debug -it mypod --image=busybox --target=mycontainer - Live debugging from a different container in the same pod, sharing
processNamespaceShareProcessNamespace: true.
Choosing for Your Runtime
| Runtime | Recommended base |
|---|---|
| Go (static, CGO_ENABLED=0) | distroless/static:nonroot or scratch |
| Rust (musl static) | scratch or alpine |
| Rust (glibc) | distroless/cc |
| Python | python:3.12-slim (distroless Python lacks pip) |
| Node | node:20-alpine or gcr.io/distroless/nodejs20-debian12 |
| Java | eclipse-temurin:21-jre-alpine or gcr.io/distroless/java21 |
| Anything FFI-heavy | debian:slim over Alpine |
Common Mistakes
| Mistake | Fix |
|---|---|
Using full python:3.12 in production | -slim minimum; better, distroless |
| Alpine for a Python app with native wheels | Use 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 image | Multi-stage; final stage from minimal base |
Forgetting CA certs in scratch | Copy /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
chmodfiles 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
| Daemon | Container UID | Result |
|---|---|---|
| Root | root (UID 0) | Worst: any escape = host root |
| Root | non-root | App-level mistakes contained; escape = host root |
| Rootless | root | Container "root" maps to your user on host |
| Rootless | non-root | Defense 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:
- Bind to a high port and map: container listens on
:8080, host publishes80:8080. Most common. - Grant the capability:
docker run --cap-add NET_BIND_SERVICE(orsetcap 'cap_net_bind_service=+ep'on the binary). - 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)). --volumenamed volume + entrypoint thatchowns 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 withoutsetcapon 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 swarmis limited.
Threat Model Comparison
| Threat | Non-root container | Rootless daemon | Both |
|---|---|---|---|
| App RCE → file tamper inside container | Partial (only files non-root owns) | No help | Same |
| App RCE → spawn local process | Limited capabilities | Limited host blast | Best |
| Container escape via kernel CVE | Host root | Host user only | Host user only |
| Daemon compromise | Host root | Host user only | Host user only |
| Image with malicious entrypoint | Runs as image's USER | Same | Same |
[!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
setuidand how doesno-new-privilegeshelp?" —setuidbinaries gain their owner's privileges on exec.no-new-privilegesmakes the kernel ignore that — closing a common escalation path post-RCE. - "How does K8s
runAsNonRootdiffer fromrunAsUser: 1000?" —runAsNonRootrejects images that would run as 0.runAsUseroverrides 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
fsGroupsmooths 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
| Parameter | Default | Description |
|---|---|---|
interval | 30s | Time between health checks |
timeout | 30s | Max time to wait for the check to complete |
start_period | 0s | Grace period for the container to initialize |
retries | 3 | Number of consecutive failures before marking unhealthy |
Health Check States
| State | Meaning |
|---|---|
starting | Container just launched, within start_period |
healthy | Health check command exited with code 0 |
unhealthy | Health 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, usewget:HEALTHCHECK CMD wget --spider -q http://localhost:3000/health || exit 1Or 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?
docker logsonly shows stdout/stderr output.- Log drivers can only capture stdout/stderr.
- Files inside the container are lost when the container is removed.
- 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
| Driver | Destination |
|---|---|
json-file | Local JSON files (default) |
syslog | Syslog daemon |
fluentd | Fluentd collector |
awslogs | AWS CloudWatch |
gcplogs | Google Cloud Logging |
splunk | Splunk HTTP Event Collector |
none | Discard 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
| Feature | Compose | Swarm | Kubernetes |
|---|---|---|---|
| Multi-host | ❌ | ✅ | ✅ |
| Auto-scaling | ❌ | ❌ | ✅ (HPA) |
| Self-healing | ❌ | ✅ (basic) | ✅ (advanced) |
| Rolling updates | ❌ | ✅ | ✅ |
| Load balancing | ❌ | ✅ (built-in) | ✅ (Service + Ingress) |
| Secrets | File-based | ✅ (encrypted) | ✅ (encrypted) |
| Community/Ecosystem | N/A | Declining | Dominant |
| Setup complexity | Minutes | Hours | Days |
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
--memorysets the hard cap. Exceed it → kernel OOM-killer fires inside the container's cgroup.--memory-swapis the total of RAM + swap. Set it equal to--memoryto disable swap.--memory-reservationis 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-periodto 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
| Symptom | Likely cause | Fix |
|---|---|---|
Exit code 137, OOMKilled: true | Hard memory cap exceeded | Raise --memory or fix leak |
Exit code 137, OOMKilled: false | Host OOM killed something, took your container with it | Raise host capacity; set memory limits everywhere |
| Container slow, CPU usage low | CFS throttling | Match thread/runtime concurrency to --cpus |
| Container slow during bursts only | Quota period too short | Same fix, or raise quota |
--cpu-shares not enforcing | Only fires under contention; expected | Use --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
GuaranteedQoS 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.js—nodebecomes PID 1.- Older Node didn't install a SIGTERM handler. Container won't stop gracefully.
- Wrapper scripts:
CMD ["./start.sh"]andstart.shdoesexec node server.js.execreplaces 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
| Mistake | Result |
|---|---|
CMD npm start | npm forks node, doesn't forward SIGTERM. 10s stalls on every stop. |
| Catching SIGTERM but not closing keep-alive connections | Pod stuck in Terminating for full grace period |
| Long-lived requests vs short grace period | Mid-flight requests get SIGKILLed |
preStop hook for cleanup, but app exits immediately on SIGTERM | Cleanup 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 apreStopsleep (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 stopwait 10 seconds?" — Default grace period, configurable via--timeorSTOPSIGNAL/STOPTIMEOUT. - "Difference between
--initand tini in the image?" — Functionally similar.--initis 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)
| Kind | Question | What happens on fail |
|---|---|---|
| startup | Has the app finished booting? | Wait longer; only after success do liveness/readiness start |
| readiness | Should this pod receive traffic right now? | Remove from Service endpoints; pod stays alive |
| liveness | Is 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 beforeunhealthy(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/livenessGET /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 command | When |
|---|---|
curl localhost:port/health | HTTP services |
pg_isready / mongo --eval ping | Datastores with built-in probes |
nc -z localhost 6379 | TCP-only services |
| Custom script | Composite 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
| Pattern | Why bad |
|---|---|
| Liveness probe checks DB | DB outage = restart loop of all pods → makes recovery slower |
| Same endpoint for liveness + readiness | Liveness false-positives on transient downstream issues |
| Startup probe → liveness with no startup probe → tight liveness | Pod killed before it finishes booting |
failureThreshold: 1 on liveness | One transient blip = restart. Use ≥ 3 |
| Health endpoint requires auth | Probes 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
Readyonly when all containers are ready. - "
execvshttpGetvstcpSocketprobes?" —execforks a process inside the container (expensive, but useful for CLI-only checks).httpGetis cheapest and most informative.tcpSocketis a minimalist liveness check.