Performance Optimization: Layer Caching, Multi-Stage Builds & Alpine
This chapter consolidates and deepens the performance-related threads from earlier chapters (Chapter 2's layer caching, Chapter 3's multi-stage builds) into a single, interview-focused treatment, and
This chapter consolidates and deepens the performance-related threads from earlier chapters (Chapter 2's layer caching, Chapter 3's multi-stage builds) into a single, interview-focused treatment, and adds the third major lever: choice of base image, especially Alpine Linux.
The three biggest levers for Docker performance — in both build time and runtime resource efficiency — are: maximizing build cache hits, eliminating unnecessary layers/content via multi-stage builds, and choosing the smallest base image that still meets your actual runtime requirements.
Alpine Linux is popular for its small size (built on musl libc and BusyBox instead of glibc and GNU coreutils), but it is not a free upgrade — musl's subtle behavioral differences from glibc have caused real production issues for some workloads (notably certain DNS resolution and Java-specific JIT/performance characteristics), which is why eclipse-temurin and similar vendors also offer Alpine-specific variants tested against musl rather than assuming any glibc-based app 'just works' on Alpine.
-
Build cache reuse (Chapter 2) is the cheapest performance win available: reordering instructions so rarely-changing steps (dependency installation) precede frequently-changing steps (source code) means most builds only re-execute the last one or two instructions.
-
BuildKit (the default builder since Docker 23+) improves on the legacy builder with parallel execution of independent build stages, more granular cache mount types (--mount=type=cache for persisting package manager caches like ~/.m2 or node_modules without baking them into any layer), and better cache import/export to registries for CI reuse.
-
Multi-stage builds (Chapter 3) eliminate entire categories of content from the final image — compilers, build-time dependencies, test frameworks — rather than just minimizing their footprint within a single stage.
-
Alpine's size advantage comes primarily from musl libc (a much smaller C standard library implementation than glibc) and BusyBox (a single small binary providing minimal implementations of many standard Unix utilities) instead of the full GNU toolchain most other distros ship.
-
musl's differences from glibc aren't purely cosmetic: name resolution behavior (particularly around DNS, historically with the lack of certain glibc-specific behaviors like NSS modules) and some performance characteristics under heavy threading differ in ways that have caused real, hard-to-diagnose production bugs for some applications — this is why due diligence (testing on the target Alpine-based image, not just assuming compatibility) matters more than for a glibc-to-glibc base image swap.
-
Distroless images (Chapter 7) take the size-minimization principle further than Alpine by removing the package manager and shell entirely, trading some operational convenience for an even smaller attack surface and footprint.
-
Audit the existing Dockerfile's instruction order; move COPY of dependency manifests (pom.xml, package.json, requirements.txt) before COPY of full source code if not already structured this way.
-
Convert any single-stage build that includes a compiler/SDK into a multi-stage build, isolating the build toolchain to an intermediate stage never copied into the final image.
-
Evaluate whether the application's current base image (e.g., a full Debian/Ubuntu-based JRE image) can be swapped for an Alpine or distroless equivalent without behavioral regressions.
-
If swapping to Alpine, run the full test suite and any integration tests against the new image specifically — do not assume compatibility based on the application working on a glibc-based image.
-
Adopt BuildKit cache mounts (--mount=type=cache,target=/root/.m2) for package manager caches so dependency downloads aren't repeated on every fresh build environment, independent of image layer caching.
-
Measure before and after: docker images for size, docker history for layer breakdown, and actual CI pipeline duration for build time — performance work should be validated with real numbers, not assumed.
-
Shrinking a Spring Boot service's final image from a full eclipse-temurin:21-jre (Debian-based, around 450MB) to eclipse-temurin:21-jre-alpine (around 200MB) or a distroless variant (around 250MB, no shell), directly reducing pull time on every deployment and node.
-
Speeding up a monorepo's CI pipeline by combining instruction reordering, multi-stage builds, and BuildKit registry caching together — often yielding 5-10x build time improvements for incremental changes.
-
Reducing Kubernetes node disk pressure and image pull latency across a large fleet by standardizing on minimal base images platform-wide, multiplying the per-image savings across hundreds of services.
-
Evaluating Alpine adoption carefully for a Java service after discovering musl-related JVM behavior differences in testing, and choosing eclipse-temurin's specifically-tested Alpine variant rather than a generic alpine plus manually-installed JDK combination.
-
Treat instruction ordering and multi-stage builds as default practice for every new Dockerfile, not an optimization pass applied later — it costs nothing to do correctly from the start.
-
Pilot Alpine/distroless base image swaps on a non-critical service first, with full test suite validation, before rolling the change out platform-wide.
-
Use BuildKit cache mounts for any package manager (npm, pip, Maven, Go modules) to decouple dependency-download caching from Docker layer caching, which is especially valuable on CI runners that don't persist the Docker layer cache between jobs.
-
Measure concretely (image size, build time, CVE count from Chapter 7's scanning) before and after any base image change — claims about Alpine's benefits should be verified against your specific application's behavior.
-
Assuming Alpine is a drop-in replacement for any glibc-based image without testing — musl's behavioral differences are subtle enough to pass casual smoke testing while still causing production issues under specific conditions (DNS resolution edge cases, certain native library interactions).
-
Continuing to COPY the entire build context before installing dependencies out of habit, silently giving up most of the available cache benefit on every single build.
-
Treating multi-stage builds as optional 'cleanup' rather than the default approach for any image that involves a compile/build step.
-
Comparing only final image size when evaluating a base image swap, while ignoring CVE count, compatibility risk, and operational debugging convenience (a shell-less image is harder to interactively debug).
-
Combine all three levers together for compounding effect: well-ordered instructions plus multi-stage builds plus a minimal final base image typically yields both the fastest CI builds and the smallest, fastest-to-pull runtime images simultaneously.
-
For JVM-based services specifically, verify whether your chosen base image's JDK/JRE distribution has been explicitly tested against the C library it ships with (e.g., eclipse-temurin publishes Alpine-specific tags maintained and tested against musl, rather than expecting users to layer a generic JDK onto generic alpine themselves).
-
Use registry-based BuildKit cache export/import (Chapter 10) so the performance benefits of good instruction ordering and multi-stage builds extend across ephemeral CI runners, not just local dev machines.
-
Standardize on a small set of vetted, organization-approved base images (one glibc-based option, one Alpine option, one distroless option) rather than letting every team choose ad hoc, so security patching and compatibility testing effort is shared rather than duplicated per-service.
-
Document any known Alpine/musl-specific gotchas discovered during adoption (e.g., specific DNS behavior differences) in a shared internal wiki so other teams don't rediscover the same issues independently.
-
Re-evaluate base image choices periodically as new minimal/distroless options become available — this is an evolving area, and a choice that was optimal a year ago may have a better-supported alternative now.
-
Take a Dockerfile with poor instruction ordering, fix it, and measure the build time difference on a source-only change using
time docker build. -
Build the same Spring Boot app on eclipse-temurin:21-jre, eclipse-temurin:21-jre-alpine, and a distroless Java base, comparing docker images sizes and trivy image CVE counts.
-
Add a BuildKit cache mount for a package manager of your choice and verify dependency downloads are skipped on a rebuild even after deleting the relevant image layer via --no-cache.
-
Research and document one known musl/glibc behavioral difference relevant to your stack (e.g., DNS resolution, locale handling) before considering an Alpine migration.
-
Three compounding performance levers: cache-friendly instruction ordering, multi-stage builds, and minimal base image choice.
-
BuildKit cache mounts persist package manager caches across builds independently of image layer caching.
-
Alpine's size savings come from musl libc and BusyBox, but musl's behavioral differences from glibc require real testing, not assumed compatibility.
-
Distroless trims further than Alpine by removing the shell/package manager entirely, trading debuggability for attack-surface reduction.
-
Always measure (size, build time, CVE count) before and after any base image change rather than assuming the benefit.
Want a visual for this concept?
Generate a diagram tailored to “Performance Optimization: Layer Caching, Multi-Stage Builds & Alpine” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →