Versöhnt euch!

Reconciliation statt klassischem sequenziellem Programmentwurf
Keine Kommentare

In verteilten Systemen ist Fehlerbehandlung oft aufwendig und schwierig. Jeder Aufruf zu einem externen System kann schiefgehen und muss daher separat behandelt, wiederholt und überwacht werden. Sollte ein Aufruf in einer Sequenz von Operationen so fehlschlagen, dass er nicht auf der Stelle behoben werden kann, muss die gesamte Sequenz neu gestartet werden. Bereits erfolgreiche Aufrufe werden ausgelassen.

Als Alternative zur klassischen sequenziellen Bearbeitung steht das Reconciliation-Programmiermodell. Hier wird der gewünschte Zielzustand zunächst definiert und vom Reconciler kontinuierlich mit dem aktuell vorherrschenden Zustand verglichen. Diese Vergleiche geschehen in einer Endlosschleife, dem Reconciliation-Loop. Sollte während diesem Vergleich eine Differenz zwischen aktuellem und Zielzustand auftreten, wird der Reconciler den nächstmöglichen Schritt in Richtung des Zielzustandes ausführen. Im nächsten Durchlauf kann der Reconciler prüfen, ob er noch warten muss, oder ob dieser Schritt bereits erfolgreich beendet wurde und er den nächsten Schritt in Richtung des Zielzustandes ausführen kann.

Motivation

Schauen wir uns das Ganze einmal anhand eines einfachen Beispiels an. Unser Code soll den Client eines Cloudanbieters verwenden, um ein Neo4j-Cluster zu starten. Wenn das erledigt ist, wird ein Speicherort (Bucket) erstellt, in dem Back-ups abgelegt werden können. Danach werden Firewallregeln konfiguriert: Das Neo4j-Cluster soll von außerhalb zugänglich sein, die Neo4j-Cluster sollen auch auf den Back-up-Bucket zugreifen können. Eigentlich ist das ein nicht allzu komplexer Prozess. Einen naiven Ansatz, ihn zu implementieren, können wir uns in Listing 1 anschauen. Für die drei Arbeitspakete, die wir erledigen müssen, haben wir jeweils eine Zeile Code verwendet. Zuerst erstellen wir das Cluster. Den Rückgabewert dieses Aufrufes verwenden wir, um den Back-up-Bucket einzurichten. Die bisherigen Rückgabewerte dienen dazu, die Firewall korrekt zu konfigurieren.

def setup_cloud_neo4j_cluster(client: GoogleClient, neo4j_version: str):
  neo4j_cluster = client.create_neo4j_cluster(neo4j_version)
  buckets = client.setup_backup_buckets(neo4j_cluster)
  client.configure_firewall_rules(neo4j_cluster, buckets)

Transaction Script – von oben nach unten

Martin Fowler nennt dieses Muster das Transaction Script und beschreibt es folgendermaßen: „The transaction script organizes business logic by procedures where each procedure handles a single request [transaction] from the presentation.” [1]

Alle Aufrufe einer Anwendung werden also sequenziell behandelt und wie Transaktionen betrachtet. In unserem Beispiel haben wir drei dieser Transaktionen: 1. Neo4j-Cluster erstellen, 2. Bucket erstellen, 3. Firewall konfigurieren. Diese einzelnen Transaktionen reihen wir nacheinander auf und die Methode setup_cloud_neo4j_cluster wäre unser Skript.

Eine Eigenschaft dieses Vorgehens ist, dass jede einzelne Transaktion sehr lange dauern kann, was die gesamte Ausführung an dieser Stelle blockieren würde. Das ist zwar nicht sonderlich performant, aber auch kein wirkliches Problem. Ein Problem ist allerdings, dass jede einzelne dieser Transaktionen auch fehlschlagen könnte.

Retries – das ständige Wiederholen

Ein üblicher Ansatz, um diesem Problem zu begegnen, sind Wiederholungen, sogenannte Retries. Dabei versucht man, einen fehlgeschlagenen Aufruf so lange zu wiederholen, bis er eventuell irgendwann erfolgreich ausgeführt werden kann. Hier gibt es verschiedene Ansätze zur Implementierung. Einfachheitshalber wollen wir annehmen, dass wir uns gegen die manuelle Implementierung dieser Retries entschieden haben und eine Sprache beziehungsweise eine Bibliothek verwenden, die dieses Problem für uns löst. Die in Listing 2 gezeigte Annotation @retry ist in Python 3 enthalten und kann umfangreich konfiguriert werden.

