Let’s talk about making your Docker images smaller. It’s a common goal, and for good reason – smaller images mean faster downloads, quicker deployments, and less storage space used. We’ll dive into two powerful techniques: multi-stage builds and distroless images, and explore how to use them effectively.
Why Bother with Smaller Docker Images?
Before we get into the “how,” let’s quickly cover the “why.” Smaller Docker images aren’t just a nice-to-have; they bring tangible benefits to your development and operations workflow.
Faster Deployments and Rollbacks
When you’re pushing and pulling images, every megabyte counts. Smaller images get to your servers and developer machines much faster. This translates directly into quicker deployment cycles and, crucially, faster rollbacks if something goes wrong. Imagine needing to revert to a previous version of your application – a smaller image makes that transition smooth and speedy.
Reduced Storage Costs
Cloud providers often charge based on storage used. While it might seem like a small difference per image, when you have many services running many versions, those storage costs can add up. Smaller images mean a leaner bill at the end of the month.
Improved Security Posture
This is a big one. The fewer things you have in your Docker image, the fewer potential attack vectors you have. Think about it: if an image only contains your application and its direct dependencies, it’s much harder for an attacker to exploit vulnerabilities in unrelated system libraries or tools that are often bundled into larger base images.
Lower Network Bandwidth Usage
Similar to deployment speed, when your images are smaller, you consume less network bandwidth. This is especially relevant if you’re working in environments with limited or expensive network access.
In the quest for optimizing Docker image sizes, best practices such as utilizing multi-stage builds and adopting distroless images can significantly enhance efficiency and performance. For those interested in exploring innovative approaches to sustainability and efficiency beyond the realm of containerization, a related article discusses how one founder recognized the potential of sustainable energy solutions. You can read more about this inspiring journey in the article available at How One Founder Realized the Potential of Sustainable Energy.
Multi-Stage Builds: The Foundation of Lean Images
Multi-stage builds are arguably the most impactful technique for reducing Docker image sizes. The core idea is simple: use one Dockerfile to build your application and another, leaner Dockerfile to package it for production.
The Problem with Single-Stage Builds
Traditionally, you might have a single Dockerfile that installs build tools, compiles your code, and then copies the compiled artifact into a final runtime image. This often means carrying around a lot of unnecessary baggage.
Including Build Tools in the Final Image
In a typical single-stage build, you’d install compilers (like GCC for C++, Go compiler, Node.js for JavaScript), SDKs, dependency management tools (like Maven, npm, pip), and other development utilities.
These are essential for building your application, but they are absolutely not needed for running it.
They bloat your final image significantly.
Large Base Images for Development
Often, the base image chosen for development might be a full-fledged operating system (like ubuntu or debian). While convenient for development and debugging, these images contain a vast array of packages and utilities that are redundant for a production runtime environment.
How Multi-Stage Builds Work
With multi-stage builds, you define multiple FROM instructions in a single Dockerfile. Each FROM instruction starts a new “stage.” You can then copy artifacts from one stage to another.
Defining Separate Build and Runtime Stages
Here’s a conceptual example of a multi-stage build for a Go application:
“`dockerfile
Stage 1: The builder
FROM golang:1.21-alpine 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: The final runtime image
FROM alpine:latest
WORKDIR /app
COPY –from=builder /app/myapp .
CMD [“./myapp”]
“`
In this example:
- Stage 1 (
builder): Uses agolang:1.21-alpineimage. This image has all the Go tools needed to compile the application. We copy our source code, download dependencies, and build themyappexecutable. Theas builderpart names this stage, which is crucial for referencing it later. - Stage 2 (the final image): Uses a minimal
alpine:latestimage. This is a very small Linux distribution. We then copy only the compiledmyappexecutable from thebuilderstage into this new image. We don’t copy any of the Go compiler or build tools.
Copying Specific Artifacts
The key command here is COPY --from=. This allows you to selectively pick what you need from a previous stage. This is the superpower of multi-stage builds – you only bring over the essentials.
Benefits of Multi-Stage Builds
- Drastically Reduced Image Size: By excluding build tools and dependencies, the final image is significantly smaller.
- Improved Security: Less code means a smaller attack surface.
- Cleaner Dockerfiles: Separating build logic from runtime logic makes Dockerfiles more organized and easier to understand.
- Simplified Build Process: You manage all your build and runtime needs in a single Dockerfile.
Practical Tips for Multi-Stage Builds
- Choose Lean Base Images for Runtime: For your final stage, opt for minimal base images like
alpine,distroless(which we’ll discuss next), or slim variants of official language images (e.g.,python:3.11-slim). - Name Your Stages: Use
ASto give descriptive names to your stages. This makes theCOPY --fromcommand more readable. - Order Your Commands Wisely: Docker caches layers. If you change a
RUNcommand that installs dependencies, all subsequentRUNcommands will be re-executed. Try to group commands that are less likely to change together. For example, download dependencies before copying your application code, so that if only your code changes, the dependency download layer is reused. - Leverage
COPYfor Caching: Copying your application code is often the last step in the build stage. This ensures that if your code changes, only the final build step needs to be re-run, not the entire dependency installation process.
Distroless Images: The Ultimate in Minimalism
Distroless images take the concept of minimalism to the extreme. They are container images that contain only your application and its runtime dependencies. They do not include package managers, shells, or any other standard Linux utilities.
What Exactly is “Distroless”?
The name says it all: “distro-less.” These images are built from scratch or based on extremely minimal bases like scratch, and then only the necessary binaries and libraries are installed. This means no /bin/bash, no apt, no yum, no ls, no vi.
The “Scratch” Image
The scratch image is the most minimal base image possible in Docker. It’s an empty image. Anything you want in your container needs to be explicitly added. Distroless images are often built using scratch as their foundation.
What’s Missing (and Why It’s Good)
- No Shell: You can’t
execinto a distroless container and start a shell. This means attackers can’t easily explore your running container or try to exploit it interactively. - No Package Manager: You can’t install new packages within a distroless container. This prevents accidental or malicious modifications to the running environment.
- No Unnecessary Libraries: Only the libraries that your application binary absolutely needs are included. This dramatically reduces the attack surface.
When to Use Distroless Images
Distroless images are ideal for applications where security and size are paramount, and interactive debugging is not a primary concern.
For Production Workloads
This is where distroless truly shines. Once your application is built and tested, packaging it into a distroless image for deployment significantly hardens your production environment.
When Interactive Debugging is Less Critical
If you heavily rely on docker exec to debug running containers, distroless images will present a challenge. However, there are strategies to mitigate this.
How to Create Distroless Images
Google provides a fantastic set of “distroless” base images. You can use these as your final stage in a multi-stage build.
Using Google’s Distroless Images
Let’s revisit our Go example, but this time using a distroless image:
“`dockerfile
Stage 1: The builder (same as before)
FROM golang:1.21-alpine 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: The distroless runtime image
FROM gcr.io/distroless/static-debian11
WORKDIR /app
COPY –from=builder /app/myapp .
CMD [“./myapp”]
“`
In this scenario, gcr.io/distroless/static-debian11 is a distroless image. It contains just enough to run a statically linked executable. If your application has dynamic dependencies (like C libraries), you would use a different distroless base image (e.g., gcr.io/distroless/base-debian11).
Considerations for Different Languages
- Statically Linked Binaries: Languages like Go and Rust often produce statically linked binaries, which are perfect candidates for
distroless/static. - Dynamically Linked Binaries: For languages like Python or Node.js, you’ll need to ensure all necessary shared libraries are included. Google’s
distroless/baseor language-specific distroless images can help here. - Java: For Java applications, you might use distroless images that contain only the Java Runtime Environment (JRE) and your application JAR.
The Trade-off: Debugging Challenges
The primary drawback of distroless images is the lack of debugging tools.
The debug Sidecar Pattern
One popular workaround is the “debug sidecar” pattern. You run your application in a distroless container, and alongside it, you run a second container (a sidecar) that contains debugging tools. You can then use Docker’s multi-container linking or orchestration tools (like Kubernetes) to inspect the sidecar and potentially debug the application container indirectly.
Building Debuggability into Your Application
Another approach is to build debuggability features into your application itself. This could include:
- Exposing metrics that can be monitored.
- Implementing logging endpoints.
- Adding health check endpoints that provide more detailed status information.
Language-Specific Optimizations
While multi-stage builds and distroless images are universal, many languages offer specific ways to optimize your build process for smaller image sizes.
For Go Applications
- Static Linking: As seen in the examples, building statically linked binaries with
CGO_ENABLED=0andGOOS=linuxis key for usingdistroless/static. This embeds all necessary libraries into the executable itself, making it self-contained. - Alpine Linux for Build Stage: Using
golang:1.21-alpineas your builder image is already a good choice because Alpine Linux is much smaller than its Debian or Ubuntu counterparts.
For Node.js Applications
npm civs.npm install: Usenpm ciin your Dockerfile. It’s designed for automated environments and installs dependencies exactly as specified inpackage-lock.json(ornpm-shrinkwrap.json), which is generally faster and more reliable for CI/CD. Crucially, it deletesnode_modulesbefore installing, ensuring a clean state.- Production Dependencies Only: In your
package.json, ensuredevDependenciesare not installed in the production image. You can achieve this by only runningnpm ci --only=productionin your final stage, or by having separatepackage.jsonfiles for development and production. - Leverage
yarnorpnpm: Some package managers likeyarnandpnpmcan also offer performance and size benefits.
For Python Applications
pip install --no-cache-dir: Use this flag to prevent pip from storing downloaded wheels and packages in a cache within the image.- Multi-stage Builds with Poetry or Pipenv: Tools like Poetry and Pipenv manage dependencies and virtual environments. You can use multi-stage builds to install these tools in the builder stage, install your application dependencies, and then copy only the installed packages and your application code to the final runtime image.
- Using Slim Base Images: Opt for
python:3.11-slimorpython:3.11-alpineinstead of the full Python images. Alpine variants are particularly small but might require recompiling some native extensions.
For Java Applications
- Building JARs or WARs: Ensure your build process creates a compact JAR or WAR file.
- JRE vs. JDK: In your final stage, use a minimal Java Runtime Environment (JRE) instead of a full Java Development Kit (JDK). The JRE is significantly smaller.
- Custom JREs with
jlink: For even greater size reduction, you can usejlinkto create a custom JRE that only includes the modules your application actually uses. This is a more advanced technique but offers significant benefits. - Exploit Cloud Native Buildpacks: Cloud Native Buildpacks can automate much of this optimization for you, including creating optimized JREs.
In the quest for optimizing Docker images, exploring various strategies can lead to significant improvements in efficiency and performance. One insightful resource that complements the discussion on reducing Docker image sizes is an article that delves into the best free drawing software for digital artists in 2023. While seemingly unrelated, the principles of efficiency and resource management in software development can be paralleled with the tools available for creative professionals. You can read more about these tools in this article, which highlights how the right software can enhance productivity, much like how multi-stage builds and distroless images streamline Docker workflows.
Advanced Techniques and Considerations
Beyond multi-stage builds and distroless images, there are other strategies to squeeze out even more space and improve efficiency.
Layer Caching: The Unsung Hero
Docker builds images in layers. Each instruction in your Dockerfile (e.g., RUN, COPY, ADD) creates a new layer. Docker caches these layers. If an instruction hasn’t changed since the last build, Docker reuses the cached layer instead of re-executing the instruction.
Optimizing Instruction Order
As mentioned earlier, the order of your instructions matters greatly for caching.
- Install dependencies early: Commands like
RUN apt-get update && apt-get install -yorRUN go mod downloadshould appear as early as possible in your Dockerfile, but after any necessary file copies for those dependencies. - Copy application code late: Copying your application’s source code should be one of the last steps in your build stage. This way, if only your code changes, Docker can reuse all the preceding build layers.
Using .dockerignore Effectively
A .dockerignore file works like a .gitignore file for Docker builds. It specifies files and directories that should not be sent to the Docker daemon during the build process.
- Exclude Development Tools: Make sure to exclude things like
.gitdirectories,node_modules(if you’re installing them within the container), build artifacts from your local machine, and any temporary files. - Prevent Accidental Inclusion of Large Files: This is crucial for keeping your build context small and preventing the accidental inclusion of large, unnecessary files that could bloat your image.
Optimizing Package Manager Usage
How you use your package manager within the Dockerfile can also impact image size and build times.
Cleaning Up After Installations
After installing packages, especially with tools like apt or yum, it’s good practice to clean up any cache or temporary files that were created.
aptexample:
“`dockerfile
RUN apt-get update && apt-get install -y \
some-package \
&& rm -rf /var/lib/apt/lists/*
“`
The rm -rf /var/lib/apt/lists/* command removes the package lists downloaded by apt-get update, saving space.
yumexample:
“`dockerfile
RUN yum install -y some-package && yum clean all
“`
yum clean all removes cached package data.
Choosing the Right Base Image
The base image is the foundation of your Docker image. A smaller base image generally leads to a smaller final image.
Alpine Linux
Alpine Linux is a popular choice for a small, secure Linux distribution. Its base image is typically only a few megabytes. However, it uses musl libc instead of glibc, which can sometimes lead to compatibility issues with pre-compiled binaries.
Debian Slim Variants
Official Debian images often have slim variants (e.g., debian:bullseye-slim). These are stripped-down versions of the full Debian images and are a good middle ground between size and compatibility.
Language-Specific Slim Images
As mentioned earlier, many programming language official images offer slim variants (e.g., python:3.11-slim, node:18-slim). These are tailored to be smaller while still providing the necessary runtime.
Putting It All Together: A Practical Workflow
Here’s a recommended workflow for reducing your Docker image sizes:
- Start with a Multi-Stage Build: This is your primary tool. Always aim to separate your build environment from your runtime environment.
- Choose a Lean Runtime Base Image: For your final stage, pick
alpine, adebian-slimvariant, or ideally, a distroless image if your application is compatible. - Leverage
.dockerignore: Configure your.dockerignorefile to exclude anything unnecessary from the build context. - Optimize Package Manager Usage: Clean up caches after package installations.
- Language-Specific Optimizations: Apply best practices for your specific programming language.
- Test and Measure: After implementing these changes, always build your image and check its size. Compare it to your previous images to see the impact. Use tools like
docker historyto inspect the layers and understand where the size is coming from. - Consider Distroless for Production: For production deployments, strongly consider migrating to distroless images to enhance security and further reduce size. Plan for potential debugging challenges and implement strategies like sidecars or application-level diagnostics.
By consistently applying these practices, you’ll find yourself building smaller, faster, and more secure Docker images, which will benefit your entire development and deployment pipeline. It might seem like a bit of extra effort upfront, but the long-term gains are well worth it.
FAQs
What are multi-stage builds in Docker?
Multi-stage builds in Docker allow you to create smaller and more efficient Docker images by using multiple build stages within a single Dockerfile. This helps to reduce the size of the final image by only including the necessary dependencies and files.
What are the best practices for reducing Docker image sizes?
Some best practices for reducing Docker image sizes include using multi-stage builds to separate the build environment from the runtime environment, minimizing the number of layers in the image, and using Distroless base images to create minimal and secure images.
What are Distroless images in Docker?
Distroless images in Docker are minimalistic base images that contain only the necessary components to run an application, without including any package manager or shell. These images are designed to be lightweight, secure, and focused solely on running the application.
How do multi-stage builds help in reducing Docker image sizes?
Multi-stage builds help in reducing Docker image sizes by allowing you to separate the build environment, which may include build tools and dependencies, from the runtime environment. This means that the final image only includes the necessary files and dependencies for running the application, resulting in a smaller image size.
Why is reducing Docker image sizes important?
Reducing Docker image sizes is important for optimizing resource utilization, improving deployment times, and enhancing security. Smaller images require less storage space, can be deployed faster, and reduce the attack surface by minimizing the number of potentially vulnerable components.

