Resilienz

Unerwartete Fehler treten immer auf und lassen sich nicht vermeiden. Sie werden heute akzeptiert, nicht mehr ignoriert und es wird versucht, auf diese geeignet zu reagieren. Fehler können beliebiger Natur sein: Netzwerk-Ausfälle, die Latenz ist zu groß, Platten laufen voll, die CPU-Auslastung ist unerwartet hoch, es steht kein RAM mehr zur Verfügung usw. Der Grund dafür, dass derartige Situationen heute mehr berücksichtigt werden, ist unter anderem der Tatsache geschuldet, dass beispielsweise Anforderungen an die Verfügbarkeit von Systemen stark gestiegen sind.

Resilienz bedeutet psychische Widerstandskraft; die Fähigkeit, schwierige Lebenssituationen ohne anhaltende Beeinträchtigung zu überstehen (vgl. https://docs.microsoft.com/de-de/dotnet/standard/microservices-architecture/implement-resilient-applications/handle-partial-failure). Entsprechend versuchen wir in Schleupen.CS mit Fehlersituationen derart umzugehen, dass diese der Benutzer bestenfalls nicht mitbekommt oder die Ausfälle nur möglichst wenige Systembestandteile betreffen. Insbesondere dürfen Teilfehler nicht dazu führen, dass das gesamte System nicht mehr funktionsfähig ist. Resilienz ist also die Fähigkeit, dass sich das System von derartigen Teilfehlern erholt und weiterhin oder wieder automatisch nach einem gewissen Zeitraum funktioniert. Genau das ist es, was auch bei Services bzw. bei deren Zusammenspiel notwendig ist, wenn das Netzwerk ausfällt oder das System sehr unter Last steht. Doch wie kann das erreicht werden?

Patterns

Für die meisten Probleme gibt es Standard-Patterns, die auch in Schleupen.CS zur Anwendung kommen. Im Folgenden wird eine Auswahl davon beschrieben, sowie wo, wie und warum wir diese nutzen. In vielen Fällen spielen diese Muster zusammen!

Timeouts

Ein Timeout wird heute eher als Fehler oder falsche Konfiguration wahrgenommen statt eines Musters zur Resilienz. Timeouts sind allerdings ein wichtiges Muster, da jede Anwendung, die remote kommuniziert, berücksichtigen muss, dass Netzwerke fehlerbehaftet sind (ein Switch oder ein Kabel geht kaputt, der Ziel-Computer stürzt ab u.v.m.). Dies kann auch während einer aufgebauten Verbindung bei einem Service-Aufruf passieren. Hier wäre es schlecht, wenn der Code in diesen Situationen unendlich lang warten würde. Damit der Client-Thread also nicht unendlich lange blockiert wird, gibt es Timeouts. Hier also das client-seitige Timeout als ein Beispiel.

In Schleupen.CS haben die WCF-Clients ein Standard-Timeout von einer Minute. Wenn dieses auftritt, ist aus Client-Sicht völlig unklar, was dies bedeutet: Es kann sein, dass der Server-Prozess einfach zu Ende arbeitet. Unter Retrys wird beschrieben, wie hiermit geeignet umgegangen werden kann.

Client-seitiges Timeout bei einem Service-Aufruf

Das Absetzen großer Requests erhöht die Wahrscheinlichkeit, in ein Timeout zu laufen, da der Service in irgendeiner Form vermutlich mehr an Arbeit verrichten muss - die Deserialisierung dauert länger, das Persistieren von Daten benötigt mehr Zeit und so weiter. Dem steht hinsichtlich der Performance entgegen, die Arbeit durch sehr kleine Teile (= chunks) auf viele kleine Aufrufe aufzuteilen ("be chunky not chatty"). D.h. hier haben wir es mit einem klassischen Trade-Off zu tun. Da man Chattiness vermeiden möchte, ergibt sich fast zwangsläufig, dass in gewissen Situationen Timeouts bei synchronen Serviceaufrufen auftreten werden. Daher sind wiederum andere Patterns für einen geeigneten Umgang mit Timeouts relevant.

Anmerkung: Auch Datenbank-Remote-Abfragen haben ein Timeout, dieses liegt im Fall des Microsoft SQL Server bei 10 Minuten (Konfigurieren der Serverkonfigurationsoption „Timeout für Remote-Abfragen“ - SQL Server | Microsoft Docs). Auch dieses ist ein natürlicher, sinnvoller Schutzmechanismus. Das client-seitige Command-Timeout beträgt indes 30 Sekunden (SqlCommand.CommandTimeout Property (System.Data.SqlClient) | Microsoft Docs). Dieser ist in Schleupen.CS angebunden.

Bei synchronen Aufrufketten verstärkt sich das Risiko eines client-seitigen Timeouts, da zum Einen jeder Service eigene Funktionalität ausführt und zum Anderen Netzwerk-Latenzen etc. hinzukommen.

Erhöhtes Risiko von Client-seitigen Timeouts bei synchronen Aufrufketten

Entsprechend setzt Schleupen.CS analog zu https://docs.microsoft.com/de-de/dotnet/architecture/microservices/architect-microservice-container-applications/communication-in-microservice-architecture#asynchronous-microservice-integration-enforces-microservices-autonomy auf asynchrone Kommunikation, was sich für die Autarkie der Bausteine, insbesondere der Länder, bezahlt macht. 

Retry

Retrys werden an viele Stellen in der Architektur von Schleupen.CS angewendet. Insbesondere bei synchronen Serviceaufrufen muss, wie bereits beschrieben, mit Client-seitigen Timeouts umgegangen werden. Da die Ursache eines Timeouts unklar ist, muss zwangsläufig ein Retry durchgeführt werden. Man benötigt ein Retry, um eine Antwort zu erhalten und dann sicher zu sein, dass die Operation erfolgreich durchgeführt worden ist. 

Damit entsteht allerdings das nächste Problem: Wurde der erste Aufruf erfolgreich zu Ende geführt, wobei im Client ein Timeout durchgeführt worden ist, so sorgt ein Retry ggf. für unerwünschte Resultate. Daher ist das Thema der Idempotenz sehr wichtig.

Idempotenz

Bei Services bedeutet Idempotenz, dass der mehrfache Aufruf eines Services mit identischem Request stets dasselbe Ergebnis liefert (d.h. einen identischen Response).

Das heißt nicht, dass die Verarbeitung intern identisch ist!

Wenn das Ergebnis ein (fachlicher) Fehler ist (ein Fault), muss auch dieser stets bei identischem Request zurückgegeben werden. Wichtig hierbei ist, dass nicht gemeint ist, dass ein Service mit denselben Parametern zweimal aufgerufen wird, sondern dass es genau derselbe identische Request ist. Denn es kann beispielsweise sein, dass zweimal das gleiche Buch angelegt wird (z.B. wird die Id server- seitig gesetzt - in Schleupen.CS die Id des Buchs als GUID durch den Client bestimmt wird).

Zweimaliger Aufruf mit ein und demselben Request

Beispiel: Gegeben sei folgender Request:

Reicht dieser Request bei technischem Retry aus?

Dass Idempotenz sehr relevant ist, wird klar, wenn man überlegt, dass über einen Service-Aufruf 50 Euro gebucht werden. Wird ein Retry aufgrund eines Timeouts durchgeführt, so sollte dies natürlich nicht dazu führen, dass zweimal 50 Euro gebucht werden. Um diesem gerecht zu werden, erhält jeder Request einen sogenannten IdempotencyKey, der jeden Request von einem anderen unterscheidet. Somit kann der Server erkennen, dass dieser Request bereits entgegengenommen worden ist.

Nehmen wir an, wir hätten vorher einen Kontostand von 1000 Euro und es würde folgende Situation eintreten:

Abbuchung mit Timeout und Retry

Wie wäre nun der Kontostand nachher? 900 Euro (das wäre schlecht) oder 950 Euro? Wenn es ein technischer Retry ist, sollte dieser 950 Euro sein. Wenn der Benutzer hingegen wirklich zweimal gebucht hat, 900 Euro.

Um sicherzustellen, dass genau einmal der Request ausgeführt wird, erhält dieser den IdempotencyKey.

Server-seitige Implementierung von Idempotenz in Schleupen.CS

Anmerkungen:

  • Da der Message Bus nur die Garantie At-Least-Once hat, ist auch in diesem Kontext der IdempotencyKey sehr relevant.

  • Ein IdempotencyKey ist nicht immer notwendig - manchmal allerdings essenziell. Ein GET ist beispielsweise per se idempotent, sodass hierfür kein IdempotencyKey notwendig ist.
  • Da der IdempotencyKey möglichst eindeutig sein muss, empfiehlt es sich GUIDs zu verwenden. Die Ursachen dafür, dass man Idempotenz berücksichtigen muss, sind ja auch technischer Natur.
  • Ein IdempotencyKey darf nicht weitergegeben werden.
  • Der Client muss denselben Request erneut ausführen und darf nicht wieder einen neuen Request generieren.

Die folgenden Abbildungen geben einen Überblick über die Funktionsweise der Implementierung einer Schleupen.CS-Idempotenz-Bibliothek. Grob gesagt, erhält ein Request im Header einen IdempotencyKey, Server-seitig wird dieser gespeichert, damit man beim erneuten Aufruf mit demselben Request sehen kann, ob der Request bereits verarbeitet worden ist. Ist der Request bereits verarbeitet worden, so wird der entsprechende Response zurückgegeben.

Serverseitige Implementierung von Idempotenz in Schleupen.CS

Generell ist ein Retry bei fachlichen Ausnahmen nicht sinnvoll, da der fachliche Fehler durch ein Retry im Allgemeinen nicht korrigiert wird

Beispiel: ein Buch ohne Titel darf nicht angelegt werden. Das wird auch nach weiteren Versuchen nicht funktionieren.

Technische Ausnahmen sind oft recoverable, so dass ein Retry sinnvoll ist: Timeout, Neustart Webserver, Neustart DB, Netzwerk-Ausfall, etc.). Die genaue Unterscheidung, welche Ausnahmen recoverable sind, ist nicht trivial, so dass wir in CS 3.0 zunächst mit den offensichtlichen wie der TimeoutException starten.

Bei obigem Ablauf kann es sein, dass der Client eine Ausnahme IdempotentOperationPendingFaultContract erhält, wenn der Service gerade zwischen dem Speichern der RequestId (= IdempotencyKeys + Operationsname) und dem Speichern des Response befindet, d.h. der zweite Aufruf den Response noch nicht geben kann, aber anhand dem IdempotencyKey klar ist, dass ein Aufruf schon in Bearbeitung ist.

Rückgabe einer bereits zuvor erfolgreich ausgeführten Operation bei gleichem IdempotencyKey

Die Ausnahme per IdempotentOperationPendingFaultContract ist also eine wiederholbare Ausnahme. Auf diese Weise kann der Zustand Client-seitig beauskunfted werden.

Anschluss von Retrys mit Idempotenz in der Infrastruktur

Die Idempotenz-Bibliothek von Schleupen.CS wurde insbesondere in folgenden Bausteinen angebunden. Bei jedem Retry, wird sichergestellt, dass ein IdempotencyKey im Request gesetzt ist und der identische Request erneut gesendet wird.

  • Business-Event-Dispatcher - Es wird 10 mal mit Zeitabstand von 10s pro Consumer (n Consumer pro Queue) versucht, eine Message durch Aufruf des Event-Service (Subscriber-Service) erneut zuzustellen, anschließend wird bei nicht erfolgreicher Verarbeitung die Message in den Deadletter-Message-Store gelegt.
  • Business-Process-Engine (Workflows) - Bei Fehler: Ein Wiederanlauf-Job nimmt 100 Workflows, startet diese wieder an, wobei der Retry nach folgendem Muster durchgeführt wird: 1h, 2h, 2h, 4h, 4h, 6h, 12h, 18h, 24h, Abbruch. Bei Ausnahmen im Mapping-Code im Workflow (Code im Workflow) wird innerhalb des Workflows kein Retry durchgeführt. Bei angehefteten Fehlerereignissen an einer Workflow-Aktivität ist konfigurierbar, wie viele Retrys ausgeführt werden sollen.
  • Transactional-Outbox - Die Transactional-Outbox versucht Nachrichten transaktional sicher an den Message-Broker zu übertragen. Auch hier gibt es ein Sicherheitsnetz, das im Folgenden beschrieben wird.
Transactional Outbox

Mit dem Begriff Transactional Outbox wird ein allgemeines Implementierungsmuster bezeichnet, bei dem das Versenden von Nachrichten im Zusammenspiel mit lokaler Persistenz durch eine Transaktion atomar abgesichert wird (vgl. z.B. Pattern: Transactional Outbox). Denn es muss ohne verteilte Transaktionen sichergestellt werden, dass keine Events versendet werden, wenn eine Transaktion zurückgerollt wird und umgekehrt muss eine Nachricht sicher versendet werden, wenn die Transaktion erfolgreich lokal durchgeführt worden ist.

Anstatt die Nachrichten (z.B. Events, Requests an Command Services) direkt zu versenden, werden sie innerhalb der Transaktion in der Datenbank (zwischen-)gespeichert und erst nachgelagert bei einem erfolgreichen Commit an das Messaging-System weitergegeben. Dadurch ist sichergestellt, dass die Nachrichten konsistent zu den Datenänderungen des Domänenmodells innerhalb der Transaktion sind. Darüber hinaus wird bei einem Rollback der Transaktion verhindert, dass die Nachrichten überhaupt zugestellt werden, weil sie ja persistiert werden. Per Job werden die persistierten Nachrichten an den Message Broker übergeben. Der Job ist notwendig, damit die Übergabe auch dann durchgeführt werden kann, wenn der Message Broker temporär nicht verfügbar ist.

Der grobe Ablauf in einer einfachen Variante ist in der folgenden Abbildung dargestellt:

Grundlegender Ablauf mit einer Transactional Outbox
  1. Service wird aufgerufen
  2. Innerhalb einer lokalen Transaktion werden die fachlichen Daten in einer fachlichen Tabelle und das Event (= die Message / Nachricht) in die Tabelle OutboxMessage gespeichert
  3. Ein Job liest aus der Tabelle OutboxMessage und stellt die Nachricht in die Ziel-Queue ein und löscht die Nachricht aus der Tabelle

Um die Nachrichten schneller an die Queue zu übergeben, wird in Schleupen.CS folgende Optimierung umgesetzt:

Optimierung der Transactional-Outbox
  1. Service wird aufgerufen
  2. Innerhalb einer lokalen Transaktion werden die fachlichen Daten in einer fachlichen Tabelle und das Event (= die Message / Nachricht) in die Tabelle OutboxMessage gespeichert
  3. nach Transaktionsabschluss wird versucht, die Nachricht in die Ziel-Queue einzustellen und die Nachricht aus der Tabelle gelöscht
  4. falls nicht erfolgreich, liest ein Job die Nachricht aus der Tabelle OutboxMessage, stellt diese in die Ziel-Queue ein (falls Schritt 3 nicht erfolgreich war) und löscht die Nachricht aus der Tabelle

Circuit-Breaker

Das Muster Circuit Breaker beschreibt, dass wenn dauerhaft Retrys durchgeführt werden, diese Aufrufe irgendwann unterbrochen werden sollten, da unnötig Last erzeugt wird. Das unterliegende Statusmodell adressiert zudem, wie das System wieder in den "Normal"-Zustand übergehen kann. Hierzu verwendet Schleupen.CS die Bibliothek Polly.

Backpressure

Backpressure ist in RabbitMQ durch das sogenannte FlowControl umgesetzt. Dabei kommt FlowControl vom Message-Broker zum Client (= Publisher) zur Anwendung, so dass der Client die Nachrichten, nicht beliebig schnell einstellen kann, da er durch den Broker blockiert wird. Dieses erfolgt in RabbitMQ credit-basiert. Danach greift die sogenannte High Watermark (RAM – Std == 40% aufgebraucht). Darüber hinaus ist es sinnvoll, eine Queue-Länge (= wieviele Nachrichten in der Queue sein dürfen) zu definieren, da der Client eine Ausnahme erhält, wenn diese voll wird. Die Message bleibt dann in der Transactional Outbox.

Throttling

Die Last (= CPU-Verbrauch, RAM) in einem verteilten System kann stark schwanken. Eine der Strategien damit umzugehen, ist die Drosselung (Throttling). Dabei wird ab definierten Grenzwerten die spezifische Funktionalität runtergeregelt, also gedrosselt. In Schleupen.CS kommt die Drosselung u.a. an folgenden Stellen zur Anwendung:

  • Die Schleupen.CS-Services werden derzeit im IIS gehostet. Im IIS kann Drosselung einfach eingestellt werden: Siehe Throttling im IIS.
  • In Schleupen.CS gibt einen Baustein namens AutoScaler, der einer Überlastung eines Schleupen.CS 3.0 GPS durch zu viele parallel ausgeführte Bearbeitungsprozesse von Business-Events verhindert. Dieser sorgt also für vertikale Skalierung: Der Business Event Dispatcher (BED) überwacht die Message-Queues im Message Bus eines Schleupen.CS 3.0 Systems, um dort eingestellte Messages abzurufen und die als Message Handler zugeordneten Event-Services zur Bearbeitung aufzurufen. Die Anzahl der parallel bearbeiteten Messages einer Message-Queue wird dabei durch die Anzahl der Consumer-Instanzen des BED bestimmt, die für die Vermittlung der Messages an die Message-Handler zuständig sind.
    Falls die über den Message Bus angelieferten Business Events die Bearbeitungskapazität der Message-Handler übersteigen - z.B. durch plötzliches Auftreten einer großen Anzahl von Events oder falls die Ankunftsrate der Messages die Verarbeitungsrate der Message-Handler für längere Zeit übertrifft - entstehen im BED Timeout-Exceptions, da die Verarbeitung der Messages nicht in allen Fällen rechtzeitig erfolgt. Das Auftreten dieser TimeoutExceptions ist ein Kennzeichen der Überlastung der Message-Handler, die noch dadurch verschärft wird, dass sie Retrys für die Event-Service-Aufrufe nach sich ziehen, die das System noch weiter belasten. Um diese Art von Überlast-Situation zu vermeiden, wird die Anzahl der Consumer-Instanzen durch eine Regelungskomponente - den AutoScaler - dynamisch so angepasst, dass die Kapazität der Message-Handler zur Verarbeitung von Messages nicht überschritten wird und übemäßige parallele Aufrufe und unnötige Wiederholungen von durch Timeouts abgebrochene Verarbeitungen vermieden werden.

Bulkheads

Schleupen.CS als verteilte Anwendung umfasst zahlreiche Services, von denen jeder einen oder mehrere Clients hat. Eine übermäßige Last oder ein Fehler in einem Service wirkt sich ggf. auf alle Nutzer der unterschiedlichen Services aus. Das gilt es zu vermeiden! Ein Bulkhead ist hierzu eine Lösung und man kann sich diesen dabei wie eine Schottwand in einem Schiff vorstellen: bei einem Leck sinkt das Schiff sinkt, sondern nur ein Teil wird geflutet (die Titanic war leider eine schlechte Umsetzung).

Bulkheads kommen in Schleupen.CS beispielsweise im IIS durch getrennte w3wp-Prozesse zur Anwendung (Container wären noch geeigneter, dies befindet sich derzeit im Aufbau). Damit in diesem Beispiel Fehler nicht kaskadieren, ist eine asynchrone Kommunikation vorteilhaft. Daher sind insbesondere Command Services, Event Services geeignete Stilmittel.

Fail-Fast

Fehler, die stark verzögert zu Tage treten, sind schwer zu analysieren und können sich sogar schleichend verbreiten. Daher ist es häufig eine gute Strategie, möglichst früh einen Fehler auszulösen. Diese Vorgehensweise findet an geeigneten Stellen der Implementierung Anwendung.

Siehe hierzu Release It!: Design and Deploy Production-Ready Software : Nygard, Michael.

Konsequenzen

Cookie Consent mit Real Cookie Banner