Eine Symfony-Anwendung containerisieren: Docker, Compose und die Produktions-Fallstricke

#Docker Symfony Produktion
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Die meisten dockerisierten Symfony-Setups beginnen ihr Leben als kopiertes Tutorial. Sie bauen, sie laufen mit docker compose up, die App antwortet auf localhost, und alle machen weiter. Dann geht dasselbe Image auf einen Server und scheitert auf eine von wenigen vorhersehbaren Arten: Der Container kann nicht in var/cache schreiben, PHP liefert veralteten Code aus, weil Opcache Dateien nie erneut prüft, FPM-Worker fressen unter Last den gesamten Speicher, oder der Orchestrator leitet Traffic an einen Container, der noch gar nicht bereit ist. Ein funktionierendes Setup für Docker Symfony Produktion ist kein längeres Tutorial. Es ist eine kurze Liste von Entscheidungen, die sich zwischen Entwicklung und Produktion unterscheiden, explizit getroffen statt zufällig.

Dieser Beitrag geht diese Entscheidungen für eine Symfony-7-Anwendung durch: das Multi-Stage-Dockerfile, Dateiberechtigungen, Opcache-Verhalten, FPM-Pool-Dimensionierung, Webserver-Konfiguration, Umgang mit Secrets, Health Checks und ein lokales Compose-Setup, das auf macOS nicht kriecht.

Das Multi-Stage-Dockerfile für Docker Symfony Produktion

Ein Produktions-Image sollte Deinen Code, Dein vendor-Verzeichnis, einen aufgewärmten Cache und eine PHP-Laufzeit enthalten. Es sollte nicht Composer, git, node, Build-Caches oder Dev-Abhängigkeiten enthalten. Multi-Stage-Builds geben Dir diese Trennung in einer Datei:

# Stage 1: build
FROM composer:2 AS composer_build
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative

# Stage 2: runtime
FROM php:8.3-fpm-alpine AS runtime
RUN apk add --no-cache icu-libs \
 && docker-php-ext-install intl opcache pdo_pgsql
WORKDIR /app
COPY --from=composer_build /app /app
COPY docker/php/prod.ini /usr/local/etc/php/conf.d/zz-prod.ini
ENV APP_ENV=prod
RUN php bin/console cache:warmup
USER www-data
CMD ["php-fpm"]

Zwei Details zählen mehr als der Rest. Erstens: Kopiere composer.json und die Lock-Dateien vor dem Quellcode, damit die Installation der Abhängigkeiten im Docker-Layer-Cache bleibt und nur dann erneut läuft, wenn sich die Lock-Datei ändert. Bei einem typischen Symfony-Projekt verwandelt das einen Rebuild von drei Minuten in fünfzehn Sekunden. Zweitens: Wärme den Cache zur Build-Zeit auf. Wenn der erste Request nach dem Deploy den Container und die Routen kompilieren muss, spitzt Deine p99-Latenz bei jedem Release, und Dein Health Check läuft womöglich in einen Timeout, bevor die App überhaupt antwortet.

Wenn Deine Frontend-Assets mit Webpack Encore oder AssetMapper gebaut werden, füge eine dritte Stage auf einem node- oder PHP-Basis-Image hinzu und kopiere nur den kompilierten public/build-Output in die Runtime-Stage. Die node-Toolchain hat in einem PHP-Runtime-Image nichts verloren.

Dateiberechtigungen: das var/-Verzeichnis-Problem

Der häufigste Fehler beim ersten Deploy ist ein Berechtigungsfehler auf var/cache oder var/log. Er passiert, weil das Image als root gebaut wurde, die Dateien in /app root gehören und die PHP-FPM-Worker als www-data laufen. Lokal fällt Dir das nie auf, weil ein Bind Mount Deine Host-Berechtigungen mitbringt.

Die Lösung ist, den Besitz genau der Pfade explizit zu machen, in die Symfony schreibt:

RUN mkdir -p var/cache var/log \
 && chown -R www-data:www-data var
USER www-data

Führe kein chown -R auf die gesamte Anwendung aus. Es verdoppelt auf manchen Storage-Treibern die Image-Größe, weil jede Datei in einen neuen Layer kopiert wird, und es versteckt eine nützliche Eigenschaft: Code, den der Laufzeit-Nutzer nicht beschreiben kann, ist Code, den auch ein Angreifer nicht verändern kann, der einen Worker kompromittiert. Anwendungscode bleibt root-eigen und schreibgeschützt; nur var ist beschreibbar. Wenn Du nach stdout loggst (was Du in Containern solltest) und der Cache vorgewärmt und überwiegend lesend ist, bleibt die beschreibbare Oberfläche klein.

Opcache: warum Produktion veralteten Code ausliefert, und warum das korrekt ist

Opcache kompiliert PHP-Dateien einmal und hält den Bytecode im Shared Memory. Die Einstellung, die steuert, ob je wieder auf die Datei geschaut wird, ist validate_timestamps. In Produktion willst Du:

opcache.enable=1
opcache.validate_timestamps=0
opcache.memory_consumption=256
opcache.max_accelerated_files=25000
opcache.preload=/app/config/preload.php
opcache.preload_user=www-data

Mit validate_timestamps=0 statet PHP nach dem ersten Kompilieren nie wieder Dateien auf der Platte. Das entfernt Dateisystem-Aufrufe aus jedem Request und ist einer der günstigsten verfügbaren Performance-Gewinne. Es bedeutet auch, dass das Bearbeiten einer Datei in einem laufenden Produktions-Container nichts bewirkt, was jeden genau einmal überrascht. Das ist kein Bug, den man umgehen muss. Container sind unveränderlich; Du lieferst Änderungen aus, indem Du ein neues Image baust und den Container ersetzt, und der Deploy selbst wird zu Deiner Cache-Invalidierung.

Der Fallstrick läuft in der Entwicklung in die andere Richtung: Wenn Du eine Produktions-ini in Dein Dev-Setup kopierst, erscheinen Deine Code-Änderungen nicht mehr, und Du verbringst einen Nachmittag damit, Deine Volume Mounts zu verdächtigen. Halte zwei ini-Dateien, eine pro Umgebung, und lass Compose die Dev-Variante mounten.

PHP-FPM-Pool-Dimensionierung: die Speicher-Rechnung, die niemand macht

Die Standard-FPM-Konfiguration ist für einen Shared Host geschrieben, nicht für einen Container mit Speicherlimit. Unter Last ist pm.max_children die einzige Einstellung zwischen Dir und dem OOM-Killer, und die Rechnung ist kurz: Spitzenspeicher pro Worker mal maximale Worker-Anzahl muss ins Container-Limit passen.

Miss den realen Worker-Speicher mit ps unter Last oder über Dein APM; eine typische Symfony-App landet zwischen 50 und 120 MB pro Worker. Für einen Container mit 1 GB Limit und Workern mit durchschnittlich 80 MB:

pm = static
pm.max_children = 10
pm.max_requests = 500

Statische Pools sind in Containern der richtige Standard. Dynamisches Spawnen ergab Sinn, als FPM sich eine Maschine mit anderen Mietern teilte; in einem Container, dem seine Speicherzuteilung gehört, willst Du eine vorhersehbare Worker-Anzahl und kein Fork-Geflatter bei Traffic-Spitzen. pm.max_requests recycelt Worker periodisch, was langsame Leaks in Extensions und langlebige Referenzen überdeckt. Wenn Du Container mit Exit-Code 137 sterben siehst, ist das der Abschnitt, den Du prüfen solltest, bevor Du die Plattform beschuldigst.

Der Front Controller: nginx und Caddy, die tatsächlich korrekt routen

Symfony leitet alles durch public/index.php, und eine subtil falsche Webserver-Konfiguration erzeugt Symptome, die wie Anwendungsfehler aussehen: Assets, die 404 liefern, URLs, die nur mit index.php darin funktionieren, oder PATH_INFO-Exploits, bei denen /uploads/evil.jpg/foo.php den Interpreter erreicht. Der nginx-Block, der sich richtig verhält:

server {
    root /app/public;
    location / {
        try_files $uri /index.php$is_args$args;
    }
    location ~ ^/index\.php(/|$) {
        fastcgi_pass app:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        internal;
    }
    location ~ \.php$ {
        return 404;
    }
}

Die internal-Direktive und der abschließende Block location ~ \.php$ { return 404; } sind die sicherheitsrelevanten Zeilen: Nur index.php wird jemals ausgeführt, und nur über internes Rewrite. Mit Caddy ist das Äquivalent ein Einzeiler (php_fastcgi app:9000 mit root /app/public), was ein echtes Argument für Caddy in kleinen Teams ist. Wenn Du das Paar aus FPM und Webserver lieber ganz entfernen willst, haben wir diesen Ansatz in FrankenPHP in production verglichen.

Umgebungsvariablen und Secrets

Symfony gibt Dir zwei Mechanismen, und sie fügen sich sauber in Container ein: Umgebungsvariablen für Konfiguration, die pro Umgebung variiert, und den Secrets-Vault für Werte, die nicht im Klartext erscheinen dürfen. In Containern bevorzuge Injektion zur Laufzeit gegenüber dem Einbacken von Werten in Images. Ein Image mit einem Datenbank-Passwort in einem ENV-Layer leakt dieses Passwort an jeden, der das Image pullen kann.

Die praktische Aufteilung: unkritische Konfiguration (APP_ENV, Feature Flags, Hostnamen) als einfache Umgebungsvariablen von Deinem Orchestrator; sensible Werte entweder aus dem Secret Store des Orchestrators (Kamal Secrets, ECS Secrets, Kubernetes Secrets), injiziert als Env-Variablen beim Container-Start, oder über Symfonys Vault, bei dem nur der Entschlüsselungsschlüssel injiziert wird. Committe niemals .env.local, und schreibe niemals COPY .env* . in ein Dockerfile, ohne darüber nachzudenken, was im Image landet.

Health Checks, die die Wahrheit sagen

Der fehlende Health Check ist der leiseste Fallstrick auf dieser Liste. Kamal, ECS und Kubernetes entscheiden alle, wann ein Container Traffic erhält, und ohne echten Check entscheiden sie auf Basis von "der Prozess ist gestartet", was nicht dasselbe ist wie "Symfony kann Requests beantworten". Das Ergebnis ist ein Schwall von 502ern bei jedem einzelnen Deploy, meist als Netzwerk-Flakiness abgetan.

Stelle eine Route bereit, die das Framework ausübt, ohne auf Abhängigkeiten einzuhämmern:

#[Route('/healthz', name: 'healthz')]
public function healthz(Connection $db): JsonResponse
{
    $db->executeQuery('SELECT 1');
    return new JsonResponse(['status' => 'ok']);
}

Verdrahte sie im Image, damit jeder Orchestrator sie aufgreift:

HEALTHCHECK --interval=10s --timeout=3s --start-period=15s \
  CMD php -r 'exit(strpos(@file_get_contents("http://127.0.0.1/healthz"), "ok") !== false ? 0 : 1);'

start-period ist wichtig: Es gibt dem Container Zeit zu booten, bevor Fehlschläge zählen. Halte den Check günstig und ehrlich. Ein Check, der vier nachgelagerte Dienste anpingt, verwandelt jeden Abhängigkeits-Schluckauf in eine volle Restart-Kaskade; ein Check, der bedingungslos 200 zurückgibt, ist Dekoration. Wenn Du mit Kamal deployst, hängt der Zero-Downtime-Deploy-Ablauf vollständig davon ab, dass dieser Endpoint ehrlich ist.

Lokale Entwicklung: Compose ohne die macOS-Steuer

Das Produktions-Image ist unveränderlich; die Entwicklung braucht das Gegenteil, und der klassische Fehler ist, eine Dockerfile-Stage für beides zu verwenden. Füge eine Dev-Stage mit xdebug und lockereren ini-Einstellungen hinzu und mounte den Quellcode über die Image-Kopie:

services:
  app:
    build:
      context: .
      target: dev
    volumes:
      - ./:/app
      - /app/vendor
      - /app/var

Die zwei anonymen Volumes sind der macOS-Performance-Fix. Docker Desktops File Sharing ist dramatisch langsamer als natives I/O, und vendor plus var machen die meisten Dateioperationen pro Request aus. Sie vom Bind Mount auszunehmen hält diese Pfade auf dem nativen Dateisystem der VM; mit VirtioFS bringt das einen typischen Request von Sekunden auf Normalniveau. Der Kompromiss ist, dass composer install im Container laufen muss, was ohnehin die richtige Gewohnheit ist.

Entwicklung vs. Produktion: die ini-Einstellungen, die sich unterscheiden müssen

EinstellungEntwicklungProduktion
opcache.validate_timestamps10
display_errorsOnOff
memory_limit512MDimensioniert nach der FPM-Pool-Rechnung
pmdynamic, wenige Childrenstatic, berechnete Children
APP_ENVdevprod, Cache vorgewärmt
xdebugNur in der Dev-Stage installiertNicht im Image vorhanden

Wenn irgendein Produktionswert oben Deinem Entwicklungswert entspricht, verdient diese Zeile einen zweiten Blick.

Die Pre-Deploy-Checkliste

Prüfe vor dem ersten echten Deploy: Das Image baut ohne Dev-Abhängigkeiten; var ist der einzige beschreibbare Pfad und gehört www-data; Opcache-Timestamps sind in prod aus und in dev an; pm.max_children mal Worker-Speicher passt ins Container-Limit; nur index.php ist über den Webserver ausführbar; kein Secret ist in einen Image-Layer eingebacken; /healthz schlägt fehl, wenn die App wirklich nicht ausliefern kann; und eine Code-Änderung auf Deinem Mac erscheint ohne Rebuild im Browser.

Keiner dieser Schritte ist für sich genommen schwierig. Was Containerisierungsprojekte in die Länge zieht, ist, sie einen Vorfall nach dem anderen zu entdecken. Wenn Du eine bestehende Symfony-Anwendung in Container bewegst, bringt das meist auch ältere Probleme an die Oberfläche, von implizitem Server-Zustand bis zu Konfiguration, die nur auf einer Maschine existiert; das ist die Modernisierungsarbeit, die wir in Legacy Code Optimization leisten. Und wenn Du ein zweites Paar Augen auf einem Setup willst, bevor es Produktions-Traffic übernimmt, fängt ein fokussiertes Review über unser Code Quality Consulting das meiste dieser Liste an einem Tag.

Fragen zu einem konkreten Setup? Schreib an hello@wolf-tech.io oder finde uns auf wolf-tech.io.