In Chapter 1.1, you discovered what containers are and why they matter — portable, self-contained environments that eliminate "works on my machine." You ran your first container. Now let's open the hood and learn how images are actually built, how layers work under the covers, how containers communicate, and how to craft production-grade images that are small, secure, and fast to rebuild.
If Chapter 1.1 taught you what a prefab apartment is, this chapter teaches you to read the blueprints, optimize construction, and wire the utilities.
1.2.1 Docker Image Anatomy: The Layered Blueprint
Analogy: The Layer Cake
Imagine a layered cake. The dense sponge base at the bottom is the foundation everything rests upon. Above it, a cream filling provides shared infrastructure. Next comes the fruit layer — runtime libraries that give your application flavor. Finally, the decorative icing — your actual application code — changes most frequently as you iterate.
A Docker image is exactly this cake. Each layer is a read-only filesystem slice created by a Dockerfile instruction. The Union File System (OverlayFS on modern Linux) stacks these layers to present a single unified filesystem. The copy-on-write (CoW) mechanism makes this efficient: when a container modifies a file, Docker copies it to a thin writable layer on top rather than altering the immutable base. Multiple containers share the same read-only layers while keeping their changes isolated — just as multiple people can view the same cake recipe but add their own toppings.
Visual Description:
Every Dockerfile instruction creates a new layer. Inspect the layer history of any image:
docker history nginx:latest
You'll see each row representing a layer — apt-get install adding 56 MB while metadata instructions like CMD, EXPOSE, and LABEL add 0 bytes because they only change image metadata, not the filesystem.
⚠️ Common Misconception: "Smaller images always mean fewer layers." False — ten tiny layers can beat five bloated ones. What matters is cumulative unique content and cache shareability.
1.2.2 Dockerfile Optimization: Building a Better Cake
The Golden Rule of Layer Ordering: Place instructions that change frequently toward the bottom of your Dockerfile, and stable instructions toward the top. This maximizes cache hits during rebuilds.
Here's an inefficient Dockerfile:
# INEFFICIENT — 5-minute rebuilds
FROM ubuntu:22.04
COPY . /app # Changes frequently — busts cache every edit!
WORKDIR /app
RUN apt-get update && apt-get install -y python3 python3-pip # Re-runs every time
RUN pip install -r requirements.txt # Re-runs every time
CMD ["python3", "app.py"]
The problem: every app.py change invalidates the COPY layer, forcing apt-get and pip install to rebuild from scratch — a 45-60 second penalty.
Now the optimized version:
# OPTIMIZED — 5-second rebuilds
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY . /app # Only this layer rebuilds on code changes
WORKDIR /app
USER 1000 # Non-root for security
CMD ["python3", "app.py"]
Changing app.py now only rebuilds the final layer — under 5 seconds. The heavy apt-get and pip install layers stay cached.
Multi-Stage Builds: Separating Build and Runtime
Most applications need build-time tools (compilers, headers) that have no place in production. Multi-stage builds compile in a full toolchain, then copy only artifacts into a minimal runtime image.
# Stage 1: Build environment
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp
# Stage 2: Minimal runtime (~50 MB vs ~1 GB)
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/myapp /myapp
USER 65532:65532
ENTRYPOINT ["/myapp"]
Visual Description:
That's a 20x size reduction, and the attack surface shrinks proportionally — no shell, no package manager, nothing for an attacker to exploit.
Minimal Base Images: Alpine Linux (~5 MB, has apk and shell), Distroless (~20 MB, no shell), and Scratch (empty, Go/Rust only).
GKE Note: GKE nodes cache commonly used base images. Google's
distrolessandgcr.ioimages pull faster and incur no egress cost within GCP's network.
🛑 PAUSE & RECALL — 2 minutes
- Why does layer ordering matter for build speed? (Hint: cache invalidation from the Layer Cake analogy)
- In the layer cake, which layer is your application code, and why should it go last?
- How does a multi-stage build achieve a 20x size reduction?
Rate your confidence (0-4).
1.2.3 Docker Networking: How Containers Talk
A container without network access is an apartment with no doors. Docker provides several networking modes.
Visual Description:
Bridge (Default): Containers get internal IPs on the docker0 bridge. They reach each other by IP, not hostname. NAT handles outbound traffic.
Host: The container shares the host's network namespace — fast but no isolation, with potential port collisions.
None: Complete isolation — only loopback. For high-security scenarios.
Custom Bridge: The mode you'll use most. Custom networks provide automatic DNS resolution — containers reach each other by name.
docker network create myapp-net
docker run -d --name web --network myapp-net nginx:alpine
docker run -d --name db --network myapp-net postgres:15
# From inside 'web', this works:
docker exec web ping db
Port Mapping Mechanics: The -p 8080:80 flag creates a NAT rule forwarding host port 8080 to container port 80. Crucially, this is only for external access — other containers on the same custom bridge reach the service directly on its container port with no -p needed.
⚠️ Common Misconception: "You need -p for containers to reach each other." False — -p is only for host-external access. Containers on the same custom bridge communicate directly on internal ports.
🛑 PAUSE & RECALL — 2 minutes
- What's the key feature that a custom bridge network provides that the default bridge lacks?
- Does a web container need
-p 80:80for a database container on the same custom bridge to reach it? - When would you use host networking instead of bridge?
Rate your confidence (0-4).
1.2.4 Docker Compose: Declarative Multi-Container Apps
Managing multiple containers with individual docker run commands is tedious. Docker Compose defines your entire stack in one YAML file.
version: "3.9"
services:
web:
build: .
ports: ["8080:8080"]
environment:
- DB_HOST=db
depends_on: [db]
networks: [myapp]
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret123
volumes: [db_data:/var/lib/postgresql/data]
networks: [myapp]
redis:
image: redis:7-alpine
networks: [myapp]
volumes: { db_data: }
networks: { myapp: }
Compose creates the custom bridge network, assigns DNS names matching service names, and handles startup order via depends_on. One docker compose up brings everything up; docker compose down tears it all down cleanly.
1.2.5 Production Image Workflow: From Laptop to GKE
Tagging Strategy: Use semantic versions for humans and Git SHAs for traceability:
docker build -t us-central1-docker.pkg.dev/proj/repo/app:v1.2.3 .
docker tag us-central1-docker.pkg.dev/proj/repo/app:v1.2.3 \
us-central1-docker.pkg.dev/proj/repo/app:sha-$(git rev-parse --short HEAD)
Cloud Build Automation:
# cloudbuild.yaml
steps:
- name: "gcr.io/cloud-builders/docker"
args: ["build", "-t",
"${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/${_IMAGE}:${COMMIT_SHA}", "."]
- name: "gcr.io/cloud-builders/docker"
args: ["push",
"${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/${_IMAGE}:${COMMIT_SHA}"]
images:
- "${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/${_IMAGE}:${COMMIT_SHA}"
substitutions:
_REGION: us-central1
_REPO: my-repo
_IMAGE: myapp
GKE Note: Artifact Registry integrates with Cloud IAM — GKE clusters in the same project pull images with no extra authentication. Enable vulnerability scanning and Binary Authorization to block images with critical CVEs from deploying to production GKE.
GKE in Practice
Image Pull Performance: GKE nodes cache images at the node level. A 50 MB distroless image starts 10-15 seconds faster than a 1 GB image — critical during node failures or autoscaling events.
Artifact Registry: Use the fully qualified path: REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:TAG. GKE clusters pull automatically with workload identity — no imagePullSecrets needed within the same project.
Cloud Build Pipeline: Push code → Cloud Build triggers → multi-stage build → push to Artifact Registry → vulnerability scan → Binary Authorization evaluation → deploy to GKE via kubectl apply or GitOps.
🤔 TRY BEFORE YOU SEE
The Dockerfile Optimization Challenge
Given this poor Dockerfile for a Node.js app, identify three problems and sketch your fix before seeing the solution:
FROM node:18
COPY . /app
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
Think about: Why is COPY . before npm install a problem? What's the security concern with running as root? How much smaller could this be?
Solution:
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Improvements: package*.json copied first keeps node_modules cached; npm ci --only=production is deterministic; multi-stage separates build and runtime; USER node runs non-root; Alpine shrinks ~900 MB → ~170 MB.
Lab: LAB-1.2 — Building Production-Ready Container Images (75 minutes)
Objectives
- Measure Docker layer caching impact on build times
- Build a multi-stage Dockerfile achieving 15-20x size reduction
- Demonstrate DNS resolution on custom bridge networks
- Orchestrate a multi-container stack with Docker Compose
- Automate image builds with Cloud Build + Artifact Registry
Part 1: Layer Caching Exploration (15 min)
Create test files and build with an inefficient Dockerfile:
mkdir ~/docker-lab && cd ~/docker-lab
cat > app.py << 'EOF'
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello(): return "Hello!\n"
EOF
cat > requirements.txt << 'EOF'
flask==3.0.0
EOF
# Dockerfile.slow — INEFFICIENT
FROM python:3.11
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
docker build -f Dockerfile.slow -t slow-build .
echo '# modified' >> app.py
time docker build -f Dockerfile.slow -t slow-build .
# Expected: ~45-90 seconds — pip install re-runs
Now the optimized version:
# Dockerfile.fast — OPTIMIZED
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER 1000
CMD ["python", "app.py"]
git checkout app.py # reset
docker build -f Dockerfile.fast -t fast-build .
echo '# modified' >> app.py
time docker build -f Dockerfile.fast -t fast-build .
# Expected: ~3-8 seconds — only final COPY rebuilds
Part 2: Multi-Stage Build (20 min)
mkdir ~/docker-lab-go && cd ~/docker-lab-go
cat > main.go << 'EOF'
package main
import "fmt"
func main() { fmt.Println("Hello!") }
EOF
cat > go.mod << 'EOF'
module hello
EOF
Build the non-optimized image:
# Dockerfile.single
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o hello
CMD ["./hello"]
docker build -f Dockerfile.single -t go-single .
docker images go-single --format "{{.Size}}"
# Expected: ~1 GB
Now the multi-stage version:
# Dockerfile.multistage
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o hello
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/hello /hello
USER 65532:65532
ENTRYPOINT ["/hello"]
docker build -f Dockerfile.multistage -t go-multi .
docker images go-multi --format "{{.Size}}"
# Expected: ~20-50 MB — 15-20x smaller
docker run --rm go-multi
# Output: "Hello!"
Part 3: Custom Networking with DNS (10 min)
docker network create lab-net
docker run -d --name backend --network lab-net alpine:3.18 \
sh -c "while true; do echo 'hello' | nc -l -p 8080; done"
docker run --rm --network lab-net alpine:3.18 \
sh -c "nslookup backend && echo 'DNS works!'"
docker run --rm --network lab-net alpine:3.18 \
sh -c "echo 'test' | nc backend 8080"
Part 4: Docker Compose Stack (10 min)
# docker-compose.yaml
version: "3.9"
services:
web:
build: .
ports: ["5000:5000"]
environment: [REDIS_HOST=redis]
depends_on: [redis]
networks: [lab]
redis:
image: redis:7-alpine
networks: [lab]
networks: { lab: }
# app.py — uses Redis
def hello():
import os, redis
r = redis.Redis(host=os.environ.get('REDIS_HOST','localhost'))
count = r.incr('visits')
return f"Hello! Visits: {count}\n"
docker compose up --build -d
curl http://localhost:5000/ # "Visits: 1"
curl http://localhost:5000/ # "Visits: 2"
docker compose down
Part 5: Cloud Build + Artifact Registry (20 min)
gcloud services enable artifactregistry.googleapis.com cloudbuild.googleapis.com
gcloud artifacts repositories create my-repo \
--repository-format=docker --location=us-central1
gcloud auth configure-docker us-central1-docker.pkg.dev
# cloudbuild.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t',
'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/flask-app:$COMMIT_SHA', '.']
- name: 'gcr.io/cloud-builders/docker'
args: ['push',
'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/flask-app:$COMMIT_SHA']
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/flask-app:$COMMIT_SHA'
gcloud builds submit --config=cloudbuild.yaml .
gcloud artifacts docker images list us-central1-docker.pkg.dev/$PROJECT_ID/my-repo
Chapter Summary
Docker images are layered, immutable filesystem stacks built through OverlayFS. Layer ordering is the key to fast rebuilds — stable layers (OS, dependencies) high, volatile layers (application code) low. Multi-stage builds separate build tooling from runtime, producing images 15-20x smaller and more secure. Custom bridge networks give containers DNS resolution by name. Docker Compose orchestrates multi-container stacks declaratively. Cloud Build + Artifact Registry form the production CI pipeline you'll use with GKE.
📇 KEY CONCEPT CARDS
- Q: What is a Docker image layer, and what makes it immutable?
A: A layer is a read-only set of filesystem changes produced by a Dockerfile instruction. Once created, it cannot be modified — only new layers can be stacked on top via copy-on-write.
- Q: Why should frequently-changing instructions go at the bottom of a Dockerfile?
A: Docker caches layers and invalidates cache for any changed layer plus all layers below it. Stable layers (OS, package installs) at the top stay cached; volatile layers (source code) at the bottom minimize rebuild scope.
- Q: What's the difference between the default bridge and a custom bridge network?
A: The default bridge requires port mapping (-p) for all external access and provides no DNS between containers. Custom bridge networks provide automatic DNS resolution — containers can reach each other by name — and offer better isolation.
- Q: How does a multi-stage build reduce both image size and attack surface?
A: Multi-stage builds use a full toolchain in a "builder" stage, then copy only compiled artifacts into a minimal runtime stage (distroless, Alpine, or scratch). Build tools, shells, and unnecessary packages are excluded from production, reducing size by 15-20x and eliminating common attack vectors.