Ausfälle kommen vor

Wann immer ein Service oder System einen anderen Service oder ein anderes System aufruft, kann es zu Ausfällen kommen. Diese Ausfälle können auf eine Vielzahl von Faktoren zurückzuführen sein. Dazu gehören Server, Netzwerke, Load Balancer, Software, Betriebssysteme oder sogar Fehler von Systembedienern. Wir gestalten unsere Systeme so, dass die Ausfallwahrscheinlichkeit sinkt, es ist jedoch unmöglich, Systeme zu entwickeln, die nie ausfallen. Deshalb entwickeln wir bei Amazon unsere Systeme so, dass sie fehlertolerant sind und die Ausfallwahrscheinlichkeit reduzieren. So vermeiden wir, dass sich ein kleiner Prozentsatz von Ausfällen zu einem Komplettausfall auswächst. Zur Entwicklung ausfallsicherer Systeme setzen wir drei wichtige Tools ein: Timeouts, Wiederholungsversuche und Backoff.

Viele Arten von Ausfällen werden erkennbar, wenn Anfragen länger als üblich dauern und möglicherweise nie abgeschlossen werden. Wenn ein Client länger als üblich auf den Abschluss einer Anfrage wartet, nimmer er auch die dafür verwendeten Ressourcen für eine längere Zeit in Anspruch. Wenn mehrere Anfragen Ressourcen länger in Anspruch nehmen, kann das dazu führen, dass dem Server die Ressourcen ausgehen. Zu diesen Ressourcen können Arbeitsspeicher, Threads, Verbindungen, flüchtige Anschlüsse oder andere Dinge gehören, die begrenzt sind. Zur Vermeidung dieser Situation legen Clients Timeouts fest. Timeouts sind die maximale Zeitspanne, die ein Client auf den Abschluss einer Anfrage wartet.

Häufig führt der erneute Versuch derselben Anfrage dazu, dass sie erfolgreich abgeschlossen wird. Das geschieht, weil die von uns entwickelten Systeme nur selten als Einheit ausfallen. Vielmehr erleiden sie partielle oder vorübergehende Ausfälle. Ein partieller Ausfall liegt vor, wenn ein Prozentsatz der Anfragen erfolgreich abgeschlossen wird. Ein vorübergehender Ausfall liegt vor, wenn eine Anfrage für kurze Zeit erfolglos verarbeitet wird. Durch Wiederholungsversuche können Clients diese zufällig erfolgenden partiellen Ausfälle und kurzzeitigen vorübergehenden Ausfälle bewältigen, indem sie die gleiche Anfrage erneut senden.

Wiederholungsversuche können jedoch nicht immer bedenkenlos durchgeführt werden. Wiederholungsversuche können das aufgerufene System zusätzlich belasten, wenn das System bereits fehlerhaft arbeitet, weil es zunehmend überlastet ist. Zur Vermeidung dieses Problems implementieren wir unsere Clients so, dass sie ein Backoff verwenden. Dadurch erhöht sich die Zeit zwischen den nachfolgenden Wiederholungsversuchen, was für eine gleichmäßige Last für das Backend sorgt. Das andere Problem bei Wiederholungsversuchen besteht darin, dass einige Remote-Aufrufe Nebenwirkungen haben. Ein Timeout oder Ausfall bedeutet nicht zwangsläufig, dass keine Nebenwirkungen aufgetreten sind. Wenn eine mehrfache Wiederholung der Nebenwirkungen unerwünscht ist, besteht eine bewährte Vorgehensweise darin, APIs idempotent zu gestalten, das heißt, dass Wiederholungsversuche bedenkenlos durchgeführt werden können.

Schließlich trifft der Datenverkehr nicht mit einer konstanten Rate bei den Amazon-Services ein. Vielmehr treffen Anfragen oft extrem gehäuft ein. Diese Anhäufungen können durch das Clientverhalten, die Wiederherstellung nach einem Ausfall und sogar durch so etwas Einfaches wie einen regelmäßigen Cron-Job verursacht werden. Werden Fehler durch die Last verursacht, können Wiederholungsversuche unwirksam sein, wenn alle Clients gleichzeitig wiederholte Versuche starten. Zur Vermeidung dieses Problems setzen wir Jitter ein. Hierbei handelt es sich um eine zufällige Zeitspanne vor der Durchführung oder Wiederholung einer Anfrage. So sollen große Anhäufungen durch die Verteilung der eintreffenden Anfragen verhindert werden.

All diese Lösungen werden in den folgenden Abschnitten erläutert.

Timeouts

Eine bewährte Methode bei Amazon besteht darin, ein Timeout für alle Remote-Aufrufe und generell für alle prozessübergreifenden Aufrufe festzulegen, sogar im selben System. Dazu gehören ein Verbindungs-Timeout und ein Anfrage-Timeout. Viele Standardclients bieten robuste integrierte Timeout-Funktionen.
Das größte Problem stellt in der Regel die Wahl des einzustellenden Timeout-Wertes dar. Wird ein Timeout zu hoch eingestellt, verringert sich dessen Nutzen, da Ressourcen immer noch genutzt werden, während der Client auf das Timeout wartet. Das Einstellen eines zu niedrigen Timeouts birgt zwei Risiken:
 
• Verstärkter Datenverkehr am Backend und erhöhte Latenzen, da zu viele Anfragen wiederholt werden.
• Erhöhte Latenzen am kleinen Backend, die zu einem Komplettausfall führen, da alle Anfragen gleichzeitig wiederholt werden.
 
Eine angemessene Vorgehensweise bei der Auswahl eines Timeouts für Aufrufe innerhalb einer AWS-Region besteht darin, mit den Latenzmetriken des nachgelagerten Service zu beginnen. Daher wählen wir bei Amazon, wenn wir einen Service einen anderen Service aufrufen lassen, eine akzeptable Rate an falschen Timeouts (zum Beispiel 0,1 %). Anschließend schauen wir uns das entsprechende Latenzperzentil für den nachgelagerten Service an (in diesem Beispiel 99,9). In den meisten Fällen funktioniert diese Vorgehensweise gut, aber es gibt einige Fallstricke, die nachfolgend beschrieben werden:
 
• Diese Vorgehensweise funktioniert nicht in Fällen, in denen Clients eine erhebliche Netzwerklatenz aufweisen, wie zum Beispiel über das Internet. In diesen Fällen kalkulieren wir eine angemessene schlimmstmögliche Netzwerklatenz ein, wobei wir berücksichtigen, dass die Clients sich auf der ganzen Welt befinden können.
• Diese Vorgehensweise funktioniert auch nicht bei Services mit engen Latenzgrenzen, wo das Perzentil 99,9 nahe am Perzentil 50 liegt. In diesen Fällen hilft uns das Hinzufügen eines Polsters, kleine Latenzanstiege zu vermeiden, die zu einer hohen Anzahl von Timeouts führen.
• Bei der Implementierung von Timeouts sind wir auf eine häufig auftretende Schwierigkeit gestoßen. Die Linux-Option SO_RCVTIMEO ist leistungsfähig, hat aber einige Nachteile, die sie ungeeignet für ein End-to-End-Socket-Timeout machen. Einige Sprachen, wie zum Beispiel Java, legen dieses Steuerelement direkt offen. Andere Sprachen, wie etwa Go, bieten robustere Timeout-Mechanismen.
• Außerdem gibt es Implementierungen, bei denen das Timeout nicht alle Remote-Aufrufe abdeckt, wie etwa DNS- oder TLS-Handshakes. Im Allgemeinen verwenden wir lieber die Timeouts, die in erprobten Clients integriert sind. Wenn wir eigene Timeouts implementieren, achten wir penibel auf die genaue Bedeutung der Timeout-Socket-Optionen und darauf, welche Arbeit durchgeführt wird.
 
In einem System, an dem ich bei Amazon gearbeitet habe, verzeichneten wir eine kleine Anzahl von Timeouts, die unmittelbar nach Bereitstellungen mit einer Abhängigkeit kommunizierten. Das Timeout war sehr niedrig eingestellt, auf etwa 20 Millisekunden. Außerhalb von Bereitstellungen verzeichneten wir trotz dieses niedrigen Timeout-Wertes keine regelmäßigen Timeouts. Bei näherer Untersuchung stellte ich fest, dass der Timer den Aufbau einer neuen sicheren Verbindung umfasste, die bei späteren Anfragen wiederverwendet wurde. Da der Verbindungsaufbau länger als 20 Millisekunden dauerte, verzeichneten wir eine kleine Zahl von Zeitüberschreitungen bei Anfragen, wenn ein neuer Server nach Bereitstellungen in Betrieb ging. In einigen Fällen wurden die Anfragen erfolgreich wiederholt. Anfangs umgingen wir dieses Problem, indem wir für den Fall eines Verbindungsaufbaus den Timeout-Wert erhöhten. Später verbesserten wir das System, indem wir diese Verbindungen beim Start eines Prozesses herstellten, aber noch vor Eingang von Datenverkehr. So konnten wir das Timeout-Problem ganz umgehen.

Wiederholungsversuche und Backoff

Wiederholungsversuche sind "eigennützig". Mit anderen Worten: Wenn ein Client Wiederholungsversuche durchführt, nimmt er mehr Serverzeit in Anspruch, um seine Erfolgschancen zu erhöhen. Dort wo Ausfälle selten oder vorübergehend sind, stellt das kein Problem dar. Das liegt daran, dass die Gesamtzahl der wiederholten Anfragen gering ist und der Kompromiss einer höheren scheinbaren Verfügbarkeit gut funktioniert. Wenn Ausfälle durch Überlastung verursacht werden, können Wiederholungsversuche, die die Last erhöhen, die Situation erheblich verschlimmern. Sie können sogar die Wiederherstellung verzögern, da die Last dadurch noch lange nach Lösung des ursprünglichen Problems hoch bleibt. Wiederholungsversuche ähneln einem starken Medikament – nützlich in der richtigen Dosis, aber potenziell extrem schädlich bei übermäßigem Gebrauch. Leider gibt es in verteilten Systemen fast keine Möglichkeit zur Koordinierung aller Clients, um die richtige Anzahl von Wiederholungsversuchen zu erreichen.

Die bevorzugte Lösung, auf die wir bei Amazon zurückgreifen, ist ein Backoff. Anstatt sofort und aggressiv Wiederholungsversuche durchzuführen, wartet der Client zwischen den Versuchen eine gewisse Zeit. Das gängigste Muster ist ein exponentielles Backoff, bei dem die Wartezeit nach jedem Versuch exponentiell erhöht wird. Exponentielles Backoff kann zu sehr langen Backoff-Zeiten führen, da Exponentialfunktionen schnell ansteigen. Damit nicht zu lange Wiederholungsversuche durchgeführt werden, wird das Backoff bei Implementierungen in der Regel bei einem Höchstwert gedeckelt. Dies wird folgerichtig als gedeckeltes exponentielles Backoff bezeichnet. Das führt jedoch zu einem anderen Problem. Jetzt führen alle Clients ständig Wiederholungsversuche in der gedeckelten Rate durch. In fast allen Fällen besteht unsere Lösung darin, die Anzahl der Wiederholungsversuche der Clients zu begrenzen und uns an vorgelagerter Stelle in der serviceorientierten Architektur um den resultierenden Ausfall zu kümmern. In den meisten Fällen wird der Client die Aufrufversuche ohnehin irgendwann einstellen, da er eigene Timeouts hat.

Es gibt noch weitere Probleme bei Wiederholungsversuchen, die nachfolgend beschrieben werden:

• Verteilte Systeme verfügen oft über mehrere Schichten. Stellen Sie sich ein System vor, bei dem der Aufruf des Kunden einen fünfschichtigen Stack an Serviceaufrufen verursacht. Es endet mit einer Abfrage an eine Datenbank und drei Wiederholungsversuchen in jeder Schicht. Was geschieht, wenn die Datenbank unter Last Abfragen nicht mehr verarbeiten kann? Wenn jede Schicht unabhängig Wiederholungsversuche durchführt, erhöht sich die Last für die Datenbank um das 243-fache, sodass eine Rückkehr zum Normalbetrieb unwahrscheinlich ist. Das liegt daran, dass sich die Wiederholungsversuche in jeder Schicht vervielfachen – zuerst drei Versuche, dann neun Versuche und so weiter. Ganz im Gegenteil: Wiederholungsversuche in der obersten Schicht des Stacks können die Arbeit aus früheren Aufrufen nutzlos machen, was die Effizienz verringert. Im Allgemeinen besteht unsere bewährte Vorgehensweise bei kostengünstigen Vorgängen auf Steuerungs- und Datenebene darin, Wiederholungsversuche an einem einzigen Punkt im Stack durchzuführen.
• Last. Selbst bei einer einzigen Schicht von Wiederholungsversuchen steigt der Datenverkehr deutlich an, wenn Fehler aufzutreten beginnen. Vielfach werden zur Lösung dieses Problems Schutzschalter empfohlen, bei denen Aufrufe eines nachgelagerten Service bei Überschreitung einer Fehlerschwelle vollständig gestoppt werden. Leider erhält mit Schutzschaltern modales Verhalten Einzug in Systeme, die mitunter nur schwer zu testen sind. Außerdem können die Schalter die Zeit bis zur Wiederherstellung erheblich verlängern. Wir haben festgestellt, dass wir dieses Risiko verringern können, indem wir die Zahl der Wiederholungsversuche lokal mit einem Token-Bucket begrenzen. Dadurch können alle Aufrufe so lange wiederholt werden, wie es Token gibt. Sind die Token erschöpft, erfolgen die Wiederholungsversuche dann mit einer festen Rate. AWS hat das AWS SDK im Jahr 2016 um dieses Verhalten erweitert. Kunden, die das SDK verwenden, verfügen also bereits über dieses Drosselungsverhalten.
• Entscheidung über den Zeitpunkt von Wiederholungsversuchen. Im Allgemeinen sind wir der Ansicht, dass APIs mit Nebenwirkungen nur dann bedenkenlos für Wiederholungsversuche verwendet werden können, wenn sie Idempotenz bieten. Das garantiert, dass die Nebenwirkungen unabhängig von der Anzahl der Wiederholungsversuche nur einmal auftreten. Schreibgeschützte APIs sind in der Regel idempotent, APIs zur Ressourcenerstellung hingegen möglicherweise nicht. Einige APIs, wie zum Beispiel die RunInstances-API von Amazon Elastic Compute Cloud (Amazon EC2), bieten explizite tokenbasierte Mechanismen, um Idempotenz bereitzustellen und die sichere Durchführung von Wiederholungsversuchen zu gewährleisten. Zur Vermeidung von doppelten Nebenwirkungen sind ein gutes API-Design und Sorgfalt bei der Implementierung von Clients notwendig.
• Wissen, bei welchen Ausfällen sich Wiederholungsversuche lohnen. HTTP bietet eine klare Unterscheidung zwischen Client- und Serverfehlern. Das Protokoll gibt an, dass bei Clientfehlern keine Wiederholungsversuche mit der gleichen Anfrage durchgeführt werden sollten, da sie später nicht erfolgreich sein werden. Bei Serverfehlern hingegen können spätere Versuche erfolgreich sein. Leider verwischt die letztendliche Konsistenz in den Systemen diese Trennlinie erheblich. Ein momentaner Clientfehler kann sich im nächsten Moment in einen Erfolg verwandeln, wenn der Status propagiert wird.

Trotz dieser Risiken und Schwierigkeiten sind Wiederholungsversuche ein leistungsfähiger Mechanismus, um angesichts von vorübergehend und zufällig auftretenden Fehlern eine hohe Verfügbarkeit zu gewährleisten. Ein gutes Augenmaß ist erforderlich, um den richtigen Kompromiss für jeden Service zu finden. Nach unserer Erfahrung sollte man sich zunächst vor Augen halten, dass Wiederholungsversuche "eigennützig" sind. Mithilfe von Wiederholungsversuchen können Clients sozusagen die Bedeutung ihrer Anfrage unterstreichen und verlangen, dass der Service einen größeren Teil seiner Ressourcen für deren Verarbeitung aufwendet. Wenn ein Client zu eigennützig ist, kann er vielfältige Probleme verursachen.

Jitter

Wenn Ausfälle durch Überlastung oder Konflikte verursacht werden, helfen Backoffs oft nicht so viel, wie sie eigentlich sollten. Das liegt an der Korrelation. Wenn für alle fehlgeschlagenen Aufrufe ein Backoff auf den gleichen Zeitpunkt erfolgt, verursachen sie beim Wiederholungsversuch einen Konflikt oder eine Überlastung. Unsere Lösung dafür heißt Jitter. Jitter verleiht dem Backoff ein gewisses Zufallsmoment, um die Wiederholungsversuche zeitlich besser zu verteilen. Weitere Informationen dazu, wie viel Jitter hinzugefügt werden sollte und wie er am besten hinzugefügt werden kann, finden Sie unter Exponentielles Backoff und Jitter.

Jitter eignet sich nicht nur für Wiederholungsversuche. Die Betriebserfahrung hat uns gelehrt, dass es beim Datenverkehr in unseren Services, sowohl auf der Steuerungs- als auch auf der Datenebene, häufig zu Spitzen kommt. Diese Spitzen im Datenverkehr können sehr kurz sein und sind in aggregierten Metriken oft schwer erkennbar. Bei der Entwicklung von Systemen ziehen wir es in Erwägung, allen Timern, regelmäßigen Aufträgen und anderen verzögerten Arbeiten Jitter hinzuzufügen. Das trägt dazu bei, Arbeitsspitzen zu verteilen, und erleichtert den nachgelagerten Services die Skalierung für eine Workload.

Wenn wir geplanten Arbeiten Jitter hinzufügen, wählen wir den Jitter nicht zufällig auf den einzelnen Hosts aus. Vielmehr verwenden wir eine konsistente Methode, mit der jedes Mal die gleiche Zahl auf demselben Host erzeugt wird. Wenn ein Service überlastet ist oder eine Art Wettlauf entsteht, dann geschieht das so auf die gleiche Weise, in einem Muster. Wir Menschen sind gut darin, Muster zu erkennen, und anhand von Mustern können wir die Ursache eher bestimmen. Der Einsatz einer Zufallsmethode sorgt dafür, dass bei Überlastung einer Ressource diese Überlastung halt zufällig geschieht. Das erschwert die Fehlerbehebung erheblich.

Bei Systemen, an denen ich gearbeitet habe, wie Amazon Elastic Block Store (Amazon EBS) und AWS Lambda, haben wir festgestellt, dass Clients häufig Anfragen in regelmäßigen Intervallen senden, zum Beispiel einmal pro Minute. Wenn ein Client jedoch mehrere Server hat, die sich auf die gleiche Weise verhalten, können sie sich aufreihen und ihre Anfragen gleichzeitig auslösen. Dies kann in den ersten Sekunden einer Minute sein oder, bei täglichen Aufgaben, in den ersten Sekunden nach Mitternacht. Indem wir auf die Last pro Sekunde achten und den regelmäßigen Workloads von Clients Jitter hinzufügen, leisten wir den gleichen Arbeitsumfang mit weniger Serverkapazität.

Wir haben weniger Kontrolle über Spitzen im Datenverkehr der Kunden. Doch selbst bei von Kunden ausgelösten Aufgaben ist es eine gute Idee, dort Jitter hinzuzufügen, wo das Kundenerlebnis dadurch nicht beeinträchtigt wird.

Fazit

In verteilten Systemen sind vorübergehende Ausfälle oder Latenzen bei Remote-Interaktionen unvermeidlich. Timeouts verhindern, dass Systeme unangemessen lange "hängen", Wiederholungsversuche können diese Ausfälle kaschieren und Backoff und Jitter können die Auslastung verbessern und Staus in Systemen verringern.

Bei Amazon haben wir gelernt, dass es wichtig ist, bei Wiederholungsversuchen Vorsicht walten zu lassen. Wiederholungsversuche können die Belastung eines abhängigen Systems erheblich verstärken. Wenn es bei Aufrufen eines Systems zu Zeitüberschreitungen kommt und dieses System überlastet ist, können Wiederholungsversuche die Überlastung verschlimmern, statt für eine Entlastung zu sorgen. Wir vermeiden diesen Verstärkungseffekt, indem wir nur dann Wiederholungsversuche durchführen, wenn wir feststellen, dass die Abhängigkeit sich in einem guten Zustand befindet. Wir stellen die Wiederholungsversuche ein, wenn sie nicht zur Verbesserung der Verfügbarkeit beitragen.


Über den Autor

Marc Brooker ist Senior Principal Engineer bei Amazon Web Services. Er hat seit 2008 bei AWS an verschiedenen Services gearbeitet, darunter EC2, EBS und IoT. Heute konzentriert er sich auf AWS Lambda, einschließlich Skalierung und Virtualisierung. Marc liest sehr gerne COEs und Post-Mortems. Er promovierte in Elektrotechnik.

Herausforderungen bei verteilten Systemen Load Shedding zur Vermeidung von Überlastzuständen Vermeiden von Fallback in verteilten Systemen