Howto: Nginx-Proxy und Nginx-Proxy-Companion im Docker Swarm Mode auf einem Host

Im letzten Beitrag habe ich die Einrichtung eines Docker-Stacks für eine PHP-Anwendung mit dem Laravel-Framework beschrieben. Diese Kombination läuft schon recht gut, hat allerdings den Nachteil, dass bei Nutzung des Ports 80, der nach außen hin auf dem Host freigegeben ist, auch nur diese eine Anwendung verwendet werden kann.

Zumindest gilt das für den Fall, dass der Nginx-Container wie beschrieben nur für die einzelne Anwendung konfiguriert wird. Genau das war aber das Ziel der Konfiguration – ein Container sollte genau für einen Service zuständig sein.

Ein Reverse-Proxy für die Web-Anwendung

Nun gibt es mit nginx-proxy ein Docker-Image, das es ermöglicht, vor die betreffende Anwendung einen Nginx-Server zu schalten, der die Requests auf den Ports 80 und 443 sammelt, um mittels entsprechender vhosts-Konfiguration den Verkehr auf weitere Images zu leiten. Das Besondere dabei ist, dass die Konfiguration dieser vhosts automatisch abläuft. Einzelne Konfigurationsdateien für die zu bedienenden virtuellen Hosts sind hierbei nicht notwendig. Der nginx-proxy-Container bedient sich vielmehr Environment-Variablen, die von den Anwendungen, d.h. Docker-Containern, gesetzt werden müssen. Dabei lauscht nginx-proxy auf dem lokalen Docker-Socket nach den für ihn relevanten Events, so dass nginx-proxy mitbekommt, wenn ein neuer Container gestartet wird. Findet nginx-proxy die entsprechend gesetzten Environment-Variablen, richtet er die Konfiguration für den jeweiligen vhost selbstständig ein und bedient diesen fortan als Reverse-Proxy.

…und bitte mit Verschlüsselung!

Nun haben wir 2018 und langsam setzt sich auch bei kleineren Sites die Notwendigkeit von Verschlüsselung durch. Dank Let’s Encrypt besteht die Möglichkeit, kostenlose Zertifikate zu nutzen, so dass die etwaigen Kosten für ein SSL-Zertifikat kein Argument mehr dagegen sind. Let’s Encrypt ist allerdings auch – sagen wir mal – etwas speziell bei der Einrichtung der Zertifikate. Zwar bietet Let’s Encrypt einen für manche wunderbar bedienbaren Automatismus an, die komplette Einrichtung und Zertifikatsvergabe erfolgt skriptgesteuert, aber genau das kann wiederum eine Hürde darstellen, denn üblicherweise werden die Zertifikate extern erstellt und einfach auf dem Web-Server hinterlegt. Dankenswerterweise gibt es mit dem nginx-proxy-companion ein Docker-Image, das das komplette Handling der Zertifikate übernimmt. Ebenfalls durch Environment-Variablen gesteuert, sorgt nginx-proxy-companion dafür, dass die Zertifikate eingerichtet werden, legt sie in Verzeichnisse ab, die von nginx-proxy ansprechbar sind, und sorgt des Weiteren für die Verlängerung, falls notwendig. Vor kurzem war dazu auch in der c’t ein Artikel zu finden, der die Einrichtung beschreibt.

Obwohl dieser Artikel erst wenige Wochen alt ist, wird dort nur die Einrichtung via Docker-Compose und mittels “alter” Docker-Kommandos (“docker run...“) beschrieben, wobei das compose-File in der älteren Version 2 vorliegt. Die Github- und Docker-Hub-Seiten beider Images stellen ebenfalls nur die Einrichtung mittels althergebrachter Docker-Kommandos dar und erwähnen den Swarm Mode überhaupt nicht. Das ist durchaus verständlich, denn vorab sei verraten, dass beide Images nicht auf den neuen Swarm-Mode ausgerichtet worden sind, und zwar wegen der wieder einmal etwas komplizierten, aber notwendigen Verteilung. Dazu gibt es auch Diskussionen und Feature-Requests bei Github, möglicherweise wird der Swarm-Mode vollumfänglich ja in einer der nächsten Versionen der Container unterstützt.

…und bitte als Docker Stack / Docker Service!

Wenn nun aber – wie im ersten Beitrag geschildert, zwar der Swarm-Mode und Docker Stack genutzt werden sollen, aber wie beabsichtigt alle Container nur auf einem einzigen Host laufen, spricht doch eigentlich auch nichts dagegen, dort nginx-proxy mitsamt nginx-proxy-companion zu betreiben. So können beide Services auf dem bislang notwendigen (lokalen) Socket von Docker lauschen und auf die Events reagieren.

Gedacht – getan! Spoiler-Warnung – es funktioniert genau so. Natürlich nicht gleich auf Anhieb, anfangs sind einige Versuche durchaus misslungen, was sich aber logisch schnell erklären ließ. Im Folgenden somit eine kleine Anleitung, verbunden mit Hinweisen, um die Hürden zu umschiffen.

Die Einrichtung – Vorbereitungen

Zunächst einmal ist sicherzustellen, dass das Overlay-Network als extern definiert ist. Das hatte ich bereits im letzten Beitrag erwähnt, denn der Betrieb von nginx-proxy war genau die Begründung dafür. Der Hintergrund ist einfach – es werden zwei Docker Stacks gestartet, darin befinden sich die Services von der Anwendung (PHP-FPM, Nginx und MariaDB) und eben der nginx-proxy (später noch ergänzt durch nginx-proxy-companion). Alle Services müssen innerhalb eines Overlay-Netzes verfügbar sein – nur so kann der nginx-proxy die Anwendung erreichen. Und ich wollte vermeiden, alle Service-Definitionen in einer docker-compose-Datei zu sammeln, denn das hätte dem Ziel der Unabhängigkeit widersprochen. Also erstmal – falls noch nicht getan:

geschke@connewitz:~/docker/service$ docker network create website_net --driver overlay
m8afte77g9b02uiu5msodx4v9

geschke@connewitz:~/docker/service$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
m8afte77g9b0        website_net         overlay             swarm
[...]

Die docker-compose-Datei der Web-Anwendung war bereits darauf ausgerichtet, hier nochmal die relevanten Abschnitte:

services:
  nginx:
    image: geschke/nginx-swrm
[...]
    networks:
      - website_net
[...]
   
  phpbackend:
    image: geschke/php-fpm-swrm
[...]
    networks:
      - website_net
[...]
  mariadb:
    image: mariadb:latest
[...]
    networks:
      - website_net
[...]



networks:
  website_net:
    driver: overlay
    external: true

Die Bezeichnung des Netzwerks kann natürlich beliebig gewählt werden.

Eine weitere Änderung ist im selben File vonnöten. Und zwar darf (!) der Port 80 nicht mehr nach außen hin (bzw. im gesamten Overlay-Network) freigegeben (exposed) werden. In der ersten Fassung war im Nginx-Service folgende Option angegeben:

    ports:
      - "80:80"

Das muss rigoros weg. Denn zukünftig soll nginx-proxy für die Requests auf Port 80 (und 443) zuständig sein, es käme hierbei ansonsten zu Konflikten, d.h. der Service würde sich erst gar nicht starten lassen. Im Overlay-Network besitzt jeder Docker-Container eine interne IP-Adresse. Auf dieser ist der Port 80 auch verfügbar, aber eben nur dort. Diese Adresse wird letztlich vom nginx-proxy genutzt, um die Requests darauf zu leiten.

Nach diesen Änderungen kann der Stack erstmal neu gestartet werden. Nun haben wir eine Web-Anwendung, die von außen nicht erreichbar ist. Genau hier kommt nun nginx-proxy ins Spiel.

Nginx-Proxy

Dazu auch noch ein kleiner Einschub. In den Anleitungen von nginx-proxy (& -companion) werden beide Container immer mittels “docker run…” gestartet. Das würde auch genügen, schließlich sollen beide Container nur lokal laufen, also wäre doch gar kein Docker Stack im Swarm-Mode notwendig? Eigentlich nicht, aber hier schlägt die mangelnde Abwärtskompatibilität von Docker zu. Ein Overlay-Network, was für Docker Swarm Mode eingerichtet worden ist, und in dem die Docker Services laufen, ist nicht erreichbar bzw. kann nicht hinzugefügt werden beim Start eines Containers mittels “docker run...“.  Ein solcher Versuch endet in einer Fehlermeldung:

geschke@waren:~/service$ docker run -d --name www --network=website_net [...] httpd
6f9d56e4ff82f758218262fbd66615ec50f5a591518e6df2c03f366100fec424
docker: Error response from daemon: Could not attach to network website_net: rpc error: code = PermissionDenied desc = network website_net not manually attachable.

Die Lösung ist somit, auch für einen einzigen Container einen Docker-Stack bzw. Docker-Service zu verwenden, was am einfachsten via docker-compose-File funktioniert.

Für nginx-proxy sieht diese Datei wie folgt aus:

version: '3.3'

services:
  nginx-proxy:
    image: jwilder/nginx-proxy:latest
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.hostname == waren.mushaake.org
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./proxy/certs:/etc/nginx/certs:ro
      - ./proxy/vhost.d:/etc/nginx/vhost.d
      - ./proxy/html:/usr/share/nginx/html 
    networks:
      - website_net
    labels:
      com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: ""
networks:
  website_net:
    driver: overlay
    external: true

 

Die Parameter aus den Hinweisen zu docker run lassen sich nahezu unverändert übernehmen, in der Beispiel-Datei aus dem c’t-Artikel sind jedoch einige Parameter vorhanden, die beim Einsatz als Docker-Stack nicht zum Einsatz kommen bzw. ignoriert werden.

Die Datei beinhaltet auch keine Neuerungen – zunächst wird sicher gestellt, dass das Deployment nur auf einem Host stattfindet. Die Ports 80 und 443 werden exposed, also nach außen hin freigegeben. Des Weiteren müssen Volumes eingebunden werden, was insbesondere relevant ist für den Betrieb mit dem nginx-proxy-companion, da darin die Zertifikate und Dateien für die Verifizierung Platz finden. Somit hatte ich drei Verzeichnisse für diese Zwecke angelegt (proxy/certs, proxy/vhost.d und proxy/html), die auf die jeweiligen Ziele innerhalb des Containers verweisen. Das hier bereits vorhandene Label ist nur für den Betrieb mit dem Companion-Container notwendig, damit der nginx-proxy-companion den nginx-proxy auch finden kann.

Somit kann der nginx-proxy bereits gestartet werden:

geschke@waren:~/docker/service$ docker stack deploy -c nginx-proxy.yml nginx-proxy
Creating service nginx-proxy_nginx-proxy

geschke@waren:~/docker/service$ docker stack ls
NAME                SERVICES
nginx-proxy          1

geschke@waren:~/docker/service$ docker service ls
ID                  NAME                     MODE                REPLICAS            IMAGE                         PORTS
fqq675j6ynh2        nginx-proxy_nginx-proxy   replicated          1/1                 jwilder/nginx-proxy:latest    *:80->80/tcp

Damit nginx-proxy erkennt, welche vhosts bedient werden sollen, muss beim Backend-Server-Container, hier also Nginx, eine Umgebungsvariable gesetzt werden. Beim betreffenden Service wurde dafür folgender Abschnitt in der docker-compose-Datei hinzu gefügt:

version: '3.3'
services:
  nginx:
    image: geschke/nginx-swrm
    [...]
    environment:
      VIRTUAL_HOST: www.leitweganzeiger.de,der.leitweganzeiger.de

Alle anderen Bestandteile bleiben unverändert. Wie man sieht, können auch mehrere vhosts angegeben werden, die per Komma getrennt werden müssen.

Danach wird der Docker Stack einfach neu gestartet. In den Service- oder auch Container-Logs von nginx-proxy kann verfolgt werden, dass die Einrichtung des vhosts anhand der Umgebungsvariablen stattfindet. Tatsächlich war es das bereits, denn nach kurzer Wartezeit, die nginx-proxy zur Generierung der Konfiguration benötigt, sind die vhosts bereits erreichbar. Der erste Schritt ist somit erledigt – in der unverschlüsselten Konfiguration ist die Web-Anwendung bereits wieder erreichbar.

Erst Proxy, dann Companion, oder auch umgekehrt?

Noch ein Wort zur Reihenfolge beim Start der Container bzw. Services. In den offiziellen Anleitungen ist zu lesen, dass erst der nginx-proxy, dann ggf. nginx-proxy-companion und danach die zu bedienende Web-Anwendung gestartet werden soll. Das ist zwar absolut richtig, andererseits ist diese Reihenfolge nicht zwingend notwendig. D.h. wenn die Environment-Variablen korrekt gesetzt sind, ist die Reihenfolge beinahe irrelevant, da sowohl nginx-proxy als auch nginx-proxy-companion zeitbasiert Prozesse starten, die den jeweils erwünschten Zustand erkennen und daraufhin reagieren. Sollte also z.B. nginx-proxy durch eine neue Version ausgetauscht werden, ist es nicht notwendig, die Services der Web-Anwendung erneut zu starten.

Nginx-Proxy-Companion

Bei den ersten Tests habe ich mich jedoch an die beschriebene Reihenfolge gehalten und somit für den Let’s Encrypt Container ein eigenes docker-compose-File erstellt:

version: '3.3'

services:
  nginx-proxy-comp:
    image: jrcs/letsencrypt-nginx-proxy-companion:latest
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.hostname == waren.mushaake.org
    networks:
      - website_net
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./proxy/certs:/etc/nginx/certs
      - ./proxy/vhost.d:/etc/nginx/vhost.d
      - ./proxy/html:/usr/share/nginx/html 
networks:
  website_net:
    driver: overlay
    external: true

Danach den Companion gestartet und einen Blick auf die Logs geworfen:

geschke@waren:~/service$ docker stack deploy -c nginx-proxy_comp.yml nginx-proxy-comp
Creating service nginx-proxy-comp_nginx-proxy-comp

geschke@waren:~/service$ docker logs -f nginx-proxy-comp_nginx-proxy-comp.1.kvfq7rdexjjph7kdkm5jv0cje
Creating Diffie-Hellman group (can take several minutes...)
Generating DH parameters, 2048 bit long safe prime, generator 2
This is going to take a long time
..........................................+...................................................................................+.........................................................................................+...........+..................................................................................................+.....................................................+...+..............................................+................................................................................................................................................................+......................................................................................................................................................................................................................................................................................................+...............................+.........................................................................................+...+...........................................................+......................................................................................................................................+........................................................................................................................................................................................++*++*
Sleep for 3600s
2018/03/30 17:09:59 Generated '/app/letsencrypt_service_data' from 6 containers
2018/03/30 17:09:59 Running '/app/signal_le_service'
2018/03/30 17:09:59 Watching docker events
Sleep for 3600s

Auch das versprach Erfolg – der nginx-proxy-companion-Service lief auf Anhieb. Die “several minutes” waren übrigens nur wenige Sekunden…

Alle spielen zusammen

Damit der nginx-proxy-companion die Let’s-Encrypt-Zertifikate erzeugen kann, müssen beim Nginx-Container für die Web-Anwendung weitere Umgebungsvariablen gesetzt werden. Der betreffende Abschnitt sieht dann wie folgt aus:

version: '3.3'
services:
  nginx:
    image: geschke/nginx-swrm
[...]
    environment:
      VIRTUAL_HOST: www.leitweganzeiger.de,der.leitweganzeiger.de
      LETSENCRYPT_HOST: www.leitweganzeiger.de,der.leitweganzeiger.de
      LETSENCRYPT_EMAIL: info@leitweganzeiger.de

Dabei ist darauf zu achten, dass die DNS-Einträge korrekt gesetzt sind. Let’s Encrypt versucht, die in den Environment-Variablen angegebenen vhosts zu erreichen, d.h. diese müssen von Internet-Seite aus zugänglich sein. Tatsächlich waren meine ersten Versuche von wenig Erfolg gekrönt, da ich für einen weiteren Hostnamen ein Zertifikat erstellen lassen wollte, für den jedoch eine andere öffentliche IP-Adresse eingetragen war. Das funktionierte verständlicherweise nicht.

Damit der nginx-proxy-companion von den Environment-Variablen Kenntnis erlangt, muss die Web-Anwendung noch neu gestartet werden:

geschke@waren:~/service$ docker stack rm website
Removing service website_mariadb
Removing service website_nginx
Removing service website_phpbackend
Removing config website_nginx_config_default

geschke@waren:~/service$ docker stack deploy -c website_prod.yml website
Creating config website_nginx_config_default
Creating service website_nginx
Creating service website_phpbackend
Creating service website_mariadb

In den Logs von nginx-proxy und nginx-proxy-companion lässt sich ggf. nachverfolgen, ob die Let’s-Encrypt-Zertifikatsvergabe erfolgreich war oder eben nicht.

Falls alles funktioniert hat, ist die Web-Anwendung nun bereits per https erreichbar!

Tatsächlich waren diese Schritte bis hierhin einfacher als zunächst gedacht, letztlich funktionierten beide Services auf Anhieb. Jedoch hatte ich mich zunächst wie erwähnt an die Start-Reihenfolge gehalten – erst nginx-proxy, dann nginx-proxy-companion. Sofern “docker-compose” zum Einsatz kommt, um die Container zu starten, d.h. sofern nicht die neueren Komponenten Docker Stack und Docker Services verwendet werden, lassen sich im docker-compose-File Abhängigkeiten definieren, so dass z.B. ein Container erst nach erfolgreichem Hochfahren eines anderen gestartet wird. Bei neueren Versionen des docker-compose-Files bzw. bei Nutzung von Docker Stack werden diese Optionen jedoch ignoriert. Da die genaue Reihenfolge aufgrund des zeitgesteuerten Triggerns aber keine Rolle spielt, lassen sich die beiden Services auch in einer docker-compose-Datei zusammenfassen:

version: '3.3'

services:
  nginx-proxy:
    image: jwilder/nginx-proxy:latest
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.hostname == waren.mushaake.org
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./proxy/certs:/etc/nginx/certs:ro
      - ./proxy/vhost.d:/etc/nginx/vhost.d
      - ./proxy/html:/usr/share/nginx/html 
    networks:
      - website_net
    labels:
      com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: ""
  nginx-proxy-comp:
    image: jrcs/letsencrypt-nginx-proxy-companion:latest
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.hostname == waren.mushaake.org
    networks:
      - website_net
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./proxy/certs:/etc/nginx/certs
      - ./proxy/vhost.d:/etc/nginx/vhost.d
      - ./proxy/html:/usr/share/nginx/html 
networks:
  website_net:
    driver: overlay
    external: true

Somit muss nur noch ein Docker Stack für beide Services gestartet werden:

geschke@waren:~/service$ docker stack rm nginx-proxy-comp
Removing service nginx-proxy-comp_nginx-proxy-comp
geschke@waren:~/service$ docker stack rm nginx-proxy
Removing service nginx-proxy_nginx-proxy
geschke@waren:~/service$ docker stack deploy -c nginx-proxy_prod.yml nginx-proxy
Creating service nginx-proxy_nginx-proxy
Creating service nginx-proxy_nginx-proxy-comp

Laravel-Nacharbeiten

Zwar war die Website per https erreichbar, aber ein Problem gab es noch – die von Laravel erzeugten URLs für die CSS- und JavaScript-Dateien wurden per http angesprochen. Denn die Web-Anwendung bekam von der Umstellung auf https ja gar nichts mit, sie wird vom “internen” Nginx weiterhin unverschlüsselt über den Port 80 angesprochen. Somit wurden die CSS- und JavaScript-Dateien vom Browser erst gar nicht geladen.

