, , , , , ,

Von der lokalen Chat-App zur Cloud

Asim Jugo

Ausgangspunkt und Tech-Stack

Unsere Chat-Anwendung entstand ursprünglich als lokales Projekt. Für das Cloud-Computing-Projekt wollten wir deshalb nicht die Anwendung neu entwickeln, sondern herausfinden, wie sie sich in der Cloud betreiben lässt.

Die Anwendung besteht aus einem React-Frontend mit Vite und einem FastAPI-Backend. WebSockets ermöglichen die Echtzeitkommunikation. MongoDB speichert die Nachrichten, Redis verteilt Ereignisse zwischen mehreren Backend-Instanzen und NGINX dient als Gateway zwischen externen Anfragen, Frontend und Backend.

Die wichtigsten Komponenten und ihre Verbindungen lassen sich vereinfacht so darstellen:

Unsere Ziele

Für das Projekt hatten wir drei Ziele:

  1.  Infrastructure as Code: Die Infrastruktur sollte reproduzierbar mit Terraform erstellt werden.
  2. Automatisiertes Deployment: Änderungen sollten über GitLab CI/CD gebaut und auf dem Server bereitgestellt werden.
  3. Skalierung: Wir wollten untersuchen, wie Kubernetes mehrere Instanzen
    verwaltet und Anwendungen unter Last automatisch skaliert.

Dabei ging es nicht darum, eine vollständig hochverfügbare Produktionsplattform zu bauen.

Terraform: Infrastruktur als Code

Mit Terraform erstellen wir den Hetzner-Server und die zugehörige Firewall. Beim Start richtet Cloud-Init den Deploy-Benutzer ein und installiert k3s. Dadurch kann ein neuer Server automatisch vorbereitet werden.

Wir mussten unterscheiden, wofür Terraform und Kubernetes zuständig sind: Terraform stellt die Maschine bereit. Kubernetes verwaltet anschließend die Anwendung, ihre Pods und Services.

Dabei mussten wir besonders auf den Terraform-State achten. Der State enthält Informationen darüber, welche Cloud-Ressourcen zu einer Konfiguration gehören. Wir verwenden deshalb einen Remote-State in GitLab. Während der Entwicklung wurde uns klar, dass getrennte Terraform-Verzeichnisse allein nicht ausreichen, wenn sie versehentlich denselben State verwenden. Ein falsch gesetzter State-Name kann dazu führen, dass Terraform Ressourcen einer anderen Infrastruktur zuordnet.

Passwörter, API-Tokens und private Schlüssel dürfen weder im Repository noch im State landen. Sensible Werte werden über Umgebungsvariablen und GitLab-CI/CD-Variablen bereitgestellt. Für den initialen SSH-Zugriff verwenden wir lokal einen SSH-Agent.

Deployment: Von Docker Compose zu Kubernetes

Unser erster Deployment-Ansatz basierte auf Docker Compose. Terraform erstellte einen Server. Die Pipeline hat die Compose- und Nginx-Konfiguration per SSH auf den Server übertragen. Dort hat Docker Compose die Images aus der GitLab Registry geladen und sie gestartet. Dieser Ansatz hat uns geholfen, den Ablauf zu verstehen: Server erstellen, Images bereitstellen, Umgebungsvariablen setzen und die Anwendung erreichbar machen.

Im Laufe des Projekts wurde uns klar, dass Docker Compose für unser eigentliches Lernziel nicht ausreicht. Wir wollten Pods, Services und automatische Skalierung kennenlernen. Deshalb wechselten wir zu k3s, einer schlanken Kubernetes-Distribution.

Zunächst probierten wir Kubernetes lokal mit Docker Desktop aus. So konnten wir schrittweise verstehen, wie Pods und Services funktionieren, bevor wir den vollständigen Aufbau auf dem Hetzner-Server einrichteten. Anschließend wurden Backend, Frontend, MongoDB, Redis und das NGINX-Gateway als Kubernetes-Ressourcen definiert.

Anfangs verwendeten wir SSH-Skripte, um Dateien auf den Server zu kopieren und dort Kubernetes-Befehle auszuführen. Später ersetzten wir dieses Verfahren durch den GitLab Kubernetes Agent. Der Agent läuft im Cluster und verbindet sich mit GitLab. Die Pipeline kann dadurch über den Kubernetes-Kontext des Agents auf den Cluster zugreifen und die Manifeste mit kubectl anwenden.

Diese Kommunikation war anfangs schwer nachzuvollziehen, weil die Pipeline nicht einfach per SSH auf dem Server arbeitet. Der Ablauf besteht aus mehreren Teilen: GitLab CI baut die Images, die Container Registry speichert sie, der Agent verbindet GitLab mit Kubernetes und Kubernetes startet die neue Version der Anwendung.

Die beiden Deployment-Ansätze

Docker ComposeKubernetes/k3s
Einfache Orchestrierung auf einem ServerVerwaltung der Anwendung in einem Cluster
Container werden direkt gestartetAnwendung läuft in Pods
Weniger automatische SkalierungHPA kann Pods automatisch skalieren
Gut für den ersten Deployment-SchrittNäher an einem realitätsnahen Cloud-Deployment
Bereitstellung zunächst per SSHBereitstellung über den GitLab Kubernetes Agent

Skalierung und Redis

Für die Skalierung verwenden wir den Horizontal Pod Autoscaler von Kubernetes. Der HPA überwacht die CPU-Auslastung und passt die Anzahl der Pods an. Für das Backend sind zwei bis fünf Pods vorgesehen, für das Gateway zwei bis vier.

Die Skalierung hat erfolgreich funktioniert. Unter Last startete Kubernetes automatisch zusätzliche Backend- und Gateway-Pods. Damit konnten wir die horizontale Skalierung praktisch nachstellen.

Redis war wichtig, damit der Chat auch mit mehreren Backend-Pods funktioniert. Eine WebSocket-Verbindung ist immer an einen bestimmten Backend-Pod gebunden. Wenn ein Benutzer mit Pod A und ein anderer mit Pod B verbunden ist, kennen die beiden Pods ihre jeweiligen Verbindungen nicht automatisch. Ohne Redis könnte eine Nachricht deshalb nur lokal beim jeweiligen Pod ankommen.

Über Redis Pub/Sub werden Nachrichten, Direktnachrichten, Löschereignisse und Änderungen des Online-Status zwischen den Pods verteilt. Jede Backend-Instanz besitzt einen Redis-Listener und leitet empfangene Ereignisse an ihre lokal verbundenen WebSockets weiter. Dadurch funktioniert der Chat auch dann, wenn Benutzer auf unterschiedlichen Pods landen.

Auch mit nur einem Backend-Pod laufen Chat-Ereignisse in unserer Anwendung über Redis. Bei mehreren Pods muss man beachten, dass Nachrichten alle Backend-Instanzen erreichen, weil jeder Pod nur seine eigenen WebSocket-Verbindungen kennt.

Lasttests und Grenzen

Mit k6 haben wir die Anwendung unter steigender Last getestet. Neben normalen Lasttests führten wir auch extreme Tests mit 300 und 600 virtuellen Benutzern durch. Diese Tests sollten keine realistische Dauerlast darstellen, sondern bewusst zeigen, wo die Grenzen der Anwendung und der Infrastruktur liegen.

Dabei stellten wir fest, dass das Gateway die erste Engstelle war. Bereits bevor das Backend vollständig ausgelastet war, traten am NGINX-Gateway höhere Antwortzeiten und teilweise Verbindungsfehler auf. Durch zusätzliche Gateway-Pods konnte die Stabilität verbessert werden.

Außerdem wurde uns klar, dass Pod-Skalierung allein keine unbegrenzte Skalierung bedeutet. Alle Pods liefen weiterhin auf demselben Hetzner-Server und teilten sich dessen CPU- und Speicherressourcen. Der einzelne Node setzte daher ein Limit.

Um über die Kapazität unseres einzelnen Hetzner-Servers hinaus zu skalieren, hätten wir weitere Nodes benötigt. Diese hätten wir auch manuell einrichten können. Für das automatische Hinzufügen weiterer Server wäre zusätzlich eine passende Autoscaling-Lösung nötig gewesen. Das haben wir in diesem Projekt nicht umgesetzt. Trotzdem konnten wir die Pod-Skalierung erfolgreich testen und eine automatische Skalierung innerhalb der vorhandenen Infrastruktur demonstrieren.

