Docker für Entwickler,

XAMP im Eigenbau aus Containern.

Docker eignet sich nicht nur dafür, Applikationen zu deployen. Auch um mehrere Webserver, Datenbanken, PHP-Versionen usw. auf einem Entwickler-System zu haben eignet sich Docker hervorragend.

Note! Ich setze voraus, dass du (der Leser) zumindest weist was Docker ist, wie es im Grunde funktioniert und wie es zu bedienen ist.

Im folgenden will ich beschreiben, wie ein XAMP bzw. ein XNPMMPP (nginx, PostgreSQL, MySQL, MariaDB, PHP5, PHP7) Setup mit Docker Containern zusammen gebaut werden kann.

Die Grundlegende Struktur

Das vorgestellte Setup benutzt einen nginx Webserver und über ein Domain-Schema werden mehrere PHP Versionen abgebildet. Die Domains sehen dabei folgendermaßen aus: {application}.{phpVersion}.localdomain und werden auf den Verzeichnisspfad $HOME/vhosts/{application} abgebildet.

D.h. die Domain kitchen.{phpVersion}.localdomain liefert die Daten aus $HOME/vhosts/kitchen. Wobei die PHP Skripte unter kitchen.php56.localdomain mit PHP 5.6 und unter kitchen.php70.localdomain mit PHP 7.0 ausgeführt werden.

Die Konfigurationen und Laufzeitdaten für die Docker Container werden in $HOME/docker/{container}/conf bzw. $HOME/docker/{container}/data abgelegt. Das $HOME/docker Verzeichnis kann dann einfach mit einem Backuptool gesichert werden. Nach einer Neuinstallation braucht man dann einfach nur die Container an gleicher Stelle neu starten und alle Dienste sind wieder verfügbar.

Note! Auf das linken von Containern wird verzichtet und die Kommunikation über die IP Adresse des Host abbilden. Das ist gerade am Anfang deutlich einfacher und schneller aufzusetzen, weil man sich nicht mit Umgebungsvariablen herum schlagen muss. Dafür wird die Host IP Adresse benötigt. In der Regel handelt es sich dabei um die IP Adresse des docker0 Interfaces.

$ ip addr show dev docker0
4: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default
    link/ether 02:42:2c:ca:cc:95 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 scope global docker0
       valid_lft forever preferred_lft forever

Die IP Adresse lautet also 172.17.0.1, diese wird später benötigt.

*.localdomain Domains

Selten werden beliebige *.localdomain Domains standardmäßig auf 127.0.0.1 aufgelöst. Unter Linux lässt sich das Dank dnsmasq sehr einfach realisieren. Bei den meisten Distributionen ist dieser mit dem NetworkManager zusammen im Einsatz, weshalb die Konfiguration für dieses Setup beschrieben wird.

Eigentlich ist es sehr einfach, es muss nur eine Datei /etc/NetworkManager/dnsmasq.d/local mit folgendem Inhalt angelegt werden:

address=/localdomain/127.0.0.1

Nicht bei jeder Distribution ist dnsmasq standardmäßig installiert und aktiviert. Bei ubuntu ist das für gewöhnlich bereits aktiv. Bei arch hingegen, muss man dnsmasq zuerst in der Datei /etc/NetworkManager/NetworkManager.conf aktivieren.

[main]
dns=dnsmasq

Jetzt noch schnell den NetworkManager neu starten sudo systemctl restart NetworkManager und dann ist dnsmasq aktiv.

Das war es auch schon. Jetzt sollte man noch überprüfen, ob die Domains korrekt aufgelöst werden:

$ nslookup kitchen.php56.localdomain
Server:         127.0.0.1
Address:        127.0.0.1#53

Name:   kitchen.php56.localdomain
Address: 127.0.0.1

Tip! Sollte das noch nicht funktioniert haben, kann man verschiedene Dinge probieren:

  • Sicherstellen dass dnsmasq installiert ist - bspw. apt-get install dnsmasq oder pacman -S dnsmasq.
  • Sicherstellen dass dnsmasq gestartet wurde - bspw. ps aux | grep dnsmasq.

Das Multi-FPM Setup

Als nächstes werden ein paar FPM Container gestartet. Ja richtig gehört, es werden gleich mehrere gestartet! Im Gegensatz zum nginx benötigt man aber erst mal keine besondere Konfiguration, weshalb kein separates Konfigurationsverzeichnis in $HOME/docker benötigt wird.

Note! Die Standardkonfiguration in dem FPM Docker Container reicht für Entwickler vollkommen aus. Diese ist aber nicht wirklich geeignet für den produktiven Einsatz!

Das starten des ersten FPM mit PHP 5.6 geht einfach:

$ docker run --detach \
           --hostname=php-fpm-5.6 \
           --name=php-fpm-5.6 \
           --restart=on-failure:5 \
           --user=$UID \
           --publish=172.17.0.1:9056:9000 \
           --volume=$HOME/vhosts/:/var/www/html/ \
           bit3/php-all-debug:5.6-fpm

Mit --user=$UID wird sichergestellt, dass es keine Berechtigungskonflikte zwischem dem Entwickler-Account und der FPM Instanz gibt..

Mit --publish=172.17.0.1:9056:9000 wird der FPM Prozess unabhängig von der Container IP verfügbar gemacht. Das binding die Host IP 172.17.0.1 sorgt dafür, dass der FPM Prozess nicht von extern, sondern nur innerhalb des Docker-Netzwerkes verfügbar ist.

Important! Man sollte niemals den Port 9000 binden, da dieser Port standardmäßig von xdebug genutzt wird!

Mit --volume=$HOME/vhosts/:/var/www/html/ werden die Dateien von $HOME/vhosts innerhalb des Containers bereitgestellt.

Tip! Der Container bit3/php-all ist ein spezieller Container, der für Entwickler konzipiert ist. Dieser beinhaltet nahezu alle Standardmodule und noch bin bisschen mehr. Die *-debug Version beinhaltet außerdem noch xdebug. Wer diesen Container nicht nutzen will, kann hier auch den offiziellen Standard Container php:5.6-fpm oder natürlich jeden anderen FPM Container nutzen.

Das ganze wird für den zweiten FPM mit PHP 7.0 wiederholt:

$ docker run --detach \
           --hostname=php-fpm-7.0 \
           --name=php-fpm-7.0 \
           --restart=on-failure:5 \
           --user=$UID \
           --publish=172.17.0.1:9070:9000 \
           --volume=$HOME/vhosts/:/var/www/html/ \
           bit3/php-all-debug:7.0-fpm

Jetzt existieren zwei Docker Container php-fpm-5.6 und php-fpm-7.0.

Tip! Ich habe für den FPM 5.6 Container den Port 9056 und für FPM 7.0 Container den Port 9070 verwendet. Dem aufmerksamem Leser wird aufgefallen sein, dass ich einfach die PHP Version in den Port eingebaut habe. Dadurch verhindere ich dass ich früher oder später in einen Port-Konflikt gerate.

Der Webserver

Als nächstes muss den Webserver konfiguriert werden. Dafür wird ein Konfigurationsverzeichnis angelegt, in dem die Site-Konfigurationen abgelegt werden:

$ mkdir -p $HOME/docker/nginx/conf

Damit es für keine Probleme bei unerwarteten Aufrufen gibt, sollte eine Konfiguration für den Standard Host angelegt werden $HOME/docker/nginx/conf/000-default.conf:

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    server_name "";

    location / {
        root /usr/share/nginx/html;
    }
}

Der Standard Host sorgt dafür, dass eine Seite ausgeliefert wird, wenn der Server ohne passenden Host-Namen aufgerufen wird.

Als nächstes werden die *.localdomain Hosts konfiguriert, $HOME/docker/nginx/conf/100-localdomain.conf:

server {
    listen 80;
    listen [::]:80;

    server_name ~^(?<application>.+)\.(?<php>php\d\d)\.localdomain;

    location / {
        root /var/www/html/$application;
        index index.php index.html;
        try_files $uri $uri/ =404;

        location ~ \.php$ {
            if ($php = "php56") {
                fastcgi_pass 172.17.0.1:9056;
            }

            if ($php = "php70") {
                fastcgi_pass 172.17.0.1:9070;
            }

            fastcgi_param SCRIPT_FILENAME /var/www/html/$application/$fastcgi_script_name;
            fastcgi_index index.php;
            include fastcgi_params;
        }
    }
}

Die Scripting Fähigkeit des nginx wird hier voll ausgenutzt. Das ist für ein produktives Setup nicht unbedingt empfehlenswert, auf einer Entwickler-Maschine ist das aber eine super Lösung :-)

Sobald das erledigt ist, kann der nginx Container gestartet werden:

$ docker run --detach \
             --hostname=nginx \
             --name=nginx \
             --restart=on-failure:5 \
             --publish=127.0.0.1:80:80 \
             --volume=$HOME/docker/nginx/conf/:/etc/nginx/conf.d/:ro \
             --volume=$HOME/vhosts/:/var/www/html/:ro \
             nginx

Im Gegensatz zu den php-fpm Containern, sollte man auf das --user=$UID verzichten. Das hängt allerdings primär damit zusammen, dass der nginx in seinem Container sonst nicht richtig funktioniert.

Durch --publish=127.0.0.1:80:80 ist es möglich mittels http://localhost auf den Webserver zuzugreifen, ohne die IP Adresse des nginx Containers zu kennen.

Important! Durch das Volume-Mapping hat die nginx- und die php-fpm-Instanzen nur Zugriff auf Dateien innerhalb von $HOME/vhosts/. Dies muss man ggf. berücksichtigen, wenn man gedenkt Dateien von außerhalb bereitstellen zu wollen!

Tip! Ich erstelle auch gerne mal statische Prototypen, die ich über den Webserver ohne PHP ausliefern will. Dazu habe ich die Datei $HOME/docker/nginx/conf/100-localdomain.conf einfach noch um folgenden Eintrag ergänzt:

server {
    listen 80;
    listen [::]:80;

    server_name ~^(?<application>.+)\.localdomain;

    location / {
        root /var/www/html/$application;
        index index.php index.html;
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        return 406;
    }
}

Setup testen

Jetzt sollten das Setup getestet werden, dazu wird einfach eine $HOME/vhosts/test/index.php Datei angelegt:

<?php
phpinfo();

... und anschließend durch den Aufruf von test.php56.localdomain und test.php70.localdomain getestet. In beiden Fällen sollte die PHPinfo zu PHP 5.6 und PHP 7.0 angezeigt werden.

Herzlichen Glückwunsch! Wir haben erfolgreich ein Multi-FPM Setup mit PHP 5.6 und PHP 7.0 eingerichtet und können jetzt die Applikation in mehreren PHP Versionen ausführen!

Datenbank einrichten

Was wäre eine PHP Applikation ohne Datenbank? Ich arbeite mittlerweile primär mit PostgreSQL, viele PHP Entwickler arbeiten allerdings mit MySQL bzw. MariaDB.

Im folgenden wird die Einrichtung aller drei Datenbanken gezeigt, das Setup kann man natürlich seinen Bedürfnissen anpassen.

Danger! Das folgende Setup ist unsicher, es werden die Standard Benutzer ohne Passwörterer eingerichtet. Für die lokale Entwicklung ist das in der Regel egal. Die Container werden ohnehin nur für den Zugriff aus dem Host-System bzw. dem Docker-Netzwerk geöffnet. Wer aber auf mehr Sicherheit setzt, muss hier ggf. Anpassungen vornehmen!

PostgreSQL

Schritt 1: Daten-Verzeichnis anlegen:

$ mkdir -p $HOME/docker/postgresql/data

Schritt 2: Container starten:

$ docker run --detach \
             --hostname=postgres \
             --name=postgres \
             --restart=on-failure:5 \
             --publish=127.0.0.1:5432:5432 \
             --publish=172.17.0.1:5432:5432 \
             --volume=$HOME/docker/postgresql/data/:/var/lib/postgresql/data/ \
             postgres

Damit ist postgres gestartet und lässt sich via psql -h 127.0.0.1 -U postgres bzw. psql -h 172.17.0.1 -U postgres verbinden.

