Building Minimal Container Images (Artikel 213)
Strategien für minimale, gehärtete Container-Images: Multi-Stage Builds, Distroless, Scratch und Security-Best-Practices für den Enterprise-Einsatz.
# Building Minimal Container Images: Strategien für High-Performance & Security
TL;DR / Management Summary Minimale Container-Images sind kein Selbstzweck für Fetischisten kleiner Dateigrößen, sondern eine harte Anforderung für Sicherheit (geringere Angriffsfläche) und Performance (schnellerer Pull/Startup). Im Enterprise-Umfeld setzen wir auf Multi-Stage Builds, Distroless oder Alpine Images und entfernen strikt alles, was nicht zur Laufzeit benötigt wird (Shells, Paketmanager). Das Ziel: Ein Image, das nur die Applikation und ihre direkten Abhängigkeiten enthält – nichts weiter.
# 1. Einführung & Architektur
Warum “weniger ist mehr” in der Container-Welt überlebenswichtig ist.
# Das Problem mit “fetten” Images
In den Anfangstagen von Docker war es üblich, einfach ein FROM ubuntu:latest zu nehmen, apt-get install apache2 auszuführen und fertig. Das Ergebnis waren Images von 800MB+, die vollgestopft waren mit:
- Paketmanagern (
apt,yum) – Ein Einfallstor für Angreifer, um weitere Tools nachzuladen. - Shells (
bash,sh) – Ermöglichen Reverse-Shell-Attacken. - Compilern (
gcc,make) – Völlig unnötig zur Laufzeit. - Veralteten Bibliotheken (CVEs), die die App gar nicht nutzt.
# Architektur-Ansatz: The Minimalist Stack
Wir bewegen uns weg von “General Purpose OS im Container” hin zu “Single Process Isolation”.
Kernel-Sicht: Dem Linux-Kernel ist es egal, ob im Userspace eine Ubuntu-Bash oder nur ein einzelnes Go-Binary liegt. Container sind keine VMs. Sie sind isolierte Prozessräume (Namespaces/Cgroups). Ein minimales Image reduziert den Userspace auf das absolute Minimum, was die Isolation stärkt.
graph TD
subgraph "Legacy Approach (Fat Image)"
A[Base OS (Debian/RHEL)] --> B[Package Manager]
B --> C[Compilers/Tools]
C --> D[Shells]
D --> E[Application]
end
subgraph "Modern Approach (Minimal)"
F[Scratch / Distroless] --> G[Application Binary]
G --> H[Runtime Deps (glibc/musl)]
H --> I[CA Certificates]
end
style A fill:#f9f,stroke:#333
style F fill:#bbf,stroke:#333
# 2. Base-Image Wahl: Die Qual der Wahl
Was liegt unter der Haube?
# 1. Scratch (FROM scratch)
Das leerste aller Images. 0 Bytes Overhead.
- Einsatz: Statisch gelinkte Go-, Rust- oder C-Binaries.
- Vorteil: Maximale Sicherheit, minimale Größe.
- Nachteil: Keine Shell, keine CA-Zertifikate (müssen manuell kopiert werden), keine User-Verwaltung out-of-the-box.
# 2. Distroless (Google)
Enthält nur die Runtime (z.B. Java, Node.js, Python) und deren Abhängigkeiten, aber keine Shell und keinen Paketmanager.
- Einsatz: Die Standard-Empfehlung für die meisten Applikationen.
- Vorteil: Hält Scanner (Trivy/Grype) ruhig, da kaum unnötige Pakete enthalten sind. Debugging ist schwerer (Feature, not Bug!).
# 3. Alpine Linux (FROM alpine)
Basiert auf musl libc und busybox. Sehr klein (~5MB), aber vollwertiges OS mit Paketmanager (apk).
- Einsatz: Wenn man zur Laufzeit doch mal kurz debuggen muss oder spezifische C-Libs braucht.
- Risiko:
muslist nicht 100% kompatibel zuglibc. DNS-Resolution und Performance können abweichen!
# 3. Multi-Stage Builds: Der Gamechanger
Wir trennen den Bauhof vom Showroom.
Anstatt alles in einem Schritt zu machen, nutzen wir Multi-Stage Builds, um Artefakte zu bauen und nur das Ergebnis in das finale Image zu kopieren.
# Praxis-Beispiel: Go Application
Wir bauen die App in einem “fetten” Golang-Container und kopieren nur das Binary in ein scratch Image.
# --- Stage 1: Builder ---
FROM golang:1.22-bookworm AS builder
WORKDIR /src
# Caching Layer: Erst go.mod kopieren, dann dependencies laden
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# Source kopieren
COPY . .
# Build: Statisch linken!
# CGO_ENABLED=0 sorgt dafür, dass wir keine externen C-Libs brauchen (wichtig für scratch)
# -ldflags="-w -s" entfernt Debug-Symbole (kleineres Binary)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/my-service ./cmd/server
# --- Stage 2: Final ---
FROM scratch
# WICHTIG: CA-Zertifikate und User-Informationen aus dem Builder oder Base kopieren
# Hier nutzen wir einen Trick und kopieren vom Builder (falls dort vorhanden) oder nutzen ein minimales Base
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Kopiere das Binary
COPY /app/my-service /usr/local/bin/my-service
# Security: Non-Root User (muss evtl. in Stage 1 angelegt werden oder via /etc/passwd file copy)
USER 1000:1000
ENTRYPOINT ["/usr/local/bin/my-service"]
# Praxis-Beispiel: Node.js / Frontend
Hier ist es komplexer, da wir node_modules brauchen, aber nicht die devDependencies.
# --- Stage 1: Dependencies ---
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
# Nur production dependencies für später
RUN npm ci --only=production
# --- Stage 2: Builder ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # Alle dependencies für build
COPY . .
RUN npm run build
# --- Stage 3: Runner ---
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
USER nonroot
CMD ["dist/main.js"]
# 4. Security Hardening & Best Practices
Wie wir das Image wasserdicht machen.
# 1. Non-Root User
Standardmäßig laufen Container als root. Das ist ein No-Go.
- Docker:
USER 1000oderUSER nonroot. - Kubernetes:
runAsNonRoot: trueim SecurityContext erzwingen.
# 2. Read-Only Root Filesystem
Wenn der Angreifer nichts schreiben kann, kann er auch keine Malware nachladen.
- Im Dockerfile: Volumes für
/tmpoder/rundefinieren, wenn die App dort schreiben muss. - Runtime:
docker run --read-only ...
# 3. Entfernen von SetUID/SetGID Binaries
Auch in minimalen Images können Binaries liegen, die Berechtigungen eskalieren.
# In einer Alpine-Stage:
RUN find / -perm /6000 -type f -exec chmod a-s {} \; || true
# 4. SBOM (Software Bill of Materials)
Wissen, was drin ist. Tools wie syft oder trivy generieren eine Liste aller Pakete. Das ist für Compliance (z.B. BSI Grundschutz) essenziell.
# 5. Troubleshooting & “War Stories”
Wenn “minimal” zu “kaputt” führt.
# Story 1: “DNS Resolution Failed” in Alpine
Symptom: App startet, kann aber keine Verbindung zur Datenbank via Hostname aufbauen. IP geht.
Ursache: Alpine nutzt musl libc. Deren DNS-Resolver verhält sich anders als glibc (z.B. kein TCP-Fallback bei großen UDP-Paketen in älteren Versionen, oder Probleme mit .local Domains / search domains in /etc/resolv.conf).
Lösung: Entweder auf Debian-Slim wechseln (glibc) oder sicherstellen, dass ndots in Kubernetes Config niedrig ist, um unnötige DNS-Queries zu vermeiden.
# Story 2: “Certificate signed by unknown authority” in Scratch
Symptom: HTTPS-Aufrufe schlagen fehl.
Ursache: FROM scratch ist leer. Wirklich leer. Es gibt keinen /etc/ssl/certs/ca-certificates.crt.
Lösung: Wie im Beispiel oben: Zertifikate manuell reinkopieren (z.B. aus alpine oder debian image).
# Story 3: Debugging ohne Shell
Symptom: Container crasht, Logs sind unklar. Ich will kubectl exec -it pod -- sh machen, aber bekomme: OCI runtime exec failed: exec: "sh": executable file not found.
Lösung:
- Ephemeral Containers (Kubernetes v1.23+):
Das injiziert einen Sidecar mit Tools in den laufenden Pod. Genial!kubectl debug -it podname --image=busybox:latest --target=containername - Distroless Debug Tags: Nutzen Sie
gcr.io/distroless/nodejs:20-debugstatt:20während der Entwicklung. Da ist eine Shell drin.
# 6. Monitoring & CI/CD Integration
Vertrauen ist gut, Kontrolle ist besser.
# Image Scanning in der Pipeline
Kein Image darf in die Registry (Harbor/Artifactory), ohne gescannt zu werden.
- Trivy: Schnell, findet OS- und Language-Packages.
- Fail-Condition: Build abbrechen bei “CRITICAL” CVEs, für die es einen Fix gibt.
# GitLab CI Beispiel
scan_image:
image: aquasec/trivy:latest
script:
- trivy image --exit-code 1 --severity CRITICAL my-image:latest
# Image Größe überwachen
Setzen Sie Limits. Wenn der Microservice plötzlich 500MB hat, hat jemand ADD huge-file.tar.gz vergessen zu löschen.
- Dive: Ein Tool, um Layer zu inspizieren (
brew install dive). Zeigt genau, wo die Bytes verschwendet werden.
# 7. Fazit & Empfehlung
Für Enterprise-Workloads empfehlen wir folgende Hierarchie:
- Go/Rust/C++:
FROM scratch(Static Build) – Unschlagbar sicher & klein. - Java/Node/Python:
Google Distroless– Bester Kompromiss aus Wartbarkeit und Sicherheit. - Wenn Shell nötig:
Debian-Slim(bevorzugt wegen glibc-Kompatibilität) oderAlpine(wenn Größe kritisch und musl getestet).
Vermeiden Sie: FROM ubuntu:latest oder FROM centos:7 für reine Applikations-Container. Das ist Verschwendung von Ressourcen und ein Sicherheitsrisiko.
# Anhang: Cheatsheet
# Dockerfile Best Practices
| Befehl | Best Practice | Grund |
|---|---|---|
COPY vs ADD |
Nutze COPY |
ADD kann URLs laden und Tars entpacken (Magic behavior). |
RUN |
Kette Befehle mit && |
Reduziert Layer-Anzahl (weniger Overhead). |
TAG |
Nutze feste Versionen (:1.2.3) |
:latest ist nicht deterministisch und gefährlich für Prod. |
USER |
Immer setzen! | Verhindert Root-Execution. |
# Dive Command
# Analysiere Image Verschwendung
dive my-app:latest