An den Grenzen von Docker Nginx-Proxy

…und wie ich darüber hinaus gekommen bin. Das war mir aber als Titel einfach zu lang. Aber zurück zum Thema – über das Nginx-Proxy-Docker-Image und den Docker-Letsencrypt-Nginx-Proxy-Companion hatte ich ja bereits den einen oder anderen Artikel verfasst. Die betreffenden Docker-Container laufen seit der Einrichtung problemlos, insofern bin ich damit vollends zufrieden.

Nun laufen auch bei mir zu Hause Server-Dienste, die sich dank schneller Internet-Leitungen auch gut nutzen lassen. Hauptsächlich betrifft dies ein Redmine-System, wobei das Wiki am meisten genutzt wird, direkt danach dürfte das Ticket-System folgen. Warum es ausgerechnet auf dem heimischen Server läuft, kann ich selbst gar nicht mehr so richtig nachvollziehen, es ist nun einfach so und ich sehe keinen Grund, dies zu ändern.

Vorbemerkungen

Ursprünglich hatte ich damit gerechnet, den Docker-Nginx-Proxy sowie den Nginx-Proxy-Companion nutzen zu können. Im Folgenden kurz zu den Prämissen für die später beschriebene Lösung.

FQDN mit dynamischem DNS

Die Konfiguration ist so wie man sie üblicherweise kennt – um das Problem mit der spätestens alle 24 Stunden sich ändernden IP-Adresse zu umgehen wird ein Dienst für dynamisches DNS genutzt, den mein Domain-Provider glücklicherweise zur Verfügung stellt. Für den Hostnamen, der vom dynamischen DNS gespeist und aktualisiert wird, habe ich dann noch einen CNAME-Eintrag definiert, so dass ich meinen bevorzugten Hostnamen nutzen kann.

Verschlüsselte Verbindung

Heutzutage ist eine verschlüsselte Netzwerkverbindung (SSL bzw. TLS) Standard, vor einigen Jahren war diese noch weniger verbreitet. Da Redmine jedoch eine Authentifizierung vorsieht, sollte die Verbindung natürlich verschlüsselt sein. Nun benötigt man dafür ein valides SSL-Zertifikat, andernfalls wird man von den Browsern vehement und permanent darauf hingewiesen, dass das Zertifikat ungültig sei, es taucht die Frage auf, ob man wirklich und ganz sicher oder lieber doch nicht auf die betreffende Seite möchte usw.. Das ist nicht nur unbequem, sondern stört auch, insofern musste ein SSL-Zertifikat her. Der Nachteil: Die Zertifizierungsstellen bzw. Provider lassen sich die Ausstellung der Zertifikate früher mehr, heute weniger fürstlich bezahlen. Nun wäre mein Zertifikat in wenigen Tagen abgelaufen und somit ein neues fällig gewesen. Doch wenn ich bereits die LetsEncrypt-Zertifikate für meinen öffentlichen Web-Auftritt nutze, warum dann nicht auch für die Systeme, die ich nur privat verwende? Noch dazu ist dies dank LetsEncrypt und dank des Nginx-Proxy-Companion nicht nur kostenlos, sondern auch sehr bequem, da die Verlängerung automatisch erfolgt.

Mehrere Server-Dienste

Neben Redmine möchte ich auch andere Dienste erreichen, und zwar einerseits verschlüsselt, andererseits unter demselben Hostnamen. Eine Lösung dafür kann die Nutzung mehrerer, vom Standard-Port 443 abweichende Ports sein. Da ich letztlich nur selbst die entsprechenden Dienste nutze, spricht nichts dagegen, die Portadresse hinter dem Hostnamen mit einzugeben. Falls man Server-Dienste für die weite Welt anbietet, wäre ein solches Verfahren natürlich weniger empfehlenswert, aber für meine Anwendung genau richtig. Zwar hätte ich auch auf weitere Subdomains ausweichen können, die alle per CNAME-Eintrag auf denselben Hostnamen zeigen, aber dagegen sprach bislang, dass man dafür entweder mehrere SSL-Zertifikate benötigt hätte – eines für jeden Hostnamen, oder ein Wildcard-Zertifikat hätte nutzen müssen. Wildcard-Zertifikate lohnen sich rein finanziell jedoch erst ab einer bestimmten Anzahl von Subdomains, und diese Anzahl hatte ich noch nicht erreicht. Und für jeden Dienst ein weiteres Zertifikat kaufen wollte ich auch nicht. Seit der Existenz der kostenlosen LetsEncrypt-Zertifikate hat sich dieses Problem glücklicherweise aufgelöst. Andererseits war ich nun an die Lösung mit unterschiedlichen Ports gewöhnt, also wollte ich diese beibehalten.

Mehrere VMs mit Server-Diensten im internen Netzwerk

Intern, d.h. im heimischen Netzwerk läuft nicht nur (aber auch) ein kleiner Raspberry Pi und stellt Server-Dienste bereit, sondern mehrere virtuelle Maschinen jeweils mit unterschiedlichen Aufgaben. Schließlich möchte man flexibel bleiben, oder auch stabil, z.B. bei Redmine. Da die Installation von Redmine – sagen wir mal nicht ganz unaufwändig ist, der Server jedoch extrem wichtig, soll auf dieser VM auch nur Redmine laufen. Andere Dienste sind weniger relevant, dienen etwa nur als Test o.ä.. Darüber hinaus wollte ich einen zentralen Punkt, an dem alle Web-Dienste auflaufen, um z.B. eine HTTP-Authentifizierung vorschalten zu können, und natürlich um die Verschlüsselung zu konfigurieren. Somit liegt das SSL-Zertifikat nur an einer Stelle und muss nur dort installiert werden. Dazu dient ein Nginx-Server, der als Reverse-Proxy konfiguriert ist. Bislang lief dieser exklusiv auf einer VM. Nun hätte es sicherlich auch gereicht, die LetsEncrypt-Tools auf dieser Maschine einzurichten und die Zertifikate generieren zu lassen. Ja, aber… Meine Erfahrungen mit Nginx-Proxy und LetsEncrypt-Nginx-Proxy-Companion waren bislang gut, insofern wollte ich auch auf eine Container-Lösung umsteigen.

Zusammengefasst:

Mehrere Server-Dienste sollen erreicht werden können, der bisherige Nginx-Reverse-Proxy sollte durch eine Docker-Lösung ersetzt werden, das ganze möglichst kostengünstig per LetsEncrypt-Zertifikaten, jedoch flexibel gehalten durch ein Reverse-Proxy-System, das nach außen hin neben den Standard-Ports weitere verschlüsselte Verbindungen auf mehreren Ports anbietet. Das sollte doch eigentlich recht einfach machbar sein, oder?

Die geplante Lösung

Aufgrund des Plans, den Nginx-Proxy-Container mitsamt Nginx-Proxy-Companion verwenden zu wollen, hätte sich eine Lösung wie in folgender Skizze ergeben:

An den Grenzen von Docker Nginx-Proxy 1

Dabei laufen alle Container auf einer VM, der Router sorgt dafür, dass die Requests auf die Standard-Ports 80 und 443 sowie auf weitere Ports an die entsprechenden Ports der VM weiter gereicht werden. Der hier als “Nginx (Proxy) intern” bezeichnete Container erledigt die eigentliche Arbeit, d.h. in dessen Konfiguration ist gespeichert, bei welchen Anfragen welcher Web-Server bzw. Dienst im Netzwerk angesprochen werden soll. Aufgrund der vorab beschriebenen Voraussetzungen erfolgt die Unterscheidung anhand der Ports und nicht anhand unterschiedlicher Subdomains oder Pfade. Im Prinzip ist dieser Container dasselbe wie die bisherige Nginx-Installation ohne Docker und hätte auch vollkommen ausgereicht, wenn nicht die Notwendigkeit von SSL-Verschlüsselung bestehen würde.

Der Nginx-Proxy-Container im Zusammenspiel mit dem Nginx-Proxy-Companion sollte sich um die Nutzung der LetsEncrypt-Zertifikate kümmern. Hierbei war es nicht wichtig, hinter dem Nginx-Proxy-Container unterschiedliche Web-Server zu betreiben, die mit mehreren (Sub-)Domains funktionieren, so dass sich der Nginx-Proxy-Container automatisch konfiguriert. Vielmehr sollte nur ein Hostname bedient werden, dafür jedoch zusätzliche Ports außer unverschlüsseltes http auf Port 80 oder verschlüsseltes https auf Port 443.

Schöner Plan, aber…

Ich will es kurz machen – die Lösung wie zunächst geplant funktionierte nicht. Der Nginx-Proxy-Container ist nicht dafür vorgesehen, weitere Ports außer den Standard-Ports zu betreiben. Es geht schlicht und einfach nicht.

Zwar kann man dafür sorgen, dass nach innen ein anderer Port genutzt wird, indem die Umgebungsvariable VIRTUAL_PORT übergeben wird, ebenso lässt sich mit Docker-Hausmitteln dafür sorgen, dass nach außen hin der Nginx-Proxy auf einem anderen Port lauscht, aber es ist nicht möglich, mehrere Ports transparent durchzureichen, so dass z.B Requests auf Port 8000 auch an Port 8000 und Requests auf Port 8001 wiederum auf Port 8001 weiter gegeben werden. Ich habe mir letztlich den Quelltext des Go-Templates durchgelesen, durch das die Konfiguration des Nginx-Proxy erfolgt. Darin sind die Ports (falls kein VIRTUAL_PORT genutzt wird), fest eingestellt, und während über eine Reihe von Hostnamen (VIRTUAL_HOST) eine Schleife ausgeführt wird, ist eine solche Möglichkeit für mehrere Ports nicht vorgesehen.

Mit der gleichzeitigen Nutzung von mehreren Ports war insofern die Grenze des Nginx-Proxy-Container bzw. insbesondere des darin eingesetzten Konfigurations-Templates erreicht. Es war einfach die falsche Lösung für die beschriebenen Anforderungen.

Zwar hätte sich das Template erweitern lassen, andererseits würde ein Fork auch wiederum Wartung bedürfen, falls er nicht ins Standard-Repository übernommen worden wäre, andererseits wäre die Komplexität durch die Unterstützung unterschiedlicher Ports doch um einiges erhöht worden, obwohl diese Funktionalität wenn überhaupt, dann nur von einem Bruchteil der Nutzer gewünscht worden wäre.

Die Lösung

Letztlich habe ich mich für einen anderen Weg entschieden, und zwar sollten die Standard-Ports 80 und 443 vom Nginx-Proxy-Container bedient werden, während für alle weiteren Ports ein anderer Nginx zuständig wäre. Schließlich würden die SSL-Zertifikate dank Nginx-Proxy-Companion nach dessen Start vorliegen, da sie in das entsprechende Volume, ergo Filesystem geschrieben werden. Somit könnten diese Zertifikate einfach genutzt werden, ein Mounten des betreffenden Volumes würde genügen. Einzig erfolgt die Konfiguration des “anderen” Nginx-Containers weniger automatisch, aber da von vornherein nur ein Hostname genutzt werden sollte und die Nginx-Config-Files bereits existierten, nahm ich das gerne in Kauf.

Die angestrebte Lösung in der Übersicht:

An den Grenzen von Docker Nginx-Proxy 2

Der “2. Nginx (Proxy) intern” erfüllt hier die Aufgabe, sich um die Requests an die Nicht-Standard-Ports zu kümmern. Dabei soll er die SSL-Zertifikate nutzen, die vom Nginx-Proxy-Companion bereitgestellt werden. Da alle Container innerhalb einer VM ablaufen, war es kein Problem, ein Verzeichnis mehreren Containern als Volume zu mounten.

Eine Hürde: Die Gültigkeit der LetsEncrypt-Zertifikate

Es gab jedoch einen Haken: Die LetsEncrypt-Zertifikate sind jeweils nur drei Monate gültig. Daher kümmert sich der Nginx-Proxy-Companion nicht nur um das initiale Generieren, sondern auch um die Aktualisierung der Zertifikatsdateien. Dabei übernimmt er auch den Neustart des Nginx-Proxy, so dass die Konfigurationsdateien neu eingelesen und die neu erstellten Zertifikate genutzt werden. Aufgrund der engen Verknüpfung bzw. des Mountens des Docker-Sockets in die Container können alle Informationen bzw. Docker-Events gelesen werden, der Neustart der Container erfolgt danach mittels der Docker-API.

Der Nginx-Proxy-Companion ist jedoch darauf ausgerichtet, mit einem einzigen Nginx-Proxy-Container zusammenzuarbeiten. Von einer Aktualisierung der Zertifikate würde insofern der zweite Nginx-Proxy gar nichts mitbekommen.

Es musste also eine Möglichkeit gefunden werden, den zweiten Nginx-Proxy, der für die Ports außer 80 und 443 zuständig sein würde, neu zu starten bzw. die Konfiguration neu einzulesen (und damit auch die SSL-Zertifikate), sobald eine Änderung der Zertifikate vorliegt.

Beobachtung von Dateiänderungen mit inotify

Eine Lösung, um Änderungen von Dateien oder Verzeichnissen zu überwachen, bietet ein System namens inotify bzw. der darauf aufbauende Cron-Daemon Incron. Zur grundsätzlichen Funktionsweise siehe beispielsweise siehe z.B. den inotify-Beitrag im Ubuntuusers-Wiki oder den Artikel über Incron im Admin-Magazin. Kurz gefasst handelt es sich um ein Subsystem des Linux-Kernels, das Dateien und Verzeichnisse überwacht und bei Änderungen Events erzeugt, auf die wiederum reagiert werden kann. Dabei werden für Dateiaktionen wie Öffnen (Lesezugriff), Schließen, Metadaten ändern, Löschen, Erzeugen, Verschieben usw. jeweils Events erzeugt. Mit den inotify-Tools inotifywait und inotifywatch können die Events live beobachtet oder in Shell-Skripts ausgewertet werden. Dankenswerterweise sind sowohl die Inotify-Tools als auch Incron als Paket in der Ubuntu-Distribution enthalten, insofern lässt sich beides schnell einsetzen.

Insofern hieß es nun, Incron so einzurichten, dass eine Beobachtung der LetsEncrypt-Zertifikatsdateien erfolgen würde, so dass der zweite Nginx-Proxy daraufhin seine Konfigurationsdateien neu einlesen konnte.

Dazu habe ich das Nginx-Docker-Image, was bei mir zum Einsatz kommt, entsprechend erweitert und mich dabei vom Nginx-Proxy inspirieren lassen. Im Nginx-Proxy-Container wird nicht nur Nginx selbst gestartet, sondern ebenfalls das Tool docker-gen, was auf Docker-Events reagiert und aus einer Datei nginx.tmpl (ein Go-Template) die Konfiguration generiert. Der Nginx-Proxy-Container nutzt dabei zum Start der Programme forego, eine Foreman-Implementierung in Go. Bei ähnlichen Anforderungen hatte ich zuvor Supervisord genutzt, aber man kann ja auch mal etwas Anderes ausprobieren, forego ist dabei sehr einfach in der Anwendung.

Erste Tests mit Incron zeigten jedoch ein Problem – und zwar nutzt die Verzeichnisstruktur, in der sich die SSL-Zertifikate befinden, symbolische Links. Die Struktur sieht z.B. wie folgt aus:

root@9990e677cf18:/etc/nginx/certs# ls -la
total 36
drwxrwxr-x 1 1000 1000  324 Sep  1 07:55 .
drwxr-xr-x 1 root root  362 Sep  1 07:55 ..
drwxr-xr-x 1 root root   56 Aug 29 18:39 accounts
-rw-r--r-- 1 root root 1736 Aug 29 18:19 default.crt
-rw-r--r-- 1 root root 3272 Aug 29 18:19 default.key
drwxr-xr-x 1 root root  106 Sep  8 07:59 home.example.com
lrwxrwxrwx 1 root root   27 Aug 29 18:39 home.example.com.chain.pem -> ./home.example.com/chain.pem
lrwxrwxrwx 1 root root   31 Aug 29 18:39 home.example.com.org.crt -> ./home.example.com/fullchain.pem
lrwxrwxrwx 1 root root   13 Aug 29 18:39 home.example.com.dhparam.pem -> ./dhparam.pem
lrwxrwxrwx 1 root root   25 Aug 29 18:39 home.example.com.key -> ./home.example.com/key.pem
-rw-r--r-- 1 root root  424 Aug 29 18:19 dhparam.pem
-rw-r--r-- 1 root root  424 Aug 29 18:39 dpharam.pem
-rw-rw-r-- 1 1000 1000    1 Aug 26 17:07 .keep

Leider werden jedoch Änderungen an Dateien, die durch symbolische Links referenziert werden, von inotify ignoriert. Insofern musste das Verzeichnis, in dem sich die eigentlichen Zertifikate ohne Symlinks befinden, überwacht werden. Ursprünglich wollte ich nur das Verzeichnis /etc/nginx/certs/ überwachen lassen, aber dies schlug leider fehl. Den Hostnamen wollte ich jedoch nicht fest im Docker-Image definieren, insofern wird der Hostname nun in einer Umgebungsvariable beim Start des Containers übergeben, ausgewertet und als Konfigurationsdatei für Incron erzeugt.

Ein neues Docker-Image für Nginx

Das Image ist dennoch sehr einfach gehalten und besteht aus den folgenden Komponenten.

Dockerfile:

FROM geschke/nginx-swrm


LABEL maintainer="Ralf Geschke <ralf@kuerbis.org>"

LABEL last_changed="2018-09-01"

