Dockerizing Applications
TL;DR
- A Docker image is a stack of read-only layers, one per Dockerfile instruction; Docker caches each layer and reuses it if its inputs haven’t changed.
- Ordering matters: put the least frequently changed instructions (base image, dependency manifests,
npm ci) first, and the most frequently changed (application source) last, to maximize cache hits. npm ci— notnpm install— belongs in Dockerfiles because it installs exact versions frompackage-lock.json, guaranteeing reproducible builds.ENTRYPOINTdefines the fixed executable;CMDsupplies default arguments that CLI args can override. Mixing these up causes confusing override behavior.- Biggest gotcha: wrapping the entrypoint in a shell (
CMD ["sh", "-c", "node server.js"]) makes the shellPID 1, which does not forwardSIGTERM— this breaks graceful shutdown in Kubernetes and adds a 30-secondSIGKILLdelay to every rollout.
Core Concepts
Image Layers and the Build Cache
A Docker image is composed of a series of read-only layers. Each instruction in a Dockerfile (FROM, RUN, COPY, etc.) creates a new layer:
┌───────────────────────────────────────────────┐
│ Container Layer (Read-Write) │ <-- Created when container starts
├───────────────────────────────────────────────┤
│ Layer 4: COPY src/ . │ <-- Created by COPY instruction
├───────────────────────────────────────────────┤
│ Layer 3: RUN npm install │ <-- Created by RUN instruction
├───────────────────────────────────────────────┤
│ Layer 2: COPY package.json . │ <-- Created by COPY instruction
├───────────────────────────────────────────────┤
│ Layer 1: FROM node:18-alpine │ <-- Base Image Layers
└───────────────────────────────────────────────┘
Docker evaluates instructions top to bottom during a build. If a layer’s contents are unchanged, Docker reuses the cached layer (Using cache). If an instruction changes — for example, editing a source file copied by COPY . . — that layer and every subsequent layer are invalidated and rebuilt, even if their own inputs didn’t change.
The resulting best practice: order Dockerfile instructions from least frequently changed to most frequently changed. Copy dependency manifests (package.json, requirements.txt) and run package installation before copying application source code, so source-code edits only invalidate the final layers, not the dependency-install layer.
ENTRYPOINT vs CMD and the Signal-Forwarding Problem
ENTRYPOINT defines the core executable that runs when the container starts; it is not easily overridden by CLI arguments (barring docker run --entrypoint). CMD supplies default arguments passed to ENTRYPOINT — if a user runs docker run image arg1 arg2, those CLI arguments completely override the CMD instruction. The combined pattern uses ENTRYPOINT for the fixed command (e.g. ["git"]) and CMD for the default subcommand (e.g. ["--help"]).
Process management inside the container matters for shutdown behavior. If the application is launched through a wrapper shell (CMD ["sh", "-c", "node server.js"] or a shell script entrypoint), the shell becomes PID 1. Shells do not forward Unix signals like SIGTERM to child processes by default. When Kubernetes attempts to stop the pod, the application never receives SIGTERM and keeps running until Kubernetes force-kills it with SIGKILL after the default 30-second grace period. The fix is to use the exec/JSON form directly (CMD ["node", "server.js"]), which makes the application itself PID 1 and a direct signal recipient, or to use an init process like tini as the entrypoint to handle signal forwarding and zombie reaping.
Reproducible Dependency Installation
npm install can update minor/patch package versions permitted by semver ranges in package.json, producing inconsistent builds across machines and over time. npm ci (clean install) bypasses dependency resolution and installs the exact versions locked in package-lock.json. It is faster (no resolution step), more predictable, and guarantees identical builds across environments — the property that matters most for reproducible container images.
Command / Configuration Reference
# 1. Base image (pin a specific version/SHA, avoid 'latest')
FROM node:18-alpine
# 2. Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# 3. Create app directory
WORKDIR /usr/src/app
# 4. Copy dependency manifests first, to maximize cache reuse
COPY package*.json ./
# 5. Install dependencies with exact locked versions, skip dev deps, clean cache
RUN npm ci --only=production && npm cache clean --force
# 6. Copy application code last (most frequently changed layer)
COPY . .
# 7. Create a non-privileged user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 8. Set ownership of the working directory for the non-root user
RUN chown -R appuser:appgroup /usr/src/app
# 9. Switch execution context to non-root
USER appuser
# 10. Document the exposed application port
EXPOSE 3000
# 11. Use exec/JSON form so the app process itself is PID 1 and receives signals
CMD ["node", "server.js"]
# Build the image and tag it with a version
docker build -t billing-app:v1.0.0 .
# Inspect per-layer size and identify bloat sources
docker history billing-app:v1.0.0
# Run the container with a restart policy and port mapping
docker run -d \
--name billing-running \
-p 3000:3000 \
--restart unless-stopped \
billing-app:v1.0.0
# Confirm the container is not running as root
docker exec billing-running whoami
# Output: appuser
Common Pitfalls
- Pitfall: Copying application source before installing dependencies. Why: Any source-code edit invalidates the
COPYlayer and every layer after it, forcing a full dependency reinstall on every build. Fix: Copy dependency manifests and run install steps before copying the rest of the source tree. - Pitfall: Using
npm installin a Dockerfile instead ofnpm ci. Why:npm installcan resolve to different minor/patch versions across builds depending onpackage.jsonsemver ranges. Fix: Usenpm ci, which installs exact versions frompackage-lock.json. - Pitfall: Launching the application through a shell wrapper (
CMD ["sh", "-c", "..."]or a shell script). Why: The shell becomesPID 1and does not forwardSIGTERMto the application process, delaying shutdown untilSIGKILL. Fix: Use the exec/JSON form directly, or run an init process liketiniasPID 1. - Pitfall: Shipping images that run as
root. Why: Default Docker behavior does not create or switch to a non-root user. Fix: Explicitly create a non-root user/group and setUSERbefore the finalCMD/ENTRYPOINT.
Interview Questions
- What is the difference between
CMDandENTRYPOINT, and how do they interact when both are set? — tests precise understanding of Dockerfile execution semantics. - Why should Dockerfile instructions be ordered with dependency installation before source code copying? — tests understanding of layer caching and invalidation.
- Why does
CMD ["sh", "-c", "node server.js"]break graceful shutdown in Kubernetes? — tests knowledge of PID 1 signal-forwarding behavior. - Why is
npm cipreferred overnpm installfor container builds? — tests understanding of reproducible builds and lockfile semantics. - How would you reduce the attack surface of a production container image? — tests knowledge of non-root users, minimal base images, and dependency pruning.