Logging, Tracing, Metriken: Unser Weg zu einem wiederverwendbaren Projekt-Skeleton

Alexander Quadflieg, Simon Breit, Pia Schweizer

Dieser Beitrag ist im Rahmen der Vorlesung „Software Development for Cloud Computing“ entstanden. Das beschriebene Projekt ist auf GitHub verfügbar:
https://github.com/simonbreit-dev/software-development-cloud-computing26
https://github.com/simonbreit-dev/sdfcc-gitops

I. Einleitung

Wer schon mal versucht hat herauszufinden, warum eine von mehreren verteilten Services abhängige Anwendung plötzlich nicht mehr wie erwartet funktioniert, weiß: Ohne die richtigen Werkzeuge kommt man schnell an seine Grenzen. Unser Ziel war es dieses Semester, einen eigenen, wiederverwendbaren Observability-Stack zu bauen, den wir auch über dieses Projekt hinaus einsetzen können.

Damit wir diesen Stack aber nicht nur theoretisch aufbauen, sondern auch unter realistischen Bedingungen testen konnten, brauchten wir eine kleine Anwendung, die uns echte Telemetriedaten liefert. Die Idee dazu kam uns bei der „Gespeichert”-Funktion von Google Maps, mit der man Orte, Restaurants oder Ähnliches in eigenen Listen organisieren kann. Genau dieses Prinzip haben wir uns als Vorlage genommen und daraus eine kleine REST-API gebaut, die Restaurants verwaltet und in Listen einordnet. Die Anwendung haben wir bewusst schlank gehalten, damit der Fokus klar auf der Infrastruktur rund um die Anwendung liegt.

II. Zielsetzung und Abgrenzung

Projektziele:

Unser Hauptziel war es, eine wiederverwendbare Projektvorlage zu entwickeln, die den Fokus auf Observability legt, also auf Logging, Tracing und Metriken. Uns war dabei wichtig, dass der entstehende Stack nicht fest an unsere konkrete Anwendung gekoppelt ist, sondern sich auch in zukünftigen, containerisierten Projekten wiederverwenden lässt. Generell wollten wir uns in diesem Projekt stärker mit dem Betrieb von Software auseinandersetzen als mit komplexer Fachlogik, weshalb auch eine automatisierte Qualitätssicherung über CI/CD, für uns von Anfang an eine Rolle gespielt hat. Außerdem ging es uns darum, moderne DevOps-Werkzeuge wie den Grafana-Stack, den OpenAPI Generator und Docker in der Praxis auszuprobieren und dabei so viel wie möglich zu lernen.

Abgrenzung (Out of Scope):

Damit unser Scope nicht zu groß wird, haben wir den Umfang unserer Beispielanwendung bewusst klein gehalten. Auf eine umfangreiche fachliche Logik haben wir daher ebenso verzichtet wie auf eine komplexe Benutzerverwaltung. Die Anwendung ist schlicht für einen einzelnen Benutzer pro Instanz ausgelegt. Auch ein eigenes Frontend haben wir uns bewusst gespart, da unser Fokus klar auf dem Backend und der dahinterliegenden Infrastruktur lag. Und da es sich um ein Lernprojekt handelt, war auch ein produktiver Einsatz der Anwendung für uns nie das Ziel.

Damit lag der Schwerpunkt des Projekts bewusst nicht auf der Beispielanwendung selbst, sondern auf der Infrastruktur, dem Observability-Stack und den Praktiken rund um deren Entwicklung und Betrieb.

III. Anwendung

Use Case

Bei der Anwendung haben wir uns an der Funktion „Gespeichert“ des Kartendienstes Google Maps orientiert. Konkret geht es darum, Orte, Restaurants oder ähnliche Ziele in Listen zu verwalten und für die Zukunft zu speichern.

Im Gegensatz zu Google Maps konzentrieren wir uns in unserem Projekt ausschließlich auf die Backend-Logik dieses Anwendungsfalls.

