Eine kleine Bestandsaufnahme

Resilient Software Design: Ein Jahr später …

Uwe Friedrichsen

© iStockphoto.com/Zoran Kolundzija

Vor gut einem Jahr gab es einen Schwerpunkt Resilience im Java Magazin. Dazu gehörte unter anderem ein Artikel von mir, der in das Themengebiet eingeführt hat. Ein gutes Jahr später stellt sich die Frage, was sich seitdem getan hat. Wie hat sich das Thema im Markt entwickelt? Gibt es neue Erkenntnisse? Was fehlt immer noch? Zeit für eine kleine Bestandsaufnahme und ein paar Ergänzungen.

Für alle, die meine Einführung in Ausgabe 5.2015 nicht gelesen haben, zum Einstieg eine ganz kurze Erläuterung, worum es bei Resilient Software Design überhaupt geht: Resilient Software Design lässt sich semi-formal als die Gestaltung und Umsetzung einer softwarebasierten Lösung definieren auf eine Weise, dass der Nutzer bei einer unerwarteten Fehlersituation davon im besten Fall überhaupt nichts bemerkt, und anderenfalls die Lösung in einem definierten, reduzierten Servicelevel weiterarbeitet.

Wichtig ist bei dieser Definition, dass die Lösung mit unerwarteten Fehlersituationen umgehen können muss. In den heutigen verteilten, hochgradig vernetzten Softwarelandschaften ist es nicht mehr möglich, a priori alle möglichen Fehlersituationen vorherzusehen und durch entsprechende Maßnahmen zu vermeiden. Insbesondere kann man sich nicht auf das korrekte Funktionieren von Bausteinen außerhalb des eigenen Prozesskontexts verlassen, sondern muss zu jeder Zeit davon ausgehen, dass sie nicht, nur sporadisch, zu langsam oder falsch reagieren. Tatsächlich kann man sich auch nicht auf das ordnungsgemäße Funktionieren des eigenen Prozesskontexts verlassen. Denn innerhalb eines Prozesses hängt man in der Regel an dem gleichen Lebensfaden wie der aufgerufene Baustein. Stirbt der aufgerufene Baustein oder wird langsamer, geht es einem als Aufrufer genauso. Aufgrund dieser Sondersituation kann man sich im Code deshalb entsprechende Fehlerbehandlungen sparen. Arbeitet man innerhalb eines Prozesses allerdings mit mehreren Threads – was heutzutage häufig der Fall ist –, hat man mit gewissen Einschränkungen die gleichen Probleme wie bei der Nutzung von Bausteinen über Prozessgrenzen hinweg.

Der zweite wichtige Punkt bei dieser Definition ist das Zurückfallen auf einen definierten Servicelevel, falls man nicht in der Lage ist, einen Fehler so zu kompensieren, dass der Nutzer gar nichts davon merkt. Das Schlüsselwort ist „definiert“. Häufig erlebt man Anwendungen, bei denen im Fehlerfall ein eher zufälliges Verhalten zu beobachten ist, das auf Systemdefaults gepaart mit der vom Entwickler für die Fehlerbehandlung eher zufällig gewählten Implementierung basiert. Ein solches Verhalten ist nicht zulässig. Der Nutzer muss stets ein klar definiertes, a priori festgelegtes Verhalten gezeigt bekommen.

Der 100-Prozent-Verfügbarkeit-Irrtum

Das bedeutet im Umkehrschluss, dass das Verhalten des Systems bei technischen Fehlern Teil der fachlichen Systemspezifikation wird. Es muss sich also der Product Owner, der Requirements Engineer, oder wer auch immer das fachliche Verhalten des Systems definiert, Gedanken darüber machen, wie das System bei technischen Fehlern reagieren soll. Kann man im Fehlerfall mit geeigneten fachlichen Defaults weiterarbeiten? Soll eine Fehlerseite angezeigt werden? Oder sind aus fachlicher Sicht weiterreichende Maßnahmen zum Umgang mit der Fehlersituation erforderlich? Dies passiert aber bis heute nur sehr selten. Tatsächlich wird implizit von einer hundertprozentigen Verfügbarkeit der beteiligten Bausteine ausgegangen. Die irrige Idee: Jeder Baustein stellt für sich sicher, dass er maximal verfügbar ist. Dann ist garantiert, dass eine Nutzung des Bausteins erfolgreich ist. Diese Annahme ist hinreichend, wenn eine Anwendung ohne oder mit nur sehr wenig Remote-Kommunikation ihre Leistung erbringen kann. Aber alle Systeme, bei denen eine hohe Verfügbarkeit wichtig ist, bestehen heute in der Regel schon aus mehreren Tiers, wie Webserver, Application Server, Datenbank, diverse Caching-Layer, Proxyserver etc. Zusätzlich nutzen sie häufig Dutzende andere Systeme, um ihre Dienstleistung zu erbringen.

