Deploye dein Modell mit Docker

Download this notebook

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.

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.

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 dieser 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 unter anderem 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 sinnvoll, 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/ .
COPY models/ /models
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "main: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.

Alternative Registry: Aufgrund eines Rate-Limits bei Docker kann es sinnvoll sein, eine alternative Registry zu nutzen, z.B. Harbor (siehe unten).

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 kein zusätzliches 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 wir 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.

Image und Container

Mit unserem fertigen Dockerfile können wir nun unser Image bauen. Dazu muss Docker installiert sein. Wenn dies der Fall ist, kannst du den folgenden Befehl in der Kommandozeile ausführen:

docker image build -t deploy-tutorial .

Befindest du dich auf einem System mit ARM-Prozessor (zum Beispiel Apples M-Serie), kann es sein, dass manche Pakete nicht für diese Architektur zur Verfügung stehen und es zu Fehlern kommt. In diesem Fall kannst du Docker sagen, dass es den Container für Intel/AMD-Architekturen bauen soll. Das macht die lokale Ausführung zwar etwas langsamer, auf einem Server mit der entsprechenden Architektur ist das Image dann aber perfekt aufgehoben.

docker image build --platform linux/amd64 -t deploy-tutorial .

Beachte den . am Ende des Befehls für das aktuelle Verzeichnis! Das Dockerfile sollte sich im gleichen Verzeichnis befinden und alle anderen Dateien müssen selbstverständlich von dort entsprechend der Angaben auffindbar sein. Mit -t kannst du zudem einen Namen vergeben, damit du das Image später wiederfindest. Docker erzeugt dann dein Image. Beim ersten Mal kann dieser Schritt durchaus mehrere Minuten dauern, aufgrund der Layer reduziert sich die Zeit bereits beim zweiten Mal jedoch deutlich. Mit dem Befehl docker image ls kannst du dir alle lokalen Images ansehen. Dort müsste nun auch dein gerade erstelltes Image auftauchen.

Ist dies der Fall, kannst du daraus einen Container starten. Der Befehl dafür lautet:

docker run -d -p 8000:5000 --name deploy-tutorial-container deploy-tutorial

Die Flag -d sorgt dafür, dass dein Container detached, also im Hintergrund läuft und das Konsolenfenster nicht geöffnet bleiben muss. Wie bereits erwähnt, haben wir den Port bisher nur innerhalb des Containers freigegeben. Mit -p 8000:5000 leiten wir diesen nun nach draußen weiter. Als Erstes wird der Port auf der Host-Maschine angegeben und danach der Port innerhalb des Containers. Beide müssen nicht übereinstimmen, der Port innerhalb des Containers muss aber unserer Angabe beim Starten des Webservers im Dockerfile entsprechen. Optional können wir mit --name einen Namen für unseren Container vergeben, andernfalls vergibt Docker einen zufälligen Namen. Als Letztes geben wir unser zuvor erstelltes Image an, das zum Starten des Containers verwendet werden soll. Im Browser können wir nun wieder unsere API aufrufen und wie gewohnt verwenden. Beachte, dass wir für das Beispiel den Port auf 8000 geändert haben.

Solltest du bei der Ausführung auf Probleme stoßen, kannst du die Flag -d einfach weglassen. Dadurch werden dir die Fehlerberichte direkt in deinem Konsolenfenster angezeigt und du kannst der Ursache auf den Grund gehen.

Mit der Zeit können sich schnell viele alte Container auf deinem System ansammeln. Mit dem Befehl docker ps -a lassen sich alle laufenden und gestoppten Container anzeigen. Du kannst diese mit docker kill CONTAINER und docker rm CONTAINER stoppen und löschen, falls sie nicht mehr benötigt werden.

Mehrere Services: Docker Compose

Oftmals ergibt es Sinn, einzelne Services einer App zu trennen, da zum Beispiel unterschiedliche Programmiersprachen oder Versionen verwendet werden. Auch kann so vorbereitet werden, dass einzelne Services auf unterschiedlicher Hardware genutzt wird, z.B. benötigt ein neuronales Netz oftmals GPU Unterstützung, wohingegen eine Weboberfläche ohne auskommt.

Solltest du während deines Projektes mehrere Services benötigen, die miteinander kommunizieren (z.B. eine API die das neuronale Netz ausführt und Ergebnisse liefert und ein Frontend, z.B. ein Dashboard), dann kannst du in deinem Projekt mehrere Ordner, die einen Dockerfile und die zugehörigen Dateien beinhalten (z.B. die Ordner api und frontend), dann kannst du auf der obersten Ebene eine Datei docker-compose.yaml nutzen. Für diesen Fall könnte eine solche Datei wie folgt aussehen:

version: '3'
services:
  api:
    container_name: deploy-tutorial-api
    build:
      context: ./api
      dockerfile: Dockerfile
      platforms:
        - "linux/amd64"
    environment:
      APP_NAME: "DEPLOYMENT-TUTORIAL"
    ports:
      - '8000:80'
    networks:
      - deployment-network
    healthcheck:
          test: curl --fail http://localhost:80/ || exit 1
          interval: 10s
          timeout: 10s
          retries: 3
          start_period: 8s
   
  frontend:
    container_name: deploy-tutorial-frontend
    build:
      context: ./frontend
      dockerfile: Dockerfile
      platforms:
        - "linux/amd64"
    environment:
      API_URL: "http://api:80/"
    ports:
      - '8002:8080'
    depends_on:
      api:
        condition: service_healthy
    networks:
      - deployment-network
       
 
networks:
  deployment-network:

Durch den Command

docker compose up

wird nun ein Container gebaut (nutze wieder die Flag -d um diesen im Hintergrund laufen zu lassen). Die beiden Services kommunizieren über ein internes Netzwerk (deployment-network). Beim Ausführen werden dann beide Services nacheinander gebaut und ausgeführt, wobei ein healthcheck ausgeführt wird, um sicherzustellen, dass die API funktioniert bevor das Frontend gestartet wird. Das Frontend wäre nun über http://localhost:8002/ erreichbar.

Übrigens kannst du durch den folgenden Command einen Container neu bauen, falls etwas am Code geändert wurde:

docker compose up -d --build

Der so gestartete Container kannst du durch folgenden Command stoppen:

docker compose stop

Den Container im GitLab speichern

GitLab bietet eine Container Registry an, in der wir unseren Container speichern können, um ihn auf den jeweiligen Deployment-Plattformen pullen zu können.

Um die Container-Registry anzuzeigen, kannst du im GitLab-Repository auf den Menüpunkt Packages & Registries → Container Registry gehen. Sollte das bei dir nicht zu sehen sein, so musst du die Container Registry in den Repository-Einstellungen erst noch aktivieren.

Um den Container nun ins GitLab zu pushen, musst du dich zunächst über die Konsole dort einloggen. Das geht mittels eines Personal Access Tokens oder eines Deployment Tokens. Wie ihr einen Token erstellen könnt, haben wir in der Einführung im Abschnitt Git für euch erklärt.

Der Befehl für den Login in der Konsole lautet nun:

docker login <registry url> -u <username> -p <token>

Die URL für die Container Registry im zivgitlab der WWU lautet zivgitlab.wwu.io

Baue nun deinen Container lokal und gib ihm einen Namen in der folgenden Form:

<registry URL>/<namespace>/<project>/<image>

In unserem Beispiel wäre der Befehl zum Bauen des Images also

docker image build -t zivgitlab.wwu.io/reach-euregio/incubaitor/deploy-tutorial .

Um das Image nun ins GitLab hochzuladen, kannst du wieder den push-Befehl nutzen:

docker push zivgitlab.wwu.io/reach-euregio/incubaitor/deploy-tutorial

Den Container im Harbor speichern

Die Uni Münster bietet eine Container Registry an, in der wir unseren Container speichern können, um ihn auf den jeweiligen Deployment-Plattformen pullen zu können. Dies ist der Harbor. Eine Anleitung gibt es auch unter Docker Registry.

Es kann sinnvoll sein, auch weitere Images dort abzulegen, da Docker ein Rate-Limit eingeführt hat. Zum Beispiel wird oben das Image python:3.7 genutzt. Es kann sinnvoll sein, ein eigenes Projekt projekt auf dem Harbor einzurichten und das lokal genutzte Image dort hochzuladen.

Um sich auf dem Harbor einzuloggen, kann man den Befehl

docker login harbor.uni-muenster.de

benutzen. Der Nutzername ist dann nutzerkennung@uni-muenster.de und das Passwort erhält man über die persönlichen Einstellungen im Harbor unter CLI Secret.

Um das Image nun auf dem Projekt hochzuladen, kann man z.B. folgende Befehle ausführen:

docker pull python:3.7

docker tag python:3.7 harbor.uni-muenster.de/<projekt>/python:3.7

docker push harbor.uni-muenster.de/<projekt>/python:3.7

Auch weitere lokale Images können dann hochgeladen werden.

Anstatt

FROM python:3.7

kann nun

FROM harbor.uni-muenster.de/<projekt>/python:3.7

im Dockerfile genutzt werden. Hierzu muss das Projekt öffentlich zugänglich sein. Alternativ kann man sich einen eigenen Zugang im Harbor (Robot Account) erstellen. Mit diesem kannst du auch eine Registry pushen.