Dafür haben wir eine REST-API mit Java und Spring Boot entwickelt. Für die Persistierung verwenden wir Spring Data JPA in Kombination mit einer PostgreSQL-Datenbank.

Die Besonderheiten der Anwendung liegen jedoch weniger in ihrem fachlichen Funktionsumfang als vielmehr in ihrer Architektur sowie in der Definition und Generierung ihrer Schnittstellen.

Hexagonale Architektur

Eine für uns besondere Entscheidung war, das Projekt nach dem Paradigma der hexagonalen Architektur zu strukturieren.

Trotz geringer Vorerfahrung haben wir uns bewusst dafür entschieden, da wir in vorherigen Projekten die Erfahrung gemacht hatten, dass bei klassischen 3-Tier-Architekturen mit Präsentations-, Logik- und Datenschicht die Grenzen zwischen den einzelnen Schichten mit zunehmender Projektgröße schnell verschwimmen können.

Durch die hexagonale Architektur wollten wir diese Komponenten stärker voneinander entkoppeln und insbesondere die Richtung der Abhängigkeiten kontrollieren.

Das führt zwar dazu, dass zusätzliche Modelle, Interfaces und Mapping-Schritte benötigt werden, hat für uns jedoch den Vorteil, dass Änderungen an einer äußeren Schnittstelle nicht automatisch Auswirkungen auf andere Teile des Systems haben.

Ändert sich beispielsweise ein REST-Endpunkt, kann die Persistenzschicht davon unabhängig bleiben.

Darüber hinaus wollten wir uns durch diese Entkopplung die Möglichkeit offenhalten, einzelne Adapter später auszutauschen. So könnte beispielsweise PostgreSQL durch eine andere Persistenztechnologie ersetzt oder anstelle der REST-Schnittstelle eine andere Art der Kommunikation verwendet werden, ohne die zentrale Geschäftslogik grundlegend verändern zu müssen.

OpenAPI als zentrale Schnittstellendefinition

Trotz der vergleichsweise geringen Größe der Anwendung haben wir uns entschieden, die benötigten Endpunkte und Models zentral in einer OpenAPI-Spezifikation zu definieren.

Dadurch lassen sich die Schnittstellen und ihre Datenmodelle an einer zentralen Stelle festlegen und gleichzeitig dokumentieren. Da die Spezifikation gemeinsam mit dem restlichen Quellcode versioniert wird, sind Änderungen an der API außerdem nachvollziehbar.

Ein weiterer Grund für die Nutzung von OpenAPI war die Möglichkeit, aus der Spezifikation automatisch Quellcode generieren zu lassen.

Dafür verwenden wir den OpenAPI Generator. Dieser wird aktiv weiterentwickelt, bietet umfangreiche Dokumentation und unterstützt zahlreiche Zielsprachen.

Gerade mit Blick auf die Wiederverwendbarkeit ist dies für uns interessant. Eine OpenAPI-Spezifikation ist grundsätzlich unabhängig von der verwendeten Programmiersprache. Aus derselben Definition können daher Assets für unterschiedliche Anwendungen generiert werden.

Die OpenAPI-Spezifikation wird damit zur Single Source of Truth für die Kommunikation zwischen verschiedenen Softwarekomponenten.

In unserem Projekt existiert aktuell ausschließlich das Backend. Würde die Anwendung beispielsweise um ein Angular-Frontend erweitert werden, könnten aus derselben Spezifikation passende API-Clients und Models generiert werden.

Dadurch kann sichergestellt werden, dass Front- und Backend dieselbe technische Schnittstellendefinition verwenden. Die funktionale Korrektheit der Anwendung wird dadurch allerdings nicht automatisch garantiert und muss weiterhin über Tests abgesichert werden.

Automatisierte Codegenerierung

