Docker in Produktion: Best Practices für PHP-Anwendungen

Docker in Produktion: Best Practices für PHP-Anwendungen

Foto von PortCalls Asia auf Unsplash

Ein Docker-Image, das in der Entwicklung wunderbar läuft, kann in Produktion bereits beim ersten Lastversuch zusammenbrechen. Nicht weil Docker ein schlechtes Werkzeug ist, sondern weil PHP in Containern Eigenheiten hat, die in den meisten Tutorials nicht erwähnt werden.

PHP ist seit über 25 Jahren produktionserprobt, Docker seit über 10 Jahren etabliert. Trotzdem sehe ich in technischen Audits regelmäßig dieselben vermeidbaren Probleme: aufgeblähte Images, falsch konfigurierte PHP-FPM-Pools, Logs, die niemand findet, und Sicherheits-Defaults, die seit Jahren als überholt gelten.

Dieser Artikel ist keine Einführung in Docker. Wer das Dockerfile-Konzept noch nicht kennt, sollte mit den offiziellen Docker-Tutorials beginnen. Es geht hier um die Praktiken, die nach dem Hello-World kommen und die unter realer Last über Stabilität entscheiden. Stand der Versionen: PHP 8.4 (aktuell 8.4.19), Laravel 13, Composer 2.x, BuildKit als Default-Builder.

Multi-Stage-Builds, der Pflichtstandard

Ohne Multi-Stage-Builds landen Composer, Node, Build-Tools und Test-Bibliotheken im finalen Image. Ergebnis: 1,2 GB Images für Anwendungen, die zur Laufzeit unter 250 MB brauchen.

Die zwei Standard-Stages

Die Mindest-Architektur trennt Build und Runtime in zwei Stages. Stage 1 hat alle Build-Werkzeuge, Stage 2 hat nur, was zur Laufzeit nötig ist.

# syntax=docker/dockerfile:1.7
 
# Stage 1: Composer-Dependencies bauen
FROM composer:2.8 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install \
        --no-dev \
        --no-scripts \
        --no-autoloader \
        --prefer-dist \
        --no-interaction
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
 
# Stage 2: Runtime
FROM php:8.4.19-fpm-bookworm AS runtime
WORKDIR /var/www/html
 
RUN apt-get update && apt-get install -y --no-install-recommends \
        libpq-dev libzip-dev libicu-dev \
    && docker-php-ext-install -j$(nproc) pdo_pgsql opcache intl zip \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*
 
COPY --from=vendor /app/vendor /var/www/html/vendor
COPY . /var/www/html
 
RUN useradd -u 1000 -m -s /bin/bash www \
    && chown -R www:www /var/www/html
 
USER www
EXPOSE 9000
CMD ["php-fpm"]

Drei Details, die den Unterschied machen:

Versions-Pinning: php:8.4.19-fpm-bookworm statt php:8.4-fpm. Floating Tags holen unerwartet eine andere Version, sobald das Base-Image aktualisiert wird. Reproduzierbarkeit ist nicht optional.

Cache-Mounts: RUN --mount=type=cache,target=/tmp/cache ist ein BuildKit-Feature, das den Composer-Cache zwischen Builds erhält. Das reduziert die Build-Zeit für Dependency-Downloads um 60 bis 80 Prozent in CI-Pipelines. Aktiviert wird BuildKit über DOCKER_BUILDKIT=1 oder ist in modernen Docker-Versionen Default.

Composer-Layer-Reihenfolge: Erst composer.json und composer.lock kopieren, dann composer install ohne Autoloader, dann den Code, dann composer dump-autoload. Damit cached Docker den teuren Install-Schritt zwischen Code-Änderungen.

Erweiterte Stages

Bei Anwendungen mit Frontend-Build kommt eine Node-Stage dazu, bei sauberer CI-Trennung eine Test-Stage. Letztere wird in der Pipeline mit --target=test aufgerufen, ohne dass Test-Dependencies ins Produktiv-Image landen.

