Software-Deployment mit Docker-Containern


Fabian Gringel


Containers on a container ship

Hier werde ich eine kurze Einführung in die Verwendung von Docker-Containern für das Software-Deployment geben. Ich gehe davon aus, dass wir eine Python-Anwendung myapp bereitstellen möchten, die eine Web-API hat.


Überblick


Ein Docker-Container ist im Wesentlichen eine virtuelle Maschine, die für die Ausführung einer einzelnen Anwendung vorgesehen ist. Sie bieten eine Möglichkeit, die Laufzeitabhängigkeiten der Anwendung von der Ebene des Hostsystems zu isolieren und somit das Deployment zu vereinfachen. Anders als herkömmliche virtuelle Maschinen laufen Docker-Container direkt auf dem Host-Kernel und verlassen sich auf die cgroups von Linux, um eine Prozessisolierung zu gewährleisten. Dies hat eine Reihe wichtiger Implikationen:

 Docker logo
  • Container haben im Vergleich zu normalen virtuellen Maschinen (VMs) viel weniger Overhead und laufen im Wesentlichen so schnell wie native Prozesse (unter Linux).

  • Docker kann wirklich nur unter Linux laufen, da es auf bestimmte Kernel-Funktionen angewiesen ist. Es gibt Docker für Windows und macOS, aber dort läuft Docker auf einer Linux-VM, mit den damit verbundenen Leistungseinbußen.

  • Container sind weniger vom Host-System isoliert als VMs und man kann sich nicht auf ihre Sicherheit verlassen.

Um einen Docker-Container auszuführen, benötigen wir ein Image als Ausgangspunkt, das die Basisdistribution sowie alle benötigten Abhängigkeiten verpackt.

Docker bietet eine deklarative Möglichkeit, solche Images mit Hilfe von Dockerfiles zu spezifizieren und zu erstellen. Diese sind ähnlich wie Shell-Skripte oder make-Dateien, allerdings mit einigen wichtigen Einschränkungen, wie wir im Folgenden sehen werden.

Berechtigungen

Der Docker-Daemon erfordert entweder Root-Rechte oder dass der Benutzer Mitglied der docker-Gruppe ist, was im Wesentlichen Root entspricht und vermieden werden sollte. Nicht-Root-Aufrufe schlagen mit der folgenden, etwas undurchsichtigen Fehlermeldung fehl:

docker ps
Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.40/containers/json": dial unix /var/run/docker.sock: connect: permission denied


Dockerfiles schreiben


Ein Dockerfile besteht aus einer Reihe von Anweisungen zum Einrichten der Umgebung, gefolgt von dem Befehl, den Docker beim Starten ausführen soll.

Auf unseren lokalen Rechnern würden wir Folgendes tun:

python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Start the server on localhost:8080
myapp api --host 0.0.0.0 --port 8080 --storage ./storage

Wir wollen ein Docker-Image erstellen, das uns im Wesentlichen die gleichen Ergebnisse liefert.

Ein erster Versuch

Das Übersetzen des obigen Shell-Skripts in Docker-Anweisungen ist einfach:

FROM python:3.7
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["myapp", "api", "--host", "0.0.0.0", "--port", "8080"]

Die Schritte sind fast die gleichen:

  • Wir beginnen mit dem Basis-Image python:3.7, das von Docker bereitgestellt wird. Dies ist im Grunde ein Debian-Image mit einer globalen Installation von Python 3.7.

  • Wir erstellen und wechseln in das Verzeichnis /app. Die folgenden Befehle werden relativ zu /app interpretiert.

  • Wir kopieren unseren Code (und alles andere in unserem Build-Verzeichnis, siehe unten) in das Image. 

  • Wir installieren unsere Abhängigkeiten wie oben, mit dem RUN-Befehl. Beachten Sie, dass wir keine virtuelle Umgebung verwenden müssen, da wir bereits Docker verwenden (aber siehe unten für einen Grund, warum wir vielleicht trotzdem eine verwenden möchten).

  • Wir geben den Befehl an, den Docker beim Starten des Containers mit CMD ausführen soll.

Häufige Fallstricke vermeiden

Wie so oft bei Docker, hat der obige einfache Ansatz einige Haken.

  • Das Basis-Image python:3.7 ist ziemlich groß (~870Mb) und enthält eine Menge Zeug, das wir vielleicht nicht brauchen. Wir können stattdessen python:3.7-slim verwenden, das viel kleiner ist (~155Mb).

  • Der obige Ansatz nutzt den Layer-Cache-Mechanismus (siehe unten) nicht aus. Da wir COPY . . vor dem zeitaufwändigen RUN pip install ... ausführen, wird der Cache schon nach kleinen Codeänderungen ungültig.

  • Unsere App läuft als Root-Benutzer innerhalb des Containers, was eine Sicherheitslücke darstellt (obwohl es durchaus üblich ist).

Das folgende Dockerfile kommt den Best Practices schon ein ganzes Stück näher.

FROM python:3.7-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN useradd appuser -m
USER appuser
CMD ["myapp", "api", "--host", "0.0.0.0", "--port", "8080"]

Dies ist etwas unordentlicher, adressiert aber die oben genannten Punkte:

  • Wir verwenden python:3.7-slim als unser Basis-Image.

  • Wir teilen die Requirements-Datei in eine separate COPY-Anweisung ab und kopieren den Rest erst nach der Installation der Abhängigkeiten. Jetzt können wir den Code frei ändern, und das Image lässt sich fast sofort neu erstellen (solange wir die Requirements nicht anfassen).

  • Wir legen einen Nicht-Root-appuser an und starten die App damit.

Das Layer-System und der Cache

Docker verwendet ein Union-Dateisystem, um das Images in Schichten aufzubauen. Jeder Schritt im Dockerfile erzeugt eine neue Schicht, die

  • die Ergebnisse des Schritts berechnet,

  • einen Hash davon errechnet,

  • sie im Build-Cache speichert,

  • die Änderungen auf Grundlage der vorherigen Schritte anwendet.

Dadurch vermeidet Docker die Wiederholung von Berechnungen und macht die Speicherung des Images effizienter. Wenn eine Schicht und alle ihre Vorgänger im Cache gespeichert wurden, dann verwendet Docker den Cache und der entsprechende Schritt im Dockerfile kann viel schneller ausgeführt werden.

Die Hashes werden wie folgt berechnet:

  • Bei COPY- und ADD-Anweisungen werden die Hashes aus den kopierten Dateien berechnet. Daher machen alle Änderungen an diesen Dateien den Cache ungültig.

  • Für alle anderen Anweisungen werden die Hashes aus den Befehlen im Dockerfile berechnet.

Images erstellen

Wir können nun unser verbessertes Image erstellen. Dies kann beim ersten Durchlauf einige Minuten dauern, wenn es viele Abhängigkeiten gibt. Aber nachfolgende Builds sollten nur noch eine Sekunde dauern (bis wir die Anforderungen wieder ändern).

sudo docker build -f Dockerfile -t myapp-api:slim .

Beachten Sie, dass das . am Ende der docker build-Kontext ist und sich auf das Stammverzeichnis des Projekts beziehen sollte. 

docker build-Kontext

Der Docker-Kontext besteht aus allen Dateien, die mit einer COPY-Anweisung in das Abbild kopiert werden könnten. Standardmäßig sind dies alle Dateien im Build-Verzeichnis oder allen seinen Unterverzeichnissen, was in der Regel recht groß ist.

Dies verlangsamt docker build, da der Kontext jedes Mal berechnet wird, auch wenn wir nur bestimmte Dateien kopieren. Außerdem führt die Verwendung von COPY ... wie wir es oben getan haben, dazu, dass eine Menge unnötiger und möglicherweise sensibler Dateien in das Image aufgenommen werden.

Um einen großen Build-Kontext zu vermeiden, können wir bestimmte Dateien oder Verzeichnisse in der Datei .dockerignore auf eine White- oder Blacklist setzen. Dies ist analog zu .gitignore, obwohl sie unterschiedlich implementiert sind und wir nicht einfach eine einzige Datei für beide verwenden können.

Ausführen des Containers

Wir können den im Dockerfile angegebenen Befehl wie folgt ausführen:

docker run -p 8080:8080 --name myapp-container -d myapp-api:slim

Die Befehlszeilenoptionen bewirken Folgendes:

  • Die Flag -d startet den Container im Hintergrund.

  • Die Option --name gibt dem Container einen Namen, um ihn später leichter referenzieren zu können.

  • Die Option -p 8080:8080 leitet den Port 8080 innerhalb des Containers an unser lokales Netzwerk weiter.

Wir können mit docker ps überprüfen, ob unser Container läuft:

sudo docker ps

Debugging von Containern

Wir können andere Befehle innerhalb eines laufenden Containers ausführen:

sudo docker exec myapp-container whoami
appuser

Dies ist sehr nützlich für die Fehlersuche. Wir können z. B. prüfen, ob unsere Tests für den Image-Build laufen:

sudo docker exec myapp-container pytest /app/tests/api -p "no:cacheprovider"

Wir können uns auch in den Container einloggen und dort beliebige Befehle ausführen (aber nur als appuser). Dazu werden die Schalter -i (interaktiv) und -t (tty) benötigt:

sudo docker exec -it myapp-container bash

Fazit


Ich hoffe, der obige Walkthrough hilft Ihnen bei den ersten Schritten. Natürlich konnte ich hier nur die Kernfunktionalität von Docker abdecken.

Seien Sie sich bewusst, dass je nach den Spezifikationen Ihrer App und dem Sicherheitsstandard, den Ihre App erfüllen soll, das Hinzufügen eines Nicht-Root-Benutzers möglicherweise nicht ausreichend ist.