Blog

Das Actor Model: ein Paradigma für die Cloud?

Jan 23, 2023

Vernünftige Software zu schreiben ist schwer. Vernünftige Software in verteilten Systemen zu schreiben ist noch schwerer. Wie können wir uns also die Arbeit erleichtern, wenn es viele bewegliche Ziele gibt, die Dienste mal da sind und mal nicht und das ganze System am besten auch noch immer verfügbar und performant sein soll wie in der Cloud?

Kolumne: EnterpriseTales

Vernünftige Software zu schreiben ist schwer. Vernünftige Software in verteilten Systemen zu schreiben ist noch schwerer. Wie können wir uns also die Arbeit erleichtern, wenn es viele bewegliche Ziele gibt, die Dienste mal da sind und mal nicht und das ganze System am besten auch noch immer verfügbar und performant sein soll wie in der Cloud?

Die Cloud und Cloud-native Anwendungen rücken in den Alltag von immer mehr Entwicklern. Hinter dem nebulösen Konzept „Cloud“ verbirgt sich regelmäßig ein verteiltes System, das aus vielen kleinen Anwendungen und Ressourcen zusammengesetzt ist. Dieser Aufbau ermöglicht es der Cloud, Eigenschaften wie Nebenläufigkeit, Fehlertoleranz und Skalierbarkeit zu bieten. Gleichzeitig birgt der Einsatz verteilter Systeme ein großes Fehlerpotenzial, insbesondere im Hinblick auf Konsistenz und Netzwerkprobleme.

Die Eigenarten verteilter Systeme erfordern von uns Entwicklern, dass wir die Anwendungen entsprechend gestalten, zusammenstellen und auch mit potenziellen Fehlerfällen angemessen umgehen können. Ein mögliches Paradigma, das die oben genannten Eigenschaften seit Jahrzehnten erfüllt und damit für den Einsatz in der Cloud wie gemacht zu sein scheint, ist das Actor Model.

Wir wollen uns heute anschauen, welche besonderen Anforderungen Cloud-native Anwendungen erfüllen müssen, was das Actor Model ist und inwiefern es für den Einsatz in der Cloud geeignet ist. Dabei soll uns ein vereinfachtes Beispiel zur Veranschaulichung begleiten: Eine Komponente nimmt eine Liste von URLs entgegen, ruft jeden URL via HTTP auf, wertet die erhaltenen Antworten aus und gibt sie gebündelt zurück.

Die Cloud

Wenn wir gegenwärtig von der Cloud sprechen, meinen wir meistens ein verteiltes System aus einer Vielzahl von Anwendungen und Ressourcen, die in Containern gekapselt und durch Orchestrierungsdienste verwaltet werden. Diese Anwendungen, gerne auch als Dienste bezeichnet, kommunizieren über definierte Schnittstellen direkt oder indirekt, synchron oder asynchron und erwirken im Zusammenspiel miteinander die gewünschte Funktionalität.

Durch diesen Aufbau erlangen wir viele positive (und auch teilweise negative) Eigenschaften, die wir sonst nicht hätten. Zu den wichtigsten dieser Eigenschaften zählen sicherlich die Ausfallsicherheit und die Skalierbarkeit.

Ausfallsicherheit

Die Ausfallsicherheit benennt die Eigenschaft des Systems, seine Funktionalität auch dann zur Verfügung stellen zu können, wenn Teile des Systems ausgefallen sind. Der Ausfall kann dabei einzelne Komponenten bis hin zu ganzen Rechenzentren betreffen. Ausgelöst durch kleine Bugs bis hin zum großflächigen Stromausfall. Für diese Fälle sind die Dienste und Ressourcen in der Cloud redundant ausgelegt und auf verschiedene Knoten verteilt. Spezielle Hard- und Software wie etwa Load Balancer erkennen den Ausfall einzelner Teile, unterbinden die Kommunikation mit ihnen und leiten eingehende Nachrichten auf alternative Routen um.

Skalierbarkeit

Die zweite wichtige Eigenschaft ist die Skalierbarkeit, die Fähigkeit des Systems, auf veränderte Lastbedingungen einzugehen und ohne Verlust von Leistung und ohne Erhöhung der Verwaltungskomplexität auf das Hinzufügen von Benutzern und Ressourcen zu reagieren.

Die Skalierbarkeit wird auf zwei Achsen betrachtet: auf der Vertikalen und auf der Horizontalen. Die Skalierbarkeit in der Vertikalen wird auch als Scaling Up bezeichnet und ist charakterisiert durch den Einsatz immer stärkerer Hardware, um den wachsenden Leistungsanforderungen gerecht zu werden. Diese anfangs simpel umzusetzende Maßnahme hat sehr schnell mehrere Nachteile. Zum einen ist der Einsatz immer besserer Hardware nicht mehr wirtschaftlich, da stärkere Hardware oder – noch schlimmer – speziell hierfür entwickelte Hardware sehr teuer ist. Zum anderen gelangt auch die beste Hardware irgendwann an die Grenzen ihrer Leistungsfähigkeit und kann den wachsenden Anforderungen nicht gerecht werden. Außerdem stellt der Einsatz eines einzelnen Servers einen Single Point of Failure dar, dessen Ausfall auf einen Schlag das gesamte System lahmlegt.

Die Probleme des Single Point of Failure und der steigenden Kosten behebt die Skalierbarkeit in der Horizontalen, auch als Scaling Out bekannt. Scaling Out bezeichnet den Zusammenschluss mehrerer Computer/Server zum gemeinschaftlichen Erreichen der Funktionalität. Die Last wird dabei durch geeignete Verfahren der Lastverteilung auf die jeweiligen Server verteilt. Das ermöglicht den Einsatz sog. Commodity-Hardware, also regulärer, bezahlbarer Computer, die in ihrem Zusammenschluss die benötigte Leistung bereitstellen.

Partition und Replikation

Die Dienste, Daten und Ressourcen werden im Rahmen der horizontalen Skalierung je nach Anwendungsfall entweder dedizierten Servern zugewiesen (Partition) oder auf mehrere Server verteilt (Replikation). Die Replikation ermöglicht es, Redundanz herzustellen und so bei einem Ausfall eines Teils des Systems die Funktionalität im Ganzen aufrecht zu erhalten. Die beiden genannten Varianten kommen im Regelfall gemeinsam vor: Dienste werden dedizierten Partitionen des Clusters zugewiesen, werden aber aus Gründen der Redundanz repliziert vorgehalten.

Die Replikation birgt jedoch auch Risiken, insbesondere in der Konsistenz der Daten, die z. B. in „Distributed Systems“ von Tanenbaum [1] und in „Designing Data-Intensive Applications“ von Kleppmann [2] ausführlich beschrieben und daher hier nicht weiter betrachtet werden.

Unser einführendes Beispiel kann z. B. so implementiert sein, dass die genannte Komponente ein Dienst ist, der von anderen Diensten aufgerufen wird. Die aufrufenden Dienste werden dann so lange auf die Antwort warten, bis unsere Komponente alle Aufrufe erledigt und ausgewertet hat. Die benötigte Zeit für das sequenzielle Abarbeiten des übergebenen URL wird so aus der Summe aller einzelnen Aufrufe ermittelt. Von möglichen Fehlerfällen wollen wir hier gar nicht erst ausgehen.

Ein anderer Ansatz für die Verarbeitung der einzelnen Aufrufe ist die parallele Ausführung. Aber wie stellt man das sinnvollerweise an? Schauen wir hierzu auf das Actor Model.

Das Actor Model

Das Actor Model (zu Deutsch Aktorenmodell) ist ein ursprünglich 1973 von Carl Hewitt [3] vorgestelltes mathematisches Modell zur Betrachtung von nebenläufigen Berechnungen. In diesem Modell ist der Aktor die elementare Einheit der Berechnung, die allein über Message Passing, also den Austausch von Nachrichten mit anderen Aktoren kommuniziert und sonst nichts mit anderen Aktoren teilt, insbesondere keine Zustände.

Was Aktoren ausmacht

