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:
- Native libraries built for glibc do not load. In Java (which can load C code, called native libraries) that shows up as
UnsatisfiedLinkErrorfrom netty-tcnative, RocksDB, Snappy, or a database driver's native part. Some crash outright: exit 139 = 128 + 11, SIGSEGV (a memory access crash). - DNS resolution differs. musl's resolver has historically behaved differently from glibc's around search domains and falling back to TCP - exactly the search-domain and ndots behaviour from Ch 8.
- Default thread stack sizes differ (musl's are much smaller), which matters to anything creating many threads.
- Tools are busybox:
ls,ps,wgetexist but with fewer flags and different output. There is no bash and no curl:
$ 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 .
docker logsprints what the container wrote to stdout and stderr.--network container:apiputs the new container into api's network namespace:localhostin the debug container is api's localhost.nicolaka/netshootis a popular image full of network tools (curl, ss, dig, tcpdump - all from Ch 8 and 9).docker cp CONTAINER:PATH DESTcopies a file out of a container.
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
- Pick a base image and say what you gain and lose.
- Explain why alpine can break glibc programs.
- Debug a shell-less container from next to it.