Mastering Docker Multi-Stage Builds 2024

Multi-stage builds in Docker are one of the most powerful features for optimizing Docker images. This approach allows developers to split the build process into different stages, enabling the use of heavyweight dependencies during the build phase, but keeping the final image slim and optimized. This guide will walk you through advanced use cases of multi-stage builds, showing how they can enhance both performance and security.

Why Multi-Stage Builds?

The primary goal of multi-stage builds is to minimize the size of the resulting image by only keeping the necessary components while discarding temporary files and build dependencies. This approach not only results in smaller images but also improves build time, security, and overall maintainability.

Here’s how multi-stage builds can benefit your development process:

  • Reduced image size: Build dependencies and unnecessary files are excluded from the final image.
  • Better security: By excluding build tools and files, you reduce the attack surface.
  • Faster builds: Docker caches intermediate stages, making subsequent builds quicker.
  • Improved clarity: Each stage is clearly defined, improving the readability and maintainability of your Dockerfiles.

Let’s dive into some advanced multi-stage build scenarios.

Scenario 1: Building a Go Application

For many applications, like Go, the final binary can be very small. However, the Go toolchain and dependencies required for building the application are large. Multi-stage builds allow you to use the Go toolchain only during the build stage, then discard it for the final image.

Example: Go Application Build

# Stage 1: Build the Go application
FROM golang:1.19 AS builder
WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Copy the source and build the application
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp

# Stage 2: Create the final image
FROM alpine:latest
WORKDIR /app

# Copy the binary from the builder stage
COPY --from=builder /app/myapp .

# Minimal runtime dependencies
RUN apk --no-cache add ca-certificates

# Set non-root user for security
RUN addgroup -S mygroup && adduser -S myuser -G mygroup
USER myuser

CMD ["./myapp"]

Explanation:

  • Stage 1 (builder): This stage uses the full golang image to build the Go application. We download dependencies and compile the binary.
  • Stage 2: We use an alpine image, which is much smaller. We copy only the necessary binary (myapp) from the previous stage, and we add only minimal dependencies (e.g., ca-certificates).
  • Security: The USER directive ensures the application runs as a non-root user, enhancing security.

Scenario 2: Conditional Builds for Multiple Targets

In some cases, you might want to support both development and production environments using the same Dockerfile. Multi-stage builds enable you to conditionally create images tailored to each environment.

Example: Node.js Application with Development and Production Targets

# Base Stage: Shared Dependencies
FROM node:16 AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install

# Development Stage
FROM base AS dev
COPY . .
RUN npm install --only=dev
CMD ["npm", "run", "dev"]

# Production Stage
FROM base AS prod
COPY . .
RUN npm prune --production
RUN npm run build
CMD ["npm", "start"]

Explanation:

  • Base Stage: The base stage installs the shared dependencies (production and development). This stage is cached, improving build performance.
  • Development Stage: The dev stage installs additional development dependencies and runs the application using the development server.
  • Production Stage: The prod stage installs only production dependencies, prunes unnecessary packages, and runs the build for deployment.

By separating the stages, you can run your application differently for development and production environments, without duplicating code.

Scenario 3: Handling Frontend and Backend in a Single Dockerfile

A full-stack application often consists of both frontend and backend codebases. With multi-stage builds, you can manage these separately and create an image that only contains the final built assets for both components.

Example: Node.js (Backend) + React (Frontend)

# Stage 1: Build the React Frontend
FROM node:16 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm install
COPY frontend/ .
RUN npm run build

# Stage 2: Build the Node.js Backend
FROM node:16 AS backend-builder
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN npm install
COPY backend/ .
RUN npm run build

# Stage 3: Create the Final Image
FROM node:16 AS final
WORKDIR /app
COPY --from=frontend-builder /app/frontend/build ./frontend/build
COPY --from=backend-builder /app/backend .
CMD ["npm", "start"]

Explanation:

  • Frontend Builder: The first stage builds the frontend assets using the React project in the frontend directory.
  • Backend Builder: The second stage compiles the backend code, typically Node.js in this case.
  • Final Stage: The final stage copies only the built frontend and backend code. This approach keeps the final image size small, as none of the source code or build tools are present.

Scenario 4: Slimming Down Python Images

Python applications often come with heavy build dependencies like gcc, make, and development headers. Multi-stage builds allow you to discard these dependencies after the application is built.

Example: Python Application with Dependencies

# Stage 1: Build the Python app with dependencies
FROM python:3.10-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN apt-get update && \
    apt-get install -y --no-install-recommends build-essential && \
    pip install --no-cache-dir -r requirements.txt && \
    apt-get purge -y --auto-remove build-essential && \
    rm -rf /var/lib/apt/lists/*

# Stage 2: Final Image with only runtime dependencies
FROM python:3.10-slim
WORKDIR /app

# Copy installed dependencies from builder
COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
COPY . .

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

Explanation:

  • Builder Stage: We use the first stage to install build dependencies (e.g., gcc, make) and install the Python dependencies from requirements.txt.
  • Final Stage: The final stage copies only the installed Python packages and source code. Since we purged unnecessary tools in the builder stage, the final image remains slim.

Scenario 5: Caching Build Dependencies

Caching dependencies during a build process can significantly reduce build times. Docker’s layer caching mechanism allows you to cache heavy dependencies like npm or pip installations, reducing build times in iterative development.

Example: Caching Dependencies with Go

# Stage 1: Cache Dependencies
FROM golang:1.19 AS base
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

# Stage 2: Build Application
FROM base AS build
COPY . .
RUN go build -o myapp

# Stage 3: Final Image
FROM alpine:latest
WORKDIR /app
COPY --from=build /app/myapp .
CMD ["./myapp"]

Explanation:

  • Base Stage: The base stage caches Go module dependencies by downloading them before copying the source code. This ensures that go mod download doesn’t run again unless the dependencies change, speeding up iterative builds.
  • Build Stage: We compile the application in this stage.
  • Final Stage: The final stage creates the production image, which only contains the binary.

Conclusion

Multi-stage builds in Docker provide immense flexibility and power for creating optimized, secure, and fast container images. By separating the build process into stages, you can selectively keep only the necessary files, significantly reduce the size of your images, and improve the overall efficiency of your CI/CD pipelines.

Advanced use cases, such as conditional builds, handling full-stack applications, and caching strategies, showcase the true potential of multi-stage builds. When implemented thoughtfully, they can drastically improve the performance and maintainability of your Docker-based projects.

Mastering these techniques will lead to better, faster, and more secure Docker images that are tailored to your development and production environments.

Subscribe to SimpleDocker

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe