aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Tools & DevOps

Docker Image Size Reduction: Multi-Stage & Alpine Guide

Docker image size reduction directly affects both deployment speed and security. A multi-gigabyte image lengthens build times, raises registry costs, slows down deploys, and enlarges the attack surface by shipping packages you never use. The good news: in most projects you can cut image size by 70-80% with a handful of core techniques. In this guide I cover multi-stage builds, alpine-based images, .dockerignore, and layer optimization with practical examples.

Why does image size matter?

You pay for a bloated image at every stage. Large images take longer to push to the registry, make you wait longer in the CI/CD pipeline, and inflate traffic when pulled to production. In autoscaling environments every new pod or container downloads the image from scratch; the gap between 50 MB and 1.2 GB turns into minutes across hundreds of containers.

  • Speed: A smaller image means faster pulls, pushes, and cold starts.
  • Security: Fewer packages mean fewer CVEs and a smaller attack surface.
  • Cost: Registry storage and network traffic scale directly with size.

Choosing the right base image

The easiest win comes from your base image choice. Most official language images are built on a full Debian and include build tools, documentation, and every extension of the language. In most cases all you need is the runtime.

  • alpine: A tiny musl libc-based distribution of ~5 MB. Variants like node:20-alpine or python:3.12-alpine are a fraction of the full version.
  • slim: Variants like python:3.12-slim are Debian-based but stripped of unnecessary packages. A good middle ground if you want to avoid Alpine's musl compatibility risk.
  • distroless: Google's gcr.io/distroless images contain only your app and the runtime; there isn't even a shell. One of the smallest and most secure options for production.

A caveat: because Alpine uses musl libc, it can cause build problems with native extensions that depend on glibc (especially packages with C extensions in Python, such as numpy or pandas). In such cases slim is smoother.

Separate build and runtime with multi-stage builds

The most powerful technique for shrinking images is the multi-stage build. The idea is simple: you compile the app in a "builder" stage with all the build tools, then copy only the produced output into a clean, small final stage. Compilers, dev dependencies, and intermediate files never enter the final image.

A typical example for a Node.js app:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

Here npm ci --omit=dev leaves only production dependencies in the final image, while COPY --from=builder moves only the compiled dist folder. Everything build-specific stays in the first stage and is discarded.

For compiled languages the gain is even bigger. The Go example shows the power of producing a static binary and placing it on a scratch image:

FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]

With this approach a build environment of hundreds of megabytes shrinks to a single binary of a few megabytes.

Optimize layer count and contents

Every RUN, COPY, and ADD command creates a new layer, and data added in one layer remains in the image history even if a later layer deletes it. That's why cleanup must happen inside the same RUN:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

Here --no-install-recommends skips recommended but unnecessary packages, while rm -rf /var/lib/apt/lists/* clears the apt cache in the same layer. If you split these into separate lines, the cache stays in the image.

Another key point for layer caching is command order: copy things that change rarely (dependency manifests) first, and things that change often (source code) later. Putting COPY package*.json ./ before COPY . . ensures npm ci doesn't rerun whenever the code changes.

Exclude the unnecessary with .dockerignore

Every file sent to the build context can leak into the image and slow down the build. The .dockerignore file works like .gitignore and is often one of the fastest wins:

.git
node_modules
dist
*.log
.env
.env.*
coverage
Dockerfile
docker-compose.yml
README.md

Excluding node_modules and .git in particular prevents both a bloated build context and accidental copying of large directories. Excluding .env files is also essential for security.

Measure and analyze the size

While optimizing, measure to see what actually works. The docker images command gives a quick comparison:

docker images myapp --format "{{.Repository}}:{{.Tag}} {{.Size}}"

For deeper analysis the dive tool is invaluable; it shows each layer's size and which files were added, so you can catch the layers that hurt "efficiency." Keeping BuildKit on (DOCKER_BUILDKIT=1, or the default in modern Docker) also enables parallel and more efficient builds. To use the build cache more smartly, you can keep a persistent dependency cache with RUN --mount=type=cache.

Frequently Asked Questions

Is Alpine always the best choice?

No. Alpine is very small, but because it uses musl libc you may hit build issues with some native packages and, rarely, differences in DNS behavior. If you have a Python/Node project heavy on native dependencies, the slim variant is usually smoother and still far smaller than the full image.

Do multi-stage builds slow down CI/CD?

In practice, no. The builder stage is cached and only reruns when dependencies or source change. Thanks to the small resulting image, push and pull times shrink; overall the pipeline usually gets faster.

I shrank the image but it's still large, what else can I do?

Find the biggest layers with dive. The culprit is usually an uncleared apt/npm cache, unnecessary dev dependencies, node_modules leaking into the build context, or large static files being copied. Switching the final stage to distroless or scratch yields an additional win.

Are your images still eating gigabytes? If you need help moving your Dockerfile to multi-stage and speeding up your CI/CD pipeline, get in touch with me.

Bu kategorideki tüm yazılar →

Devamı için