Beim Thema Architektur sehe ich die grösste Notwendigkeit zum Umdenken. Uns allen wurden 3-Schichten-Modelle gelehrt und wir haben viele davon gebaut. Leider funktionieren diese in der Cloud schlecht, da wir in der Regel die Datenbanken als unlimitiert skalierbar betrachten, was selbst in Zeit von Cosmos-DB nicht der Fall ist. Wir betrachten VMs als etwas statisches, das nicht ausfällt. Wir koppen unsere Services aneinander oder versuchen weiterhin Monolithen zu entwickeln, wie wir dies taten, als wir noch On-Premise Software entwickelten. Wenn man sich in der Cloud-Native Community etwas umtreibt hört man so oft Berichte von Teams, welche Ihre neue Software Online schalteten und einige wenige Requests bearbeiten konnten bevor ihr System versagte. Komplette Fehlstarts. Wir können von Ihnen lernen, dass uns so etwas nicht erste geschehen muss.
Dazu möchte ich auf ein paar Punkte detailliert eingehen:
- Orchestration: Warum wir es uns in der Cloud nicht leisten können Hand anzulegen!
- Lose Kopplung: Sollte uns allen schon gut bekannt sein – aber in der Cloud bekommt Kopplung eine neue Dimension!
- Asynchronität: Wie wir tausende Requests pro Sekunde überhaupt erst bewältigen können!
- Messaging: Warum Messaging und überhaupt erst lose Kopplung und Asynchronität ermöglicht
Orchestration
Wie betreiben Firmen wie Google ihre Software? Wie liefert Netflix hunderttausende Videostunden täglich aus? Wie stellt Signal Milliarden verschlüsselte Messages jeden Tag zu? Es lohnt sich abzuschauen! Auch wenn wir höchstwarscheinlich nie Software in diesem Ausmass betreiben werden, können wir doch so einiges von diesen Firmen lernen, welche glücklicherweise nicht damit geizen, ihr Wissen zu teilen. Die wertvollste Umstellung im Denken eines Softwareentwicklers ist wohl jenes, dass er sich beginnt zu überlegen: „Bin ich der Erste auf der Welt, der dieses Problem hat?“. Meine Erfahrung mit mir selbst sagt: zu 90% ist die Antwort: Nein! So schwer es uns fällt – wir sind keine Unicorns, keine Dropboxes, Googles, oder WhatsApps, welche zuerst Werkzeuge bauen mussten um erreichen zu können was Sie heute haben! Dasselbe trifft auch darauf zu wie wir Applikationen betrieben. Meine Applikation entsprechend des aktuellen Loads hoch bzw. hinunter skalieren? Traffic über hunderte Instanzen verteilen, welche dynamisch gestartet oder gestoppt werden?
Genau das tun Orchestrationstools wie Kubernetes für uns. Gebaut von einigen Entwicklern bei Google. Ihre Erfahrung damit mehrere Milliarden Container jeden Tag zu betreiben – unbezahlbar und destiliert in ein solides, modulares, vielseitiges Produkt. Es eignet sich zum Betrieb eine PHP-MySQL-Apache-Webhostings genau so gut wie für den Betrieb einer Serverless-Applikation. Kubernetes wird in dieser Blog-Serie oft auftauchen. Dies aus verschiedenen Gründen:
- Es ist unsere Orchestrationsplattform für alles von Ingress-Webservern (Nginx) bis zu einem Teil unserer Applikation, welche ein Serverless-Pattern einsetzt und C# Code als Skripts ausführt.
- Es betreibt unsere Infrastruktur auch wenn wir schlafen – unsere Metriken und Health-Checks stellen sicher, dass keinem Teammember durch unser System der Schlaf geraubt wird!
- Kubernetes wird breit als der Kernel der Cloud bezeichnet. Die Adoptionsrate ist weltweit steil steigend, die Community hilfsbereit uns ausgesprochen innovativ.
- Die Features und die Modularität, welche Kubernetes out-of-the-box mitbringt sind praktisch und ausgereift.
- Spart und Zeit und Nerven
Natürlich gibt es andere Orchestrationsplattformen wie Docker-Swarm – doch es spricht für sich, dass selbst der Hersteller eines Konkurrenzproduktes nun auf Kubernetes als Basis für ihr gehostetes Enterprise-Angebot umgestiegen ist!
Lose-Kopplung
Während Kopplung bei On-Premise-Applikationen und Monolithen die Wartung und Weiterentwicklung vor allem mühsam macht, ist höchstwahrscheinlich sie bei Cloud-Applikationen tödlich. Neu erkannt haben wir, dass Kopplung nicht nur im Code entsteht und sehr oft an Orten entsteht, wo man Sie eigentlich nicht erwartet. Offensichtlich ist für uns Entwickler Kopplung wenn wir Klassen betrachten, welche Funktionalität anderer Klassen benutzt welche nicht über Schnittstellen getrennt sind (zugegeben ein einfaches Beispiel – aber warum muss auch alles immer komplex sein…).
Was wir aber gerne tun und oft auch als Best-Practise anschauen ist, DTOs über mehrere destilliert Schichten hinweg zu verwenden! Damit koppeln wir alle Schichten an dieselben DTOs und damit an dieselbe Sicht auf unser Domänenmodell. Dies ist ebenfalls Kopplung und eine ganz besonders hässliche, da Sie sich nicht mit einfachen Refactoring-Operationen beheben lässt!
„DTOs über mehrere Schichten zu verwenden ist keine Best-Practise es ist Faulheit und erhöht die Kopplung der Schichten!“
Ein weiteres Beispiel sind sogenannte Common-Bibliotheken. Wir fassen oft gemeinsam genutzte Funktionalität einfach zusammen in eigene Bibliotheken und versuchen damit Kopplung zu reduzieren. Dieser Ansatz ist absolut lobenswert und in Monolithen auch korrekt eingesetzt! Bei Hochverteilten Cloud-Native Applikationen wird er jedoch gefährlich da wir oft vergessen, diese Common Bibliotheken auch als eigenständige Komponenten unserer Gesamtsystems zu betrachten. Das heisst, wir müssten Sie sauber versionieren, Aberwärtskompatibilität gewähleisten und auch diese Bibliotheken nicht aneinanderkoppeln. All diese Dinge gehen verloren und erhöhen dabei die Kopplung des Gesamtsystems. Die wunderbarste Microservice-Architektur nützt nichts, wenn ein Refactoring in einer Common-Bibliothek zu einem Refactoring des Gesamtsystems führt!
Ein letztes Beispiel sind sogenannte kanonische Datenmodelle. Dabei handelt es sich um all jene ER-Modelle welche wir seit Jahrzehnten entwickeln. Wir modellieren dabei die Informationen unserer Domäne in ein ER-Modell, normalisieren diese Information. Daraus resultieren – konsequent umgesetzt – für einfache Modelle mehrere oft dutzende und grosse Applikationen auch hunderte Tabellen. Die schlichte Anzahl Tabellen ist dabei nicht das Problem – moderne Datenbanksysteme interessiert die schlechte Anzahl Datenbanken nicht. Was sie jedoch interessiert ist die Komplexität der Abfragen!
Jedes System teilt sich irgendwann in eigene kleinen Domänen auf oder erhält bald weitere Verwendungszwecke welche ursprünglich nicht in das Datenmodell eingearbeitet wurden. Dabei entstehen unterschiedliche Sichten auf das Datenmodell der Applikation. Diese Sichten können oft mit Strukturen wie Views aus dem kanonischen Modell erstellt werden oder werden dann über komplexe Datenbankabfragen aggregiert. Geschrieben werden müssen die Daten dann aber wieder im kanonischen Modell, was den Nutzen von View auf Lesende Zugriffe limitiert. Die Folge: Ein heilloses Durcheinander von Views, komplexe langsame Datenbankabfragen und die Kopplung aller Services an ein einziges kanonisches Datenmodell. Ändert sich das Datenmodell sind alle damit verbunden Services davon betroffen und müssen zwingend zeitgleich angepasst werden, was fast zwingend mit einer gewissen Downtime des gesamten Systems verbunden ist. Eine denkbar schlechte Ausgangslage für Cloud-Native Apps, welche nie offline sein dürfen…
„Kanonische Datenmodelle nehmen uns Flexibilität in der Gestaltung unserer Applikationen und erhöhen die Kopplung unserer Systeme und bremsen sie dann noch aus!“
Kopplung tötet Systeme und ist schneller passiert als erwartet. Sie zu vermeiden ist ein ständiger Kampf, welcher stark davon geprägt ist uns selbst und unsere Weltbilder im Bezug auf Software-Entwicklung und Best-Practices ständig zu hinterfragen. Dazu gehören kanonische Datenmodelle. Die Alternative ist die Denormalisierung der Daten in unterschiedliche Darstellungen oder gar Datenbanktechnologien – was immer der Bedarf der verwendenden Applikation gerecht wird.
Akzeptiere Asynchronität
Ich kann mich gut an die Zeit erinnern in welchen ich nur synchronen Code schrieb. Ein Prozess, ein Thread, ein Stack. Ich kann mich auch noch gut daran erinnern, wie ich begann nebenläufigen Code zu schreiben. Thread erstellen, Funktionszeiger übergeben, Thread starten – dann, wie terminieren? Was, wenn sich der Thread aufhängt…? Wie bekommen ich diese Fehler da wieder raus? Soll ich einfach einen Retry versuchen? Habe ich alle Ressourcen wieder freigegeben? Alle Files wieder geschlossen?
Unsere Hardware könnte mehr
Hier ist das Ding: Moderne Prozessoren können selbst auf einem Ultrabook bis zu 12 Threads parallel verarbeiten. Die meiste Zeit davon machen ausser einem unsere 6 CPUs nichts – unser Code ist nicht oder nur beschränkt multi-threaded. Schade, denn der Nutzer wartet vor dem Bildschirm – seine Maschine aber schläft 90% der Zeit. Schade, denn alle modernen Sprachen bieten uns (meist geniale) High-Level-Sprachfeatures an, mit denen wir unseren Code sehr schnell uns einfach parallelisieren können. In der .Net Welt gibt es seit einigen Jahren „async & await“. Dasselbe Konstrukt gibt es auch in den neuen JavaScript Standards. In Go gibt es Routinen und Channels. In Java gibt es RX Extensions.
Effizienz = Bares
Ein weiterer Grund unseren Code zu parallelisieren: Geld! Bisher mussten wir uns um die Effizient unserer Applikationen nicht kümmern, stand doch die Maschine beim Kunden, war es doch seine Zeit die er mit Warten verschwendete! In der Cloud sieht das anders aus! Natürlich bezahlen wir in der Cloud nur was wir gebrauchen. Starten wir also 100 Maschinen für einen Applikationscluster – bezahlen wir jede dieser Maschinen im Minutentakt. Sie laufen, wir bezahlen! Wenn unserer Cloud-Native-Applikationen unsere CPUs zwar belegen, diese aber nicht ausnutzen bezahlen wir genau gleich viel. Nicht parallelisierter Code ist ineffizient. Selbst wenn ich eine sehr grosszügige Zahl bezüglich der Idle-Ressource-Usage von 30-60% annähme (in der Regeln werden CPUs wesentlich weniger ausgelastet!) würde das bedeuten, dann ich es als Entwickler in der Hand habe bares Geld im Betrieb zu starten, wenn ich meine Applikationen effizient schreibe!
„Höhere Asynchronität = Höhere Effizient = Tiefere Kosten“
Messaging als Grundlage
Wir sind es uns bisher gewohnt, mit unseren serverseitigen Applikationen synchron zu interagieren. Wir senden einen Befehl an einen Service und erwarten, dass zum Zeitpunkt an welchem wir die Antwort erhalten, serverseitig alle Daten konsistent in unserem Datenbankmodell gespeichert sind. In der Praxis ist es jedoch so, dass je mehr wir unsere Systeme in einander verweben, das simple Erstellen eines Benutzers bedingt, dass der entsprechende Benutzer oder seine Informationen in diversen Systeme ebenfalls erstellt werden müssen.
Das ist jedoch kein Problem: Schliesslich haben wir alle das Facade-Pattern gelernt! Wir implementieren also eine Fassade vor unseren Benutzer-Service, welche auch gleich noch im Billing-System den neu erstellten Benutzer mit seiner Lizenz einträgt. Wir brauchen diese Information um am Ende des Monats dem Kunden die Rechnung stellen zu können. Oh, und wenn wir gerade dabei sind, brauchen wir für jeden User noch eine Mailbox und eventuell auch noch einen Ordner auf einen File-Share. Wenn Sie Entwickler sind, wissen Sie wovon ich spreche! Unser Facade-Pattern tut seinen Dienst aber immer noch wunderbar – nichts Falsches daran!
Bis das File-Share voll ist. Das CRM für ein Update offline ist. Der Mailserver ein Update erhält. Unsere Applikation steht still. Der Request einen Nutzer zu beantworten dauert ewig. Im besten Fall kommt ein Timeout und ein übler Fehler (Exception Handling ist kritisch – war es aber schon immer, es hat uns nur nicht wirklich Interessiert wenn der Nutzer seine gecrashte Applikation neu starten musste!). Unsere Applikation steht still, unsere Supportchannels laufen heisst! Der Entwickler mitten in der Nacht aus dem Bett geholt. Ist das nötig? gehört das einfach zum Job? NEIN! Um aber noch fair zu sein mit unseren Arbeitsgebern – wir sind daran oft selber schuld! Die Probleme welche durch solch statische und synchrone Integrationen mit Umsystemen entstehen wären offensichtlich gewesen hätten wir uns Gedanken darüber gemacht! Eine gute Faustregel ist, nie davon auszugehen, dass eine andere Komponente entweder schnell genug, korrekt oder überhaupt antwortet! Auch Domänenkontroller können ausfallen oder gehen geplant offline für Updates – unsere Systeme müssen damit umgehen können. Wir dürfen auch nicht davon ausgehen, dass unsere IT-Abteilung uns benachrichtigt, dass das Mailserver für ein Update offline geht!
Async Messages to the rescure
Es gibt jedoch Abhilfe: asynchrones Messaging. Wir entkoppeln damit die Interaktion mit Drittsystemen und unseren internen Applikationskomponenten vom Anspruch unmittelbarer Konsistenz aber auch von den Fehler welche durch Sie bedingt sein können unser eigenes System jedoch nicht funktional beeinträchtigen würden. Das heisst: Wird ein Benutzer im Frontend erstellt, macht unsere Fassade keine synchronen Aufrufe in interne Komponenten und Drittsysteme, sondern sendet eine oder mehrere Messages auf ein Messaging-System, welche Nachrichten queuen, persistieren und zustellen kann. Adapter-Code für Drittsysteme bedienen sich nun an diesen Messages.
Schlägt die Verarbeitung einer Message fehl, kann diese zum Beispiel requeued werden oder in einer Dead-Letter-Box entsorgt werden! Achtung: Dead-Letter-Boxen sind keine Papierkörbe! Schlagen Nachrichten fehl gibt es dafür einen guten Grund, welcher analysiert werden, Fehler die möglicherweise geflickt werden müssen, bevor die Nachricht wieder gequeued werden darf. Alle Nachrichten müssen grundsätzlich erfolgreich verarbeitet werden. Das kann auch bedeuten eine gegebe Nachricht bewusst zu ignorieren, wenn dies zulässig ist! Verarbeiten wir nicht alle Nachrichten, nimmt das System einen inkonsistenten Stand ein. Sind Informationen in einer Komponente vorhanden in der anderen jedoch nicht, kann dies zu schwerwiegenden Problemen führen! Dieser Architekturansatz ist eventuell Konsistent. Dies bedeutet, dass die Komponenten unserer Systems so lange inkonsistent sein können bis alle Nachrichten erfolgreich verarbeitet wurden.
So können wir viele Probleme welche mit Umsystemen oder Komponenten in unserer eigenen Umgebung bedingt wurden ausschalten. Eventuelle Konsistenz ist ein eher neues Architekturmuster und besonders für eingefleischte Datenbank-Entwickler ein rotes Tuch.
Oft wird mir in diesem Zusammenhang als Gegenargument ein Anwendungsfall einer Bank geschildert um mir zu beweisen, dass Synchrone Transaktionen für gewisse Systeme unabkömmlich sind. Dies geht etwa so:
„Wenn meine Applikation ein Kontenübertrag vornimmt, müssen Beträge von einem Konto abgebucht und auf das andere Konto verschoben werden. Dafür braucht es eine Transaktion, denn würde die Abbuchung auf dem Schuldnerkonto oder dann die Zubuchung auf dem Empfänger Konto nicht funktionieren, darf keine der beiden Operationen ausgeführt werden!“
Was für ein – entschuldigen Sie meine Wortwahl – Müll! Solche Aussagen können nur einem Schulbuch gekoppelt mit dem eigenen verengten Denken entspringen! Jeder, der weiss wie die digitale Buchführung einer Bank funktioniert, weiss, dass alle Zahlungen asynchron ausgeführt werden – nur schon, weil die Systeme unterschiedlicher Banken keine verteilten Transaktionen ermöglichen! Somit würde das Argument höchstens für systeminterne Überweisungen halten – doch auch dort funktioniert nichts so! Früher waren alle Zahlungen asynchron: Ein Mensch buchte auf einem Konto den geschuldeten Betrag ab, nahm sich das Zielkonto vor und buchte den Betrag darauf. Wäre das Zielkonto nun gesperrt würde eine sogenannte Korrekturbuchung auf dem Schuldner-Konto vorgenommen, in der exakt der zuvor abgebuchte Betrag wieder rückvergütet wird. Obwohl dies heute alles digital läuft – ist das Prinzip bis heute im Einsatz und wir sollten davon etwas lernen!
„Wenn es selbst im Umgang mit so etwas heiklem wie Geld keine Transaktionen braucht – wo ist denn sofortigen Konsistenz noch unabdingbar?“
Ich bin mir sicher, ich kratze noch nicht einmal an der Oberfläche dessen, was in Cloud-Native-Apps in Bezug auf deren Architektur gesagt sein sollte/könnte! Ich hoffe jedoch, dass Sie etwas ins Nachdenken gekommen sind und sich überlegen ob Sie das eine oder andere Konzept einsetzen könnten!
Dieser Artikel ist Teil der Cloud-Native Serie: