Operations 17 min read

How to Shrink Docker Images by Up to 99.99% with Multi‑Stage Builds

This article explains why naïve Docker images can balloon to gigabytes, then walks through multi‑stage builds, language‑specific trimming techniques, and the use of minimal base images such as scratch, showing how to reduce image size from over a gigabyte to just a few megabytes while preserving developer and operations convenience.

Programmer DD
Programmer DD
Programmer DD
How to Shrink Docker Images by Up to 99.99% with Multi‑Stage Builds

For newcomers to containers, the size of a Docker image can be shocking; a simple executable that is only a few megabytes may result in an image larger than 1 GB. This article introduces several tricks to slim images without sacrificing developer or operations convenience, divided into three parts.

Preface

The first part focuses on multi‑stage builds, a crucial step for image reduction. It explains the difference between static and dynamic linking, their impact on image size, and includes a brief introduction to the Alpine image.

The second part discusses language‑specific strategies for Go, Java, Node, Python, Ruby, and Rust, and provides an Alpine‑specific pitfall guide.

The third part covers generic strategies such as using common base images, extracting executables, and reducing layer size, and mentions advanced tools like Bazel, Distroless, DockerSlim, and UPX, noting that they often backfire.

01 Root Cause

Most first‑time users are startled by the image size. For example, a simple hello.c program built with the following Dockerfile produces an image over 1 GB because the entire gcc toolchain is included.

/* hello.c */
int main() {
  puts("Hello, world!");
  return 0;
}
FROM gcc
COPY hello.c .
RUN gcc -o hello hello.c
CMD ["./hello"]

Using an ubuntu image with the compiler yields a ~300 MB image, still far larger than the 20 KB executable.

$ ls -l hello
-rwxr-xr-x 1 root root 16384 Nov 18 14:36 hello

Similarly, a Go hello world built from the golang base results in an 800 MB image, while the compiled binary is only 2 MB.

$ ls -l hello
-rwxr-xr-x 1 root root 2008801 Jan 15 16:41 hello

02 Multi‑Stage Build

Multi‑stage builds dramatically reduce image size by separating the build environment from the runtime image. Each FROM starts a new stage, optionally named with AS.

FROM gcc AS builder
COPY hello.c .
RUN gcc -o hello hello.c
FROM ubuntu
COPY --from=builder hello .
CMD ["./hello"]

This produces a final image of about 64 MB, a 95 % reduction compared to the original 1.1 GB.

Stages can also be referenced by index instead of name, e.g., COPY --from=0 /src/hello ..

Classic Base Images

It is recommended to use classic base images such as CentOS, Debian, Fedora, or Ubuntu for the first stage. Alpine is discouraged due to hidden pitfalls.

Using COPY --from with Absolute Paths

When copying from a previous stage, the path is relative to the root of that stage. Specifying a WORKDIR in the build stage and using an absolute path in the final stage avoids path‑related errors.

FROM golang
WORKDIR /src
COPY hello.go .
RUN go build hello.go
FROM ubuntu
COPY --from=0 /src/hello .
CMD ["./hello"]

03 The Magic of scratch

Using scratch as the final base image can produce an image as small as the binary itself (e.g., 2 MB for the Go hello world). However, scratch lacks a shell, debugging tools, and the standard C library.

Missing Shell

Commands like CMD ./hello fail because Docker tries to run them via /bin/sh. The fix is to use JSON syntax: CMD ["./hello"].

Missing Debug Tools

Utilities such as ls, ps, and ping are absent, making docker exec ineffective. One can use docker cp or network‑namespace tricks, but a lightweight image like busybox or alpine is often a better compromise.

Missing libc

Dynamic linking requires the appropriate C library (e.g., libc.so.6). Without it, programs abort with “no such file or directory”. Solutions include static linking ( -static), copying required libraries identified via ldd, or using a busybox:glibc image (≈5 MB) that provides glibc and basic tools.

Static Linking

$ gcc -o hello hello.c -static

The resulting binary is ~760 KB and runs in a scratch image.

Copying Libraries

$ ldd hello
	linux-vdso.so.1 (0x00007ffdf8acb000)
	libc.so.6 => /usr/lib/libc.so.6 (0x00007ff897ef6000)
	/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007ff8980f7000)

Copying the listed libraries works for simple programs but becomes fragile for complex ones that depend on additional libraries (e.g., DNS via glibc NSS).

Using busybox:glibc

This 5 MB image includes glibc and debugging tools, making it a practical choice for dynamically linked binaries.

Summary

Original build method: 1.14 GB

Multi‑stage with ubuntu: 64.2 MB alpine + static glibc: 6.5 MB alpine + dynamic libs: 5.6 MB scratch + static glibc: 940 KB scratch + static musl libc: 94 KB

The overall reduction reaches 99.99 %.

While scratch yields the smallest images, it is hard to debug; using a minimal base like busybox:glibc offers a better balance.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Dockerimage-optimizationLinuxContainermulti-stage-build
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.