Howto: Laravel-Anwendung im Docker Swarm-Mode betreiben

Seit kurzem beschäftige ich mich wieder etwas intensiver mit PHP-Frameworks, schließlich schreitet die Entwicklung stetig voran, somit war es an der Zeit, wieder einmal einen genaueren Blick darauf zu werfen. Da Laravel in den letzten Jahren im Vergleich an Popularität gewonnen hat, fiel die Wahl nicht schwer. Über die ersten Schritte inklusive Hürden und Fallstricke werde ich später noch berichten, hier soll zunächst das Deployment einer Laravel-Anwendung mit Hilfe des Docker Swarm-Mode thematisiert werden. 

Zugegebenermaßen ist mir der gar nicht mehr so neue Swarm Mode von Docker durchaus sympathisch. Zwar ist eindeutig eine Tendenz zur Orchestrierung von Containern mit Kubernetes festzustellen, dies gilt insbesondere im Unternehmensumfeld, aber während Kubernetes eine wohl überlegte und komplexere Einrichtung voraussetzt, bringt Docker den Swarm-Mode von Haus aus mit. Mittlerweile bietet der Docker auch Features wie Secrets und Configs (dazu gleich mehr), die für den Betrieb zwar nicht unerlässlich sind, aber einiges doch sehr vereinfachen. 

Die Komponenten

Die Laravel-Anwendung habe ich zunächst mit dem in PHP eingebauten Web-Server entwickelt. Die Aufgabe bestand nun darin, diese Anwendung in eine Produktionsumgebung zu überführen. Dabei sollte Nginx als Web-Server mit PHP-FPM zum Einsatz kommen, als Datenbank MySQL bzw. MariaDB. Somit besteht die Anwendung insgesamt aus diesen drei Komponenten, die ihre Entsprechung in Docker-Containern finden:

  • Nginx
  • PHP-FPM
  • MariaDB

Vorüberlegungen

An dieser Stelle eröffnet sich einem die Frage, warum PHP-FPM und Nginx in zwei unterschiedlichen Containern Platz nehmen sollen. Statt dessen ließe sich daraus – sogar inklusive der Laravel-Anwendung auch ein gemeinsames Image bauen, schließlich arbeiten beide Systeme doch sehr eng zusammen. Das ist zwar richtig, doch es widerspricht es dem Prinzip von Docker, für jeden Dienst einer Anwendung einen separaten Container zu verwenden. Darüber hinaus wäre der Aufbau der Docker-Images komplexer, d.h. es müsste wieder ein Dienst zur Prozesskontrolle gestartet werden, etwa Supervisord, der beide Komponenten verwaltet. Auch wäre ein Austausch eines Dienstes mit mehr Aufwand verbunden, beispielsweise wären bei einem Update immer beide Komponenten betroffen. Daher wollte ich die Dienste sauber trennen, möglichst einfache Docker-Images benutzen und die komplette Anwendung mittels Docker Swarm-Mode zusammenfügen. 

Eine weitere Vorüberlegung betrifft die Eigenschaften, die Docker beim Swarm-Mode immer wieder betont, und zwar die Verteilung und Skalierung der Services auf mehreren Nodes. So lässt sich mit einem Docker-Kommando die Anzahl der Instanzen der einzelnen Services erhöhen oder verringern. Darüber hinaus können die Instanzen im Swarm-Cluster verteilt werden, bzw. Docker verteilt sie automatisch, sofern man dies nicht unterbindet. Darum ging es mir in diesem Beispiel jedoch nicht, denn eine Verteilung hätte zunächst weitere Aufwände bedeutet. Das betrifft z.B. die Speicherung der Daten der MariaDB-Datenbank. Auf welchem Node sollen die Daten Platz finden? Oder sollte die Datenbank ebenfalls verteilt vorliegen, z.B: mittels Galera Cluster? Ähnliches gilt, wenngleich ein wenig einfacher, für die Dateien der Anwendung, d.h. PHP-Files, statisches HTML, JavaScript etc.. All dies müsste in einem Docker-Image vorhanden sein und die Images müssten von jedem Node abgerufen werden können. Dazu wäre ggf. wieder ein privates Docker-Repository notwendig, sofern das Angebot des einen kostenlosen privaten Images aus der Docker-Cloud bzw. Docker Hub nicht ausreicht. Die von PHP möglicherweise gespeicherten Dateien, etwa Uploads oder Session-Files, müssten ebenfalls verteilt werden. Bei Sessions bietet sich die Speicherung in einer (wiederum verteilten) Redis-Datenbank oder einem Memcache-Cluster an, für hochgeladene Dateien müsste es andere Mechanismen geben. Andererseits basieren die meisten und insbesondere kleinere Web-Auftritte auf einer Maschine, von der allenfalls ein Backup vorliegt, was bei einem Ausfall mehr oder minder manuell eingespielt werden muss. Das primäre Ziel bestand somit zunächst nicht darin, Ausfallsicherheit oder Skalierbarkeit zu schaffen, sondern die Laravel-Anwendung zwar mit Hilfe von Docker Swarm-Mode und dessen Eigenschaften, aber auf einer einzigen Maschine bzw. einem Node zum Laufen zu bringen. Falls notwendig, können einzelne Komponenten schrittweise verteilt werden. 

Vorbereitungen

Die erste Frage beim Einsatz von Docker lautet fast immer: Welches Image soll zugrunde gelegt werden? Muss es unbedingt ein eigenes Image sein, oder findet sich im Docker-Repository ein passendes? 

Welches Docker-Image darf’s denn sein?

An dieser Stelle habe ich mich für beides entschieden. Im “offiziellen” Repository befinden sich von Nginx nur Varianten von Debian und Alpine Linux. Da ich größtenteils und gerne Ubuntu einsetze, mir dabei insbesondere die Vorkonfiguration von Nginx gefällt, habe ich ein eigenes, minimales Docker-Image erstellt (nginx-swrm). Dabei handelt es sich letztlich nur um die Erweiterung des Ubuntu-Images durch Nginx. Im Unterschied zum bereits hier beschriebenen Verfahren sollen die Konfigurationsdateien diesmal nicht in den Container hinein gemountet werden, sondern der Zugriff wird mittels Docker configs-Funktionen geregelt. 

Ebenfalls wurde PHP-FPM als neues und eigenes Image gebaut (php-fpm-swrm), wiederum basierend auf Ubuntu. Es handelt letztlich um eine aktualisierte Fassung des letzten Images, neben PHP 7.2 werden noch Composer und einige notwendige PHP-Libraries installiert. 

Demgegenüber nutze ich MariaDB aus dem offiziellen Image. Und zwar aus dem Grund, weil MariaDB in hervorragender Weise konfiguriert werden kann, nicht nur mit Umgebungsvariablen, die man für sicherheitsrelevante Informationen eher vermeiden sollte, sondern auch mittels der neuen Docker Secrets. Davon abgesehen nutzt MariaDB im Docker-Image für die Datenbank-Binaries das Percona-Repository, nutzt insofern bereits fertige Pakete, anstatt dass diese während des Build-Vorgangs anhand der Beschreibung im Dockerfile neu erstellt werden. 

Der Docker-Stack von oben

Für den Aufbau des Docker-Stacks kommt ein docker-compose-File zum Einsatz. Neben diesem befinden sich die MariaDB-Daten, die Konfigurationsdatei für Nginx sowie die Laravel-Anwendung in einem Verzeichnis, das ich einfach “service” genannt habe. Die Struktur sieht somit wie folgt aus:

MariaDB

Für MariaDB habe ich die Docker Secrets Funktion bemüht. Dabei handelt es sich um ein Verfahren, mit dem sensitive Daten in einem Docker Swarm Cluster verteilt werden können. Diese Daten können z.B. User- oder Passwörter, aber auch Zertifikate und ähnliches umfassen. Grundsätzlich können jegliche Art Daten hinterlegt werden, sofern sie nicht größer als 500kb sind. 

Im folgenden Beispiel wird das MariaDB-Root-Passwort als Docker Secret hinterlegt. Zwar bietet das MariaDB-Image auch die Möglichkeit, direkt beim Start des Containers eine Datenbank inkl. User- und Passwortdaten anzugeben, aber diese Funktion habe ich bislang noch nicht genutzt. Vielleicht wäre dies eine Erweiterung für die nächste Version. 

Docker speichert das Passwort unter der Kennung “website_mysql_root_password”, der Erfolg kann kontrolliert werden:

Die Besonderheit ist, dass dieses Secret von jedem Node im selben Cluster aus erreichbar ist, und nicht nur auf der Maschine, auf der es erzeugt wurde. Dasselbe gilt für die eng verwandte Funktion der Docker Configs, nur dass die darin gespeicherten Daten nicht verschlüsselt werden. 

Nginx

Für die Nginx-Konfigurationsdatei kommen die Docker Configs zum Einsatz. Jedoch wurde dabei, wie gleich ersichtlich, nichts vorab definiert, sondern die Einrichtung erfolgt während des Starts des Docker Stacks. D.h. sämtliche Optionen sind in der docker-compose-Datei hinterlegt. Beim Beenden des Docker Stacks werden somit die Docker Configs auch wieder entfernt. 

Die Nginx-Konfiguration ist zunächst einmal so einfach wie möglich:

Als erstes erfolgt die Definition des Document Root, hier im “public”-Verzeichnis der Laravel-Anwendung. Ansonsten wird geprüft, ob eine statische Datei vorliegt, alles andere wird an den Front-Controller, d.h. die index.php-Datei weiter geleitet. PHP wird mittels FPM (FastCGI Process Manager) angebunden, und zwar auf dem Port 9000, der im Dockerfile des PHP-FPM-Images definiert ist. Der Name des Servers lautet “phpbackend”, dieser wiederum ist der Service-Name aus dem nachfolgenden docker-compose-File.

Weitere Möglichkeiten, etwa Rewrites oder ähnliches, sind hier im Beispiel absichtlich außen vor gelassen. Letztlich kann Nginx auf alle eigenen Features zugreifen, die auch ohne den Einsatz von Docker möglich wären. 

Die Steuerzentrale – das docker-compose-File

Zur Beschreibung des Docker-Services bzw. -Stacks dient das folgende docker-compose-File:

Dazu ein paar Erläuterungen.

Die Versionsnummer. Tatsächlich ist diese sehr relevant, da Docker den Einsatz neuer Funktionen davon abhängig macht. So wurden etwa die hier verwendeten Docker Configs Einstellungen noch nicht in Version “3.2” unterstützt. Die Entwicklung von Docker ist positiv gesprochen sehr dynamisch, somit lohnt sich hier der Blick ins Manual.

Die einzelnen Services tragen die Namen “nginx“, “phpbackend” und “mariadb“. Unter diesen (Host-)Namen sind die Dienste im internen Netzwerk auch ansprechbar. D.h. um auf den MariaDB-Server zu gelangen, ist in der Laravel-Konfigurationsdatei einfach “mariadb” als Hostname angegeben. 

Die Einstellung des Deploy-Ziels wurde festgelegt auf einen Host (hier namens “connewitz“). Wie oben beschrieben, sollen alle Dienste auf einem Docker Node laufen. Dies lässt sich über die Constraints (Einschränkungen) steuern. 

Alle Services nutzen ein gemeinsames Overlay-Network namens “website_net“, das als “external” definiert ist. Dies müsste nicht sein, denn per Default wird ein internes Overlay-Network angelegt, was auch nicht weiter bezeichnet werden muss, und über das sich die Services gegenseitig erreichen können. Das hier definierte “externe” Netz dient jedoch für den Zugriff eines weiteren Dienstes, dazu mehr in einem weiteren Beitrag. Daher wurde das Netz vorab angelegt:

Zur Kontrolle:

Das Laravel-Verzeichnis wird in die Services nginx und phpbackend hinein gemountet. Für Nginx reicht dazu der read-only-Zugriff, PHP muss jedoch Cache- und Session-Dateien schreiben können. 

Nginx erhält die Konfiguration mittels Docker Configs. Hier wird eine Datei “nginx/sites-enabled/default” unter dem Bezeichner “nginx_config_default” definiert. Beim Aufbau der Verzeichnisstruktur wurde sich an der Ubuntu-Einstellung orientiert. Dabei finden sich alle vhost-Definitionen in /etc/nginx/sites-enabled/, aus denen üblicherweise per Symlink auf die Config-Dateien in /etc/nginx/sites-available/ verwiesen wird. Derartige Links sind hier nicht notwendig. Der Bezeichner findet sich in der Service-Definition wieder und gibt das Ziel an. Darüber hinaus werden noch die Zugriffsrechte gesetzt. 

Der von Nginx nach außen freigegebene Port hat die Nummer 80, also normales, unverschlüsseltes http. Bei PHP-FPM ist keine Freigabe des Ports notwendig, da der Zugriff nur vom Nginx-Container über das interne Netzwerk (website_net) erfolgt. 

