Motivation

Microservices sind aktuell in aller Munde. Sollte man sich selbst und seine Kunden in ein solches Abenteuer stürzen? Was ist dran, an dieser Architektur? Was bringt sie den Beteiligten und welchen Preis zahlt man unter dem Strich?

Wir haben es getan, sagen warum und berichten von unseren Erfahrungen mit einer verteilten Microservice Architektur im Kubernetes-Cluster. Dieser Post will lediglich einen ersten Überblick geben. Spezielle Fragestellungen werden in nachfolgenden „Deep-Dives“ behandelt.

Die Aufgabenstellung

Der Auftrag lautete, einen digitalen Marktplatz (wir sagen auch „Plattform“ dazu) für die Kreislaufwirtschaft zu bauen. Das Geschäftsmodell ist schnell beschrieben:

Kunden bestellen Abfallcontainer in verschiedenen Größen und für unterschiedliche Abfallarten zum Wunschtermin per Mobile- oder Web-App oder über ein Kundenportal. Die Plattform vermittelt diese Aufträge an ein angeschlossenes Entsorgernetzwerk. Der Entsorgungspartner, der einen Auftrag gewinnt, wird bei der Abwicklung der Prozesse „Aufstellen, Abholen und Tauschen von Containern“, „Rückmeldung von Tonnagen“ und „Dokumentation der Leistungen (Nachweisfotos und Wiegescheine)“ ebenfalls von einer Mobile App End-to-End unterstützt.
Die Plattform schreibt Rechnungen an Kunden und Gutschriften an Entsorgungspartner.

Wir haben Web-Portale für Kunden und Entsorgungspartner, eine Mobile App für Bauleiter, Entsorger-Disponenten und –Fahrer sowie eine Web-App für das Backoffice der Plattform entwickelt. Die Versorgung der Plattform mit Stammdaten erfolgt über TEGOS-enwis, ein Standard ERP System der Branche.

Das Setup – Alles in die Cloud

Unser Auftraggeber hat für den Betrieb des Marktplatzes ein selbständiges Unternehmen, die Redooo, ausgegründet, sodass wir es de-facto mit einem Start-up zu tun hatten. Als Start-up wollte man keine eigene Hardware anschaffen, geschweige denn betreiben. Alles, was Redooo an Software verwendet, also unseren digitalen Marktplatz, das ERP-System und sogar die Office Anwendungen, kommt aus der Cloud. Aber auch die Entwicklung der Software selbst findet in der Cloud statt. Das GitLab Code-Repository, die automatisierten Build- und Deploy-Pipelines, das Doku-Wiki – alles liegt im Cloud-Account der Redooo.

Eine Anwendung aus Microservices

Über Microservices gib es im Netz viel zu lesen, sodass ich hier Text einsparen kann. Wichtig ist mir, festzuhalten, dass wir über Anwendungsarchitektur sprechen. Es geht also nicht um die großen Services einer Enterprise Architektur im Sinne einer SOA, sondern darum, wie man eine einzelne Anwendung aus verschiedenen Services „komponiert“. Jeder unserer Services ist ein in sich abgeschlossenes Stück Software, das separat installiert wird und dann ein eigenständiges Leben führt. Die Logik der Anwendung ergibt sich aus dem Zusammenspiel dieser ansonsten autarken Services. Idealerweise erfüllt jeder Service dabei genau eine einzige, wohldefinierte Aufgabe. Wenn jeder Service ein autarkes Stück Software ist, benötigt er seine eigene Laufzeitumgebung. Unsere Services leben in Docker Containern, die ihrerseits in einem Kubernetes Cluster betrieben werden. Die Rolle von Kubernetes in einer Microservice Architektur ist ein zentrales Thema in diesem Post. Damit sind die Grundzüge unserer Anwendungsarchitektur aus der Vogelperspektive beschrieben und lassen sich in einem Bild zusammenfassen:

Abbildung 1: Microservices in einem 4-Knoten Kubernetes Cluster

Die Abbildung zeigt ein paar ausgewählte Services in einem aus 4 Nodes bestehenden Kubernetes Cluster. Kubernetes bindet die einzelnen Services an eine öffentliche IP Adresse, die von mobilen Endgeräten und Desktop Web-Anwendungen genutzt wird.

Das Bild deutet schon an, dass dieses Setup im Vergleich zu einer monolithischen Architektur deutlich mehr Anforderungen an die Betriebsinfrastruktur der Anwendung stellt. Tatsächlich sieht das Ganze ziemlich „wild“ aus und es stellt sich die Frage, welche Vorteile diesem Mehr an Komplexität entgegenstehen.

Verteilte Microservice Architektur

Was also hat uns dazu bewogen, auf eine Microservice Architektur zu setzen? „Weil das Thema gerade hip und cool ist“, kann ja keine seriöse Antwort sein.

Da war zunächst die Forderung der Redooo nach feingranularer Skalierbarkeit, gekoppelt mit einer hohen Verfügbarkeit der Plattform im 24×7 Online Business. Ebenso wichtig war dem Kunden ein schneller Go-Live mit einem „Minimal Viable Product“ (MVP) und der anschließenden kontinuierlichen Entwicklung und Inbetriebnahme neuer Features ohne Beeinträchtigungen für die Kunden der Plattform.

Auf diese Anforderungen möchte ich gerne näher eingehen.

Skalierbarkeit

Skalierbarkeit ist gerade für ein Start-up eine sehr gut nachvollziehbare Forderung, besonders dann, wenn man einen digitalen Markplatz etablieren möchte. Der entfaltet seine Vorteile für den Kunden erst dann, wenn es auf der Anbieterseite ein signifikantes Netzwerk gibt, das dem Kunden mehr Service bietet als dies ein einzelner Anbieter kann. So ein Netzwerk entsteht aber nicht von heute auf morgen, denn auf der anderen Seite wird die Plattform für den potentiellen Entsorgungspartner erst dann wirklich interessant, wenn er über die Plattform seine Auftragssituation nachhaltig verbessern kann. So etwas nennt man ein „Henne-Ei“ Problem. Wer ein solches Geschäftsmodell etablieren möchte, kämpft also sofort an zwei Fronten gleichzeitig und benötigt möglicherweise einen „langen Atem“ bis das Geschäft läuft.

Den Begriff „Skalierbarkeit“ assoziiert man gerne zuerst mit dem oberen Ende der Skala: „nach oben hin muss das System mit jeder Last fertig werden“. Auf dem Weg dahin ist es aber durchaus hilfreich, wenn man zunächst nur für die Ressourcen bezahlt, die man tatsächlich benötigt, anstatt schon zu Beginn „auf Vorrat“ einzukaufen und bezahlen zu müssen.

Wie stehen Microservices zu solchen Wünschen? Bei einer Microservice Architektur wird die Gesamtleistung des Systems nicht von einem monolithischen Prozess, sondern von vielen einzelnen Services erbracht, die unabhängig voneinander und in gewünschter Anzahl installiert werden können.

Am Beispiel unserer Plattform lässt sich das deutlich machen.

Unser System besteht aus Services, wie z.B.:

  • Kundenservice
  • Entsorgerservice
  • Orderservice
  • Pricingservice
  • Paymentservice
  • Matchingsevice
  • Mailingservice

Das Zusammenspiel all dieser Services macht unseren Marktplatz aus.

Wenn Kunden und Entsorger auf der Plattform agieren, werden diese Services mit sehr unterschiedlicher Intensität in Anspruch genommen. So ist z.B. der Order Service unser am meisten beanspruchter Dienst, zum Glück, möchte man sagen. Letztendlich dreht sich eben alles um Bestellungen.

Der Payment Service ist sicherlich wichtig, wird aber nicht so häufig beansprucht wie der Order Service, da leider nicht jede Bestellung zum Abschluss und damit zu einem Bezahlvorgang gebracht wird.

Noch deutlicher werden die unterschiedlichen Ansprüche an die Services, wenn man sich die Anforderungen an den Mailing Service ansieht. Hier sieht die Erwartungshaltung eines Endkunden völlig anders aus als z.B. bei einer Bestellung per Mobile-App. Im Gegensatz zum Bestellprozess, der möglichst geschmeidig funktionieren muss, wird der leicht verzögerte Eingang einer Bestätigungs-Mail vom Kunden nicht als Beeinträchtigung empfunden.

Die Microservice Architektur versetzt uns in die Lage, diesen so unterschiedlichen Anforderungen auf Service-Level gerecht zu werden. Wir erreichen das, indem wir die Services jeweils in geeigneter Anzahl starten und die Last auf die einzelnen Instanzen verteilen. Wir führen also ein Load-Balancing auf Service Ebene durch. Die angestrebte Performance der Plattform wird erreicht, indem jeder Service in genau der Anzahl von Instanzen betrieben wird, die zur Sicherstellung dieser Leistung benötigt wird.

Dieses „Tuning“ auf Service-Ebene garantiert den optimalen Ressourcenverbrauch der Gesamtplattform durch eine zielgerichtete Reaktion auf Lastveränderungen. Durch permanentes Beobachten lernen wir, welche unserer Services aus der definierten Bandbreite der optimalen Auslastung herauslaufen und bzgl. ihrer laufenden Instanzen nach oben oder unten angepasst werden sollten.

Während der Monolith als Ganzes, oft nur vertikal durch den Einsatz von mehr Hardware, skaliert werden kann, gelingt das in einer Microservice Anwendung viel feingranularer, nämlich auf der Ebene jedes einzelnen Service: wird ein Service zum Bottleneck, kümmert man sich genau um diesen einen Service.

Verfügbarkeit

Bei der Anpassung der Anzahl der laufenden Instanzen eines Service nach unten, stoßen wir auf die zweite nichtfunktionale Anforderung an unsere Anwendung, nämlich auf deren Verfügbarkeit. Verfügbarkeit ist eine Maßzahl, die den Anteil der Benutzbarkeit einer Anwendung ins Verhältnis zu einem Betrachtungszeitraum setzt.

Beispiel: Wenn unser Kunde innerhalb einer Stunde eine Minute lang keinen Container bestellen könnte, betrüge die Verfügbarkeit der Anwendung innerhalb dieser Stunde (60-1)/60, also 0,983 oder 98,3%. Nicht-Verfügbarkeit würde bei uns konkret bedeuten, dass ein Kunde nicht bestellen kann, dass ein Entsorgungspartner die Aufstellung oder die Abholung eines Containers nicht verbuchen kann oder dass das Backoffice bei Problemen nicht unterstützen und eingreifen kann.

Im Onlinehandel ist die Verfügbarkeit einer Anwendung ein hohes Gut, denn ein Kunde, der nicht bestellen kann, wendet sich an den nächsten Händler und kommt ggf. nie wieder. Monolithische Anwendungen stellen ihre Verfügbarkeit in der Regel dadurch sicher, dass die Anwendung entweder aktiv oder passiv in mehreren Instanzen vorgehalten wird. Verfügbarkeit wird also über Redundanz hergestellt, was für den Monolithen bedeuten würde, dass die gesamte Anwendung oder zumindest große Teile redundant vorgehalten werden müssten. Auch eine Microservice Architektur stellt ihre Verfügbarkeit über Redundanz sicher. Der entscheidende Unterschied besteht darin, dass die Redundanz hier für jeden einzelnen Service separat festgelegt werden kann.

Auf unserer Plattform verfolgen wir aktuell die allgemeine Strategie, alle Services mindestens in zwei Instanzen zu betreiben. Mehr Instanzen gibt es für „Bottleneck“ Services zwecks besserer Lastverteilung (siehe „Skalierbarkeit). Nur Services, die für die Gesamtverfügbarkeit der Anwendung unkritisch sind, dürfen in einer Instanz gefahren werden. Unter dem Strich kann man sagen, dass wir bezogen auf Verfügbarkeit drei Klassen von Services unterscheiden:

  • Unkritische Services – laufen in einer Instanz
  • Normale Services – laufen in zwei Instanzen
  • Kritische Services – laufen in mehr als zwei Instanzen

Ob das jetzt besonders schlau ist oder nicht, ist hier gar nicht der Punkt. Entscheidend ist, dass Microservices uns in die Lage versetzen, unsere Strategie jederzeit zu ändern, wenn wir mehr über den Betrieb unserer Plattform gelernt haben. Wir können uns anpassen.

Deployments ohne Downtime

