Micro oder Nicht-Micro - das ist hier die Frage

Eine Geschichte von Architekten über die Architektur des mandantenfähigen B2B-Cloud-Systems

Haftungsausschluss: Dieser Artikel teilt Wissen aus einem Projekt, das von einem Hycom-Mitarbeiter durchgeführt wurde, das Projekt wurde jedoch nicht von Hycom geliefert.

Einführung

Microservices sind ein Architekturparadigma, das seit über einem Jahrzehnt bekannt ist. Ein großes monolithisches System wird in kleinere unabhängige Komponenten unterteilt. Diese Komponenten bieten ihre Dienste über eine exponierte API an – normalerweise REST.
Ebenso wird eine zentrale Datenquelle, meist eine relationale Datenbank, in separate Schemen unterteilt, die von einzelnen Microservices verwaltet werden (nach dem Datenbank-per-Service-Muster). Dadurch wird jeder Microservice autonom, unabhängig und vollständig von den anderen entkoppelt.

Das Konzept der Microservices bringt viele Vorteile, aber auch viele Herausforderungen mit sich. Hier beschreibe ich die Geschichte der Definition eines Architekturprojekts, bei dem ich
leitender Architekt war, die im Laufe des Projekts gesammelten Erfahrungen und Schlussfolgerungen.

Anforderungen

Während das System auf Finanztransaktionen ausgerichtet war, lag die Priorität auf Sicherheit und Datenkonsistenz. Natürlich musste das System auch Hochverfügbarkeit, Leistung und
Skalierbarkeit bieten. Wir haben geschätzt, dass das System letztendlich etwa 86 GB pro Tag generieren würde (etwa 30 TB pro Jahr). Das in der Cloud verfügbare und für Unternehmen konzipierte System sollte von verschiedenen Einheiten verwendet werden, daher musste die Plattform Mandantenfähigkeit bieten.

Architektur und Lösung

Aufgrund der oben genannten Anforderungen haben wir uns für eine Architektur auf Basis von Microservices entschieden. Um Microservices noch unabhängiger voneinander zu machen und eine maximale Systemresistenz gegenüber Ausfällen und Fehlern zu erreichen, haben wir die Kommunikation zwischen Services auf dem Konzept eines Message Brokers aufgebaut. Aufgrund der großen Möglichkeiten der Routing-Konfiguration wurde dieser Dienst mit RabbitMQ implementiert. Alle wichtigen Ereignisse im System – Domain Events – flossen durch den Broker und wurden dort dauerhaft gespeichert (Datenkonsistenz). Für die Datenspeicherung wurde ein verwalteter PostgreSQL-Clusterdienst verwendet, und der Protokollstrom wiederum wurde im JSON-Format in MongoDB gespeichert.

Der verwendete Technologie-Stack war Java 11, Spring Boot und Spring Cloud und auf dem
Front-End ReactJS – für eine Webanwendung für Administratoren und React Native – für Endbenutzer mit einer mobilen Schnittstelle. Alle Arten von Dateien (jpg, gif, png etc. und PDF) wurden in S3 gehalten. Das System wurde in der Cloud als eine Reihe von Docker-Containern in der Kubernetes-Umgebung bereitgestellt. Um die Größe von Docker-Images zu
reduzieren, wurde ein mehrstufiger Build verwendet und eine dedizierte JRE erstellt.

Wir haben versucht, bewährte Verfahren und anerkannte Muster auf die Welt der Microservices anzuwenden. Hinsichtlich der Datenmuster wurde das bereits erwähnte
Datenbank-per-Service-Muster verwendet. Das Transaktionsausgangsmuster wurde verwendet, um die Datenkonsistenz und vor allem die Atomarität des Schreibens in das lokale PostgreSQL-Schema zusammen mit der Ereignisübertragung an den RabbitMQ-Broker aufrechtzuerhalten. Sagas wiederum sorgte für das korrekte Rollback verteilter Transaktionen.

Das gesamte System, insgesamt 14 Microservices, war über API Gateway verfügbar. Da das System eine öffentliche API bereitstellte, haben wir ein zusätzliches, nur für externe Benutzer von Drittanbietern mit entsprechenden Zugangsschlüsseln vorgesehenes Gateway (Gatekeeper) eingeführt. Die Service-Discovery wurde von der Kubernetes-Laufzeitumgebung bereitgestellt. Ebenso basierte die gesamte Konfiguration auf den in Kubernetes verfügbaren Funktionen.

In Bezug auf Infrastrukturmuster wurden die integrierten Zustandsprüfungen des Orchestrators (Ressourcenanforderungen und -limits) verwendet. Die zentrale Protokollverwaltung basierte auf dem EFK-Stack (Elasticsearch-Fluentd-Kibana), und Spring Cloud Sleuth kümmerte sich um das verteilte Tracing, d. h. die Fähigkeit, einen Abfragefluss anhand einer eindeutigen Kennung zu identifizieren.

Der Zugang zum Cluster wurde durch mit Nginx implementierten Ingress bereitgestellt, bei dem die HTTPS-Verbindung beendet wurde. Die Zertifikatsverwaltung wurde durch das ACME Cert-Manager Helm Chart bereitgestellt. Die Benutzerauthentifizierung wurde durch mit einem privaten Schlüssel (asymmetrische Kryptografie) signierte JWT-Token sichergestellt, und alle Autorisierungs- und Datenzugriffsprobleme wurden von einem einzigen Microservice namens auth bereitgestellt.

Es wurden auch mehrere in den Orchestrator integrierte Sicherheitsmechanismen verwendet, wie etwa die Möglichkeit, eine private Docker-Registrierung, ein dediziertes Servicekonto, Ressourcenanforderungen und -limits (CPU-Zyklen und RAM), rollenbasierte Zugriffskontrolle (RBAC) und Netzwerkrichtlinien zu verwenden. Die Automatisierung von Testprozessen und die Generierung von Artefakten (Docker Images) wurde mit Jenkins und GitLab CI implementiert.

Erfahrung und Schlussfolgerungen

Das Projekt erwies sich als technologischer Erfolg. Performance- und Chaos-Monkey-Tests, die vor dem Produktionsstart durchgeführt wurden, zeigten, wie die angewandte Architektur die Anforderungen an Datenkonsistenz, Systemausfallsicherheit und Effizienz erfüllt. Das hat uns große Zufriedenheit bereitet.

Wenn wir jedoch noch einmal mit der Arbeit an diesem Projekt begonnen hätten, hätten wir ein modulares monolithisches System gebaut, das als eine Komponente implementiert ist, über die REST-API verfügbar ist, aber so gebaut, dass es bei Bedarf leicht in separate Komponenten, wie Microservices, unterteilt werden kann.

Das Projekt wurde für ein deutsches Unternehmen umgesetzt, das On-Demand-Treueprogramme für Unternehmen anbietet. Diese B2B-Lösung ermöglichte es Geschäftskunden, ihre Treueprogramme zu starten, bei denen die Endbenutzer Punkte gegen beliebige Prämien eintauschen konnten, die sie im Internet kaufen konnten, entsprechend der Anzahl der gesammelten Punkte.

Überraschenderweise gestaltete sich die Zerlegung des Gesamtsystems in unabhängige Domains relativ einfach. Vielleicht haben wir bei dieser Unterteilung einige logische Fehler
gemacht, die nie aufgedeckt wurden. Unabhängig von der eingesetzten Architektur zahlt sich dieser Aufwand jedoch immer aus.

Das zugrunde liegende Muster des Microservices-Paradigmas – Datenbank-per-Service – hat einen exponentiellen Einfluss auf die Komplexität des Systems. Insbesondere erschwert es die Aufrechterhaltung der Datenkonsistenz. Da jeder Microservice seine individuelle Datenbank hat, können wir Daten in zwei Microservices nicht transaktional (ACID) ändern. In der Regel ist es daher unmöglich, die Konsistenz der Transaktionsdaten zu wahren – wir sprechen von der sogenannten eventuellen Konsistenz. Es gibt Design Patterns wie SAGA oder Transactional Outbox, aber es besteht kein Zweifel, dass die Komplexität des Systems erheblich zunimmt.

Ebenso können wir aus dem gleichen Grund keine Daten aggregieren, die über zwei Microservices mit einem einfachen SQL-Ausdruck (JOIN) verwaltet werden. Eine einfache
Tabelle auf der Benutzeroberfläche, die beispielsweise Systembenutzer und die Anzahl der von jedem von ihnen aus einer anderen Domain generierten Objekte darstellt, wird zu einer Herausforderung. Auch hier haben wir Muster (Aggregator, Backend-For-Frontend), aber SQL JOIN ist viel einfacher.

Durch die Anzahl der Komponenten steigt auch die operationelle Komplexität des Gesamtsystems deutlich an. Sie benötigen ein erfahrenes Operations Team, das die CI/CD-Pipelines optimal anordnet. Auch die Laufzeitumgebung selbst – meist Kubernetes – ist nicht die einfachste, was Konfiguration und Verwaltung betrifft. Hier werden ebenfalls wieder einfache, aus dem Monolith bekannte Aspekte zu einer großen Herausforderung, wie etwa Log-Aggregation, verteiltes Tracing, Monitoring und Alerting.

Das QA-Team hat es keineswegs leichter. Das Testen von Verträgen der einzelnen Microservices ist relativ einfach. Aber Microservices sind keine separaten Einheiten, sie
implementieren bestimmte Geschäftsanforderungen gemeinsam, und daher müssen wir beim Testen des gesamten Systems alle Abhängigkeiten berücksichtigen. Mocking ist hier das Stichwort.

Abschließende Gedanken

Man sollte bedenken, dass wir beim Betreten der Welt der Microservices den komplexen Raum verteilter Systeme betreten. Wir gewinnen einen enormen Mehrwert auf Kosten von
Komplexität und viel höherem Aufwand. Es ist in diesem Moment schwer zu sagen, wie viel höher, aber wir schätzen, dass es ziemlich viel ist (das 2- bis 3-Fache).

Wenn wir also kurzfristig die aus Startup-Sicht interessante Herausforderung (Validierung der Idee, Feedback vom Markt) erneut annehmen sollten, würden wir uns für die Umsetzung eines modularen Monolithen entscheiden, immer noch die potenzielle Migration zur Microservice-Architektur im Hinterkopf behaltend.

Bleiben Sie in Kontakt mit uns.

Wenn Sie mehr wissen möchten, wenden Sie sich an