Writings

Blog

CI/CD

DRY Dockerfiles – Multi Arch,Env,Stage Part 1

30.07.25

Multi 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 --from=builder /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_IMAGE entscheidet, ob wir mit oder ohne Source weiterbauen.
  • SHOULD_BE_BUILD legt 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 --from=project_builder /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‑Mount
  • project_builder: kompilierte Binary für das finale Image
  • project_binary: Produktions‑Image mit minimalen Layern
  • with_cicd_tools: CI/CD‑Image mit Analysewerkzeugen auf Basis von BASE_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_BUILD bestimmen, welcher Pfad durch das Dockerfile gewählt wird.
  • --target bzw. 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.

Back