Gehen wir pro benötigtem Baustein von einer Verfügbarkeit von 99,5 Prozent inklusive geplanter Downtimes aus. Und das ist ein ziemlich hoher Wert. Nur wenige Softwarebetreiber sind bereit, so hohe Verfügbarkeiten in ihren SLAs zu garantieren. Bei zehn beteiligten Bausteinen sind wir dann bei einer Gesamtverfügbarkeit von ca. 95 Prozent und bei fünfzig beteiligten Bausteinen – heute keine Seltenheit bei wichtigen Unternehmensanwendungen – bei einer Gesamtverfügbarkeit von ca. 75 Prozent. Dabei sind noch nicht die Ausfälle der Netzwerkinfrastruktur mit eingerechnet, die ebenfalls wesentlich häufiger auftreten, als man üblicherweise annimmt. Für eine Übersicht siehe z. B. Kapitel 2 in [1]. Entsprechend liegt die Gesamtverfügbarkeit des Systems noch unter den genannten Zahlen. Und berücksichtigt man aktuelle Trends wie Microservices, Self-contained Systems, Cloud-Infrastrukturen, Mobile First und das aufkeimende Internet of Things, wird ganz schnell klar, dass die Anzahl der Bausteine eher noch wachsen wird.

Kurzum: Die hundertprozentige Verfügbarkeitsannahme genutzter Bausteine ist keine Option. Trotzdem trifft man sie noch allenthalben an. Das beginnt bei Fachexperten, die versuchen, sich mit Kommentaren wie „Das ist die Verantwortung von euch IT-lern, dafür zu sorgen, dass das nicht passiert“ um das Thema zu drücken, und geht bis zu Entwicklern, von denen man hört „Ach was, das ist doch ein Cluster und so hoch verfügbar, da muss ich die Zugriffe nicht absichern“. Das habe ich noch vor wenigen Wochen in einem Projekt fast im Wortlaut zu hören bekommen. Dieser Irrglaube ist der hartnäckigste Feind von Resilient Software Design. Solange alle Parteien außerhalb des Betriebs das Problem ignorieren oder kleinzureden versuchen, weil es gerade Wichtigeres zu tun gibt, wird die Situation wahrscheinlich nicht besser werden. An dieser Front gibt es also noch einiges an Überzeugungsarbeit zu leisten.

Das Dev-vs-Ops-Dilemma

Der zweite große Feind von Resilient Software Design ist die in den meisten Unternehmen immer noch existierende Mauer zwischen der Entwicklung (Dev) und dem Betrieb (Ops). Stabilitäts- und Verfügbarkeitsprobleme werden in der Produktion sichtbar, müssen aber bereits während der Entwicklung aktiv adressiert werden. Da der Betrieb sich in der IT-Wertschöpfungskette aber hinter der Entwicklung befindet – im schlimmsten Fall noch über eine dedizierte Qualitätssicherung voneinander getrennt –, benötigt man einen aktiven Informationsrückfluss aus der Produktion in die Entwicklung, um dort Verfügbarkeitsdefizite sichtbar zu machen.

In der Praxis sind Entwicklung und Betrieb in den meisten Unternehmen aber immer noch bis zur Vorstandsebene strikt voneinander getrennt. Organisatorisch sind keine direkte Kommunikation und damit auch keine Feedbackschleifen erwünscht. Zusätzlich werden die beiden Bereiche für konkurrierende Ziele belohnt; die Entwicklung nämlich für die Umsetzung möglichst vieler Fachfeatures und der Betrieb für eine möglichst hohe Stabilität. Wenn also ein Entwickler im Prinzip dafür bestraft wird, wenn er sich Gedanken um Betriebsstabilität macht, ist es nicht verwunderlich, wenn das Thema Anwendungsrobustheit in der Entwicklung auf der Strecke bleibt.

