Mit dem fertigen Programm können wir uns nun ansehen, wie wir dieses dauerhaft bereitstellen. Da wir einige Python-Packages verwendet haben, welche wiederum zahlreiche Abhängigkeiten mit sich bringen und möglicherweise auf spezifische Versionen angewiesen sind, suchen wir nach einer Möglichkeit, unser Programm zuverlässig auf einem (nahezu) beliebigen Server unabhängig von anderen Anwendungen laufen zu lassen. Dafür bietet sich Software zur Containervirtualisierung, wie zum Beispiel Docker, an.

Inhaltsverzeichnis

<aside> 📌 Auf vielen Servern ist Docker der Standard, wenn es darum geht die eigene Anwendung zu deployen. Auch Heroku unterstützt Docker, jedoch wird hier zusätzlich eine einfache Möglichkeit angeboten, seinen Code direkt mit Git zu deployen. Wenn du tatsächlich Heroku für das Deployment verwenden möchtest, kannst du dieses Kapitel also überspringen. Der Workflow mit Docker ist dagegen ähnlicher zu vielen anderen Providern.

</aside>

Was ist Docker?

Empowering App Development for Developers | Docker

Docker ist eine Software, die Anwendungen in sogenannten Containern zusammenfasst. Diese Container enthalten neben der Anwendung selbst auch benötigte Bibliotheken (bzw. Packages), Konfigurationen und andere Dateien, die zum Betrieb der Anwendung nötig sind. Mehrere Container können dann auf einer Maschine isoliert voneinander betrieben werden und kommunizieren nur über explizit definierte Kanäle. Im Vergleich zu virtuellen Maschinen sind sie extrem schlank, weshalb es problemlos möglich ist auf einem System mehrere Container zu betreiben. Auch wenn Docker ursprünglich für Linux entwickelt wurde, kann es inzwischen auch auf Windows und macOS installiert werden. Anleitungen zur Installation findest du hier: https://docs.docker.com/engine/install/.

Jeder Container wird aus einem sogenannten Image erzeugt, welches nicht verändert werden kann und sozusagen als Vorlage für die Container dient. In einem Dockerfile wird zu Beginn mit einigen Befehlen beschrieben, wie dieses Image aufgebaut ist. Für jeden dieser Befehle wird ein neues Layer angelegt, sodass das resultierende Image aus mehreren Layern besteht und eine genaue Historie nachvollzogen werden kann. Eigene Images bauen üblicherweise auf bestehenden Images auf, welche in Registries zu finden sind und von dort direkt heruntergeladen werden können. Am bekanntesten ist hierfür Docker Hub.

Python Requirements

Um später unser Docker Image einfach bauen zu können, müssen wir wissen welche Abhängigkeiten unsere Anwendung besitzt. Wir haben in Python 3.7 entwickelt und die jeweils aktuellste Version der benötigten Packages installiert. Da wir nun schon wissen, dass unsere Anwendung mit diesen Version funktioniert, behalten wir sie für das Deployment bei. Wie bereits bekannt sein sollte, werden die Requirements bei einem Python Projekt üblicherweise in einer requirements.txt Datei festgehalten. Auch unabhängig von Docker ist es immer sinnvoll eine solche Datei aktuell zu halten, um eine erneute Ausführung nach einiger Zeit oder die Benutzung für andere möglichst einfach zu gestalten.

Falls noch nicht geschehen, legen wir nun also eine requirements.txt Datei an. Falls die Version einzelner Packages nicht mehr bekannt ist, kann diese innerhalb von Python über das Attribut __version__ direkt auf dem importierten Package abgefragt werden. Alternativ können mit dem Befehl pip freeze > requirements.txt alle in der aktuellen Python-Umgebung installierten Packages zusammen mit ihrer Version in eine Datei geschrieben werden. Der Aufruf scheint zwar einfach zu sein, allerdings sollte die generierte Liste dringend nochmal manuell geprüft werden! Hier sind nämlich unteranderem Packages aufgelistet, die während der Entwicklung eventuell zwischenzeitlich benutzt wurden, aber in dem finalen Projekt doch keine Anwendung mehr finden. Außerdem werden nicht nur händisch installierte Packages aufgenommen, sondern auch alle dabei mitinstallierten Abhängigkeiten. Diese können von Betriebssystem zu Betriebssystem jedoch variieren und so zu Problemen führen. Unsere fertige requirements.txt enthält die folgenden Einträge:

Flask==2.0.2
Flask-Cors==3.0.10
gunicorn==20.1.0
numpy==1.21.4
pandas==1.3.4
scikit-learn==1.0.1
tensorflow-cpu==2.7.0

Das Package photonai haben wir nicht mit aufgenommen, da wir es nur im ersten Kapitel brauchten, aber uns bei der finalen Anwendung für die Lösung mit TensorFlow entschieden haben. Stattdessen taucht nur bereits gunicorn in der Liste auf, obwohl wir dieses Package während der Entwicklung nicht benötigten. In unserem Docker Container wollen wir damit jedoch den Webserver betreiben und müssen es entsprechend vorher installieren. Außerdem haben wir das Package tensorflow durch tensorflow-cpu ersetzt, um die Größe des Containers zu reduzieren und weil uns bei unserem kostenlosen Heroku-Paket ohnehin keine GPU enthalten ist. Falls dir ein anderer Server mit GPU zur Verfügung steht, ist es natürlich häufig sinnoll diese auch zu nutzen.

Dockerfile

Das Dockerfile ist eine Textdatei mit eben diesem Namen (Dockerfile) ohne weitere Endung. Diese Datei dient Docker als eine Art Rezept anhand dessen ein Image gebaut werden kann. Wie bereits zuvor erwähnt, wird für jeden Befehl ein neues Layer erzeugt, um später eine Historie nachvollziehen zu können. Docker kann Änderungen in Dateien erkennen und baut ein Layer nur dann neu auf. Sobald ein Layer ersetzt wurde, müssen jedoch auch alle nachfolgenden Layer neu erstellt werden. Dadurch ist die Reihenfolge der Befehle in dem Dockerfile relevant. Unser Dockerfile hat den folgenden Aufbau:

FROM python:3.7
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/app.py .
COPY app/utils.py .
COPY models/ /models
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

Zunächst geben wir an, welches Image wir als Basis für unser eigenes Image verwenden wollen. Glücklicherweise gibt es offizielle Python Images bei denen wir auch eine Version spezifizieren können. Da dieses Image im offiziellen Docker Hub vorhanden ist (siehe hier), müssen wir keine Registry explizit angeben, sondern nur den Namen und unsere gewünschte Version. Falls das Image noch nicht lokal verfügbar ist, lädt Docker dieses dann später automatisch von dort herunter.

Nun ändern wir unser WORKDIR zu /app. Dieser Schritt ist vergleichbar zu einem Aufruf von cd in der Kommandozeile. Wir ändern also lediglich unseren Arbeitsort innerhalb des Containers. Anschließend kopieren wir unsere zuvor angelegte requirements.txt Datei in diesen Ordner innerhalb des Containers. Mit dem Befehl RUN kann ein beliebiger Befehl innerhalb des Containers ausgeführt werden. Wir nutzen ihn dazu die Packages mit pip zu installieren. Da wir uns ohnehin in einer separaten Umgebung befinden, müssen wir bei der Installation keine zusätzliche Virtual Environment verwenden.

Aufgrund des Aufbaus aus mehreren Layern, kopieren wir erst danach unseren Code und die benötigten Modelle in den Container. Falls wir später doch noch etwas an unserem Code verändern oder die Modelle austauschen müssen, können dadurch die installierten Packages erhalten bleiben und müssen nicht neu installiert werden. Unsere Modelle kopieren wir nicht in den /app Ordner, sondern in einen separaten Ordner. Dies hat den einfachen Grund, dass dadurch die relativen Pfade erhalten bleiben, die wir in unserem Programm zum Einlesen der Modelle angegeben haben.

In der letzten Zeile geben wir mit dem CMD Befehl an, was beim Starten des Containers ausgeführt werden soll. Dieser Befehl dient also nicht zur Erstellung des Images. In unserem Fall starten wir hier unseren Gunicorn Webserver mit unserer App. Als Port verwenden wir 5000, diese Angabe müssen wir uns merken (oder wie schauen sie später einfach hier nochmal nach). Der Port wird nämlich erstmal nur innerhalb des Containers freigegeben und ist deshalb nicht von außen erreichbar.