Docker ====== Einleitung ---------- Docker ist eine Open-Source-Plattform, die es Entwicklern ermöglicht, Anwendungen in Containern zu erstellen, zu verpacken und bereitzustellen. Container sind eine Art von virtuellen Umgebungen, die alle benötigten Bibliotheken, Abhängigkeiten und Konfigurationen einer Anwendung enthalten, um sie reibungslos und konsistent auf verschiedenen Systemen auszuführen. Docker bietet eine leichtgewichtige und standardisierte Möglichkeit, Anwendungen zu isolieren und über verschiedene Umgebungen hinweg zu verschieben, was die Entwicklung, Bereitstellung und Skalierung von Software erleichtert. In diesem Kapitel wollen wir die grundlegenden Funktionen von Docker aufzeigen. Hierfür wollen wir ein kleines Spiel bauen, welches mit verschiedenen Docker-Containern interagiert. Die Wahl fällt hier auf das einfache Spiel *Hot Potato*. Um eine Programm zu schreiben, das dieses Spiel implementiert, müssen wir uns zunächst die Spielregeln des Spiels ansehen. .. note:: Für diesen Artikel setzten wir voraus, dass Sie bereits Grundkenntnisse in Python besitzen. .. note:: Wie Sie später unsere konkrete Implementierung starten und so dem Spiel zuschauen können, entnehmen Sie bitte der README im entsprechenden Ordner. Spielregeln *Hot Potato* ------------------------ Hot Potato ist ein einfaches Spiel, das oft von Kindern gespielt wird, aber auch in verschiedenen Varianten für Erwachsene existiert. In unserem konkreten Fall eignet sich das Spiel auch für introvertierte Informatikerinnen und Informatiker. Das Spiel erfordert normalerweise eine Gruppe von Personen. Die grundlegende Version des Spiels läuft ungefähr wie folgt ab: [SPIELREGELN_HOT_POTATO_] Die Spieler setzen sich im Kreis zusammen und geben einen Gegenstand (die *heiße Kartoffel*) von Person zu Person weiter, während ein Timer abläuft. Der Gegenstand wird schnell weitergegeben, und die Spielerinnen und Spieler müssen aufpassen, dass sie die *heiße Kartoffel* nicht in der Hand halten, wenn die Zeit auf 0 fällt. Wenn dies passiert, muss der Spieler, der den Gegenstand gerade in der Hand hält, ausscheiden oder eine Aufgabe erfüllen (je nach den Regeln des Spiels). Das Ziel des Spiels ist es, der letzte Teilnehmer im Kreis zu sein. In der realen Welt ist dies ein lustiges Spiel um schnell viel Gelächter zu erzeugen. Es gibt viele Varianten des Spiels, zum Beispiel mit verschiedenen Strafen für denjenigen, der am Ende die "heiße Kartoffel" hat, oder mit zusätzlichen Regeln, die das Spiel spannender machen. .. figure:: ./img/hotPotato.jpg :align: center Abb. 1: Spielablauf *Hot Potato* Analogie zum Projekt Kontext ---------------------------- Wir wollen also die eben genannten Spielregeln in Programmcode gießen um die Spiellogik abzubilden. Hierfür müssen wir festlegen, welche Rollen aus dem Kapitel 'Spielregeln' welchen Teilen in unserem Projekt entsprechen. Da wir hier eine kurze Docker Einführung geben wollen, gibt es hierfür eine Grundvoraussetzung. Jeder Spieler soll in einem eigenen Docker Container laufen. Um dies später zu realisieren, starten wir für jeden Spieler einen lokalen Server. Die Server und damit die Teilnehmer werfen sich die *Kartoffel* dann via *FastAPI* zu und kommunizieren auf diese Art und Weiße. Einer der Spieler wird zum Schiedsrichter ernannt und bildet somit den Master Container. Das zentrale Element bildet eine log-Datei, in die jeder Spieler schreibt, wenn er die *Kartoffel* fängt und weiter wirft. Ein konkreter Spielablauf könnte dann wie folgt aussehen: 1. Der Schiedsrichter startet das Spiel und damit einen Timer 2. Er wartet eine zufällige Zeit, greift sich das log-File und schreibt hinein, an wen er die *Kartoffel* weiter gibt. (Die Spieler bekommen hier einfach ein Nummer) 3. Ist der nächste Spieler an der Reihe greift dieser sich die log-Datei und schreibt ebefalls hinein, dass er die *Kartoffel* gefangen hat. Anschließend wartet auch er eine radomisierte Zeit ab usw. 4. Meldet jetzt der Schiedsrichter (Master-Container), dass der Timer abgelaufen ist, so scheidet der Spieler aus, der gerade die *Kartoffel* hat. Jezt spielen die übrigen Container weiter, bis nur noch ein Spieler übrig ist. Der Verlauf des Spiels kann dann in der log-Datei eingesehen werden. Die Spiellogik -------------- Wir wollen nun Stück für Stück durch den Programmcode gehen und versuchen zu erklären, wie das Spiel funktioniert. Grundsätzlich gliedern wir unseren Programmcode in zwei Module *player.py* und *referee.py*. Der Schiedsrichter muss neben dem Spielen des Spiels auch einige andere Dinge erledigen. Bevor das eigentliche Spiel gestartet werden kann, müssen wir ein paar Konfigurationen vornehmen. Um dies logisch vom eigentlichen Spielablauf zu trennen, bauen wir eine Methode, die diese Konfiguration vornimmt. Wie dies konkret aussieht, sehen sie in unten stehenden Codeblock. .. code-block:: python :caption: Block 1: setup()-Methode app = FastAPI() # Die Anwendung game_no = 0 # aktuelle Spielnummer players = [] # Liste mit allen players is_setup = False # true, wenn setup() erfolgreich durchlaufen game_duration_minutes = 0 # Spieldauer in Minuten game_duration_seconds = 30 # Spieldauer in Sekunden @app.get("/setup") async def setup(): global players, is_setup # Read URLs of players participating players = [] with open("players", "r") as file: lines = file.readlines() for player in lines: players.append(player.strip()) # Create log file for current game log = "game_" + str(game_no) file = open(log, 'w') file.close() is_setup = True return {"message": "game_no " + str(game_no) + " is setup"} Wir schauen uns zunächst die Variablen am Anfang des Codeblocks an. Hier können wir grundsätzliche Konfigurationen am Spiel vornehmen. Die Benennung der Variablen deutet auf die Funktion hin. Der wichtige Teil findet in der Methode *setup()* statt. Zu Beginn lesen wir die beteiligten Spieler aus der Datei *players* und Speichern die Adressen der Spieler in einer Liste. Anschließend wird die zentrale log-Datei erstellt, auf die später alle Teilnehmner zugreifen sollen. Hierzu nutzen wir die Variable *game_no* um für jedes Spiel eine eigenen log-Datei zu haben. Am Ende wird noch ein globaler boolean *is_setup* auf true gesetzt, um sicherzustellen, dass die Methode auch ausgeführt wurde, bevor das Spiel gestartet wird. Die Methode gibt dann einen String aus, der dem User mitteilt, dass das Spiel initalisiert wurde und bereit ist zum Spielen. An dieser Stelle sei nochmal erwäht, dass wir das Package *FastAPI* verwenden um zwischen den Servern und damit den Teilnehmern zu kommunizieren. Die Methoden werden also aufgerufen, wenn die entsprechende URL aufgerufen wird. Welche URL welche Methode triggert steht in der Annotation der Methode. .. code-block:: python :caption: Block 2: Annotaionen (Pseudocode) @app.get("/setup") def setup(): foo = true ... Bevor wir uns die Methode für den Spielablauf ansehen, müssen wir noch eine weitere Methode betrachten, die ebenfalls für die Logik relevant ist. Es handelt sich hierbei um die Methode jedes Spielers. Das was alle Spieler tun, wenn sie an der Reihe sind, ist ... sie fangen die *Kartoffel*. Also packen wir die Logik für diesen Teil in die Methode *catch()*. Es bietet sich an, diese Methode in das Modul *player* auszulagern. Lassen Sie uns einen Blick auf diese Methode werfen. .. code-block:: python :caption: Block 3: catch()-Methode für jeden Spieler app.post("/catch") async def catch(player, game_no): game_log = "game_" + str(game_no) # Open the current file and enter the actions with open(game_log, 'a') as file: file.write(str(datetime.now()) + ": " + player + " caught the hot potato\n") random_seconds = random.randint(3, 8) sleep(random_seconds) file.write(str(datetime.now()) + ": " + player + " threw the hot potato back\n") return {"message": "NEXT!"} Zu Beginn sei wieder gesagt, dass wir die Methode aufrufen, wenn wir die in der Annotation hinterlegte Route ansteuern. Diese Methode soll generisch für alle Spieler einsetzbar sein, unabhängig wie viele Spieler es sind. Wir geben der Methode also den Parameter *player* mit, mit dem der Spieler identifiziert wird. Des weiteren benötigen wir die Nummer des aktuellen Spiels, damit der Spieler auch weiß, in welche Datei er greifen muss. Dies geschieht mit dem Parameter *game_no*. Die Methode öffnet anschließend die entsprechende log-Datei und schreibt den Zeitstempel mit der info *Kartoffel gefangen* hinein. Anschließend wartet sie eine zufällig lange Zeit und schreibt dann den Zeitstempel für das Weitergeben der *Kartoffel* in die Datei. Mit dem eben angeeigneten Wissen können wir uns nun das Herz der Anwendung, die Methode *start()*, genauer ansehen. Um den Überblick nicht zu verlieren, teilen wir die Methode in fünf Teile ein. | 1. Initalisierung | 2. Startzeit in die Datei schreiben | 3. Das Spiel starten und einen zufälligen Spieler auswählen | 4. Den Gewinner in die Datei schreiben | 5. Die Spielnummer inkrementieren Schauen wir uns also an, wie das Spiel startet und wie es initalisiert wird: .. code-block:: python :caption: Block 4: Die start()-Methode Initalisierung (Pseudocode) @app.get("/start") async def start(): global is_setup, game_no, is_setup if not is_setup: return { "message": "You need to first setup the game" } game_log = "game_" + str(game_no) game_start = datetime.now() game_end = game_start + timedelta(minutes=game_duration_minutes, seconds=game_duration_seconds) ... Hier sehen wir wieder die Annotation von FastAPI, darunter die Methodensignatur. Im ersten Schritt prüfen wir, ob die Metode *setup()* aus Block 1 wirklich ausgeführt und durchgelaufen ist. Anschließend generieren wir den Namen der aktuellen Log-Datei, abhängig von der Spielnummer. Außerdem wird hier die Startzeit und das Ende des Spiels festgelegt. Im nächsten Schritt greifen wir uns die eben erstellte log-Datei und schreiben die ebenfalls eben erstellte Start- und Endzeit in die Datei. Dies können Sie im Codeblock 5 einsehen. .. code-block:: python :caption: Block 5: Die start()-Methode Startzeit in die Datei schreiben (Pseudocode) ... # Write start time to log with open(game_log, 'a') as file: file.write("Start: " + str(game_start) + "\n") print("Game " + str(game_no) + " started...") ... Werfen wir anschließend einen Blick auf den nächsten Teil der der Funktion. .. code-block:: python :caption: Block 6: Die start()-Methode Das Spiel starten und einen zufälligen Spieler auswählen (Pseudocode) ... # Start the game by throwing the hot potato to a randomly chosen player while datetime.now() < game_end and len(players) > 1: player = random.randrange(len(players)) player_url = players[player] + "/catch?" + "player=" + players[player] + "&game_no=" + str(game_no) print("Threw hot potato to " + players[player]) response = requests.post(player_url) if datetime.now() > game_end: with open(game_log, 'a') as file: file.write("player " + players[player] + " lost\n") print("player " + players[player] + " lost") players.pop(player) game_end = datetime.now() + timedelta(minutes=game_duration_minutes, seconds=game_duration_seconds) winner = players[0] ... Hier sehen wir die Main-Gameloop des Spiels. Diese wird immer wieder ausgeführt, so lange die aktuelle Zeit kleiner der Endzeit ist und wenn gleichzeitig auch mehr als 1 Spieler noch im Spiel ist. Die Methode sucht innerhalb der Gameloop also einen zufälligen Spieler aus der Liste aus und wirft Ihm die *kartoffel* zu. Innerhalb der Gameloop wird für jede Iteration geprüft ob der Timer und damit die *Hot Potato* abgelaufen ist. Ist dies der Fall, so wird erneut das Log-File geöffnet und der Verlierer in die Datei eingetragen. Anschließend muss noch der Spieler, der verloren hat, aus der Liste aller Spieler entfernt werden (diese Liste haben wir in Block 1 erstellt). Der lezte Spieler in der Liste ist dann gleichzeitig auch der Gewinner. Mit dem Durchlauf der Main-Gameloop ist das Spiel beendet. Wir müssen nun noch den Gewinner in die Datei eintragen. Dies sehen wir im nachfolgenden Block. .. code-block:: python :caption: Block 7: Den Gewinner in die Date schreiben (Pseudocode) ... # Write the result to the log with open(game_log, 'a') as file: file.write("End: " + str(datetime.now()) + "\n") file.write("Winner is player " + winner + "\n") ... Diese Art von Code haben wir bereits häufig gesehen. Wir öffnen die entsprechenden Datei und schreiben einfach den Gewinner mit der Zeit in das File. Damit wir für jedes Spiel eine andere log-Datei haben und somit alle alten Spiele erhalten bleiben, inkrementieren wir die Log-Dateien. .. code-block:: python :caption: Block 8: Die Spielnummer inkrementieren (Pseudocode) ... # Increment game_no game_no = game_no + 1 is_setup = False Mit dem Abschluss der Methode *start()* ist die Logik abgeschlossen. Im nächsten Kapitel wollen wir uns damit beschäfftigen wie wir unsere Logik dockerisieren können. Dockerisierung -------------- Wie wir bereits in früheren Kapiteln erwähnt haben, war eine der Grundvoraussetzung die wir an unsere kleine Anwendung hatten, dass unterschiedliche Docker container miteinander kommunizieren. Jeder Spieler sollte demnach in einem eigenen Container laufen. Wir wollen hier also einen kurzen Blick auf die Implementierung der Container geben. Sehen wir uns also die folgende Docker Compose an. .. code-block:: python :caption: Block 9: Docker Compose (Pseudocode) services: referee: build: referee ports: - 8000:8000 extra_hosts: - "host.docker.internal:host-gateway" volumes: - ./usr_src_logs:/usr/src/app/logs player1: ... ... player2: ... ... player3: ... ... Insgesamt baut die Docker Compose 4 Container zusammen. Diese sind bis auf den Namen und den Port sehr ähnlich. Wir wollen uns den ersten Container *referee* greifen und ihn Stichpuntartig eklären. - Es wird ein Container namens *referee* erstellt. - Die Build-Konfiguration weist Docker an, das Image aus dem Verzeichnis *referee* zu erstellen. - Der Container wird so konfiguriert, dass er den Port 8000 des Hosts auf den Port 8000 des Containers abbildet. - Es wird eine spezielle Host-Konfiguration angegeben, um die Kommunikation zwischen dem Container und dem Host zu ermöglichen. - Ein Volumen wird erstellt, um das Verzeichnis /usr/src/app/logs im Container mit dem lokalen Verzeichnis ./usr_src_logs zu verbinden. Zusammenfassend ermöglicht dieses Docker Compose-Setup die Erstellung und Ausführung von Containern für unseren Schiedsrichter und die Spieler, die miteinander kommunizieren können. Die spezielle Host-Konfiguration und die gemeinsamen Volumen helfen dabei, die Kommunikation und den Datenaustausch zwischen den Containern und dem Host zu erleichtern. Schlusswort ----------- Am Ende dieses kleinen Projektes lässt sich folgende Aussage über Docker und das Projekt allgemein treffen: Wir nutzen Docker, um verschiedene Teile einer Anwendung zusammenzubringen und laufen zu lassen. Docker hilft uns, dass diese Teile miteinander reden können und Informationen austauschen können. Das macht es einfacher, unsere Anwendung zu entwickeln und sie überall laufen zu lassen. Des weiteren ist es ein gutes dev-Tool. Entwickler können sich so auf das Entwickeln der eigentlichen Anwendung konzentrieren und müssen sich nicht mit vielen Konsolenbefehlen rumschlagen. .. _lit: Literatur --------- .. [SPIELREGELN_HOT_POTATO] Playworks: (abgerufen am 11.04.2024): https://www.playworks.org/game-library/hot-potato/ .. vim: et sw=3 ts=3