Die Absurdität dieser Konstellation wird so richtig offensichtlich, wenn man bedenkt, dass all die hektisch zusammengedengelte Software, all die vielen in Entwicklung programmierten Fachfeatures nur dann einen Mehrwert haben, wenn die Software auch zuverlässig in Produktion läuft. Anwendungen, die nicht, nicht zuverlässig oder langsam in Produktion laufen, liefern keinen oder nur einen stark reduzierten Geschäftswert. So gesehen müsste jedem Anforderer dringend daran gelegen sein, sich Gedanken dazu zu machen, wie die Anwendung reagieren soll, wenn ein technischer Fehler auftritt – insbesondere bei entfernten Aufrufen. Aufgrund der fehlenden Feedbackschleife aus der Produktion passiert das aber nicht.

Der gute Teil der Nachricht ist, dass Ansätze, die die benötigte Feedbackschleife vom Betrieb zur Entwicklung enthalten und verstärken, langsam an Popularität gewinnen. Das sind zum einen DevOps [2] und zum anderen Site Reliability Engineering (SRE) [3]. DevOps versucht als ganzheitliches Wertesystem, Durchlaufzeiten durch die IT-Wertschöpfungskette von der Idee bis der Kunde das Ergebnis sieht zu minimieren. Dazu zählt letztlich auch eine möglichst gute Verfügbarkeit des Ergebnisses, weil der Kunde sonst das Ergebnis nicht sieht und so kein Mehrwert erzeugt wird. SRE fokussiert auf die Balance zwischen Verfügbarkeit und neuen Features. SRE wurde von Google entwickelt und besteht im Kern aus einer Reihe von konkreten Prinzipien, die dafür sorgen, dass bei sinkender Verfügbarkeit der Systeme die Entwicklungskapazitäten stärker auf die Robustheit der Anwendung konzentriert werden, während bei hoher Verfügbarkeit mehr Energie in die Entwicklung neuer Fachfeatures gesteckt werden kann. Google hat die Prinzipien über Jahre hinweg immer weiter verfeinert, um damit einen selbst balancierenden Prozess zu implementieren.

Aufgrund seiner Konkretheit ist SRE für traditionelle Unternehmen häufig leichter zu verstehen und aufzugreifen als DevOps. DevOps auf der anderen Seite adressiert ein wesentlich weiteres Themenspektrum. Da es aber im Kern ein radikales Umdenken von reiner Kosteneffizienz auf Durchlaufzeiten erfordert, tun sich viele traditionelle Unternehmen sehr schwer damit.

Man sieht zwar viele Unternehmen, die DevOps machen wollen, aber häufig endet das schnell in Karikaturen der eigentlichen DevOps-Ideen. Da hört man dann markige „You build it, you run it“-Parolen aus Managementkreisen. Aber alles andere, was dazu gehört, wird geflissentlich ignoriert. Und am Ende ist nichts schneller oder besser geworden, sondern man hat nur die guten Entwickler vergrault. Das ist in Bezug auf robustes Softwaredesign auch keine Hilfe.

Auch hier ist also noch einiges an Überzeugungsarbeit zu leisten, bevor die notwendigen Voraussetzungen für ein funktionierendes und nachhaltiges Resilient Software Design geschaffen sind. Es gibt zwar vielversprechende Ansätze, aber bis die Mauer zwischen Entwicklung und Betrieb in den Köpfen der Entscheider und in der IT-Organisation eingerissen ist, wird in den meisten Unternehmen wohl noch einige Zeit vergehen.

Design for Isolation

