Am Beispiel einer PHP-Applikation auf Basis von Laravel möchte ich den Umgang mit Schema-Änderungen (Migrations) demonstrieren, die darauf warten müssen, dass ein benachbarter MySQL-Container bereit ist.

Zunächst ein schneller Blick auf das Stack-File, welches später mit docker stack deploy die Applikation startet. Die Datei ist etwas gekürzt, um den Fokus nicht zu verlieren. Zusätzliche Netzwerke sind ebenso entfernt, wie auch die Behandlung von TLS-Zertifikaten im nginx. Über die Eigenschaft deploy:, die in Swarm Clustern besonders nützlich ist, verliere ich später noch ein Wort.

version: "3.7"

services:
  nginx:
    image: registry.example.com/project/nginx
    ports:
      - "80:80"
  php:
    image: registry.example.com/project/php
    environment:
      ENV_FILE_SOURCE: /run/secrets/php_env
    secrets:
      - php_env
  mysql:
    image: mariadb:10.4
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
      MYSQL_DATABASE_FILE: /run/secrets/mysql_name
      MYSQL_USER_FILE: /run/secrets/mysql_user
      MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
    secrets:
      - mysql_root_password
      - mysql_name
      - mysql_user
      - mysql_password

secrets:
  php_env:
    file: secrets/php_env.txt
  mysql_root_password:
    file: secrets/mysql_root_password.txt
  mysql_name:
    file: secrets/mysql_name.txt
  mysql_user:
    file: secrets/mysql_user.txt
  mysql_password:
    file: secrets/mysql_password.txt

Für die Zugangsdaten sind die Docker Secrets im Einsatz, die in diesem Artikel genauer von mir erörtert werden. Das MariaDB-Image kann damit von Hause aus umgehen. Jeder einzelne Wert schlummert dabei in einer eigenen Datei und wird per Secret in den Dienst gegeben. Anschließend wird per Environment Variable der Pfad zum Secret übergeben. Das Secret php_env beinhaltet Laravels .env Datei.

Docker hochseetauglich

Zunächst einmal sollte man sich Gedanken über die Abhängigkeiten machen. nginx nimmt Anfragen der Benutzer entgegen und leitet diese an den PHP-Server weiter. Der PHP-Dienst, der die Projekt-Dateien beinhaltet, soll später den Befehl zur Datenbank-Migration ausführen und benötigt dazu eine Verbindung zum MariaDB-Server. Dabei ist allerdings Vorsicht geboten, denn nur weil der MariaDB-Container läuft, heißt es noch lange nicht, dass der Dienst auch per TCP erreichbar ist. MariaDB verhält sich da genau wie seine antiquierte Bruder MySQL und verrichtet allerlei Dinge, wie das Überprüfen von korrupten Tabellen, bevor es seine Schotten öffnet - doch dazu später mehr.

Leider berücksichtigt docker stack deploy die im Standard des Docker Compose Files nützliche Eigenschaft depends_on nicht, womit Dienste aufeinander warten, wenn sie einander bedingen. Darüber ist in den GitHub-Issues eine rege Diskussion entbrannt. Viele sind der Meinung, dass der Gesundheitszustand eines Containers, der mittels HEALTHCHECK überprüft wird, hinzugezogen werden sollte, bevor der davon abhängige Dienst gestartet wird. Auf der anderen Seite wird mit „Resilience“ argumentiert, denn die einzelnen Dienste sollten mit dem Ausfall eines benötigen Dienst umgehen können. Das Verhalten des Containers beim Ausrollen sei ja mit der in Version 3 integrierten Eigenschaft deploy steuerbar. Nachzulesen ist die Diskussion hier, hier und hier.

Asynchron, er arbeitet asynchron!

Die obige docker-compose.yml wird, nachdem die darin gebrauchten Images in der Registry abgelegt sind, auf einen Manager Node übertragen. Theoretisch ginge das auch über einen geöffneten Port des Docker Daemons, aber der müsste wieder abgesichert werden, was uns an dieser Stelle zu aufwendig ist. Aus diesem Grund landet die Datei per SCP, rsync oder Quantenteleportation auf dem Docker Swarm Manager unserer Wahl. Ich lege eine solche Datei meist in einen Ordner, wie /var/www/project, um zumindest die Stack-Definition dort liegen zu haben, denn dank Applikations-Containern liegen die Projektdaten dort nicht mehr.

Tipp: Der Swarm Mode lässt sich auch auf dem eigenen Rechner mit dem Befehl docker swarm init einrichten. Anschließend ist man in der Lage, Stack-Files auf der lokalen Maschine „auszurollen“. Im Hinterkopf sollte man nur behalten, dass das Cluster nur aus einem Node besteht, womit sich viele Probleme, wie die Beziehung zwischen Container und Volume, gar nicht ergeben.

Weiter im Text: Um einen Stack zu erstellen, nutzen wir nun also den folgenden Befehl.

docker stack deploy -c docker-compose.yml --with-registry-auth --prune mystack

Beim Pfad zur docker-compose.yml sollte man darauf achten, dass eventuell darin enthaltene Verknüpfungen wie z. B. env_file: .env-mysql nicht aufgelöst werden können, sollte man den oberen Befehl nicht aus dem Verzeichnis, in dem die Datei selbst liegt, aufrufen. Mit --with-registry-auth werden die Authentifizierungsinformationen im Swarm Cluster verteilt, damit andere Nodes in die Lage versetzt werden das geschützte Image aus der Registry zu laden. Vorausgesetzt wird dabei natürlich, dass auf dem Manager Node zuvor ein docker login durchgeführt wurde. Es kann auch nicht schaden, das mit in die CI-/CD-Pipeline mit aufzunehmen.

Achtung: Mit dem Flag --prune, der mit Version 17.05 Einzug gehalten hat, werden nicht mehr nötige Dienste automatisch entfernt.

Nun könnte jemand meinen, dass man sich jetzt einfach mit geschicktem Filtern die ID des PHP-Containers schnappt und mit docker exec CONTAINER_ID die Schema-Migration anstößt. docker stack deploy arbeitet allerdings asynchron. Nach Bestätigen des Befehls wird man nur kurz über die aus dem Stack-File resultierenden Dienste informiert und landet dann auch schon wieder im Shell-Prompt. Ein Großteil der Magie, wie das Erzeugen der Tasks, das Laden der Images und Starten der Container passiert im Hintergrund und braucht seine Zeit. Mit docker service ls lässt sich ein flüchtiger Blick auf die Spalte Replicas werfen. Bei 0/1 scheint er also noch nicht fertig zu sein. Alternativ lässt sich mit docker service ps --no-trunc SERVICE_ID ein Blick in die Tasks werfen oder es wird noch genauer mit docker service inspect SERVICE_ID. Sich anhand dieser Infos einen Prozess in seiner CI-/CD-Pipline zu bauen halte ich aber auch für etwas umständlich.

Entrypoint to the Rescue

Mein Tipp an dieser Stelle ist mal wieder der Gebrauch eines Shell-Scripts, welches im Container als ENTRYPOINT fungiert. Netter Nebeneffekt ist, dass unsere CI-/CD-Skripte schlanker werden und weitere Logik zum Betreiben der App, wie eben das Ausführen unserer Migrations, in das Container-Image wandern, wo es ja auch gut aufgehoben ist. Der Container erhält somit mehr Intelligenz. Gemessen an dem, was so alles im ENTRYPOINT des MariaDB-Images passiert, hält sich das bei uns allerdings in Grenzen. Nach einem kurzen Blick in das Dockerfile des PHP-Images, folgt der Inhalt der Datei docker-entrypoint.sh.

FROM php:7.1-fpm-alpine

# Build app
[...]

# Helper for MySQL readiness
RUN curl -o /usr/local/bin/wait-for https://raw.githubusercontent.com/Eficode/wait-for/master/wait-for && \
    chmod +x /usr/local/bin/wait-for

# Entrypoint
COPY entrypoint.sh /usr/local/bin
ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"]

Im Dockerfile wird die PHP-Applikation zusammengebaut. Anschließend laden wir ein kleines Helfer-Skript von GitHub, welches ohne weitere Abhängigkeiten, einfach mit der Shell funktioniert. Anschließend wird der ENTRYPOINT definiert.

#!/bin/sh
set -e

ENV_FILE_DESTINATION="/var/www/html/.env"
MYSQL_HOST="mysql"
MYSQL_PORT="3306"
MYSQL_TIMEOUT="60"

# Link secret for production environment
if [ -s "$ENV_FILE_SOURCE" ];then
    ln -s "$ENV_FILE_SOURCE" "$ENV_FILE_DESTINATION"
    echo "Secret linked."
else
    echo "No secret linked."
fi

echo "$(date) Waiting for MySQL service ..."
wait-for --timeout="$MYSQL_TIMEOUT" "$MYSQL_HOST":"$MYSQL_PORT" -- \
echo "$(date)" php artisan migrate --force

exec "$@"

Laravels Konfigurationsdatei .env gelangt per Secret in den Container und wird nun per Symlink an die richtige Stelle befördert. Mittels Helfer wait-for wird nun auf den benachbarten Container mit dem Hostnamen mysql gewartet, um anschließend die Datenbank-Migration mit dem Befehl php artisan migrate --force durchzuführen. Sollte der MySQL-Dienst nicht innerhalb von 60 Sekunden (--timeout=60) zur Verfügung stehen, bricht wait-for den Vorgang ab. Mit den beiden Datumsausgaben im Skript lässt sich schnell verifizieren, wie lange es gedauert hat, bis der MySQL-Dienst erreichbar ist.

Fazit

Mit unserer Arbeit stellen wir sicher, dass der PHP-Container wartet bis der MySQL-Dienst zur Verfügung steht. Dieser führt die Datenbank-Migration durch und startet anschließend den eigentlichen PHP-Dienst, damit nginx Anfragen an ihn stellen kann. Die Ausführungen sollen selbstverständlich nur als erster Ansatz dienen. Der MySQL-Hostname ließe sich beispielsweise per Environment Variable in den ENTRYPOINT einschleusen, statt ihn statisch zu hinterlegen. Den Timeout von wait-for könnte man auch noch weiter verarbeiten und z. B. das weitere Starten des PHP-Prozesses (php-fpm) unterbinden. Dann würde sich der Swarm Mode mit einer restart-policy, die man in der docker-compose.yml mit der Direktive deploy definiert, darum kümmern, den Container neu zu starten. Weiteres dazu kann man selbstverständlich in der offiziellen Dokumentation nachlesen. Wem das alles noch nicht reicht, dem empfehle ich einen Blick auf Flyway zu werfen.

Bild von Erda Estremera