Produktions-PHP-Docker-Images bauen — Die Basis-Schicht (Teil 1/3)
Jedes PHP-Team steht irgendwann vor der gleichen Frage: Wie liefert man eine PHP-Runtime aus, die über Dev, Staging und Produktion konsistent ist — auf Intel-Servern und ARM-Laptops läuft — und nicht zu einem 2-GB-Blob wird, den niemand versteht?
Wir haben es mit einem geschichteten Docker-Image-System gelöst. Ein einzelnes Base-Image liefert das Fundament: OS-Pakete, PHP-Extensions, FPM-Tuning, OPcache und strukturiertes Log-Routing. Darauf setzen Variation-Images projektspezifische Extensions und Module auf. Das Ganze baut für linux/amd64 und linux/arm64 in einem Befehl.
Das ist Teil 1 einer 3-teiligen Serie. Wir behandeln das Base-Image, seine Flags und die Konfiguration, die es produktionsreif macht. Teil 2 taucht in Custom-Module und Variation-Images ein. Teil 3 verdrahtet alles mit GitHub Actions für CI/CD.
Die Namenskonvention
Bevor wir ein einziges Dockerfile geschrieben haben, haben wir einen Naming-Standard festgelegt. Jeder Image-Tag codiert genau, was drin ist:
registry.example.com/php:<flavour>-<php-version>-<release-version>
Base-Images (kein Flavour):
registry.example.com/php:8.4-1.0.0
registry.example.com/php:8.1-1.0.0
Variation-Images:
registry.example.com/php:amber-8.4-1.0.0
registry.example.com/php:amber-8.1-1.0.0-dev
Man kann jeden Tag ansehen und sofort die PHP-Version, das interne Release und ob es ein Dev-Build ist, erkennen. Kein Raten. Kein latest-Roulette.
Das Base-Dockerfile
Das Base-Image startet von serversideup/php, das uns eine solide Debian-basierte PHP-FPM-Runtime gibt. Darauf schichten wir alles, was ein Produktions-Workload braucht:
ARG PHP_VERSION='8.4'
ARG PHP_VARIATION='fpm'
FROM serversideup/php:${PHP_VERSION}-${PHP_VARIATION}
ARG DEPENDENCY_EXTRA_PHP_EXTENSIONS='sockets intl bcmath'
ARG DEPENDENCY_PACKAGES_DEBIAN='vim vim-tiny net-tools nano iputils-ping telnet git'
USER root
RUN apt update && \
apt upgrade -y && \
apt install -y ${DEPENDENCY_PACKAGES_DEBIAN} && \
apt clean && \
apt purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*
Ein paar Dinge, die erwähnenswert sind:
ARG für PHP-Extensions. sockets, intl und bcmath sind als Build-Argumente deklariert, nicht hardcodiert. Jedes nachgelagerte Image kann sie überschreiben. Das ist der „zusätzliche Flags"-Ansatz — das Base-Image ist konfigurierbar, ohne es zu forken.
Aggressives Aufräumen. Das apt purge und rm -rf im selben RUN-Layer hält das Image klein. Docker-Layer sind unveränderlich — wenn man Pakete in einem Layer installiert und in einem anderen aufräumt, bleibt der Ballast.
OS-Utilities sind bewusst gewählt. vim, net-tools, iputils-ping, telnet — die sehen nach Dev-Tools aus, aber sie sind unbezahlbar, wenn man um 2 Uhr morgens in einem Produktions-Pod ein Netzwerkproblem debuggt. Der Gewichtsaufwand ist vernachlässigbar im Vergleich dazu, sie nicht zu haben.
OPcache-Konfiguration
OPcache ist in der Produktion nicht verhandelbar. Wir richten es über Umgebungsvariablen ein, damit man pro Deployment überschreiben kann, ohne neu zu bauen:
ENV PHP_OPCACHE_ENABLE=1 \
PHP_OPCACHE_INTERNED_STRINGS_BUFFER=64 \
PHP_OPCACHE_MEMORY_CONSUMPTION=512 \
PHP_OPCACHE_MAX_ACCELERATED_FILES=50000 \
PHP_OPCACHE_REVALIDATE_FREQ=0 \
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 \
PHP_ERROR_REPORTING=22527
Die wichtigsten Flags:
| Flag | Wert | Warum |
|---|---|---|
VALIDATE_TIMESTAMPS | 0 | Nie Datei-mtimes prüfen. In Produktion ändert sich Code nicht zur Laufzeit |
REVALIDATE_FREQ | 0 | Kombiniert mit oben: OPcache ist beim Boot eingefroren |
MAX_ACCELERATED_FILES | 50000 | Große Symfony/Laravel-Apps erreichen leicht 10k+ Dateien |
MEMORY_CONSUMPTION | 512 | MB Shared Memory. Großzügig, aber OPcache-Eviction ist schlimmer als verschwendeter RAM |
INTERNED_STRINGS_BUFFER | 64 | MB für interned Strings. Klassen mit vielen String-Konstanten profitieren |
Die INI-Datei selbst ist minimal — nur die Timestamp-Überschreibung, da der Rest über Umgebungsvariablen gehandhabt wird:
; Other settings can be set up with environment variables
opcache.validate_timestamps = ${PHP_OPCACHE_VALIDATE_TIMESTAMPS}
Ein Entrypoint-Skript kümmert sich um das Zurücksetzen beim Start:
if [ "$PHP_OPCACHE_ENABLE" = "1" ] || [ "$PHP_OPCACHE_ENABLE" = "true" ]; then
if [ "$PHP_OPCACHE_VALIDATE_TIMESTAMPS" = "0" ]; then
php -r "opcache_reset();"
fi
fi
Das stellt einen sauberen OPcache-Zustand bei jedem Container-Start sicher — kein veralteter Bytecode von einem vorherigen Lauf.
FPM-Socket-Konfiguration
Wir lassen FPM auf einem Unix-Socket statt einem TCP-Port laufen. Sockets umgehen den TCP/IP-Stack komplett und eliminieren den Connection-Overhead, wenn Nginx und PHP-FPM im selben Pod leben:
[global]
daemonize = no
error_log = /tmp/php-log.pipe
[www]
listen = /var/run/fpm/fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm.status_path = /3d633e69231403b286884d73fe3d3cf1e2dddbe2
clear_env = no
catch_workers_output = yes
decorate_workers_output = no
php_admin_value[error_log] = /tmp/php-log.pipe
php_admin_flag[log_errors] = on
Der pm.status_path ist ein gehashter Endpunkt — erreichbar, wenn er im Load Balancer aktiviert ist, aber nicht von Bots erratbar. clear_env = no macht Host-Umgebungsvariablen für PHP-Skripte verfügbar, was für 12-Factor-App-Konfiguration entscheidend ist.
Das Dockerfile erstellt das Socket-Verzeichnis mit korrekten Berechtigungen:
RUN mkdir -p /var/run/fpm \
&& chown -R www-data:www-data /var/run/fpm
VOLUME /var/run/fpm
Die VOLUME-Deklaration ermöglicht Nginx-Containern, dasselbe Socket-Verzeichnis in einem geteilten Pod zu mounten.
Strukturiertes Log-Routing
PHP-FPM hat ein bekanntes Problem: Alles geht nach stderr. Application-Logs, Debug-Output, Warnungen, fatale Fehler — alles in einem Stream zusammengeworfen. Wir lösen das mit einer Named Pipe und einem Log-Router:
LOG_PIPE="/tmp/php-log.pipe"
setup_log_pipe() {
rm -f "$LOG_PIPE"
mkfifo "$LOG_PIPE"
chmod 666 "$LOG_PIPE"
}
start_log_router() {
(
while true; do
if [ -p "$LOG_PIPE" ]; then
while IFS= read -r line; do
if [ -n "$line" ]; then
if is_error_level "$line"; then
printf '%s\n' "$line" >&2
else
printf '%s\n' "$line"
fi
fi
done < "$LOG_PIPE"
fi
sleep 0.1
done
) &
sleep 0.2
}
Die is_error_level-Funktion matcht gegen Monolog-JSON-Level ("level":300+), Klartext-Marker ([WARNING], .ERROR:), native PHP-Fehler (PHP Fatal, Parse error) und Stack-Traces. Alles ab WARNING geht nach stderr. Alles andere geht nach stdout.
Das bedeutet, deine Container-Runtime (Docker, Kubernetes) sieht saubere, getrennte Streams. Log-Aggregatoren können filtern, ohne zu parsen. Alerting auf stderr bedeutet tatsächlich etwas.
Die FPM-Tuning-Defaults
ENV FCGI_CONNECT="/var/run/fpm/fpm.sock" \
PHP_FPM_PM_MAX_CHILDREN=20 \
PHP_FPM_PM_MAX_SPARE_SERVERS=5 \
PHP_FPM_PM_MIN_SPARE_SERVERS=1 \
PHP_FPM_PM_START_SERVERS=5 \
PHP_MAX_EXECUTION_TIME=90 \
PHP_MEMORY_LIMIT=512M
Das sind Startpunkte, kein Evangelium. MAX_CHILDREN=20 funktioniert für einen Pod mit 2 GB RAM und 512 MB pro Prozess. Die Formel ist einfach: verfügbarer_ram / memory_limit_pro_prozess. Per Deployment über Umgebungsvariablen überschreiben.
Bauen mit dem Skript
Das Build-Skript unterstützt mehrere PHP-Versionen, Multi-Arch und alle Flags über CLI:
bash build-base.sh --release-version 1.0.2
bash build-base.sh --release-version 1.0.2 --php-versions "8.4,8.1"
Unter der Haube nutzt es docker buildx build mit --platform linux/amd64,linux/arm64:
docker buildx build \
--platform "${PLATFORMS}" \
-t "${image_tag}" \
--build-arg "PHP_VERSION=${php_version}" \
--build-arg PHP_VARIATION=fpm \
-f "${DOCKERFILE}" \
"${CONTEXT}"
Ein Befehl, zwei Architekturen. Deine Entwickler auf M-Series-Macs bekommen ARM-Images. Deine Produktionsserver bekommen AMD64. Gleiches Dockerfile, gleiche Flags, gleiches Verhalten.
Sicherheit: Privilegien ablegen
Das Dockerfile endet, wo es sollte:
USER www-data
Alles nach dem Build läuft als unprivilegierter User. Root wird nur während der Image-Erstellung verwendet — Pakete installieren, Verzeichnisse anlegen, Berechtigungen setzen. Der laufende Container hat nie Root-Zugriff.
Was als Nächstes kommt
Das Base-Image ist der langweilige Teil — und genau das ist der Punkt. Es ist stabil, konfigurierbar und produktionsreif. In Teil 2 bauen wir Variation-Images darauf: Custom-PHP-Module, einen wiederverwendbaren Extension-Installer, Dev-Images mit Xdebug und wie das ganze Layer-System zusammenpasst.
Weiter voran und genieße jeden Schritt deiner Coding-Reise.
