Datenbank-Migration im Docker Swarm Mode
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