MariaDB schlussendlich erhält das Verzeichnis mariadb/data als Daten-Verzeichnis nach /var/lib/mysql gemountet. Das vorab definierte Secret wird übergeben und ist im Container unter /run/secrets/website_mysql_root_password verfügbar. Dass dies funktioniert, ist natürlich eine Besonderheit des MariaDB-Images – bei einigen Environment-Variablen wird das Suffix “_FILE” berücksichtigt, und falls vorhanden, die darin angegebene Datei ausgelesen. Das Docker Secret wird als “external” definiert, da es vorab angelegt wurde. Auch hier könnte wie beim Docker Config als Datei hinterlegt werden, was jedoch nicht viel sicherer wäre als wenn es direkt im docker-compose-File Platz finden würde. 

Vorbereitungen von MariaDB – hinterher

Der nach außen hin offene Port von MariaDB ist nur für die erste Konfiguration notwendig. Beim ersten Start von MariaDB werden die üblichen systemeigenen Datenbanken angelegt, etwa für die Zugriffsrechte der User. Per MySQL-Client lässt sich somit auf MariaDB zugreifen und weitere Datenbanken anlegen. Sobald dies passiert ist, kann der nach außen hin verfügbare Port wiederum geschlossen werden. Zugegebenermaßen bedarf dies noch einiger manueller Arbeit. Die Möglichkeit, MariaDB direkt eine Datenbank mit anzugeben, habe ich noch nicht genutzt. Jedoch ist das Anlegen der Laravel-Datenbank inkl. Zugriffsrechten auch nur einmal notwendig, da sämtliche Daten auf dem Host-Verzeichnis verbleiben. 

Von einem Rechner im Netzwerk mit MySQL-Client wären somit folgende Schritte auszuführen:

Dieser Schritt kann natürlich erst nach dem Start des Docker Stacks erfolgen, soll heißen, sobald MariaDB einmal erfolgreich läuft. 

Später wurden von der Laravel-Anwendung Zugriffsprobleme berichtet – der Zugriff von einer bestimmten IP-Adresse wurde ihr verwehrt. Es stellte sich heraus, dass Docker ein anderes (internes) Netz verwendet hatte, und zwar 10.0.x. Daher mussten die Rechte ein wenig erweitert werden:

Docker nutzt hier IP-Adressen, die zur internen Verwendung erlaubt sind. Welche dabei verwendet werden, verrät ein Blick auf die Services, und zwar mit docker inspect:

Laravel PHP Framework

Eine Laravel-Anwendung besitzt im Hauptverzeichnis eine Konfigurationsdatei namens .env. Darin können Umgebungsvariablen gesetzt werden, die die Standard-Einstellungen in den PHP-Dateien im config-Verzeichnis überschreiben. Auf jeden Fall sind die Verbindungsparameter für die Datenbank anzupassen, so dass die Datenbank unter dem Service-Namen “mariadb” angesprochen wird:

Weitere Anpassungen könnten notwendig sein, etwa bei der Angabe des Mailservers. Hier kommt es jedoch auf die spezifische Infrastruktur an, inwiefern Änderungen erforderlich sind. Falls etwa Redis verwendet wird, würde ich den Redis-Daemon in einen weiteren Service bzw. Container legen, so dass die Verbindungsparameter dann entsprechend geändert werden müssen.  

Docker Stacks, Services & Co.

Nach all diesen Vorbereitungen kann der Docker Stack gestartet werden:

Bei Erfolg sollte die Prüfung folgendes aussagen:

Es sind drei Services gestartet, über die sich ebenfalls wieder Auskunft einholen lässt:

Es kann eine Weile dauern, bis der Wert “1/1” bei der REPLICA-Angabe erscheint. Zunächst müssen die Images heruntergeladen werden, falls diese auf dem System noch nicht vorliegen. Danach werden die Container gestartet, die u.U. noch Fehler erzeugen. Beispielsweise wenn Nginx vor dem Start von PHP-FPM verfügbar ist, versucht Nginx auf den Hostnamen “phpbackend” zuzugreifen. Dieser wird aber erst einen Moment später im Netz propagiert. Daher ist der Start von Nginx nicht erfolgreich, solange der Name “phpbackend” noch nicht verfügbar ist. Ggf. hilft ein genauerer Blick auf den Startvorgang der Container mittels “docker logs -f containername“, wobei der Container-Name den Informationen aus “docker ps” entnommen werden kann. 

Initiales Anlegen der Datenbank-Tabellen

Nachdem alle Services gestartet sind und alle Container laufen, kann der letzte Schritt erfolgen. Die Tabellen der Datenbank muss angelegt werden. Auch dies ist nur einmal notwendig, und zwar genau dann, wenn noch keine Inhalte vorhanden sind. Man erinnere sich – die Datenbanken werden auf dem Host im Verzeichnis “./mariadb/data” gespeichert. Sofern die Datenbank vorhanden ist und später womöglich nur ein Service updated werden soll, ist dies ohne weiteres möglich. Eine Alternative zum hier beschriebenen Schritt ist die Nutzung eines Datenbank-Dumps, der beim initialen Start des MariaDB-Servers dem Container übergeben werden kann. Dabei wird der Dump in die Datenbank gebracht, so dass die Inhalte anschließend vorliegen. Ich habe mich jedoch für die Nutzung des Laravel-eigenen Verfahrens, der Database-Migrations entschieden. Damit lassen sich Updates der Anwendung, die eine Änderung der Datenbank mit sich bringen, programmatisch steuern. Initial genügt der Aufruf eines Laravel-Kommandos, das alle Tabellen anlegt, sofern die Datenbank erreichbar ist. Das heißt, die Verbindungsparameter müssen korrekt sein, so dass der Datenbank-User Zugriff auf die Datenbank hat. 

Nun läuft Laravel bzw. PHP jedoch in einem Container. Es wäre kaum sinnvoll, PHP extra auf dem Host zu installieren, nur um auf diesem im Anwendungsverzeichnis einmalig das Skript für die Database-Migrations auszuführen. Glücklicherweise ist dies auch gar nicht notwendig, denn es lässt sich in den PHP-FPM-Container wechseln. Dazu ist zunächst der Container-Name zu ermitteln, der aus den Komponenten Stack-Name, Service-Name, Replica und einer ID besteht. Das Kommando docker ps gibt darüber Auskunft. 

Anstatt des Namens kann auch die ID verwendet werden, um in den Container zu wechseln. Im Beispiel verwende ich den Namen:

Wie zu erkennen ist, befindet sich der gesamte Inhalt der Laravel-Anwendung im Zugriff des Containers. 

Danach wird der Migrationsschritt gestartet und somit werden die notwendigen Tabellen in der vorab erzeugten Datenbank generiert:

Danach kann der Container mit “exit” wieder verlassen werden. 

Das Ergebnis

Falls alles funktioniert hat, ist die Laravel-Anwendung nun auf dem Port 80 des Hosts erreichbar.

Hinweise und Fallstricke

Wie man sich vorstellen kann, gab es bei der Einrichtung auch ein paar Hürden zu bewältigen. Den Punkt mit der Versionskennzeichnung der docker-compose-Datei hatte ich weiter oben bereits erwähnt. 

Ein weiteres Problem waren fehlende Zugriffsrechte vom PHP-Container auf das Verzeichnis “storage” innerhalb der Laravel-Anwendung. Da ich die gesamte Anwendung unter meinem Usernamen angelegt hatte, konnte PHP-FPM, was als “www-data” läuft, nicht darauf schreiben. Dem konnte Abhilfe geschaffen werden durch Setzen des Users und der Gruppe:

Weiterhin geht dieser Artikel von der Voraussetzung aus, dass alle zur Laravel-Anwendung gehörigen Dateien initial vorhanden sind. Falls die Anwendung mit Hilfe von Git entwickelt wurde, werden üblicherweise die Dateien im vendor-Verzeichnis nicht mit ins Versionskontrollsystem gebracht, da sie jederzeit durch einen Aufruf von Composer wieder erzeugt werden können. Damit PHP und Composer nicht auf dem Host-System installiert werden müssen, beinhaltet das PHP-FPM-Image bereits Composer. Somit genügt es erneut, in den Container zu wechseln und “composer update” aufzurufen. Damit werden alle Abhängigkeiten erzeugt:

Auch dies ist selbstverständlich nur einmal notwendig, oder natürlich bei etwaigen Updates der Laravel-Anwendung.

Fazit

Die meisten der hier genannten Schritte und Hinweise gelten selbstverständlich allgemein für PHP-Anwendungen, die mit Hilfe von Docker Swarm Mode deployed werden sollen. Ich wollte mich jedoch nicht auf ein kleines Test-Skript beschränken, sondern die Infrastruktur im realen Betrieb genau so einrichten, wie sie aktuell produktiv für einen kleinen Web-Auftritt verwendet wird. Zwar spielt im Produktivbetrieb noch ein weiterer Docker Service eine nicht zu unterschätzende Rolle, aber genau das ist dann auch Thema eines kommenden Beitrags.

 

Ähnliche Beiträge

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Tags:
Kategorien: DevOps PHP