Für die Ausführung des OpenAPI Generators haben wir uns für eine zentrale Lösung über ein Makefile entschieden.

Unser Ziel war es, die Bedienung für alle Entwickler möglichst einheitlich zu gestalten. Der Generator wird deshalb aus einem containerisierten Image ausgeführt und die dazugehörigen Befehle werden zentral im Makefile definiert.

Dadurch ist insbesondere sichergestellt, dass alle Entwickler dieselbe Version des OpenAPI Generators verwenden.

Ein vereinfachter Ausschnitt zur Generierung der Endpunkte und Models sieht folgendermaßen aus:

generate:
	@echo ">>> Running OpenAPI generator..."
	docker run --rm \
		$(USER_FLAG) \
		-v "$(CURDIR):/workspace" \
		-w /workspace \
		$(OPENAPI_IMAGE) generate \
			-i /workspace/$(SPEC_FILE) \
			-c /workspace/$(CONFIG_FILE) \
			-o /workspace/$(OUTPUT_DIR)
	@echo ""
	@echo ">>> Done. Generated files written to: $(GENERATED_DIR)"
	@echo ""

Im Verlauf der Entwicklung fiel uns dabei jedoch ein Problem auf: Da die generierten Dateien Teil des Repositorys sind, könnte ein Entwickler diese theoretisch manuell verändern.

In diesem Fall würde der eingecheckte Code nicht mehr der OpenAPI-Spezifikation entsprechen.

Wir haben uns deshalb entschieden, das Makefile um eine zusätzliche Überprüfung zu erweitern. Dabei werden die Dateien erneut aus der Spezifikation generiert und anschließend mit dem eingecheckten Zustand verglichen.

check-generated: generate
	@echo ">>> Diffing generated files against committed state..."
	@if ! git diff --quiet -- $(GENERATED_DIR); then \
		echo ""; \
		echo "  Generated files are out of sync with $(SPEC_FILE)"; \
		echo ""; \
		echo "    Either the spec was changed without regenerating,"; \
		echo "    or generated files were edited manually."; \
		echo ""; \
		echo "    Fix: run 'make generate' locally and commit the result."; \
		echo ""; \
		git diff --stat -- $(GENERATED_DIR); \
		echo ""; \
		exit 1; \
	fi
	@echo "  Generated files are in sync with the spec."

Diese Überprüfung lässt sich anschließend einfach in GitHub Actions integrieren und stellt sicher, dass die eingecheckten generierten Dateien weiterhin mit der OpenAPI-Spezifikation übereinstimmen.

IV. Observability

Ziel des Observability-Stacks

Ein weiteres zentrales Ziel unseres Projekts war die Entwicklung eines wiederverwendbaren Observability-Stacks.

Dieser sollte möglichst unabhängig von unserer konkreten Anwendung funktionieren und sich dadurch auch in zukünftige containerisierte Projekte integrieren lassen.

Unter Observability verstehen wir in diesem Zusammenhang insbesondere die Erfassung der drei zentralen Telemetriearten Metrics, Logs und Traces. Gemeinsam ermöglichen diese einen Einblick in den Zustand einer Anwendung und helfen dabei Fehler sowie Performance-Probleme nachvollziehen zu können.

Weitere Informationen zu den verschiedenen Telemetrie-Signalen bietet die OpenTelemetry-Dokumentation.

Komponenten

Für die Umsetzung haben wir uns für einen vollständig containerisierten Stack auf Basis von Grafana, Prometheus, Loki, Tempo und Grafana Alloy entschieden.

Die einzelnen Komponenten übernehmen unterschiedliche Aufgaben:

  • Prometheus speichert und verarbeitet Metriken.
  • Loki dient zur zentralen Speicherung von Logs.
  • Tempo wird für Distributed Tracing verwendet.
  • Grafana bildet die gemeinsame Oberfläche zur Abfrage und Visualisierung der Daten.
  • Grafana Alloy fungiert als zentrale Schnittstelle zwischen Anwendungen und Observability-Backends.

Anwendungen müssen dadurch nicht direkt mit Prometheus, Loki oder Tempo kommunizieren. Stattdessen werden die Telemetriedaten zunächst an Alloy übertragen und von dort abhängig vom Datentyp an das entsprechende Backend weitergeleitet.

OpenTelemetry und OTLP

Für die Übertragung verwenden wir primär OpenTelemetry und das dazugehörige OpenTelemetry Protocol (OTLP).

Dadurch ist die Instrumentierung der Anwendung nicht unmittelbar an die konkret verwendeten Grafana-Komponenten gekoppelt.

Unsere Anwendung sendet Metrics, Logs und Traces über OTLP an Alloy. Von dort werden die Metriken an Prometheus, die Logs an Loki und die Traces an Tempo weitergeleitet.

Der Datenfluss lässt sich vereinfacht folgendermaßen darstellen:

Instrumentierung der Anwendung

Bei unserer Spring-Boot-Anwendung erfolgt die OpenTelemetry-Integration direkt innerhalb der Anwendung.

Neben automatisch erzeugten Telemetriedaten haben wir eigene anwendungsspezifische Metriken implementiert.

Dafür wird jeder HTTP-Request durch einen Filter erfasst. Gemessen werden unter anderem:

  • die Anzahl der Requests,
  • die Anzahl fehlgeschlagener Requests
  • sowie die Dauer der einzelnen Requests.

Zusätzlich werden Attribute wie HTTP-Methode, Route und Statuscode gespeichert.

Dadurch können später beispielsweise Fehlerraten oder Antwortzeiten einzelner Endpunkte analysiert werden.

Wiederverwendbarkeit und Docker-Fallback

Ein wichtiger Aspekt bei der Entwicklung des Stacks war seine Wiederverwendbarkeit.

Nicht jede Anwendung unterstützt OpenTelemetry direkt. Gerade bei bestehenden oder fremden Docker-Containern möchten wir außerdem nicht zwangsläufig Änderungen an der jeweiligen Anwendung vornehmen.

Deshalb unterstützt unser Stack zusätzlich einen Docker-basierten Fallback.

Grafana Alloy kann über den Docker-Socket laufende Container automatisch erkennen. Über Docker-Labels kann anschließend festgelegt werden, ob für einen Container Logs gesammelt oder ein vorhandener Metrics-Endpunkt abgefragt werden soll.

Ein Container kann beispielsweise folgendermaßen für das Erfassen von Metriken konfiguriert werden:

labels:
  observability.metrics: "true"
  observability.metrics_port: "8080"
  observability.metrics_path: "/metrics"

Auf ähnliche Weise kann über

observability.logs: "true"

die Erfassung der Standardausgabe eines Containers aktiviert werden.

Damit kann derselbe Observability-Stack sowohl Anwendungen anbinden, die OpenTelemetry nativ unterstützen, als auch solche, die lediglich Logs über stdout beziehungsweise stderr oder einen Prometheus-kompatiblen Metrics-Endpunkt bereitstellen.

Beide Varianten sollten für dieselben Telemetriedaten jedoch nicht gleichzeitig verwendet werden. Würden Logs beispielsweise sowohl über OpenTelemetry übertragen als auch über Docker eingesammelt, könnten dieselben Daten doppelt in Loki gespeichert werden.

Für selbst entwickelte Anwendungen ist deshalb die direkte OpenTelemetry-Integration unser bevorzugter Weg. Die Docker-Integration dient primär als Fallback für andere Services.

Betrieb des Stacks

Auch der Observability-Stack selbst wird überwacht.

Alloy fragt regelmäßig die Metrics-Endpunkte von Prometheus, Loki, Tempo, Grafana und Alloy ab und schreibt die gewonnenen Daten ebenfalls nach Prometheus.

Dadurch können nicht nur die angebundenen Anwendungen, sondern auch die Komponenten des Monitoring-Systems selbst beobachtet werden.

Der gesamte Stack wird über Docker Compose gestartet und ist weitestgehend unabhängig von der eigentlichen Anwendung definiert.

Prometheus, Loki und Tempo sind standardmäßig nur innerhalb des gemeinsamen Docker-Netzwerks erreichbar. Nach außen müssen lediglich tatsächlich benötigte Schnittstellen wie Grafana oder die OTLP-Endpunkte von Alloy veröffentlicht werden.

Die Daten der einzelnen Komponenten werden zusätzlich über Docker-Volumes persistent gespeichert.

Durch diese Architektur haben wir einen Observability-Stack erhalten, der innerhalb des Projekts entwickelt und getestet wurde, jedoch nicht speziell von unserer Anwendung abhängig ist.

Neue Docker-basierte Projekte können an das gemeinsame Netzwerk angebunden werden und anschließend entweder über OpenTelemetry oder über die Docker-basierte Erkennung ihre Telemetriedaten bereitstellen.

Damit kann der Stack als Grundlage für weitere Projekte wiederverwendet werden.

V. Deployment mit GitOps

Motivation

Neben der Entwicklung und Beobachtung der Anwendung wollten wir uns auch damit beschäftigen, wie diese reproduzierbar und möglichst automatisiert deployed werden kann.

Dafür haben wir uns für einen GitOps-basierten Ansatz auf Kubernetes entschieden.

GitOps beschreibt ein Vorgehen, bei dem der gewünschte Zustand eines Systems deklarativ definiert und versioniert gespeichert wird. Ein im Cluster laufender Controller gleicht diesen gewünschten Zustand kontinuierlich mit dem tatsächlichen Zustand ab und versucht Abweichungen zu korrigieren.

Damit folgt der Ansatz den vier von OpenGitOps definierten Grundprinzipien: deklarative Konfiguration, Versionierung und Unveränderlichkeit des gewünschten Zustands, automatisches Abrufen der Konfiguration sowie kontinuierliche Reconciliation.

Flux als GitOps-Operator

Als Kubernetes-Operator verwenden wir Flux.

Flux läuft direkt innerhalb des Kubernetes-Clusters und überwacht ein separates Git-Repository, in dem die Deployment-Konfiguration der Anwendung hinterlegt ist.

Damit unterscheidet sich dieser Ansatz von einer klassischen CI/CD-Pipeline, bei der beispielsweise GitHub Actions nach einem erfolgreichen Build direkt mittels kubectl oder helm upgrade Änderungen am Cluster durchführen.

Stattdessen endet die Verantwortung unserer CI zunächst mit dem Erstellen und Veröffentlichen der benötigten Artefakte.

Der grundsätzliche Deployment-Prozess sieht damit folgendermaßen aus:

Continuous Integration und Artefakte

Die Continuous-Integration-Pipeline befindet sich weiterhin im eigentlichen Anwendungsrepository.

Bei Änderungen wird die Anwendung zunächst gebaut und getestet.

Nach einem erfolgreichen Push auf den Main-Branch erzeugt GitHub Actions anschließend ein Multi-Platform-Docker-Image für linux/amd64 und linux/arm64 und veröffentlicht dieses in der GitHub Container Registry (GHCR).

Zusätzlich wird unser Helm-Chart validiert, paketiert und ebenfalls als OCI-Artefakt in GHCR veröffentlicht.

Für beide Artefakte werden Versionen verwendet, die auf den jeweiligen Git-Commit zurückgeführt werden können. Dadurch lässt sich eindeutig nachvollziehen, welcher Quellcode einem veröffentlichten Deployment-Artefakt zugrunde liegt.

GitOps-Repository

Das eigentliche Deployment wird anschließend über ein getrenntes GitOps-Repository gesteuert.