def setup_cloud_neo4j_cluster(client: GoogleClient, neo4j_version: str):
  neo4j_cluster = create_neo4j_cluster(client, neo4j_version)
  buckets = setup_backup_buckets(client, neo4j_cluster)
  client.configure_firewall_rules(client, neo4j_cluster, buckets)
 
@retry(exceptions=(RegionFullException,RetryableException), tries=20, delay=10, backoff=2, max_delay=120)
def create_neo4j_cluster(client: GoogleClient, neo4j_version: str):
  return client.create_neo4j_cluster(k8s_version)
 
...

Die erste Konfiguration besagt, dass wir nur beim Auftreten von RetryableException oder RegionFullException die Ausführung wiederholen wollen. Die Art des aufgetretenen Fehlers macht nämlich einen erheblichen Unterschied bei der Frage, ob ein Aufruf wiederholt werden sollte oder nicht. Zum Beispiel ergibt es keinen Sinn, den Aufruf zu wiederholen, wenn man einen Authentifizierungsfehler macht, da dieser Aufruf wahrscheinlich immer fehlschlagen wird. Auf der anderen Seite ist es sehr wohl sinnvoll, den Aufruf zu wiederholen, wenn nur ein temporäres Problem vorliegt – etwa, wenn aktuell keine Kapazitäten vorhanden sind oder die Netzwerkverbindung kurz nicht verfügbar war.

Die zweite Konfiguration besagt, dass im Fehlerfall maximal 20 erneute Versuche unternommen werden sollen. Die dritte Konfiguration legt fest, dass nach jedem fehlgeschlagenen Versuch 10 Sekunden gewartet werden soll, bis ein erneuter Versuch unternommen wird. Wenn eine gewisse Zeit gewartet wurde, hat der erneute Versuch eine höhere Wahrscheinlichkeit, erfolgreich zu sein. Die vierte getroffene Konfiguration definiert, dass nach jedem fehlgeschlagenen Versuch die Wartezeit verdoppelt werden soll. So würde nach dem ersten fehlgeschlagenen Versuch 10 Sekunden gewartet werden, nach dem zweiten 20 Sekunden, dann 40 Sekunden und so weiter. Das hat den Hintergrund, dass einem System, das gerade überlastet ist, mit einem exponentiellen Warten (Backoff) pro Versuch mehr Zeit gegeben wird, sich zu erholen. Schließlich besagt die letzte Konfiguration, dass die maximale Wartezeit zwischen zwei fehlgeschlagenen Versuchen 120 Sekunden betragen soll.

Mit der Verwendung von Retries haben wir unser Transaction Script also wesentlich toleranter gegenüber Fehlern gemacht und haben eine höhere Wahrscheinlichkeit, dass unser Programm erfolgreich ausgeführt werden kann. Allerdings ist die erfolgreiche Ausführung immer noch nicht garantiert. Zum Beispiel könnte ein Aufruf mehr als 20 Retries benötigen.

Insert + Update = Upsert

Wenn der Aufruf configure_firewall_rules zum Beispiel 21 Retries benötigt, könnten wir unser Programm ja einfach erneut starten, und dieses Mal würde es erfolgreich beendet werden, oder?

Das Problem dabei ist, dass das der dritte Aufruf in unserem Skript ist und wir bis zu dieser Stelle ja schon ein Neo4j-Cluster und ein Back-up-Bucket erstellt haben. Wenn das Programm also erneut durchlaufen wird, würde ein zweites Neo4j-Cluster und ein zweiter Back-up-Bucket erstellt werden. Das wollen wir aber gar nicht.

Eine übliche Herangehensweise, um dieses Problem zu lösen, ist das sogenannte Upsert, also eine Mischung aus Insert und Update. In Listing 3 können wir eine sehr triviale Implementierung dieses Vorgehens sehen.

def setup_cloud_neo4j_cluster(client: GoogleClient, name: str, neo4j_version: str):
  neo4j_cluster = upsert_neo4j_cluster(client, name, neo4j_version)
  buckets = upsert_backup_buckets(client, neo4j_cluster)
  client.configure_firewall_rules(client, neo4j_cluster, buckets)
 
