linux-arch-alpine-minimal container-lightweight docker kubernetes security deep-dive

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: musl ist nicht 100% kompatibel zu glibc. 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 --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Kopiere das Binary
COPY --from=builder /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 --from=deps /app/node_modules ./node_modules
COPY --from=builder /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 1000 oder USER nonroot.
  • Kubernetes: runAsNonRoot: true im SecurityContext erzwingen.

# 2. Read-Only Root Filesystem

Wenn der Angreifer nichts schreiben kann, kann er auch keine Malware nachladen.

  • Im Dockerfile: Volumes für /tmp oder /run definieren, 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:

  1. Ephemeral Containers (Kubernetes v1.23+):
    kubectl debug -it podname --image=busybox:latest --target=containername
    Das injiziert einen Sidecar mit Tools in den laufenden Pod. Genial!
  2. Distroless Debug Tags: Nutzen Sie gcr.io/distroless/nodejs:20-debug statt :20 wä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:

  1. Go/Rust/C++: FROM scratch (Static Build) – Unschlagbar sicher & klein.
  2. Java/Node/Python: Google Distroless – Bester Kompromiss aus Wartbarkeit und Sicherheit.
  3. Wenn Shell nötig: Debian-Slim (bevorzugt wegen glibc-Kompatibilität) oder Alpine (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