Darin wird festgelegt, welche Version des Helm-Charts und welches konkrete Container-Image in einer Umgebung verwendet werden sollen.

Wir verwenden dafür bewusst unveränderliche Image-Tags beziehungsweise Digests anstelle eines veränderlichen Tags wie latest.

Dadurch beschreibt ein bestimmter Commit des GitOps-Repositories einen reproduzierbaren Deployment-Zustand und verweist immer auf dieselben Artefakte.

Helm und Flux

Für die Bereitstellung des Helm-Charts verwendet Flux eine OCIRepository-Ressource. Diese verweist auf das in GHCR gespeicherte Chart.

Eine HelmRelease beschreibt anschließend deklarativ, mit welcher Version und welchen Konfigurationswerten dieses Chart installiert werden soll.

Der Flux Helm Controller übernimmt darauf basierend Installation und Updates des Helm-Releases und prüft regelmäßig, ob der reale Zustand im Cluster dem in Git definierten Zustand entspricht.

Damit ergibt sich eine klare Trennung zwischen Build und Deployment:

CI:
Source Code
   -> Test
   -> Build
   -> Docker Image
   -> Helm Chart
   -> Registry

GitOps:
GitOps Repository
   -> Flux
   -> Kubernetes

Eine neue Version der Anwendung wird damit nicht dadurch deployed, dass eine CI-Pipeline direkten Zugriff auf den Kubernetes-Cluster erhält.

Stattdessen wird die gewünschte Version im GitOps-Repository geändert. Nachdem diese Änderung geprüft und gemerged wurde, erkennt Flux den neuen gewünschten Zustand und setzt die entsprechende Änderung im Cluster um.

Git wird damit zur Single Source of Truth für den Deployment-Zustand.

Abhängigkeiten zwischen Komponenten

Zusätzlich verwenden wir Flux-Kustomization-Ressourcen, um verschiedene Teile des Deployments voneinander abhängig zu machen.

In unserer Entwicklungsumgebung wird zunächst die benötigte Infrastruktur bereitgestellt und erst anschließend die Anwendung deployed.

Die Kustomization der Anwendung besitzt dafür eine Abhängigkeit von der Infrastruktur. Flux beginnt mit dem Deployment der Anwendung erst, wenn die Infrastruktur erfolgreich als Ready markiert wurde.

Dadurch kann beispielsweise verhindert werden, dass das Backend gestartet wird, bevor die benötigte PostgreSQL-Datenbank bereitsteht.

Weitere Informationen zur kontinuierlichen Reconciliation bietet die Flux-Dokumentation.

Änderungen und Rollbacks

Ein weiterer Vorteil des GitOps-Ansatzes zeigt sich bei manuellen Veränderungen innerhalb des Clusters.

Wird eine von Flux verwaltete Ressource beispielsweise direkt mittels kubectl verändert, entspricht der tatsächliche Zustand nicht mehr dem in Git definierten gewünschten Zustand.

Flux kann diese Abweichung erkennen und den definierten Zustand wiederherstellen.

Direkte Änderungen innerhalb des Clusters sollen dadurch zur Ausnahme werden. Änderungen am Deployment werden stattdessen wie normaler Quellcode über Commits und Pull Requests vorgenommen und sind damit nachvollziehbar und reviewbar.

Dasselbe Prinzip vereinfacht auch Rollbacks.

Anstatt manuell rekonstruieren zu müssen, welche Konfiguration zuvor aktiv war, kann eine fehlerhafte Änderung im GitOps-Repository zurückgenommen werden.

Flux erkennt anschließend den veränderten gewünschten Zustand und gleicht den Cluster entsprechend mit diesem Zustand ab

Direkte helm upgrade– oder helm rollback-Aufrufe sind in unserem Operations-Konzept deshalb lediglich als Notfallmechanismus vorgesehen. Im Normalfall sollen Änderungen über Git erfolgen.

Secrets