Für die Applikation sind folgende Daten zu nutzen:

Host 172.17.0.1
Port 5432
Benutzername postgres
Passwort  

MySQL

Schritt 1: Daten-Verzeichnis anlegen:

$ mkdir -p $HOME/docker/mysql/data

Schritt 2: Container starten:

$ docker run --detach \
             --hostname=mysql \
             --name=mysql \
             --restart=on-failure:5 \
             --env=MYSQL_ALLOW_EMPTY_PASSWORD=yes \
             --publish=127.0.0.1:3307:3306 \
             --publish=172.17.0.1:3307:3306 \
             --volume=$HOME/docker/mysql/data/:/var/lib/mysql/ \
             mysql

Damit ist mysql gestartet und lässt sich via mysql -h 127.0.0.1 -P 3307 -u root bzw. mysql -h 172.17.0.1 -P 3307 -u root verbinden.

Für die Applikation sind folgende Daten zu nutzen:

Host 172.17.0.1
Port 3307
Benutzername root
Passwort  

MariaDB

Schritt 1: Daten-Verzeichnis anlegen:

$ mkdir -p $HOME/docker/mariadb/data

Schritt 2: Container starten:

$ docker run --detach \
             --hostname=mariadb \
             --name=mariadb \
             --restart=on-failure:5 \
             --env=MYSQL_ALLOW_EMPTY_PASSWORD=yes \
             --publish=127.0.0.1:3306:3306 \
             --publish=172.17.0.1:3306:3306 \
             --volume=$HOME/docker/mariadb/data/:/var/lib/mariadb/ \
             mariadb

Damit ist mariadb gestartet und lässt sich via mysql -h 127.0.0.1 -u root bzw. mysql -h 172.17.0.1 -u root verbinden.

Für die Applikation sind folgende Daten zu nutzen:

Host 172.17.0.1
Port 3306
Benutzername root
Passwort  

CLI

Gerade wenn man Composer, Symfony oder ein ähnliches Tool/Framework einsetzt, dann benötigt man des öfteren die Konsole. Am einfachsten startet man sich einen php-cli Container und mapped diesen auf sein Arbeitsverzeichnis. Ein Standard Container ist allerdings sehr spartanisch aufgebaut, weshalb es von meinem bit3/php-all Containern auch jeweils eine CLI Version gibt.

$ docker run --rm -it --user=$UID --volume=$HOME/vhosts/:/var/www/html/ bit3/php-all:7-cli

Sobald man sich auf der Container Konsole befindet, kann man innerhalb von /var/www/html in sein Applikations- Verzeichnis wechseln und sich dort austoben. Das besondere an den bit3/php-all:*-cli Containern ist, dass diese mit vorinstalliertem Composer und einer gepimpten zsh Konsole bestückt sind. Neben dem zsh-git-prompt Plugin, sind noch das git, composer und symfony2 Plugins von oh-my-zsh aktiviert!

Summary

Eigentlich ist ein händisch zusammengebautes XAMP Setup unter den meisten Linux Distributionen relativ leicht aufzusetzen. Allerdings ist man oft an die Versionen der Distribution gebunden, wenn man nicht gerade mit PPA's o.ä. um sich wirft. Und für eine Multi-PHP Installation muss man darüber hinaus auch noch auf Tools wie bspw. php-brew zurückgreifen, wenn man sich das Leben nicht all zu schwer machen will.

Docker bietet einem hier mehrere Vorteile:

  1. ist man eigentlich gar nicht mehr von der verwendeten Distribution abhängig.
  2. können Dienste mehrfach in unterschiedlichen Versionen bereitgestellt werden.
  3. ist man nicht an die Freigabe einer Version in der jeweiligen Distribution gebunden.
  4. hält man sein lokales Betriebssystem sauber.

Der Umgang mit den Docker Containern ist ein wenig gewöhnungsbedürftig, aber deutlich leichtgewichtiger als bspw. Virtuelle Maschinen. Ich persönlich bin ein echter Fan von Docker geworden. Die saubere Trennung hält das eigene System sauber und es ist absolut leicht einen Dienst mal eben schnell neu zu installieren.