No history yet

Optimizing Docker Images

Beyond the Basics of `docker build`

You already know how to build a Docker image. Now, let's focus on building a good Docker image. A well-crafted image is lean, builds quickly, and presents a minimal attack surface. The key is mastering the Dockerfile itself, turning it from a simple script into a precise blueprint for an optimized, production-ready container.

It’s really important to craft your Dockerfile well to keep the resulting image secure, small, quick to build, and quick to update.

Three core principles guide this process: reducing the final image size, speeding up the build process, and minimizing security vulnerabilities. We'll achieve this by strategically choosing what goes into our image and in what order.

The Multi-Stage Build

One of the most powerful features for creating lean images is the multi-stage build. This technique allows you to use one container environment for compiling or building your application, and a completely separate, clean environment for running it. You get all the tools you need to build, without shipping them in your final product.

Imagine building a Go application. The Go toolchain is necessary to compile the source code into a binary, but you don't need the compiler or the source code to actually run the binary. A single-stage build would bundle everything together.

# Dockerfile.bad (Single Stage)

# Use a base image with the Go SDK
FROM golang:1.22

# Set the working directory
WORKDIR /app

# Copy the source code into the image
COPY . .

# Build the Go application
RUN go build -o myapp .

# Set the command to run the application
CMD ["./myapp"]

This image works, but it's huge. It contains the entire Go SDK, all your source code, and any intermediate build artifacts. The final image could easily be over 800MB.

Now, let's use the with a multi-stage build to isolate the build environment.

# Dockerfile.good (Multi-Stage)

# Stage 1: The Build Environment
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .

# Stage 2: The Production Environment
FROM scratch
WORKDIR /app

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

# Set the command to run the application
CMD ["./myapp"]

This Dockerfile defines two stages. The first, named builder, does all the heavy lifting of compiling the code. The second stage starts from scratch, an empty base image, and copies only the compiled myapp binary from the builder stage. The resulting image contains just one file—the executable—and can be as small as a few megabytes. All the build tools and source code are left behind.

Choosing Your Base

The FROM instruction in your Dockerfile sets the foundation for everything that follows. The base image you choose has a significant impact on size and security. A full operating system like Ubuntu is familiar and packed with tools, but it's also large and has a wider attack surface. For containers, less is often more.

Base ImageTypical SizeIncluded ToolsSecurity Footprint
ubuntu>100 MBFull shell, package manager, many common utilitiesLarge
alpine~5 MBMinimal shell (ash), apk package managerSmall
distroless~2 MBNo shell, no package manager, no utilitiesMinimal

Alpine is a popular choice for its tiny footprint. However, it uses instead of the more common glibc, which can cause compatibility issues with some software, particularly in the Python and Java ecosystems.

images, championed by Google, take minimalism a step further. They contain only your application and its runtime dependencies, without a shell or package manager. This makes them incredibly secure and small, but can complicate debugging since you can't easily exec into the container to poke around.

Mastering the Build Cache

Docker builds images in layers, and each instruction in a Dockerfile creates a new layer. Docker is smart about this; if an instruction and its context haven't changed since the last build, it reuses the existing layer from its cache. This is what makes subsequent builds so fast. You can master this by ordering your Dockerfile instructions strategically.

Place the instructions that change least often at the top, and those that change most often at the bottom. For a typical application, dependencies change less frequently than the source code itself.

# Inefficient Dockerfile: Bad for caching
FROM node:20-alpine
WORKDIR /app

# 1. Copies all source code
COPY . . 

# 2. Installs dependencies. Cache is busted on ANY code change.
RUN npm install --production

CMD [ "node", "server.js" ]

In the example above, any change to any file, even a README, will invalidate the COPY layer. Because the RUN npm install layer comes after it, its cache is also busted, forcing npm to run again every single time you build. This can be very slow.

# Efficient Dockerfile: Good for caching
FROM node:20-alpine
WORKDIR /app

# 1. Copy only the dependency file first
COPY package.json package-lock.json* ./

# 2. Install dependencies. This layer is only rebuilt when dependencies change.
RUN npm install --production

# 3. Now, copy the rest of the source code.
COPY . .

CMD [ "node", "server.js" ]

This version is much more efficient. It copies only the package.json file and installs the dependencies. This layer will only be rebuilt if the package.json file itself changes. Since source code changes far more often, the slow npm install step will be pulled from the cache most of the time, making your builds significantly faster.

To keep your image clean and your build context small, always use a .dockerignore file. It works just like .gitignore, preventing files and directories from being sent to the Docker daemon. This is crucial for excluding things like node_modules, build artifacts, .git directories, and local secrets.

A .dockerignore file prevents local development clutter from ever touching your final image, keeping it lean and secure.

Finally, use LABEL instructions to add metadata to your images. This helps with organization and automation. You can include information like a maintainer's email, a version number, or a link to the source code repository.

LABEL maintainer="you@example.com"
LABEL version="1.0"
LABEL description="This is my awesome application."

Now you're ready to test your knowledge on building better Docker images.

Quiz Questions 1/6

What is the primary advantage of using a multi-stage build in a Dockerfile?

Quiz Questions 2/6

To best leverage Docker's layer cache for a Node.js application, which order of instructions is most efficient?

By applying these techniques, you can create Docker images that are not just functional, but truly optimized for production environments.