Agile Software Entwicklung - ein Freifahrtschein? Zuverlässige Software

Vom Sonnenschein zum Schlechtwetter - Fault Tolerance

Wie im ersten Teil festgestellt, gibt es viel zu oft nur Schönwetter-Tests. Damit nicht genug – das System ist auch oft nur für Schönwetter gebaut. Wer kennt das nicht?

try { ... }
catch(Exception ex) { ... } // Catch-Log-Forget

In manchen Situationen ist diese Technik unvermeidbar - oft ist sie aber ein Zeichen für schlechtes Design. Einen passenden Einstiegspunkt für dieses Thema zu finden, finde ich persönlich kompliziert. Probieren wir’s mal mit einem Blog Post von Eric Lippert - ein Auszug:

  • Fatal exceptions are not your fault, you cannot prevent them, and you cannot sensibly clean up from them. […] Out of memory, thread aborted, and so on. There is absolutely no point in catching these because nothing your puny user code can do will fix the problem.
  • Boneheaded exceptions are your own darn fault, you could have prevented them and therefore they are bugs in your code. You should not catch them; doing so is hiding a bug in your code.
  • Vexing exceptions are the result of unfortunate design decisions. Vexing exceptions are thrown in a completely non-exceptional circumstance, and therefore must be caught and handled all the time. […] The classic example of a vexing exception is Int32.Parse, which throws if you give it a string that cannot be parsed as an integer.
  • And finally, exogenous exceptions appear to be somewhat like vexing exceptions except that they are not the result of unfortunate design choices. […] You’ve got to catch an exogenous exception because it always could happen no matter how hard you try to avoid it; it’s an exogenous condition outside of your control.

Mit dieser Aufzählung trifft Eric Lippert den Nagel auf den Kopf. Und gegen die Grundprinzipien scheint sehr oft verstoßen zu werden. So treten NullReferenceException auf – welche an einem Ende des Programmes gefangen werden – und am anderen Ende komische Effekte erzeugen. Die Folge sind aufwendige und unnötige Debugging-Sessions. Über diese Exceptions werde ich in Teil 3 schreiben.

Reliable Systems

Was der Fokus dieses Artikels ist: Fatal Exceptions und Boneheaded exceptions. Ich möchte das Ganze abstrakt betrachten und an dieser Stelle zu einem anderen Buzzword überleiten: “Reliable Systems”. Joe Amstrong hat hier 4 Prinzipien zusammengefasst die ein Reliable System ausmachen [1]:

  • Isolation: Ein Fehler (Fault) in einer Komponente sollte keine Auswirkung auf eine andere Komponente haben.
  • Concurrency: die Welt ist nebenläufig. Man braucht mindestens zwei Computer um ein zuverlässiges System zu bekommen (d.h. das System ist concurrent und distributed)
  • Failures detektieren: Wenn Failures nicht detektiert werden, können sie auch nicht behoben werden. Das muss auch über die Maschinengrenzen hinaus funktionieren, da auch die ganze Maschine in einem Failure State sein kann. Prinzipien wie Distributed Error Handling, No Shared State und Asynchronous Messaging spielen hier eine Rolle (siehe u.a. folgenden Artikel).
  • Fault Identifizierung: Man sollte immer genügend Informationen zur Laufzeit sammeln, um den Failure verstehen zu können.

Wie erreicht man Isolation? Robert Hanmer [4] präsentiert hierzu ein Pattern: Units Of Mitigation. Units Of Mitigation steht im Kontrast zu einem monolithischen Design. Ein monolithisches Design bedeutet, dass ein System zur Gänze gestoppt werden muss, um einen Error aufzulösen. Teilt man das System in Units (Einheiten) ein, entstehen neue Möglichkeiten: weist eine Unit einen Fehler auf, so können andere Units trotzdem weiterarbeiten. Units sollten dem Fail Silent Deterministic Paradigma folgen (Vermeidung von Byzantine Failures und falschen Antworten – mehr dazu später). Units of Mitigation sollten also als Barrieren für Errors dienen.

Das nächste große Schlagwort ist Concurrency. Mit diesem Wort sind viele Ideologien verbunden. Um zuverlässige Software zu bekommen ist eines sehr wichtig: Nicht offensichtliche Fehler sollten vermieden werden. Und das heißt u.a., dass Concurrency abstrahiert werden sollte. Wer kennt das nicht: Ein Incident eines Kunden – Analyse – ein Monitor (Synchronisationsmechanismus) verhakt. Sprichwörtlich “A Nightmare To Remember”. Viele Sprachen nehmen sich dem Problem schon an. Sei es Erlang / Elixir oder C# oder Google Go. In C# ist die Beste Implementierung die ich bis jetzt gesehen haben jene von Postsharp. Es integriert sich fast „geräuschlos“ in den Code und nutzt die TPL.

