LEMP-Stack mit Docker Compose am Beispiel einer Lumen-Applikation

Dank Docker for Mac und Docker for Windows muss man nicht mehr Docker Machine aus der Toolbox bemühen, um lokal einen Docker Host mittels Virtual Box zu erzeugen. Jetzt lädt man sich nur noch Docker for Mac/Windows herunter und kann ohne Umwege mit Applikationscontainern arbeiten.

Da Docker weiterhin einen Linux Kernel benötigt, werkelt unter der Haube ein leichtgewichtiges Alpine Linux. Durch die native Integration in die Plattformen kriegt man von dieser Virtualisierungsschicht jedoch recht wenig mit. Die Tage, an den man sich während des Hochfahrens der lokalen Vagrant-Umgebung noch einen Kaffee kochen konnte, sind gezählt.

Der Projektordner

Am Beispiel einer Lumen-Applikation möchte ich den Aufbau des Setups erläutern. Wir werfen zunächst einen Blick in den Projektordner.

etc
  nginx
    app.conf
  php
    php.ini
    php-fpm.conf
src
docker-compose.yml

In etc befinden sich die Konfigurationsdateien für unsere wichtigsten Dienste, den Webserver und PHP. Denkbar wäre hier natürlich auch noch eine my.cnf für die MySQL-Konfiguration zu platzieren. Erfahrungsgemäß benötigt der Datenbankdienst jedoch weniger Anpassungen. In etc/nginx/app.conf befindet sich die Konfiguration eines Server Blocks (VHost). Die Konfiguration für den PHP-Interpreter liegt in etc/php/php.ini. Da wir PHP über den Fast Process Manager (FPM) laufen lassen, gibt es dafür eine eigene Pool-Konfigurationsdatei: etc/php/php-fpm.conf.

Mit diesen Konfigurationsdateien sind wir also später in der Lage, das Verhalten unserer Dienste innerhalb der Docker Container zu steuern. Der Nebeneffekt ist, dass wir diese Konfigurationsdateien im Projektverzeichnis und damit in der Versionierung haben. Den Inhalt der Dateien werde ich bei erster Verwendung erläutern.

Der Ordner src beinhaltet später unseren Lumen-Code.

In der Datei docker-compose.yml passiert nun die eigentliche Magie. In ihr werden die verschiedenen Dienste miteinander verknüpft. Zum besseren Verständnis werde ich die Datei Schritt für Schritt erweitern. Wer es kaum noch abwarten kann, scrollt ans Ende des Artikels.

Service Orchestrierung mit Docker Compose

In unserer docker-compose.yml beginnen wir zunächst mit der Definition unseres Webservers.

version: "2"
services:
  webserver:
    image: nginx:1.11
    ports:
       - "80:80"

Die Präambel in der ersten Zeile sorgt dafür, dass wir das Compose File in Version 2 nutzen. Mit Version 1 funktionieren Dinge wie Volumes und Netzwerke nicht, oder nur stark eingeschränkt. Wir nutzen also die neueste Version. Anschließend folgt auch direkt die Definition der services, die wir selbst benennen können. Der bisher einzige Dienst webserver nutzt dabei das offizielle nginx-Image aus dem Docker Hub in Version 1.11. Ich empfehle eine konkrete Version anzugeben. Im Endeffekt handelt es sich dabei um eine externe Abhängigkeit. Mit einer anderen Version dieser Abhängigkeit könnte unsere Applikation nicht funktionieren. In Produktivumgebungen ist also von der Verwendung des latest-Tags abzuraten. Über den Eintrag ports wird ein Mapping nach dem Schema Host:Container definiert. Das erlaubt es uns, den Webserver über unser Hostsystem aufzurufen.

Folgender Befehl startet den Container im Vordergrund: docker-compose up

Im Browser erscheint nun beim Aufruf von http://localhost die Willkommenseite des nginx. Der Terminal lässt uns nun die Logeinträge des Dienstes verfolgen. Der Webserver steht. BTW: Nicht über die Fehlermeldung wundern. Ein Favicon kann nicht gefunden werden. Mit STRG + C beenden wir die Ausführung.

webserver_1  | 2016/09/27 15:53:10 [error] 7#7: *1 open() "/usr/share/nginx/html/favicon.ico" failed (2: No such file or directory), client: 192.168.112.1, server: localhost, request: "GET /favicon.ico HTTP/1.1", host: "localhost", referrer: "http://localhost/"
webserver_1  | 192.168.112.1 - - [27/Sep/2016:16:07:34 +0000] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36" „-"

Volumes: Datenaustausch zwischen
Hostsystem und Container

Zunächst bringen wir unsere eigenen Projektdateien in den Container und nutzen dazu den Abschnitt volumes in docker-compose.yml. Ähnlich der Syntax für das Port-Mapping leiten wir damit, beginnend im aktuellen Projektverzeichnis, den Ordner src in unseren nginx-Container. Dieser erwartet in der Standardkonfiguration die Dateien im Verzeichnis /usr/share/nginx/html. Dieser Mount legt sich, wie unter Unix üblich, über die bisherigen Dateien dieses Ordners und damit über die nginx-Willkommensseite.

version: "2"
services:
  webserver:
    image: nginx:1.11
    ports:
      - "80:80"
    volumes:
      - "./src:/usr/share/nginx/html"

Bevor wir nun erneut Docker Compose ausführen, legen wir noch eine Testdatei an.
echo "Hallo Welt" > src/index.html

Ein erneuter Aufruf von http://localhost im Browser zeigt nun „Hallo Welt“. Diese Datei können wir nun bearbeiten. Durch den Mount wird jede Änderung direkt nach dem Aktualisieren im Browser angezeigt.

PHP

Nun geht es an den PHP-Interpreter. Wir erweitern unsere docker-compose.yml um einen neuen Dienst.

version: "2"
services:
  webserver:
    image: nginx:1.11
    ports:
      - "80:80"
    volumes:
      - "./etc/nginx:/etc/nginx/conf.d"
      - "./src:/var/www"
  php:
    image: servivum/php:7.0-fpm
    volumes:
      - "./etc/php/fpm/php-fpm.conf:/usr/local/etc/php-fpm.conf"
      - "./etc/php/php.ini:/usr/local/etc/php/php.ini"
      - "./src:/var/www/"

In diesem Fall greife ich auf ein eigenes PHP-Image zurück, indem Erweiterungen, wie mcrypt, die Lumen benötigt, enthalten sind. Beim offiziellen PHP-Image muss man sich selbst um die Erweiterungen sowie Composer kümmern.

Mit Volumes leite ich die Pool- und Interpreter-Konfiguration in den Container. Hierbei muss man sich zuvor informieren, an welchem Ort im Container die Datei zu liegen hat. Hier hilft der Befehl docker-compose exec php bash weiter, mit dem man direkt in den PHP-Container springt und eine interaktive Bash bekommt, um sich umzuschauen.

;etc/php/fpm/php-fpm.conf
[global]

error_log = /proc/self/fd/2
daemonize = no

[www]

; if we send this to /proc/self/fd/1, it never appears
access.log = /proc/self/fd/2

user = www-data
group = www-data

listen = [::]:9000

pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

clear_env = no

; Ensure worker stdout and stderr are sent to the main error log.
catch_workers_output = yes
#etc/php/php.ini
display_errors = On

Dem ein oder anderen ist es vielleicht schon aufgefallen: Ich habe in der docker-compose.yml nicht nur einen neuen Dienst hinzugefügt. Für die Interaktion der beiden Container muss natürlich auch noch die nginx-Konfiguration angepasst werden, die ich dem nginx-Container mit auf den Weg gebe.

Achtung: Die Projektdateien werden nun auch in das Verzeichnis /var/www im nginx-Container mittels der docker-compose.yml geleitet.

# etc/nginx/app.conf
server {
    server_name _;
    listen 80;
    root /var/www/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        #try_files $uri =404;

        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        if (!-f $document_root$fastcgi_script_name) {
            return 404;
        }

        include         fastcgi_params;
        fastcgi_index   index.php;
        fastcgi_param   SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param   DOCUMENT_ROOT $document_root;
        fastcgi_pass    php:9000;
    }
}

