|

Produktions-PHP-Docker-Images bauen — Custom-Module & Variationen (Teil 2/3)

In Teil 1 haben wir das Base-Image gebaut — PHP-FPM mit OPcache-Tuning, strukturiertem Log-Routing, Socket-Konfiguration und allen Compile-Flags als Build-Argumente verfügbar gemacht. Jetzt wird draufgestapelt.

Nicht jede Anwendung braucht die gleichen PHP-Extensions. Ein Projekt braucht eine proprietäre Dokumentenverarbeitungsbibliothek zur Generierung von Geschäftsdokumenten. Ein anderes nur Xdebug für die Entwicklung. Ein drittes braucht SOAP, weil Legacy-APIs sich weigern zu sterben. Die Lösung: Variation-Images, die vom Base erben und genau das hinzufügen, was jedes Projekt braucht.

Die Layer-Architektur

┌─────────────────────────────────────────┐
│         Variation: Amber                │
│   + customlib, SOAP, Custom-Ext          │
│   + optional: Xdebug (Dev-Builds)├─────────────────────────────────────────┤
│         Variation: Dev                  │
│   + Xdebug                              │
(eigenständiges Dev-Image, keine      │
│    Custom-Erweiterungen)├─────────────────────────────────────────┤
│         Base-Image                      │
│   + sockets, intl, bcmath               │
│   + OPcache, FPM, Log-Routing           │
│   + Debian-Pakete                       │
├─────────────────────────────────────────┤
│   serversideup/php:8.x-fpm             │
└─────────────────────────────────────────┘

Jede Schicht fügt nur hinzu, was sie braucht. Ein Rebuild des Base erfordert kein Rebuild jeder Variation — Docker-Layer-Caching kümmert sich darum. Ein Rebuild einer Variation zieht nur das bereits gecachte Base.

Die Amber-Variation

Amber ist die Projekt-Variation für unsere Hauptanwendung. Sie fügt SOAP (für Legacy-Integrationen) und eine proprietäre Extension (für Dokumentenverarbeitung) hinzu. Hier ist das Dockerfile:

ARG PHP_VERSION='8.4'
ARG BASE_VERSION='1.0.0'

FROM registry.example.com/php:${PHP_VERSION}-${BASE_VERSION}

ARG DEPENDENCY_EXTRA_PHP_EXTENSIONS='soap'
ARG DEPENDENCY_DEVELOPMENT_EXTENSIONS

USER root

COPY src/variations/common/scripts/install-custom-ext /scripts/install-custom-ext
COPY src/variations/amber/custom-ext /scripts/custom-ext

RUN /scripts/install-custom-ext customlib

RUN install-php-extensions "${DEPENDENCY_EXTRA_PHP_EXTENSIONS}"

RUN if [ -z "$DEPENDENCY_DEVELOPMENT_EXTENSIONS" ] ; then \
    echo Argument 'DEPENDENCY_DEVELOPMENT_EXTENSIONS' not provided; \
  else \
    echo Argument is $DEPENDENCY_DEVELOPMENT_EXTENSIONS; \
    install-php-extensions "${DEPENDENCY_DEVELOPMENT_EXTENSIONS}"; \
  fi

USER www-data

Die interessanten Teile:

BASE_VERSION als Build-Arg. Die Variation hardcodiert nicht, welches Base-Image sie erweitert. Man kann eine bestimmte Base-Version zum Testen pinnen und dann unabhängig bumpen. Das Build-Skript übernimmt dieses Mapping.

DEPENDENCY_DEVELOPMENT_EXTENSIONS ist optional. Wenn leer, überspringt der bedingte RUN die Installation komplett. Wenn auf xdebug gesetzt, bekommt man einen Dev-Build aus demselben Dockerfile. Ein Dockerfile, zwei Image-Varianten — Produktion und Entwicklung.

Custom-Extensions bekommen ihren eigenen Installer. install-php-extensions (vom serversideup-Base) handhabt Standard-PECL/Distro-Extensions. Aber manche Bibliotheken sind proprietär und liefern vorkompilierte Binaries. Die brauchen ein eigenes Installationsskript.

Der Custom-Extension-Installer

Manche Extensions können nicht über install-php-extensions oder pecl installiert werden. Es sind proprietäre Binaries, die heruntergeladen, architekturgenau zugeordnet und im richtigen Verzeichnis platziert werden müssen. Wir haben einen generischen Installer dafür gebaut:

#!/bin/sh

SCRIPT_DIR=$(dirname "$0")

if [ $# -eq 0 ]; then
  echo "Extension names are not provided"
  exit 1
fi

if [ ! -d "$SCRIPT_DIR/custom-ext" ]; then
  echo "Custom extension directory is not found $SCRIPT_DIR"
  exit 1
fi

for EXTENSION_NAME in "$@"
do
  if [ ! -f "$SCRIPT_DIR/custom-ext/$EXTENSION_NAME" ]; then
    echo "Script for extension $EXTENSION_NAME is not found"
    exit 1
  fi

  echo "Running $SCRIPT_DIR/custom-ext/$EXTENSION_NAME"
  if ! sh "$SCRIPT_DIR/custom-ext/$EXTENSION_NAME"; then
    echo "Error running $SCRIPT_DIR/custom-ext/$EXTENSION_NAME"
    exit 1
  fi
done

Das Muster ist einfach: Jede Custom-Extension hat ein Skript, das nach ihr benannt ist, in einem custom-ext/-Verzeichnis. Der Installer iteriert über die Argumente, findet das passende Skript und führt es aus. Eine neue Custom-Extension hinzuzufügen bedeutet, eine einzige Skript-Datei hinzuzufügen — keine Dockerfile-Änderungen nötig.

Proprietäre Extensions: Multi-Architektur-Installation

Manche proprietäre Extensions werden als vorkompilierte .so-Dateien für bestimmte PHP-Versionen und CPU-Architekturen geliefert. Der Installer muss beides erkennen und die richtige Binärdatei herunterladen:

#!/bin/sh
set -e
set -x

PHP_VERSION=$(php -r "echo sprintf('%s%s0', PHP_MAJOR_VERSION, PHP_MINOR_VERSION);")
PHP_NTS=$(php -r "echo ((int)PHP_ZTS === 0) ? '-nts' : '';")

LIB_VERSION="10.0.3"
LIB_VPATH="1003"

ARCHITECTURE=$(uname -m)

# Manche Anbieter nutzen 'x64' statt 'x86_64'
if [ "$ARCHITECTURE" = "x86_64" ]; then
  ARCHITECTURE="x64"
fi

# Cache für lokale Entwicklung — nicht bei jedem Build erneut herunterladen
if [ -f "$SCRIPT_DIR/customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php.tar.gz" ]; then
  cp "$SCRIPT_DIR/customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php.tar.gz" customlib.tar.gz
else
  LIB_URL="https://vendor.example.com/binaries/customlib/$LIB_VPATH/customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php.tar.gz"
  curl -L "$LIB_URL" -o customlib.tar.gz
fi

tar -xzf customlib.tar.gz

EXTENSION_DIR="$(php -d display_errors=stderr -r 'echo realpath(ini_get("extension_dir"));')"
FROM="customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php/bind/php/php-$PHP_VERSION$PHP_NTS/php_customlib.so"
TO="$EXTENSION_DIR/customlib.so"
mv "$FROM" "$TO"

rm -rf customlib.tar.gz "customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php"
docker-php-ext-enable customlib

Mehrere Details machen das Multi-Plattform-fähig:

uname -m zur Architektur-Erkennung. Wenn docker buildx für linux/arm64 baut, meldet der Build-Container aarch64. Beim Bau für AMD64 meldet er x86_64. Das Skript mappt x86_64 auf die x64-Benennung des Anbieters.

PHP-Versionsformatierung. Der Anbieter organisiert seine Binaries nach PHP-Version in einem bestimmten Format (8.40 für PHP 8.4). Der sprintf('%s%s0', ...)-Trick erzeugt genau dieses Format.

Thread-Safety-Erkennung. PHP_ZTS verrät, ob PHP mit Zend Thread Safety kompiliert wurde. Das -nts-Suffix wählt die Non-Thread-Safe-Binärdatei, die FPM verwendet.

Lokales Caching. Wenn das Tarball bereits im Build-Context existiert (von einem vorherigen Download oder manuellen Platzierung), wird der Download übersprungen. Das beschleunigt iterative Entwicklung, ohne CI-Builds zu beeinflussen.

Die Dev-Variation

Für die Entwicklung haben wir ein eigenständiges Dev-Image, das Xdebug auf das Base aufschichtet — keine projektspezifischen Extensions:

ARG PHP_VERSION='8.4'
ARG BASE_VERSION='1.0.0'

FROM registry.example.com/php:${PHP_VERSION}-${BASE_VERSION}

ARG DEPENDENCY_DEVELOPMENT_EXTENSIONS

USER root

RUN install-php-extensions "${DEPENDENCY_EXTRA_PHP_EXTENSIONS}"

RUN if [ -z "$DEPENDENCY_DEVELOPMENT_EXTENSIONS" ] ; then \
    echo Argument 'DEPENDENCY_DEVELOPMENT_EXTENSIONS' not provided; \
  else \
    echo Argument is $DEPENDENCY_DEVELOPMENT_EXTENSIONS; \
    install-php-extensions "${DEPENDENCY_DEVELOPMENT_EXTENSIONS}"; \
  fi

USER www-data

Das gibt Teams ein sauberes Entwicklungsimage mit Xdebug, das exakt zum Produktions-Base passt — gleiche PHP-Version, gleiche OPcache-Einstellungen, gleiche FPM-Konfiguration. Der einzige Unterschied ist der Debugger.

Das Build-Skript: Flags für alles

Das Variation-Build-Skript ist, wo die Flexibilität zusammenkommt. Es unterstützt mehrere PHP-Versionen, Dev-Builds, CI-Hashes und Multi-Arch — alles über CLI-Flags:

bash build.sh
bash build.sh --base-version 1.0.0 --release-version 1.0.2
bash build.sh --base-version 1.0.0 --release-version 1.0.2 --php-versions "8.4,8.1" --with-dev
bash build.sh --base-version 1.0.0 --release-version 1.0.2 --php-versions "8.4,8.1" --hash abcdef123456

Die Flags:

FlagStandardZweck
--base-version1.0.0Auf welchem Base-Image aufgebaut wird
--release-versionGleich wie BaseTag für die Output-Images
--php-versions8.4,8.1Komma-getrennte PHP-Versionen zum Bauen
--with-devDeaktiviertAuch Dev-Images mit Xdebug bauen
--hashKeinerCI-Commit-Hash, angehängt an Release-Tag

Das --hash-Flag ist besonders nützlich für CI. Bei Pull Requests werden Images mit dem Commit-SHA getaggt, damit Reviewer genau den Build testen können. Auf dem Main-Branch werden saubere Release-Tags verwendet.

So handhabt die Build-Funktion alle Kombinationen:

function build_php_version {
  local php_version="$1"
  local with_dev="$2"

  # Immer das Produktions-Amber-Image bauen
  build_image \
    "${AMBER_DOCKERFILE}" \
    "registry.example.com/php:amber-${php_version}-${RELEASE_TAG_VERSION}" \
    "${php_version}" \
    "false"

  if [[ ${with_dev} != "true" ]]; then
    return
  fi

  # Dev Amber: Produktions-Extensions + Xdebug
  build_image \
    "${AMBER_DOCKERFILE}" \
    "registry.example.com/php:amber-${php_version}-${DEV_TAG_VERSION}" \
    "${php_version}" \
    "true"

  # Dev Base: nur Xdebug, keine Projekt-Extensions
  build_image \
    "${DEV_DOCKERFILE}" \
    "registry.example.com/php:${php_version}-${DEV_TAG_VERSION}" \
    "${php_version}" \
    "true"
}

Eine Funktion, drei Image-Varianten. Das --with-dev-Flag steuert, ob Dev-Images überhaupt gebaut werden, und hält CI-Pipelines schnell, wenn man nur Produktions-Images braucht.

Die Push-Skripte spiegeln die Build-Skripte

Jedes Build-Flag hat ein entsprechendes Push-Flag. Die Push-Skripte verwenden dieselbe Argument-Analyse und Versions-Logik:

bash push.sh --base-version 1.0.0 --release-version 1.0.2 --with-dev
bash push.sh --base-version 1.0.0 --release-version 1.0.2 --hash abcdef123456

Diese Symmetrie ist beabsichtigt. Was auch immer du gebaut hast, du pushst es mit den gleichen Flags. Kein mentales Mapping zwischen Build-Tags und Push-Tags.

Eine neue Variation hinzufügen

Eine neue Variation hinzufügen folgt einem vorhersehbaren Muster:

  1. Erstelle ein Verzeichnis unter src/variations/<name>/
  2. Schreibe ein Dockerfile, das das Base-Image erweitert
  3. Wenn du Custom-Extensions brauchst, füge Skripte zu custom-ext/ hinzu
  4. Füge die Variation zum Build-Skript hinzu

Der Custom-Extension-Installer ist wiederverwendbar — jede Variation kann ihn nutzen, indem sie das gemeinsame install-custom-ext-Skript kopiert und eigene Extension-Skripte bereitstellt.

Code-Formatierung

Selbst Shell-Skripte und YAML-Dateien verdienen Formatierung. Wir erzwingen Konsistenz mit shfmt und yamlfmt:

shfmt -l -w -s ./*.sh
yamlfmt -dstar **/*.{yaml,yml} -formatter indent=2,eof_newline=true

Das läuft in CI neben den Builds. Wenn dein Shell-Skript shfmt nicht besteht, scheitert die Pipeline, bevor sie auch nur versucht, ein Image zu bauen.

Was als Nächstes kommt

Mit den Base- und Variation-Images haben wir eine vollständige Lokal-Build-Geschichte. Aber niemand will sich in einen Server einloggen und bash build.sh manuell ausführen. In Teil 3 automatisieren wir alles mit GitHub Actions: Multi-Arch-Builds auf Self-Hosted-Runnern, PR-Preview-Images, Slack-Benachrichtigungen und ein workflow_dispatch-Release-Flow für Base-Images.

Weiter voran und genieße jeden Schritt deiner Coding-Reise.