Ergebnisse der Lasttests

TestLastBeobachtung
Normaler Lasttest100 VUs49.691 Requests, 0 % Fehler, P95-Latenz von 290 ms. Der HPA reagierte bereits auf diese Last und startete zusätzliche Pods.
Stresstest300 VUsDie Latenz stieg deutlich an. Das Gateway war stark ausgelastet, konnte die meisten Anfragen aber noch verarbeiten.
Grenztest600 VUsDie Latenz erhöhte sich extrem. Das Gateway erreichte seine Belastungsgrenze und konnte viele Anfragen nicht mehr verarbeiten.

Herausforderungen

Die größte Herausforderung war das Zusammenspiel der verschiedenen Werkzeuge. Zunächst war nicht klar, wie GitLab CI/CD den Kubernetes-Cluster erreicht. er erste Ansatz verwendete SSH-Skripte und später wechselten wir zum GitLab-Kubernetes-Agenten. Dieser Wechsel erforderte ein neues Verständnis davon, wie Pipeline, Container Registry und Cluster miteinander verbunden sind.

Der Wechsel von Docker Compose zu K3s hat länger gedauert als gedacht. Wir mussten uns zunächst mit Pods, Services, Deployments und Kubernetes-internen Namen vertraut machen. Erst danach konnten wir Backend, Frontend, MongoDB, Redis und NGINX gemeinsam im Cluster betreiben.

Bei der CI-Konfiguration führten Protected Variables zu einem weiteren Problem. Unser Feature-Branch war zunächst nicht geschützt, daher erhielt die Pipeline den geschützten SSH-Key nicht. Zusätzlich war nicht abgestimmt, ob
SSH_PRIVATE_KEY als Text- oder File-Variable angelegt wird. Bei einer File-Variable muss das Skript den Pfad zu einer temporären Datei verarbeiten, nicht den Key direkt.

Auch die Tests und die Infrastruktur erforderten Fehlersuche. Ein Hintergrundprozess für den Redis-Listener ließ pytest in GitLab hängen und musste in den Tests kontrolliert werden. Nach dem Ersetzen eines Servers mussten wir außerdem SSH-Warnungen wegen eines geänderten Host-Keys prüfen. Als wir versehentlich den falschen Terraform-State verwendeten, wurde der Compose-Server zerstört. Deswegen sollte vor terraform apply geprüft werden, welchen State Terraform verwendet und welche Änderungen der Plan anzeigt.

Für die Lasttests war der GitLab Runner kein zuverlässiger Ausgangspunkt, da er den öffentlichen NodePort nicht immer erreichen konnte. Wir führten k6 daher lokal aus.

Was wir gelernt haben

Wir haben gelernt, Infrastructure as Code mit Terraform selbst zu programmieren, anzuwenden und mit einem automatisierten Deployment zu verbinden. Nach dem Docker-Compose-Setup richteten wir die Anwendung auch mit K3s, Services und mehreren Pods ein.

Außerdem wurde deutlich, dass Skalierung mehr bedeutet als nur die Anzahl der Replicas zu erhöhen. Redis ist notwendig, damit mehrere Backend-Pods bei WebSocket-Kommunikation zusammenarbeiten. Mit k6 konnten wir Last gezielt steigern, während wir Pods, Ressourcenauslastung, Antwortzeiten und Fehlerraten beobachteten. So wurde sichtbar, wann der HPA skaliert, dass das Gateway die erste Engstelle bildet und dass ein einzelner Server die weitere Skalierung begrenzt.

Unser wichtigstes übergreifendes Learning ist daher, dass ein Deployment aus mehreren überprüfbaren Schritten besteht: Infrastruktur erstellen, Images bauen, Anwendung deployen, Kommunikation zwischen Instanzen sicherstellen und das Verhalten unter Last messen. Erst das Zusammenspiel dieser Schritte zeigt, ob die Anwendung in der Cloud tatsächlich funktioniert.

Projektteam

  • Henry Spechtenhauser – @hs128
  • Georgi Bozhilski – @gb040
  • Martin Herdt – @mh370
  • Asim Jugo – @aj049

Comments

Leave a Reply