Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

In diesem Artikel werde ich darĂŒber berichten, wie das Projekt, an dem ich arbeite, von einem großen Monolithen in eine Sammlung von Microservices umgewandelt wurde.

Das Projekt hat seine Geschichte vor langer Zeit begonnen, Anfang der 2000er Jahre. Die ersten Versionen wurden in Visual Basic 6 geschrieben. Im Laufe der Zeit wurde klar, dass die Entwicklung in dieser Sprache in Zukunft schwer zu unterstĂŒtzen sein wĂŒrde, da sich die IDE und die Sprache selbst nur langsam weiterentwickeln. Ende der 2000er Jahre entschied man sich, auf das vielversprechendere C# umzusteigen. Die neue Version wurde parallel zur Überarbeitung der alten geschrieben, und allmĂ€hlich war mehr Code in .NET. Das Backend in C# war ursprĂŒnglich auf eine serviceorientierte Architektur ausgerichtet, wĂ€hrend in der Entwicklung jedoch gemeinsame Bibliotheken mit Logik verwendet wurden und die Services in einem einzigen Prozess gestartet wurden. Es entstand eine Anwendung, die wir „serviceorientierten Monolith“ nannten.

Einer der wenigen Vorteile dieser Kombination war die Möglichkeit, dass sich die Services ĂŒber eine externe API gegenseitig aufrufen konnten. Es gab deutliche Anzeichen fĂŒr den Übergang zu einer besseren serviceorientierten und perspektivisch auch microservice-orientierten Architektur.

Wir begannen etwa 2015 mit unserer Arbeit an der Dekompensation. Wir haben zwar noch nicht den idealen Zustand erreicht – es gibt Teile des großen Projekts, die man kaum noch als Monolithen bezeichnen kann, aber sie Ă€hneln auch nicht wirklich Microservices. Dennoch ist der Fortschritt erheblich.
DarĂŒber werde ich in dem Artikel berichten.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Inhalt

Architektur und Probleme der bestehenden Lösung


UrsprĂŒnglich sah die Architektur wie folgt aus: UI – eine separate Anwendung, der monolithische Teil wurde in Visual Basic 6 geschrieben, die Anwendung in .NET stellte eine Sammlung von verbundenen Services dar, die mit einer recht großen Datenbank arbeiteten.

Nachteile der frĂŒheren Lösung

Einzelner Ausfallpunkt
Wir hatten einen einzigen Single Point of Failure: Die .NET-Anwendung lief in einem einzigen Prozess. Wenn einer der Module ausfiel, fiel die gesamte Anwendung aus und musste neu gestartet werden. Da wir eine große Anzahl von Prozessen fĂŒr verschiedene Benutzer automatisieren, konnten aufgrund eines Ausfalls in einem dieser Prozesse eine Zeit lang alle nicht arbeiten. Und bei einem Programmfehler half auch die Redundanz nicht.

Warteschlange fĂŒr Anpassungen
Dieser Nachteil ist eher organisatorischer Natur. In unserer Anwendung gibt es viele Kunden, und alle möchten die Anpassungen so schnell wie möglich. FrĂŒher war es unmöglich, dies parallel zu tun, und alle Kunden mussten sich in eine Warteschlange einordnen. Dieser Prozess fĂŒhrte zu negativen RĂŒckmeldungen aus der Wirtschaft, denn sie mussten nachweisen, dass ihre Aufgabe von Bedeutung war. Das Entwicklungsteam verbrachte Zeit damit, diese Warteschlange zu organisieren. Das kostete viel Zeit und Energie, und letztlich konnte sich das Produkt nicht so schnell Ă€ndern, wie es gewĂŒnscht war.

Suboptimale Ressourcennutzung
Bei der Bereitstellung von Services in einem einzigen Prozess kopierten wir immer die Konfiguration vollstÀndig von Server zu Server. Wir wollten die am stÀrksten belasteten Services separat betreiben, um Ressourcen nicht unnötig zu verschwenden und eine flexiblere Verwaltung unseres Bereitstellungsschemas zu erhalten.

Schwierigkeiten bei der EinfĂŒhrung moderner Technologien
Ein Problem, das allen Entwicklern bekannt ist: Der Wunsch, moderne Technologien in das Projekt zu integrieren, ist da, aber die Möglichkeit fehlt. Bei einer großen monolithischen Lösung wird jedes Update der aktuellen Bibliothek, ganz zu schweigen vom Wechsel zu einer neuen, zu einer recht komplexen Aufgabe. Man muss dem Teamleiter lange nachweisen, dass dies mehr Vorteile bringen wird als Nerven kosten.

KomplexitĂ€t der Änderungsbereitstellung
Das war das grĂ¶ĂŸte Problem – wir hatten alle zwei Monate Releases.
Jedes Release wurde trotz Tests und der BemĂŒhungen der Entwickler zu einer echten Katastrophe fĂŒr die Bank. Das GeschĂ€ft verstand, dass zu Beginn der Woche ein Teil der FunktionalitĂ€t nicht funktionieren wĂŒrde. Und die Entwickler wussten, dass sie eine Woche voller ernsthafter VorfĂ€lle bevorstand.
Der Wunsch, die Situation zu Àndern, war bei allen vorhanden.

Erwartungen an Microservices


Bereitstellung der Komponenten nach Fertigstellung. Bereitstellung der Komponenten nach ihrer Fertigstellung dank der Dekomposition der Lösung und der Separation verschiedener Prozesse.

Kleine Produktteams. Das ist wichtig, weil es schwierig war, ein großes Team, das an einem alten Monolithen arbeitete, zu managen. Ein solches Team musste einem strengen Prozess folgen, wĂ€hrend mehr KreativitĂ€t und UnabhĂ€ngigkeit gewĂŒnscht war. Das konnten sich nur kleine Teams leisten.

Isolation von Diensten in separaten Prozessen. Idealerweise wollten wir die Dienste in Containern isolieren, aber viele Dienste, die auf dem .NET Framework geschrieben sind, laufen nur unter Windows. Es kommen zwar immer mehr Dienste auf .NET Core, aber davon gibt es bisher nur wenige.

FlexibilitĂ€t beim Deployment. Wir möchten die Dienste so kombinieren, wie es fĂŒr uns notwendig ist, und nicht so, wie es der Code vorschreibt.

Einsatz neuer Technologien. Das ist fĂŒr jeden Programmierer interessant.

Probleme beim Übergang


NatĂŒrlich, wenn es einfach wĂ€re, einen Monolithen in Mikrodienste zu zerlegen, mĂŒsste man nicht auf Konferenzen darĂŒber sprechen und Artikel schreiben. In diesem Prozess gibt es viele Fallstricke, ich werde die wichtigsten beschreiben, die uns behindert haben.

Das erste Problem typisch fĂŒr die meisten Monolithen: enge Kopplung der GeschĂ€ftslogik. Wenn wir einen Monolithen schreiben, möchten wir unsere Klassen wiederverwenden, um unnötigen Code zu vermeiden. Bei der Migration zu Mikrodiensten wird dies jedoch problematisch: Der gesamte Code ist ziemlich fest miteinander verbunden und es ist schwierig, die Dienste zu trennen.

Zum Zeitpunkt des Projektbeginns gab es im Repository mehr als 500 Projekte und ĂŒber 700.000 Zeilen Code. Das ist eine ziemlich große Lösung und das zweite Problem. Es war einfach nicht möglich, ihn in Mikrodienste zu zerlegen.

Das dritte Problem — fehlende notwendige Infrastruktur. TatsĂ€chlich haben wir manuell den Quellcode auf die Server kopiert.

Wie man vom Monolithen zu Microservices ĂŒbergeht


Abgrenzung von Mikrodiensten.

ZunĂ€chst haben wir fĂŒr uns selbst klargestellt, dass die Abgrenzung der Mikrodienste ein iterativer Prozess ist. Wir wurden immer gefordert, gleichzeitig die Entwicklung der GeschĂ€ftsanforderungen voranzutreiben. Wie wir das technisch umsetzen, ist unser Problem. Daher haben wir uns auf einen iterativen Prozess vorbereitet. Andernfalls wird es nicht funktionieren, wenn Sie eine große Anwendung haben, die von Anfang an nicht darauf vorbereitet ist, dass sie umgeschrieben wird.

Welche Methoden verwenden wir zur Abgrenzung von Mikrodiensten?

Erste Methode — bestehende Module als Dienste auslagern. In diesem Zusammenhang hatten wir GlĂŒck: Es gab bereits eingerichtete Dienste, die das WCF-Protokoll verwendeten. Diese waren in separate Assemblys aufgeteilt. Wir haben sie einzeln ĂŒbertragen und jeder Assembly ein kleines Startmodul hinzugefĂŒgt. Dieses wurde mit der großartigen Topshelf-Bibliothek geschrieben, die es ermöglicht, die Anwendung sowohl als Dienst als auch als Konsole auszufĂŒhren. Das ist bequem fĂŒr das Debugging, da keine zusĂ€tzlichen Projekte in der Lösung erforderlich sind.

Die Dienste waren durch die Business-Logik miteinander verbunden, da sie gemeinsame Assemblys verwendeten und mit einer gemeinsamen Datenbank arbeiteten. Sie waren schwer als reine Mikrodienste zu bezeichnen. Dennoch konnten wir diese Dienste separat in verschiedenen Prozessen ausfĂŒhren. Bereits das ermöglichte es, ihre gegenseitigen Auswirkungen zu verringern, wodurch die Probleme mit paralleler Entwicklung und einem einzelnen Ausfallpunkt verringert wurden.

Die Assembly mit dem Host ist nur eine Codezeile in der Program-Klasse. Die Arbeit mit Topshelf haben wir in einer Hilfsklasse verborgen.

namespace RBA.Services.Accounts.Host
{
   internal class Program
   {
      private static void Main(string[] args)
      {
        HostRunner.Run("RBA.Services.Accounts.Host");

       }
    }
}

Der zweite Weg zur Auslagerung von Mikrodiensten: sie zur Lösung neuer Aufgaben zu erstellen. Wenn dabei das Monolith nicht wĂ€chst, ist das bereits hervorragend, das bedeutet, dass wir in die richtige Richtung gehen. Zur Lösung neuer Aufgaben haben wir versucht, separate Dienste zu erstellen. Wenn es die Möglichkeit gab, dann haben wir „kanonischere“ Dienste geschaffen, die vollstĂ€ndig ihre Datenmodell verwalten und eine separate Datenbank haben.

Wie viele andere haben wir mit Authentifizierungs- und Autorisierungsdiensten begonnen. Diese eignen sich perfekt dafĂŒr. Sie sind unabhĂ€ngig, in der Regel haben sie ein isoliertes Datenmodell. Sie interagieren nicht direkt mit dem Monolithen, sondern dieser wendet sich lediglich an sie, um bestimmte Aufgaben zu lösen. Mit diesen Diensten kann der Übergang zu einer neuen Architektur begonnen werden, die Infrastruktur kann darauf getestet und bestimmte AnsĂ€tze in Bezug auf Netzwerklibraries ausprobiert werden usw. Unserer Organisation gibt es keine Teams, die es nicht geschafft hĂ€tten, einen Authentifizierungsdienst zu erstellen.

Der dritte Weg zur Auslagerung von Mikrodiensten, die wir verwenden, ist fĂŒr uns etwas speziell. Es handelt sich um die Auslagerung der GeschĂ€ftslogik aus der UI-Schicht. Unsere Hauptanwendung fĂŒr die BenutzeroberflĂ€che ist eine Desktop-Anwendung, die ebenso wie das Backend in C# geschrieben ist. Die Entwickler haben gelegentlich Fehler gemacht und Teile der Logik in die BenutzeroberflĂ€che ausgelagert, die eigentlich im Backend existieren und dort wiederverwendet werden sollten.

Wenn man sich ein echtes Beispiel aus demă‚łăƒŒăƒ‰ der UI-Teile ansieht, wird deutlich, dass ein großer Teil dieser Lösung echte GeschĂ€ftslogik enthĂ€lt, die in anderen Prozessen nĂŒtzlich ist, nicht nur zur Erstellung von UI-Formularen.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Echte Logik im UI gibt es nur in den letzten beiden Zeilen. Wir haben sie auf den Server ĂŒbertragen, um sie wiederverwendbar zu machen, wodurch wir die BenutzeroberflĂ€che verringern und eine korrekte Architektur erreichen.

Der vierte und wichtigste Weg zur Identifikation von Mikrodiensten, der es ermöglicht, das Monolith zu reduzieren, besteht in der Auslagerung bestehender Dienste mit Überarbeitung. Wenn wir bestehende Module unverĂ€ndert auslagern, sind die Ergebnisse nicht immer zur Zufriedenheit der Entwickler, und der GeschĂ€ftsprozess kann seit der Erstellung der FunktionalitĂ€t veraltet sein. Durch Refactoring können wir den neuen GeschĂ€ftsprozess unterstĂŒtzen, denn die Anforderungen des Unternehmens Ă€ndern sich stĂ€ndig. Wir können den Quellcode verbessern, bekannte Fehler beheben und ein qualitativ hochwertigeres Datenmodell erstellen. Es ergeben sich viele Vorteile.

Die Trennung von Diensten mit Überarbeitung ist untrennbar mit dem Konzept des begrenzten Kontexts verbunden. Dieses Konzept stammt aus der objektorientierten Softwareentwicklung. Es bedeutet einen Abschnitt des DomĂ€nenmodells, in dem alle Begriffe einer gemeinsamen Sprache eindeutig definiert sind. Nehmen wir das Beispiel des Kontexts von Versicherungen und Rechnungen. Wir haben eine monolithische Anwendung und mĂŒssen in den Versicherungen mit der Rechnung arbeiten. Wir erwarten, dass der Entwickler in einem anderen Build die vorhandene Klasse „Rechnung“ findet, von der Klasse „Versicherung“ darauf verweist und wir funktionierenden Code erhalten. Das DRY-Prinzip wird eingehalten, die Aufgabe wird durch die Nutzung des vorhandenen Codes schneller erledigt.

Es stellt sich heraus, dass die Kontexte von Rechnungen und Versicherungen miteinander verbunden sind. Wenn neue Anforderungen hinzukommen, wird diese Verbindung die Entwicklung erschweren und die ohnehin komplexe GeschĂ€ftslogik weiter verkomplizieren. Um dieses Problem zu lösen, mĂŒssen wir im Code die Grenzen zwischen den Kontexten finden und ihre Überschreitungen beseitigen. Zum Beispiel wird es fĂŒr den Versicherungsbereich möglicherweise ausreichen, eine 20-stellige Kontonummer der Zentralbank und das Eröffnungsdatum des Kontos zu verwenden.

Um diese begrenzten Kontexte voneinander zu trennen und den Prozess der Abtrennung von Mikrodiensten aus einer monolithischen Lösung zu starten, haben wir einen Ansatz verwendet, bei dem wir innerhalb der Anwendung externe APIs erstellen. Wenn wir wussten, dass ein Modul ein Mikrodienst werden sollte oder sich im Rahmen des Prozesses Ă€ndern muss, haben wir sofort Logikaufrufe, die einem anderen begrenzten Kontext gehören, ĂŒber externe Aufrufe gemacht, beispielsweise ĂŒber REST oder WCF.

Wir haben uns entschieden, den Code, der verteilte Transaktionen erfordert, nicht zu vermeiden. In unserem Fall stellte sich heraus, dass es einfach war, dieses Prinzip einzuhalten. Bislang sind keine Situationen aufgetreten, in denen wirklich strikte verteilte Transaktionen erforderlich waren – es reicht aus, eine finale Konsistenz zwischen den Modulen zu gewĂ€hrleisten.

Betrachten wir ein konkretes Beispiel. Wir haben das Konzept eines Orchestrators – eines Pipelines, die die EntitĂ€t „Antrag“ verarbeitet. Er erstellt nacheinander den Kunden, das Konto und die Bankkarte. Wenn der Kunde und das Konto erfolgreich erstellt werden, aber die Erstellung der Karte fehlschlĂ€gt, wechselt der Antrag nicht in den Status „erfolgreich“ und bleibt im Status „Karte nicht erstellt“. In Zukunft wird eine HintergrundaktivitĂ€t ihn aufgreifen und abschließen. Das System befindet sich einen Moment lang in einem Inkonsistenzzustand, was uns jedoch im Großen und Ganzen nicht stört.

Falls jedoch eine Situation eintreten sollte, in der ein konsistentes Speichern eines Teils der Daten erforderlich ist, werden wir wahrscheinlich den Service zusammenfassen, um dies in einem Prozess zu behandeln.

Betrachten wir das Beispiel der Abtrennung eines Mikrodienstes. Wie kann man ihn relativ sicher in die Produktion bringen? In diesem Beispiel haben wir einen separaten Teil des Systems – ein Modul zur Gehaltsabrechnung, einen Teil des Codes, den wir zu einem Mikrodienst machen möchten.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

ZunĂ€chst erstellen wir einen Mikrodienst, indem wir den Code umschreiben. Wir verbessern einige Aspekte, die uns nicht gefallen haben. Wir implementieren neue GeschĂ€ftsanforderungen des Kunden. Wir fĂŒgen einen API-Gateway in die Verbindung zwischen UI und Backend hinzu, der die Durchleitung von Aufrufen gewĂ€hrleistet.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Anschließend setzen wir diese Konfiguration in Betrieb, aber im Pilotstatus. Die meisten unserer Benutzer arbeiten weiterhin mit den alten GeschĂ€ftsprozessen. FĂŒr neue Benutzer entwickeln wir eine neue Version der monolithischen Anwendung, die diesen Prozess nicht mehr enthĂ€lt. Im Grunde funktioniert unsere Verbindung aus Monolith und Mikrodienst im Pilotbetrieb.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Bei einem erfolgreichen Pilotversuch stellen wir fest, dass die neue Konfiguration tatsÀchlich funktionsfÀhig ist. Wir können den alten Monolithen aus der Gleichung entfernen und die neue Konfiguration anstelle der alten Lösung belassen.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Zusammenfassend verwenden wir nahezu alle bestehenden Methoden zur Trennung des Quellcodes des Monolithen. All diese Methoden ermöglichen es uns, die GrĂ¶ĂŸe der Teile der Anwendung zu verringern und sie auf neue Bibliotheken zu migrieren, wodurch der Quellcode qualitativ hochwertiger wird.

Arbeiten mit der Datenbank


Die Datenbank lÀsst sich schlechter trennen als der Quellcode, da sie nicht nur das aktuelle Schema, sondern auch angehÀufte historische Daten enthÀlt.

Unsere Datenbank hatte, wie viele andere auch, einen weiteren wichtigen Nachteil – die enorme GrĂ¶ĂŸe. Diese Datenbank wurde gemĂ€ĂŸ einer komplizierten GeschĂ€ftslogik des Monolithen entworfen, und zwischen den Tabellen verschiedener begrenzter Kontexte haben sich Verbindungen angesammelt.

In unserem Fall kam zusĂ€tzlich zu all den Schwierigkeiten (große Datenbank, zahlreiche Verbindungen, manchmal unklare Grenzen zwischen den Tabellen) das Problem auf, das in vielen großen Projekten auftritt: die Verwendung des Shared-Database-Musters. Daten wurden aus Tabellen ĂŒber Views abgerufen, repliziert und in andere Systeme ĂŒbertragen, wo diese Replikation benötigt wurde. Dadurch konnten wir Tabellen nicht in ein separates Schema auslagern, da sie aktiv genutzt wurden.

Bei der Trennung hilft uns genau das Aufteilen in begrenzte Kontexte im Code. Dies gibt in der Regel ein ausreichend gutes VerstĂ€ndnis dafĂŒr, wie wir Daten auf der Ebene der Datenbank aufteilen. Wir verstehen, welche Tabellen zu einem begrenzten Kontext gehören und welche zu einem anderen.

Wir haben zwei globale Methoden zur Trennung der Datenbank angewendet: die Trennung bestehender Tabellen und die Trennung mit Überarbeitung.

Die Trennung bestehender Tabellen ist eine Methode, die gut angewendet werden kann, wenn die Datenstruktur von hoher QualitÀt ist und den GeschÀftsanforderungen entspricht. In diesem Fall können wir bestehende Tabellen in ein separates Schema auslagern.

Die Trennung mit Überarbeitung ist nötig, wenn sich das GeschĂ€ftsmodell stark geĂ€ndert hat und die Tabellen uns nicht mehr zufriedenstellen.

Die Trennung bestehender Tabellen. Wir mĂŒssen festlegen, was wir trennen wollen. Ohne dieses Wissen wird es nicht funktionieren, und hier hilft uns die Trennung begrenzter Kontexte im Code. In der Regel wird es klar, welche Tabellen in die Trennungsliste aufgenommen werden sollten, wenn man die Grenzen der Kontexte im Quellcode verstehen kann.

Stellen wir uns vor, wir haben eine Lösung, in der zwei Module des Monolithen mit einer gemeinsamen Datenbank interagieren. Wir mĂŒssen sicherstellen, dass nur ein Modul mit dem Bereich der abtrennbaren Tabellen interagiert, wĂ€hrend das andere ĂŒber die API mit ihm kommuniziert. Zu Beginn reicht es, wenn ĂŒber die API nur Schreibzugriff erfolgt. Dies ist eine notwendige Bedingung, um ĂŒber die UnabhĂ€ngigkeit der Mikrodienste sprechen zu können. Leseverbindungen können bestehen bleiben, solange dies kein großes Problem darstellt.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Im nĂ€chsten Schritt können wir den Codeabschnitt, der mit den abzutrennenden Tabellen arbeitet, mit oder ohne Überarbeitung in einen separaten Mikrodienst auslagern und in einem separaten Prozess oder Container ausfĂŒhren. Dies wird ein separater Dienst mit einer Verbindung zur Datenbank des Monolithen und zu den Tabellen sein, die nicht direkt zu ihm gehören. Der Monolith interagiert noch lesend mit dem abgetrennten Teil.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

SpĂ€ter werden wir diese Verbindung entfernen, das heißt, auch der Lesezugriff der monolithischen Anwendung auf die abgetrennten Tabellen wird auf die API umgestellt.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Als NÀchstes trennen wir aus der gemeinsamen Datenbank die Tabellen, mit denen nur der neue Mikrodienst arbeitet. Wir können die Tabellen in ein separates Schema oder sogar in eine separate physische Datenbank auslagern. Es bleibt eine Leseverbindung zwischen dem Mikrodienst und der Datenbank des Monolithen bestehen, aber das ist nicht schlimm, in dieser Konfiguration kann er lange Zeit so leben.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Der letzte Schritt besteht darin, alle Verbindungen vollstÀndig zu entfernen. In diesem Fall benötigen wir möglicherweise eine Datenmigration von der Hauptdatenbank. Manchmal wollen wir Daten oder Verzeichnisse, die aus externen Systemen repliziert werden, in mehreren Datenbanken wiederverwenden. Das begegnet uns gelegentlich.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Abteilung fĂŒr Aufbereitung. Diese Methode Ă€hnelt der ersten, geht jedoch umgekehrt. Wir erstellen sofort eine neue Datenbank und einen neuen Mikroservice, der ĂŒber die API mit dem Monolithen interagiert. Dabei bleibt jedoch eine Reihe von Datenbanktabellen bestehen, die wir in Zukunft entfernen möchten. Diese sind fĂŒr uns nicht mehr erforderlich, in dem neuen Modell haben wir sie ersetzt.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Damit dieses Schema funktioniert, benötigen wir höchstwahrscheinlich eine Übergangsphase.

Danach gibt es zwei mögliche AnsÀtze.

Erster: Wir duplizieren alle Daten in den neuen und alten Datenbanken. In diesem Fall entsteht DatenĂŒberfluss, es können Synchronisationsprobleme auftreten. Aber wir können zwei verschiedene Kunden bedienen. Einer verwendet die neue Version, der andere die alte.

Zweiter: Wir teilen die Daten nach einem bestimmten GeschĂ€ftskriterium auf. Zum Beispiel hatten wir in unserem System 5 Produkte, die in der alten Datenbank gespeichert sind. Das sechste Produkt fĂŒgen wir im Rahmen eines neuen GeschĂ€ftsprojekts in die neue Datenbank ein. Aber wir benötigen ein API Gateway, das diese Daten synchronisiert und dem Kunden zeigt, wo er was entnehmen kann.

Beide AnsÀtze sind praktikabel, wÀhlen Sie je nach Situation.

Nachdem wir sicher sind, dass alles funktioniert, kann der Teil des Monolithen, der mit alten Datenbankstrukturen arbeitet, abgeschaltet werden.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Der letzte Schritt wird das Entfernen der alten Datenstrukturen sein.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Zusammenfassend lĂ€sst sich sagen, dass wir Probleme mit der Datenbank haben: Es ist schwieriger, damit zu arbeiten als mit dem Quellcode, es ist komplizierter zu trennen, aber es ist möglich und notwendig. Wir haben einige Möglichkeiten gefunden, die es ermöglichen, dies ausreichend sicher zu tun; mit Daten einen Fehler zu machen, ist schließlich einfacher als mit Quellcode.

Arbeiten mit Quellcode


So sah das Schema des Quellcodes aus, als wir begannen, das monolithische Projekt zu analysieren.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Sie lĂ€sst sich grob in drei Schichten unterteilen. Dies ist die Schicht der ausfĂŒhrbaren Module, Plugins, Dienste und einzelner AktivitĂ€ten. TatsĂ€chlich waren dies die Einstiegspunkte innerhalb einer monolithischen Lösung. Alle waren fest mit der Schicht Common verbunden. Darin befand sich die GeschĂ€ftslogik, die von den Diensten gemeinsam genutzt wurde, sowie zahlreiche Verbindungen. Jeder Dienst und jedes Plugin verwendete je nach GrĂ¶ĂŸe und Gewissen der Entwickler 10 oder mehr Common-Bibliotheken.

Wir hatten GlĂŒck, denn wir hatten infrastrukturelle Bibliotheken, die separat verwendet werden konnten.

Manchmal kam es vor, dass einige Common-Objekte tatsÀchlich nicht zu dieser Schicht gehörten, sondern infrastrukturelle Bibliotheken waren. Dies wurde durch Umbenennung gelöst.

Am meisten sorgten die begrenzten Kontexte fĂŒr Besorgnis. Es kam vor, dass 3-4 Kontexte in einer einzigen Common-Bibliothek gemischt wurden und sich innerhalb derselben GeschĂ€ftsfunktionen gegenseitig verwendeten. Es war notwendig zu verstehen, wo dies getrennt werden konnte und nach welchen Grenzen, sowie was als NĂ€chstes mit der Zuordnung dieser Trennung zu den Quellcode-Bibliotheken zu tun war.

Wir haben einige Regeln fĂŒr den Code-Trennungsprozess formuliert.

Erstens: Wir wollten nicht lĂ€nger die GeschĂ€ftslogik zwischen Diensten, AktivitĂ€ten und Plugins teilen. Wir wollten die GeschĂ€ftslogik innerhalb der Mikrodienste unabhĂ€ngig gestalten. Andernfalls werden Mikrodienste idealerweise als Dienste wahrgenommen, die vollkommen unabhĂ€ngig existieren. Ich halte diesen Ansatz fĂŒr etwas verschwenderisch und es ist auch schwierig, ihn zu erreichen, da beispielsweise Dienste in C# in jedem Fall mit der Standardbibliothek verbunden sein werden. Unser System ist in C# geschrieben, andere Technologien mussten bisher nicht verwendet werden. Daher haben wir beschlossen, dass wir es uns leisten können, gemeinsame technische Bibliotheken zu verwenden. Das Wichtigste ist, dass keine Fragmente der GeschĂ€ftslogik enthalten sind. Wenn Sie eine praktische Wrapper ĂŒber ORM haben, die Sie verwenden, ist es sehr kostspielig, diese von Dienst zu Dienst zu kopieren.

Unser Team ist ein Fan von domÀnenspezifischem Design, deshalb passte die "Zwiebelarchitektur" hervorragend zu uns. Grundlage unserer Services ist nicht die Datenzugriffsschicht, sondern eine Sammlung mit domÀnenspezifischer Logik, die nur die GeschÀftslogik enthÀlt und keine AbhÀngigkeiten zur Infrastruktur aufweist. Auf diese Weise können wir die DomÀnen-Assembly unabhÀngig weiterentwickeln, um Probleme im Zusammenhang mit Frameworks zu lösen.

In diesem Stadium trafen wir auf unser erstes ernsthaftes Problem. Der Service sollte auf eine DomĂ€nen-Assembly verweisen, wir wollten die Logik unabhĂ€ngig gestalten, und dabei störte uns das DRY-Prinzip erheblich. Die Entwickler wollten zur Vermeidung von Duplikationen Klassen aus benachbarten Assemblies wiederverwenden, und infolgedessen begannen die DomĂ€nen erneut miteinander verbunden zu werden. Wir analysierten die Ergebnisse und kamen zu dem Schluss, dass das Problem möglicherweise auch im Bereich der Struktur des Quellcode-Repositorys liegt. Wir hatten ein großes Repository, in dem sich der gesamte Quellcode befand. Es war sehr schwierig, die Lösung fĂŒr das gesamte Projekt auf einem lokalen Rechner zusammenzustellen. Daher wurden fĂŒr Projektteile separate kleine Lösungen erstellt, und niemand verbot es, eine Common- oder DomĂ€nen-Assembly hinzuzufĂŒgen und wiederzuverwenden. Das einzige Werkzeug, das uns daran hinderte, war das Code-Review. Aber manchmal fiel auch dieses aus.

Dann begannen wir, auf ein Modell mit separaten Repositories umzusteigen. Die GeschĂ€ftslogik begann nicht mehr von Service zu Service zu lecken, die DomĂ€nen wurden tatsĂ€chlich unabhĂ€ngig. Begrenzte Kontexte werden klarer unterstĂŒtzt. Wie nutzen wir dabei infrastrukturelle Bibliotheken wieder? Wir haben sie in ein separates Repository ausgelagert, dann in Nuget-Pakete verpackt, die wir in Artifactory hinterlegt haben. Bei jeder Änderung erfolgt der Build und die Veröffentlichung automatisch.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Unsere Services begannen, auf interne infrastrukturelle Pakete genau wie auf externe zu verweisen. Externe Bibliotheken laden wir von Nuget herunter. FĂŒr die Arbeit mit Artifactory, wo wir diese Pakete ablegten, verwendeten wir zwei Paketmanager. In kleinen Repositories nutzen wir ebenfalls Nuget. In Repositories mit mehreren Services verwendeten wir Paket, das mehr Konsistenz bei den Versionen zwischen den Modulen gewĂ€hrleistet.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Durch die Arbeit am Quellcode, die leichte Änderung der Architektur und die Trennung der Repositories machen wir unsere Dienste unabhĂ€ngiger.

Infrastrukturprobleme


Die meisten Nachteile beim Übergang zu Mikrodiensten hĂ€ngen mit der Infrastruktur zusammen. Sie benötigen automatisierte Bereitstellungen und neue Bibliotheken fĂŒr die Infrastruktur.

Manuelle Installation in Umgebungen

UrsprĂŒnglich haben wir die Umgebungen manuell eingerichtet. Um diesen Prozess zu automatisieren, haben wir eine CI/CD-Pipeline erstellt. Wir wĂ€hlten den Prozess des Continuous Delivery, da Continuous Deployment fĂŒr uns aus GeschĂ€ftsprozesssicht derzeit nicht akzeptabel ist. Daher erfolgt die Bereitstellung per Knopfdruck, wĂ€hrend die Tests automatisch durchgefĂŒhrt werden.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Wir verwenden Atlassian, Bitbucket zur Speicherung des Quellcodes und Bamboo fĂŒr den Build-Prozess. Wir schreiben unsere Build-Skripte gerne in Cake, da es dasselbe wie C# ist. In Artifactory kommen bereits fertige Pakete an, und Ansible wird automatisch auf die Testserver ĂŒbertragen, die dann sofort getestet werden können.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Getrennte Protokollierung


Einer der Gedanken hinter dem Monolithen war das gemeinsame Logging. Wir mussten außerdem herausfinden, was wir mit den einzelnen Logs machen, die auf Festplatten liegen. Unsere Logs werden in Textdateien geschrieben. Wir entschieden uns, den Standard-ELK-Stack zu verwenden. Anstatt direkt ĂŒber Provider in ELK zu schreiben, haben wir uns entschieden, die Textlogs zu optimieren und die Trace_ID als Identifikator hinzuzufĂŒgen, zusammen mit dem Servicenamen, damit wir die Logs spĂ€ter analysieren können.

Der Übergang von Monolithen zu Mikroservices: Geschichte und Praxis

Mit Filebeat haben wir die Möglichkeit, unsere Logs zu sammeln von Server, sie anschließend zu transformieren, Queries in Kibana im UI zu erstellen und zu sehen, wie der Aufruf zwischen den Diensten verlief. Dabei hilft die Trace_ID erheblich.

Testen und Debuggen von miteinander verbundenen Services


UrsprĂŒnglich waren wir uns nicht ganz sicher, wie wir die entwickelten Dienste debuggen sollten. Bei einem Monolithen war es einfach, wir starteten ihn auf einem lokalen Computer. Zuerst versuchten wir es auch mit Mikrodiensten, aber manchmal benötigt man fĂŒr den vollstĂ€ndigen Start eines Mikrodienstes mehrere andere, was unpraktisch ist. Wir haben verstanden, dass wir zu einem Modell ĂŒbergehen mĂŒssen, bei dem wir nur den Dienst oder die Dienste, die wir debuggen möchten, auf dem lokalen Computer lassen. Die anderen Dienste werden von Servern genutzt, die mit der Prod-Konfiguration ĂŒbereinstimmen. Nach dem Debuggen werden beim Testen fĂŒr jede Aufgabe nur die geĂ€nderten Dienste auf den Testserver bereitgestellt. Auf diese Weise wird die Lösung in der Form getestet, in der sie in Zukunft in der Produktion erscheinen wird.

Es gibt Server, auf denen nur Produktionsversionen der Dienste installiert sind. Diese Server sind fĂŒr den Fall von VorfĂ€llen, zur ÜberprĂŒfung der Bereitstellung vor dem Deployment und fĂŒr interne Schulungen nötig.

Wir haben einen automatisierten Testprozess mit der beliebten Bibliothek Specflow eingefĂŒhrt. Die Tests werden automatisch mit NUnit direkt nach dem Deployment aus Ansible gestartet. Wenn die Abdeckung der Aufgabe vollstĂ€ndig automatisiert ist, ist kein manuelles Testing erforderlich. Obwohl manchmal doch zusĂ€tzliches manuelles Testing notwendig ist. Um zu bestimmen, welche Tests fĂŒr eine bestimmte Aufgabe durchgefĂŒhrt werden sollen, verwenden wir Tags in Jira.

ZusĂ€tzlich ist der Bedarf an Lasttests gestiegen, die frĂŒher nur in seltenen FĂ€llen durchgefĂŒhrt wurden. FĂŒr den Teststart verwenden wir JMeter, zur Speicherung nutzen wir InfluxDB und zur Erstellung von Grafiken des Prozesses verwenden wir Grafana.

Was haben wir erreicht?


Erstens haben wir das Konzept „Release“ abgeschafft. Die monströsen Releases alle zwei Monate sind verschwunden, bei denen dieses Ungeheuer in der Produktionsumgebung bereitgestellt wurde und vorĂŒbergehend GeschĂ€ftsprozesse störte. Jetzt stellen wir die Dienste im Durchschnitt alle 1,5 Tage bereit, indem wir sie gruppieren, da sie nach Genehmigung in die Betriebsphase ĂŒbergehen.

In unserem System gibt es keine fatalen AusfÀlle. Wenn wir einen Mikrodienst mit einem Fehler herausgeben, ist die damit verbundene FunktionalitÀt beeintrÀchtigt, wÀhrend die gesamte andere FunktionalitÀt nicht betroffen ist. Dies verbessert die Benutzererfahrung erheblich.

Wir können das Bereitstellungsschema verwalten. Es ist möglich, Gruppen von Diensten separat von der restlichen Lösung zu ĐČыЎДлen, falls dies erforderlich ist.

DarĂŒber hinaus haben wir das Problem mit langen Warteschlangen fĂŒr Anpassungen erheblich verringert. Wir haben separate Produktteams, die unabhĂ€ngig an Teilen der Dienste arbeiten. Hier passt der Scrum-Prozess bereits gut. Ein bestimmtes Team kann einen eigenen Product Owner haben, der Aufgaben zuweist.

Zusammenfassung

  • Mikroservices eignen sich gut fĂŒr die Dekomposition komplexer Systeme. Im Verlauf beginnen wir zu verstehen, was in unserem System vorhanden ist, welche begrenzten Kontexte bestehen und wo deren Grenzen verlaufen. Dies ermöglicht es, Anpassungen korrekt auf Module zu verteilen und Verwirrung im Code zu vermeiden.
  • Mikroservices bieten organisatorische Vorteile. Oft wird nur ĂŒber sie als Architektur gesprochen, aber jede Architektur dient der Lösung von GeschĂ€ftsbedĂŒrfnissen und nicht um ihrer selbst willen. Daher können wir sagen, dass Mikroservices gut geeignet sind, um Aufgaben in kleinen Teams zu lösen, insbesondere da Scrum derzeit sehr beliebt ist.
  • Die Aufteilung ist ein iterativer Prozess. Man kann eine Anwendung nicht einfach in Mikroservices aufteilen. Das entstandene Produkt wird wahrscheinlich nicht funktionsfĂ€hig sein. Bei der Trennung von Mikrodiensten ist es vorteilhaft, das bestehende Legacy neu zu schreiben, also in Code umzuwandeln, der uns gefĂ€llt und besser auf die GeschĂ€ftsanforderungen in Bezug auf FunktionalitĂ€t und Geschwindigkeit zugeschnitten ist.

    Eine kleine Warnung: Die Kosten fĂŒr den Übergang zu Mikroservices sind recht erheblich. Allein die Lösung der Infrastrukturproblematik hat viel Zeit in Anspruch genommen. Daher, wenn Sie eine kleine Anwendung haben, die keine spezifische Skalierung erfordert, und wenn es nicht viele Kunden gibt, die um die Aufmerksamkeit und Zeit Ihres Teams kĂ€mpfen, dann sind Mikroservices möglicherweise nicht das, was Sie heute benötigen. Es ist ziemlich teuer. Wenn Sie mit Mikroservices beginnen, werden die Anfangskosten höher sein, als wenn dasselbe Projekt mit der Entwicklung eines Monolithen begonnen wird.

    P.S. Eine emotionalere ErzĂ€hlung (als ob sie persönlich an Sie gerichtet ist) – zu dem Link.
    Hier ist die vollstÀndige Version des Vortrags.

Quelle: habr.com

60GB SSD 8Gb DDR4