Software-Deployment mit Docker-Containern
Fabian Gringel
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:
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 stattdessenpython: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ändigenRUN 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
- undADD
-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.