Admin, ich habe den Container geschrumpft! – Ein minimales Nginx-Docker-Image

Seit einigen Monaten läuft auf einer meiner VMs Gitlab als Docker-Container. Wie bereits beschrieben, funktioniert dies in der Praxis wunderbar, wobei die komplette Anwendung aus einem Redis-, einem PostgreSQL- und dem Gitlab-Container besteht. Hinzu kam vor einigen Wochen noch ein Nameserver-Container. Wenn man – wie ich – neugierig auf die Vorgänge auf der virtuellen Maschine sowie innerhalb der Container ist, beispielsweise Informationen über deren Ressourcenverbrauch erhalten möchte, bietet sich z.B. Googles cAdvisor an. Das Tool bietet eine Live-Ansicht über Ressourcen wie CPU-Zeit, Speicherverbrauch, Größe der Images usw., zusammen gefasst in einer durchaus netten Web-UI. 

Dank eines vorbereiteten Docker-Images ist cAdvisor schnell installiert, ich zitiere dass README, und zwar mittels eines Kommandos wie:

sudo docker run \
  --volume=/:/rootfs:ro \
  --volume=/var/run:/var/run:rw \
  --volume=/sys:/sys:ro \
  --volume=/var/lib/docker/:/var/lib/docker:ro \
  --publish=8080:8080 \
  --detach=true \
  --name=cadvisor \
  google/cadvisor:latest

Wie unschwer zu erkennen ist, wird hier der Port 8080 genutzt, so dass das Web-UI fortan unter dem Servernamen auf Port 8080 erreichbar isr. Nun möchte man jene Informationen jedoch nicht in aller Welt veröffentlichen, sondern der Zugang sollte eingeschränkt werden, insofern muss zumindest eine rudimentäre Authentifizierung aktiviert werden. Zwar bietet cAdvisor auch diese Möglichkeit an, jedoch blieb die Frage offen, inwieweit die Authentifizierungsoptionen vom Docker-Image unterstützt werden. Ganz zu schweigen davon, dass die Übertragung im besten Fall verschlüsselt werden sollte, damit nicht jeder das Passwort mitlesen kann…

Daher entstand die Idee, einen Nginx-Webserver einzusetzen, der vorgelagert die Aufgaben Authentifizierung und SSL-Verschlüsselung übernimmt und als Reverse Proxy die Requests an den cAdvisor-Server weitergibt. Zugegebenermaßen finden sich auf dem Docker Hub etliche Nginx-Images, also warum noch ein eigenes bauen? Ganz einfach – um es einerseits so minimal wie möglich halten und dennoch an die eigenen Bedürfnisse anpassen zu können.

Nginx ist bekanntlich ein kleiner, schneller Web-Server, daher erschien mir der Gedanke reizvoll, ein minimalistisches Docker-Image dafür zu bauen. Auf dem bereits früher zitierten Docker-Meetup Cologne bin ich auf eine dafür sehr gut geeignete Linux-Distribution aufmerksam geworden – Alpine Linux. Basierend u.a. auf Busybox verbraucht das Grundsystem gerade einmal ca. 5 MB. In Worten, fünf Megabyte! Zwar war ich anfangs noch inspiriert, die Rails-Anwendung darauf aufzubauen, aber das scheiterte letztlich an einigen Abhängigkeiten (hat jemand gerade “Nokogiri” gesagt..?). Aber bei Nginx sollte dies eigentlich einfacher sein, denn glücklicherweise wird ein Nginx-Paket bereits von Alpine Linux angeboten, und das wollte ich auch gerne verwenden. Der erste – und eigentlich einzige – Schritt bestand nun im Aufbau des Dockerfile, welches letztlich wie folgt aussieht:

FROM alpine:3.2

MAINTAINER Ralf Geschke <ralf@kuerbis.org>

RUN apk update && apk add nginx && chown -R nginx /var/lib/nginx

COPY files/nginx.conf /etc/nginx/
VOLUME /etc/nginx/sites-enabled
VOLUME /etc/nginx/conf.d

EXPOSE 80 443

CMD ["nginx"]

Bis das Dockerfile so weit war, bedurfte es jedoch einiger Experimente – oder nennen wir es Erfahrungen.

Zunächst wird Nginx aus dem Paketsystem installiert – das war noch der einfachste Schritt. Das Paket enthält bereits eine Konfigurationsdatei nginx.conf im Verzeichnis /etc/nginx/, diese musste jedoch ein wenig angepasst werden. Um es mir einfacher zu machen, habe ich die Original-Datei verwendet und entsprechend modifiziert. Während des Bauens des Containers wird diese zentrale nginx.conf im Image abgelegt. Diese Datei sieht wie folgt aus:

#user  nobody;
worker_processes  4;
daemon off;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    gzip on;
    gzip_disable "msie6";


    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;


}

Die Änderungen gegenüber der Originaldatei im Detail:

  • daemon off – Üblicherweise startet Nginx als Daemon, der die Kontrolle wieder an die Konsole zurück gibt. Da im Container jedoch genau der Nginx-Prozess gestartet wird bzw. als einziger laufen soll, ist das Daemon-Verhalten unerwünscht.
  • gzip on – zur Reduktion des übertragenen Datenvolumens
  • include /etc/nginx/conf.d/*.conf – Diese und die nachfolgende Option habe ich bei Ubuntu immer als sehr praktisch empfunden. Damit lassen sich weitere Optionen setzen, während die Original-nginx.conf-Datei nicht modifiziert werden muss.
  • include /etc/nginx/sites-enabled/* – Sämtliche Einstellungen der zu bedienenden Websites werden in Dateien in diesem Verzeichnis vorgenommen. D.h. darin befinden sich die Direktiven für server { .. } und somit die Konfiguration für die jeweiligen Hosts.
  • Der server { … }-Block wurde aus der nginx.conf entfernt.

Zum Bauen des Docker-Images liegt diese nginx.conf im Verzeichnis namens files. Damit kann nun das Nginx-Image gebaut werden:

docker build -t geschkenet/nginx .

Die Verzeichnisse conf.d und sites-enabled werden später beim Start des Containers in diesen hinein gemountet. Bei den ersten Versuchen lief Nginx noch nicht, was daran lag, dass die Rechte für die Verzeichnishierarchie /var/lib/nginx falsch gesetzt waren. Da Nginx auch unter dem User nginx läuft, erhält dieser nach der Installation von Nginx die entsprechenden Rechte.

Nach dem Bauen des Images zeigt ein Blick auf die Image-Größe den hier an den Tag gelegten Minimalismus:

REPOSITORY              TAG         [...]   VIRTUAL SIZE
geschkenet/nginx        latest              7.413 MB

Das komplette Nginx-Image umfasst weniger als 8 MB! Zum Vergleich habe ich mir das offizielle Nginx-Image aus dem Docker-Hub geholt, was auf Debian basiert:

REPOSITORY              TAG         [...]   VIRTUAL SIZE
nginx                   latest              132.8 MB

Ein kleiner, aber feiner Unterschied… Natürlich muss beachtet werden, dass es sich um die “virtual size” handelt. D.h. wenn mehrere Images auf z.B. dem Debian-Image basieren, ist dieses auch nur einmal vorhanden, die darauf basierenden Images beinhalten nur die Differenz. Das gilt jedoch für jedes andere Image analog, und Nginx alleine belegt gerade einmal ca. 2,5 MB. Insofern hat dies dies durchaus gelohnt, und nebenbei weiß man so wenigstens, was in dem Image enthalten ist und wie es konfiguriert wird.

Wie spielt nun alles zusammen? Zunächst cAdvisor, der jedoch nicht von außen auf Port 8080 erreichbar sein soll. Daher sieht das Start-Kommando nun wie folgt aus:

sudo docker run \
--volume=/:/rootfs:ro \
--volume=/var/run:/var/run:rw \
--volume=/sys:/sys:ro \
--volume=/var/lib/docker/:/var/lib/docker:ro \
--detach=true \
--restart=always \
--name=cadvisor \
google/cadvisor:latest

In diesem Beispiel wurden die Ports entfernt und die Option –restart=always hinzu gefügt.

Der Zugriff auf cAdvisor soll nur für den Nginx-Container erlaubt sein, daher erhält dieser beim Start einen Link auf den cAdvisor-Container. Die Konfiguration des virtuellen Hosts findet im Verzeichnis sites-enabled statt. Auf dem Host habe ich der Einheitlichkeit wegen die Config-Files unter /srv/docker/nginx/sites-enabled bzw. /srv/docker/nginx/conf.d abgelegt. Dafür zunächst das Nginx-Config-File des virtuellen Hosts:

server {
        listen 80 ssl;
        server_name git.kuerbis.org;

        ssl on;
        ssl_session_timeout 5m;

        add_header Front-End-Https on;

        ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:EC
DHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:EC
DHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDH
E-RSA-AES256-SHA;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_prefer_server_ciphers on;
        [...gekürzt, copy&paste wird nicht klappen...]

        # Nginx main config includes only .conf files in conf.d directory
        ssl_certificate /etc/nginx/conf.d/git_kuerbis_org_bundle.crt;
        ssl_certificate_key /etc/nginx/conf.d/git_kuerbis_org.key;

        # Remember this setting for 365 days
        add_header Strict-Transport-Security max-age=31536000;
      
        location ~  {
                auth_basic "cAdvisor area - access restricted";
                auth_basic_user_file /etc/nginx/conf.d/htpasswd;

                proxy_pass http://cadvisor:8080;
        }

}

Hier wird zunächst Nginx so eingestellt, dass der virtuelle Host auf Port 80 lauscht, aber bereits SSL unterstützt. Dies ist kein Problem, da es sich um den internen Port handelt, der im Dockerfile als EXPOSE (neben 443) eingestellt ist. Nginx erhält somit Requests auf Port 80 und setzt diese mit proxy_pass auf den per Link erhaltenen Hostnamen “cadvisor” auf Port 8080 um. Der Key und das Zertifikat liegen im gemounteten conf.d-Verzeichnis, somit können sie von aussen eingestellt und innerhalb des Containers erreicht werden. Da die zentrale Nginx-Konfiguration nur Dateien einbindet, die die Endung “.conf” beinhalten, sind weitere, anders lautende Dateien kein Problem.

Darüber hinaus wird die Passwort-Datei ebenfalls in das conf.d-Verzeichnis gebracht, so dass sie innerhalb des Containers erreichbar ist. Falls keine Apache-Tools zur Hand sein sollten, helfen u.a. diese Hinweise bei der Erstellung dieser Datei. Auf weitere bzw. komplexere Möglichkeiten zur Authentifizierung habe ich zunächst verzichtet.

Sobald alle Konfigurationsdateien an Ort und Stelle sind, kann der Nginx-Container nun endlich gestartet werden:

sudo docker run -d --name nginx --restart=always \
-v /srv/docker/nginx/sites-enabled:/etc/nginx/sites-enabled \
-v /srv/docker/nginx/conf.d:/etc/nginx/conf.d \
--publish <ggf. IP>:81:80 --link cadvisor:cadvisor \
geschkenet/nginx

Wie bereits beschrieben werden die Volumes in den Container gemountet. Der cAdvisor-Container wird als Link “cadvisor” zur Verfügung gestellt, was nichts anderes als ein Hostname ist, weshalb der cAdvisor-Server auch innerhalb des Containers unter der bei proxy_pass verwendeten URL http://cadvisor:8080 erreichbar ist. Von aussen hingegen ist der neue Nginx-Server auf dem Port 81 zu erreichen – das hat einfach pragmatische Gründe, denn sowohl HTTP auf Port 80 als auch HTTPS auf Port 443 sind bereits durch den Gitlab-Server belegt.

Zur Verdeutlichung: Request auf Port 81 auf Host -> geht auf Port 80 des Nginx-Containers -> geht auf Port 8080 von cAdvisor

Das mag kompliziert wirken, aber letztlich ist Nginx nur ein einfacher Reverse-Proxy, der intern auf einen weiteren Server verweist. Natürlich ließen sich hier auch mehrere Backends einbauen, genau wie mehrere virtuelle Hosts, Ports und sonstiges, kurzum, alle Features, die Nginx ermöglicht.

Insgesamt war der Schritt der Erstellung des Nginx-Containers noch einer der einfachsten bzw. schnellsten. Die Nginx-Konfiguration hingegen unterscheidet sich kaum davon, als wenn sie ohne Container eingesetzt würde. Noch ungelöst ist die Speicherung der Logs – auch dafür könnte ein Volume eingesetzt werden, so dass die Nginx-Logfiles auf dem Host erreichbar wären. Oder aber ganz andere Lösungen, das hebe ich mir für später noch auf. Ebenso wie die dauerhafte Speicherung der per cAdvisor erfassten Daten, denn cAdvisor stellt immer nur eine Momentaufnahme dar. Insofern ist das Ende dieses Blog-Eintrags beinahe schon wieder der Anfang eines neuen…

 

Schreibe einen Kommentar

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

Tags: