Qualitätssicherung verteilter Systeme in agilen Projekten
Nicht erst seit dem Aufkommen von Microservices bestehen zeitgenössische Softwaresysteme aus einer Vielzahl an Services. Jedoch bringt es das Entwurfsparadigma Microservice zu einer neuen Qualität der Verteilung: Mehrere kleine, gut gekapselte Services werden von unterschiedlichen Teams zeitgleich und unabhängig entwickelt. Insbesondere für die Qualitätssicherung (QS) des Gesamtsystems stellt die eigenständige verteilte Entwicklung kollaborierender Services eine Herausforderung dar.
In agilen Projekten stellen Methoden wie Continuous Integration und Continuous Delivery sicher, dass zu jedem Zeitpunkt eine lauffähige Version des zu entwickelnden Systems existiert. Dies wird vor allem durch einen hohen Grad an Testautomatisierung in Verbindung mit Deploymentpipelines erreicht. Eine Deploymentpipeline teilt einen automatisierten Deploymentprozess in unterschiedliche Phasen auf. In den einzelnen Phasen werden dedizierte Tests für das Deploymentartefakt durchgeführt. So kann es beispielsweise eine Phase für Unit-Tests, eine für Service-Tests und eine für End-2-End-Tests geben. Jeder Service stellt ein eigenes unabhängiges Deploymentartefakt dar und besitzt daher auch eine eigene Pipeline. Wie stellt man aber sicher, dass ein solches Artefakt mit allen anderen Artefakten im Sinne der Gesamtfunktionalität jederzeit korrekt kommuniziert? Und dies unter der Randbedingung, dass alle Services unabhängig voneinander deployed werden können. Die Handhabung dieses Problems beginnt bereits mit der Kommunikationsarchitektur der Services, d. h. wenn Änderungen eines Services Änderungen an anderen Services bedingen, wird es schwieriger.
Kommunikation unter Services
Damit zwei Services miteinander kommunizieren können, müssen beide Seiten (Sender und Empfänger) dieselbe Sprache und dasselbe Protokoll beherrschen. Eine typische Konstellation ist beispielsweise SOAP, wobei HTTP das Protokoll und XML (syntaktisch), bzw. XSD (semantisch) die Sprache darstellt. Sender und Empfänger tauschen folglich über ein gemeinsames Protokoll Nachrichten aus, welche in einer gemeinsam bekannten Sprache verfasst sind. Dies ist ein Vertrag beider Seiten bzgl. der Kommunikation untereinander.
Die Nutzung von Informationen ist oft nicht auf zwei einzelne Services beschränkt. In der Regel gibt es Produzenten von Informationen (als Nachrichten codiert), die von mehreren Konsumenten genutzt werden. Dabei nutzen die einzelnen Konsumenten die Informationen auf unterschiedliche Weise, d. h. sie haben unterschiedliche Anforderungen an den Informationsgehalt der gesendeten Nachrichten. An einem Beispiel lässt sich das verdeutlichen: Ein Produzent für Kontoinformationen versendet Nachrichten, welche Informationen zu Kontoinhaber, Kontonummer, Kontostand und Dispokreditrahmen enthalten. Verwendet ein Konsument A Kontoinhaber und Kontostand zur Anzeige und ein weiterer Konsument B Kontonummer und Dispokreditrahmen, so haben beide Konsumenten orthogonale Anforderungen an den Inhalt der Nachrichten. Entsprechend sind sie auch von Änderungen am Inhalt der Nachrichten in unterschiedlicher Weise betroffen.
Design für Qualitätssicherung
Qualitätssicherung verteilter Systeme beginnt mit ihrem Design. Durch eine lose Kopplung der einzelnen Services kann QS effizienter gestaltet und – da sich QS technisch als Deploymentpipeline manifestiert – so deren Durchsatz erhöht werden. Schnellere Releases sind die Folge.
Tolerant Reader
Wählt man die Zeitspanne nur groß genug, wird sich der Inhalt einer Nachricht mit hoher
Wahrscheinlichkeit einmal ändern. Dann gilt es, die Auswirkungen für konsumierende Services so gering wie möglich zu halten, so dass der korrekte Informationsaustausch unter den Services auch weiterhin gegeben ist. Wir betrachten zunächst das Problem, die Auswirkungen einer Nachrichtenänderung begrenzt zu halten.
Sei streng bei dem was du tust und offen bei dem was du von anderen akzeptierst.
Der Internet-Robustheitsgrundsatz besagt "sei streng bei dem was du tust und offen bei dem was du von anderen akzeptierst". Ein gutes Beispiel für die Anwendung dieser Aussage sind Internet-Browser und ihr Umgang mit veraltetem, bzw. ungültigem HTML. So ziemlich jede HTML-Nachricht wird im Browser verarbeitet und dargestellt. Im Normalfall braucht ein Service sicherlich nicht in diesem Ausmaß Nachrichten zu tolerieren. Das Beispiel soll an dieser Stelle einfach mal als theoretisches Idealbild fungieren.
Es lässt sich aber hieraus ein solides Paradigma für die Validierung eingehender Nachrichten im Service ableiten: Ein Service sollte nur so viel wie nötig und so wenig wie möglich validieren. Dies ermöglicht es, auch Nachrichten zu akzeptieren, die in Bestandteilen variieren, welche den validierenden Service nicht betreffen.
In unserem Kontobeispiel könnte man die Kommunikation mittels XLM/XSD realisieren. Es ist ein Klassiker, eingehende Nachrichten anhand des vorliegenden XSDs zu validieren. So kann man sicher sein, dass die eingehende Nachricht zu 100% dem ausgehandelten Vertrag genügt. Genau dies ist es aber auch, was eine enge Kopplung von Produzent und Konsument erzeugt. Wenn Konsumenten Nachrichten vollständig gegen ein Schema validieren, führt zwangsläufig jede Änderung der Nachrichtenstruktur zu einer Änderung am Konsumenten egal ob sie relevant für ihn ist oder nicht. Wenn also beispielsweise unsere Kontonachricht einfach nur ein weiteres Attribut Geburtsdatum hinzubekommt, muss die Validierung in allen Konsumenten angepasst werden. Es wäre an dieser Stelle angenehmer, wenn jeder Konsument nur genau diejenigen Attribute der XML-Nachricht validieren würde, die auch tatsächlich von ihm verwendet werden. Als Alternative zu einer XSD-Validierung könnte hierzu Schematron [1] eingesetzt werden. Schematron erlaubt die partielle Validierung von XML-Dokumenten anhand vorgegebener Muster.
Qualitätssicherung während der Entwicklung
QS während der Entwicklung umfasst verschiedene Testverfahren. Dabei ist es wichtig, die verschiedenen Aspekte der Tests zu betrachten, um sie dementsprechend einzusetzen.
Allgemein kann man einen Trade-Off zwischen Laufzeit und Test-Scope beobachten. Ein größerer Test-Scope führt dabei automatisch zu mehr Sicherheit in Bezug auf die Funktionalität des Systems.
Unit-Tests
Das Fundament moderner Softwareentwicklung sind Unit-Tests. Sie sind klein und schnell, so dass eine hohe Anzahl während des Builds ausgeführt werden kann, ohne dass dieser gleich zum Kaffeetrinken auffordert. Durch den beschränkten Scope lässt sich die Ursache eines fehlgeschlagenen Unit-Tests effizient analysieren. Jedoch ist es auch genau dieser kleine Scope, der am Ende dazu führt, dass die Aussagekraft einer Unit-Testsuite recht gering für die Gesamtfunktionalität eines Systems ist. Und so entsteht der Wunsch nach Tests mit einem größeren Scope um diesbezüglich mehr Sicherheit zu bekommen. Nichtsdestotrotz sollte der quantitative Löwenanteil aller automatisierten Tests bei den Unit-Tests liegen. Den nächst größeren Scope bieten dann Service-Tests. Hierbei ist die Unit ein einzelner Service, dessen Abhängigkeiten gemockt werden.
End-2-End-Tests
Will man die fachliche Funktionalität eines Systems überprüfen, sind End-2-End-Tests erste Wahl. Sie decken das gesamte System ab und bieten daher die höchste Sicherheit für die
Gesamtfunktionalität. Jedoch hat dies auch seinen Preis.
- Laufzeit: End-2-End-Tests sind definitiv kein Kandidat für den Build. Es ist nicht unüblich, dass die Ausführung einer End-2-End-Testsuite mehrere Stunden dauert.
- Fehleranalyse: Ein fehlgeschlagener End-2-End-Test bedingt eine aufwändige Ursachenanalyse, so dass Fehler langsamer behoben werden, als in früheren Testphasen. Da nachfolgende Deployments gestoppt werden müssen bis der Fehler im End-2-End-Test gefunden wurde, kann dies zu einem sogenannten "Pile-up" nachfolgender Deploymentartefakte führen.
- Brüchig/Flackernd: End-2-End-Tests decken das gesamte System, also mehrere verteilte Services ab. Durch den hohen Anteil an Fehlerquellen sind sie entsprechend anfällig für Ausfälle (brüchig). Es kann also leicht sein, dass ein End-2-End-Test zu flackern anfängt, d. h. mal funktioniert er, mal nicht. Ursachen für ein solches Flackern liegen oft in der Netzwerkkommunikation, oder in Race Conditions zwischen Prozessen. In jedem Fall müssen solche Tests gefixt werden, da sie keine deterministische Aussage über ein Fehlverhalten des Systems erlauben und die Ursache des Flackerns nicht zuletzt auch in Produktion auftreten kann.
End-2-End-Tests sollten aber in keinen Fall ausgelassen werden. Sie stellen einen wichtigen Pfeiler für die Sicherung der Abnahmekriterien dar. Insbesondere für kleinere verteilte Systeme mit zwei bis drei Services, sind End-2-End-Tests sehr hilfreich. Es fängt erst für massiv verteilte Systeme aus 3, 4, 10 oder 20 Services an, problematisch zu werden. Um den Trade-Off aus Nachteilen und Sicherheit für massiv verteilte Systeme zu handhaben, sollten automatisierte End-2-End-Tests sich auf wenige zentrale Anwendungsfälle beschränken, welche weite Teile des Systems berühren.
Consumer-driven Testing
Consumer-driven Contracts sind eine gute Antwort auf die Frage, wie man bei Änderungen von Nachrichten die korrekte Kommunikation der Services untereinander sicherstellen kann. Wir haben bereits gesehen, dass Konsumenten unterschiedliche Anforderungen an den Informationsgehalt einer Nachricht haben. Bezogen auf unser XML-Beispiel bedeutet das, dass Konsumenten unterschiedliche Attribute aus der Nachricht benötigen. Wir haben ebenfalls gelernt, dass die Nachrichtenstruktur Teil eines Kommunikationsvertrags zwischen Produzent und Konsument ist.
Dem Produzenten als Emitter der Nachricht obliegt hierbei der Entwurf des Vertrags. Um diesen Vertrag nun Konsumenten-getrieben zu entwickeln, benötigt man die Anforderungen der Konsumenten an den Inhalt der Nachricht. Das Prinzip des Consumer-driven Contracts macht im Kern keine Aussage dazu, wie dies geschehen soll. Im einfachsten Fall listen die Konsumenten (also die Teams, die den Konsumenten entwickeln) ihre Anforderungen auf und alle setzen sich an einen Tisch. Da wir uns aber in einem agilen Kontext bewegen, müssen solche Abstimmungen in jedem Entwicklungszyklus eines Services stattfinden. Daher bieten sich automatisierte Tests als Formulierung der Konsumentenanforderungen an, um dies effektiv zu gestalten. Schlägt ein solcher Test fehl, ist der Kommunikationsvertrag zwischen Produzent und einem einzelnen Konsumenten gebrochen. Diese vom Konsumenten geschriebenen Tests können in der Deploymentpipeline des Produzenten in einer dedizierten Phase platziert werden. Somit wird bei Änderungen für jeden einzelnen Konsumenten sichergestellt, dass seine Anforderungen an das Nachrichtendesign eingehalten werden, aber eben auch nicht mehr. Und das ist im Sinne einer unabhängigen Weiterentwicklung von Produzent und Konsument.
Non-Functional Tests
In der Regel unterliegen Systeme sogenannten "nicht-funktionalen" Anforderungen. Sie können meistens erst in Produktion überprüft werden. Aber man kann Tests definieren, um zumindest Abweichungen vom Optimum zu erkennen. Ein prominentes Beispiel hierfür sind Lasttests. Jemand hat mir einmal gesagt "Jeder zahlt für Lasttests. Entweder während der Entwicklung oder in Produktion." Letzteres will man ganz bestimmt nicht. Daher sind Lasttests unverzichtbar für die QS. Sie dienen mitnichten nur dem Ziel einer Performance-Verbesserung. Vor allem sind sie dazu gedacht, die Stabilität des Systems sicherzustellen.
Üblicherweise wird man einen Mix verschiedener Lasttests einsetzen. Angefangen bei umfassenden Tests, welche zentrale Anwendungsfälle abdecken bis hin zu Tests, die einzelne Services isolieren. Es bietet sich an, die Anzahl simulierter Benutzer während der Testdurchläufe inkrementell zu erhöhen, um dadurch Antwortzeiten in Abhängigkeit wachsender Last zu beobachten. Wichtig ist es, im Vorfeld Ziele zu definieren, um so Abweichungen vom Optimum zu definieren und zu erkennen.
Leider haben Lasttests eine zu lange Laufzeit, als das es Sinn machen würde, sie als Teil des Builds auszuführen. Es ist übliche Praxis, z. B. einen Teil täglich, einen größeren Teil wöchentlich laufen zu lassen. Die zeitlichen Abstände müssen individuell festgelegt werden.
Qualitätssicherung in Produktion
Löst man sich von der Vorstellung, dass ein Deployment gleichbedeutend mit einem Release ist, so ergeben sich weitere interessante Möglichkeiten der QS. In Produktion zu testen ist sicherlich keine Blaupause für jedes Geschäftsmodell, aber es macht Sinn, die Möglichkeit in Betracht zu ziehen. Die Motivation hierfür resultiert aus der Erkenntnis, dass in Tests von Umgebung und Daten, wie man sie in Produktion vorfindet, nur abstrahiert wird. Warum also nicht unter realen Bedingungen testen?
Damit einher geht auch die Überlegung, dass der Nutzen weiterer Tests evtl. nicht im Verhältnis zum Aufwand der Implementierung steht (weil beispielsweise bereits eine hohe Testabdeckung existiert). Unter dieser Bedingung kann man sich überlegen, welches Ziel man eigentlich mit der QS verfolgen will. Typischerweise will man nicht, dass ein Fehler in Produktion überhaupt auftritt. Und darauf richtet sich auch die QS aus: Man schreibt Tests, um jede erdenkliche Fehlfunktion auszuschließen. In diesem Fall nennt man das Ziel "meantime between failure" was bedeutet, dass man die Zeit zwischen zwei Fehlfunktionen in Produktion maximieren will. Jedoch ist es schlicht nicht möglich, mit Tests (egal welcher Art) Fehler vollständig auszuschließen. Und aus dieser Erkenntnis ergibt sich ein weiteres Optimierungsziel für die QS: "meantime to recovery". Hierbei akzeptiert man das Auftreten von Fehlern in Produktion, versucht aber deren Behebungszeit (also die Zeitspanne, die das System ausfällt) zu minimieren. Diesem Ziel sind die Techniken gewidmet, die wir in diesem Abschnitt betrachten.
Monitoring
Die Grundlage zur Fehlerbehebung – ungeachtet des Optimierungsziels – ist Monitoring. Ein System, welches nicht überwacht wird, ist wie ein herrenloser Ozeanriese ohne Ruder.
- Kennzahlen – Monitoring steht und fällt mit dem Entwurf eines Kennzahlensystems. Es genügt aber nicht, Zahlen zu erfassen. Man muss sie auch bewerten und Schlussfolgerungen auf den Zustand des Systems ziehen können. Was sagt beispielsweise eine CPU-Auslastung von 72% über den Zustand eines Systems? Dazu muss man das System beobachten, Erfahrungen sammeln und Schwellwerte festlegen sowie deren Über- oder Unterschreiten eine Bedeutung für das System beimessen. Man unterteilt damit Metriken der Kennzahlen in kritische Bereiche. Jeder Bereich steht für eine Aussage zum Zustand des Systems. Ein typisches Beispiel ist ein Ampelsystem, welches die Metriken seiner Kennzahlen in drei Bereiche einteilt.
- Aktives/Passives Monitoring – Man unterscheidet zwei Arten von Monitoring: Aktives und Passives Monitoring. Letzteres ist das, was man gemeinhin als Monitoring bezeichnet. Nämlich das Aufzeichnen und (hoffentlich) Beobachten des Systemverhaltens unter Einfluss realen Traffics. Aktives Monitoring hingegen stellt eine interessante Alternative hierzu dar. Hierbei wird das System aktiv mit künstlichen Inputs beschossen und sein Verhalten hierzu aufgezeichnet und beobachtet. Man könnte beispielsweise Selenium-Tests – aus der End-2-End-Testsuite – auf dem System (in Produktion) ausführen und sein Verhalten beobachten. Man nennt diese Vorgehensweise auch "Synthetic Monitoring" (SM). Durch SM kommen wir einen Schritt näher an die Beantwortung der Frage "Funktioniert unser System?". Denn SM ist eine Simulation typischen Benutzerverhaltens oder -navigation und kann so gut eingesetzt werden, kritische Geschäftsprozesse zu beobachten. Da SM keinen echten Traffic benötigt, kann man mit Hilfe dieser Technik Probleme der Anwendung aufdecken bevor sie echte Kunden betreffen. SM kann beispielsweise zu einem Zeitpunkt durchgeführt werden, da wenig Last auf dem System liegt.
- Logging – Hat man sein Kennzahlensystem einmal entworfen, kann man anfangen, die Daten in Log-Dateien zu erfassen. Dabei sollte man eine Log-Datei als Dokument mit einer dezidierten Leserschaft betrachten. Eine Person, die eine Log-Datei öffnet, verfolgt ein bestimmtes Ziel. Sie will Informationen hierzu aus der Log-Datei ziehen. Je schneller sie an diese Informationen kommt, um so besser. Man kann also ruhig in Betracht ziehen, mehrere dedizierte Log-Dateien zu einzelnen Themen aufzusetzen, um dadurch ihre Lesbarkeit zu erhöhen. Last but not least, um der Anzahl entstehender Log-Dateien in verteilten Systemen Herr zu werden gibt es eine Reihe Werkzeuge, die ich hier nicht vorstellen kann [2].
Blue/Green-Deployment
Wird ein Service in Produktion mit dieser Methode deployed, schaltet man nicht gleich seinen Vorgänger ab, sondern hält beide Versionen eine Zeit lang in Produktion. Man führt eine Reihe an Tests auf der neuen Version durch und wenn diese erfolgreich waren, schaltet man die neue Version scharf (Release). Ohne jedoch dabei den Vorgänger zu löschen. Tritt nun in Produktion ein Fehler auf, hat man die alte Version als Fallback.
Canary Release
Ähnlich zum Blue/Green Deployment ersetzt man nicht die Vorgängerversion durch ein neues Deployment, so dass beide Versionen eine Zeit lang in Produktion existieren. Beim Canary Release leitet man nun den Traffic partiell auf das neue Deployment um, wodurch ein kleiner Anteil der Requests nun vom neuen Deployment und der Löwenanteil weiterhin vom alten Service bearbeitet wird. Treten keine Fehler auf, kann man den Anteil des umgeleiteten Traffics sukzessive erhöhen und somit das neue Deployment schleichend freischalten. Dabei hat man auch die Möglichkeit, Traffic zu duplizieren, so dass weiterhin der gesamte Traffic vom alten Service bearbeitet wird, aber ein kleiner Anteil zusätzlich vom neuen Deployment, ohne dass dessen Antworten nach außen gehen. Der Begriff "Canary" stammt übrigens von Kanarienvögeln, die in Kohlegruben eingesetzt wurden, um Arbeiter zu warnen, wenn der Sauerstoffgehalt abnimmt. Man setzt also Benutzer als Kanarienvögel ein.
Fazit
Es gibt zwei Eckpfeiler, welche die moderne QS prägen. Zum einen sind das agile Entwicklungsmethoden wie Continuous Integration und Continuous Delivery, die mittels Testautomatisierung schnelle iterative Prozesse schaffen. Zum anderen sind es moderne Entwurfsparadigmen wie Microservices, deren Maxime (massiv) verteilte Systeme sind. So kommt es zu parallelen iterativen Entwurfsprozessen, in deren Rahmen die QS die korrekte Kollaboration aller Services sicherstellen muss, ohne dabei Releasezyklen unnötig zu verlängern. Hierfür gibt es mehrere Ansätze, angefangen beim Design der Kommunikationsarchitektur bis hin zum Deployment und Betrieb in Produktion. Wir haben gesehen,
- dass bereits während des Designs erste Entscheidungen zur QS getroffen werden,
- dass mittels Consumer-driven Tests automatisiert die Abhängigkeiten zwischen Services evaluiert werden können,
- dass End-2-End-Tests auf wenige zentrale Anwendungsfälle beschränkt werden sollten und
- dass in Produktion mittels Monitoring sowie einer Trennung von Deployment und Release QS betrieben werden kann.
Die hier vorgestellten Techniken sind keine Blaupause nach der jedes Projekt arbeiten muss. Vielmehr sind sie als Werkzeugkasten gedacht, aus dem man sich bedienen kann. Alle Techniken beziehen sich auf automatisierte Tests. Da wir für massiv verteilte Systeme empfohlen haben, automatisierte End-2-End-Tests nicht für jedes Feature anzulegen, sondern sich auf wenige Anwendungsfälle zu beschränken, kann es daher durchaus sinnvoll sein, außerhalb der Entwicklung zusätzlich manuelle Abnahmetests einzurichten.