LearnKubernetes
Kubernetes1. Containerization Basics·INTERMEDIATE·6 min read

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 — not npm install — belongs in Dockerfiles because it installs exact versions from package-lock.json, guaranteeing reproducible builds.
  • ENTRYPOINT defines the fixed executable; CMD supplies 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 shell PID 1, which does not forward SIGTERM — this breaks graceful shutdown in Kubernetes and adds a 30-second SIGKILL delay 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 COPY layer 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 install in a Dockerfile instead of npm ci. Why: npm install can resolve to different minor/patch versions across builds depending on package.json semver ranges. Fix: Use npm ci, which installs exact versions from package-lock.json.
  • Pitfall: Launching the application through a shell wrapper (CMD ["sh", "-c", "..."] or a shell script). Why: The shell becomes PID 1 and does not forward SIGTERM to the application process, delaying shutdown until SIGKILL. Fix: Use the exec/JSON form directly, or run an init process like tini as PID 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 set USER before the final CMD/ENTRYPOINT.

Interview Questions

  • What is the difference between CMD and ENTRYPOINT, 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 ci preferred over npm install for 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.
🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
Knowledge check
5 scenario questions on this topic
Take the quiz →
Related in 1. Containerization Basics
Docker Fundamentals
INTERMEDIATE
Multi-stage Docker Builds
EXPERT