Docker & Containerization for Developers#

Table of Contents#

  1. 1. Containers vs Virtual Machines (Mental Model)
  2. 2. Core Primitives: Images, Containers, Volumes & Registries
  3. 3. Anatomy of a Clean Dockerfile
  4. 4. Production Multi-Stage Builds (Shrink Image Size)
  5. 5. Layer Caching & .dockerignore Optimization
  6. 6. Container Security: Non-Root Users & Secrets
  7. 7. Docker Compose for Local Multi-Service Development
  8. 8. Container Networking & Embedded DNS
  9. 9. PID 1, Signals & Graceful Container Shutdown
  10. 10. Developer Debugging & CLI Toolkit

1. Containers vs Virtual Machines (Mental Model)#

Traditional Virtual Machines (VMs) virtualize physical hardware. Each VM runs a full guest operating system kernel (gigabytes in size, minutes to boot) on top of a hypervisor. In contrast, Docker containers virtualize the operating system. All containers share the host Linux kernel directly, isolated through two Linux kernel primitives:

Under the Hood of a Container

  • Linux Namespaces (Visibility Boundary): Partitions system resources so each container sees its own isolated view of processes (PID namespace), network interfaces (NET namespace), mount points (MNT namespace), and user IDs (USER namespace).
  • cgroups / Control Groups (Resource Boundary): Enforces hardware limits (maximum memory allocation, CPU quotas, disk I/O throughput) so a runaway memory leak in one container cannot starve the rest of the host machine.
  • Union File System (Storage Efficiency): Combines immutable read-only image layers with a thin, ephemeral read-write container layer using copy-on-write (CoW).

Analogy: A Virtual Machine is like a standalone single-family house with its own plumbing, heating, and foundation. A Container is like a private apartment in a high-rise building: it has its own private rooms and locks, but shares the underlying infrastructure (kernel) with zero boot overhead.

2. Core Primitives: Images, Containers, Volumes & Registries#

Software developers interact with four foundational Docker concepts:

  1. Image (The Blueprint): An immutable, read-only snapshot containing your application binaries, runtime, system libraries, and default environment variables. (Analogy: a compiled class).
  2. Container (The Running Process): A live, runnable instance of an image with its own isolated file system and network stack. (Analogy: an instantiated object).
  3. Volume & Bind Mount (Persistent Storage): Containers are ephemeral; when deleted, data written inside them disappears. Volumes and bind mounts map host directories into the container to persist database data or enable live hot-reloading.
  4. Registry (The Distribution Center): A repository service (Docker Hub, GitHub Packages GHCR, AWS ECR) where images are pushed, version-tagged, and pulled across environments.

3. Anatomy of a Clean Dockerfile#

A Dockerfile contains sequential instructions to build an image. Here are the core directives every developer needs to master:

# 1. Base image: Choose official minimal images (Debian slim, Alpine, or Distroless)
FROM node:20-slim

# 2. Working Directory: Sets working directory for all subsequent instructions
WORKDIR /app

# 3. Environment Variables: Available during build and runtime
ENV NODE_ENV=production \
    PORT=3000

# 4. Copying Files: Copy dependency definitions first (for layer caching!)
COPY package*.json ./

# 5. Execute Build Commands: Run dependency installation in a single layer
RUN npm ci --only=production

# 6. Copy Application Source Code
COPY . .

# 7. Documentation: Informs human operators which port the app listens on
EXPOSE 3000

# 8. Entrypoint vs CMD:
# ENTRYPOINT defines the immutable executable
# CMD defines the default arguments (can be overridden on 'docker run')
CMD ["node", "src/server.js"]

CMD vs ENTRYPOINT: Exec Form vs Shell Form

  • Always use the JSON Exec Form: CMD ["node", "server.js"] executes the process directly as PID 1. This ensures Unix signals like SIGTERM are delivered directly to your application for graceful shutdown!
  • Avoid the Shell Form: CMD node server.js spawns /bin/sh -c as PID 1, and your node process runs as a child. The shell ignores SIGTERM, causing Docker to hang for 10 seconds before forcibly killing your container with SIGKILL.

4. Production Multi-Stage Builds (Shrink Image Size)#

In standard single-stage builds, your production container image carries heavy build tools (compilers, npm devDependencies, C++ header files, TypeScript compilers). Multi-Stage Builds solve this by compiling code in a heavy "builder" stage, then copying only the compiled artifacts into a lightweight runtime image.

# ==========================================
# Stage 1: Build & Compile Environment
# ==========================================
FROM node:20 AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build # Compiles TypeScript into /app/dist

# ==========================================
# Stage 2: Minimal Production Runtime
# ==========================================
FROM node:20-slim AS runner

WORKDIR /app
ENV NODE_ENV=production

