Stackvora.tech

Reduce Docker Image Size With Multistage Builds

Optimizing Docker image size is critical for efficient containerized deployments. Smaller images lead to faster build times, quicker pulls across…

Optimizing Docker image size is critical for efficient containerized deployments. Smaller images lead to faster build times, quicker pulls across networks, reduced storage consumption, and a smaller attack surface due to fewer installed dependencies. Multistage builds are a powerful Docker feature introduced in Docker 17.05 (API version 1.29) that allow you to dramatically reduce the final image size by separating build-time dependencies from runtime requirements.

This article details the practical implementation of multistage builds, providing specific examples for common application stacks and outlining additional strategies for image size reduction. We'll cover techniques applicable to various build environments, ensuring your Docker images are as lean and secure as possible.

Understanding the Core Concept of Multistage Builds

Before multistage builds, developers often had two choices: create a large image containing all build tools and runtime dependencies, or maintain separate Dockerfile.build and Dockerfile.run files, complicating the CI/CD pipeline. Multistage builds solve this by allowing multiple FROM statements in a single Dockerfile. Each FROM instruction starts a new build stage, and files can be copied from previous stages using the COPY --from=<stage_name_or_index> instruction.

The key benefit is that only artifacts explicitly copied from a previous stage are included in the final image. All intermediate build tools, compilers, development libraries, and temporary files from earlier stages are discarded, never making it into the final layer.

Illustrative Example: Node.js Application

Consider a typical Node.js application that needs to be built (e.g., frontend frameworks like React/Angular/Vue, or TypeScript compilation) and then run. A monolithic Dockerfile might look like this:

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

This approach results in a large image because it includes development dependencies (e.g., devDependencies from package.json) and the build tools themselves (like Webpack, Babel, TypeScript compiler) that are not needed at runtime. A multistage build significantly improves this:

# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production --ignore-scripts --cache /tmp/.npm \
    && npm cache clean --force \
    && npm install --prefix /app/build --no-optional \
    && mv node_modules /app/build/node_modules # Install production dependencies in a separate dir
COPY . .
RUN npm run build # Assuming 'npm run build' compiles to /app/dist

# Stage 2: Runner
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/build/node_modules ./node_modules
COPY --from=builder /app/dist ./dist # Copy built assets
COPY --from=builder /app/package.json ./package.json # Copy package.json for start script
EXPOSE 3000
CMD ["node", "dist/server.js"] # Adjust based on your actual entry point

In this example:

  • The builder stage uses node:20-alpine (a slim builder) and performs npm install (including dev dependencies) and npm run build.
  • The runner stage also uses node:20-alpine, but only copies the essential node_modules (production dependencies only) and the compiled application artifacts (/app/dist) from the builder stage.
  • The alias AS builder and AS runner makes the COPY --from instruction more readable than using stage indices (e.g., COPY --from=0).

Choosing the Right Base Images

The choice of base image for each stage greatly influences the final image size and security. While a "fat" image like node:20 might be convenient for the builder stage due to pre-installed tools, the runtime image should be as minimal as possible.

  • Builder Stage: Can use a more feature-rich image (e.g., python:3.9-slim-buster, openjdk:17-jdk, golang:1.21). These images often contain compilers, build tools, and common development utilities.
  • Runner Stage: Prioritize minimal images. Options include:
    • *-alpine: e.g., node:20-alpine, python:3.9-alpine. Alpine Linux is extremely small and uses musl libc, which can sometimes cause compatibility issues with certain compiled binaries (e.g., Python packages with C extensions).
    • *-slim / *-slim-buster / *-slim-bullseye: Debian-based images (e.g., python:3.9-slim-buster) are larger than Alpine but generally more compatible. They remove non-essential components.
    • Distroless Images (gcr.io/distroless/...): These are ultra-minimal images from Google that contain only your application and its direct runtime dependencies (e.g., gcr.io/distroless/static-debian11 for static binaries, gcr.io/distroless/nodejs18-debian11 for Node.js apps). They significantly reduce attack surface as they lack shell, package managers, and many standard Linux utilities, which can complicate debugging.

Example: Go Application with Distroless

Go applications compile into static binaries, making them ideal for distroless images.

# Stage 1: Builder
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 -a -installsuffix cgo -o main .

# Stage 2: Runner (Distroless)
FROM gcr.io/distroless/static-debian11
WORKDIR /app
COPY --from=builder /app/main .
EXPOSE 8080
ENTRYPOINT ["/app/main"]

This results in an extremely small and secure image, often just a few MBs.

Advanced Image Reduction Techniques

1. Consolidate RUN Commands and Clean Up

Each RUN command creates a new layer in your Docker image. Intermediate files generated by one command that are then cleaned up by a subsequent command in a separate RUN instruction will still be present in the previous layer, bloating the image. Combine related commands using && and ensure cleanup happens in the same layer.

# BAD example (multiple layers for apt, unnecessary bloat)
RUN apt-get update
RUN apt-get install -y some-package
RUN apt-get clean && rm -rf /var/lib/apt/lists/*

# GOOD example (single layer, cleans up in the same step)
RUN apt-get update && \
    apt-get install -y --no-install-recommends some-package && \
    rm -rf /var/lib/apt/lists/*

The --no-install-recommends flag for apt-get install is crucial to avoid installing packages that are not strictly dependencies.

2. Leverage .dockerignore

Similar to .gitignore, a .dockerignore file prevents unnecessary files (e.g., .git/, node_modules/ if installed inside the container, build artifacts, temporary files) from being sent to the Docker daemon during the build context. This speeds up builds and prevents sensitive information or large, irrelevant files from being copied into intermediate layers.

# .dockerignore example
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.env
dist # If dist is generated by local build before docker build

3. Minimize Layers

While multistage builds help, minimizing layers within a stage is still good practice. Combine RUN commands and COPY instructions where logical. However, balance this with Docker's layer caching; if a step frequently changes, keeping it in its own layer might be beneficial for cache hit rates in earlier, stable layers.

4. Install Production Dependencies Only

For interpreted languages like Node.js or Python, explicitly install only production dependencies in the final stage.

  • Node.js: npm ci --only=production or yarn install --production.
  • Python: Use pip install --no-dev -r requirements.txt if your requirements.txt separates dev dependencies, or generate a requirements.prod.txt.

5. Optimize COPY Instructions

Copy files in order from least frequently changed to most frequently changed. This maximizes Docker's build cache. For example, copy package.json and then RUN npm install before copying the rest of your application code. If only your code changes, Docker won't re-run npm install.

Scanning for Vulnerabilities and Bloat

After building, it's crucial to scan your images for vulnerabilities and identify potential sources of bloat. This ties directly into the "fewer CVEs" benefit of smaller images.

  • Docker Scout: docker scout cves my-app:latest. Integrates directly with Docker Desktop and CLI to provide vulnerability scanning, image build analysis, and supply chain insights. Provides detailed information on CVEs found, including fix versions.
  • Trivy: An open-source, comprehensive scanner for vulnerabilities in OS packages, application dependencies, IaC, and more.
    docker pull aquasec/trivy:0.48.0 # Pull the scanner image
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
        aquasec/trivy:0.48.0 image --format table --vuln-type os,library my-app:latest

    This command scans your my-app:latest image. --vuln-type os,library specifies scanning for OS-level and programming language library vulnerabilities.

  • Hadolint: A Dockerfile linter that helps enforce best practices, which often contribute to smaller and more secure images. Can be integrated into CI pipelines.

Common Pitfalls and Troubleshooting

  • Missing Runtime Dependencies: The most common issue. You've stripped down the image too much and omitted a shared library or environment variable needed by your application. Check application logs for errors like "command not found" or "shared library not found". Use ldd /path/to/binary in the builder stage to list dynamic dependencies.
  • Incorrect Paths in COPY --from: Double-check the exact paths of artifacts in the source stage. Case sensitivity and typos are frequent culprits.
  • Permissions Issues: Files copied from one stage might retain user/group IDs from the source stage. If your runner stage uses a non-root user (good practice!), ensure copied files are accessible or owned by that user. Use COPY --chown=<user>:<group> ....
  • Cached Layers Hiding Bloat: If you modify an instruction far down in your Dockerfile, Docker might reuse cached layers from earlier steps, potentially masking suboptimal practices (e.g., large files copied in an early layer) that would be obvious in a full, uncached build. Force a no-cache build occasionally: docker build --no-cache ..
  • Alpine Compatibility: While small, Alpine Linux uses musl libc, which can sometimes cause issues with glibc-dependent binaries. If you encounter segfaults or unexpected behavior with C-extension heavy packages (e.g., some Python packages, custom compiled C++ code), consider switching to a *-slim Debian-based image for the runner.

Back to the knowledge base · Ask the AI assistant