Aber nehmen wir einmal an, die Probleme wären gelöst. Können wir dann jetzt endlich mal Resilience-Code schreiben? Es ist immerhin schon der halbe Artikel herum und es gab noch keinen Code – sehr ungewöhnlich für das Java Magazin. Die Antwort darauf ist leider „Nein“. Wenn Sie sich jetzt endgültig frustriert abwenden, verstehe ich das und wünsche ich Ihnen ein wenig Aufheiterung und mehr Spaß mit den restlichen Artikeln des Hefts. Für diejenigen, die jetzt noch da sind, kommt nun die Erklärung, warum wir jetzt immer noch keinen Code schreiben können. Das Problem liegt in dem Muster Bulkhead. Dies ist das elementarste Resilience-Muster. Alle anderen Muster bauen darauf auf. Die Beschreibung des Musters liest sich recht einfach: „Um zu vermeiden, dass eine Anwendung als Ganzes ausfällt, trenne sie in mehrere Teile (die Bulkheads) auf und isoliere die Teile gegeneinander, sodass keine kaskadierenden Fehler entstehen.“

Kaskadierende Fehler sind Fehler, die in einem Baustein entstehen und sich in andere Bausteine fortpflanzen. Ein klassisches Beispiel für einen kaskadierenden Fehler ist, wenn ein Problem in der Datenbank dazu führt, dass alle Worker Threads im Application Server blockieren, was wiederum die Worker Threads im Webserver blockiert, was dem Anwender in seinem Browserfenster die Sanduhr beschert. Hier pflanzt sich der Fehler aus der Datenbank über Application Server und Webserver bis hin zum Anwender fort.

Was ist jetzt so schwer daran? Einfach die Anwendungs-Tiers gegeneinander isolieren und gut ist, oder? Ganz so einfach ist es leider nicht. Das Beispiel beschreibt eine monolithische Anwendung, die einfach nur in mehrere technische Tiers aufgeteilt ist. Monolithen haben aber, wenn sie größer werden, diverse bekannte Nachteile. Deshalb versucht man heute, größere Anwendungen zu modularisieren, bis hin zu vollständigen Servicelandschaften. Und hier wird es kompliziert.

Die Frage ist nämlich: Wie zerlege ich die Anwendung, um die entstehenden Bausteine möglichst gut gegen kaskadierende Fehler isolieren zu können? Der tollste Isolationscode hilft nämlich überhaupt nichts, wenn Baustein A immer Baustein B benötigt, um seine Dienstleistung zu erbringen. Dann ist Baustein A fachlich eng mit Baustein B gekoppelt und alle technische Isolation ist für die Katz.

Hier kommt das fachliche Design ins Spiel. Wie zerlege ich meine fachlichen Verantwortlichkeiten so, dass die einzelnen Bausteine möglichst unabhängig voneinander funktionieren und dass keine langen Aufrufketten entstehen? Im Prinzip wird man also auf die Grundprinzipien guter Modularisierung zurückgeworfen, insbesondere auf Themen wie Separation of Concerns oder Low Coupling/High Cohesion. Das Problem damit ist, das die meisten zwar wissen, was das ist, aber bis heute kaum jemand ein Verständnis davon hat, wie man das konkret umsetzt.

Eine gewisse Hilfe bieten die Konzepte und Muster aus Domain-driven Design (DDD) [4]. Aber auch sie sind keine Wunderwaffe. Nicht umsonst betont Eric Evans immer wieder, dass es viel Zeit und Energie bedarf, eine (Fach-)Domäne so zu durchdringen und zu verstehen, bis man in der Lage ist, eine gute Zerlegung dafür zu finden. Denn eine gute Zerlegung für die Funktionalität zu finden, die einem eine funktionierende Isolation der einzelnen Bausteine (Bulkheads) gegen kaskadierende Fehler ermöglicht, ist mindestens die halbe Miete. Entsprechend kann man dieses Thema nicht genug betonen, auch wenn es nichts direkt mit Code zu tun hat, sondern eher mit Papier, Bleistift sowie viel Hirnschmalz und Schweiß.

Die richtigen Muster wählen

Nehmen wir einmal an, dass wir auch diese Hürde gemeistert haben und eine gute fachliche Zerlegung des Systems gefunden haben. Kommen wir jetzt endlich zum Code? Nun ja, fast. Eine letzte Hürde liegt noch auf unserem Weg, nämlich die richtigen Muster zu finden. Was sind die besten Maßnahmen, um die gefundenen Bausteine (Bulkheads) gegen kaskadierende Fehler zu schützen und die Gesamtverfügbarkeit des Systems zu maximieren? Viel hilft nicht immer viel. So ist das auch hier. Jedes Resilience-Muster, das man implementiert, erhöht die Komplexität des Codes. Außerdem erhöht es in der Regel auch die Komplexität und die Kosten zur Laufzeit. Sehr viele Muster basieren nämlich auf Redundanz. Das bedeutet, dass die betroffenen Ressourcen zur Laufzeit mehrfach benötigt werden.

Wie alle Architektur- und Designentscheidungen haben auch Resilience-Muster Trade-offs: Man gewinnt Robustheit durch ihren Einsatz, aber man zahlt an anderer Stelle auch einen Preis dafür. Also: Wie viele Muster sollten wir nutzen und welche? Wie so häufig gibt es auch auf diese Frage keine einfache Antwort, weil es immer auf die konkreten Robustheitsanforderungen des betreffenden Systems ankommt. Entsprechend kann ich hier nur eine einfache Heuristik anbieten, um sich der Antwort auf die Frage anzunähern. Bei dieser Heuristik kommen zwei Hilfsmittel zum Einsatz: eine Mustertaxonomie und Szenarien mit den wichtigsten Fehlersituationen. Die Taxonomie ist in Abbildung 1 dargestellt und teilt die bestehenden Resilience-Muster in verschiedene Klassen ein:

  • Error Detection: Das Erkennen eines internen Fehlers, bevor er außerhalb der Systemgrenzen sichtbar wird. Mit diesem Schritt, dem Erkennen des Problems, beginnt die Fehlerbehandlung immer.
  • Error Recovery: Das vollständige Beheben eines erkannten Fehlers, sofern man dafür genügend Wissen und Zeit hat.
  • Error Mitigation: Das Eindämmen oder Abmildern der Folgen eines erkannten Fehlers, ohne ihn abschließend zu beheben, weil Wissen oder Zeit fehlt.
  • Error Prevention: Reduzieren der Wahrscheinlichkeit, dass ein Fehler überhaupt erst auftritt.
  • Fault Treatment: Die Ursache (root cause) eines entstandenen Fehlers beheben. Das ist insbesondere dann relevant, wenn man den Fehler zuvor nur abmildern, aber nicht abschließend beheben konnte.
  • Architecture: Ergänzende architektonische Muster, die die Umsetzung von Mustern aus den zuvor genannten Klassen unterstützen.
Abb. 1: Eine einfache Taxonomie für Resilience-Muster

Abb. 1: Eine einfache Taxonomie für Resilience-Muster

In diese Klassen lassen sich die meisten Resilience-Muster eingruppieren. Eine unvollständige Übersicht findet sich in meinen Präsentationen „Patterns of Resilience“ und „Resilience reloaded„. Um daraus die geeigneten Muster für den eigenen Kontext auszuwählen, kommen die Szenarien mit den wichtigsten Fehlersituationen zum Einsatz. Die Szenarien findet man, indem man sich seine fachlichen Use Cases vornimmt, sie gegen die Systemzerlegung hält und sich fragt, an welchen Stellen etwas schiefgehen kann. So könnte ein entfernter Baustein nicht, instabil, zu langsam oder falsch antworten. Daraus kann man dann die wichtigsten Szenarien extrahieren, auf die man auf jeden Fall automatisiert innerhalb der Anwendung reagieren will. Als Daumenregel sollten das fünf, maximal zehn Szenarien sein. Diese Szenarien kombiniert man dann mit der Taxonomie, indem man die folgenden Fragen für jedes Szenario beantwortet:

  • Wie will man den Fehler erkennen? Welches Muster aus der Klasse Error Detection ist dafür am besten geeignet?
  • Kann der Fehler direkt behoben werden? Wenn ja, mit welchem Muster aus der Klasse Error Recovery?
  • Wenn nein, mit welchem Muster aus der Klasse Error Mitigation lässt sich der Fehler am besten abmildern?
  • Soll die Wahrscheinlichkeit des Auftretens des Fehlers reduziert werden (Error Prevention)?

Nachdem man alle Szenarien durchgegangen ist, hat man seine initialen Muster an der Hand. Übergreifend überlegt man dann noch:

  • Kann oder muss ich die ausgewählten Muster durch geeignete Muster der Klasse Architecture unterstützen?
  • Benötige ich gezielte Maßnahmen für die Beseitigung der Fehlerursache (Fault Treatment)?

Hat man das gemacht und die Auswahl gegebenenfalls auf dem Weg konsolidiert, landet man normalerweise bei etwa sechs bis zwölf Mustern. Deutlich weniger Muster sind nur in sehr eingeschränkten Spezialfällen sinnvoll. Hat man deutlich mehr Muster, sollte man aus Komplexitäts- und Kostengründen noch einmal kritisch hinterfragen, ob das Kosten-/Nutzen-Verhältnis noch gewährleistet ist oder ob man die Robustheit zu Lasten anderer Systemeigenschaften zu sehr optimiert hat. Wichtig ist bei alledem, dass das kein wasserfallartiger Prozess ist, sondern ein evolutionäres Vorgehen. Sowohl das initiale Design der Bulkheads als auch die Musterauswahl werden beim ersten Mal nicht perfekt sein. Entsprechend sollte man auch nur gerade genug Zeit auf diesen ersten Wurf verwenden.

Viel wichtiger ist, sich in der Praxis unterstützt durch ein gutes Monitoring und gegebenenfalls auch aktive Fehlersimulationen anzusehen, ob die Maßnahmen wie geplant greifen. Dabei wird man immer wieder Lücken entdecken und entsprechend sowohl Design als auch Musterauswahl und -umsetzung evolutionär anpassen müssen (Abb. 2).

Abb. 2: Der evolutionäre resiliente Anwendungsentwurf

Abb. 2: Der evolutionäre resiliente Anwendungsentwurf

Fazit

Jetzt könnten wir endlich zum Code kommen, aber leider sind wir am Ende des Artikels angekommen. Tatsächlich habe ich aber in der Zeit, in der ich mit dem Thema unterwegs bin, immer wieder festgestellt, dass die Codeebene nicht das Problem ist. Ja, eine Resilience-Bibliothek wie Hystrix hat eine gewisse Lernkurve, und es gibt bessere und schlechtere Wege, sie zu nutzen. Aber das haben die meisten Entwickler, die mir begegnet sind, von sich aus gut in den Griff bekommen. Die eigentlichen Probleme auf dem Weg zu einem guten Resilient Software Design liegen aber in den Punkten, die ich beschrieben habe:

  • Das hartnäckige Festhalten an der irrigen 100-Prozent-Verfügbarkeit-Annahme bei allen an der Softwareentwicklung beteiligten Parteien.
  • Die Mauer zwischen Entwicklung und Betrieb in den meisten Unternehmen inklusive konkurrierender Zielvorgaben. Das verhindert eine wertschöpfende Zusammenarbeit und die notwendigen Feedbackschleifen.
  • Das Unterschätzen der Konsequenzen des Bulkhead-Musters und die daraus resultierenden Defizite beim Design von Bulkheads.

Hier gibt es aus meiner Sicht noch viel zu tun. Denn das sind die Grundvoraussetzungen für ein gutes Resilient Software Design. Erst wenn diese Herausforderungen gemeistert sind, ist es sinnvoll, sich mit den einzelnen Mustern auseinanderzusetzen und ein geeignetes Set für die konkrete Aufgabenstellung zu identifizieren. Und erst dann kommt der Teil, den wir alle so lieben: der Code (Abb. 3). Aber da sind wir, außer bei abzählbar wenigen Unternehmen, aktuell noch lange nicht angekommen. Schauen wir mal, wo wir in einem Jahr oder zwei stehen. Vielleicht sind wir dann so weit, gemeinsam über den Code zu fachsimpeln. Ich würde mich freuen.

Abb. 3: Der weite und steinige Weg zu Resilient Software Design

Abb. 3: Der weite und steinige Weg zu Resilient Software Design

Verwandte Themen:

Geschrieben von
Uwe Friedrichsen
Uwe Friedrichsen
  Uwe Friedrichsen ist ein langjähriger Reisender in der IT-Welt. Als CTO der codecentric AG darf er seine Neugierde auf neue Ansätze und Konzepte sowie seine Lust am Andersdenken ausleben. Seine aktuellen Schwerpunktthemen sind agile Architektur und verteilte, hochskalierbare Systeme. Er ist außerdem Autor diverser Artikel und diskutiert seine Ideen regelmäßig auf Konferenzen.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.