What distroless, Alpine and slim images actually contain

Every base image answers two questions before it answers "how big": which C library your binary will link against at runtime, and what tools you have when something breaks in production. Size follows from those choices.

Slim variants (such as debian:*-slim or the -slim tags of language images) are ordinary Debian or Ubuntu userlands with documentation, man pages and most locales removed. They keep glibc, a POSIX shell and apt, so almost any prebuilt binary or wheel runs unchanged.

Alpine pairs musl libc with BusyBox, which provides a shell and core utilities from a single binary, and uses apk for packages. It is small because both components are minimal, but musl is not a drop-in replacement for glibc.

Distroless images (the gcr.io/distroless family) contain a language runtime and its libraries, CA certificates, timezone data and a passwd file. They have no shell and no package manager. Most variants are built from Debian packages and use glibc. The static variant has no libc and is meant for fully static binaries. :debug tags add a BusyBox shell.

scratch is an empty filesystem. You supply every file yourself.

Image libc Shell Package manager Debug tooling Typical use Trades away
Slim glibc Yes apt Installable General apps, native deps Size, larger package surface
Alpine musl BusyBox sh apk BusyBox, installable Small images, musl-safe code glibc compatibility
Distroless glibc (most); none (static) No (except :debug) None None by default Production runtimes In-container debugging
scratch None No None None Static binaries Everything not shipped explicitly

Where the bytes in a Docker image really come from

Measure before changing the base. docker history <image> lists every layer with the instruction that created it and its size. The base is often not the largest entry. The bigger layers are usually a RUN that installs a compiler, a copied node_modules or virtualenv, or a package cache left behind, such as /var/lib/apt/lists or pip's cache directory. Layers are additive. Deleting files in a later RUN hides them but does not shrink the layer that added them, so cleanup must happen in the same instruction or in a separate build stage. docker image inspect shows the layer digests, which you can compare across images.

Illustrative estimate (assumed sizes, not measured)

Assume these uncompressed sizes: slim base 75 MB, Alpine base 8 MB, build toolchain 250 MB, runtime dependencies 120 MB, application 15 MB.

Final image Base Toolchain Deps + app Total
Slim, toolchain kept 75 250 135 460 MB
Alpine, toolchain kept 8 250 135 393 MB
Slim, toolchain removed 75 0 135 210 MB

Under these assumptions, switching to Alpine saves 67 MB. Removing the toolchain from the final stage saves 250 MB.

Size depends on where you look. docker images reports uncompressed on-disk size. Registries store and transfer compressed layers, which are typically much smaller. Layers are also content-addressed. If ten services share one base, a node pulls that base once, so its cost is spread across all of them, while each service's dependency layers are pulled separately.

Check whether pulls actually slow you down before optimizing. Time a pull on a cold node, or look at the image pull durations your orchestrator records, and compare that figure with application startup time.

#!/usr/bin/env bash
# Find what drives image size before switching base images.
set -euo pipefail

IMG=registry.example.com/orders-api:1.4.2
OTHER=registry.example.com/billing-api:2.0.1

# 1. Per-layer size and the instruction that created it.
#    Look for large RUN layers (compilers, apt lists, pip cache)
#    and COPY layers (node_modules, virtualenvs), not just the base.
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' "$IMG"

# 2. Uncompressed on-disk size, as reported by the local engine.
docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' "$IMG"

# 3. Rough compressed size, closer to what a registry stores and a node pulls.
#    This is only an approximation. Registry compression differs in detail.
echo "approx compressed bytes: $(docker save "$IMG" | gzip | wc -c)"

# 4. Layer digests. Layers that appear in both images are stored
#    and pulled once per node, so a shared base costs little per service.
layers() {
  docker image inspect --format '{{range .RootFS.Layers}}{{println .}}{{end}}' "$1" | sort
}
echo "layers shared between the two services:"
comm -12 <(layers "$IMG") <(layers "$OTHER")

# 5. Is pull time actually a bottleneck? Time a cold pull.
#    Note: removing the image also removes any of its layers that no other
#    local image uses, so this is cold only for those layers.
docker image rm "$IMG" >/dev/null
time docker pull --quiet "$IMG"
# Compare the result with the application's own startup time before optimizing.

A shell script that lists each layer's size and the instruction that created it, compares uncompressed size with an approximate compressed size, finds layers two services share, and times a pull to check whether pulls are worth optimizing.

How to implement multi-stage builds for smaller Docker images

Multi-stage builds do most of the work of shrinking an image. Compilers, package managers, headers and caches stay in a builder stage, and the final stage starts from a minimal base and receives only the files the process needs at runtime. Earlier stages contribute no layers to the shipped image, so nothing you delete in the builder affects the final size.

builder (compiler, deps, cache) ──COPY --from──▶ runtime (binary/venv/node_modules + config) ──▶ shipped image

Go: static binary into scratch or distroless/static

Build with CGO_ENABLED=0 to get a binary with no libc dependency, then copy it into scratch or distroless/static. scratch contains nothing, so you must also copy CA certificates and timezone data if the program makes TLS calls or parses time zones. distroless/static already includes both, plus a non-root user. Use exec form, ENTRYPOINT ["/app"]. Shell form wraps the command in /bin/sh -c, and that fails because neither image has a shell.

Python and Node

For Python, create a virtualenv in a python:*-slim builder and copy the whole venv into a runtime with the same Python version at the same path. A venv contains absolute paths to its interpreter, so it breaks if either one moves. Compiled wheels must also match the runtime's libc.

For Node, run npm ci --omit=dev in the builder and copy node_modules alongside the app. Native addons tie that directory to the Node ABI and libc they were built against, so the builder and runtime must agree on both.

JVM

Use jlink to assemble a runtime that contains only the modules your application uses. Copy that runtime and the JAR onto a small glibc-based image.

# ---- Builder stage: full Go toolchain, module cache, build tools ----
FROM golang:1.22 AS builder
WORKDIR /src

# Download modules first so this layer stays cached until go.mod/go.sum change
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# CGO_ENABLED=0 produces a static binary with no libc dependency.
# -trimpath removes local file paths; -s -w strips symbol and debug info.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

# ---- Runtime stage: no shell, no package manager ----
# distroless/static already ships CA certificates, tzdata and a nonroot user.
# To use scratch instead, you would also need to copy these from the builder:
#   COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
#   COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
FROM gcr.io/distroless/static-debian12:nonroot

# Only the compiled binary crosses the stage boundary
COPY --from=builder /out/app /app

USER nonroot:nonroot
EXPOSE 8080

# Exec form: the kernel runs /app directly.
# Shell form (ENTRYPOINT /app) would need /bin/sh, which this image lacks.
ENTRYPOINT ["/app"]

A two-stage Dockerfile that compiles a static Go binary in the full golang image and copies only that binary into distroless/static. The final stage runs as a non-root user and uses an exec-form ENTRYPOINT.

musl vs glibc: when Alpine should be avoided

Most prebuilt Python wheels and vendor binaries target glibc, labelled manylinux for wheels. A musllinux tag exists and many popular packages publish for it, but coverage is uneven. When pip finds no matching wheel, it builds from source. That means a compiler, headers and -dev packages in the build stage, noticeably longer builds, and sometimes a larger final image if build artifacts or extra shared libraries leak into it. Precompiled binaries linked against glibc may not run at all.

The runtime differences fall into a few categories:

  • DNS resolution. musl's resolver handles resolv.conf differently from glibc. It queries nameservers in parallel instead of in order, and it treats search domains and ndots differently. In Kubernetes, where search lists are long, this changes which lookups happen and how failures appear.
  • Locales. musl's locale support is minimal. Collation and some formatting behave differently from a glibc system with full locale data installed.
  • Memory allocation. musl's malloc favours simplicity and low overhead. Allocation-heavy, multithreaded programs can perform worse on it, which is why some teams link in an alternative allocator.
  • Thread stack size. musl's default stack is much smaller than glibc's. Deep recursion or large stack buffers can crash in threads that run fine elsewhere.

None of these differences show up in a quick local test on a glibc laptop. They appear under concurrency, at deep call depths, or on specific network and DNS setups, which usually means production.

Alpine works well when your dependencies are musl-friendly, such as statically linked Go binaries or packages with musllinux wheels, and when your team already knows its quirks.

Debugging and operating shell-less and scratch images

Removing the shell removes more than an attack vector. You can no longer kubectl exec into a container and run sh, there is no curl to probe a dependency, and entrypoint scripts that template config or wait for a database cannot run. That logic has to move into the application or into the orchestrator.

Debugging without a shell

The most practical tool is kubectl debug with an ephemeral container. You attach a tool-rich image such as busybox to a running pod and pass --target to share the process namespace with the app container. From there you can inspect its processes, and reach its filesystem through /proc/<pid>/root, without rebuilding anything. Distroless also publishes :debug variants that include a busybox shell. Use them in local or staging environments only, because shipping them to production brings back the surface you removed.

The longer-term fix is to make the app observable from outside: a health endpoint the orchestrator can probe and structured logs on stdout. Then you rarely need to get inside the container at all.

Files you still need

Distroless images already include CA certificates, tzdata and a nonroot user. Scratch includes none of these. A missing CA bundle does not fail the build. It surfaces at runtime as TLS errors on the first outbound HTTPS call. Missing tzdata breaks timezone lookups. Without an /etc/passwd entry, a non-root UID has no name, which confuses some libraries.

To fix this, install ca-certificates and tzdata in the builder stage. Create the user there as well. Then copy /etc/ssl/certs/ca-certificates.crt, /usr/share/zoneinfo, and /etc/passwd into the final stage.

# Builder stage: has a package manager, so install the operational files here
FROM golang:1.22-alpine AS builder

# CA bundle for outbound TLS, zoneinfo for timezone lookups
RUN apk add --no-cache ca-certificates tzdata

# Create a non-root user so /etc/passwd has a named entry for the UID
RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .

# Static binary: scratch has no libc to link against
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/server ./cmd/server

# Final stage: nothing but what we copy in
FROM scratch

# Without this bundle the build succeeds and the first HTTPS call fails at runtime
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

# Timezone database for time.LoadLocation and TZ
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo

# User and group entries so UID 10001 resolves to a name
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group

COPY --from=builder /out/server /server

# Numeric UID lets Kubernetes runAsNonRoot verify the user without a lookup
USER 10001:10001

# Exec form: there is no shell to interpret a string command
ENTRYPOINT ["/server"]

A multi-stage Dockerfile that installs CA certificates, tzdata and a non-root user in an Alpine builder stage, then copies those files and a static Go binary into a scratch image.

Security surface, stated honestly

Removing packages has two real security benefits. Each package you omit is one fewer source of CVE matches in a scanner report, and one fewer tool an attacker can use after gaining code execution. A container with no shell, package manager, curl or wget makes post-exploitation slower and noisier. It does not make exploitation impossible.

Scanner counts are a weak proxy for risk. A finding means a package version matched an advisory. It does not mean the vulnerable code path is reachable from your service. Results also vary by distro, because scanners rely on each distribution's security tracker and package metadata. Those trackers differ in how they backport fixes, how they mark issues as "won't fix" or "not affected," and how quickly they publish. The same application can therefore get very different numbers on Debian, Alpine and distroless bases without its actual exposure changing much.

Avoid false confidence. Your application code and its language-level dependencies remain the primary attack surface, and a minimal base does nothing about them. The controls that matter more:

  • Run as a non-root user so a compromise does not start with root inside the container.
  • Use a read-only root filesystem so an attacker cannot drop binaries or modify the app.
  • Pin base images by digest, not tag, so builds are reproducible.
  • Rebuild on a schedule. Digest pins never update on their own. Without automated bumps and rebuilds, you will keep shipping stale bases.

Language-specific distroless images also tie your runtime version and patch timing to the publisher's release schedule.

# Build stage: full toolchain, never shipped.
# Pin by digest, not tag. Resolve the current digest with:
#   docker buildx imagetools inspect golang:1.22-bookworm
# Replace <build-digest> and <runtime-digest> with the sha256 values you resolve.
FROM golang:1.22-bookworm@sha256:<build-digest> AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Static binary so the runtime image needs no libc or shell.
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app

# Runtime stage: no shell, package manager, curl or wget.
# This makes post-exploitation harder. It does nothing about bugs in /app
# or its Go dependencies, which remain the main attack surface.
FROM gcr.io/distroless/static-debian12:nonroot@sha256:<runtime-digest>
COPY --from=build /out/app /app
# Use a numeric UID so Kubernetes runAsNonRoot can verify it.
# 65532 is the distroless nonroot user.
USER 65532:65532
ENTRYPOINT ["/app"]

# Run with a read-only root filesystem, a scratch tmpfs, and no extra privileges:
#   docker run --read-only --tmpfs /tmp \
#     --cap-drop=ALL --security-opt=no-new-privileges \
#     registry.example.com/team/app:1.4.2
#
# Digest pins never move on their own. Use a scheduled job or a dependency bot
# to bump <runtime-digest> and rebuild, or you will keep shipping a stale base.

This multi-stage Dockerfile pins both base images by digest, ships a static binary on a distroless non-root image, and shows how to run it with a read-only root filesystem, all capabilities dropped, and no-new-privileges.

Decision guide by workload, alternatives and impact on engineering time

Given those maintenance trade-offs, choose the base by workload, not by the smallest number on a registry page.

Workload Base Why
Static Go or Rust binary scratch or distroless static No dynamic linker is needed. Add CA certificates, tzdata and a non-root user yourself.
JVM, Python or Node with native dependencies slim or glibc-based distroless Prebuilt wheels and native modules usually target glibc.
Tooling-heavy or shell-script-driven slim Entrypoints expect a shell, coreutils and a package manager.
musl-friendly dependencies and a team fluent in Alpine Alpine Small and predictable when nothing needs recompiling.

Where engineering time actually goes

On Alpine, the main cost is native dependencies that have no musl build. They compile from source, which slows CI and adds build toolchains you then have to strip back out. On distroless, the cost shows up during incidents. If on-call engineers haven't practised attaching a debug container or a sidecar ahead of time, the first outage is when they learn it.

Megabytes are often the least valuable target. Shared base layers are pulled once per node and then cached.

Illustrative estimate (assumed numbers, not a benchmark): savings per deploy = MB saved × nodes that don't already cache the layer. Assume 40 MB saved and 2 cold nodes: 80 MB transferred per rollout. Weigh that against 2 engineer-days spent migrating.

Before switching

  • Run the full test suite inside the target image, not on the build host.
  • Run a load test, because allocator and DNS resolver behaviour differ between libc implementations.
  • Compare cold-start time against the current image.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to distroless vs alpine, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.