Durch die Cloud verändert sich die Art und Weise, wie Softwarelösungen entwickelt, entworfen und betrieben werden. Der Fokus liegt auf der Entwicklung kleiner und unabhängiger Services. Die Kommunikation zwischen den Services geschieht über definierte Schnittstellen. Diese können sowohl synchron (z. B. Request/Response) als auch asynchron (z. B. Events oder Commands) sein. Man kann sie je nach Auslastung einzeln skalieren. Damit Cloud-Anwendungen trotz verteilter Zustände ordnungsgemäß funktionieren, sollten bei der Planung die Stabilität und die Resilienz berücksichtigt werden. Zusätzlich ist die Überwachung der Telemetriedaten wichtig, um einen Einblick in das System zu erhalten, falls Anomalien im Anwendungsprozess auftreten.
Die ersten Überlegungen
Bevor die Entwicklung von Cloud-Lösungen starten kann, sollte die grundlegende Architektur der Anwendung erörtert und entworfen werden. In den folgenden Abschnitten schauen wir uns die bekanntesten Architekturstile an und erläutern die Vor- und Nachteile ihrer Verwendung.
N-Schichten-Architektur
Der wohl bekannteste Stil ist die sogenannte N-Schichten-Architektur. Bei der Entwicklung monolithischer Anwendungen kommt in den meisten Fällen dieses Prinzip zum Einsatz. Beim Entwurf der Lösung werden logische Funktionen in Schichten unterteilt, so wie in Abbildung 1 beispielhaft verdeutlicht ist. Die konventionellen Schichten (Präsentationsschicht/UI, Business Layer, Data Layer) bauen aufeinander auf, wodurch die Kommunikation nur von einer übergeordneten zu einer untergeordneten Schicht stattfindet. Die Ebene Business Layer beispielsweise kennt also keine Details des UI. Eine Kommunikation zwischen diesen Schichten geht daher immer vom UI aus.
Im Kontext der Cloud-Entwicklung können die Schichten auf eigenen Instanzen gehostet werden und über Schnittstellen miteinander kommunizieren. In der Regel werden N-Schichten-Architekturen verwendet, wenn die Anwendung als Infrastructure-as-a-Service-(IaaS-)Lösung implementiert wird. Dabei wird jede Schicht auf einer eigenen virtuellen Maschine gehostet. In der Cloud bietet es sich an, zusätzlich verwaltete Dienste, wie etwa ein Content Delivery Network, Load Balancer, Caching oder Entwurfsmuster wie den Circuit Breaker zu verwenden.
Die Stärken der N-Schichten-Architektur liegen in der einfachen Ausführung auf lokalen Systemen sowie in der Cloud. Auch bei der Entwicklung einfacher Webanwendungen bietet sich dieses Verfahren an. Sobald die Geschäftsfunktionen einer Anwendung komplexer werden oder die Skalierung einzelner Geschäftsfunktionen im Vordergrund steht, bieten sich Architekturen an, die im Gegensatz zu dieser nicht monolithisch sind. Bei diesen Prinzipien können die einzelnen fachlichen Anforderungen getrennt gehostet und skaliert werden.
Microservices-Architektur
Dieses Verfahren ist, wie bereits angeschnitten, nützlich, wenn die Komplexität einer Anwendung steigt [2]. Denn hiermit kann man einzelne Domänen (Geschäftsfunktionen) separat entwickeln. Jeder Microservice kann individuell kreiert werden und muss lediglich abgestimmte Schnittstellen implementieren (Abb. 2). Beispielsweise kann Microservice 1 mit .NET und Microservice 2 mit Node.js entwickelt werden. Dabei sollten die verwendeten Technologien so gewählt werden, dass die fachlichen Anforderungen leicht erfüllt werden können. Die Skalierung kann für jeden Microservice einzeln erfolgen, da die Microservices lose gekoppelt sind. Somit muss der Prozess nicht auf die gesamte Anwendung angewendet werden, wenn nur einzelne Domänen hohen Traffic aufweisen. Die deutlich kleinere Codebasis lässt sich mit Unit-Tests einfacher automatisiert prüfen und kann im Anschluss leichter refaktoriert werden, wodurch wiederum die Codequalität gesteigert werden kann.
Der größte Nachteil einer Microservices-Architektur ist das Sicherstellen der Kommunikation zwischen den einzelnen Diensten.
Event-driven-Architektur/Serverless Computing
Eine ereignisgesteuerte Architektur verwendet sogenannte Events, um zwischen entkoppelten Diensten zu kommunizieren. Diese Verfahren haben drei Schlüsselkomponenten bezüglich der Ereignisse: Produzenten, Router und Verbraucher. Ein Produzent veröffentlicht Events an den Router, der diese filtert und an die Verbraucher weiterleitet. Produzenten- und Verbraucherdienste sind voneinander entkoppelt, wodurch sie unabhängig voneinander skaliert, aktualisiert und eingesetzt werden können. Dieses Prinzip kann dann auch in eine Microservices-Architektur oder einen Monolithen integriert werden, wodurch sich die Architekturmodelle ergänzen.
Eine ereignisgesteuerte Architektur kann auf einem Pub/Sub-Modell oder einem Event-Notification-Modell aufsetzen. Bei Ersterem handelt es sich um eine Messaging-Infrastruktur, bei der Ereignisströme abonniert werden. Wenn ein Ereignis auftritt oder veröffentlicht wird, wird es an die jeweiligen Abonnenten/Konsumenten gesendet (Abb. 3).
Beim Event-Notification-Modell werden Ereignisse in eine Queue geschrieben, vom ersten Event Handler verarbeitet und aus der Warteschlange entfernt. Das Verhalten ist dann erwünscht, wenn Events nur einmal verarbeitet werden dürfen. Sollte es bei der Verarbeitung zu einem Fehler kommen, wird das Ereignis zurück in die Queue geschrieben. In der einfachsten Variante löst ein Event unmittelbar im Konsumenten eine Aktion aus. Das kann beispielsweise eine Azure Function sein, die einen Queue Trigger implementiert. Ein Vorteil bei der Verwendung dieses Microservice und auch der verwandten Azure Storage Queue ist, dass beide Services serverless sind. Das bedeutet, dass diese Dienste nur im Bedarfsfall ausgeführt werden und so kostenschonender sind als solche, die potenziell durchgehend zur Verfügung stehen und dadurch Kosten verursachen. Serverlose Services sind ebenfalls eine gute Wahl, wenn eine schnelle Bereitstellung benötigt wird und die Anwendung je nach Auslastung automatisch skaliert. Da Serverless-Dienste vom Cloud-Betreiber zur Verfügung gestellt werden, ist eine starke Bindung an diesen einer der größten Nachteile.
LUST AUF NOCH MEHR ARCHITEKTUR & API-TRENDS?
Entdecke Workshops vom 25. - 27. November 2024
Entwurfsprinzipien
Die nun folgenden Abschnitte erläutern Entwurfsprinzipien, die dabei helfen, Anwendungen hinsichtlich Skalierbarkeit, Resilienz und Wartbarkeit zu optimieren. Besonderes Augenmerk wird dabei auf die Entwicklung von Cloud-Anwendungen gelegt.
Selbstreparatur einer Anwendung
Bei verteilten Systemen kann es gelegentlich dazu kommen, dass Hardware ausfällt, Remotesysteme nicht zur Verfügung stehen oder Fehler im Netzwerk auftreten. In diesen Fällen ist es nützlich, wenn Anwendungen wissen, was zu tun ist und für den Nutzer weiter bedienbar bleiben. Wenn ein Remotesystem nicht zur Verfügung steht, ist im ersten Moment nicht klar, ob es sich um einen kurzfristigen oder einen längerfristigen Ausfall handelt. Abhilfe schafft in diesem Fall das Circuit-Breaker-Pattern. Je nach Konfiguration wird dieses Muster gemeinsam mit dem Retry-Pattern verwendet. In den meisten Fällen handelt es sich um kleinere Störungen, die nur kurzfristig auftreten. In solchen Fällen reicht es, wenn die Anwendung mit Hilfe des Retry-Patterns nach einer gewissen Zeitspanne den fehlgeschlagenen Aufruf wiederholt. Sollte es dann nochmal zu einem Fehler kommen und das Remotesystem damit länger ausfallen, kommt das Circuit-Breaker-Pattern zum Einsatz. Abbildung 4 zeigt den Aufbau dieses Patterns. Ein Circuit Breaker agiert als Proxy für Vorgänge, bei denen möglicherweise Fehler auftreten. Der Proxy soll die Anzahl der kürzlich aufgetretenen Fehler überwachen und entscheiden, ob der Vorgang fortgesetzt oder sofort eine Ausnahme zurückgegeben werden soll. Im geschlossenen Zustand werden Anfragen an das Remotesystem weitergeleitet. Kommt es dabei zu einem Fehler, wird der Fehlerzähler hochgezählt. Sobald der Fehlerschwellenwert überschritten wird, wird der Proxy in den Zustand „offen“ versetzt.
In diesem Status werden Anfragen direkt mit einem Fehler beantwortet und eine Ausnahme wird zurückgegeben. Sobald ein Timeout abgelaufen ist, befindet sich der Proxy im Zustand „halboffen“. In diesem Status wird eine begrenzte Anzahl Anforderungen zugelassen. Wenn diese erfolgreich sind, geht der Proxy in den geschlossenen Zustand über und lässt Anfragen wieder zu. Kommt es jedoch erneut zu einem Fehler, wird zurück nach „offen“ gewechselt. Der halboffene Zustand ermöglicht außerdem, dass empfindliche Systeme nicht mit Anfragen überschwemmt werden, nachdem sie wieder zur Verfügung stehen.
Für .NET-Entwickler empfiehlt es sich, einen genaueren Blick auf Polly [1] zu werfen. Das ist eine .NET-Bibliothek für Ausfallsicherheit und Behandlung transienter Fehler, die es Entwicklern ermöglicht, Richtlinien wie Retry, Circuit Breaker, Timeout, Bulkhead, Isolation und Fallback auf threadsichere Weise auszudrücken.
Koordination minimieren
Damit Anwendungen skaliert werden können, ist es notwendig, dass die einzelnen Dienste einer Anwendung (Frontend, Backend, Datenbank o. Ä.) auf eigenen Instanzen ausgeführt werden. Problematisch wird es, wenn zwei Instanzen gleichzeitig eine Operation ausführen wollen, die sich auf einen gemeinsamen Zustand auswirkt. Eine der beiden Instanzen wird solange gesperrt, bis die andere Instanz mit der Operation fertig ist. Je mehr Instanzen zur Verfügung stehen, desto größer wird das Problem der Kommunikation. Der Vorteil der Skalierung wird dadurch immer geringer. Ein Architekturmuster, das Operationen nur kurzfristig sperrt, ist das Event Sourcing. Hierbei werden alle Änderungen als eine Reihe von Ereignissen abgebildet und aufgezeichnet. Anders als in klassischen relationalen Datenbanken speichert man dabei nicht den aktuellen Zustand der Anwendung, sondern die einzelnen Veränderungen, die mit der Zeit zum aktuellen Zustand geführt haben. Entscheidend dabei ist, dass ausschließlich neue Einträge hinzugefügt werden dürfen. Diese Ereignisse werden im Event Store gespeichert. Er muss vor allem das schnelle Einfügen von Events unterstützen und dient als Single Source of Truth.
Ein weiteres Architekturmuster ist CQRS (Command Query Responsibility Segregation). Dabei trennt man die Anwendung in zwei Teile: einen Lese- und einen Schreib-Service. Der Vorteil besteht einerseits in der unterschiedlichen Skalierbarkeit und andererseits in der Anpassbarkeit an Businessanforderungen. Vor allem in der Kombination mit Event Sourcing kann CQRS seine Vorteile ausspielen. Zum Beispiel kann man im Schreib-Service das Event Sourcing nutzen und im Lese-Service eine optimierte Abfrage für Event-Sourcing-Einträge implementieren. Abbildung 5 zeigt, dass im einfachsten Fall ein Command einer Queue hinzugefügt und anschließend verarbeitet wird, sobald ein Command Handler verfügbar ist. Die Ereignisse im Event Store werden entsprechend ihren Änderungen in die Datenbanken überführt. Systeme, die mit diesem Muster entwickelt werden, bieten im Standard keinerlei Konsistenzgarantien (Eventual Consistency). Im Modell der Eventual Consistency verzichtet man aus Performancegründen bei Schreiboperationen darauf, Daten sofort auf alle Server beziehungsweise Partitionen zu verteilen. Stattdessen kommen Algorithmen zum Einsatz, die sicherstellen, dass nach Beendigung der Schreiboperationen die Daten konsistent sind. In der Regel werden keine Aussagen über den Zeitraum des Vorgangs getroffen.
Ausrichtung auf die Unternehmensanforderungen
Die verwendeten Entwurfsmuster müssen Geschäftsanforderungen unterstützen. Es ist wichtig zu wissen, ob tausend oder Millionen Nutzer am Tag die Anwendung nutzen. Ebenso muss man wissen, wie ausfallsicher die Anwendung sein muss. Im ersten Schritt sollten die nichtfunktionalen Anforderungen definiert werden. Im Anschluss können Entwurfsmuster analysiert und auf die nichtfunktionalen Anforderungen überprüft werden. Wenn es sich um eine komplexe Software handelt, kann Domain-driven Design (DDD) eine Möglichkeit der Softwaremodellierung darstellen. Kurz gesagt liegt beim DDD der Schwerpunkt auf der Fachlichkeit und der Fachlogik, an der sich sowohl Architektur als auch Implementierung orientieren. Ein durchaus logischer Schritt, wenn man bedenkt, dass Software fachliche Prozesse unterstützt. In Verbindung mit Domain-driven Design wird oft die Microservices-Architektur verwendet, wobei jede Fachlichkeit (Bounded Context) als ein Microservice abgebildet wird. Gemäß dem Prinzip „Do one Thing and do it well“ hat jeder Bounded Context genau eine funktionale Aufgabe. Aus technischer Sicht wird je Bounded Context ein Service implementiert, der sowohl für die Datenhaltung als auch für die Businesslogik und das User Interface verantwortlich ist. Zusätzlich hat sich im DDD die Onion-Architektur durchgesetzt. Abbildung 6 zeigt den Aufbau dieses Prinzips.
Anders als bei anderen Architekturen liegt beim Onion-Prinzip die Fachlichkeit im Zentrum. Ausschließlich die äußeren Ebenen dürfen auf die inneren Ebenen zugreifen. Das hat zur Folge, dass fachlicher Code von Anwendungscode getrennt wird. So bleibt die eher langlebige Geschäftslogik unberührt, wenn Änderungen auf der Infrastrukturebene notwendig sind. Durch die Trennung von Infrastrukturaspekten und Geschäftslogik in der Onion-Architektur wird das Domänenmodell weitestgehend frei von den oben erwähnten technischen Aspekten. Neben der einfacheren Testbarkeit der Fachlogik erlaubt das vor allem besser lesbaren fachlichen Code.
Fazit
Um das volle Potenzial der Cloud ausschöpfen zu können, sollten vor der Entwicklung einer Anwendung mindestens die nichtfunktionalen Anforderungen definiert sein. Mit Hilfe dieser Vorkenntnisse können die richtigen Entwurfsmuster und grundlegenden Architekturen gefunden und kombiniert werden. Vor allem bei verteilten Systemen sollten sich Softwareentwickler, Softwarearchitekten und Domain-Experten mehr Gedanken über das Verhalten im Fehlerfall machen.
Links & Literatur
[1] http://www.thepollyproject.org
[2] https://medium.com/brickmakers/cloud-architekturen-im-%C3%BCberblick-d7b6a366fc5d