Eine Herausforderung bei diesem Ansatz stellen sensible Konfigurationswerte dar.

Passwörter oder andere Secrets dürfen trotz Git als zentraler Quelle nicht im Klartext im Repository gespeichert werden.

Für unser Deployment ist deshalb die Verwendung von SOPS zusammen mit age vorgesehen.

Secrets können damit verschlüsselt im GitOps-Repository versioniert und erst innerhalb des Clusters von Flux entschlüsselt werden. Der private Schlüssel selbst ist dabei nicht Bestandteil des Repositorys.

Ergebnis

Durch diesen Aufbau erreichen wir eine klare Trennung zwischen Anwendung, Build-Prozess und Deployment.

Das Anwendungsrepository beschreibt, wie ein Artefakt gebaut wird. Die Container Registry speichert die daraus resultierenden unveränderlichen Artefakte. Das GitOps-Repository beschreibt wiederum, welche dieser Artefakte in einer Umgebung ausgeführt werden sollen.

Flux verbindet diese Komponenten, indem es den in Git definierten Zustand kontinuierlich mit dem Kubernetes-Cluster abgleicht.

Dadurch werden Deployments reproduzierbarer, Änderungen besser nachvollziehbar und möglichst wenig Konfiguration existiert ausschließlich innerhalb des Clusters.

VI. Learnings

OpenAPI und OpenAPI Generator

Der API-First-Ansatz mit OpenAPI hat sich für uns als deutlicher Gewinn erwiesen: Da Endpunkte und Modelle zentral in der Spec definiert sind, mussten wir sie nicht doppelt pflegen, und Änderungen an der Schnittstelle waren sofort für alle Beteiligten nachvollziehbar. Gleichzeitig haben wir gelernt, dass die automatische Generierung allein nicht ausreicht: Ohne den Abgleich zwischen Spec und generierten Dateien (unser check-generated-Target) hätten unbemerkte manuelle Anpassungen schnell zu Inkonsistenzen führen können. Insgesamt hat uns der Ansatz gezeigt, wie viel Zeit und Fehleranfälligkeit sich einsparen lässt, wenn Schnittstellen als „Single Source of Truth” behandelt werden.

Hexagonale Architektur

Die Entscheidung für eine hexagonale Architektur hat sich trotz anfänglich geringer Erfahrung damit ausgezahlt. Der Mehraufwand durch zusätzliche Modell-Klassen wurde durch die klare Trennung von Präsentations-, Logik- und Datenschicht ausgeglichen: Änderungen an einer Schicht wirkten sich nicht ungewollt auf die anderen aus, und ein Austausch einzelner Komponenten wie der Datenbank oder der REST-Schnittstelle wäre mit deutlich geringerem Aufwand möglich als in einer klassischen 3-Tier-Architektur. Für uns war das ein guter Beleg dafür, dass sich eine höhere initiale Komplexität langfristig auszahlen kann.

Observability

Beim Aufbau des Observability-Stacks haben wir gemerkt, wie viel Struktur es bringt, wenn Anwendungen nicht direkt mit den einzelnen Backends kommunizieren, sondern alle Telemetriedaten zunächst zentral über Grafana Alloy laufen. So bleibt unsere Anwendung unabhängig von den konkreten Backends. Ändert sich etwas, passen wir nur Alloy an, nicht den Code.

Außerdem haben wir gemerkt, dass Wiederverwendbarkeit nicht heißt, dass jede Anwendung OpenTelemetry unterstützen muss. Für eigene Anwendungen ist OpenTelemetry der beste Weg. Für fremde oder ältere Container gibt es zusätzlich einen Docker-basierten Fallback über Labels, mit dem Alloy auch Logs über stdout oder einen Prometheus-Endpunkt einsammeln kann.

Noch ein wichtiges Learning: Beide Wege gleichzeitig für dieselben Daten zu nutzen, führt schnell zu doppelten Logs oder Metriken. Deshalb haben wir pro Anwendung bewusst nur einen Weg genutzt. Insgesamt hat uns das gezeigt, dass es bei Observability nicht nur ums Sammeln von Daten geht, sondern genauso um eine klare Datenroute ohne Duplikate.

GitOps und Kubernetes

Ein großes Learning für uns war, wie viel angenehmer GitOps das Deployment macht. Die Grundidee: Git ist die einzige Quelle der Wahrheit. Wir beschreiben in Konfigurationsdateien und Helm-Charts, wie unsere Anwendung laufen soll, und der Rest passiert automatisch. GitHub Actions baut bei Änderungen ein neues Docker-Image und veröffentlicht es in der GitHub Container Registry. Welche Version deployed werden soll, wird anschließend im GitOps-Repository festgelegt. Flux erkennt die dort definierte Änderung und gleicht den Kubernetes-Cluster mit diesem gewünschten Zustand ab. Wir mussten also nie manuell auf den Cluster zugreifen, und jede Änderung ist über Commits nachvollziehbar. Sollte mal was schiefgehen, reicht ein Git-Revert, um zur letzten funktionierenden Version zurückzukommen.

Bei Kubernetes selbst hat uns überrascht, wie viel davon eigentlich nur Konfiguration ist. Unsere Anwendung läuft in einem Pod, ein Deployment legt fest, wie viele Replicas laufen, und ein Service macht sie im Cluster erreichbar. Besonders hilfreich fanden wir die klare Trennung zwischen Anwendung und Infrastruktur: Unser Backend interessiert sich nicht dafür, wo es läuft, das regelt die Kubernetes-Konfiguration außenrum. Gelernt haben wir auch, Secrets wie Passwörter oder JWT-Schlüssel nie ins Git-Repository zu packen, sondern in Kubernetes-Secrets.

Gleichzeitig haben wir gemerkt, dass GitOps auch seine Grenzen hat. Nur weil ein Image gebaut wurde, heißt das noch lange nicht, dass die Anwendung auch wirklich funktioniert. Tests und Health Checks müssen also weiterhin Teil der Pipeline sein. Unterm Strich war das für uns der größte Unterschied zu klassischem Deployment. Man beschreibt zuerst in Git, wie der Zielzustand aussehen soll, und das System sorgt danach selbstständig dafür, dass dieser Zustand auch eingehalten wird.

VII. Fazit

Zusammenfassend konnten wir mit dem Projekt verschiedene Konzepte moderner Softwareentwicklung und des Betriebs von Anwendungen praktisch miteinander verbinden.

Mit der Spring-Boot-Anwendung haben wir zunächst einen konkreten Anwendungsfall geschaffen, anhand dessen wir eine hexagonale Architektur und eine zentral über OpenAPI definierte Schnittstelle umsetzen konnten.

Darüber hinaus konnten wir einen wiederverwendbaren Observability-Stack auf Basis von OpenTelemetry, Grafana Alloy, Prometheus, Loki, Tempo und Grafana entwickeln und mit unserer Anwendung testen.

Durch die zusätzliche Beschäftigung mit Kubernetes und GitOps konnten wir außerdem den gesamten Weg von der Entwicklung über den Build und die Veröffentlichung von Artefakten bis zur deklarativen Bereitstellung einer Anwendung betrachten.

Besonders wichtig war uns dabei die Wiederverwendbarkeit der entwickelten Konzepte. Sowohl der Observability-Stack als auch Teile der Deployment- und CI-Struktur können als Grundlage für zukünftige Projekte dienen.

Der Code zu dem Projekt ist unter folgendem Link verfügbar:
https://github.com/simonbreit-dev/software-development-cloud-computing26
https://github.com/simonbreit-dev/sdfcc-gitops


Posted

in

by

Alexander Quadflieg, Simon Breit, Pia Schweizer

Comments

Leave a Reply