Kapitel 1. Die Begegnung mit komplexen Systemen
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
In erkunden wir im ersten Teil dieses Kapitels die Probleme, die beim Umgang mit komplexen Systemen auftreten. Chaos Engineering wurde aus der Notwendigkeit heraus geboren, ein komplexes verteiltes Softwaresystem zu betreiben. Es befasst sich speziell mit den Anforderungen, die der Betrieb eines komplexen Systems mit sich bringt, nämlich dass diese Systeme nichtlinear sind, was sie unvorhersehbar macht und wiederum zu unerwünschten Ergebnissen führt. Das ist für uns Ingenieure oft unangenehm, weil wir gerne glauben, dass wir die Ungewissheit durch Planung umgehen können. Wir sind oft versucht, die Schuld für dieses unerwünschte Verhalten auf die Menschen zu schieben, die die Systeme bauen und betreiben, aber in Wirklichkeit sind Überraschungen eine natürliche Eigenschaft komplexer Systeme. Im weiteren Verlauf dieses Kapitels stellen wir die Frage, ob wir die Komplexität aus dem System herausnehmen können und damit auch die unerwünschten Verhaltensweisen beseitigen können. (Spoiler: Nein, das können wir nicht.)
Komplexität bedenken
Bevor du entscheiden kannst, ob Chaos Engineering für dein System sinnvoll ist, musst du verstehen, wo die Grenze zwischen einfach und komplex zu ziehen ist. Eine Möglichkeit, ein System zu charakterisieren, ist die Art und Weise, wie Änderungen am Input des Systems mit Änderungen am Output korrespondieren. Einfache Systeme werden oft als linear beschrieben. Eine Änderung des Inputs eines linearen Systems führt zu einer entsprechenden Änderung des Outputs des Systems. Viele natürliche Phänomene stellen bekannte lineare Systeme dar. Je härter du einen Ball wirfst, desto weiter fliegt er.
Nichtlineare Systeme haben einen Output, der je nach Veränderung der einzelnen Komponenten stark schwankt. Der Bullwhip-Effekt ist ein Beispiel aus dem Systemdenken1 das diese Wechselwirkung veranschaulicht: Eine Bewegung des Handgelenks (kleine Veränderung des Systeminputs) führt dazu, dass das andere Ende der Peitsche in einem Moment so viel Strecke zurücklegt, dass die Schallgeschwindigkeit überschritten wird und das krachende Geräusch entsteht, für das Peitschen bekannt sind (große Veränderung des Systemoutputs).
Nichtlineare Effekte können verschiedene Formen annehmen: Änderungen an Systemteilen können zu exponentiellen Änderungen des Outputs führen, wie z. B. soziale Netzwerke, die schneller wachsen, wenn sie groß sind, als wenn sie klein sind; oder sie können zu Quantenänderungen des Outputs führen, wie z. B. die Anwendung zunehmender Kraft auf einen trockenen Stock, der sich nicht bewegt, bis er plötzlich bricht; oder sie können zu scheinbar zufälligem Output führen, wie z. B. ein fröhlicher Song, der jemanden an einem Tag beim Training inspiriert, ihn am nächsten Tag aber langweilt.
Lineare Systeme lassen sich natürlich leichter vorhersagen als nichtlineare Systeme. Es ist oft relativ einfach, den Ausgang eines linearen Systems zu erahnen, besonders wenn man mit einem der Teile interagiert und den linearen Ausgang erlebt hat. Aus diesem Grund können wir sagen, dass lineare Systeme einfache Systeme sind. Im Gegensatz dazu zeigen nichtlineare Systeme ein unvorhersehbares Verhalten, vor allem wenn mehrere nichtlineare Teile nebeneinander existieren. Sich überschneidende nichtlineare Teile können dazu führen, dass die Systemleistung bis zu einem bestimmten Punkt steigt, dann plötzlich umschlägt und dann ebenso plötzlich ganz aufhört. Wir sagen, diese nichtlinearen Systeme sind komplex.
Eine andere Art, Systeme zu charakterisieren, ist weniger technisch und subjektiver, aber wahrscheinlich intuitiver. Ein einfaches System ist ein System, bei dem eine Person alle Teile verstehen kann, wie sie funktionieren und wie sie zum Ergebnis beitragen. Ein komplexes System hingegen hat so viele bewegliche Teile oder die Teile ändern sich so schnell, dass kein Mensch in der Lage ist, ein mentales Modell davon im Kopf zu behalten. Siehe Tabelle 1-1.
Einfache Systeme | Komplexe Systeme |
---|---|
Linear | Nichtlinear |
Vorhersehbare Leistung | Unvorhersehbares Verhalten |
Nachvollziehbar | Unmöglich, ein vollständiges mentales Modell zu erstellen |
Wenn man sich die Eigenschaften komplexer Systeme ansieht, wird schnell klar, warum herkömmliche Methoden zur Erforschung der Systemsicherheit unzureichend sind. Nichtlinearer Output ist schwer zu simulieren oder genau zu modellieren. Die Ergebnisse sind unvorhersehbar. Menschen können sie nicht mental modellieren.
In der Welt der Software ist es nicht ungewöhnlich, mit komplexen Systemen zu arbeiten, die diese Merkmale aufweisen. Eine Folge des Gesetzes der erforderlichen Vielfalt2 ist, dass jedes Kontrollsystem mindestens so komplex sein muss wie das System, das es steuert. Da die meiste Software das Schreiben von Steuerungssystemen beinhaltet, nimmt die Komplexität mit der Zeit zu, wenn man Software entwickelt. Wenn du in der Softwarebranche arbeitest und heute noch nicht mit komplexen Systemen zu tun hast, ist es immer wahrscheinlicher, dass du es irgendwann tun wirst.
Eine Folge der Zunahme komplexer Systeme ist, dass die traditionelle Rolle des Softwarearchitekten mit der Zeit an Bedeutung verliert. Bei einfachen Systemen kann eine Person, in der Regel ein erfahrener Ingenieur, die Arbeit von mehreren Ingenieuren koordinieren. Die Rolle des Architekten hat sich weiterentwickelt, weil diese Person das gesamte System im Kopf modellieren kann und weiß, wie alle Teile zusammenpassen. Er kann als Leitfaden und Planer dafür dienen, wie die Funktionen geschrieben werden und wie sich die Technologie in einem Softwareprojekt im Laufe der Zeit entwickelt.
Bei komplexen Systemen erkennen wir an, dass eine Person nicht alle Teile im Kopf haben kann. Das bedeutet, dass Softwareentwickler/innen stärker an der Gestaltung des Systems beteiligt sein müssen. Historisch gesehen ist der Beruf des Ingenieurs ein bürokratischer Beruf: Einige Leute entscheiden, welche Arbeit erledigt werden muss, andere entscheiden, wie und wann sie erledigt wird, und wieder andere erledigen die eigentliche Arbeit. In komplexen Systemen ist diese Arbeitsteilung kontraproduktiv, weil die Menschen, die den meisten Kontext haben, auch die eigentliche Arbeit machen. Die Rolle der Architekten und die damit verbundene Bürokratie wird weniger effizient. Komplexe Systeme ermutigen unbürokratische Organisationsstrukturen dazu, sie effektiv aufzubauen, mit ihnen zu interagieren und auf sie zu reagieren.
Die Begegnung mit der Komplexität
Die unvorhersehbare, unverständliche Natur komplexer Systeme stellt neue Herausforderungen dar. Die folgenden Abschnitte enthalten drei Beispiele für Ausfälle, die durch komplexe Wechselwirkungen verursacht wurden. In jedem dieser Fälle würden wir nicht erwarten, dass ein vernünftiges Ingenieursteam die unerwünschte Interaktion im Voraus erkennen kann.
Beispiel 1: Ungleichgewicht zwischen Geschäftslogik und Anwendungslogik
Betrachte die hier beschriebene und in Abbildung 1-1 dargestellte Microservice-Architektur. In diesem System haben wir vier Komponenten:
- Service P
- Speichert personalisierte Informationen. Eine ID steht für eine Person und einige Metadaten, die mit dieser Person verbunden sind. Der Einfachheit halber sind die gespeicherten Metadaten nie sehr groß, und die Personen werden nie aus dem System entfernt. P übergibt die Daten an Q, um sie zu persistieren.
- Dienstleistung Q
- Ein allgemeiner Speicherdienst, der von mehreren vorgelagerten Diensten genutzt wird. Er speichert Daten in einer persistenten Datenbank für Fehlertoleranz und Wiederherstellung und in einer speicherbasierten Cache-Datenbank für Geschwindigkeit.
- Service S
- Eine Datenbank mit persistenter Speicherung, vielleicht ein spaltenbasiertes Speichersystem wie Cassandra oder DynamoDB.
- Dienst T
- Ein In-Memory-Cache, vielleicht so etwas wie Redis oder Memcached.
Um diesem System ein paar vernünftige Fallbacks hinzuzufügen, rechnen die für die einzelnen Komponenten zuständigen Teams mit Ausfällen. Der Dienst Q schreibt Daten an beide Dienste: S und T. Wenn er Daten abruft, liest er zuerst von Dienst T, weil das schneller geht. Wenn der Cache aus irgendeinem Grund fehlschlägt, liest er aus dem Dienst S. Wenn sowohl der Dienst T als auch der Dienst S fehlschlagen, kann er eine Standardantwort für die Datenbank zurücksenden.
Ebenso hat der Dienst P rationale Ausweichmöglichkeiten. Wenn Q eine Zeitüberschreitung oder einen Fehler meldet, kann P eine Standardantwort zurückgeben und so die Leistung verbessern. Zum Beispiel könnte P unpersonalisierte Metadaten für eine bestimmte Person zurückgeben, wenn Q fehlschlägt.
Eines Tages schlägt T fehl(Abbildung 1-2). Die Suchvorgänge in P werden langsamer, weil Q merkt, dass T nicht mehr reagiert, und deshalb auf das Lesen von S umsteigt. Leider ist es bei diesem System üblich, dass Systeme mit großen Caches eine hohe Lesebelastung aufweisen. In diesem Fall konnte T die Leselast recht gut bewältigen, weil das Lesen direkt aus dem Speicher schnell ist, aber S ist nicht darauf vorbereitet, diese plötzliche Zunahme der Arbeitslast zu bewältigen. S wird langsamer und schlägt schließlich fehl. Diese Anfragen werden abgebrochen.
Glücklicherweise war Q auch darauf vorbereitet und gibt daher eine Standardantwort zurück. Die Standardantwort für eine bestimmte Version von Cassandra, wenn ein Datenobjekt gesucht wird und alle drei Replikate nicht verfügbar sind, ist ein 404 [Not Found]-Antwortcode, also sendet Q einen 404 an P.
P weiß, dass die Person, nach der es sucht, existiert, weil es eine ID hat. Personen werden nie aus dem Dienst entfernt. Die Antwort 404 [Not Found], die P von Q erhält, ist daher aufgrund der Geschäftslogik eine unmögliche Bedingung(Abbildung 1-3). P hätte einen Fehler von Q oder sogar eine fehlende Antwort verarbeiten können, aber es gibt keine Bedingung, um diese unmögliche Antwort abzufangen. P stürzt ab und reißt das gesamte System mit sich(Abbildung 1-4).
Was ist der Fehler in diesem Szenario? Dass das gesamte System zusammenbricht, ist offensichtlich ein unerwünschtes Systemverhalten. Es handelt sich um ein komplexes System, bei dem wir zugeben müssen, dass niemand alle beweglichen Teile im Blick haben kann. Jedes der Teams, die für P, Q, S und T verantwortlich sind, hat vernünftige Entscheidungen getroffen. Sie haben sogar einen zusätzlichen Schritt unternommen, um Fehler vorauszusehen, diese abzufangen und sie zu beheben. Was ist also die Schuld?
Niemand ist schuld und kein Dienst ist schuld. Es gibt nichts zu tadeln. Es handelt sich um ein gut gebautes System. Es wäre unvernünftig zu erwarten, dass die Ingenieure dieses Versagen hätten vorhersehen müssen, denn das Zusammenspiel der Komponenten übersteigt die Fähigkeit eines Menschen, alle Teile im Kopf zu behalten, und führt unweigerlich zu Lücken in den Annahmen darüber, was andere Menschen im Team wissen könnten. Das unerwünschte Ergebnis dieses komplexen Systems ist ein Ausreißer, der durch nichtlineare Faktoren verursacht wird.
Schauen wir uns ein anderes Beispiel an.
Beispiel 2: Kundeninduzierter Retry Storm
Betrachte den folgenden Ausschnitt aus einem verteilten System eines Film-Streaming-Dienstes(Abbildung 1-5). In diesem System gibt es zwei Hauptsubsysteme:
- System R
- Speichert eine personalisierte Benutzeroberfläche. Bei einer ID, die für eine Person steht, wird eine Benutzeroberfläche zurückgegeben, die an die Filmvorlieben dieser Person angepasst ist. R ruft S für zusätzliche Informationen über jede Person auf.
- System S
- Speichert eine Vielzahl von Informationen über die Nutzer, z. B. ob sie ein gültiges Konto haben und was sie sehen dürfen. Das sind zu viele Daten, um sie auf einer Instanz oder virtuellen Maschine unterzubringen. Deshalb trennt S den Zugriff sowie das Lesen und Schreiben in zwei Teilkomponenten:
- S-L
- Load Balancer, der einen konsistenten Hash-Algorithmus verwendet, um die leseschwere Last auf die S-D Komponenten zu verteilen.
- S-D
- Eine Speichereinheit, die einen kleinen Teil des gesamten Datensatzes enthält. Eine Instanz von S-D könnte zum Beispiel Informationen über alle Nutzer/innen haben, deren Namen mit dem Buchstaben "m" beginnen, während eine andere die Nutzer/innen speichert, deren Namen mit dem Buchstaben "p" beginnen.3
Das Team, das dies pflegt, hat Erfahrung mit verteilten Systemen und Industrienormen für die Cloud-Bereitstellung. Dazu gehören Maßnahmen wie vernünftige Fallbacks. Wenn R keine Informationen über eine Person von S abrufen kann, hat es eine Standard-Benutzeroberfläche. Beide Systeme achten auch auf die Kosten und haben daher Skalierungsrichtlinien, die die Cluster in einer angemessenen Größe halten. Wenn beispielsweise die Festplatten-E/A auf S-D unter einen bestimmten Schwellenwert fällt, gibt S-D die Daten des am wenigsten ausgelasteten Knotens ab und schaltet diesen ab. Die Daten von S-D werden in einem redundanten Cache auf dem Knoten gespeichert. Wenn also die Festplatte aus irgendeinem Grund langsam ist, kann ein etwas veraltetes Ergebnis aus dem Cache zurückgegeben werden. Warnungen werden bei erhöhten Fehlerquoten ausgelöst, die Ausreißererkennung startet Instanzen, die sich seltsam verhalten, neu usw.
Eines Tages schaut sich ein Kunde, den wir Louis nennen, unter nicht optimalen Bedingungen Streaming-Videos von diesem Dienst an. Louis greift über einen Webbrowser auf seinem Laptop in einem Zug auf das System zu. Irgendwann passiert etwas Seltsames in dem Video und überrascht Louis. Er lässt seinen Laptop auf den Boden fallen, drückt einige Tasten und als er den Laptop wieder aufstellt, um weiterzuschauen, ist das Video eingefroren.
Louis tut, was jeder vernünftige Kunde in dieser Situation tun würde, und drückt 100 Mal auf die Aktualisierungsschaltfläche. Die Anfragen werden im Webbrowser in die Warteschlange gestellt, aber in diesem Moment befindet sich der Zug zwischen zwei Mobilfunkmasten, so dass eine Netzabtrennung verhindert, dass die Anfragen zugestellt werden. Wenn das WiFi-Signal wieder da ist, werden alle 100 Anfragen auf einmal zugestellt.
Zurück auf der Serverseite empfängt R alle 100 Anfragen und leitet 100 gleiche Anfragen an S-L weiter, der den konsistenten Hash von Louis' ID verwendet, um alle diese Anfragen an einen bestimmten Knoten in S-D weiterzuleiten, den wir S-D-N nennen. 100 Anfragen auf einmal zu erhalten, ist ein erheblicher Anstieg, da S-D-N daran gewöhnt ist, einen Basiswert von 50 Anfragen pro Sekunde zu erhalten. Das ist eine Verdreifachung der Grundlast, aber zum Glück haben wir vernünftige Ausweich- und Degradierungsmaßnahmen getroffen.
S-D-N kann nicht 150 Anfragen (Basis plus Louis) in einer Sekunde von der Festplatte bedienen, also beginnt es, Anfragen aus dem Cache zu bedienen. Das ist deutlich schneller. Infolgedessen sinken sowohl die Festplatten-E/A als auch die CPU-Auslastung drastisch. An diesem Punkt setzen die Skalierungsrichtlinien ein, um das System aus Kostengründen in der richtigen Größe zu halten. Da die Festplatten-E/A- und CPU-Auslastung so niedrig ist, beschließt S-D, S-D-N abzuschalten und seine Arbeitslast an einen anderen Knoten zu übergeben. Oder vielleicht hat die Anomalieerkennung diesen Knoten abgeschaltet; in komplexen Systemen ist das manchmal schwer zu sagen(Abbildung 1-6).
S-L antwortet auf 99 von Louis' Anfragen, die alle aus dem Cache von S-D-N stammen. Die 100. Antwort geht jedoch verloren, da die Konfiguration des Clusters geändert wurde, als S-D-N heruntergefahren wurde und die Datenübergabe stattfand. Da R für diese letzte Antwort einen Timeout-Fehler von S-L erhält, gibt es eine Standard-Benutzeroberfläche zurück, anstatt die personalisierte Benutzeroberfläche für Louis.
Zurück auf seinem Laptop ignoriert der Webbrowser von Louis die 99 richtigen Antworten und zeigt die 100ste Antwort an, die Standard-Benutzeroberfläche. Für Louis scheint dies ein weiterer Fehler zu sein, denn es ist nicht die personalisierte Benutzeroberfläche, an die er gewöhnt ist.
Louis tut, was jeder vernünftige Kunde in dieser Situation tun würde, und drückt die Aktualisierungsschaltfläche weitere 100 Mal. Dieses Mal wiederholt sich der Vorgang, aber S-L leitet die Anfragen an S-D-M weiter, das von S-D-N übernommen wurde. Leider ist die Datenübergabe noch nicht abgeschlossen, so dass die Festplatte auf S-D-M schnell überlastet ist.
S-D-M geht dazu über, Anfragen aus dem Cache zu bedienen. Wenn du die Prozedur von S-D-N wiederholst, beschleunigt das die Anfragen erheblich. Festplatten-E/A und CPU-Auslastung sinken dramatisch. Die Skalierungsrichtlinien greifen und S-D beschließt, S-D-M abzuschalten und seine Arbeitslast an einen anderen Knoten zu übergeben(Abbildung 1-7).
S-D hat jetzt eine Datenübergabesituation für zwei Knotenpunkte in der Luft. Diese Knoten sind nicht nur für den Louis-Benutzer zuständig, sondern für einen Prozentsatz aller Benutzer. R erhält mehr Timeout-Fehler von S-L für diesen Prozentsatz von Nutzern, so dass R eine Standard-Benutzeroberfläche statt der personalisierten Benutzeroberfläche für diese Nutzer zurückgibt.
Zurück auf ihren Client-Geräten haben diese Nutzer/innen nun ein ähnliches Erlebnis wie Louis. Für viele von ihnen scheint dies ein weiterer Fehler zu sein, da es sich nicht um die personalisierte Benutzeroberfläche handelt, an die sie gewöhnt sind. Auch sie tun, was jeder vernünftige Kunde in dieser Situation tun würde, und drücken 100 Mal auf die Aktualisierungsschaltfläche.
Wir haben jetzt einen von den Nutzern verursachten Sturm der Wiederholungen.
Der Zyklus beschleunigt sich. S-D schrumpft und die Latenzzeit steigt, da immer mehr Knotenpunkte durch die Übergabe überlastet werden. S-L kämpft damit, die Anfragen zu befriedigen, da die Anfragen von Client-Geräten dramatisch ansteigen, während die Zeitüberschreitung gleichzeitig die Anfragen an S-D länger offen hält. Wenn R all diese Anfragen an S-L offen hält, obwohl sie irgendwann auslaufen, ist der Thread-Pool schließlich so überlastet, dass die virtuelle Maschine abstürzt. Der gesamte Dienst bricht zusammen(Abbildung 1-8).
Erschwerend kommt hinzu, dass der Ausfall zu mehr Wiederholungsversuchen durch die Kunden führt, was es noch schwieriger macht, das Problem zu beheben und den Dienst wieder in einen stabilen Zustand zu bringen.
Wieder können wir fragen: Was ist in diesem Szenario der Fehler? Welche Komponente wurde falsch gebaut? In einem komplexen System kann niemand alle beweglichen Teile im Kopf behalten. Jedes der Teams, die R, S-L und S-D gebaut haben, hat vernünftige Entscheidungen getroffen. Sie haben sogar einen zusätzlichen Schritt unternommen, um Ausfälle vorherzusehen, diese abzufangen und die Leistung zu verbessern. Was ist also die Schuld?
Wie bei dem vorherigen Beispiel ist auch hier niemand schuld. Es gibt nichts zu tadeln. Natürlich können wir dieses System im Nachhinein verbessern, um zu verhindern, dass sich das beschriebene Szenario wiederholt. Dennoch wäre es unvernünftig zu erwarten, dass die Ingenieure dieses Versagen hätten vorhersehen müssen. Wieder einmal trugen nichtlineare Faktoren dazu bei, dass dieses komplexe System ein unerwünschtes Ergebnis lieferte .
Beispiel 3: Feiertagscode einfrieren
Unter findest du die folgende Infrastruktur(Abbildung 1-9) für ein großes Onlinehandelsunternehmen:
- Komponente E
- ein Load Balancer, der Anfragen einfach weiterleitet, ähnlich wie ein Elastic Load Balancer (ELB) beim AWS-Cloud-Service.
- Komponente F
- Ein API-Gateway. Es analysiert einige Informationen aus den Headern, Cookies und dem Pfad. Es nutzt diese Informationen, um eine Anreicherungsrichtlinie zu erstellen, z. B. indem es zusätzliche Header hinzufügt, die angeben, auf welche Funktionen der Nutzer zugreifen darf. Es führt dann einen Mustervergleich mit einem Backend durch und leitet die Anfrage an das Backend weiter .
- Komponente G
- Ein wucherndes Durcheinander von Backend-Anwendungen, die auf verschiedenen Plattformen mit unterschiedlichen Kritikalitätsstufen laufen und unzählige Funktionen für eine unbestimmte Anzahl von Nutzern erfüllen.
Das Team, das F wartet, hat einige interessante Hindernisse zu bewältigen. Sie haben keine Kontrolle über den Stack oder andere betriebliche Eigenschaften von G. Ihre Schnittstelle muss flexibel sein, um mit vielen verschiedenen Formen von Mustern umgehen zu können, um Anfrage-Header, Cookies und Pfade abzugleichen und die Anfragen an die richtige Stelle zu liefern. Das Leistungsprofil von G umfasst das gesamte Spektrum, von Antworten mit geringer Latenz und kleinen Nutzdaten bis hin zu Keep-Alive-Verbindungen, die große Dateien übertragen. Keiner dieser Faktoren ist planbar, denn die Komponenten in G und darüber hinaus sind selbst komplexe Systeme mit sich dynamisch verändernden Eigenschaften.
F ist sehr flexibel und kann eine Vielzahl von Arbeitslasten bewältigen. Um eine solche funktionale Komponente bereitzustellen, skaliert das Team die Lösung im Laufe der Zeit vertikal, um der Zunahme der Anwendungsfälle für G gerecht zu werden. Immer mehr Musterabgleiche sowohl für die Anreicherung als auch für das Routing führen zu einem umfangreichen Regelsatz, der in einem Zustandsautomaten aufbereitet und für einen schnelleren Zugriff in den Speicher geladen wird. Auch das braucht Zeit. Letztendlich dauert die Bereitstellung dieser großen virtuellen Maschinen mit F jeweils etwa 40 Minuten, vom Start der Bereitstellungspipeline bis zu dem Zeitpunkt, an dem alle Caches warm sind und die Instanz mit oder nahe der Basisleistung läuft.
Da F im kritischen Pfad aller Zugriffe auf G liegt, weiß das Team, das es betreibt, dass es ein potenzieller Single Point of Failure ist. Sie setzen nicht nur eine Instanz ein, sondern einen Cluster. Die Anzahl der Instanzen zu einem bestimmten Zeitpunkt wird so festgelegt, dass der gesamte Cluster über eine zusätzliche Kapazität von 50 % verfügt. Zu jeder Zeit könnte ein Drittel der Instanzen plötzlich ausfallen und alles sollte trotzdem weiter funktionieren.
Vertikal skaliert, horizontal skaliert und überprovisioniert: F ist eine teure Komponente.
Um die Verfügbarkeit noch weiter zu erhöhen, trifft das Team einige zusätzliche Vorkehrungen. Die CI-Pipeline führt eine Reihe von Unit- und Integrationstests durch, bevor ein Image für die virtuelle Maschine erstellt wird. Automatisierte Kanarienvögel testen jede neue Code-Änderung mit einer kleinen Menge an Datenverkehr, bevor sie zu einem Blue/Green Deployment-Modell übergehen, bei dem ein großer Teil des Clusters parallel läuft, bevor die neue Version vollständig umgestellt wird. Alle Pull-Requests zur Änderung des Codes in F werden von zwei Reviewern geprüft, wobei der Reviewer nicht derjenige sein darf, der an der zu ändernden Funktion arbeitet, so dass das gesamte Team über alle Aspekte der laufenden Entwicklung informiert sein muss.
Schließlich wird das gesamte Unternehmen von Anfang November bis Januar in eine Codesperre versetzt. In dieser Zeit dürfen keine Änderungen vorgenommen werden, es sei denn, sie sind absolut kritisch für die Sicherheit des Systems, denn die Feiertage zwischen Black Friday und Neujahr sind die Hauptverkehrszeiten für das Unternehmen. Die Gefahr, dass ein Fehler wegen einer unkritischen Funktion eingeführt wird, könnte in dieser Zeit katastrophal sein. Da viele Leute zu dieser Zeit Urlaub machen, ist es auch aus Sicht der Aufsicht sinnvoll, den Codeeinsatz zu beschränken.
In einem Jahr tritt dann ein interessantes Phänomen auf. Ende der zweiten Novemberwoche, zwei Wochen nach dem Einfrieren des Codes, wird das Team angepiepst, weil die Zahl der Fehler in einer Instanz plötzlich zunimmt. Kein Problem: Diese Instanz wird heruntergefahren und eine andere hochgefahren. Im Laufe der nächsten 40 Minuten, bevor die neue Instanz voll funktionsfähig ist, kommt es auf mehreren anderen Rechnern zu einem ähnlichen Anstieg der Fehler. Während neue Instanzen gebootet werden, um diese zu ersetzen, tritt das gleiche Phänomen auch im Rest des Clusters auf.
Im Laufe von mehreren Stunden wird der gesamte Cluster durch neue Instanzen ersetzt, auf denen genau derselbe Code läuft. Selbst bei einem Overhead von 50 % bleibt eine beträchtliche Anzahl von Anfragen während der Zeit, in der der gesamte Cluster in einem so kurzen Zeitraum neu gestartet wird, unbearbeitet. Dieser teilweise Ausfall schwankt über Stunden hinweg, bis der gesamte Bereitstellungsprozess abgeschlossen ist und sich der neue Cluster stabilisiert.
Das Team steht vor einem Dilemma: Um das Problem zu beheben, müssten sie eine neue Version mit Beobachtungsmaßnahmen in einem neuen Bereich des Codes einsetzen. Aber der Code-Freeze ist in vollem Gange und der neue Cluster scheint nach allen Maßstäben stabil zu sein. In der nächsten Woche beschließen sie, eine kleine Anzahl neuer Instanzen mit den neuen Beobachtbarkeitsmaßnahmen einzusetzen.
Zwei Wochen vergehen ohne Zwischenfälle, als plötzlich das gleiche Phänomen wieder auftritt. Zuerst bei einigen wenigen, dann bei allen Instanzen, steigt die Fehlerquote plötzlich an. Das heißt, alle Instanzen, außer denen, die mit den neuen Beobachtungsmaßnahmen ausgestattet wurden.
Wie beim vorherigen Vorfall wird der gesamte Cluster über mehrere Stunden neu gestartet und scheint sich zu stabilisieren. Der Ausfall ist dieses Mal schwerwiegender, da sich das Unternehmen jetzt in der Hauptsaison befindet.
Ein paar Tage später beginnen die Instanzen, die mit neuen Beobachtungsmaßnahmen instrumentiert wurden, die gleiche Fehlerspitze zu sehen. Anhand der gesammelten Metriken wird festgestellt, dass eine importierte Bibliothek ein vorhersehbares Speicherleck verursacht, das linear mit der Anzahl der bearbeiteten Anfragen skaliert. Da die Instanzen so groß sind, dauert es etwa zwei Wochen, bis das Leck so viel Speicher verbraucht hat, dass andere Bibliotheken davon betroffen sind.
Dieser Fehler war fast neun Monate zuvor in die Codebasis eingeführt worden. Das Phänomen war vorher noch nie aufgetreten, weil keine Instanz im Cluster jemals länger als vier Tage gelaufen war. Neue Funktionen führten dazu, dass neuer Code bereitgestellt wurde, der in neuen Instanzen zirkulierte. Ironischerweise war es ein Verfahren, das die Sicherheit erhöhen sollte - das Einfrieren des Codes im Urlaub -, das den Fehler zu einem Ausfall führte.
Wieder fragen wir uns: Was ist in diesem Szenario der Fehler? Wir können den Fehler in der abhängigen Bibliothek identifizieren, aber wir lernen nichts, wenn wir die Schuld auf einen externen Programmierer schieben, der nicht einmal Kenntnis von diesem Projekt hat. Alle Teammitglieder, die an F gearbeitet haben, haben vernünftige Designentscheidungen getroffen. Sie gingen sogar noch einen Schritt weiter, um Fehler vorauszusehen, die Einführung neuer Funktionen zu stufen, übermäßig viel bereitzustellen und so viel "vorsichtig" zu sein, wie ihnen nur einfiel. Wer trägt also die Schuld daran?
Wie bei den beiden vorangegangenen Beispielen trifft auch hier niemanden die Schuld. Es gibt nichts zu beanstanden. Es wäre unvernünftig zu erwarten, dass die Ingenieure dieses Versagen hätten vorhersehen müssen. Nichtlineare Faktoren haben in diesem komplexen System zu einem unerwünschten und teuren Ergebnis geführt.
Der Komplexität entgegentreten
Die drei vorangegangenen Beispiele zeigen Fälle, in denen keiner der Menschen in der Schleife die Interaktionen vorhersehen konnte, die schließlich zu dem unerwünschten Ergebnis führten. Menschen werden auch in absehbarer Zukunft noch Software schreiben, also ist es keine Option, sie aus dem Kreislauf herauszunehmen. Was kann also getan werden, um Systemfehler wie diese zu reduzieren?
Eine beliebte Idee ist es, die Komplexität zu reduzieren oder zu beseitigen. Nimm die Komplexität aus einem komplexen System heraus, und wir werden die Probleme komplexer Systeme nicht mehr haben.
Wenn wir diese Systeme auf einfachere, lineare Systeme reduzieren könnten, wären wir vielleicht sogar in der Lage zu erkennen, wer die Schuld trägt, wenn etwas schief läuft. In dieser hypothetischen, einfacheren Welt könnten wir uns vorstellen, dass ein hypereffizienter, unpersönlicher Manager alle Fehler beseitigen könnte, indem er einfach die schlechten Äpfel loswird, die diese Fehler verursachen.
Um diese mögliche Lösung zu untersuchen, ist es hilfreich, ein paar zusätzliche Merkmale von Komplexität zu verstehen. Grob gesagt kann man Komplexität in zwei Kategorien einteilen: zufällig und wesentlich. Diese Unterscheidung wurde von Frederick Brooks in den 1980er Jahren getroffen.4
Unbeabsichtigte Komplexität
Die zufällige Komplexität ist eine Folge davon, dass man Software in einem ressourcenbegrenzten Umfeld schreibt, nämlich in diesem Universum. Bei der täglichen Arbeit gibt es immer konkurrierende Prioritäten. Für Softwareentwickler/innen können die expliziten Prioritäten Feature-Geschwindigkeit, Testabdeckung und Idiomatizität sein. Die impliziten Prioritäten können Wirtschaftlichkeit, Arbeitsbelastung und Sicherheit sein. Niemand hat unendlich viel Zeit und Ressourcen, also muss man bei diesen Prioritäten zwangsläufig einen Kompromiss eingehen.
Der Code, den wir schreiben, ist durchdrungen von unseren Absichten, Annahmen und Prioritäten zu einem bestimmten Zeitpunkt. Er kann nicht richtig sein, denn die Welt wird sich verändern und damit auch das, was wir von unserer Software erwarten.
Ein Kompromiss in der Software kann sich in einem leicht suboptimalen Codeschnipsel, einer vagen Absicht hinter einem Vertrag, einem zweideutigen Variablennamen, einer Betonung eines später aufgegebenen Codepfads und so weiter manifestieren. Wie Schmutz auf dem Boden sammeln sich diese Schnipsel an. Niemand bringt absichtlich Dreck in ein Haus und legt ihn auf den Boden; das passiert einfach als Nebenprodukt des Lebens. Genauso entsteht suboptimaler Code als Nebenprodukt der Entwicklung. Irgendwann übersteigen diese angehäuften Suboptimalen die Fähigkeit eines Menschen, sie intuitiv zu verstehen, und dann haben wir es mit Komplexität zu tun - genauer gesagt mit zufälliger Komplexität.
Das Interessante an zufälliger Komplexität ist, dass es keine bekannte, nachhaltige Methode gibt, um sie zu reduzieren. Du kannst unbeabsichtigte Komplexität zu einem bestimmten Zeitpunkt reduzieren, indem du die Arbeit an neuen Funktionen einstellst, um die Komplexität in bereits geschriebener Software zu verringern. Das kann funktionieren, hat aber seine Tücken.
Es gibt zum Beispiel keinen Grund anzunehmen, dass die Kompromisse, die bei der Erstellung des Codes gemacht wurden, weniger fundiert waren als die, die bei einem Refactoring gemacht werden. Die Welt verändert sich und damit auch unsere Erwartungen an das Verhalten von Software. Wenn man neue Software schreibt, um die zufällige Komplexität zu reduzieren, entstehen oft einfach neue Formen der zufälligen Komplexität. Diese neuen Formen können akzeptabler sein als die vorherigen, aber diese Akzeptanz wird in etwa gleich schnell ablaufen.
Große Refactors leiden oft unter dem sogenannten Second-System-Effekt, einem Begriff, der ebenfalls von Frederick Brooks eingeführt wurde und besagt, dass das nachfolgende Projekt aufgrund der bei der Entwicklung des ersten Projekts gewonnenen Erkenntnisse besser sein soll als das ursprüngliche. Stattdessen werden diese zweiten Systeme durch unbeabsichtigte Kompromisse, die durch den Erfolg der ersten Version entstanden sind, größer und komplexer.
Unabhängig davon, welcher Ansatz gewählt wird, um die zufällige Komplexität zu reduzieren, ist keine dieser Methoden nachhaltig. Sie alle erfordern eine Ablenkung von begrenzten Ressourcen wie Zeit und Aufmerksamkeit von der Entwicklung neuer Funktionen. In jeder Organisation, die Fortschritte machen will, stehen diese Ablenkungen im Konflikt mit anderen Prioritäten. Daher sind sie nicht nachhaltig.
Beim Schreiben von Code entsteht also immer wieder zufällige Komplexität.
Wesentliche Komplexität
Wenn wir die zufällige Komplexität nicht nachhaltig reduzieren können, dann können wir vielleicht die andere Art von Komplexität reduzieren. Wesentliche Komplexität in Software ist der Code, den wir schreiben und der absichtlich mehr Overhead verursacht, weil das unsere Aufgabe ist. Als Softwareentwickler schreiben wir neue Funktionen, und neue Funktionen machen die Dinge komplexer.
Betrachte das folgende Beispiel: Du hast die einfachste Datenbank, die du dir vorstellen kannst. Sie ist ein Schlüssel/Wert-Datenspeicher, wie in Abbildung 1-10 zu sehen: Gib ihr einen Schlüssel und einen Wert, und sie speichert den Wert. Gib ihr einen Schlüssel, und sie gibt den Wert zurück. Um es absurd einfach zu machen, stell dir vor, dass sie auf deinem Laptop im Speicher läuft.
Jetzt stell dir vor, dass du die Aufgabe bekommst, sie verfügbarer zu machen. Wir können sie in die Cloud stellen. Auf diese Weise bleiben die Daten erhalten, wenn wir den Laptop schließen. Wir können mehrere Knotenpunkte für Redundanz hinzufügen. Wir können den Schlüsselraum hinter einen konsistenten Hash legen und die Daten auf mehrere Knoten verteilen. Wir können die Daten auf diesen Knoten auf der Festplatte speichern, so dass wir sie für Reparaturen oder Datenübertragungen ein- und ausschalten können. Wir können einen Cluster auf einen anderen in verschiedenen Regionen replizieren, so dass wir auch dann noch auf den anderen Cluster zugreifen können, wenn eine Region oder ein Rechenzentrum nicht verfügbar ist.
In einem Absatz können wir sehr schnell eine Reihe von bekannten Designprinzipien beschreiben, um eine Datenbank verfügbarer zu machen.
Kehren wir zu unserem einfachen Key/Value-Datenspeicher zurück, der auf unserem Laptop im Arbeitsspeicher läuft(Abbildung 1-11). Stell dir vor, du bekommst die Aufgabe, ihn gleichzeitig verfügbarer und einfacher zu machen. Verbringe nicht zu viel Zeit mit dem Versuch, dieses Rätsel zu lösen: Es ist nicht möglich, dies auf sinnvolle Weise zu tun.
Das Hinzufügen neuer Funktionen zu einer Software (oder Sicherheitseigenschaften wie Verfügbarkeit und Sicherheit) erfordert zusätzliche Komplexität.
Zusammengenommen ist die Aussicht, unsere komplexen Systeme gegen einfache Systeme einzutauschen, nicht ermutigend. Unbeabsichtigte Komplexität wird immer als Nebenprodukt der Arbeit entstehen, und wesentliche Komplexität wird durch neue Funktionen verursacht. Um in der Softwareentwicklung voranzukommen, wird die Komplexität zunehmen.
Die Komplexität annehmen
Wenn die Komplexität zu schlechten Ergebnissen führt und wir die Komplexität nicht beseitigen können, was sollen wir dann tun? Die Lösung ist ein zweistufiger Prozess.
Der erste Schritt besteht darin, die Komplexität anzunehmen, anstatt sie zu vermeiden. Die meisten Eigenschaften, die wir uns für unsere Software wünschen und für die wir sie optimieren, erfordern zusätzliche Komplexität. Der Versuch, auf Einfachheit zu optimieren, setzt die falsche Priorität und führt in der Regel zu Frustration. Angesichts der unvermeidlichen Komplexität hören wir manchmal: "Füge keine unnötige Komplexität hinzu." Sicher, aber das Gleiche könnte man auch von allem anderen sagen: "Füge nichts Unnötiges hinzu _____." Akzeptiere, dass die Komplexität zunehmen wird, auch wenn sich die Software verbessert, und das ist keine schlechte Sache.
Der zweite Schritt, der Gegenstand von Kapitel 2 ist, besteht darin, zu lernen, mit der Komplexität umzugehen. Finde Werkzeuge, um schnell und sicher voranzukommen. Lerne, wie du neue Funktionen hinzufügen kannst, ohne dein System einem erhöhten Risiko unerwünschten Verhaltens auszusetzen. Anstatt in der Komplexität zu versinken und in Frustration zu ertrinken, solltest du auf ihr wie auf einer Welle surfen. Für dich als Ingenieur ist Chaos Engineering vielleicht der einfachste und effizienteste Weg, um die Komplexität deines Systems zu beherrschen.
1 Siehe Peter Senge, The Fifth Discipline (New York, NY: Doubleday, 2006).
2 Siehe Kommentar zu W. Ross Ashbys "Law of Requisite Variety", in W. Ross Ashby, "Requisite Variety and Its Implications for the Control of Complex Systems", Cybernetica 1:2 (1958), S. 83-99. Vereinfacht ausgedrückt: Ein System A, das System B vollständig kontrolliert, muss mindestens genauso komplex sein wie System B.
3 Genau so funktioniert es nicht, denn der konsistente Hash-Algorithmus verteilt die Datenobjekte pseudozufällig auf alle S-D-Instanzen.
4 Frederick Brooks, "No Silver Bullet-Essence and Accident in Softwareentwicklung", aus Proceedings of the IFIP Tenth World Computing Conference, H.-J. Kugler ed., Elsevier Science BV, Amsterdam (1986).
Get Chaos Engineering now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.