StudySwipe ist eine Karteikarten-Anwendung: Man legt Ordner an, füllt sie mit Karten und lernt sie im Swipe-Prinzip durch. Technisch besteht es aus einem Spring-Boot-Backend, einem React-Frontend und einer PostgreSQL-Datenbank. Entstanden ist es als Studienprojekt im dritten Semester, bis dahin lief es ausschließlich lokal über Docker Compose.
Ausgangslage
Für den Kurs Softw. Dev. Cloud Computing habe ich mich für ein Projekt entschieden, das folgendes Ziel hatte. Dieses Ziel hab ich mir am Anfang so notiert:
„1x Script laufen lassen und alles hochfahren können.”
Was ich zu dem Zeitpunkt wusste war was eine EC2-Instanz grob ist, aber ich wusste nicht wie eine EC2-Instanz mit Docker zusammenspielt. Wie kommt ein Container, den ich lokal gebaut habe, überhaupt auf die Maschine? Und was passiert eigentlich mit meiner docker-compose.yml, die bisher nur lokal existiert hat? Was haben AWS & Terraform damit zu tun?
Genau diese Fragen zu beantworten war rückblickend der eigentliche Kern des Projekts.
Warum StudySwipe
Ursprünglich wollte ich ein Projekt namens Aviation Photo nehmen, eine Datenbank für Flugzeugfotos mit Suche und Filtern.
Ich habe während der Vorlesungen für Aviation Photo die Vorbereitungen getroffen, mich mit dem Projekt und AWS so vertraut wie möglich gemacht, denn für dieses Projekt existierte bereits ein AWS Konto mit getrennter PROD/TEST-Umgebung in produktiver Nutzung. In der letzten Vorlesung habe ich dann zusammen mit meinem Dozenten meine Optionen angeschaut, um mein Ziel zu erreichen. Dabei sind wir zu dem Entschluss gekommen, dass dieses Projekt nicht ideal dafür geeignet ist. Da ich nicht vertraut war mit dem Tech-Stack, als auch das Projekt noch viele Fragezeichen aufgeschmissen hat. Ebenso würde ich nicht ohne Risiko arbeiten und eventuell etwas zerstören, wenn ich in einer bereits laufenden Umgebung arbeiten würde. Mit der größte Punkt für StudySwipe war, dass StudySwipe bereits in GitLab versioniert war, mein SE3 Projekt war, der Tech-Stack (Java/Spring Boot) war mir vertraut, und es fehlte genau das was ich mir als Ziel gesetzt habe: eine Deployment-Pipeline.
Der direkte Vergleich der beiden Architekturen zeigt, wo ich bewusst einfacher geblieben bin:
| Aviation Photo | StudySwipe | |
| Datenbank | RDS My SQL (managed) | Postgres im eigenen Container |
| Storage | S3 für Bilder | nicht benötigt |
| Domain/HTTPS | Route 53 + Let’s Encrypt | nur IP, kein HTTPS |
| Umgebungen | PROD + TEST getrennt | eine einzige Instanz |
Aviation Photo hat eine deutlich produktionsnähere Infrastruktur. Für mein Kurs-Projekt hätten RDS und eine eigene Domain zusätzliche Komplexität bedeutet, ohne dass ich dadurch mehr über das gelernt hätte, worum es mir ging. Ich wollte bei den Basics anfangen und verstehen, was jeder einzelne Baustein tut, statt möglichst viele AWS-Dienste zu benutzen. Die rechte Spalte ist damit weniger eine Liste von Lücken als eine bewusste Reihenfolge: erst das Fundament, der Rest kann später drauf.
Erstmal: ein eigener AWS-Account
Bevor überhaupt Code ins Spiel kam, brauchte ich eine Umgebung, in der ich gefahrlos experimentieren konnte. Zugriff auf ein bestehendes AWS-Konto hatte ich zwar, aber dort lief bereits etwas produktiv. Kein guter Ort, um Dinge auszuprobieren, deren Auswirkungen man noch nicht abschätzen kann.
Also eigener Account, und dann der Reihe nach das Nötigste: Root-Zugang mit Zwei-Faktor-Authentifizierung absichern, einen Budget-Alarm für den Fall, dass ich versehentlich etwas Teures laufen lasse, einen separaten IAM-Benutzer für die tägliche Arbeit statt alles über den Root-Account zu machen, und die AWS CLI lokal konfigurieren, damit Terraform später überhaupt mit dem Konto sprechen kann.
Klingt nach Formalitäten, war aber rückblickend eine der besseren Entscheidungen: Alles, was danach kam, inklusive mehrfachem kompletten Löschen und Neuaufbauen der gesamten Infrastruktur, konnte ich ohne Sorge machen. Im schlimmsten Fall wäre nur mein eigener Testaccount betroffen gewesen.
Phase 1: Erstmal alles von Hand
Bevor ich irgendwas automatisiert hab, wollte ich die App einmal manuell auf einer EC2-Instanz zum Laufen bringen. Terraform hat mir dafür eine leere Instanz erzeugt, den Rest hab ich per SSH selbst gemacht: Java installieren, Docker installieren, Postgres als Container starten, die Anwendung starten.
Das war der Punkt, an dem ich das erste Mal richtig hängen geblieben bin. Die Anwendung fuhr hoch, aber ohne Datenbank-Anbindung:
No bean named 'entityManagerFactory' available
Die Fehlermeldung selbst führt erstmal in die Irre, sie klingt nach einem Konfigurationsfehler in der Anwendung. Tatsächlich lag es am Build: Das Projekt hatte einen eigenen, selbstgebauten Mechanismus, um das Backend-Docker-Image zu erzeugen. Dieser hat alle Bibliotheken entpackt und in einen gemeinsamen Ordner zusammengeführt, mit der Einstellung, bei Namenskonflikten einfach eine Version zu behalten und den Rest zu verwerfen:
duplicatesStrategy(DuplicatesStrategy.EXCLUDE)
Das Problem: Mehrere Spring-Bibliotheken bringen jeweils eine Datei mit demselben Namen mit, eine Art Inhaltsverzeichnis, das Spring Boot sagt, welche Funktionen es beim Start automatisch aktivieren soll. Beim Zusammenführen hat also nur eine dieser Dateien überlebt, die anderen wurden stillschweigend weggeworfen. Darunter die für die Datenbank-Anbindung. Kein Fehler beim Bauen, keine Warnung, die App startete einfach unvollständig.
Nachweisen ließ sich das mit einem Blick in die fertige jar-Datei. Der folgende Befehl listet ihren Inhalt auf und zählt, wie viele Einträge davon im Bibliotheks-Ordner liegen:
$ unzip -l application-plain.jar | grep -c 'BOOT-INF/lib/'
0
Null Bibliotheken. Nach dem Fix, mit Spring Boots eigenem Build-Mechanismus (bootJar statt des selbstgebauten jar-Tasks), derselbe Befehl:
$ unzip -l application-plain.jar | grep -c 'BOOT-INF/lib/'
73
73 Bibliotheken, darunter der Postgres-Treiber, Hibernate und die Spring-Autokonfiguration, also genau das, was vorher gefehlt hat.
Der Fix selbst waren am Ende zwei geänderte Zeilen:
- copyFile jar.archiveFileName.get(), '/app/test_service.jar'
+ copyFile bootJar.archiveFileName.get(), '/app/test_service.jar'
- from jar.destinationDirectory
+ from bootJar.destinationDirectory
Beide Stellen verwiesen auf den Output des selbstgebauten jar-Tasks statt auf den von bootJar. Weil beide Varianten zufällig denselben Dateinamen erzeugen, ist nie aufgefallen, dass die falsche Datei ins Image gewandert ist. Der Weg dorthin war deutlich länger als der Fix selbst.
Phase 2: Terraform und die Frage, was eigentlich wohin gehört
Terraform war von Anfang an dabei, die leere EC2-Instanz kam ja schon per terraform apply. Was ich in dieser Phase gelernt hab, war weniger die Syntax als die Frage, welche Ebene wofür zuständig ist.
Terraform beschreibt Infrastruktur: welche Maschine, welches Betriebssystem-Abbild, welche Netzwerk-Regeln, welche Zugriffsrechte. Was auf dieser Maschine dann läuft, ist eine andere Ebene und dafür gibt es in Terraform ein Feld namens user_data: ein Skript, das beim allerersten Hochfahren der Instanz automatisch ausgeführt wird.
Dort kam alles, was ich in Phase 1 manuell gemacht hatte rein. Der Ablauf verschob sich damit von “ich logge mich ein und richte ein” zu “die Instanz richtet sich beim Booten selbst ein”.
Eine Eigenheit davon hat mich später nochmal beschäftigt: user_data läuft nur beim allerersten Boot. Ändere ich das Skript, passiert auf einer bereits laufenden Instanz gar nichts, Terraform speichert den neuen Wert zwar, ausgeführt wird er aber nie. Um Änderungen daran zu testen, muss man die Instanz gezielt neu bauen lassen:
terraform apply -replace="aws_instance.studyswipe_server"
In dieser Phase ist mir auch das erste Mal etwas passiert, das sich später wiederholt hat: Ich hatte postgres:latest verwendet, und latest zeigte inzwischen auf eine neue Hauptversion mit geändertem internen Speicherformat:
Error: in 18+, these Docker images are configured to store database
data in a format which is compatible with "pg_ctlcluster"
Ich hatte an meinem Setup nichts geändert, aber die Umgebung hat sich unter mir verändert. Dasselbe Muster kam später nochmal, als das gradle:alpine-Image in der CI-Pipeline eine viel neuere Gradle-Version enthielt als lokal und ein Build fehlschlug.
Phase 3: Docker-Compose und ECR
Bis hierhin lief auf der Instanz eine Mischung, die ich mir selbst zusammengesetzt hatte: Backend direkt als systemd-Dienst, nur die Datenbank in einem Container, Nginx auf dem Host. Das funktionierte, war aber nicht die Struktur, die das Original-Projekt vorsah. Dort waren von Anfang an vier Container geplant: Datenbank, Backend, Frontend und Nginx, alle über docker-compose.
Ich hab mich für den Umbau entschieden, obwohl das, was ich hatte, bereits lief. Das war keine leichte Entscheidung.
Dagegen sprach: Das bestehende Setup lief nachweislich, war getestet, und der Umbau bedeutete, Backend und Frontend erst zu containerisieren, ECR einzurichten und dabei den Fat-Jar-Bug im Original-Build zu finden. Arbeit für etwas, das schon funktionierte.
Dafür sprach: Das Original-Projekt war von Anfang an so gedacht. Ein einziger docker-compose up als Startbefehl liegt näher an meinem Ziel als vier verschiedene Mechanismen nebeneinander (systemd fürs Backend, Docker für die Datenbank, Nginx auf dem Host). Und für die Automatisierung wird das Provisionierungs-Skript dadurch deutlich einfacher: statt Java, Docker, Nginx und systemd einzeln zu installieren und zu konfigurieren, reicht “Docker installieren, docker-compose up”. Die ganze Komplexität steckt dann im Compose-File statt im Skript.
Damit kam die Frage, die vorher gar nicht existiert hatte: Wie kommen vier fertig gebaute Container-Images automatisch auf eine neue Instanz? Genau hier hat sich die anfängliche Wissenslücke geschlossen. Die Antwort ist ECR, eine Container-Registry bei AWS. Ich baue die Images lokal, lade sie einmal hoch, und die Instanz zieht sie sich beim Booten selbst. Authentifiziert über eine IAM-Rolle, ohne dass irgendwo ein Passwort im Skript steht.
Damit war auch klar, wie sich lokale und entfernte Docker-Umgebung zueinander verhalten. Zwei komplett getrennte Docker-Installationen, die nichts voneinander wissen. Die eine baut, die andere führt aus. ECR ist das Bindeglied dazwischen.
Das Timing-Rennen mit ECR
Nach dem ersten kompletten terraform destroy und terraform apply kam dieser Fehler:
ERROR: for backend failed to resolve reference
"...studyswipe-backend:latest": not found
Die Ursache: Registry (ECR) und Server (EC2) lagen im selben Terraform-Projekt. Ein einziger apply hat beides gleichzeitig erzeugt. Die Instanz war nach etwa anderthalb Minuten fertig gebootet und wollte sofort die Images abholen, aber die musste ich erst manuell hochladen und das dauerte länger, weil das Frontend-Image fast 800 MB groß ist.
Der erste Gedanke war, schnell nachzupushen, während die Instanz noch bootete. Das hat funktioniert. Aber es war offensichtlich keine Lösung, sondern ein Test um zu sehen ob alles funktioniert soweit. Denn das nächste Mal mit langsamerem Internet oder größerem Image hätte es wieder nicht geklappt.
Die eigentliche Lösung kam, als klar wurde, warum das Rennen überhaupt entstehen konnte. Zwei Dinge mit völlig unterschiedlicher Änderungsgeschwindigkeit lagen im selben Terraform-Zustand. Die Registry ändert sich fast nie, die Instanz wird bei jedem Test neu gebaut, die Images ändern sich bei jedem Code-Release.
Ich hab beides in zwei getrennte Terraform-Projekte aufgeteilt:
terraform/
├── registry/ ← nur die ECR-Repositories
│ ├── main.tf
│ └── outputs.tf
└── infrastructure/ ← Netzwerk, IAM, EC2, Elastic IP
├── main.tf
├── iam.tf
├── user_data.sh.tpl
└── ...
In infrastructure werden die Repositories nicht mehr erzeugt, sondern nur noch nachgeschlagen:
data "aws_ecr_repository" "backend" {
name = "studyswipe-backend"
}
Der Unterschied ist nicht die Ordnerstruktur an sich, sondern dass sie die richtige Reihenfolge erzwingt: erst Registry bauen, dann in Ruhe die Images hochladen, danach erst die Instanz starten, die garantiert etwas vorfindet. Vorher war diese Reihenfolge nur etwas, das ich mir selbst merken musste und deshalb vergessen konnte.
Kleinere Stolpersteine
Nicht jedes Problem war eine große Geschichte, aber es gab einige davon:
SSH-Key-Rechte unter Windows. Mein privater Schlüssel lag in einem OneDrive-Ordner mit zu offenen Zugriffsrechten:
UNPROTECTED PRIVATE KEY FILE!
Permissions for 'studyswipe-key.pem' are too open.
Gelöst mit icacls, um die Rechte auf meinen eigenen Benutzer zu beschränken. Ein Windows-spezifisches Problem, das man auf einem Linux-Rechner so nie hätte.
Java-Versions-Chaos. Mein lokal installiertes Java war neuer als das, was der im Projekt hinterlegte Gradle-Wrapper verstand:
Unsupported class file major version 69
Erschwerend kam dazu, dass java -version im Terminal eine andere Version anzeigte als die, mit der Gradle tatsächlich arbeitete. Auf meinem Rechner waren mehrere Java-Versionen gleichzeitig installiert, und je nachdem welches Werkzeug man fragte, bekam man eine andere Antwort.
Prozesse, die an der SSH-Sitzung hängen. Als ich das Backend testweise direkt gestartet hatte, war es nach einem Verbindungsabbruch plötzlich weg. Ein Programm, das man im Vordergrund einer SSH-Sitzung startet, wird beendet, sobald die Verbindung endet, auch unbeabsichtigt. Die Lösung war, es als systemd-Dienst einzurichten, der unabhängig von der Sitzung läuft und nach einem Neustart automatisch wieder hochkommt.
Die alte GitLab-Pipeline. Sie war seit der Projektabgabe nicht mehr gelaufen und schlug fehl, weil in der settings.gradle ein Modul eingebunden war, dessen Ordner es gar nicht mehr gab. Lokal mit älterem Gradle war das nur eine Warnung, mit der neueren Version im CI-Image ein harter Fehler. Zusätzlich hab ich die Gradle-Version im Image festgenagelt, statt sie über latest bestimmen zu lassen.
Ein Fehler nachdem man dachte alles sei getan
Dieses Problem ist mir nach meiner Präsentation passiert. Als ich das Deployment eigentlich schon für fertig hielt ist beim erneuten Ausführen von deploy.ps1 der ECR-Login fehlgeschlagen:
Error response from daemon: login attempt to
https://...amazonaws.com/v2/ failed with status: 400 Bad Request
Was folgte, war eine Reihe von Theorien auf der Suche nach dem Fehler:
Erste Theorie: Der Token ist abgelaufen. Klingt naheliegend, weil zwischen den Versuchen lag etwas Zeit. Stimmt aber nicht, denn der Token wird bei jedem Aufruf neu bei AWS angefordert, ein “alter” Token existiert also gar nicht.
Zweite Theorie: Die Uhrzeit meiner Docker-VM stimmt nicht. Docker Desktop läuft unter Windows über eine Linux-VM, die eine eigene Systemuhr hat. Und tatsächlich zeigte docker ps einen Container mit dem Erstellungsdatum “2 years ago”, den es unmöglich so lange geben konnte. AWS reagiert empfindlich auf falsche Systemzeiten. Nach einem Neustart der VM war der Zeitstempel korrekt, aber der Login-Fehler blieb.
Dritte Theorie: Es liegt an der Terminal-Umgebung. Ein manueller Test in einem separaten PowerShell-Fenster hatte funktioniert, im IDE-Terminal nicht. Also lag es vermutlich an unterschiedlichen Umgebungsvariablen. Auch das ließ sich widerlegen, da in einem komplett frischen externen Fenster derselbe Fehler auftrat.
Was am Ende geholfen hat, war ein direkter Vergleichstest mit demselben Token. Diese Variante schlug fehl:
aws ecr get-login-password --region $REGION `
| docker login --username AWS --password-stdin $REGISTRY
Diese funktionierte sofort:
$token = (aws ecr get-login-password --region $REGION).Trim()
docker login --username AWS `
--password $token `
$REGISTRY
Der Unterschied ist die Pipe. Windows-PowerShell reicht scheinbar Text zwischen zwei externen Programmen anders durch als innerhalb der eigenen Umgebung. Dabei findet vermutlich eine Umkodierung statt, die bei normalem Text nie auffällt, bei einem langen, zufällig aussehenden Token aber offenbar zu Beschädigungen führen kann. Zumindest wäre das eine Erklärung für mich. Der Token sah am Bildschirm identisch aus, die Länge stimmte und trotzdem kam bei Docker etwas anderes an.
Drei falsche Theorien und jedes mal die Kette weiter in einzelne Teile zerlegt und jeden Teil einzeln getestet bis man das Problem gefunden hat.
Der Smoke-Test
Eine Sache die ich noch unbedingt hinzufügen wollte war ein Smoke-Test. Da mein Deployment-Skript immer mit “Fertig” endete, unabhängig davon, ob die App danach wirklich erreichbar war. terraform apply sagt nur, dass die Ressourcen existieren und nicht, dass etwas funktioniert.
Also hab ich am Ende einen Test ergänzt, der wiederholt versucht, die Seite aufzurufen:
for ($i = 1; $i -le $maxAttempts; $i++) {
try {
$r = Invoke-WebRequest -Uri "http://$ip" `
-UseBasicParsing -TimeoutSec 5
if ($r.StatusCode -eq 200) {
$success = $true
break
}
} catch {
Write-Host "... noch nicht bereit ($i von $maxAttempts)"
}
Start-Sleep -Seconds 10
}
Ein echter Durchlauf sieht dann so aus:
=== 5. Smoke-Test ===
... noch nicht bereit (Versuch 1 von 18)
... noch nicht bereit (Versuch 2 von 18)
...
App erreichbar nach 120 Sekunden (Status 200)
Zwei Minuten von einer leeren AWS-Umgebung bis zur laufenden Anwendung und jetzt automatisch bestätigt statt manuell im Browser nachgeschaut.
Beim ersten echten Einsatz hat der Test übrigens sofort etwas gefunden: Er meldete, dass die App nicht antwortet, weil die Images gar nicht erst in ECR angekommen waren. Ohne den Test hätte ich das erst gemerkt, wenn ich zufällig die Seite aufgerufen hätte.
Was am Ende steht
Ein Skript, das aus einer leeren AWS-Umgebung heraus die komplette Infrastruktur aufbaut, die Anwendung baut und ausliefert und am Ende überprüft, ob sie wirklich läuft. Dazu drei weitere Skripte für den restlichen Lebenszyklus: destroy, pause, resume.
Der Unterschied zwischen “pause” und “destroy” ist das destroy und deploy beweisen, dass wirklich alles reproduzierbar aus dem Code entsteht, allerdings alle Daten dafür ins Wasser fallen, weil eine neue Instanz mit neuer Festplatte entsteht. pause und resume erhalten die Daten, beweisen aber nichts über den Neuaufbau. Am Ende brauchte ich beide, da ich unbedingt eine Option wollte, die Instanz runterzufahren oder zu pausieren ohne dabei zum Beispiel Karteikarten zu löschen. Das würde den ganzen Sinn des StudySwipe Projekts besiegen.
Damit pause und resume überhaupt praktikabel sind, kam noch eine Elastic IP dazu, eine feste öffentliche Adresse, die an die Instanz gebunden bleibt. Ohne sie bekommt eine gestoppte Instanz beim nächsten Start eine völlig neue IP, was jedes Lesezeichen und jeden SSH-Befehl ungültig macht. In Terraform sind das drei Zeilen. Der eigentliche Punkt ist, dass die Zuordnung damit automatisch mitwandert, statt ein manueller Schritt zu sein, den man vergessen kann.
Was als Nächstes möglich wäre
HTTPS. Aktuell fehlt es, und konkret merkt man das an einer Stelle: Die App setzt beim Ordnerwechsel ein secure-Cookie, das Browser über eine unverschlüsselte Verbindung grundsätzlich ablehnen. Dadurch funktioniert die Lernkarten-Ansicht nicht. Um das sauber zu lösen, braucht es eine Kette von Voraussetzungen: feste IP (habe ich inzwischen über eine Elastic IP), dann eine Domain, auf die ein Zertifikat ausgestellt werden kann.
Datenbank als eigener Dienst. Postgres läuft aktuell als Container auf derselben Instanz. Mit RDS wäre die Datenbank unabhängig von der Instanz, Daten würden dann auch ein destroy überleben.
Die Pipeline weiter nach vorne ziehen. Aktuell baue und pushe ich die Images von meinem eigenen Rechner aus. Der nächste logische Schritt wäre, das in die GitLab-CI zu verlagern, sodass ein Push auf den Branch den ganzen Ablauf auslöst.
Learnings
latest ist kein fester Punkt. Zweimal ist mir die Umgebung unter den Füßen weggerutscht, ohne dass ich etwas geändert hatte, einmal bei Postgres, einmal beim Gradle-Image in der CI. Seitdem pinne ich Versionen, sobald Reproduzierbarkeit eine Rolle spielt.
Infrastruktur und Anwendung sind unterschiedliche Ebenen mit unterschiedlichen Lebenszyklen. Das ECR-Timing-Problem war im Kern kein Timing-Problem, sondern ein Struktur-Problem: Etwas, das sich fast nie ändert (die Registry), lag im selben Zustand wie etwas, das bei jedem Test neu gebaut wird (die Instanz). Die Trennung hat nicht das Symptom behandelt, sondern die Ursache entfernt.
Eine Lösung, die funktioniert, ist nicht automatisch die richtige. Das schnelle Nachpushen beim ECR-Problem hat funktioniert. Trotzdem war es keine Lösung, sondern eine Verlagerung des Problems auf mein Glück mit der Upload-Geschwindigkeit. Der Moment, in dem mir das aufgefallen ist, war rückblickend wertvoller als der Fix selbst.
Erst verstehen, dann automatisieren. Ich hab jeden Schritt zuerst von Hand gemacht, die App manuell auf der Instanz zum Laufen gebracht, die Container-Struktur manuell getestet, und erst danach automatisiert. Das hat sich mehrfach ausgezahlt: Wenn im automatisierten Ablauf etwas schiefging, wusste ich, wie das Ergebnis aussehen sollte, und konnte den Fehler eingrenzen, statt bei null anzufangen.
Wenn eine Theorie nicht passt, weiter zerlegen statt weiter raten. Bei der Docker-Login-Geschichte hatte ich drei plausible Erklärungen, die alle falsch waren. Weitergeholfen hat erst, die Befehlskette in ihre Einzelteile zu trennen und mit demselben Token beide Varianten direkt gegeneinander zu testen. Das ist wahrscheinlich mein persönlich wichtigstes Learning aus dem Projekt, nicht ein technisches Detail, sondern eine Methode.

Leave a Reply
You must be logged in to post a comment.