Auch erwähnt wurde Distributed. Wir wollen zuverlässig sein – also brauchen wir mehrere Knoten. Das Stichwort: Redundanz. Um einen hohen Grad an Verfügbarkeit (engl. Availability) zu haben, muss die Zeit zwischen Auftreten eines Fehlers und Fortsetzung der normalen Operation gering sein. Redundanz ist eine Möglichkeit, um die Zeit, in der das System nicht operativ ist, gering zu halten. Zu beachten ist, dass im Regelfall Software Redundanzen mit derselben Implementierung nicht zum gewünschten Erfolg führen werden, da bei gleichen Stimuli gleiches Verhalten zu erwarten ist.

Der nächste Punkt auf der Liste ist das Detektieren von Failures. Dazu möchte ich weiter ausholen und einige Begrifflichkeiten erläutern. Wie auch schon im ersten Teil wage ich einen Exkurs in die Embedded Welt. Hier wird man mit einer Fülle von Begriffen erschlagen. Ich möchte mit einer Unterscheidung von Nancy Leveson beginnen, um die Sache mit der “Reliable Software” abzugrenzen. Zu Beginn ein Exkurs: die fälschliche Annahme, dass Sicherheit (Safety) durch Zuverlässigkeit (Reliability) erhöht werden kann ist falsch! Diese beiden nicht funktionalen Anforderungen bedeuten etwas Unterschiedliches. So kann ein System e.g. zuverlässig aber unsicher sein. D.h., dass die Komponenten nicht versagt haben, aber trotzdem unsicher gehandelt haben. Ein Beispiel ist der Marks Polar Lander. Ein Ruck am Landesensor am Ende eines Beines dürfte zum frühzeitigen abschalten der Bremstriebwerke geführt haben, welches zu einem Absturz der Sonde führte. Alle Komponenten haben funktioniert, es wurden allerdings nicht alle Beeinflussungen, welche durch das Ausfahren der Beine entstehen können, berücksichtigt. Man nennt diese Art von Unfall einen Component Interaction Accident. Fehler in Komponenten haben bereits eine hohe Aufmerksamkeit, allerdings erlangen Component Interaction Accidents durch die immer steigende System Komplexität eine steigende Interesse. Ein System kann auch sicher aber unzuverlässig sein. Ein Beispiel für solche Komponenten sind Menschen, welche mit einem System interagieren. Wenn Menschen vordefinierten, sicheren Anweisungen nicht folgen, kann dies zu Unfällen führen. Der erste Fall auf gut deutsch: “Es tuat – aber net gscheit”. D.h. wir beschränken uns hier mal auf es soll imma tuan.

Failure, Error, Fault

Jetzt haben wir auf den ersten Blick drei nicht zusammenhängende Begriffe: Failure, Error und Fault. Die Unterscheidung lässt sich wie folgt erklären: Jedes Fault Tolerant System muss eine Spezifikation besitzen, welche Aussagen über die Zuverlässigkeit gibt (e.g. 99,999% Verfügbarkeit). Wird diese Spezifikation nicht eingehalten, hat das System seine Spezifikation “verfehlt” (failed). D.h., dass der Term Failure sich auf ein System bezieht, welches seine Spezifikation nicht einhält (e.g. durch Abstürze oder es kann nicht mehr auf Benutzeranfragen antworten). Ein Failure wird von einem Error (Differenz zwischen Soll- und Istwert von einem berechneten Wert) hervorgerufen. Errors in Fault Tolerant Systems sind wichtig, da sie entdeckt werden können bevor sie zu einem Failure führen. Ein Fault (Umgangssprachlich “Bug”) ist ein Defekt in der e.g. Software (e.g. menschliche Fehler in der Spezifikation, Design- oder Coding-Process). Im Regelfall merken weder Software noch Beobachter die Anwesenheit eines Faults – bis ein Error auftritt. Mehrere Faults können zu einem Error führen. Mehrere Errors können einen Failure eines Systems hervorrufen. Ein Beispiel soll den Zusammenhang zeigen: Ein fehlgerouteter Telefonanruf ist ein Beispiel eines Failure (entgegen der Spezifikation). Der Fault ist e.g. eine falsch gespeicherte Routing Information. Der Error entsteht, wenn auf die fehlerhaften Daten zugegriffen wird und der Routingpfad berechnet wird. Cristian (1991) bzw. Hadzilacos und Toueg (1993) haben einige unterschiedliche Typen von Failures präsentiert:

  • Crash Failure: Ein Server stürzt unerwartet ab – hat bis zu diesem Zeitpunkt aber fehlerfrei gearbeitet. Wenn der Server abgestürzt ist, hört man nichts mehr von ihm.
  • Omission Failure: Dieser Fehler tritt auf, wenn der Server e.g. auf einen Request nicht antwortet. Eine Ursache könnte sein, dass er den Request nie bekommen hat.
  • Timing Failure: Dieser Fehler tritt auf wenn die Antwort außerhalb eines definierten Zeitfensters ist
  • Respsonse Failure: Der Fehler tritt auf wenn die Antwort des Server inkorrekt ist
  • Arbitary Failure: auch bekannt als Byzantine Failures. Fehler müssen dabei nicht immer unter den gleichen Umständen auftreten (Consistent), sondern können auch zufällig auftreten - Byzantine Failures. Diese Art von Fehler ist schwer zu finden und sollten vermieden werden (e.g. durch Design der Software).