# Only install production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy ONLY compiled JavaScript output from builder stage
COPY --from=builder /app/dist ./dist

# Create and switch to non-root user
USER node

EXPOSE 3000
CMD ["node", "dist/index.js"]

Result: Image size drops from 1.4 GB down to 120 MB. A smaller image deploys 10x faster, consumes less network bandwidth, and dramatically shrinks your security attack surface.

5. Layer Caching & .dockerignore Optimization#

Docker executes instructions in order and caches each resulting layer. If a layer hasn't changed, Docker reuses the cached layer in seconds. However, as soon as one layer changes, all subsequent layers must be rebuilt from scratch.

The Golden Rule of Dockerfile Ordering

Put instructions that change least frequently at the top; put frequently changing source code at the bottom.

  • Anti-Pattern: COPY . . followed by RUN npm install. Every single code edit invalidates the cache, forcing an expensive 2-minute dependency re-install!
  • Best Practice: COPY package*.json ./RUN npm installTHEN COPY . .. Changing your source code re-builds in under 1 second because the dependencies layer is cached!

Always create a comprehensive .dockerignore file in your project root to prevent copying local bloated files into your build context:

# .dockerignore
node_modules
.git
.gitignore
.env
.env.*
dist
coverage
npm-debug.log
README.md
*.test.js

6. Container Security: Non-Root Users & Secrets#

By default, containers run as root (UID 0). If an attacker finds a Remote Code Execution (RCE) vulnerability in your app or exploits a container breakout, they have root access to the underlying host node. Production containers must always run as an unprivileged user.

# Python / Linux example of adding an unprivileged appuser
FROM python:3.12-slim

# Create dedicated non-root group and user
RUN groupadd -r appgroup && useradd -r -g appgroup -s /sbin/nologin -d /app appuser

WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
# Change ownership to appuser
RUN chown -R appuser:appgroup /app

# Switch to the non-root user!
USER appuser

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Warning Regarding Secrets: Never write ENV DATABASE_PASSWORD="secret" in a Dockerfile! Environment variables declared via ENV are permanently baked into image metadata and readable by anyone who runs docker inspect or docker history. Inject secrets at runtime via Docker Compose or Kubernetes Secrets.

7. Docker Compose for Local Multi-Service Development#

Modern applications depend on databases, Redis caches, and message queues. Installing these directly on your host machine leads to version conflicts. Docker Compose orchestrates multi-container applications locally with full reproducibility:

version: '3.8'

services:
  web:
    build:
      context: .
      target: runner
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://app_user:app_password@db:5432/app_db
      - REDIS_URL=redis://cache:6379
    volumes:
      # Live hot-reloading: bind-mount local directory into container
      - .:/app
      - /app/node_modules # Anonymous volume preserves container-built dependencies
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
      POSTGRES_DB: app_db
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
      interval: 3s
      timeout: 3s
      retries: 5

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata: # Named volume persists database records across container restarts

8. Container Networking & Embedded DNS#

How do containers communicate with each other? Docker Compose creates a private user-defined bridge network automatically. Docker runs an internal Embedded DNS Server at 127.0.0.11:

9. PID 1, Signals & Graceful Container Shutdown#

When you run docker stop <id>, Docker sends SIGTERM to PID 1 inside the container, waits 10 seconds (default), and sends SIGKILL if the process hasn't exited.

// Node.js Express / Fastify Graceful Shutdown Handler
const server = app.listen(process.env.PORT, () => console.log('Server running'));

function gracefulShutdown(signal) {
  console.log(`Received ${signal}. Draining active HTTP connections...`);
  server.close(() => {
    console.log('HTTP server closed. Disconnecting from database...');
    db.pool.end(() => {
      console.log('Database pool closed. Exiting process.');
      process.exit(0);
    });
  });
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

10. Developer Debugging & CLI Toolkit#

The daily diagnostic commands every software developer uses:

# 1. Build and Run
docker build -t my-app:v1.0 .
docker run -d -p 8080:8080 --name my-app --env-file .env my-app:v1.0

# 2. View Realtime Logs
docker logs -f my-app
docker logs --tail 50 my-app

# 3. Interactive Shell inside Running Container
docker exec -it my-app /bin/sh
# (or /bin/bash if installed)

# 4. Inspect Container Resource Utilization (CPU, Memory, Network I/O)
docker stats

# 5. Inspect Layer History and Sizes
docker history my-app:v1.0

# 6. Docker Compose Commands
docker compose up -d           # Start all services in background
docker compose ps              # List running compose services & health status
docker compose logs -f web     # Stream logs for specific service
docker compose down -v         # Stop all services and wipe volumes (fresh state)

# 7. Reclaim Disk Space (Remove unused containers, dangling images, build cache)
docker system prune -af --volumes