# Install wget and install/updates certificates
RUN apt-get update \
 && apt-get install -y -q --no-install-recommends \
    ca-certificates \
    incron  \
    inotify-tools \ 
 && apt-get clean \
 && rm -r /var/lib/apt/lists/*


# Install Forego
ADD https://github.com/jwilder/forego/releases/download/v0.16.1/forego /usr/local/bin/forego
RUN chmod u+x /usr/local/bin/forego


COPY . /app/
WORKDIR /app/


EXPOSE 8000 8100 8200 


ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["forego", "start", "-r"]

Procfile zum Start von forego:

incrond: incrond -n
nginx: nginx

Und schließlich dem Skript docker-entrypoint.sh, das die Konfiguration für Incron generiert:

#!/bin/bash
set -e

echo "/etc/nginx/certs/$LETSENCRYPT_HOST/ IN_MODIFY,IN_ATTRIB /usr/sbin/nginx -s reload" > /etc/incron.d/nginx



exec "$@"

Dadurch entsteht eine Datei namens “nginx” im Verzeichnis “/etc/incron.d/“. Die Incron-Dateien lassen sich entweder als User ähnlich wie beim normalen Cron erzeugen, nur dass anstatt des Kommandos “crontab -e” das Kommando “incrontab -e” lauten muss. Eine andere Möglichkeit ist die Nutzung von systemweiten Incron-Tabs, die als Dateien im hier genannten Verzeichnis liegen. Die Struktur der Crontab-Dateien ist vergleichbar zum normalen Cron. Die Incron-Tab-Datei sieht wie folgt aus:

/etc/nginx/certs/home.example.com/ IN_MODIFY,IN_ATTRIB /usr/sbin/nginx -s reload

Zunächst wird das zu überwachende Verzeichnis bzw. die zu überwachende Datei genannt, danach folgen die Kennungen der Events, auf die reagiert werden soll. Dabei können mehrere Events durch Komma getrennt angegeben werden. Danach folgt das Kommando, das ausgeführt werden soll, wenn ein Event auftritt. Weitere Hinweise finden sich in den zuvor genannten Artikel und auf der Incron-Website.

Das Docker-Image wird nun auf dem üblichen Weg (docker build -t nginx-proxy-int .)erzeugt, getauft habe ich es nginx-proxy-int.

Das Docker-Compose-File sorgt nun für den gemeinsamen Start:

version: '3.3'

services:
  nginx-proxy:
    image: jwilder/nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    networks: 
      - proxynet
    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 
    labels:
      com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: true

  nginx-proxy-comp:
    image: jrcs/letsencrypt-nginx-proxy-companion:latest
    networks:
      - proxynet
    depends_on: # sehr WICHTIG!!!!!!!
      - nginx-proxy      
    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 
    
  nginx:
    image: geschke/nginx-swrm
    volumes:
      - type: bind
        source: ./nginx/html
        target: /var/www/html
        read_only: true
      - type: bind
        source: ./nginx/sites-enabled
        target: /etc/nginx/sites-enabled
    networks: 
      - proxynet
    ports:
      - "80"
      - "443"
    environment:
      VIRTUAL_HOST: "home.example.com"
      LETSENCRYPT_HOST: home.example.com
      LETSENCRYPT_EMAIL: webmaster@example.com
      
  nginx_other:
    image: nginx-proxy-int
    volumes:
      - ./proxy/certs:/etc/nginx/certs:ro
      - ./nginx_other/sites-enabled:/etc/nginx/sites-enabled
    networks: 
      - proxynet
    depends_on: 
      - nginx-proxy-comp
    ports:
      - "8000:8000"
      - "8100:8100"
      - "8200:8200"
    environment:
      LETSENCRYPT_HOST: "home.example.com"
    
    

networks:
  proxynet:
    driver: bridge

Die Ports, Hostnamen etc. sind wie immer nur als Beispiel zu sehen, die Infrastruktur muss so eingerichtet sein, dass beim Start mittels docker-compose der jeweilige Hostname von außen erreichbar ist, so dass das LetsEncrypt-Zertifikat nach dem ACME-Protokoll generiert werden kann.

Im Docker-Compose-File sind auch Abhängigkeiten definiert, denn der Nginx-Proxy-Container muss vor dem Start von Nginx-Proxy-Companion zwingend laufen, ansonsten schlägt Nginx-Proxy-Companion gnadenlos fehl.

Fazit

Soviel zu meiner Lösung, die letztlich etwas komplexer ist als ursprünglich gedacht war, aber angesichts der schier unermesslichen Vielfalt von Tools rund um Linux doch realisierbar. Man lernt ja auch gerne mal etwas Neues kennen, in diesem Fall waren es das inode-Überwachungs-Subsystem inotify und Incron. Natürlich ist diese Anwendung aufgrund der Anforderungen, mehrere, verschlüsselte Ports zu unterstützen, sehr speziell. Aber immerhin fand sich auch dafür ein Weg zur Realisierung.

 

2 Gedanken zu „An den Grenzen von Docker Nginx-Proxy“

  1. Versteh nicht warum das so kompliziert ist? Der Nginx reverse proxy kann auf beliebige ports hören? Man muss doch nur die Firewall konfigurieren

    1. Hast Du gelesen und wirklich verstanden, worum es mir ging? Abgesehen davon – die geschilderte Lösung ist nun ca. drei Jahre alt. Ich bezweifle gar nicht, dass es andere Ansätze oder inzwischen einfachere und bessere Lösungen geben mag. Aktuell würde ich evtl. Traefik statt des Nginx-Proxy-Containers verwenden, genau wie auf anderen Web-Sites seit geraumer Zeit im Produktivbetrieb eingesetzt.
      Evtl. wurde auch die Funktionalität des Nginx-Proxy-Images inzwischen geschraubt, so dass dieser nun die Möglichkeit bietet, mehrere Ports so durchzuleiten, dass gilt: externer Port == innerer Port (im Container).

Schreibe einen Kommentar

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

Tags: