Docker 1.12 Swarm mode mit PHP und Nginx in der Praxis

Ausgehend vom ersten Beispiel der Docker Services aus dem Blog-Beitrag von Docker selbst, habe ich mich gefragt, wie eine klassische PHP-Anwendung als Docker-Service aussehen würde. Einer kurzen Einführung in die neuen Docker-Services habe ich im letzten Beitrag einen Abschnitt gewidmet, der folgende Text geht davon aus, dass ein Docker Swarm im neuen Swarm mode erfolgreich eingerichtet wurde.

Ein erstes Beispiel

Eines der neuen Features von Docker ist der eingebaute Load-Balancer. Dabei handelt es sich eigentlich um einen alten Bekannten, denn hier verwendet Docker den IPVS (IP Virtual Server) für so genanntes Layer-4-Switching, der sich seit ca. 15 Jahren im Linux-Kernel befindet. Das folgende Beispiel stammt somit direkt aus dem bereits zitierten Blog-Beitrag, der Swarm Cluster sieht wie folgt aus:

Nun wird die „vote“-Anwendung gestartet, zunächst nur ein Task:

Laut Beispiel müsste nun tatsächlich der Port 8080 auf allen beteiligten Nodes verfügbar sein und die Einstiegsseite der Anwendung darstellen. Tatsächlich ist genau dies der Fall, wie die nächsten Screenshots zeigen – gleichgültig auf welchen der Nodes man sich bewegt, die Antwort wird vom gleichen Container generiert.

Bildschirmfoto 2016-07-25 um 19.12.22

Bildschirmfoto 2016-07-25 um 19.12.44

Damit die Anwendung vollständig läuft, wäre zwar noch ein Overlay-Network und eine darüber ansprechbare Redis-Datenbank notwendig, dies ist aber hier vernachlässigbar.

Natürlich funktioniert das Scale-Kommando wie bereits gezeigt und erwartet:

Nun befindet sich auf allen Nodes jeweils ein Task. Das Load-Balancing wird auch dahingehend fortgeführt, als dass bei einem Reload auf derselben URL zufällig einer der Container ausgewählt und angesprochen wird. Ob diese Strategie auch änderbar ist, konnte ich der bisherigen Dokumentation nicht wirklich entnehmen, auch der Unterschied der beiden möglichen „endpoint modes“ „vip“ (virtual IP) und „dnsrr“ (DNS Round Robin) verlangt noch einige nähere Erläuterungen.

Ein Image für PHP-FPM

Insgesamt ist dieses eingebaute Load-Balancing-Feature schon ziemlich cool. Nun bringt die Beispiel-Anwendung alles mit bis auf die Datenbank, insofern „Backend“ und „Frontend“ in einem. Wie würde aber z.B. eine klassische PHP-Anwendung als Service aussehen? Aktuell wird häufig PHP als php-fpm-Prozess gestartet, während Nginx als Web-Server genutzt wird. Damit ist bereits eine grundsätzliche Verteilung möglich, so können durch Nginx etwa nicht nur PHP über den Unix-Socket auf dem lokalen Rechner angesprochen werden, sondern auch über IP auf weiteren Rechnern. Üblicherweise steht zuletzt noch eine Datenbank, etwa MySQL/MariaDB zur Verfügung, die ebenfalls wieder auf mehreren Nodes ansprechbar ist, sei es durch eine Master-Slave-Konfiguration oder wie bei Galera Cluster als gleichberechtigte Instanzen. Oder vielleicht lieber eine MongoDB als ReplicaSet verwenden? Die Möglichkeiten sind vielfältig, daher klammere ich den Aspekt der Datenbank hier zunächst aus.

Interessanter wird es bereits bei PHP-FPM. Davon ausgehend, dass Nginx und PHP-FPM in unterschiedlichen Containern laufen sollen, müssen für beide Services entsprechende Images generiert werden. Nun ist es jedoch nicht möglich, die PHP-Anwendung, insofern das Verzeichnis, in dem die PHP-Dateien enthalten sind, beim Definieren der Services und somit Start der Container als Volume hinzu zu mounten. Beim Weg per „docker run...„-Kommando war dies noch möglich, weil die Verbindung von Container zu Node viel enger war.

Das funktioniert also nicht. Insofern müssen die PHP-Quelltexte im Image enthalten sein, was als Service gestartet werden soll. Als Basis-Image habe ich dafür zunächst ein minimales Image für PHP-FPM erstellt: php-fpm.

Zwar existieren etliche, auch offizielle Images für PHP, aber diese sind teilweise für einen sehr speziellen Einsatz oder aber so allgemein gehalten, dass die Konfiguration schwieriger zu verstehen ist als schnell ein minimales Image zu erstellen, was genau diejenigen Module beinhaltet, die man benötigt und noch dazu auf der gewohnten Linux-Distribution basiert.

Mit dem Image ist es auch möglich, ein Volume für den PHP-Quellcode hinein zu mounten, aber für die Verwendung als Service muss ein abgeleitetes Dockerfile erstellt werden. Dies sieht wie folgt aus:

Viel einfacher geht es nicht – der Inhalt des Verzeichnisses „html“ wird in das Image nach /var/www/html/ kopiert. Zum Test befinden sich genau zwei Dateien darin, eine mit dem berühmten Aufruf von phpinfo(), die andere zeigt letztlich auch nur den Server-Namen an, denn dieser wird vom jeweils laufenden Container bzw. Task bestimmt, vgl. die „vote“-Anwendung aus dem Eingangsbeispiel:

Also Image bauen und ins private Repository pushen, schließlich müssen derartige Tests nicht unbedingt auf dem Docker Hub landen:

Damit ist das Image namens „hub.kuerbis.org:81/phptest“ nun auch erreichbar und kann herunter geladen werden. Vor dem ersten Test wird noch ein Overlay-Network angelegt, was zur Kommunikation zwischen den späteren Nginx-Services und den PHP-FPM-Services dienen soll:

Das sieht soweit gut aus, und die Ports 9000, auf denen PHP-FPM lauscht, sollen auch nicht nach außen freigegeben werden. Somit kann der Service gestartet werden:

Das sieht soweit erfolgreich aus, doch befindet sich das Image in einem privaten Repository. Üblicherweise werden die Images automatisch auf den jeweiligen Nodes herunter geladen, sofern sie nicht bereits existieren. Wie sieht dies hier aus?

Die Antwort lautet, dass die einzelnen Nodes zwar den Download versuchen, aber dabei zunächst scheitern. Der Status wechselt dabei auch auf „rejected“, da die Credentials für den Download zwar auf dem Client bekannt sind, von dem das Image in das private Repository gepusht wurde (Node „connewitz“), aber die anderen Nodes kennen die Zugangsdaten nicht. Daher werden die Tasks nicht erfolgreich gestartet.

Glücklicherweise hat Docker dafür einen Schalter vorgesehen, und zwar „--registry-auth„. Wird beim create-Kommando „--registry-auth“ angegeben, werden die Credentials an die einzelnen Nodes weiter gegeben, so dass der Download erfolgreich stattfinden kann. Ich habe dies auch erst später bemerkt und beim Update verwendet, die ersten Versuche bestanden darin, das Image einzeln auf jeden Node zu laden, was bei einer gewissen Anzahl von Nodes eher suboptimal ist.

Und sobald die Images vorhanden sind, werden sie auch erfolgreich gestartet:

Ein Image für Nginx

Um die Funktionalität des PHP-FPM-Services grundsätzlich zu testen, wollte ich zunächst auf klassischem Wege per „docker run...“ einen Nginx-Container starten, der der zuvor angelegte Overlay-Network nutzt, um PHP-FPM auf Port 9000 zu erreichen. Auch für Nginx hatte ich mir vor einiger Zeit ein minimales Image gebaut, was auf Alpine Linux basiert, und mit dem es möglich ist, eine doch Ubuntu-ähnliche Konfiguration (Konfigurationsdateien für virtuelle Server in „sites-enabled“ etc.) zu verwenden. Dabei werden beim bisherigen Weg die Konfigurationsdateien als Volume zur Laufzeit in den Container gemountet.

Nach Anpassung der Konfigurationsdateien der erste Versuch:

Auf die klassische Art und Weise funktionierte dies also nicht. Insofern musste auch für Nginx bzw. dessen Konfiguration ein Image gebaut werden, was wiederum im privaten Repository Platz genommen hat.

Um es abzukürzen – die Konfiguration bestand aus einigen Versuchen per Trial-and-Error, hauptsächlich bedingt durch die jeweiligen Pfade, die bei Nginx und PHP-FPM verwendet werden. Nginx gibt z.B. die Angabe über das Document-Root-Verzeichnis an PHP-FPM weiter, so dass PHP seine Dateien darin finden kann. Denn sobald eine Datei mit der Endung „.php“ genutzt wird, kommt auch PHP zum Einsatz. Statische Dateien hingegen können im Nginx-Image Platz nehmen, von dort aus werden sie direkt von Nginx beim Request ausgeliefert.

Das Dockerfile von Nginx sieht letztlich wie folgt aus:

Im Verzeichnis files/sites-enabled befindet sich eine Datei namens „default“, die nicht viel anders ist als die Standard-Konfiguration für einen virtuellen Host:

Ebenfalls recht kurz gehalten ist die zentrale nginx.conf, die in das Image hinein kopiert wird:

In dem „files/www/„-Verzeichnis befindet sich nur eine statische HTML-Datei, um die direkte Auslieferung von Nginx zu testen. Die PHP-Dateien hingegen sind im PHP-FPM-Image „phptest“ enthalten!

Also gebaut und gepusht:

Bei einem weiteren Versuch wollte ich den Nginx-Service nur auf dem (lokalen) Node „connewitz“ starten. Dazu wurden so genannte Constraints genutzt, hier zur Einschränkung der Node-ID:

Aus noch unbekannten Gründen war dies leider nicht erfolgreich, der Port 80 ließ sich auf dem betreffenden Node nicht ansprechen. Daher der neue Versuch, nun sollte Docker sich um die Verteilung der Tasks kümmern:

Der Task lief auf „miltitz“ – und tatsächlich war die „Anwendung“ nun auch erreichbar:

Bildschirmfoto 2016-07-25 um 21.11.24

 

Und auch die anderen Nodes waren erreichbar, einzig wechselte wiederum der Hostname, je nach dem, auf welcher PHP-FPM-Instanz man sich befand bzw. von welchem Task der Request verarbeitet wurde. Grundsätzlich also sehr, sehr cool!

Auch das Hinzufügen weiterer Tasks war möglich, ebenso konnte die Anzahl wieder verringert werden usw.:

Load-Balancing per DNS-Round-Robin

Eine (sehr) einfache Möglichkeit der Lastverteilung kann z.B. per DNS Round Robin erfolgen. Dabei werden einem Hostnamen mehrere IP-Adressen zugeordnet. Die grundlegende Funktionsweise beschreibt Wikipedia sehr gut, im Beispiel hatte ich den lokalen DNS so konfiguriert, dass alle Nodes mittels des Namens „swarm“ ansprechbar sind:

Bei der DNS-Abfrage wird dieser Name aufgelöst, und bei mehreren Anfragen wechselt die Reihenfolge.

Für wirkliche Ausfallsicherheit müsste nun noch der DNS-Server so konfiguriert werden, dass je nach Verfügbarkeit der Nodes die Konfiguration angepasst wird. Später…

Nachtrag – scale up and down

Als ich während des Schreibens dieses Artikels ein wenig die laufenden Services modifiziert habe, d.h. die Anzahl der Tasks verringert, wieder erhöht usw., zeigte sich eine zeitweise Instabilität. So wurden bei manchen Nodes 502 „Bad gateway“-Fehler angezeigt, andere antworteten gar nicht. In der Netzwerk-Konfiguration von Docker bzw. den Services fand ich nichts Ungewöhnliches („docker service inspect <service>“), und bis zur Analyse der Logfiles bin ich währenddessen nicht gekommen. Die genaue Ursache gilt es somit noch, herauszufinden, denn natürlich darf die erwünschte Eigenschaft der Ausfallsicherheit nicht zu Lasten der Stabilität gehen. Insofern bleibt noch einiger Raum für Analysen und Experimente…

Fazit

Grundsätzlich sind die Docker Services ein sehr gutes Feature, insbesondere das Load-Balancing, sofern es denn funktioniert, bietet eine relativ einfach konfigurierbare Lösung zur Herstellung von Ausfallsicherheit. Ich beziehe mich hier tatsächlich auf Ausfallsicherheit und nicht auf Skalierbarkeit. Denn um Letztere zu erreichen, müssen alle Systemkomponenten darauf ausgelegt sein, was jedoch nicht mit einem einzelnen „scale“-Kommando auf einer fest stehenden Anzahl von Nodes erreicht wird. Zwar können Nodes auch relativ einfach hinzugefügt werden, womit wiederum eine horizontale Skalierung erreicht würde, aber dazu muss erst einmal die entsprechende Infrastruktur bereit stehen.

Betrachtet man jedoch ein mit Docker Services aufgebautes System, was aus mehreren Nodes besteht, ist eine gewisse Ausfallsicherheit gegeben. Fälle ein Node aus, sorgt Docker dafür, dass die Tasks auf die noch vorhandenen Nodes verteilt werden.

Die Images müssen jedoch für den Betrieb als Service von vornherein ausgelegt sein. In diesem Sinn sind die Tasks der Services ein shared-nothing-System, sie kennen keinen gemeinsamen Speicherbereich, haben keinen Zugriff auf ein gemeinsames Dateisystem und müssen auch unabhängig voneinander in derselben Konfiguration lauffähig sein. Die Konfiguration muss daher ebenfalls entweder direkt in das Image gepackt werden oder als Umgebungsvariable mittels --env-Parameter übergeben werden können.

Dies wird bereits bei einfachen Features von Web-Anwendungen wie Uploads von Bildern etc. zu einer Herausforderung, auf die die Anwendung angepasst werden muss. Die Speicherung im Filesystem verbietet sich, insofern müsste ein gemeinsamer Speicher bzw. -Dienst wie AWS S3 genutzt werden, oder z.B. GridFS von MongoDB. Überhaupt stellen Datenbanken eine weitere Herausforderung dar. Dies beginnt bereits bei der Konfiguration, denn für den Aufbau etwa eines MariaDB-Galera-Clusters muss ein Image vorhanden sein, was von Initialisierung bis zu Betrieb einwandfrei ist, und wo sollen überhaupt die Daten gespeichert werden – verteiltes Dateisystem – oder doch besser auf Docker in diesem Umfeld verzichten? Und man muss ja auch nicht alles „dockerisieren“. Bei MongoDB funktioniert dies zwar auf dem klassischen Weg recht gut, aber auch das setzt einige Vorab-Konfiguration voraus. Und nicht zuletzt liegen die Daten auf den Hosts, von denen die Volumes in die Container gemountet werden. Um diese von einem Service-Task zu erreichen, müsste es jedoch ein gemeinsames Netz geben, oder man müsste mittels Firewall wiederum nur den Zugriff von außen verbieten…

Insofern – insgesamt gibt es noch etliche Baustellen, noch sind nicht alle Probleme gelöst oder Herausforderungen bewältigt. Und nicht zu vergessen, aktuell (Stand 25.07.2016) ist Docker 1.12 mit diesen neuen Features ja auch noch Release Candidate…

 

Ähnliche Beiträge

Tags:

Ein Gedanke zu „Docker 1.12 Swarm mode mit PHP und Nginx in der Praxis“

  1. Danke für den Artikel!
    Ich beschäftige mich gerade genau mit dem Thema, da ich demnächst eine wohl sehr schnell waschsende Online Plattform betreiben werde.

Schreibe einen Kommentar

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