Das Deployment ist seit jeher eines der weniger beliebten Themen in unserem Gewerbe. Ich kann mich noch gut an die Zeiten erinnern, als man sich am Wochenende oder unter der Woche, dann aber nachts, getroffen hat, um die neue Version einer Software in Produktion zu bringen. Von den endlosen Vorbereitungen für ein neues Release einmal ganz zu schweigen. Die Konsequenz waren jährliche, halbjährliche oder bestenfalls vierteljährliche Releases.

Das ist nicht mehr ganz zeitgemäß. Nutzer mobiler Apps erwarten heute, laufend mit neuen Features versorgt zu werden. Auf der anderen Seite bieten schnelle Release Zyklen den Anbietern von Anwendungen die Möglichkeit, das Interesse an ihrem Produkt hoch zu halten. Aus diesem Grund war auch die Redooo sehr daran interessiert, seinen Kunden neue entwickelte Features sofort und ohne Downtime zur Verfügung stellen zu können. Eine Microservice Architektur alleine löst das Problem noch nicht, liefert aber die Grundlage, auf der eine geeignete Betriebsarchitektur aufsetzen kann. Microservices sind autark, vollständig und separat installierbar.

Um es vorwegzunehmen: Auch hier werden wir massiv von Kubernetes unterstützt. Wir deployen jedes Feature, sobald es fertig ist. Features werden nicht mehr „ge-bundelt“. Statt zu warten, deployen wir sofort und ggf. mehrmals am Tag. Für die Kunden unserer Plattform läuft das völlig geräuschlos, d.h. ohne Downtime, ab.

Unabdingbare Voraussetzungen dafür sind automatisierte Tests (auf die Bedeutung von Testautomatisierung gehen wir in einem separaten Post ein) und die ebenfalls von Kubernetes unterstützte Fähigkeit, im Notfall auf den letzten funktionierenden Versionsstand der Services zurückrollen zu können.

Weil wir das können, ist unser Team nach dem initialen Go-Live von der Scrum-Methode auf einen Kanban Prozess umgestiegen. Was das für die Kultur innerhalb des Teams bedeutet hat und was man prozesstechnisch dafür noch tun muss, ist ebenfalls Thema eines weiteren Posts.

Nachdem der Name Kubernetes jetzt mehrfach gefallen ist, möchte ich im nächsten Abschnitt auf die neuen Anforderungen an die Infrastruktur und die Unterstützung durch Kubernetes überleiten.

Anforderungen an die Infrastruktur und die Rolle von Kubernetes

Nachdem wir gesehen haben, welche Vorteile eine Microservice Architektur gegenüber einem Monolithen mit sich bringen kann, kommen wir nicht umhin, uns mit den Kosten einer solchen Architektur zu beschäftigen.

Die Komplexität der Anwendung hat sich aus dem Code heraus in die Infrastruktur verlagert. Jeder Service für sich ist weniger komplex als der Monolith. Jetzt haben wir es mit einer verteilten Anwendung zu tun, was neue Fragen und Aufgabenstellungen aufwirft:

  • Wer sorgt dafür, dass ein Service immer in der gewünschten Anzahl an Instanzen läuft?
  • Wer sorgt für eine geeignete Verteilung der Service-Instanzen auf die Knoten meines Clusters?
  • Wie funktionieren Deployments ohne Downtime?
  • Kann man ggf. auf den zuletzt funktionierenden Stand der Anwendung zurückrollen?
  • Wie stellt man fest, ob es dem „Zoo“ von Services gut oder schlecht geht?
  • Wie versioniert man einzelne Services und wie komme ich zu einer Version der gesamten Plattform?

Auf die meisten dieser Fragen gibt es eine einfache Antwort: Kubernetes. Um den Rest mussten wir uns leider selber kümmern. Fangen wir der Einfachheit halber damit an, was Kubernetes für uns tut.

Wo und wie hilft Kubernetes?

Kubernetes verwaltet in Containern verpackte Services in einer geclusterten Umgebung und ist damit eine Art Betriebssystem für verteilte, serviceorientierte Anwendungen. Die Services selber müssen in Containern laufen. Wir verwenden Docker Container.

Ein Kubernetes Cluster besteht in der Regel aus dem Verbund mehreren Maschinen, auf denen die erforderlichen Kubernetes Dienste laufen. Kubernetes nennt diese Maschinen „Nodes“. In einem Kubernetes-Deployment beschreiben wir, aus welchen Services unsere Anwendung besteht und wie viele Instanzen von jedem Service benötigt werden. Das Deployment selber ist eine einfache Textdatei im Yaml-Format. Diese Textdatei übergeben wir quasi als Wunschzettel an Kubernetes und vertrauen darauf, dass Kubernetes sein Möglichstes dafür tut, unseren Wunsch zu erfüllen. Kubernetes startet unsere Services in der gewünschten Anzahl von Instanzen und verteilt diese Instanzen nach eigenem Ermessen über die zur Verfügung stehenden Knoten.

Gleichzeitig etabliert Kubernetes für jeden Service einen sogenannten „Reconciliation Loop“, der regelmäßig nachhält, ob die gewünschte Anzahl von Service Instanzen auch tatsächlich immer noch verfügbar ist. Kommt es innerhalb des Clusters zu Abweichungen von unserem Wunschzettel, reagiert Kubernetes entsprechend, indem fehlende Instanzen erzeugt oder überzählige Instanzen heruntergefahren werden.

Wie es sich für einen Wunsch gehört, bekommen wir diese Leistung von Kubernetes geschenkt. Damit sind die ersten beiden Fragen bereits beantwortet. Kubernetes erzeugt unsere Service Instanzen in der gewünschten Anzahl, verteilt diese Instanzen „intelligent“ über die zur Verfügung stehen Knoten und „heilt“ selbstständig alle Abweichungen von diesem Wunschbild.

Downtime freie Deployments unterstützt Kubernetes als eine von mehreren Deployment Strategien. Die von uns eingesetzte „Rolling Update“ Strategie ersetzt sukzessive die alten Instanzen eines Services durch neue Instanzen. Während dieser Prozess vonstatten geht, steht der Service permanent zur Verfügung. Hier muss darauf hingewiesen werden, dass die Anwendung so entwickelt sein muss, dass der kurzfristig parallele Betrieb von alten und neuen Versionen von Service-Instanzen möglich ist.

Jetzt dürfte auch klar sein, wie Kubernetes das Rollback auf den letzten funktionierenden Stand unterstützt: Man reicht schlicht den alten Wunschzettel wieder ein. Kubernetes stellt genau diesen Zustand wieder her. Auch hier darf die Anmerkung nicht fehlen, dass Kubernetes sich natürlich nicht darum kümmert, ob die letzte funktionierende Version eins Service mit dem jetzt neuen Stand der Anwendungsdaten harmoniert.

Damit beantwortet Kubernetes den Wunsch nach Deployments ohne Downtime und stellt uns die Mittel zur Verfügung, notfalls auf einen letzten funktionierenden Stand zurückrollen zu können.

Was mussten wir selber regeln?

Kubernetes regelt das Deployment und den Betrieb unserer Microservices in einer Cluster-Umgebung und übernimmt damit viele Aufgaben, um die man sich nur sehr ungerne selber kümmern möchte. Eine solide Anwendungsarchitektur garantiert das aber noch nicht.

Sehr früh im Projekt hat sich die Frage nach der Datenhaltung der Services gestellt. Gibt es einen zentralen Datenbankservice oder besitzt und verwaltet jeder Service seine eigenen Daten? Im Sinne der Unabhängigkeit und Vollständigkeit eines Services haben wir uns für Letzteres entschieden. Wir gönnen jedem Service seine eigene Datenhaltung. Dabei setzen wir MongoDB ein, eine dokumentenorientierte Datenbank, die selbst auf Verteilung ausgelegt ist und sich in einem Kubernetes Cluster entsprechend wohl fühlt.

Wenn man jeden Service für die Haltung seiner eigenen Daten verantwortlich macht, muss man damit umgehen, dass mehrere Services dieselben Daten benötigen. Wir akzeptieren eine überlappende und damit redundante Datenhaltung innerhalb der Services und synchronisieren die Services untereinander über ein Message-basiertes Publish-Subscribe Muster: Ein Service publiziert Veränderungen in seinem Datenbestand an ein so genanntes „Topic“, das „interessierte“ Services abonnieren und so über den geänderten Datenhaushalt informiert werden

Dieses Muster verwenden wir nicht nur für die Synchronisation unserer Services. Wir haben es zum grundsätzlichen Kommunikationsmuster unserer Services untereinander erhoben. Auf diese Weise bleiben wir dem Paradigma treu, dass Microservices möglichst unabhängig voneinander sein sollten. In unserer Anwendungsarchitektur weiß kein Service von der Existenz anderer Business-Services. Die einzige Abhängigkeit besteht zum Messaging-Dienst, von dem ein Service seinen Input abonniert und an den er seine Ergebnisse publiziert.

Wie steht es um die Abbildung von Transaktionen, wenn man Serviceübergreifende Prozesse über das Abonnieren und Publizieren von Events abbildet? Mit der Entscheidung für Microservices haben wir uns für eine verteilte Anwendungsarchitektur entschieden und unterliegen damit dem CAP-Theorem – wie gesagt, nichts ist kostenfrei. Um die Stärken von Kubernetes bezogen auf Verfügbarkeit und Skalierbarkeit voll ausspielen zu können, setzen wir auf das Konzept der „Eventual Consistency“ und nehmen zugunsten der Verfügbarkeit kurze Phasen der Inkonsistenz des Systems in Kauf. Auf gar keinen Fall wollten wir ein „Two Phase Commit“ über mehrere Services spannen. In einem verteilten System kann man erwiesenermaßen nicht alles haben.

Fazit und Ausblick

Wir sind uns mit unserem Kunden einig, dass die gewählte Microservice Architektur und der Betrieb der Microservices in einem Kubernetes Cluster ein voller Erfolg war. Auch wenn Verfügbarkeit und Skalierbarkeit gerne als selbstverständlich vorausgesetzt werden, beeindruckt die Reaktionsgeschwindigkeit durch zeitnahe Deployments, ggf. mehrmals am Tag, immer wieder.

Klar ist aber auch, dass wir uns auf dieses Abenteuer nicht eingelassen hätten, wenn so etwas wie Kubernetes nicht zur Verfügung gestanden wäre. Aber auch mit Unterstützung durch Kubernetes stellen Microservices hohe Anforderungen an Entwicklung und Betrieb.

Was nützt mir die Möglichkeit, jedes Feature ohne Downtime sofort in die Produktion bringen zu können, wenn ich die Anwendung vor jedem Deployment manuell regressionstesten muss. Ohne Testautomatisierung wird man früher oder später wieder in die alten Release-Muster zurückfallen.

Auch den Selbstheilungskräften von Kubernetes sind natürlich Grenzen gesetzt. Die müssen im Betrieb genauso auffallen, wie die kleinen, versteckten Fehler innerhalb der Anwendung. Ohne ein ordentliches Monitoring mit sinnvollen KPIs und Fehler-Triggern wäre man in einem gefährlichen Blindflug unterwegs.

Wir haben erlebt, wie viele wichtige Entscheidungen es auch in der Welt von Kubernetes noch zu treffen gilt und dass es auf dem Weg zu einer soliden, verteilten Anwendung viele Fettnäpfchen gibt, von denen man das ein oder andere gerne auch mal auslassen darf.

Dieser Beitrag sollte nur einen groben Überblick über unsere Erfahrungen und einige wesentliche Erfolgsfaktoren geben und ist dafür wahrscheinlich schon zu lang geraten. Darum wollen wir über ausgewählte Themen in weiteren Posts berichten.

Ein spezielles Thema wird die Abbildung von Prozessen und das Konzept der Eventual Consistency in unserer Anwendung sein. Dabei berichten wir insbesondere über unsere Erfahrungen mit Kafka.

Ein weiterer Post beleuchtet die Versionierung einzelner Services und die damit verbundene Frage nach der Version der Gesamtplattform. Hier gehen wir genauer auf das Helm Deployment Tool ein, mit dem wir unsere Services geordnete in die Produktion bringen.

Hinsichtlich des Software Entwicklungsprozesses werden wir über den spannenden Übergang vom Scrum- hin zu einem Kanban-Prozess berichten.

Abschließen möchte ich mit dem Ausblick auf unseren KI gestützten, selbstlernenden „Killer Bot“, der permanent versucht, unseren Cluster in Bedrängnis zu bringen und so wertvolle Hinweise zur Stärkung und Verbesserung des Systems liefert.

 

Jetzt teilen auf:

Jetzt kommentieren