|

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:

FlagWertWarum
VALIDATE_TIMESTAMPS0Nie Datei-mtimes prüfen. In Produktion ändert sich Code nicht zur Laufzeit
REVALIDATE_FREQ0Kombiniert mit oben: OPcache ist beim Boot eingefroren
MAX_ACCELERATED_FILES50000Große Symfony/Laravel-Apps erreichen leicht 10k+ Dateien
MEMORY_CONSUMPTION512MB Shared Memory. Großzügig, aber OPcache-Eviction ist schlimmer als verschwendeter RAM
INTERNED_STRINGS_BUFFER64MB 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.