Es gibt also unterschiedliche Fehler - aber wie geht man am besten mit ihnen um? Wieder hat Hanmer [4] eine sehr gute Auflistung (sein Buch ist wirklich sehr empfehlenswert - sieht man, dass ich ein Fan bin?):

  • Error Detection: Wenn ein Fault (“Bug”) einen Error auslöst, muss dies erkannt werden. Detektieren von Faults und den daraus resultierenden Errors muss geschehen, bevor es zu einem Failure kommt. Von A Priori Detection spricht man, wenn man gewisse Rahmenbedingungen kennt und dadurch den Fehler detektieren kann.
  • Error Recovery: Die Detektion eines Errors hilft nicht bei der Beseitigung des Errors. Dabei erfolgt das Prozedere meist in zwei Schritten: die Effekte des Errors werden rückgängig gemacht und anschließend wird das System wieder in einen validen Zustand gebracht. Bekannte Techniken wie Restart oder Failover können im extremsten Fall angewandt werden.
  • Error Mitigation: Durch Bearbeitung des fehlerhaften Zustands wird versucht weiter zu machen (e.g. Daten korrigieren)
  • Error Treatment: Durch Updates / Patching wird der Fehler vom System entfernt

Wir wissen also, dass wir nicht 100% “wasserdichten” Code mit vertretbaren Aufwand schreiben können. Auch können wir Ausfälle von Komponenten nicht garantieren. Ein Stichwort, welches in der Literatur öfters zu finden ist: Fault Tolerance.

Fault Tolerance

Von Software Fault Tolerance spricht man, wenn Software weiter funktioniert auch wenn Teile von ihr nicht mehr korrekt funktionieren. Das hört sich in erster Instanz “genial” an – allerdings muss man dafür einiges tun. In erster Linien hat Fault Tolerance auf Design und die Denkmuster, die ein Entwickler haben sollte, großen Einfluss. Nichts im Leben ist eben gratis …

Fault Tolerance geschieht in mehreren Bereichen bzw. Phasen. Die Phasen wurden bereits aufgezählt: Als erstes muss man den Fehler “beobachten” (erkennen). Wie von Kopetz [2] und Laprie [3] beschrieben bzw. von Hanmer zusammengefasst, gibt es mehrere Möglichkeiten, Fehler zu beobachten. Von Fail-Silent Systemen spricht man, wenn ein System entweder das Service anbietet oder nicht (silent) – das Senden von fehlerhaften Ergebnissen wird verhindert. Das hat den Vorteil, dass es für einen Beobachter einfacher ist, festzustellen, ob eine Komponente nicht mehr antwortet, anstatt feststellen zu müssen, dass die Antworten nicht korrekt sind. Ebenfalls kann dadurch verhindert werden, dass andere Komponenten in Mitleidenschaft gezogen werden. Um f Faults zu tolerieren, sind daher f + 1 Systeme notwendig. Ist ein Beheben des Problems nicht möglich und wird ein Failure ausgelöst, so nennt man dies Crash-Failure Mode. Von einem Fail-Stop System spricht man, wenn der Crash-Failure im restlichen System sichtbar ist. Ein Beispiel eines Fail-Stop Systems ist der Computer der Voyager Raumsonde, welcher in den Crash-Failure Mode ging, nachdem es einen Failure entdeckt hatte. Der Backup Computer konnte übernehmen. Fehler müssen dabei nicht immer unter den gleichen Umständen auftreten (engl. Consistent), sondern können auch zufällig auftreten, e.g. Byzantine Failures. Diese Art von Fehlern sind schwer zu finden und sollten vermieden werden (e.g. durch gutes Design der Software).

Einen Fehler entdecken ist das eine – doch wie kann man verhindern, dass es erst gar nicht soweit kommt? Qualitätssicherende Maßnahmen können die Anzahl der Softwarefehler reduzieren (und die dadurch entstehenden Folgen). Allerdings weiß man, dass dies nie zur Gänze (mit gerechtfertigtem Aufwand) passieren kann. Daher ist Fault-Tolerance erforderlich. Hanmer beschreibt einige Techniken, welche dabei helfen, Fault Tolerant Software zu gestalten – ein Auszug:

  1. Keep It Simple: Ein wichtiges Prinzip für Fault-Tolerance ist auch das K.I.S.S. Prinzip (Keep It Simple, Stupid). Komplexe Systeme sind schwerer zu testen und die Operation zum Korrigieren des Fehlers ist schwieriger. Zusätzliche Komplexität erfordert einen zusätzlichen Code, welcher wieder Fehler enthalten kann.
  2. Defensives Programmieren (engl. Defensive Programming): Defense Programming Techniken sollten angewandt werden, um Fault-Tolerant Code zu produzieren:
  • Auch bei Software, welche für Fault-Tolerance Aktivitäten geschrieben wurde, sollte das K.I.S.S. Prinzip eingehalten werden. Wird hohe Komplexität für das Error Handling angewandt, besteht die Gefahr, dass dadurch weitere Fehler in die Software gelangen.
  • Memory Corruption kann auch eine Rolle spielen. Daten, welche vom Speicher geholt werden, sollten stets auf Plausibilität überprüft werden. Memory Corruption kann durch Hardware Fehler oder auch e.g. falsch angewandte Zeiger (engl. Pointer) passieren. Zu diesem Punkt kann prinzipiell jede Art von Input gezählt werden - man sollte immer auf Plausibilität prüfen!
  • Einfache Wartbarkeit in Zukunft sollte beim Entwickeln der Software wichtig sein.
  • Redundanz e.g. durch Ausführen einer Berechnung auf zwei unterschiedlichen Rechnern oder zwei unterschiedlichen Implementierungen.
  • Statische Codeanalyse

Ausgefeilte Testtechniken sind ebenfalls qualitätssteigernd. E.g. Fault Insertion Test (siehe auch Teil 1), bei dem bewusst Fehlerszenarien hervorgerufen werden und das System beobachtet wird.

Techniken, um sich auch im “Nachhinein” für schlechten Daten zu schützen sind Correcting Audits: Man unterscheidet zwischen statischen Dateien (e.g. Konfigurationsdaten, welche sich im Laufe des Programms nur selten ändern) und dynamische Daten (welche sich häufig ändern). Falls der Datenfehler spät erkannt und behoben wird, so können die fehlerhaften Daten durch das System propagiert werden und andere Komponenten können u.U. auch einen Error aufweisen.

Als nächstes zähle ich zwei theoretische Konzepte von Hanmer [4] auf, um etwas Struktur in das Ganze zu bringen. Es gibt Komponenten zum erkennen von Fehlern - diese haben folgende zwei Aufgaben:

  • Leitende Position (Someone in Charge): Alles kann in einem System schief gehen. Auch das Error Processing selbst. Es ist daher wichtig, dass jemand beliebiger im System weiß, was zu tun ist. Wenn „jemand“ (e.g. eine Komponente) weiß, wie eine Error Processing Routine einer anderen Komponente funktioniert und wird e.g. die Zeit, welche zum Beheben des Fehlers benötigt wird nicht eingehalten, kann diese e.g. einen Reset durchführen.
  • Fault Observer: Wurden Fehler automatisch behoben, so ist es für Beobachter (engl. Observer – andere Systeme oder Personen) von Interesse dies zu erfahren. Jede Komponente welche einen Error detektiert hat, sollte diese Information auch publizieren können. Allerdings sollte vermieden werden, dass Fehler vielfach gemeldet werden, da es zu Verwirrung führen kann. Es ist daher besser, diese Aufgabe zu zentrieren. Ein Fault Observer propagiert die Fehlerinformation in ein Maintenance Interface (e.g. Reports oder eine rote LED). Jeder Error sollte einem Fault Observer gemeldet werden

Die einzige Sprache (ausgenommen Nachbauten in anderen Sprachen, welche aber nur Teile umsetzen) die ich bis jetzt gefunden habe, die das alles unterstützt, ist Erlang. Dort spricht man von Supervisors:

A supervisor is responsible for starting, stopping and monitoring its child processes. The basic idea of a supervisor is that it should keep its child processes alive by restarting them when necessary.

Erlang wartet mit vielen interessanten Lösung für die oben erwähnten theoretischen Konstrukte auf. Es lohnt sich die Sprache (bzw. Elixir) anzuschauen - was ich auch tun werden ;-) Der nächste Artikel kommt …

[1] http://qconlondon.com/dl/qcon-london-2009/slides/JoeArmstrong_ErlangALanguageForProgrammingReliableSystems.pdf
[2] Kopetz, Hermann. Real-time systems: design principles for distributed embedded applications. Springer Science & Business Media, 2011.
[3] Laprie, Jean-Claude. Dependability: basic concepts and terminology. Springer Vienna, 1992.
[4] Hanmer, Robert. Patterns for fault tolerant software. John Wiley & Sons, 2013.