# Stage: Frontend-Assets bauen
FROM node:20-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY resources ./resources
COPY vite.config.js tailwind.config.js ./
RUN npm run build
 
# In der Runtime-Stage:
COPY --from=assets /app/public/build /var/www/html/public/build

Was nicht ins finale Image gehört

composer.json mit Dev-Dependencies, das .git-Verzeichnis, Test-Suites, Tools wie PHPStan oder Pint und die tests/-Struktur. Eine sauber gepflegte .dockerignore schließt diese Pfade beim COPY . . aus und macht den Build deterministisch.

# .dockerignore
.git
.github
.idea
node_modules
tests
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
.env*
docker-compose*.yml
README.md
phpunit.xml
phpstan.neon
pint.json

Image-Größe ist nicht eitle Optimierung. Kleinere Images bedeuten schnelleres Deployment, schnellere Container-Starts, kleinere Angriffsfläche und niedrigere Storage-Kosten in der Container-Registry.

Base-Image-Wahl: Alpine, Slim oder Distroless

Die Wahl des Base-Images ist eine Wartungs- und Sicherheitsentscheidung, keine reine Größenfrage.

Alpine

Alpine-basierte Images sind 5 bis 10 MB groß und damit die kompakteste Option. Der Haken: Alpine nutzt musl libc statt glibc. Manche PHP-Extensions (mongodb, oci8, einige Crypto-Libraries) bauen mit Sonderbehandlung oder gar nicht. Zudem haben sich in der Community in den letzten Jahren wiederholt subtile Verhaltensunterschiede gezeigt, etwa bei DNS-Auflösung oder Speicher-Allokation unter hoher Last.

Empfehlung: Wenn die App mit Alpine kompiliert und alle benötigten Extensions verfügbar sind, ist Alpine eine gute Wahl. Bei den ersten musl-Stolperfallen schneller wechseln als stundenlang debuggen.

Debian Slim

Debian Slim mit dem bookworm-Tag liegt bei 80 bis 120 MB. Vorteil: glibc, breite Extension-Kompatibilität, vertraute Paket-Tools. Nachteil: größer, mehr Pakete, mehr potentielle CVEs.

Für die meisten Laravel-Projekte ist Debian Slim die sinnvolle Default-Wahl. Die Image-Größe rechtfertigt die zusätzlichen 80 MB durch Stabilität und Kompatibilität.

Distroless

Google pflegt mit dem Distroless-Projekt eine Familie minimaler Images, die weder Shell noch Paketmanager enthalten. Vorteil: maximale Sicherheit, minimale Angriffsfläche. Nachteil: Debugging ist deutlich aufwändiger, der Build benötigt externe Tooling, und für PHP gibt es kein offizielles Distroless-FPM-Image.

Für sicherheitskritische Workloads in regulierten Umgebungen ist der Aufwand vertretbar. Für die meisten Mittelstand-Projekte ist Debian Slim mit gepflegten Updates der pragmatischere Weg.

Die Vergleichstabelle

Base-ImageGröße (PHP 8.4 FPM)KompatibilitätWartungsaufwandSinnvoll für
Alpine100 bis 130 MBEingeschränkt (musl)MittelStandard-Apps ohne Spezial-Extensions
Debian Slim180 bis 230 MBBreit (glibc)NiedrigDefault für Laravel-Projekte
Distroless90 bis 120 MBEingeschränktHochRegulierte, sicherheitskritische Workloads

Die Werte schwanken je nach installierten Extensions. Eine reale Laravel-App mit pdo, intl, gd, zip und opcache liegt mit Debian Slim typischerweise bei 230 bis 280 MB, mit Alpine bei 130 bis 180 MB.

PHP-FPM in Containern richtig konfigurieren

Das häufigste Performance-Problem produktiver PHP-Container ist nicht Docker, sondern PHP-FPM. Die Standard-Konfiguration ist für Bare-Metal mit 16 GB RAM gedacht, nicht für Container mit 512 MB Memory-Limit.

Worker-Sizing nach Container-Memory

Die Faustregel lautet:

pm.max_children = (Container-Memory in MB) / (Speicher pro Worker in MB)

Worker-Speicher messen, nicht raten. Eine reale Laravel-App mit aktivem OPcache liegt typischerweise bei 80 bis 150 MB pro Worker. Bei 1 GB Container-Memory und 100 MB pro Worker ist pm.max_children = 8 ein realistischer Wert. Reserve für PHP-FPM-Master, Sidecars und unerwartete Lastspitzen einplanen, lieber 8 statt 10.

Welcher pm-Modus

In Containern mit festen Memory-Limits ist pm = static oft die beste Wahl. Der Speicherbedarf ist vorhersagbar, OOM-Kills passieren seltener. pm = dynamic ist flexibler, aber schwerer zu kalibrieren. pm = ondemand spart Speicher, kostet aber Latenz beim ersten Request nach Idle-Phasen.

; pool.conf für Container mit 1 GB Memory
[www]
user = www
group = www
listen = 9000
 
pm = static
pm.max_children = 8
pm.max_requests = 500
 
; Stdout/Stderr für Container-Logs
access.log = /proc/self/fd/2
catch_workers_output = yes
decorate_workers_output = no
 
; Worker-Lifecycle
request_terminate_timeout = 60s
slowlog = /proc/self/fd/2
request_slowlog_timeout = 5s
 
; Umgebungsvariablen vom Container durchreichen
clear_env = no

Drei Parameter, die in Containern besonders relevant sind:

pm.max_requests: Werte zwischen 200 und 1000 verhindern Memory-Leaks, ohne Worker zu früh neu zu starten. Bei 0 (Default) bleiben Worker unendlich aktiv und Memory-Wachstum durch Leaks führt früher oder später zu OOM-Kills.

request_terminate_timeout: Explizit setzen. Ohne Limit hängen Worker an langsamen externen Calls (Datenbank, HTTP-APIs, S3) und blockieren den Pool.

slowlog: Schreibt langsame Requests nach stderr. Unverzichtbar für Performance-Debugging in Produktion. Ohne Slow-Log sucht man die langsamen Pfade per Vermutung.

OPcache in Produktion

OPcache ist der größte einzelne Performance-Faktor einer PHP-App. In Produktion sieht die Konfiguration anders aus als in der Entwicklung.

; php.ini Production-Settings
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.preload = /var/www/html/preload.php
opcache.preload_user = www
opcache.jit_buffer_size = 100M
opcache.jit = tracing
 
realpath_cache_size = 4096K
realpath_cache_ttl = 600

validate_timestamps = 0 spart bei jedem Request einen stat-Syscall pro PHP-Datei. Voraussetzung: Bei jedem Deployment wird der OPcache invalidiert oder der Container neu gestartet. In Container-Workflows ist das ohnehin der Fall, weil ein neues Image einen neuen Container erzeugt.

opcache.preload lädt Framework-Klassen einmal beim FPM-Start in den Speicher und teilt sie zwischen allen Workern. Für Laravel und Symfony bringt das 10 bis 25 Prozent geringere Response-Zeiten in CPU-lastigen Pfaden. Wichtig: opcache.preload_user muss gesetzt sein, weil Preload sonst beim Start als Root läuft.

JIT bringt 5 bis 30 Prozent Performance bei CPU-lastigen Workloads. Bei reinen IO-Workloads (Datenbank, externe APIs) ist der Effekt klein. Vor Aktivierung mit realistischen Lasttests messen, in seltenen Fällen kann der Tracing-JIT bei Code mit vielen kurzen Funktionen Performance kosten.

Composer und Autoloader-Optimierung

Composer ist in Produktion kein Entwicklungswerkzeug, sondern ein Installations-Schritt mit klaren Flags.

RUN composer install \
    --no-dev \
    --optimize-autoloader \
    --classmap-authoritative \
    --no-interaction \
    --prefer-dist

Die vier wichtigen Flags:

FlagWirkung
--no-devPHPUnit, Pint, Faker, Mockery bleiben draußen
--optimize-autoloaderPSR-4-Auflösung wird durch eine Classmap ersetzt
--classmap-authoritativeKeine Filesystem-Lookups für nicht-gefundene Klassen
--prefer-distKeine Git-Histories werden geclont, kleinere Layer

Der Performance-Unterschied zwischen Standard-Autoloader und --classmap-authoritative ist messbar. Bei großen Laravel-Apps mit hunderten Klassen reduziert sich die Autoload-Zeit pro Request um eine bis zwei Größenordnungen.

Laravel-spezifische Caches

RUN php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache \
    && php artisan event:cache

In Produktion immer setzen, in Build-Zeit, nicht zur Laufzeit. Cache-Generation zur Laufzeit kostet wertvolle Sekunden beim Container-Start und macht das Image-Verhalten nicht-deterministisch.

Wichtig: config:cache führt dazu, dass env()-Aufrufe außerhalb von Config-Dateien null zurückgeben. Das ist kein Bug, sondern Absicht: Konfiguration kommt zur Laufzeit aus dem Cache, nicht aus der Umgebung. Wer trotzdem env() im Code nutzt, hat einen Bug, der erst in Produktion auffällt.

Logging: stdout, stderr und nichts anderes

Die Grundregel für Container-Logs: Alles geht nach stdout und stderr. Punkt.

Docker, Kubernetes, ECS, jede Runtime liest stdout. Wer in Dateien loggt, umgeht die zentrale Log-Pipeline und braucht zusätzliche Sidecars, Volumes oder Tools wie Filebeat, um die Logs überhaupt zu sehen.

PHP-FPM auf stdout konfigurieren

; php-fpm.conf
error_log = /proc/self/fd/2
 
; pool.conf
access.log = /proc/self/fd/2
catch_workers_output = yes
decorate_workers_output = no

/proc/self/fd/2 ist der File-Descriptor für stderr des aktuellen Prozesses. Alle Logs landen direkt im Container-Log und damit in der zentralen Log-Pipeline.

Laravel auf stderr

// config/logging.php
'channels' => [
    'stderr' => [
        'driver' => 'monolog',
        'level' => env('LOG_LEVEL', 'info'),
        'handler' => Monolog\Handler\StreamHandler::class,
        'formatter' => env('LOG_STDERR_FORMATTER', Monolog\Formatter\JsonFormatter::class),
        'with' => [
            'stream' => 'php://stderr',
        ],
    ],
],

In Produktion LOG_CHANNEL=stderr setzen. Der JsonFormatter macht die Logs für CloudWatch, ELK, Loki und ähnliche Tools direkt parsebar, ohne weitere Transformation.

Nginx im selben oder eigenen Container

error_log /dev/stderr warn;
access_log /dev/stdout main;

Wenn Nginx im selben Container wie PHP-FPM läuft, müssen beide Prozesse über einen Supervisor wie supervisord oder s6-overlay koordiniert werden. Sauberer ist die Trennung in zwei Container, die über eine TCP-Verbindung kommunizieren. In modernen Orchestrierungs-Plattformen (ECS, Kubernetes) ist die Trennung der Default-Ansatz.

Häufiger Fehler

Logs werden in storage/logs/laravel.log geschrieben, das Filesystem ist read-only oder läuft voll. Beides verhindert, dass die App startet oder weiter läuft. Wenn die App erst nach dem Health-Check stirbt, entsteht eine subtile Restart-Schleife, die in der Container-Runtime kaum erkennbar ist.

Container-Sicherheit

Container-Sicherheit ist kein einzelnes Feature, sondern eine Reihe von Standard-Praktiken, die zusammen die Angriffsfläche um Größenordnungen reduzieren.

Non-root user

PHP-FPM darf nicht als Root laufen, weder im Container noch in einem Pod.

RUN useradd -u 1000 -m -s /bin/bash www \
    && chown -R www:www /var/www/html
 
USER www

Die 1000 ist eine UID, die auf den meisten Linux-Systemen nicht reserviert ist. Bei Volume-Mounts ist die UID-Konsistenz zwischen Host und Container relevant, sonst stimmen die Berechtigungen nicht.

Read-only Root-Filesystem

# docker-compose.yml
services:
  app:
    image: myapp:1.2.3
    read_only: true
    tmpfs:
      - /tmp
      - /var/run
    volumes:
      - app-storage:/var/www/html/storage:rw

Der Container kann keinen Code mehr schreiben, außer in explizit freigegebenen Verzeichnissen. Das verhindert, dass Angreifer im Container Persistenz aufbauen oder Web-Shells installieren.

Laravel braucht Schreibrechte für storage/ und bootstrap/cache/. Beide werden als beschreibbares Volume oder als emptyDir gemountet. Die App selbst (Code, Vendor) bleibt read-only.

HEALTHCHECK

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl --silent --fail http://localhost/health || exit 1

Eine Container-Runtime ohne Health Check kann nicht erkennen, dass die App hängt, aber noch auf TCP antwortet. Der Endpoint sollte mehr als nur HTTP 200 liefern, im Idealfall einen DB-Check und einen Cache-Check.

// routes/web.php
Route::get('/health', function () {
    try {
        DB::connection()->getPdo();
        Cache::put('health-check', now(), 10);
    } catch (\Throwable $e) {
        return response()->json(['status' => 'unhealthy', 'error' => $e->getMessage()], 503);
    }
    return response()->json(['status' => 'ok']);
});

Image-Scanning

CVEs erscheinen täglich. Ein gestern sauberes Image kann heute eine kritische Schwachstelle haben. Trivy, Grype oder Snyk in der CI-Pipeline laufen lassen und Builds bei kritischen Findings stoppen. Die offiziellen PHP-Images werden regelmäßig aktualisiert, aber nur wer rebuiltet, bekommt die Patches.

Secret-Management

Niemals Secrets in Dockerfile oder Layer. Stattdessen BuildKit-Secrets für Build-Zeit:

RUN --mount=type=secret,id=composer_auth,target=/root/.composer/auth.json \
    composer install --no-dev

Zur Laufzeit kommen Secrets aus dem Orchestrator (ECS Secrets Manager, Kubernetes Secrets, Docker Secrets) als Environment-Variablen oder gemountete Dateien. Wer Secrets in Layern speichert, hat sie für immer in der Image-History, auch nach RUN rm secret.txt.

Performance-Faktoren, die oft übersehen werden

Drei Stellschrauben jenseits von OPcache und FPM-Sizing:

Realpath-Cache vergrößern. Der Default ist für moderne Frameworks deutlich zu klein. realpath_cache_size = 4096K und realpath_cache_ttl = 600 reduzieren Filesystem-Lookups erheblich.

Unix Socket statt TCP zwischen Nginx und PHP-FPM. Nur möglich, wenn beide im selben Container laufen, dann aber 5 bis 10 Prozent schneller. In getrennten Containern bleibt TCP die einzige Option.

Composer-Plugins prüfen. composer/package-versions-deprecated und ähnliche Plugins sind in modernen Composer-Versionen oft überflüssig. composer why zeigt, welche Pakete Plugins ziehen, die nicht mehr nötig sind.

docker-compose vs. Kubernetes vs. ECS

Die richtige Orchestrierung hängt von Team-Größe, Erfahrung und Komplexität ab, nicht vom Buzzword.

OrchestratorGeeignet fürStärkenSchwächen
docker-composeSingle-Server-Setups, EntwicklungEinfaches Mental Model, schnelles SetupKein Auto-Healing, kein Multi-Host
KubernetesGrößere Teams, Multi-Service-ArchitekturenAuto-Scaling, selbstheilend, Standard-ÖkosystemHoher Lernaufwand, Operations-Overhead
ECS FargateAWS-fokussierte Teams, mittlere KomplexitätWeniger komplex als Kubernetes, gute AWS-IntegrationVendor-Lock-in, weniger flexibel

Für die meisten PHP-Projekte im Mittelstand ist ECS Fargate oder ein dediziertes docker-compose-Setup auf einem gut gewarteten Server der bessere Weg als Kubernetes. Kubernetes lohnt sich erst, wenn die Komplexität durch andere Faktoren (Multi-Service, polyglotte Stacks, große Teams) bereits da ist. Der nächste Artikel der Serie vergleicht ECS und EKS für PHP-Workloads im Detail.

Häufige Anti-Patterns

Sieben Muster, die ich in Audits regelmäßig sehe und die in Produktion regelmäßig schmerzen:

Apache mit mod_php in Produktion-Containern. In Containern schwerer zu skalieren und zu monitoren als Nginx mit PHP-FPM. Ausnahme: Legacy-Apps mit .htaccess-Abhängigkeiten, die nicht migrierbar sind.

Ein Container für alles (App, Datenbank, Cache). Verstößt gegen das Single-Responsibility-Prinzip, macht Skalierung und Monitoring kompliziert. Datenbanken gehören in eigene Services oder Managed-Offerings.

Volume-Mounts für Anwendungscode in Produktion. Der Code gehört ins Image, nicht ins Volume. Mounts sind ein Entwicklungs-Konzept.

Build-Argumente für Konfiguration. ARG DB_HOST ist falsch, weil es das Image an eine Umgebung bindet. Konfiguration kommt zur Laufzeit über Environment-Variablen.

composer install zur Laufzeit. Verlangsamt Container-Start und macht Builds nicht reproduzierbar. Alle Abhängigkeiten gehören ins Image.

Logs in Volumes statt nach stdout. Verliert die zentrale Log-Pipeline und macht Debugging unnötig schwer.

Keine Health Checks. Container-Runtime kann nicht erkennen, dass die App hängt. Ein Health-Endpoint mit DB-Check ist Pflicht, nicht Kür.

Fazit

Docker hat den Betrieb von PHP-Anwendungen vereinfacht, aber nicht trivialisiert. Die häufigsten Probleme sind keine Docker-Probleme, sondern PHP-Probleme, die in Containern andere Symptome zeigen.

Die hier vorgestellten Praktiken sind Mindeststandard, nicht Premium. Multi-Stage-Builds, Versions-Pinning, korrekt dimensioniertes PHP-FPM, OPcache mit Preload, stdout-Logging, Non-root user, Health Checks, Image-Scanning. Wer diese acht Punkte konsequent umsetzt, hat 80 Prozent der Stabilitäts- und Sicherheitsprobleme erschlagen, die ich in Audits regelmäßig finde.

Die letzten 20 Prozent kommen aus Erfahrung, Monitoring und ehrlicher Bilanz nach Lastspitzen. Die hier dokumentierten Stellschrauben sind die Grundlage, auf der diese Erfahrung aufbaut. Eine ausführliche Behandlung der zugrundeliegenden Server-Konfiguration findet sich im LEMP-Stack-Tuning-Artikel, eine Performance-Schicht oberhalb von PHP-FPM beschreibt der Laravel-Octane-Artikel.


Sie planen die Migration einer PHP-Anwendung in Container oder zweifeln an der Stabilität Ihres aktuellen Container-Setups? Kontaktieren Sie mich für einen technischen Audit, der Image, Konfiguration und Deployment in zwei bis drei Tagen prüft.