Eine schnelle, aber vielleicht nicht besonders elegante Lösung war schnell gefunden – in die Datei “routes/web.php” folgende Zeilen eintragen und die entsprechende Environment-Variable setzen:

if (env('APP_ENV') === 'production') {
    URL::forceSchema('https');
}

Evtl. ist der Einsatz einer kleinen Middleware-Klasse dafür jedoch empfehlenswerter.

Nachdem der Website-Stack dann nochmal neu gestartet worden war, stimmten auch die von Laravel erzeugten URLs, so dass die Site nun über https erreichbar ist.

Fazit

Insgesamt war die Einrichtung bis auf lösbare Kleinigkeiten schnell und problemarm. Nginx-Proxy im Gespann mit Nginx-Proxy-Companion bieten eine sehr schöne Werkzeugkiste, die einem etlichen manuellen Aufwand bei der Zertifikatserzeugung erspart. Nicht zuletzt können selbstverständlich weitere Web-Anwendungen im Backend durch den Reverse-Proxy angesprochen werden, und somit mehrere vhosts auf dem einzelnen Node realisiert werden. Gleichzeitig bleibt die Flexibilität von Docker und -Stack, -Services erhalten. Wenn es jetzt noch gelänge, dies in einem Swarm-Cluster auf mehreren Nodes in derselben einfachen Art und Weise einzurichten, wäre eine weitere Hürde bewältigt. Vielleicht probiere ich noch ein wenig aus… 🙂

PS. Heute ist zwar der 01. April, aber dass die o.g. Konfiguration funktioniert, davon kann sich jeder gerne überzeugen. Scherze gibt’s anderswo. 😉

Update: Ports im Cluster vs. Ports auf einem Node

Nach Fertigstellung gab es noch ein kleines Problem. Denn wie im Docker Swarm üblich, findet ein Loadbalancing statt, so dass alle Nodes auf den nach außen freigegebenen Ports angesprochen werden können. Dieses “ingress routing mesh” genannte Verfahren sorgt dafür, dass die Requests ggf. auf den betreffenden, aktiven Container weiter geleitet werden. Nun ist dies bei der hier vorgestellten Einschränkung nicht nötig, da alle Services nur auf einem Host laufen sollen. Vielmehr stellte es sich sogar als kontraproduktiv heraus, da auf einem anderen Node im Cluster noch Docker-Container nach dem alten Start-Schema (“docker run…”) liefen. Diese gaben ebenfalls den Port 443 nach außen frei. Und schon kam es zu einem Konflikt, der übrigens das ingress-Network gewonnen hatte, d.h. der andere Dienst war nicht mehr ansprechbar. In letzter Konsequenz hätte ich zwar den bisherigen Dienst auch als Service konfigurieren können, aber selbst das wäre noch problematisch gewesen, da der zweite Dienst ja auch auf einem anderen Node laufen sollte, somit würde der nginx-proxy dessen Events auf dem lokalen Socket (des anderen Nodes) nicht folgen können.

Kurzum – es wäre besser gewesen, wenn der nginx-proxy-Service auch nur Ports auf dem eigenen Node öffnet und alle anderen Nodes im Cluster in Ruhe lässt. Genau das funktioniert mit folgender Port-Konfiguration:

version: '3.3'

services:
  nginx-proxy:
    image: jwilder/nginx-proxy:latest
[...]
    ports:
      - target: 80
        published: 80
        protocol: tcp
        mode: host
      - target: 443
        published: 443
        protocol: tcp
        mode: host

Per Default ist der Mode auf “ingress” eingestellt, d.h. Loadbalancing findet statt. Mit “mode: host” hingegen wird das Load-Balancing umgangen und der Port wird nur auf dem einzelnen Host geöffnet.

 

Schreibe einen Kommentar

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

Tags: