OnCallReady

Lesson 10.38 · Images & Builds · 12 min read

Choosing a base: slim, alpine, distroless, scratch

In plain words

Think of backpacks for a trip. A big hiking pack holds a tent, a stove and a first-aid kit: heavy, but you can fix anything. A small daypack is light but you had better know what you need. An empty envelope holds one letter and nothing else.

ubuntu and debian are the big pack: apt, bash, every tool. -slim images are the daypack. alpine is tiny but swaps glibc for musl and GNU tools for busybox, which can break native libraries. Distroless has your runtime and certificates but no shell at all, and scratch is the empty envelope. Smaller means fewer CVEs and faster pulls, and fewer tools when things break, so you learn to debug from next to the container: --network container:api or nsenter.

Why this matters

The security scanner lists 180 known vulnerabilities in your image; most are in packages your app never uses. A smaller base removes them - but also removes the tools you debug with, and some small bases quietly break programs. Choose on purpose.

What you need to know already: base images and FROM, glibc and static binaries (previous lesson), network namespaces and nsenter ("What a container actually is"), DNS search domains and ndots (Ch 8), ss and curl (Ch 9).

The menu

ubuntu:24.04 / debian:12        full distro, apt, bash. Easiest to debug
debian:12-slim                  Debian minus docs and extras, 75MB
<lang>:<ver>-slim               slim + the language runtime
alpine:3.20                     8MB, musl libc, busybox, apk
gcr.io/distroless/*             no shell, no package manager, just runtime + certs
scratch                         empty

A CVE is a publicly listed security vulnerability with an id (CVE-2024-1234); an image scanner lists the CVEs of every package in an image. Smaller means fewer packages to patch, fewer CVEs in the report, less to pull. It also means fewer tools when something breaks.

Alpine and musl

Alpine is small because it uses musl (a small C library) instead of glibc, and busybox (one small binary that acts as ls, ps, sh and more) instead of the usual GNU tools. Its package manager is apk. For a Go static binary or a shell script, it is great. For anything built against glibc, it can quietly misbehave:

$ docker run --rm alpine:3.20 bash
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "bash": executable file not found in $PATH: unknown.
$ docker run --rm alpine:3.20 sh -c 'curl -s example.com'
sh: curl: not found

Read the first error from the end: runc could not find bash in $PATH. (OCI, the Open Container Initiative, is the standard that Docker images and runc follow; "OCI runtime" means runc.)

The Java runtime itself is built for musl in the -alpine images, so plain Java works. The rule: prefer a -jre slim or distroless image for Java unless you have verified alpine works for your application, including every native library, under load.

Distroless: no shell, on purpose

Distroless images (from Google, gcr.io/distroless/...) contain only your language runtime, its libraries and CA certificates:

# api = a distroless container (the mission below)
docker exec -it api sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH: unknown

There is no shell, no package manager, no coreutils. An attacker who gets code execution has nothing to work with - and neither do you. Distroless images also come in :nonroot variants that run as uid 65532 by default, and :debug variants that add a busybox shell for troubleshooting (never in production).

Debugging an image with no shell

You do not need a shell in the image, you need one next to it:

# the logs and the config are always there
docker logs api
docker inspect -f '{{.State.ExitCode}} {{.State.Error}}' api

# a debug container in the SAME network namespace
docker run --rm -it --network container:api nicolaka/netshoot
  curl -s localhost:8080/actuator/health
  ss -tlnp

# the host's tools in the container's network namespace
sudo nsenter -t $(docker inspect -f '{{.State.Pid}}' api) -n ss -tlnp

# copy files out
docker cp api:/app/config/application.yml .

When the image will not even start

docker exec needs a running container. If it exits immediately, override the entrypoint and look around:

docker run --rm -it --entrypoint sh app:1          # images with a shell
docker run --rm --entrypoint ls app:1 -la /app     # run a single tool
docker run --rm --entrypoint cat app:1 /app/config.yml

Scratch

Nothing at all: no /etc/passwd, no /tmp, no certificates, no timezone data. Only for fully static binaries that need none of those, and even then distroless/static is usually the better trade for 2MB.

A sensible default

Java       eclipse-temurin:21-jre, or distroless java21, or debian-slim + jlink
Node       node:22-slim
Python     python:3.12-slim
Go         gcr.io/distroless/static-debian12:nonroot

Alpine when you have tested it and the size difference matters. And pin every one of them by digest in anything that ships.

What you can now do

Why it helps

The scanner reports 180 CVEs on an image and the team asks what to do: switching from a full base to slim or distroless often removes most of them without touching the app. The opposite ticket: a Java service moved to alpine to save 100MB now crashes with UnsatisfiedLinkError from netty-tcnative, or exit 139, and you know musl is the suspect. Then the 3am one: a distroless container misbehaves, docker exec -it api sh fails with executable file not found, and you do not panic, because a debug container sharing its network namespace (docker run --network container:api nicolaka/netshoot) gives you curl and ss against its localhost, and nsenter gives you the host's tools. Choosing a base is also a standard design question in platform interviews.

Commands in this lesson

docker

FAQ

Is alpine always the best choice because it is smallest?

No. Alpine uses musl libc and busybox. Static binaries and scripts are fine, but anything built against glibc may fail: Java native libraries like netty-tcnative or RocksDB, prebuilt Python packages, some database drivers. Resolver and thread stack defaults also differ. For JVM workloads prefer a slim JRE or distroless unless you have tested alpine with every native dependency, under load.

How do I debug a distroless container that has no shell?

Bring the tools next to it. docker logs and docker inspect always work. docker run --rm -it --network container:api nicolaka/netshoot shares the container's network namespace, so curl localhost reaches the app. sudo nsenter -t <pid> -n ss -tlnp runs host tools in its network namespace. docker cp copies files out. Add --pid container:api to the debug container to see its processes too.

What are the :nonroot and :debug distroless tags?

:nonroot variants set the default user to uid 65532, so the container does not run as root without you adding a USER line. :debug variants add a busybox shell for troubleshooting. Use :debug locally or in a test environment when you need to poke around, never in production, since it reintroduces the shell that distroless removes on purpose.

Why is there no bash or curl in alpine?

Alpine ships busybox, which provides sh and small versions of common tools like ls, ps and wget, with fewer flags. bash and curl are separate packages you add with apk add --no-cache bash curl. So #!/bin/bash scripts fail with a confusing no such file or directory, and health checks should use wget -qO- if you do not install curl.

How do I run a single tool in an image that exits immediately?

Override the entrypoint. docker run --rm -it --entrypoint sh app:1 gives you a shell in images that have one. For images without a shell, run one binary: docker run --rm --entrypoint ls app:1 -la /app or --entrypoint cat app:1 /app/config.yml. docker exec does not help here, because it needs a running container.

In an interview Junior

What are the trade-offs between a full, slim, alpine and distroless base image?

Smaller means fewer packages to patch and pull, and fewer tools when something breaks. To debug a shell-less container, work from next to it: docker logs, docker run --network container:api nicolaka/netshoot, docker cp.

Also asked: How would you debug a container that has no shell? · Why can a Java app with native libraries break on alpine? · What is a CVE, and why do smaller images have fewer of them?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.