Writings
Blog
DRY Dockerfiles – Multi Arch,Env,Stage Part 1
30.07.25Multi Arch, Multi Environment, Multi Stage Docker Compose Build – Part 1
DRY für Dockerfiles
"Schon mal versucht, einfach nur die Version eurer Sprache im Docker-Image zu ändern – und plötzlich funktioniert die halbe CI/CD-Pipeline nicht mehr?"
Mir ist das mehr als einmal passiert. Eigentlich wollte ich nur auf eine neue Runtime-Version umstellen – Go, Node, Python, völlig egal. Stattdessen musste ich mehrere Dockerfiles anpassen, Build-Jobs umbauen, und das Dev-Team war erstmal blockiert. Warum? Weil Entwicklungs- und Produktions-Images komplett unterschiedlich gebaut wurden – mit separaten Pfaden, Base-Images und Abhängigkeiten.
Viele Teams begegnen dem Problem mit einem dritten Dockerfile: einem geteilten Basis-Image. Aber auch das hat seine Tücken – vor allem, wenn man Plattformunterschiede (x86/ARM), Debug-Tools oder Build-Zeitoptimierungen ins Spiel bringt. Oft wird es dann noch komplexer.
Heute baue ich Images anders: mit einem einzigen, flexiblen Multi-Stage-Dockerfile. Modular, steuerbar per Variablen, vorbereitet für alle Umgebungen – egal ob lokal, im CI oder auf der Zielplattform. Kombiniert mit docker compose, include, x-bake und BuildKit lässt sich so ein Setup realisieren, das entwicklungsfreundlich, CI-kompatibel und produktionsreif ist.
In Teil 1 dieser Serie geht es um die Grundlage: DRY für Dockerfiles – wie man ein einziges, variables Build-Setup für Dev, Prod, Debug, ARM und mehr realisiert.
1. Multi-Stage-Builds – mehr als nur kleinere Images
Multi-Stage-Builds sind ein Kernfeature moderner Docker-Projekte. Sie ermöglichen es, mehrere voneinander getrennte Schritte im selben Dockerfile zu definieren – zum Beispiel Build, Test, Debug und Release – ohne mehrere Dateien oder komplizierte Skripte zu pflegen.
Jede Stufe (Stage) wird dabei mit einem eigenen FROM eingeleitet und kann einen eigenen Namen bekommen:
FROM golang:1.24-alpine as builder RUN go build -o /app/mybinary FROM alpine:3.20 as runtime COPY /app/mybinary /usr/local/bin/ ENTRYPOINT ["/usr/local/bin/mybinary"]
Vorteil: Nur die letzte Stage (runtime) landet im finalen Image – alle vorherigen Layer werden verworfen. Das reduziert die Größe und erhöht die Sicherheit.
Noch spannender wird es, wenn man diese Stufen gezielt ansteuern kann:
- Nur bestimmte Targets bauen (
--target) - Einzelne Stufen zwischenspeichern
- Oder den gesamten Buildprozess modularisieren, abhängig davon, ob wir ein Dev-, Test- oder Prod-Image erzeugen wollen.
Diese Modularität bildet die Grundlage für dynamisch steuerbare Builds – was in Abschnitt 2 zum Tragen kommt.
2. Dynamische Stages – Build-Logik mit Variablen steuern
Multi-Stage-Builds lassen sich nicht nur hart verkettet definieren – sie können über Build-Argumente (ARG) dynamisch verschaltet werden. So entsteht ein extrem flexibles Setup, das je nach Umgebung unterschiedliche Wege im Dockerfile nimmt.
Ein Beispiel aus der Praxis:
ARG GO_VERSION=1.24.1 ARG ALPINE_VERSION=3.20 FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} as base FROM base as without_source # nur Runtime, kein Code. z.b. lokale Umgebungen in denen FROM without_source as with_source # hier wird Source reinkopiert FROM ${BASE_IMAGE-without_source} as without_debug # Produktionsbuild FROM without_debug as with_debug # Debug-Werkzeuge wie delve installieren FROM ${SHOULD_BE_BUILD-without_debug} as result # finale Stage – hängt vom Build-Kontext ab
Was passiert hier?
BASE_IMAGEentscheidet, ob wir mit oder ohne Source weiterbauen.SHOULD_BE_BUILDlegt fest, ob am Ende ein Dev- oder Prod-Image entsteht.- Durch diese Verkettung entsteht ein baumartiger Buildpfad, der je nach Parametern unterschiedliche Wege durch das Dockerfile geht.
In Compose:
build: context: . dockerfile: docker/go.Dockerfile args: BASE_IMAGE: with_source SHOULD_BE_BUILD: with_debug
CLI-Variante:
docker build \ --build-arg BASE_IMAGE=without_source \ --build-arg SHOULD_BE_BUILD=with_debug \ -t myapp:debug .
Das Ergebnis: Ein einziges Dockerfile, das je nach Zielumgebung automatisch den richtigen Pfad nimmt – sei es Dev mit Debugger, Prod ohne Source, CI-only oder Local Testing.
3. Build‑Ziele gezielt ansteuern – mit --target und Compose target:
Ein Schlüsselmerkmal von Multi‑Stage‑Builds ist die gezielte Steuerung des Endpunkts eines Builds. Über das --target‑Flag (CLI) oder target: (Docker Compose) lässt sich genau festlegen, bis zu welcher benannten Stage gebaut wird.
Das ist besonders nützlich, wenn das Dockerfile mehrere Endzustände definiert, z. B.:
- Entwicklungs‑Image mit eingebettetem Code
- CI/CD‑Image mit Analyse‑Tools wie
golangci‑lint - Debug‑Image mit Delve
- Schlankes Produktions‑Image mit nur dem Binary
Beispiel‑Dynamik:
ARG IMAGE_PREFIX="" ARG GO_VERSION=1.24.1 ARG ALPINE_VERSION=3.20 FROM ${IMAGE_PREFIX}golang:${GO_VERSION}-alpine${ALPINE_VERSION} as without_debug ... FROM without_debug as with_debug ... FROM ${IMAGE_PREFIX}${BASE_IMAGE} as project_without_source ... FROM project_without_source as project_with_source ... FROM project_with_source as project_builder RUN go build -o /build/app ./cmd/server FROM ${IMAGE_PREFIX}golang:${GO_VERSION}-alpine${ALPINE_VERSION} as project_binary COPY /build/app /usr/local/bin/app ENTRYPOINT ["/usr/local/bin/app"] FROM ${BASE_CICD_IMAGE-without_debug} as with_cicd_tools ARG GOLANGCI_LINT_VERSION="v1.43.0" RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
project_with_source: Dev‑Image mit eingebundenem Source über einen Volume‑Mountproject_builder: kompilierte Binary für das finale Imageproject_binary: Produktions‑Image mit minimalen Layernwith_cicd_tools: CI/CD‑Image mit Analysewerkzeugen auf Basis vonBASE_CICD_IMAGE
🔁 Proxy-Caching & Rate Limits vermeiden
Beispiel 1: JFrog Artifactory als Docker-Hub-Proxy
Artifactory kann als Remote Repository fungieren, das Docker Hub cached – somit werden Pull-Rate Limits umgangen und CI/CD-Pipelines werden stabiler
IMAGE_PREFIX: IMAGE_PREFIX=artifactory.int.company.net/dockerhub/
Beispiel 2: Docker Registry Mirror via Docker Daemon
Docker unterstützt offiziell einen Pull-through-Cache, der in der Datei /etc/docker/daemon.json über "registry-mirrors" konfiguriert wird. Docker lädt Images über den Mirror statt direkt von Docker Hub Docker Documentation.
⛔ Nachteil:
Du musst den Docker Daemon des Hosts konfigurieren – etwa /etc/docker/daemon.json anpassen und dockerd neu starten. Das funktioniert nicht, wenn du keinen Zugriff auf den Daemon hast – z. B. in automatisierten CI-Umgebungen oder Container-Laufzeitumgebungen, die dir keinen Hostzugriff erlauben Docker Documentation.
CLI‑Beispiel:
docker build \ --target=with_cicd_tools \ --build-arg BASE_CICD_IMAGE=without_debug \ --build-arg IMAGE_PREFIX=artifactory.int.company.net/dockerhub/ \ -t project:ci-tools .
Compose‑Beispiel:
services: ci_tools: build: context: . dockerfile: docker/go.Dockerfile target: without_source args: BASE_CICD_IMAGE: with_debug IMAGE_PREFIX: artifactory.int.company.net/dockerhub/
📌 Die Trennung von Weg und Ziel bleibt zentral:
- Variablen wie
BASE_IMAGE,BASE_CICD_IMAGE,IMAGE_PREFIX,SHOULD_BE_BUILDbestimmen, welcher Pfad durch das Dockerfile gewählt wird. --targetbzw.target:steuern, bis wohin gebaut wird (z. B. Dev, CI/CD, Binary oder Produktion).
So entsteht ein universell flexibles Build‑Setup, geeignet für lokale Entwicklung, CI/CD‑Workflows und Produktionsimages – mit nur einem Dockerfile, ohne Duplikate.