Skip to content

Advanced Image Optimization

This guide covers the next level of optimization. These techniques are used to build production-grade images that are exceptionally minimal, secure, and compatible with diverse hardware.

We will cover:

  1. "Distroless" Images: The philosophy of shipping nothing but your application.

  2. docker buildx: The modern tool for building multi-architecture images (e.g., for amd64 and arm64).

  3. The squash debate: An old technique and its modern, superior alternative.

1. The "Distroless" Philosophy (Maximum Security)

Problem: Even a minimal alpine or slim image (as recommended in File 10) still contains a package manager (apk, apt), a shell (/bin/sh), and other utilities (ls, cat, wget). In a production container, these are not just unnecessary—they are attack vectors.

Solution: "Distroless" images. Pioneered by Google, these are base images that contain only your application, its runtime dependencies, and the bare minimum of OS components (like glibc, SSL certs, and a timezone file).

They contain no package manager, no shell, and no utilities.

The Security Benefit

This is the ultimate expression of the "minimal attack surface" principle. If an attacker gains remote code execution in your application, they land in an empty container:

  • They can't run ls or cat to explore the filesystem.

  • They can't run /bin/sh to get an interactive shell.

  • They can't use apk or apt to install malicious tools (netcat, nmap).

  • They can't use wget or curl to download an exploit payload.

Practical Example: Go (static binary)

This is the easiest use case. Go compiles to a single, static binary.

# --- Stage 1: Builder ---
FROM golang:1.21-alpine AS builder

WORKDIR /src
COPY . .
# Build a static, self-contained binary
RUN CGO_ENABLED=0 go build -o /app .

# --- Stage 2: Production ---
# 'static' is the smallest distroless base, for static binaries
FROM gcr.io/distroless/static-debian11

# Copy the *only* file we need
COPY --from=builder /app .

# Set the user (from File 10)
USER 65532:65532

# Set the entrypoint
ENTRYPOINT ["/app"]

The final image might be <10MB, versus 300MB+ for a standard Go image.

Practical Example: Python/Node.js (interpreted)

For interpreted languages, you use a distroless base that includes the runtime:

# --- Stage 1: Builder ---
FROM python:3.10-slim AS builder

WORKDIR /app
COPY requirements.txt .
# Install dependencies into a specific folder
RUN pip install --target=/app/deps -r requirements.txt
COPY . .

# --- Stage 2: Production ---
# Use the distroless base image for Python 3.10
FROM gcr.io/distroless/python3-debian11

WORKDIR /app
# Copy the dependencies from the builder stage
COPY --from=builder /app/deps /app/deps
# Copy the application code
COPY . .

# Add the non-root user
USER 65532:65532

# Set the PYTHONPATH to include our dependencies
ENV PYTHONPATH=/app/deps
# Set the entrypoint to be the python binary
ENTRYPOINT ["python3", "app.py"]

The gcr.io/distroless/java17-debian11 and gcr.io/distroless/nodejs18-debian11 images work similarly.

2. docker buildx (Multi-Architecture Builds)

Problem: We now live in a multi-architecture world.

  • Developers use Apple M1/M2/M3 chips (which are linux/arm64).

  • Production Servers often run on Intel/AMD (which are linux/amd64).

  • Cloud Providers (like AWS Graviton) offer high-performance linux/arm64 servers.

If you build an image on your arm64 Mac, it will not run on your amd64 production server without slow emulation.

Solution: Use docker buildx to build a manifest list. This is a single image tag (e.g., my-app:latest) that points to multiple, architecture-specific images. When a user pulls my-app:latest, their Docker client automatically picks the correct image for their hardware.

Practical Workflow

docker buildx is the modern build engine (and is included in Docker Desktop and recent Docker Engine installs).

Step 1: Create and use a new "builder" This is a one-time setup. A new builder is needed to enable the multi-architecture capabilities.

docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap

Step 2: Build and push When building for multiple architectures, you cannot store the result as a single local image. You must build and push to a registry simultaneously.

# This command:
# 1. Builds for both AMD64 and ARM64
# 2. Tags the result as 'my-username/my-app:latest'
# 3. Pushes the manifest list and both images to the registry

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t my-username/my-app:latest \
  --push \
  .

Your registry will now host both versions, and all users can pull the same tag.

3. To Squash or Not to Squash?

Problem: Dockerfiles can end up with many layers from RUN, COPY, etc. In the past, developers tried to "flatten" these layers to reduce image size.

The Old Solution: docker build --squash This was an experimental feature that would "squash" all filesystem layers created during the build into a single new layer.

Why This is a Bad Idea (Usually): This is a brute-force approach that destroys layer caching.

  • Without Squash: You change a line of source code. Your COPY . . layer (File 07) is rebuilt. Your base image and dependency layers are pulled from the cache. The download is tiny.

  • With Squash: You change a line of source code. The entire squashed image is rebuilt. This includes your application, all its dependencies, and the base image, all in one giant, uncached layer. The download is massive, and builds are slow.

The Modern Solution: Use Multistage Builds Multistage builds solve the same problem (a small final image) in a smarter way.

  • They allow you to have many layers with lots of build tools and caches in your builder stage.

  • They result in a final image with only the layers you explicitly COPY --from=builder.

  • You get the benefit of a tiny, minimal production image and the benefit of a fast, cached build process.

Conclusion: Do not use --squash. Use a multistage build.