Symfony Docker Images: Produktionsreife Multi-Stage-Builds
AI generated
SF
{ }
Symfony · Docker · DevOps · Deployment
Symfony Docker Images für die Produktion
Multi-Stage-Builds, Caching und Security-Hardening

Ein Symfony Docker Image, das lokal läuft, ist noch lange nicht produktionsreif. Multi-Stage-Builds trennen Build- und Laufzeitumgebung, Composer-Caching verkürzt jeden Build, ein Non-Root-User und ein Healthcheck machen den Container betriebssicher. Dieser Artikel zeigt Schritt für Schritt, wie ein schlankes, gehärtetes Symfony Docker Image entsteht.

18 Min. Lesezeit Multi-Stage-Build · Composer-Cache · PHP-FPM Alpine · Healthcheck Symfony 7 · Docker 26 · PHP 8.4

1. Warum ein Symfony Docker Image in Produktion anders aussieht

Ein lokales Entwicklungsimage installiert Xdebug, mountet den Quellcode als Volume und startet mit aktiviertem Debug-Modus. Ein produktionsreifes Symfony Docker Image darf keine dieser Eigenschaften teilen. Es enthält keinen Debugger, keine Entwicklungswerkzeuge und keinen gemounteten Quellcode. Stattdessen ist der Anwendungscode fest in das Image eingebacken, der Container ist unveränderlich und jede Instanz eines Deployments läuft mit exakt demselben Byte für Byte identischen Artefakt.

Der häufigste Fehler in Teams, die neu mit Containern arbeiten, ist ein einziges Dockerfile für Entwicklung und Produktion. Das Ergebnis: entweder das Produktionsimage schleppt unnötige Werkzeuge mit sich, oder das Entwicklungsimage verliert Komfortfunktionen wie Xdebug und Volume-Mounts. Ein sauberes Symfony Docker Image für die Produktion entsteht aus einem mehrstufigen Dockerfile, das genau eine Zielumgebung sauber bedient und den Entwicklungspfad über Docker Compose Overrides oder ein separates Target abbildet.

Der Container wird in diesem Modell zum reinen Artefakt: einmal in der CI-Pipeline gebaut, mit einem eindeutigen Tag versehen, über eine Registry verteilt und in jeder Umgebung unverändert gestartet. Diese Denkweise ist die Grundlage für alles Weitere in diesem Artikel: Caching, Sicherheit, Größe und Betriebsfähigkeit eines Symfony Docker Image lassen sich nur konsequent verbessern, wenn Build-Zeit und Laufzeit klar getrennt sind.

2. Multi-Stage-Builds: Build- und Runtime-Stage trennen

Ein Multi-Stage-Build besteht aus mehreren FROM-Anweisungen in derselben Dockerfile, wobei jede Stage ihre eigene Umgebung mitbringt und nur explizit benannte Artefakte an die nächste Stage weiterreicht. Für ein Symfony Docker Image bedeutet das konkret: Eine Build-Stage installiert Composer, Node und alle Kompilierwerkzeuge, führt composer install sowie den Frontend-Build aus. Die finale Runtime-Stage beginnt dagegen mit einem schlanken PHP-FPM-Basisimage und kopiert nur den fertigen vendor-Ordner, die kompilierten Assets und den Anwendungscode hinein.

Der Vorteil ist doppelt. Erstens verschwinden Composer, Node, Git und alle Build-Abhängigkeiten spurlos aus dem finalen Symfony Docker Image, weil sie nur in einer Zwischenstage existierten, die beim Push nie in die Registry gelangt. Zweitens lässt sich jede Stage unabhängig cachen: Ändert sich nur der Anwendungscode, muss Docker die Build-Stage mit den Composer-Abhängigkeiten nicht neu ausführen, solange composer.lock unverändert bleibt.


# Dockerfile — multi-stage build for a production Symfony image
# Stage 1: build dependencies and compile frontend assets
FROM composer:2 AS composer_stage
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

FROM node:20-alpine AS assets_stage
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: runtime image — only what the application needs at runtime
FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache icu-libs libzip \
    && docker-php-ext-install -j$(nproc) intl pdo_mysql opcache

WORKDIR /var/www/html
COPY --from=composer_stage /app/vendor ./vendor
COPY --from=assets_stage /app/public/build ./public/build
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative

USER www-data
EXPOSE 9000
CMD ["php-fpm"]

Der entscheidende Punkt: Jede Stage darf ihre eigene, spezialisierte Basis wählen. Die Composer-Stage nutzt das offizielle Composer-Image, die Assets-Stage ein Node-Image, und nur die letzte Stage bestimmt, was tatsächlich im fertigen Symfony Docker Image landet. Zwischenschichten, die Hunderte Megabyte an Build-Werkzeugen enthalten, verschwinden vollständig, ohne dass ein manuelles Aufräumen im selben Layer nötig wäre.

3. Composer-Dependencies cachen und optimieren

Die Reihenfolge der Instruktionen in einer Dockerfile bestimmt direkt, wie effektiv Docker seinen Layer-Cache nutzt. Wird zuerst der komplette Quellcode kopiert und danach composer install ausgeführt, invalidiert jede Codeänderung sofort den Composer-Layer, selbst wenn sich keine einzige Abhängigkeit geändert hat. Das korrekte Muster für ein performantes Symfony Docker Image kopiert zuerst nur composer.json, composer.lock und symfony.lock, installiert die Abhängigkeiten, und kopiert erst danach den restlichen Anwendungscode.

Zusätzlich zur Layer-Reihenfolge lohnt sich der native BuildKit-Cache-Mount. Statt Composer bei jedem Build komplett neu herunterladen zu lassen, bindet --mount=type=cache das Composer-Verzeichnis über mehrere Build-Läufe hinweg an denselben Host-Cache. Das reduziert Build-Zeiten in CI-Pipelines von Minuten auf Sekunden, insbesondere bei großen Symfony-Projekten mit vielen Bundles. Die Flags --no-dev, --optimize-autoloader und --classmap-authoritative sind für ein produktives Symfony Docker Image Pflicht: Sie entfernen Entwicklungsabhängigkeiten und bauen eine statische Klassenkarte, die zur Laufzeit keinen Dateisystem-Scan mehr benötigt.


# syntax=docker/dockerfile:1.7
FROM composer:2 AS composer_stage
WORKDIR /app

# Copy only the lock files first — cache stays warm across code changes
COPY composer.json composer.lock symfony.lock ./

# BuildKit cache mount — Composer downloads persist across builds
RUN --mount=type=cache,target=/tmp/composer-cache \
    composer install \
      --no-dev \
      --no-scripts \
      --no-autoloader \
      --prefer-dist \
      --no-interaction \
      --cache-dir=/tmp/composer-cache

# Only now copy the actual application source
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative \
    && composer check-platform-reqs

4. PHP-FPM Tuning und die Wahl des Base-Images

Die Wahl des Base-Images entscheidet maßgeblich über Größe, Kompatibilität und Startzeit eines Symfony Docker Image. Die offiziellen php:8.4-fpm-alpine Images sind mit rund 80 MB deutlich kleiner als die Debian-basierten php:8.4-fpm Varianten, nutzen aber musl statt glibc als C-Bibliothek. Für die meisten Symfony-Anwendungen ist das unproblematisch, bei Extensions mit komplexen nativen Abhängigkeiten wie manchen ICU- oder GD-Kombinationen kann es jedoch zu subtilen Unterschieden im Verhalten kommen, die auf Debian-Basis nicht auftreten.

Neben dem Base-Image ist die Konfiguration des FPM-Process-Managers entscheidend für die Stabilität unter Last. Der dynamic-Modus passt die Anzahl der Worker-Prozesse automatisch an, verursacht aber unter Lastspitzen kurze Verzögerungen beim Hochfahren neuer Worker. Der static-Modus startet sofort die maximale Anzahl an Workern und eliminiert diese Verzögerung, verbraucht dafür dauerhaft mehr Arbeitsspeicher. In einem Symfony Docker Image, das in Kubernetes horizontal skaliert wird, ist static mit einer moderaten Worker-Zahl pro Pod meist die robustere Wahl, da die horizontale Skalierung ohnehin für zusätzliche Kapazität sorgt.


; docker/php/fpm-pool.conf — tuned for a containerized Symfony application
[www]
user = www-data
group = www-data
listen = 9000

; Static pool: predictable memory footprint, no cold-start latency
pm = static
pm.max_children = 8
pm.max_requests = 500

; Log slow requests instead of silently absorbing them
request_terminate_timeout = 30s
slowlog = /proc/self/fd/2
request_slowlog_timeout = 5s

; Production-safe OPcache defaults for this image
php_admin_value[opcache.validate_timestamps] = 0
php_admin_value[opcache.memory_consumption] = 256
php_admin_value[expose_php] = off

5. Security-Hardening: Non-Root-User und minimale Base-Images

Ein produktives Symfony Docker Image läuft niemals als Root. Die offiziellen PHP-Images bringen bereits einen www-data-Benutzer mit, und die USER-Anweisung am Ende der Dockerfile stellt sicher, dass der PHP-FPM-Prozess mit reduzierten Rechten startet. Sollte ein Angreifer über eine Anwendungsschwachstelle Code im Container ausführen können, begrenzt der Non-Root-Kontext den Schaden erheblich: kein Zugriff auf Systemdateien außerhalb des Anwendungsverzeichnisses, kein Installieren neuer Pakete, keine Manipulation anderer Prozesse im selben Namespace.

Zusätzlich zum Non-Root-Benutzer sollte das Dateisystem außerhalb von var/cache, var/log und temporären Verzeichnissen als read-only gemountet werden. Docker unterstützt das über die Laufzeitoption --read-only in Kombination mit expliziten tmpfs-Mounts für die Verzeichnisse, in die Symfony tatsächlich schreiben muss. Diese Kombination aus Non-Root-User und Read-Only-Filesystem reduziert die Angriffsfläche eines Symfony Docker Image erheblich, ohne die Anwendung selbst anzufassen.


#!/usr/bin/env bash
# run-hardened.sh — start the Symfony Docker image with reduced privileges
set -euo pipefail

docker run -d \
  --name symfony-app \
  --read-only \
  --tmpfs /var/www/html/var/cache:rw,size=256m \
  --tmpfs /tmp:rw,size=64m \
  --security-opt no-new-privileges:true \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --user www-data \
  --memory 512m \
  --pids-limit 200 \
  registry.mironsoft.de/symfony-app:1.4.2

echo "[OK] Hardened Symfony container started as non-root, filesystem read-only"

6. Frontend-Assets und Symfony-Cache im Image vorbauen

Ein Symfony Docker Image, das erst beim Containerstart den Produktions-Cache aufwärmt, verzögert jeden Rollout um mehrere Sekunden und riskiert, dass die ersten Anfragen an einen frisch gestarteten Container auf einen unvollständigen Cache treffen. Der robustere Ansatz führt bin/console cache:warmup --env=prod bereits während des Docker-Builds aus, sodass der kompilierte Container-, Routen- und Twig-Cache Teil des Images selbst wird. Der Container startet dann sofort betriebsbereit, ohne beim ersten Request nachzuarbeiten.

Gleiches gilt für Frontend-Assets. Wird Webpack Encore oder AssetMapper genutzt, gehört der komplette Asset-Build in die separate Build-Stage aus Abschnitt zwei. Nur die kompilierten, versionsstabilen Dateien aus public/build landen im finalen Symfony Docker Image, niemals der Quellcode der Assets, node_modules oder der Encore-Build-Cache. Das hält das Runtime-Image klein und stellt sicher, dass Asset-Versionierung und Symfony-Cache exakt zu demselben Codestand passen, aus dem sie gebaut wurden.

7. Docker-Healthcheck und sauberes Signal-Handling

Ohne eine HEALTHCHECK-Anweisung weiß eine Orchestrierungsschicht wie Docker Compose oder Kubernetes nur, ob der Hauptprozess im Container läuft, nicht ob die Anwendung tatsächlich Anfragen sinnvoll beantwortet. Ein Symfony Docker Image sollte deshalb einen leichten HTTP-Endpunkt bereitstellen, der den Datenbankzugriff, die Cache-Verbindung und den Anwendungsstatus prüft, ohne selbst teure Berechnungen auszulösen. Dieser Endpunkt wird sowohl vom Docker-Healthcheck als auch später von Kubernetes-Probes genutzt.

Ebenso wichtig ist korrektes Signal-Handling beim Container-Shutdown. PHP-FPM reagiert auf SIGTERM mit einem sofortigen, hartem Abbruch laufender Requests, nicht mit einem geordneten Graceful Shutdown. Ein Init-Prozess wie tini als ENTRYPOINT stellt sicher, dass Signale korrekt an PHP-FPM weitergereicht werden und Zombie-Prozesse nicht liegen bleiben, wenn der Container beendet wird. Für echtes Graceful Shutdown lohnt sich zusätzlich ein kurzer preStop-Hook, der dem Load Balancer Zeit gibt, den Container aus der Rotation zu nehmen, bevor SIGTERM tatsächlich gesendet wird.


# Add to the runtime stage of the Symfony Docker image
FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache tini curl

# tini as PID 1 forwards signals correctly to php-fpm
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["php-fpm"]

# Docker-native healthcheck against a lightweight endpoint
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
  CMD curl -f http://localhost:9000/health || exit 1

8. Image-Größe reduzieren: Alpine und Distroless im Vergleich

Ein kleineres Symfony Docker Image zieht schneller in der Registry, startet schneller in neuen Pods und reduziert die Angriffsfläche, weil weniger installierte Software potenzielle Schwachstellen mitbringt. Alpine-basierte Images landen für eine typische Symfony-Anwendung mit den nötigen PHP-Extensions oft bei 120 bis 180 MB, während Debian-Slim-Varianten leicht 250 bis 350 MB erreichen. Distroless-Images gehen noch einen Schritt weiter und enthalten nach dem Build überhaupt keine Shell, keinen Paketmanager und keine Debug-Werkzeuge mehr, was die Angriffsfläche zusätzlich minimiert.

Der Preis für Distroless-Images ist eingeschränkte Debugbarkeit: Ohne Shell im Container lässt sich kein exec -it mehr in den laufenden Container öffnen, um schnell etwas zu prüfen. In der Praxis empfiehlt sich für die meisten Teams ein Alpine-basiertes Symfony Docker Image als guter Kompromiss zwischen Größe, Kompatibilität und operativer Praktikabilität, während Distroless für besonders sicherheitskritische Umgebungen mit etablierten Observability-Werkzeugen infrage kommt, die ein Shell-Debugging im Container überflüssig machen.

9. Docker-Strategien für Symfony im direkten Vergleich

Die folgende Übersicht fasst zusammen, wie sich verschiedene Build-Ansätze für ein Symfony Docker Image hinsichtlich Größe, Sicherheit und Betriebsaufwand unterscheiden.

Strategie Typische Größe Build-Zeit Einsatz
Single-Stage, Debian 450 bis 600 MB langsam, kein Cache-Vorteil nur für schnellen Prototypenbau geeignet
Multi-Stage, Debian-Slim 250 bis 350 MB mittel solide Standardwahl mit breiter Extension-Kompatibilität
Multi-Stage, PHP-FPM Alpine 120 bis 180 MB schnell mit BuildKit-Cache empfohlener Standard für Produktions-Images
Distroless Runtime-Stage unter 100 MB schnell, komplexeres Setup sicherheitskritische Umgebungen mit externem Debugging

Für die meisten Symfony-Teams ist die dritte Zeile der Tabelle der pragmatische Zielzustand: ein mehrstufiger Build auf PHP-FPM-Alpine-Basis, konsequent gecacht und mit Non-Root-User betrieben. Ein Symfony Docker Image dieser Kategorie lässt sich in unter einer Minute bauen, wiegt deutlich unter 200 MB und startet in Sekundenbruchteilen neu, was insbesondere beim horizontalen Skalieren in Kubernetes spürbar wird.

Mironsoft

Symfony DevOps, Container-Infrastruktur und produktionsreife Deployments

Symfony Docker Images, die im Betrieb wirklich standhalten?

Wir bauen mehrstufige, gehärtete Symfony Docker Images: schlanke Runtime-Stage, gecachtes Composer-Layer, Non-Root-Betrieb und Healthchecks, die zu euren Kubernetes-Probes passen.

Dockerfile-Audit

Bestehende Symfony Docker Images auf Größe, Caching und Sicherheitslücken prüfen

Multi-Stage-Migration