@retry(exceptions=(RegionFullException,RetryableException), tries=20, delay=10, backoff=2, max_delay=120)
def upsert_neo4j_cluster(client: GoogleClient, name: str, neo4j_version: str):
  existing_cluster = client.get_neo4j_cluster(name)
  if not existing_cluster:
    return client.create_neo4j_cluster(name, k8s_version)
  return client.update_neo4j_cluster(name, k8s_version)
 
@retry(exceptions=(RegionFullException,RetryableException), tries=20, delay=10, backoff=2, max_delay=120)
def upsert_backup_buckets(client: GoogleClient, neo4j_cluster: str):
  client.upsert_backup_buckets(neo4j_cluster)
 
...

Das Vorgehen bei der Implementierung wäre so, dass zunächst geschaut wird, ob zum Beispiel bereits ein Neo4j-Cluster vorhanden ist, bei Bedarf erstellt oder ein bestehendes Cluster aktualisiert wird. Damit das allerdings funktionieren kann, benötigen wir einen eindeutigen Namen für unser Neo4j-Cluster.
Also geben wir unserem Cluster einen eindeutigen Namen und erweitern die Methode upsert_neo4j_cluster um das soeben beschriebene Vorgehen. Es wird zunächst geschaut, ob es das Cluster bereits gibt. Falls nicht, wird es frisch erzeugt. Falls das Cluster schon vorhanden ist, wird es aktualisiert, um sicher zu sein, dass wir die korrekte Version verwenden. Einige Clients bieten auch schon ein Upsert von Haus aus an, das wir dann zum Beispiel in der Methode upsert_backup_buckets direkt verwenden können.

Reconciliation – immer im Kreis

Retries und Upserts sind bereits sehr hilfreiche Werkzeuge, um fehlertolerante Software zu entwickeln. Diese Werkzeuge allein stoßen aber immer noch an ihre Grenzen. Zum Beispiel muss ein fehlgeschlagener Programmdurchlauf erneut gestartet werden. Aber könnten wir da nicht einfach eine Endlosschleife drum herum schreiben und hätten das Problem ebenfalls gelöst?

Im Endeffekt kann man das. Ich würde allerdings einen Schritt weiter gehen und das Programmierparadigma leicht abwandeln, um diese Art der Programmierung noch flexibler und fehlertoleranter zu machen.

Wie wir in Listing 4 sehen, schließen wir nun unser Programm in einer Endlosschleife ein. Im Vergleich zum letzten Vorgehen sind wir nun dazu übergegangen, den gewünschten Zielzustand mit dem aktuellen Zustand zu vergleichen. Dazu müssen wir natürlich zunächst den aktuellen Zustand und den gewünschten Zielzustand berechnen. In unserem Beispiel würde für die Berechnung des aktuellen Zustands wieder der Client verwendet werden. Zunächst wird geprüft, ob bereits ein Neo4j-Cluster vorhanden ist, ob es einen Back-up-Bucket gibt und ob die Firewall korrekt konfiguriert ist. Um den Zielzustand zu berechnen, muss keine externe Schnittstelle befragt werden, da das in der Regel deterministisch lokal berechnet werden kann.

def cloud_neo4j_cluster_reconciler(client: GoogleClient, name: str, neo4j_version: str):
  while True:
    current_state = calculate_current_state(client, name)
    desired_state = calculate_desired_state(client, name, neo4j_version)
      if current_state != desired_state:
        reconciled = reconcile_neo4j_cluster(client, current_state.cluster, desired_state.cluster)
      if reconciled:
        reconciled = reconcile_buckets(client, current_state.bucket, desired_state.bucket)
      ...
    time.sleep(5)
 
def reconcile_neo4j_cluster(client: GoogleClient, current_state: Neo4jCluster, desired_state: Neo4jCluster):
  if current_state != desired_state:
    if current_state and not desired_state:
      delete_neo4j_cluster(client, current_state)
    else:
      upsert_neo4j_cluster(client, desired_state)
 
    return False
  return True
...

In einem etwas erweiterten Beispiel könnte der Zielzustand allerdings auch in einer Datenbank gespeichert sein. Der aktuelle Zustand könnte sich stetig ändern, basierend darauf, ob zum Beispiel ein Knoten in unserem Neo4j-Cluster fehlgeschlagen ist.

Wenn wir den Zielzustand und den aktuellen Zustand nun berechnet haben, können wir mit dem Vergleich beginnen. Unser Programm soll am aktuellen Zustand nur etwas verändern, wenn dieser vom gewünschten Zielzustand abweicht. Wenn eine Abweichung erkannt wird, soll ein kleiner Schritt unternommen werden, um vom aktuellen Zustand in Richtung des Zielzustandes zu gehen. Üblicherweise würde pro Durchlauf unserer Endlosschleife nur ein Schritt gemacht werden. Das heißt zum Beispiel, dass, falls in reconcile_neo4j_cluster eine Abweichung entdeckt wird und eine Änderung vorgenommen wird, die restlichen Schritte übersprungen werden und erst dann bearbeitet werden, wenn das aktuelle Neo4j-Cluster dem gewünschten Neo4j-Cluster entspricht.

Das Reconciliation-Programmiermodell

Das soeben beschriebene Vorgehen ist als Reconciliation-Programmiermodell beziehungsweise -Muster bekannt. Reconciliation ist dabei einfach nur der Begriff dafür, dass ein Programm andauernd den aktuellen Zustand mit dem Zielzustand vergleicht und gegebenenfalls Änderungen nach dem beschriebenen Vorgehen vornimmt. Die Endlosschleife bezeichnet man daher auch als Reconciliation-Loop. Man unterscheidet üblicherweise zwischen zwei verschiedenen Ausprägungen des Reconciliation-Loops.

Die erste haben wir bereits kennengelernt, den unendliche Reconciliation-Loop. Er wird üblicherweise eingesetzt, wenn man für immer sicherstellen möchte, dass ein Zielzustand erreicht ist beziehungsweise erhalten bleibt. Wenn z. B. der Zielzustand erreicht wurde, aber nach einigen Minuten ein Knoten des Neo4j-Clusters fehlschlägt, würde das Programm (der Reconciler) das feststellen und die Situation beheben. Ebenfalls könnten Updates am Neo4j-Cluster leicht konfiguriert werden.

Wenn nun zum Beispiel die aktuelle Neo4j-Version von 3.5.11 auf Version 3.5.12 geändert wird, dann würde der Reconciler feststellen, dass der aktuelle Zustand vom Zielzustand abweicht und ein rollendes Update starten.

Die zweite Art des Reconciliation-Loops ist keine Endlosschleife. Es ist eine Schleife, die bis zur erstmaligen Erreichung des Zielzustandes läuft und dann abbricht. Diese Art ist eher dafür geeignet, Systeme zu installieren, und nicht so sehr, um Systeme zu verwalten, in denen sich Komponenten zur Laufzeit stark verändern können.

Welche Vorteile bringt das dem Entwickler?

Der große Vorteil ist, dass die Behandlung von speziellen Zuständen weniger häufig wird beziehungsweise man sehr generell implementiert, wie man ein System vom aktuellen Zustand in den Zielzustand bewegt. Dabei ist es egal, wie das System in diesen Zustand gelangt ist.

Nehmen wir hierfür unser Neo4j-Cluster in Abbildung 1 als Beispiel: Wenn unser Reconciler ein neues Cluster startet, würde er als Zielzustand drei hochgefahrene Neo4j-Instanzen berechnen. Der aktuelle Zustand beinhaltet allerdings keine einzige hochgefahrene Neo4j-Instanz. Daher wird der Reconciler zunächst einen kleinen Schritt machen, eine Neo4j-Instanz starten und warten, bis diese hochgefahren ist. Danach wird er die zweite Instanz starten und wieder warten, bis diese hochgefahren ist. Dann kommen wir zu dem Zustand aus der Abbildung, in dem die dritte Instanz gestartet wird und wieder gewartet wird, bis diese hochgefahren ist. Danach wäre das Cluster erfolgreich hochgefahren.

Abb. 1: Reconciler für Neo4j-Cluster

Abb. 1: Reconciler für Neo4j-Cluster

Nun gehen wir zur rechten Seite der Abbildung und betrachten den Fall, dass eine Instanz fehlschlägt. Das Cluster ist also nun wieder im Zustand, dass zwei Instanzen laufen, der gewünschte Zielzustand allerdings drei hochgefahrene Instanzen beinhaltet. Also würde der Reconciler wieder eine neue Neo4j-Instanz starten und warten, bis sie hochgefahren ist, damit der aktuelle Zustand dem gewünschten Zielzustand entspricht. Wie wir dem Beispiel entnehmen können, ist es für unseren Reconciler völlig egal, wie das Cluster in den aktuellen Zustand gekommen ist. Ob das Cluster sich gerade in der Phase des Startens oder in der Phase des Heilens eines Fehlers befindet, spielt für die Entscheidungen keine Rolle. Wichtig für den Reconciler ist nur, dass er immer versucht, den aktuellen Zustand so zu modifizieren, dass er sich in die Richtung des Zielzustands bewegt. Dadurch, dass es nun keine spezielle Behandlung dieser verschiedenen Fälle mehr gibt, kann das im Idealfall auch mit sehr viel weniger Code realisiert werden.

Welche Vorteile bringt das dem Anwender?

Für den Anwender sollte es eigentlich egal sein, wie eine verwendete Komponente implementiert ist, da es sich im Idealfall um ein Implementierungsdetail handelt. Allerdings wird der Anwender eine effizientere beziehungsweise schnelle Bearbeitung wahrnehmen, falls zum Beispiel mehrere Neo4j-Cluster gleichzeitig bearbeitet werden sollen. Ganz am Anfang haben wir ja kritisiert, dass gewisse Aufrufe blockieren und Zeit in Anspruch nehmen können. Wenn wir nun unseren aktuellen Zustand nur Schritt für Schritt in Richtung des Zielzustands bewegen, können wir die Zeit, die wir eigentlich warten würden, dafür verwenden, andere Cluster zu bearbeiten.

Weiterhin ist es bei derartigen Programmen üblich, dass der Zielzustand als Eingabe bereitgestellt wird und dem Anwender eine sehr deklarative und klare Schnittstelle zur Verfügung gestellt wird. Da der gewünschte Zielzustand demnach direkt als Eingabe verwendet wird, ist der Umgang mit solchen Programmen meist sehr verständlich für den Anwender.

Welche Programme arbeiten so?

Als sehr prominente Beispiele sind hier alle Kubernetes Controller oder -Operatoren zu nennen. Nehmen wir etwa den PodController. Der führt ständig eine Reconciliation über alle Pod-Ressourcen aus, die in Kubernetes gespeichert sind. Er interpretiert diese Ressourcen als gewünschten Zielzustand, vergleicht sie mit den aktuell laufenden Pods und handelt bei einer Abweichung entsprechend. Weiterhin ist dieses Programmiermodell sehr beliebt bei Software, die sich mit der Installation von Infrastruktur beschäftigt. Dort ist es üblich, mit externen Diensten zu kommunizieren, die fehlschlagen können, um einen konfigurierten Zielzustand zu erreichen.

Ist dieses Programmiermodell auf Infrastruktursoftware beschränkt?

Nein, dieses Programmiermodell ist für vielerlei Probleme geeignet. Wie man den Beispielen dieses Artikels entnehmen konnte, arbeite ich häufig mit der Graphdatenbank Neo4j. Mein Team entwickelt ein gehostetes Angebot dieser Datenbank, Neo4j Aura. Dabei haben wir sowohl Kubernetes Controller nach diesem Programmiermodell implementiert als auch viele Teile unserer Software, um unsere Infrastruktur zu installieren und zu updaten. Allerdings wurden auch andere Komponenten unseres Systems nach diesem Muster entworfen, und zwar immer die, die mit anderen Systemen interagieren und deshalb Aufrufe haben, die fehlschlagen könnten. Beispielsweise besteht eine Integration mit einem externen Abrechnungssystem, in dem dieses Muster ebenfalls erfolgreich eingesetzt wird.

Das Reconciliation-Muster ist sicherlich nicht die beste Wahl für alle Arten von Problemen. Es ist aber ein sehr nützliches Werkzeug, um Probleme der beschriebenen Art zu lösen. Ich hoffe, dass es auch Ihnen beim nächsten Einsatz gute Dienste erweisen wird.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -