Ein typisches Container-Image schleppt ein komplettes Linux-System mit sich herum, obwohl die eigentliche Anwendung oft nur eine einzige Binärdatei ist. Jedes Paket darin ist potenzielle Angriffsfläche und Wartungslast. Wer Images härtet, reduziert beides gleichzeitig. Drei Hebel bringen dabei den größten Effekt: Multi-Stage Builds, distroless Base-Images und ein Scan, der direkt in der Pipeline läuft. Keiner davon ist aufwendig, und zusammen senken sie die Liste bekannter Schwachstellen drastisch.
Multi-Stage Builds: nur was läuft, kommt ins Image
Der Klassiker ist, die Anwendung im selben Image zu bauen, in dem sie später läuft. Compiler, Header, Build-Tools und Paketcache landen damit alle im Endprodukt, obwohl sie zur Laufzeit nichts zu suchen haben. Multi-Stage trennt Bauen und Laufen in zwei Phasen. Nur das fertige Artefakt wandert in das finale Image.
# Build-PhaseFROM golang:1.22 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /app ./cmd/server# Lauf-PhaseFROM gcr.io/distroless/static-debian12COPY --from=build /app /appUSER nonroot:nonrootENTRYPOINT ["/app"]
Das golang-Image bringt mehrere hundert Megabyte mit. Im Endergebnis bleibt die Binärdatei plus ein minimaler Unterbau. Aus einem Image jenseits der 800 MB werden so oft unter 20 MB. Weniger Bytes bedeuten auch weniger Software, die jemand auf Schwachstellen prüfen oder ausnutzen kann.
distroless: das Betriebssystem weglassen
distroless-Images enthalten keine Shell, keinen Paketmanager und keine Coreutils. Drin ist nur die Laufzeit, die deine Sprache braucht, dazu ein paar Root-Zertifikate und Zeitzonendaten. Das hat zwei Folgen. Erstens schrumpft die Liste bekannter CVEs deutlich, weil schlicht weniger Pakete vorhanden sind. Zweitens wird ein kompromittierter Container unbequem für Angreifer: ohne sh, ohne curl, ohne apt lässt sich kaum etwas nachladen oder erkunden.
Der Preis dafür ist, dass Debugging anders abläuft. Ein docker exec in eine Shell gibt es nicht mehr. Dafür existieren die :debug-Varianten mit BusyBox, die man gezielt für die Fehlersuche einsetzt und in Produktion wieder weglässt. Für statisch gelinkte Sprachen wie Go oder Rust passt static-debian12. Für Java, Python oder Node gibt es eigene Basis-Images mit der jeweiligen Laufzeit.
Scan in der Pipeline
Ein hartes Base-Image ersetzt keinen Scan. Abhängigkeiten altern, und neue Schwachstellen tauchen täglich auf. Sinnvoll ist deshalb ein Scanner, der bei jedem Build mitläuft und die Pipeline bricht, sobald etwas Kritisches gefunden wird. Mit Trivy sieht das in GitHub Actions so aus:
- name: Image scannen uses: aquasecurity/trivy-action@0.20.0 with: image-ref: ${{ env.IMAGE }} severity: HIGH,CRITICAL exit-code: '1' ignore-unfixed: true
Mit exit-code: '1' lässt ein Fund vom Schweregrad HIGH oder CRITICAL den Lauf scheitern, statt ihn nur zu protokollieren. ignore-unfixed blendet Schwachstellen aus, für die es noch keinen Fix gibt, damit man nicht an Dingen hängen bleibt, die ohnehin nicht behebbar sind. Wichtig ist, dass der Scan vor dem Push in die Registry läuft, nicht erst danach. Sonst liegt das verwundbare Image schon dort, wo es deployt werden kann.
Was die drei zusammen bringen
Die Maßnahmen greifen ineinander. Multi-Stage hält das Image klein, distroless reduziert die Angriffsfläche, und der Scan fängt ab, was trotzdem durchrutscht. In Summe sind das ein paar Zeilen im Dockerfile und ein einziger Schritt in der Pipeline. Der Effekt auf die Schwachstellenliste steht in keinem Verhältnis zum Aufwand.
Sobald man das über mehrere Repos und Teams hinweg ausrollt, kommt die nächste Frage von selbst: Welches Image wurde wann mit welchem Ergebnis gescannt, und wie weist man das nachvollziehbar nach? Wie sich solche Belege sauber in den Lieferprozess einbauen lassen, vertiefe ich auf digital-business.blog.
Hinterlasse einen Kommentar