Bestehende Single-Stage-Builds auf schlanke, gecachte Multi-Stage-Pipelines umstellen

CI-Integration

BuildKit-Caching und Registry-Strategie in eure bestehende Pipeline einbauen

10. Zusammenfassung

Ein produktionsreifes Symfony Docker Image entsteht nicht durch einen einzigen Dockerfile-Befehl, sondern durch das konsequente Zusammenspiel mehrerer Prinzipien. Multi-Stage-Builds trennen Build-Werkzeuge sauber von der Runtime-Umgebung. Die richtige Layer-Reihenfolge sorgt dafür, dass Composer-Abhängigkeiten nur bei tatsächlichen Änderungen neu installiert werden. Ein Non-Root-User und ein read-only Dateisystem reduzieren die Angriffsfläche erheblich, ohne den Anwendungscode anzufassen.

Cache-Warmup und Asset-Build gehören in den Buildprozess, nicht in den Containerstart, damit jeder neue Container sofort betriebsbereit ist. Ein Healthcheck mit einem leichten Endpunkt und sauberes Signal-Handling über tini machen den Container für Orchestrierungswerkzeuge berechenbar. Wer diese Punkte konsequent umsetzt, erhält ein Symfony Docker Image, das klein, sicher und in Sekunden startklar ist, egal ob es in Docker Compose, Kubernetes oder einer anderen Orchestrierungsschicht läuft.

Symfony Docker Images für die Produktion — Das Wichtigste auf einen Blick

Multi-Stage-Build

Build-Werkzeuge in einer separaten Stage halten. Nur Vendor-Ordner, kompilierte Assets und Anwendungscode landen im Runtime-Image.

Composer-Caching

Lock-Dateien zuerst kopieren, danach installieren. BuildKit-Cache-Mounts sparen Minuten bei jedem Build.

Security-Hardening

Non-Root-User, read-only Filesystem, minimale Alpine-Basis. Weniger installierte Software, weniger Angriffsfläche.

Betriebsbereitschaft

Cache-Warmup während des Builds, HEALTHCHECK gegen echten Endpunkt, tini für sauberes Signal-Handling.

11. FAQ: Symfony Docker Images in der Produktion

1Was unterscheidet Produktions- von Entwicklungsimage?
Kein Debugger, keine Build-Werkzeuge, kein gemounteter Code. Unveränderlich, einmal in CI gebaut, mit reduzierten Rechten betrieben.
2Warum Alpine als Base-Image?
Kleinere Basisgröße, weniger Angriffsfläche. Musl statt glibc ist für die meisten Symfony-Setups unproblematisch, Extension-Kompatibilität vorab prüfen.
3Wie Composer-Builds beschleunigen?
Lock-Dateien zuerst kopieren und installieren, danach Code kopieren. BuildKit-Cache-Mount für das Composer-Verzeichnis nutzen.
4Static oder dynamic Process-Manager?
Static mit moderater Worker-Zahl je Pod, kombiniert mit horizontaler Skalierung über mehr Replikas statt dynamischer Worker.
5Warum niemals als Root laufen?
Root im Container erlaubt bei Kompromittierung deutlich mehr Schaden. Der mitgelieferte www-data-Benutzer reduziert das Risiko erheblich.
6Was bringt ein read-only Dateisystem?
Angreifer können keine Dateien verändern, selbst mit Codeausführung nicht. Nur explizite tmpfs-Mounts bleiben beschreibbar.
7Warum cache:warmup im Build statt beim Start?
Beim Start aufgewärmt, verzögert das jeden Container und riskiert unvollständigen Cache bei ersten Requests. Im Build ist der Cache bereits Teil des Images.
8Wozu tini als Entrypoint?
PHP-FPM als PID 1 verarbeitet Signale nicht sauber. tini leitet Signale korrekt weiter und ermöglicht geordnetes Herunterfahren.
9Wie groß sollte das fertige Image sein?
Zwischen 120 und 180 MB mit Multi-Stage-Build auf Alpine-Basis. Werte über 300 MB deuten auf fehlendes Multi-Stage-Setup hin.
10Wann Distroless statt Alpine?
Wenn maximale Sicherheit wichtiger ist als Shell-Zugriff und externes Debugging über Logs und Metriken bereitsteht.