Hierbei handelt es sich um eine Schmalspurkonfiguration, die lediglich den Server-Block (VHost) unseres Lumen-Projektes beinhaltet. Spannend ist hier aber vor allem die Zeile ganz unten. Der PHP-Interpreter wird nicht per Socket, sondern per TCP-Verbindung hinzugezogen. Hier nimmt uns Docker Compose eine Menge Arbeit ab, indem es den PHP-Container unter dem Hostnamen verfügbar macht, der in der docker-compose.yml auch als Service-Name vergeben wurde. Darüber hinaus wird ein Docker Network aufgebaut, indem sich bereits beide Container befinden.

Wenn wir das Setup nun erneut hochfahren, fügen wir dem Befehl gleich noch den Flag -d an, um den Stack im Hintergrund laufen zu lassen: docker-compose up -d

Rufen wir nun http://localhost auf erhalten wir ein „404 Not Found“, was daran liegt, dass die src/index.html aufgrund der nginx-Config nun nicht mehr genutzt wird und wir noch keine Lumen-Dateien angelegt haben. Das holen wir nun nach. Wir entfernen zunächst die src/index.html und führen anschließend diesen Befehl aus:

docker-compose exec php composer create-project --prefer-dist laravel/lumen .

Damit führen wir mittels Docker Compose einen Composer-Befehl zum Initialisieren des Lumen-Projektes aus, was eine praktische Angelegenheit ist, da wir so nicht zunächst in den Container springen müssen, um darin Befehle auszuführen, sondern können dem Container diese von außen mitgeben. Die durch Composer geladenen Dateien werden dank des Mounts in unserem src-Ordner auf dem Hostsystem abgelegt.

Ein anschließender Aufruf von http://localhost zeigt nun „Lumen (5.3.0) (Laravel Components 5.3.*)“. Wir haben Lumen also erfolgreich installiert.

MySQL

Eine Datenbank fehlt uns nun natürlich auch noch. Der Docker Hub bietet uns hierfür fertige Images von MySQL oder Percona. Ich entscheide mich für MariaDB und füge diesen Dienst der docker-compose.yml hinzu.

version: "2"
services:
  webserver:
    image: nginx:1.11
    ports:
      - "80:80"
    volumes:
      - "./etc/nginx:/etc/nginx/conf.d"
      - "./src:/var/www"
  php:
    image: servivum/php:7.0-fpm
    volumes:
      - "./etc/php/fpm/php-fpm.conf:/usr/local/etc/php-fpm.conf"
      - "./etc/php/php.ini:/usr/local/etc/php/php.ini"
      - "./src:/var/www/"
  mysql:
    image: mariadb
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=db
      - MYSQL_USER=db
      - MYSQL_PASSWORD=db

Mit einem Port-Mapping, wie bereits beim Webserver zu sehen, haben wir die Möglichkeit, mit einem externen MySQL-Client auf unserem Hostsystem, wie z. B. SequelPro, auf die Datenbank zuzugreifen. Wie der Beschreibung des MariaDB-Images zu entnehmen, konfiguriert man den Container mittels Environment-Variablen. Wir setzen also das Root-Passwort auf root und lassen eine Datenbank mit dem Namen db, einen gleichnamigen User, der auf diese Datenbank mit dem Passwort db zugreifen kann. Als Hostnamen nutzen wir mysql, da Docker Compose analog zum PHP-Container den Dienstnamen innerhalb des Container-Netzwerks darunter verfügbar macht.

Fertig ist unser LEMP-Stack auf Basis von Docker.

Fazit

Dieses Setup stellt einen guten Startpunkt dar und kann nun beliebig erweitert werden. Denkbar ist der Einsatz einer Test-Datenbank. Hierfür lässt sich schnell ein weiterer MySQL-Container hinzufügen, der die Testdaten in einer separaten Datenbank vorhält. Wir nutzen darüber hinaus z. B. auch noch einen Container, der uns Swagger UI bereitstellt. Damit können wir unserem schlanken Microservice eine API-Dokumentation verpassen, die dem Entwickler auch lokal bereitsteht.

Patrick Baber

Als erfahrener Programmierer löst Patrick jeden Gordischen Knoten, findet die geeignete Methode und entwickelt damit flexible Software-Systeme.