Jeder Aktor wird durch drei wesentliche Eigenschaften definiert: Logik, Speicher und Kommunikation. Logik meint die Implementierung, Speicher die Möglichkeit, Zustände zwischen einzelnen Nachrichten behalten zu können, und Kommunikation die Adressierbarkeit.

Ein Aktor verfügt über einen Posteingang und kann über eine eindeutige Adresse angesprochen werden. Erhält ein Aktor eine Nachricht, kann er auf drei Arten reagieren:

  1. Nachrichten an andere Aktoren senden, deren Adressen er hat (oder an sich selbst),

  2. weitere Aktoren erzeugen oder

  3. bestimmen, wie mit der nächsten eingehenden Nachricht verfahren werden soll, also den eigenen Zustand für die Behandlung der nächsten Nachricht bestimmen.

Das bedeutet, dass sich der Zustand nicht ändert, sondern ein neuer Zustand für die nächste Operation gegeben ist.

Die Verarbeitung der eingehenden Nachrichten erfolgt sequenziell in der Reihenfolge des Eingangs (FIFO-Prinzip). Die Reihenfolge des Empfangs muss nicht zwingend der Reihenfolge entsprechen, in der die Nachrichten gesendet wurden. Die Kommunikation mit Aktoren erfolgt grundsätzlich asynchron, ein synchrones Verhalten kann aber durch eine ausgehende Nachricht als Antwort auf eine eingehende Nachricht abgebildet werden.

Teile nichts

Eine wesentliche Eigenschaft eines Aktors ist, dass der interne Speicherbereich isoliert ist. Das bedeutet, dass kein anderer nebenläufiger Aktor den Speicherbereich direkt verändern kann. Zustandsänderungen können allein über den Austausch von Nachrichten erfolgen, die Entscheidung hierüber trifft aber der jeweilige empfangende Aktor über das für ihn definierte Verhalten.

Diese Eigenschaft wird idealerweise mit dem Einsatz von nicht-veränderlichen (immutable) Datenstrukturen verbunden. Hierdurch kann die Verwendung von Mechanismen zur Synchronisierung des Zugriffs, also z. B. Locks und Mutexe, vermieden werden, da nicht mehrere Aktoren gleichzeitig schreibend auf die Datenstruktur zugreifen können. Der Verzicht auf diese Mechanismen erlaubt es dann auch, die Verarbeitung einfacher zu parallelisieren, weil nun der gleichzeitige Zugriff auf Daten unterbunden ist.

Zu den derzeit verbreiteten Lösungen, die sich am Actor Model orientieren, zählen die Erlang-Plattform mit der gleichnamigen Sprache [4], die ebenfalls hierauf aufbauende Sprache Elixir [5] und diverse Bibliotheken für verschiedene Sprachen wie Akka [6] und actor4j [7]. Aktoren werden hier durch Prozesse (Erlang) respektive Objekte (Akka) repräsentiert, die in Isolation zu anderen Einheiten stehen und nur durch den Austausch von Nachrichten kommunizieren.

Supervision

Ein Konzept, das in Zusammenhang mit Aktoren besonders sinnvoll ist, ist der Supervisor. Ein Supervisor ist ein Aktor, dessen einzige Aufgabe es ist, andere Aktoren zu erzeugen, zu überwachen und ggf. zu beenden oder neu zu starten. Beenden und Neustarten können dabei nach verschiedenen Strategien erfolgen und z. B. nur einen Aktor betreffen oder alle von diesem Supervisor überwachten Aktoren. Hierdurch ist es möglich, im Falle eines unerwarteten Fehlers eine ganze Reihe von Aktoren in deterministischen Zuständen neu zu starten, ohne dass die gesamte Anwendung inkonsistent wird.

Das ermöglicht weiter die Anwendung der sog. Let-it-crash-Mentalität: Bei der Entwicklung wird nicht versucht, jeden einzelnen Fehlerfall abzufangen, sondern akzeptiert, dass unerwartete Fehler vorkommen werden. Daher wird darauf geachtet, dass ein Fehler und seine Auswirkungen auf den Rest des Systems weitestgehend isoliert sind. Eine mögliche Implementierung unseres Beispiels mit Aktoren ist in Abbildung 1 dargestellt.

willems_enterprisetales_1.tif_fmt1.jpgAbb. 1: Modellierung mit Aktoren

Der Aktor A erhält eine Nachricht, die mehrere URLs enthält. Für jeden erhaltenen URL kann A nun weitere Aktoren erzeugen und jeweils einen URL übergeben. Die erzeugten Aktoren führen den Aufruf via HTTP aus, werten die Antwort aus und schicken sie per Nachricht an A zurück. Danach werden die erzeugten Aktoren wieder beendet. Die Ausführung der einzelnen Aufrufe erfolgt nun parallel und dauert maximal so lange, wie der langsamste Aufruf dauert. Für Fehlerfälle wie etwa fehlschlagende Aufrufe kann ein Supervisor eingesetzt werden, der beispielsweise die betroffenen Aktoren für weitere Versuche neustartet.

Aktoren in der Cloud

Die Einfachheit in der Entwicklung, die durch den Einsatz des Actor Models erreicht werden kann, eignet sich hervorragend für den Einsatz in Cloud-Umgebungen. Jeder Aktor wird durch eine konkrete, dedizierte Aufgabe definiert. Durch das Zusammenspiel der einzelnen Aktoren können dann die positiven Effekte einer Cloud-Anwendung erreicht werden.

Im Kontext von Software für Cloud-Umgebungen können Aktoren für verschiedene Rollen eingesetzt werden, etwa als Microservices, Event Handler und Publisher, Broker oder verteilte Logger. Die jeweilige Ausprägung des Verhaltens ist hierbei unterschiedlich, die zugrunde liegende Arbeitsweise jedoch immer die Gleiche.

Da die Kommunikation auf Message Passing basiert, ist die konkrete Implementierung der Übertragung in der Regel verborgen. Plattformen wie Erlang setzen auf eine transparente Kommunikation zwischen Prozessen und vereinfachen so den Austausch von Nachrichten. Transparent bedeutet, dass es im Rahmen der Adressierung unerheblich ist, ob die Prozesse innerhalb desselben Knotens laufen oder über verschiedene Knoten im Cluster verteilt sind. Hierdurch ist es möglich, Anwendungen bei Bedarf von einem auf mehrere Knoten zu verteilen, ohne die Struktur wesentlich anpassen zu müssen.

Der Einsatz von Message Passing ist weiter eine bewährte Grundlage, um die Eigenschaften Ausfallsicherheit und Skalierbarkeit zu erhalten. Nachrichten werden an eine Adresse gesendet. Hinter dieser Adresse kann sich ein Aktor verbergen, aber auch eine ganze Reihe von Aktoren, die parallel und isoliert von den anderen Aktoren ihrer Funktion nachgehen. Verbunden mit dem geeigneten Einsatz eines Supervisors, der auf den unerwarteten Ausfall von Aktoren reagieren kann, lässt sich eine hohe Verfügbarkeit der Anwendung erreichen.

Der oben genannte Supervisor kann in Cloudumgebungen, die häufig durch eine Form von Orchestrierung wie Kubernetes betrieben werden, entweder ersetzt oder damit verbunden werden. Wenn ein Service im Cluster ausfällt, wird die Orchestrierung diesen normalerweise auch neustarten, wie ein Supervisor das erledigen würde.

Ein wichtiger Aspekt, der bei der Kommunikation zwischen den Diensten beachtet werden muss, ist, dass die einzelnen Nachrichten verschlüsselt und signiert sind. So kann sichergestellt werden, dass nur legitimierte Nachrichten verarbeitet werden und das System nicht durch bösartige Nachrichten beeinträchtigt wird.

Links & Literatur

[1] https://www.distributed-systems.net/index.php/books/ds3/

[2] https://dataintensive.net

[3] https://dl.acm.org/doi/10.5555/1624775.1624804

[4] https://www.erlang.org

[5] https://elixir-lang.org

[6] https://akka.io

[7] https://actor4j.io

Alle News zum Software Architecture Summit!