Day 9: Layering and Caching in Docker Images

Learning Objectives: By the end of this lesson, you will be able to:

  • Understand how Docker image layers work and why they matter
  • Optimize Dockerfiles to maximize build cache efficiency
  • Analyze image layers using Docker tools and commands
  • Apply advanced caching strategies for faster builds and smaller images

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Have you ever noticed that sometimes Docker builds are lightning-fast, while other times they seem to take forever? The secret lies in understanding Docker’s layering and caching system. Today, we’ll unlock this mystery and learn how to make your builds consistently fast and efficient.

Lesson Overview

  • Duration: 50-60 minutes
  • Prerequisites: Completion of Day 8 (Dockerfile Basics), understanding of basic Docker commands
  • Materials Needed: Docker installed, text editor, sample applications from previous lessons

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. Docker Layer Architecture - How layers are created and stored
  2. Build Cache Mechanics - How Docker decides what to rebuild
  3. Cache Optimization Strategies - Writing cache-friendly Dockerfiles
  4. Layer Analysis Tools - Inspecting and understanding your images
  5. Advanced Techniques - Multi-stage builds and cache mounts

Success Criteria:

By the end of this session, you should be able to write Dockerfiles that build 5-10x faster through effective caching and produce smaller, more efficient images.

3. Foundation Knowledge (15-20 minutes)

Understanding Docker Layers

Every instruction in a Dockerfile creates a new layer in the resulting image. These layers are like transparent sheets stacked on top of each other - each adding something new while preserving everything below.

Real-World Analogy: Think of Docker layers like layers in a cake. Each layer (sponge, cream, frosting) adds to the final product. If you need to change the frosting, you don’t remake the entire cake - you just replace the top layer. Similarly, Docker only rebuilds layers that have changed.

Key Layer Concepts:

  • Immutable: Once created, layers never change
  • Shareable: Multiple images can share the same layers
  • Cacheable: Unchanged layers are reused from cache
  • Hierarchical: Each layer builds upon the previous ones

How Docker Build Cache Works

Docker uses a sophisticated caching mechanism to speed up builds:

  1. Cache Key Calculation: Docker creates a unique identifier for each layer based on:

    • The instruction itself (RUN, COPY, etc.)
    • The contents of files being copied
    • The previous layer’s cache key
  2. Cache Lookup: Before executing an instruction, Docker checks if a layer with the same cache key already exists

  3. Cache Hit/Miss:

    • Hit: Reuse existing layer (fast)
    • Miss: Execute instruction and create new layer (slow)
  4. Cache Invalidation: Once a layer cache misses, all subsequent layers must be rebuilt

Key Terminology

  • Layer: A read-only filesystem created by a Dockerfile instruction
  • Cache Hit: When Docker finds and reuses an existing layer
  • Cache Miss: When Docker must execute an instruction and create a new layer
  • Cache Invalidation: When changes force rebuilding of layers
  • Build Context: The directory sent to Docker daemon containing files for the build

4. Detailed Explanation and Examples (25-30 minutes)

Layer Creation Rules

Instructions That Create Layers

FROM ubuntu:20.04          # Creates base layer
RUN apt-get update         # Creates new layer
COPY app.js /app/          # Creates new layer
RUN npm install            # Creates new layer

Instructions That Don’t Create Layers

WORKDIR /app              # Metadata only
EXPOSE 3000               # Metadata only
ENV NODE_ENV=production   # Metadata only
LABEL version="1.0"       # Metadata only

Cache Optimization Strategies

1. Order Instructions by Change Frequency

# ❌ Poor caching - source code changes invalidate dependency installation
FROM node:18-alpine
WORKDIR /app
COPY . .                    # Changes frequently
RUN npm install            # Gets invalidated often

# ✅ Good caching - dependencies cached separately from source code
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./      # Changes infrequently
RUN npm install            # Cached until package.json changes
COPY . .                   # Source code changes don't affect dependencies
# ❌ Multiple layers for related operations
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y wget
RUN apt-get clean

# ✅ Single layer for related operations
RUN apt-get update && \
    apt-get install -y curl wget && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

3. Use Specific File Patterns

# ❌ Copies everything, invalidates cache on any file change
COPY . .

# ✅ Copy only what's needed, when it's needed
COPY package*.json ./
RUN npm install
COPY src/ ./src/
COPY public/ ./public/

Advanced Caching Techniques

Multi-Stage Build Optimization

# Build stage
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=dependencies /app/node_modules ./node_modules

Cache Mount (BuildKit Feature)

# Use cache mount for package managers
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

Common Misconceptions

  1. Misconception: “More layers always mean larger images” Reality: Layer count doesn’t directly correlate with image size; content and compression matter more

  2. Misconception: “Cache is always beneficial” Reality: Sometimes clearing cache is necessary for security updates or debugging

  3. Misconception: “All instructions create layers” Reality: Only certain instructions (RUN, COPY, ADD) create filesystem layers

5. Live Coding Demonstration (20-25 minutes)

Example 1: Cache-Inefficient Dockerfile

# This Dockerfile doesn't leverage caching well
FROM node:18-alpine
WORKDIR /app

# This copies everything first - any file change invalidates cache
COPY . .

# Dependencies are reinstalled on every source code change
RUN npm install

# Build happens every time
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

Build Performance:

# First build: ~2 minutes
docker build -t inefficient-app .

# Change one line in source code and rebuild: ~2 minutes again!

Example 2: Cache-Optimized Dockerfile

# This Dockerfile maximizes caching efficiency
FROM node:18-alpine AS dependencies
WORKDIR /app

# Copy dependency files first - these change rarely
COPY package*.json ./

# Install dependencies - cached until package.json changes
RUN npm ci --only=production

# Separate stage for development dependencies
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Final stage with only production assets
FROM node:18-alpine
WORKDIR /app

# Copy built application
COPY --from=builder /app/dist ./dist

# Copy production dependencies
COPY --from=dependencies /app/node_modules ./node_modules

# Copy only necessary runtime files
COPY package.json ./

EXPOSE 3000
CMD ["npm", "start"]

Build Performance:

# First build: ~2 minutes
docker build -t optimized-app .

# Change source code and rebuild: ~30 seconds!
# Only the builder stage and final copy are rebuilt

Example 3: Analyzing Layer Impact

# Let's build an image and analyze its layers
FROM ubuntu:20.04

# Layer 1: System updates
RUN apt-get update && apt-get install -y \
    curl \
    wget \
    git \
    && rm -rf /var/lib/apt/lists/*

# Layer 2: Application directory
WORKDIR /app

# Layer 3: Copy application
COPY app.py requirements.txt ./

# Layer 4: Install Python dependencies
RUN pip install -r requirements.txt

# Layer 5: Copy source code
COPY src/ ./src/

CMD ["python", "app.py"]

Analysis Commands:

# Build the image
docker build -t layer-demo .

# Inspect layers
docker history layer-demo

# See detailed layer information
docker image inspect layer-demo

Debugging Cache Issues

# Build without cache to force rebuild
docker build --no-cache -t myapp .

# Build with cache debugging
docker build --progress=plain -t myapp .

# Check what files changed
docker diff <container-id>

6. Hands-On Practice (30-40 minutes)

Exercise 1: Cache Analysis (10 minutes)

Objective: Understand cache behavior by observing build times

Instructions:

  1. Create the inefficient Dockerfile from the demo
  2. Build it and time the process
  3. Make a small change to source code
  4. Rebuild and observe the time difference
  5. Now optimize the Dockerfile and repeat

Starter Code: Create app.js:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.json({ 
        message: 'Hello from optimized Docker!',
        version: '1.0.0'
    });
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

Create package.json:

{
  "name": "cache-demo",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.0",
    "lodash": "^4.17.21"
  },
  "scripts": {
    "start": "node app.js"
  }
}

Task: Write two Dockerfiles - one cache-inefficient and one optimized.

Exercise 2: Layer Inspection (15 minutes)

Objective: Learn to analyze and understand image layers

Instructions:

  1. Build an image with your optimized Dockerfile
  2. Use docker history to examine layers
  3. Calculate the size impact of each layer
  4. Identify opportunities for further optimization

Commands to use:

# Examine layer history
docker history <image-name>

# Get detailed layer information
docker image inspect <image-name>

# Compare image sizes
docker images

Analysis Questions:

  • Which layers are largest?
  • Which layers change most frequently?
  • How can you reduce the number of layers?

Exercise 3: Multi-Stage Build Optimization (15 minutes)

Objective: Create an efficient multi-stage build for a Python application

Challenge: Build a Python Flask application using multi-stage builds to:

  1. Install build dependencies in one stage
  2. Build/compile the application in another stage
  3. Create a minimal runtime image

Starter Files: Create app.py:

from flask import Flask, jsonify
import requests

app = Flask(__name__)

@app.route('/')
def hello():
    return jsonify({
        'message': 'Hello from Flask!',
        'status': 'optimized'
    })

@app.route('/health')
def health():
    return jsonify({'status': 'healthy'})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

Create requirements.txt:

Flask==2.3.0
requests==2.31.0
gunicorn==21.2.0

Hints:

  • Use python:3.9 for building and python:3.9-slim for runtime
  • Install build dependencies separately from runtime dependencies
  • Use gunicorn for production serving

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Cache never hits despite no changes Cause: Including timestamp or random data in build context Solution: Use .dockerignore to exclude changing files
# Exclude files that change frequently but aren't needed
*.log
.git
.DS_Store
node_modules/.cache
  1. Problem: Dependencies reinstall on every source code change Cause: Copying source code before installing dependencies Solution: Reorder Dockerfile instructions
# ❌ Wrong order
COPY . .
RUN npm install

# ✅ Correct order
COPY package*.json ./
RUN npm install
COPY . .
  1. Problem: Large layers due to inefficient cleanup Cause: Cleaning up in separate RUN instructions Solution: Clean up in the same layer where mess is created
# ❌ Creates large layer
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ Efficient cleanup
RUN apt-get update && \
    apt-get install -y curl && \
    rm -rf /var/lib/apt/lists/*

Debugging Tips

  • Use docker build --progress=plain to see detailed build output
  • Use docker system df to see Docker disk usage
  • Use docker image prune to clean up unused layers
  • Monitor build times and look for sudden increases

Cache Troubleshooting Commands

# Clear all build cache
docker builder prune

# Clear only unused cache
docker builder prune --filter "until=24h"

# Build without cache
docker build --no-cache -t myapp .

# Check cache usage
docker system df

8. Best Practices and Professional Tips (10 minutes)

Industry Standards

Dockerfile Layer Optimization

# ✅ Optimized production Dockerfile
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
    npm cache clean --force

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && \
    npm prune --production

FROM node:18-alpine
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001
WORKDIR /app
COPY --from=dependencies --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --chown=nextjs:nodejs package.json ./
USER nextjs
EXPOSE 3000
CMD ["npm", "start"]

BuildKit Features for Advanced Caching

# syntax=docker/dockerfile:1.4
FROM node:18-alpine
WORKDIR /app

# Use cache mounts for package managers
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    npm ci --only=production

Performance Monitoring

  • Track build times in CI/CD pipelines
  • Monitor image sizes over time
  • Set up alerts for unexpected size increases
  • Use tools like dive to analyze layer efficiency

Next Steps in Your Learning Journey

  • Learn about Docker BuildKit features
  • Explore advanced caching with registry caching
  • Study container image scanning for security
  • Practice with different language ecosystems

9. Summary and Review (5 minutes)

Key Takeaways

  1. Layer order matters significantly - Put frequently changing instructions last
  2. Cache invalidation is cascading - Once a layer changes, all subsequent layers rebuild
  3. Multi-stage builds optimize both cache and size - Use them for complex applications

Quick Review Questions

  1. What happens to cache when you modify package.json in a Node.js application?
  2. Why should you combine related RUN instructions?
  3. How does multi-stage building help with caching?

Answers:

  1. All layers after the package.json COPY instruction will be invalidated and rebuilt
  2. To reduce layer count and enable cleanup in the same layer, reducing final image size
  3. It allows caching of dependencies separately from source code and creates smaller final images

For Instructors

Teaching Tips

  • Use timing examples to make cache benefits tangible
  • Show before/after build times with real numbers
  • Use visual diagrams to explain layer concepts
  • Demonstrate cache invalidation with live examples

Assessment Ideas

  • Build time comparison exercise
  • Layer analysis assignments
  • Dockerfile optimization challenges
  • Real-world application optimization projects

Adaptation Notes

  • For beginners: Focus on basic layer concepts and simple optimizations
  • For advanced students: Introduce BuildKit features and advanced caching strategies
  • Online delivery: Use screen sharing for timing demonstrations
  • Time management: Core concepts need 25-30 minutes, exercises can be extended or shortened