Vor zwei Jahren haben wir angefangen, ein Kundenprodukt Cloud-Native auf Basis von Serverless, Java und AWS Managed Services umzusetzen. Im Folgenden möchte ich beschreiben, was wir in dieser Zeit gemeinsam gelernt haben und was wir heute besser machen würden.
Der Blogpost ist in verschiedene Sektionen unterteilt. Wir stellen am Anfang unser generelles AWS Setup vor, gehen dann auf unsere Infrastructure-as-code-Lösungen ein und beschreiben ein Integration Pattern, das wir nutzen. Final betrachten wir die Probleme und Lösungen, die den gesamten Entwicklungs-Workflow betreffen, und wie wir unseren Code testen.
tldr;
Best Practices mit Serverless Java:
- Für einen direkten Feedbackloop erstelle Releases ab Tag eins (Easy CI/CD).
- Monitoring-Infrastruktur, Skalierbarkeit und Verfügbarkeit kommen out of the box.
- User-Management mit Cognito geht einfach und schnell.
- Mehrere Environments löst ihr mit verschiedenen AWS Accounts.
- Infrastruktur und Funktionen immer als Code beschreiben.
- Ressourcen in unterschiedliche CloudFormation Stacks ablegen (database, Cognito, …).
- Für die Integration nutze Eventing mit dem Fan-Out Pattern.
- Serverless Framework:
- Nur die Funktionen – nicht alles mit dem Serverless Framework ergibt eine losere Kopplung und einfachere Wartbarkeit.
- Unterschiedliche fachliche Domains gehören in unterschiedliche Stacks.
Lessons learned bei Serverless Java:
- Serverless mit Java funktioniert (Kaltstarts sind einfach zu beheben).
- Die finale JAR nicht zu groß werden lassen – keine schwergewichtigen Frameworks verwenden.
- Mehr Security erhöht meistens die Kopplung.
- Das nachträgliche Aufbauen einer lokalen Entwicklungsumgebung ist schwierig.
- Das Debugging ist daher nicht einfach.
- Integration-Test sind nicht einfach, aber auch nicht empfohlen mit einer Serverless-Architektur (Der Integration Test findet statt, wenn die Funktion deployed ist.)
- Unit-Test sollten so viele wie möglich geschrieben werden.
- Ende-zu-Ende-Tests, um Use-Cases testen zu können.
Bevor wir loslegen, zu der Frage, die wir am häufigsten gestellt bekommen, wenn wir von unserem Projekt erzählen: Warum schreibt ihr denn serverless functions in Java?
Die Antwort: Weil unser Team dem Kunden schnellstmöglich Ergebnisse präsentieren will, ohne sich in eine neue Programmiersprache wie bspw. Go einzuarbeiten. Wir wissen, wie gutes Design und Struktur in Java-Projekten aussehen und kennen das Java-Ökosystem. Es reicht aus, wenn sich das Team in die APIs von Cognito, DynamoDB, Elastic, S3, SNS, SQS sowie das Serverless-Framework und CloudFormation-Templates einarbeiten darf. Des Weiteren hat Java bis heute eine sehr große Reichweite bei Backend-Entwicklern, so dass auch im Nachgang, sollten wir einmal nicht mehr das Projekt betreuen, Entwickler auf dem Markt zu finden sind, die das Projekt weiterführen können. Die Schwachstelle der Cold-Starts (start der Ausführungsumgebung – Starten der VM und der Docker Container je Funktion) haben wir mit dem Serverless-Warm-up-Plug-in behoben, dies kann auch direkt durch das AWS Lambda Feature “Provisioned Concurrency” gelöst werden.
Generelles Projekt-Setup
Für das gesamte Projekt nutzen wir ausschließlich AWS Managed Services und somit einen Cloud-Native-Ansatz. Wir profitieren von Monitoring-Infrastruktur, Skalierbarkeit, Verfügbarkeit und CI/CD-Infrastruktur out of the box. Ziel war es, sich von Tag eins auf die fachliche Logik zu konzentrieren, somit einen Mehrwert zu liefern und die Software direkt deployen zu können. Um dies zu ermöglichen, wird ein ordentliches Staging (dev, int, prod) benötigt. In AWS empfiehlt es sich, pro Stage einen eigenen Account anzulegen, siehe dazu Abbildung 1. Dementsprechend haben wir auch unsere git branches aufgebaut. Der master-Branch entspricht der Development-Stage, der int-Branch der Integrations-Stage und der release-Branch der Produktion-Stage. Wobei jeder der Branches mit einem eigenen Account verknüpft ist und somit auch in dessen Repository pusht. Die dahinterliegenden CI/CD-Pipelines sind dann ebenso Account eindeutig wie die verwendete Infrastruktur von bspw. DynamoDB, Elastic, API-Gateways und Lambdas.
Abbildung 1: Drei AWS Accounts mit jeweils eigenem Git-Repositrory
Wenn ihr Fragen zum Aufsetzen mehrerer AWS Accounts mit einer Git-BranchingStrategie habt, dann meldet euch bei uns. Die Details würden jedoch den Rahmen dieses Blogposts sprengen.
Infrastructure-as-code
Auch heute verpassen es viele Projekte, ihre Infrastruktur als Code zu beschreiben. Dies führt auch in AWS dazu, dass eine Entscheidung, bspw. die AWS-Region zu wechseln, zu mehrwöchiger Anpassung und Arbeit ausartet, da das Wissen über das initiale Aufsetzen der Infrastruktur mit der Zeit verloren gegangen ist. Daher haben wir uns von Anfang an für das konsequente Beschreiben von Infrastruktur als Code entschieden.
Jede einzelne unserer Lambdas, die auf AWS betrieben wird, benötigt eine ganze Reihe von Konfigurationsschritten. Um diese Schritte zu beschreiben, verwenden wir CloudFormation und das Serverless-Framework. Wir haben CloudFormation erst mit der Zeit dazugenommen, da wir gelernt haben: „Je größer das Projekt, desto sinnvoller ist die Trennung von Funktionen und Infrastruktur.“ Dies erhöht die Wartbarkeit, ermöglicht eine losere Kopplung und steigert insgesamt die Geschwindigkeit bei größeren Änderungen an der Infrastruktur. Sowohl die Ressourcen in CloudFormation als auch in Serverless haben wir in unterschiedliche Stacks für die verschiedenen Domains ausgelagert. Ein Stack ist dabei eine Sammlung von AWS-Ressourcen. Pro Stack gibt es ein Ressourcen-Limit, das fest bei 200 Ressourcen je Stack liegt. Solltet ihr an Ressourcen-Limits kommen, so hilft uns bei Serverless das Plug-in serverless-plugin-split-stacks. Damit ist es möglich, die Ressourcen in kleinere Stacks zu splitten, je nachdem per Funktion oder je Type. Für Details schaut euch die dazugehörige GitHub-Seite an. Die Verzahnung der einzelnen Stacks beschäftigt uns bis heute immer wieder. Vor einiger Zeit haben wir uns dazu entschieden, die einzelnen Ressourcen (bspw. DynamoDB-Tabellen, Queues aus SQS) besser zu schützen. Dazu haben wir den einzelnen Lambdas detaillierte Rechte auf Ressourcen zugewiesen. Leider mussten wir feststellen, dass wir dadurch eine größere Kopplung der einzelnen Stacks erzeugen. Am Beispiel: Bisher durfte eine Funktion aus Stack A auf alle Tabellen aus DynamoDB zugreifen. Nach der Änderung wird detailliert aufgelistet, auf welche Tabellen die Funktion zugreifen darf. Wenn diese Tabelle jetzt aber aus einem anderen Stack kommt, muss sie dort erst einmal zur Verfügung gestellt werden. Dies führt insgesamt zu einer höheren Koppelung. AWS-Infrastruktur-Stacks, mit denen wir die verschiedenen Geschäftsbereiche trennen, und deren benötigte Ressourcen (bspw. Datenbanktabellen) sind ein wiederkehrendes Thema. Das erscheint uns aber auch gut, da wir uns wiederkehrend mit der Struktur unsere Anwendung im Großen auseinandersetzen und kleinere wie größere Verbesserungen auf den Weg bringen. Unser nächstes Ziel in diesem Bereich ist es, uns anzuschauen wie wir die Abhängigkeiten ggf. durch einen verteilten Key Value Store besser auflösen können, so dass die einzelnen AWS-Stacks sich nicht beim Deployment im Weg stehen.
Integration-Pattern
Unser Team entwickelt in diesem Projekt ein „Backend for Frontend“, das mit einem weiteren Backend Daten austauscht. In Abbildung 2 ist die gesamte Architektur mit den verschiedenen AWS-Services zu sehen, die wir für die bisherigen Use Cases einsetzen. Habt ihr dazu Fragen, nutzt die Kommentarfunktion oder meldet euch direkt bei uns, ihr wisst schon – zu viel für diesen Blogpost.
Vom Start weg war uns klar, dass wir die Komposition eines größeren Prozesses möglich machen müssen. Das Ganze jedoch unter dem Gesichtspunkt, dass eine Lambda genau eine Aufgabe übernehmen soll. Nach etwas Recherche haben wir uns für das Integration Pattern „Fan-Out“ entschieden, siehe Abbildung 3. AWS SNS (Simple Notification Service) übernimmt den Publish-Subscribe-Part, und AWS SQS (Simple Queue Service) vor den einzelnen Lambdas sorgt dafür, dass in Fehlerfällen ein Retry möglich ist. Wenn ein Service über mehrer Versuche hinweg keinen Erfolg bringt, wird die Nachricht in eine Deadletter-Queue geschrieben. Wir haben in unserem Projekt bisher keine Lastprobleme, jedoch kann SQS hier auch als eine Art Load-Balancer dienen, um Last zu verteilen.
Abbildung 2: Die aktuelle Lambda-Architektur
Wie bereits weiter oben beschrieben, ist die Integration der verschiedenen Services teilweise sehr gut und reibungslos gelöst. So bietet DynamoDB einen Stream an, über den auf Änderungen reagiert werden kann. Wir schreiben so bspw. alle Änderungen direkt in verschiedene Elasticsearch-Indizes. Auf diese greifen wir für die verschiedenen Suchanfragen aus dem Frontend zurück.
AWS spielt hier also genau die Stärken aus, die zu erwarten sind. Die Integration der einzelnen Managed Services ist sehr gut gelöst und wir müssen nur sehr wenig Zeit in die Integration investieren.
Abbildung 3: Das Fan-Out-Pattern
Entwicklungs-Workflow
Die größte Änderung für uns als Entwickler ist sicherlich der Entwicklungs-Workflow. Dieser gestaltet sich mit Serverless und AWS etwas anders als gewohnt. Zunächst haben wir es in unserem Projekt verpasst, von Beginn an eine lokale Entwicklungsumgebung bereitzustellen. Es gibt mittlerweile Projekte wie LocalStack , die es einem Entwickler ermöglichen, viele, bei Weitem nicht alle, Cloud-Services auch lokal laufen zu lassen. Leider bietet LocalStack in der freien Version keinen Cognito Service. Da wir aber einen Cognito User Pool benutzen, um die einzelnen APIs vor unautorisierten Zugriffen zu schützen, müssten wir entweder die Pro-Version von LocalStack nutzen oder weiterhin lokal entwickeln und auf der dev-Stage testen. Dies führt zwar hin und wieder zu Wartezeiten, bis der einzelne Stack erneuert wurde, führt aber auch zu einer besseren Absprache zwischen Frontend- und Backend-Entwicklern. Wenn wir das Projekt nochmals frisch aufsetzen könnten, würden wir mit einer Mischung aus LocalStack und TestContainers versuchen, eine lokale Testumgebung bereitzustellen und den Cognito Service ggf. zu mocken. Da wir also (bisher) keine lokale Umgebung haben, sieht unser Debugging auch dementsprechend aus. Wir loggen und betrachten die Ergebnisse in Cloudwatch. Insgesamt ist das Tooling rund um Serverless und Lambdas erstaunlich gewachsen, mit deutlichen Vorteilen für die leichtgewichtigen Laufzeiten wie bspw. Node.js. Wir sind gespannt, was die Zukunft mit native images in Java durch bspw. Graal-VM bietet. Mehr zum Thema GraalVM hat unser Kollege Timo in einem Blogpost beschrieben.
Unsere gewählte Projektstruktur sieht wie folgt aus: Die einzelnen Geschäftsbereiche sind durch einzelne Module voneinander getrennt. Gemeinsam nutzen alle ein common-Modul, das bspw. die Implementierungen für alle Services in Richtung AWS bereitstellt. Grundsätzlich gilt es darauf zu achten, dass die eingesetzten Dependencies die finale JAR nicht zu groß werden lassen. Unsere finale JAR ist bspw. 35 MB groß, davon entfällt ein Großteil auf den Elasticsearch-Client. Dies lässt sich mit Lambda Layers lösen, indem die benötigten Dependencies in ein eigenes bspw. Maven-Modul geschoben werden, dieses als Lambda Layer deployed und im eigentlichen Modul beim Packen des Jars nicht berücksichtigt wird.
Testing
Testen – so einfach und doch immer wieder so kompliziert. Nachdem wir zunächst ausschließlich mit Unit Tests gearbeitet und die Integration durch das Deployment getestet haben, kam durch mehr Komplexität immer deutlicher der Wunsch nach fachlichen Use-Case-Tests auf. Das Testen der nacheinander aufgerufenen Endpunkte im Backend wäre eine Möglichkeit, der Einsatz eines Frontend-End-to-End-Testframeworks eine andere. Wir haben uns für die End-to-End-Tests aus dem Frontend und für Cypress entschieden (mehr zum Thema Cypress im Blogpost unseres Kollegen Jonas). Damit haben wir nach und nach unsere wichtigsten Use-Cases als Code beschrieben. Diese Tests lassen wir nun in der Code-Pipeline mitlaufen, um direktes Feedback zu bekommen. Das Feedback zeigt uns, ob unsere neue Implementierung einen bestehenden Use Case in Fehler laufen lässt. Damit fahren wir aktuell sehr gut und was am wichtigsten ist: Wir haben alle Spaß an der Entwicklung.
FAZIT
In diesem Blogpost haben wir gezeigt: Das Entwickeln von Cloud-Native-Anwendungen mit Java ist nicht nur möglich, sondern ein sehr gangbarer Weg. Die Entwickler-Community ist groß, die Werkzeuge weitläufig bekannt und so kann sich das Team von Tag eins um das kümmern, womit auch für die Firma der größte Mehrwert entsteht: das Umsetzen von fachlichen Anforderungen in Code, der schnellstmöglich produktiv genommen werden kann. Der Post zeigt, wie wir unser Projekt in AWS aufgesetzt haben und dass Infrastruktur-als-Code heutzutage nicht mehr wegzudenken ist. Integration mit dem Fan-out-Pattern sorgt für eine Entkopplung der Fachlichkeit in den einzelnen Lambdas wobei AWS bei der Integration verschiedener Managed Services seine Stärken ausspielt. Integration von Services und Systemen, die bei vielen Projekten sehr viel Zeit in Anspruch nimmt, ist hier gefühlt einfacher und geht fließend von der Hand. Dies beschleunigt die Entwicklung von Features um ein Vielfaches und spart somit Zeit und Geld. Und das wichtigste: Es macht Spaß, eine Cloud-Native-Anwendung serverless mit Java und AWS zu entwickeln.
Weitere Beiträge
von Felix Massem
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Felix Massem
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.