Von Monolithen und Microservices
Als wir 2011 mit der Neuentwicklung unseres Online-Shops [1] starteten, wählten wir frühzeitig eine verteilte, vertikal geschnittene Architektur. Erfahrungen mit unserem Altsystem zeigten uns, dass eine monolithische Architektur den stetig wachsenden Anforderungen nicht gewachsen ist: Steigendes Datenvolumen, immer höhere Last und die Notwendigkeit, auch organisatorisch skalieren zu können, erforderten ein Umdenken. Welche Lösungen wir gewählt haben und warum, darum soll es in diesem Artikel gehen.
Monolithen
Mit dem Start einer Neuentwicklung wird in der Regel ein Entwicklungsteam zusammengestellt. Das Team klärt die Frage nach der Programmiersprache und einem geeigneten Framework. Vor allem für Server-Anwendungen (und um die soll es hier vor allem gehen), ist die Antwort dann also etwa Java + Spring Framework, Ruby on Rails oder ähnliches.
Die Entwicklung startet – und es entsteht eine Anwendung. Implizit und ohne es zu hinterfragen wird dabei also bereits eine monolithische Makro-Architektur gewählt, die im Laufe der Weiterentwicklung nach und nach ihre Schattenseiten zeigt:
- Es entsteht eine schwergewichtige Mikro-Architektur.
- Die Skalierbarkeit beschränkt sich ausschließlich auf Load Balancing.
- Insbesondere bei großen Anwendungen leidet die Wartbarkeit des Systems.
- Unterbrechungsfreie Deployments werden unter anderem durch die oft zustandsbehaftete Anwendung erschwert.
- Die Entwicklung mit mehreren Teams ist ineffizient und erfordert einen hohen Abstimmungsbedarf.
…um nur einige wenige zu nennen.
Selbstverständlich startet keine Neuentwicklung als „großer Monolith“. Anfangs ist die Anwendung schlank, leicht zu erweitern und gut zu verstehen – die Architektur adressiert die Probleme, die das Team zu dieser Zeit hat. Im Laufe der Monate entsteht mehr und mehr Code. Es werden Schichten definiert, Abstraktionen gefunden, Module, Services und Frameworks eingeführt, um die wachsende Komplexität in den Griff zu bekommen.
Bereits bei mittelgroßen Anwendungen (etwa eine Java-Anwendung mit mehr als 50.000 LOC) werden monolithische Architekturen langsam unangenehm. Das gilt vor allem für Anwendungen, die hohe Anforderungen an die Skalierbarkeit stellen. Aus der schlanken Neuentwicklung entwickelt sich das nächste Legacy-System, über das folgende Generationen von Entwicklern fluchen werden.
Teile und Herrsche
Die Frage ist natürlich, wie sich diese Entwicklung vermeiden lässt und wie wir uns die schönen Eigenschaften kleiner Anwendungen auch in großen Architekturen erhalten können. Kurz: wie wir eine nachhaltige Architektur herleiten können, die auch nach Jahren noch eine effiziente Entwicklung zulässt.
Rechner benötigen keine Klassen – wir benötigen sie.
In der Software-Entwicklung stehen uns viele Konzepte zur Strukturierung des Codes zur Verfügung: Funktionen, Methoden, Klassen, Komponenten, Libraries, Frameworks, usw. Alle diese Konzepte haben nur einen Zweck: Entwicklern das Verständnis ihrer Anwendung zu erleichtern. Nicht der Rechner benötigt Klassen – wir benötigen sie.
Da wir als Software-Entwickler all diese Konzepte so sehr verinnerlicht haben, fragt man sich natürlich, warum sie auf eine Anwendung beschränkt bleiben sollen. Was hindert uns daran, eine Anwendung in mehrere, möglichst lose gekoppelte Teile, also ein System von Anwendungen zu zerlegen?
Im Wesentlichen sind das drei Dinge:
- Conway’s Law: Die Entwicklung startet mit einem Team und anfangs ist die Anwendung ja auch noch recht übersichtlich: man „braucht“ zunächst nur eine Anwendung.
- Initiale Aufwände: Eine neue Anwendung aufzusetzen und in Betrieb zu nehmen, ist nur scheinbar einfach. Man benötigt ein VCS-Repository, Build Files etc., eine Build Pipeline, Deployment-Prozesse, Hardware oder VMs, man muss den Prozess ins Monitoring einhängen, das Logging konfigurieren, etc. Oft kommen auch noch organisatorische Hürden hinzu.
- Komplexität im Betrieb: Größere verteilte Systeme sind deutlich aufwändiger zu betreiben als ein kleiner Load Balanced Cluster.
Lässt man die Dinge also laufen, wird sich daher in der Regel kein System aus kleinen, schlanken Anwendungen ergeben, sondern eben ein Monolith. Die sich daraus ergebenden Probleme werden erst spürbar, wenn es zu spät ist. In der Regel wird allerdings frühzeitig klar, ob es besondere Anforderungen an die Skalierbarkeit gibt oder ob sich eher eine größere Code Base entwickeln wird. Ist das der Fall, kommt man um o. g. Hindernisse nicht herum, man muss sie lösen oder mit den Folgen dauerhaft leben.
Bei OTTO hat es sich beispielsweise ausgezahlt, dass bereits sehr früh im Projekt vier Cross-Functional Teams gegründet wurden und die Entwicklung nicht einem Team überlassen war. Es entstanden folgerichtig auch vier Anwendungen anstelle einer einzigen. Da wir bereits vorher ein großes System betreiben mussten, war auch die Komplexität im Betrieb ein lösbares Thema: Ob man 200 Instanzen eines Monolithen oder eine ähnliche Anzahl von kleineren Systemen betreibt, ist letztlich kein großer Unterschied.
Initiale Aufwände zum Aufsetzen eines neuen Servers kann man schließlich durch Standardisierung und Automatisierung in den Griff bekommen. Greift man nicht auf geeignete Cloud-Services zurück, muss man diese Automatisierung zwar erst einmal aufsetzen. Dafür profitiert man aber anschließend auch lange Zeit davon – ein Aufwand, der sich auszahlt.
Skalierbarkeit
Wie also können wir anstelle eines Monolithen ein System aus kleinen, schlanken Anwendungen konzipieren? Erinnern wir uns zunächst einmal daran, in welchen Dimensionen sich Anwendungen skalieren lassen.
Vertikale Dekomposition
Die vertikale Dekomposition ist ein so naheliegender Ansatz, dass man ihn gerne übersieht. Anstelle einer Anwendung, in der alle Features in einen Prozess gepackt werden, zerlegen wir die Anwendung von vornherein in mehrere, voneinander möglichst unabhängige Anwendungen.
Für den Systemschnitt bieten sich fachliche (Teil-)Domänen an. Wir haben für otto.de den Online-Shop beispielsweise in elf Vertikale zerlegt: Backoffice, Product, Order und so weiter.
Jede dieser „Vertikalen“ wird von einem Team entwickelt, hat ein eigenes Frontend sowie eine eigene Datenhaltung. Shared Code wird zwischen den Vertikalen nicht akzeptiert. In den wenigen Fällen, in denen wir uns dennoch Code teilen wollen, tun wir das über Open Source Projekte [2]. Vertikalen sind damit Self-Contained Systems (SCS), wie sie Stefan Tilkov in seinem Vortrag über „Sustainable Architecture“ [3] beschreibt.
Distributed Computing
Auch eine Vertikale kann noch eine verhältnismäßig große Anwendung sein, so dass man sie vielleicht weiter untergliedern möchte: Vorzugsweise, indem man eine Vertikale in mehrere aufteilt oder indem man sie über das Distributed Computing in mehrere Komponenten zerlegt, die in eigenen Prozessen laufen und beispielsweise über REST miteinander kommunizieren.
Die Anwendung wird dabei also nicht nur vertikal, sondern auch horizontal geschnitten. Requests werden in einer solchen Architektur von einem Service entgegengenommen, die Verarbeitung dann auf mehrere Systeme verteilt. Die Teil-Ergebnisse werden dann zu einer Response zusammengefasst und an den Aufrufer zurückgegeben. Die einzelnen Services teilen sich kein gemeinsames Datenschema, da dieses eine zu enge Kopplung zwischen den Anwendungen bedeuten würde: Schemaänderungen würden dazu führen, dass ein Service sich nicht mehr ohne weiteres unabhängig von anderen Services deployen ließe.
Sharding
Eine dritte Variante zum Skalieren eines Systems kommt unter anderem dann zum Einsatz, wenn sehr große Datenmengen verarbeitet werden, oder wenn man eine Anwendung dezentral betreiben möchte: Beispielsweise, wenn ein Service weltweit angeboten werden soll.
Relativ bekannt ist das Sharding von den „Sticky-Sessions“, die Entwickler so gerne dazu verwenden, bedenkenlos große Datenmengen in HTTP-Sessions ablegen zu können. Da wir Sharding selbst aber aktuell nicht einsetzen, gehe ich hier nicht weiter darauf ein.
Load Balancing
Sobald die Last nicht mehr von einem einzelnen Server verarbeitet werden kann, kommt das Load Balancing ins Spiel. Eine Anwendung wird dabei N-fach geklont und die Last über einen Load-Balancer verteilt.
Die verschiedenen Instanzen der Anwendung teilen sich dabei häufig eine gemeinsame Datenbank. Diese könnte daher zum Flaschenhals werden, so dass ggf. auch hier eine gut skalierbare Technik eingesetzt werden sollte: Das ist einer der Gründe dafür, warum sich in den letzten Jahren NoSQL-DBs etabliert haben, die sich häufig besser skalieren lassen, als relationale DBMS.
Maximale Skalierbarkeit
Die genannten Varianten können wir miteinander kombinieren und damit eine fast beliebige Skalierbarkeit des Systems erreichen.
Selbstverständlich ist das ganze kein Selbstzweck: wenn man die entsprechenden Anforderungen nicht hat, dürfte das Ergebnis ein wenig zu komplex sein. Zum Glück kann man sich nach und nach an die Zielarchitektur herantasten und muss nicht sofort den großen Wurf planen. Bei otto.de sind wir beispielsweise mit vier Vertikalen plus Load Balancing gestartet. Im Laufe der letzten drei Jahre entstanden weitere Vertikale. Einige davon wurden mittlerweile ihrerseits zu groß und unhandlich: hier führen wir aktuell Microservices ein und erweitern die Architektur einzelner Vertikalen um das Distributed Computing.
Microservices
In der letzten Zeit ist unter dem Begriff Microservice [4] ein Architekturstil populär geworden, in dem Systeme sehr feingranular nach fachlichen Domänen geschnitten werden.
Ein Microservice kann dabei sowohl eine kleine Vertikale, als auch ein Service in einer Distributed Computing Architektur sein. Der Unterschied liegt dabei also vor allem in der Größe der Anwendung: Ein Microservice sollte nur wenige Features aus einer fachlichen Domäne implementieren und von einem Entwickler in seiner Gesamtheit verstanden werden können.
Da ein Microservice so klein ist, laufen in der Regel mehrere davon auf einem Server. Application Server kann man dabei getrost ignorieren. Gute Erfahrungen haben wir mit „Fat JARs“ gemacht, die sich einfach mit java –jar <file> ausführen lassen und bei Bedarf einen embedded Jetty oder ähnliches starten. Um das Deployment und den parallelen Betrieb von Microservices auf einem Server zu vereinfachen, läuft jeder Service bei uns in einem Docker Container [5].
REST und Microservices sind eine gute Kombination und geeignet, um auch größere Systeme aufzubauen. Ein Microservice könnte etwa für eine REST-Ressource zuständig sein. Über Hypermedia wird damit auch das Problem der Service-Discovery (teilweise) gelöst. Mediatypes helfen, wenn es um die Versionierung von Schnittstellen und die Unabhängigkeit der Service-Deployments geht.
Insgesamt haben Microservices eine ganze Reihe von schönen Eigenschaften:
- Die Entwicklung in einer Microservice-Architektur macht Spaß: Alle paar Wochen oder Monate eine Neuentwicklung statt Software-Archäologie in übergroßen Altsystemen zu betreiben.
- Aufgrund ihrer geringen Größe benötigt man wenig Boiler-Plate Code und keine schwergewichtigen Frameworks.
- Sie lassen sich unabhängig voneinander deployen. Continuous Delivery bzw. Deployment lässt sich damit sehr viel einfacher realisieren.
- Die Architektur unterstützt die Arbeit in mehreren, unabhängigen Teams.
- Es ist pro Service möglich, die jeweils „beste“ Programmiersprache zu wählen. Man kann ohne großes Risiko auch mal eine neue Sprache, ein neues Framework oder ähnliches ausprobieren. Man sollte es dabei nur nicht übertreiben.
- Da sie klein sind, lassen sie sich auch jederzeit mit vertretbarem Aufwand durch eine Neuentwicklung ablösen.
- Die Skalierbarkeit des Systems ist deutlich besser als in monolithischen Architekturen, da jeder Service unabhängig von anderen Services skaliert werden kann.
- Microservices kommen der agilen Entwicklung entgegen. Ein neues Feature, von dessen Erfolg beim Kunden man noch nicht überzeugt ist, lässt sich nicht nur schnell entwickeln – es lässt sich auch schnell wieder wegwerfen.
Makro- und Mikro-Architektur
Der Begriff Software-Architektur bezieht sich traditionell eher auf die Architektur eines einzelnen Programmes. In einer Vertikalen- oder Microservice-Architektur ist dieser Ansatz jedoch kaum noch relevant, denn „Software-Architektur ist das, was sich schwer ändern lässt“. Was aber ist in einer Microservice-Architektur noch schwer zu ändern? Es sind nicht mehr die inneren Strukturen einer Anwendung. Schwer zu ändern sind eigentlich nur noch die Entscheidungen darüber, wie sich Microservices integrieren, welche Kommunikationsprotokolle zum Einsatz kommen, etc.
Wir unterscheiden daher zwischen der Mikro-Architektur einer Anwendung und der Makro-Architektur des Systems. Die Mikro-Architektur beschränkt sich dabei auf eine Vertikale oder einen Microservice und ist vollständig den Teams überlassen. Für die Makro-Architektur lohnt es sich jedoch, gemeinsame Richtlinien zu definieren, die das Zusammenspiel der Services festlegen. Bei uns sind das:
- Vertikale Dekomposition: Das System wird in mehrere Vertikale geschnitten, die in der Hoheit jeweils eines Teams liegen. Kommunikation zwischen Vertikalen gibt es nur im Hintergrund, nicht während der Ausführung eines User-Requests.
- RESTful Architecture: Die Kommunikation und Integration der verschiedenen Services erfolgt ausschließlich über REST.
- Shared Nothing Architecture: Es gibt keinen shared mutable state, über den sich Services austauschen müssen. Es gibt keine HTTP-Sessions, keine zentrale Datenbank und auch keinen geteilten Code. Mehrere Instanzen eines Service dürfen sich jedoch eine Datenbank teilen.
- Data Governance: Für jedes Datum gibt es genau ein führendes System, eine „Wahrheit“. Andere Systeme haben lesenden Zugriff über eine REST API zur Datenversorgung und halten sich die Daten redundant in der eigenen Datenbank.
Wie jede Architektur entwickelt sich auch unsere. Aktuell wird beispielsweise die Art und Weise, in der Microservices bei OTTO verwendet werden, standardisiert.
Integration
Bisher bin ich vor allem darauf eingegangen, nach welchen Mustern sich ein System zerlegen lässt. Trotzdem müssen wir natürlich aus Sicht der Anwender eine Software entwickeln, die wie „aus einem Guss“ wirkt. Die Frage ist also, auf welche Weise wir ein verteiltes System integrieren können, so dass der Kunde von unserer verteilten Architektur nichts bemerkt.
Hyperlinks
Die einfachste Art der Integration von Services in einem Web-Frontend ist die Verwendung von Hyperlinks. Services sind für verschiedene Seiten zuständig, die Navigation erfolgt über die Verlinkung zwischen den Seiten.
AJAX
Ähnlich naheliegend ist die Verwendung von AJAX, um Teile der Seite clientseitig per Javascript nachzuladen und in die Seite zu integrieren.
Für weniger wichtige oder ohnehin nicht im sichtbaren Bereich der Seite befindliche Teile der Seite, ist AJAX eine gute Methode, um eine Seite von verschiedenen Services gemeinsam generieren zu lassen. Die Abhängigkeiten zwischen den beteiligten Services sind gering: sie müssen sich beispielsweise über URLs und verwendete Mediatypes einig sein.
Asset Server
Die verschiedenen Seiten des Systems sollen natürlich auch optisch ein einheitliches Bild ergeben. Hinzu kommt, dass die beteiligten Services sich über die verwendeten Javascript-Bibliotheken (und deren Versionen) einig sein müssen. Statische „Assets“ wie CSS, Javascript und Images werden daher bei uns über einen zentralen Asset-Server ausgespielt.
Das Deployment und die Versionsverwaltung dieser gemeinsamen Ressourcen in einem vertikal geschnittenen System ist ein Thema für sich und bietet genug Stoff für einen separaten Artikel. Da das Deployment der Services unabhängig bleiben soll, Assets aber geteilt werden, gibt es hier einige Herausforderungen zu bewältigen.
Edge-Side Includes
Eine nicht ganz so bekannte Variante, um Fragmente einer Seite von verschiedenen Services zu integrieren, sind Server- oder auch Edge-Side Includes (SSI bzw. ESI). Ob es Varnish, Squid, Apache oder Nginx sind: die meisten Web-Server und Reverse Proxies unterstützen solche Includes. Die Technik ist einfach: Ein Service fügt in eine Response ein Include-Statement mit einer URL ein, die von dem Web-Server bzw. Reverse Proxy aufgelöst wird. Der Proxy folgt der URL, erhält eine weitere Response, und fügt deren Body anstelle des Includes in die Seite ein. In unserem Shop wird beispielweise auf jeder Seite eine Navigation aus dem Search & Navigation (SAN) Service eingebunden:
<html> ... <esi:include src=“/san/...“ /> ... </html>
Der Reverse Proxy (bei uns Varnish) parst die Seite und löst die URL der enthaltenen Includes auf. SAN steuert dann ein HTML-Fragment bei:
<div class="navi"> ... </div>
Der Varnish Proxy ersetzt den Include durch das Fragment und liefert die Seite an den Aufrufer aus:
<html> ... <div class="navi"> ... </div> ... </html>
Auf diese Weise lassen sich – für den Aufrufer vollkommen transparent – Seiten aus den Fragmenten unterschiedlicher Services zusammensetzen.
Datenreplikation
Mit den genannten Techniken zur Frontend-Integration kommt man bereits recht weit. Damit die Services aber ihre Aufgabe erfüllen können, muss man noch ein weiteres Problem lösen: Services benötigen gemeinsame Daten, sollen sich gleichzeitig aber keine Datenbank teilen. Im Falle unseres Online-Shops werden beispielsweise viele Services irgendetwas mit Produktdaten zu tun haben.
Eine Lösung dafür ist die Replikation von Daten. Alle Services, die beispielsweise Produktdaten benötigen, fragen regelmäßig den Feed der führenden Vertikalen (Product) an, um Datenänderungen zu erhalten. Wir verwenden also keine Message Queues, um Änderungen per Push zu verteilen. Stattdessen Pollen die Services einen Atom Feed [6], um Datenänderungen zu erhalten, wann immer sie welche benötigen oder verarbeiten können. Mit temporären Inkonsistenzen müssen wir dabei umgehen können – das lässt sich in einem verteilten System aber ohnehin nur auf Kosten der Verfügbarkeit der Services vermeiden.
Keine Remote Service Calls
Theoretisch könnten wir in einigen Fällen die Replikation von Daten dadurch vermeiden, dass wir Services bei Bedarf synchron (im Sinne von „während eines User-Requests“) auf andere Vertikalen zugreifen lassen. Ein Warenkorb könnte also auf die redundante Speicherung von Produktdaten verzichten und stattdessen die Product-Vertikale anfragen, wenn der Warenkorb dargestellt wird.
Wir verzichten darauf aus verschiedenen Gründen:
- Die Testbarkeit leidet, wenn man für wesentliche Features auf ein anderes System angewiesen ist.
- Ein langsamer Service kann über Schneeball-Effekte die Verfügbarkeit des gesamten Shops in Mitleidenschaft ziehen.
- Die Skalierbarkeit des Systems wird eingeschränkt.
- Unabhängige Deployments der Services werden erschwert.
Wir sind mit der Trennung der Vertikalen bisher sehr gut gefahren. Mit Sicherheit hilft eine etwas striktere Trennung zumindest in der Anfangsphase dabei, zu lernen, wie sich Services unabhängig voneinander entwickeln, testen und live stellen lassen.
Fazit
Wir haben mittlerweile über drei Jahre Erfahrungen mit einer nach den vorgestellten Prinzipien konzipierten Anwendung gesammelt und insgesamt sehr gute Erfahrungen damit gemacht. Das größte Kompliment ist natürlich, wenn man kopiert wird [7] oder andere die umgesetzten Konzepte interessant finden [8].
Wenn ich heute auf unsere Entwicklung zurückblicke, würde ich vor allem eine Sache anders machen: Ich würde noch frühzeitiger und noch feingranularer schneiden. Die nahe Zukunft bei otto.de gehört offensichtlich den Microservices in einer Vertikalen-Architektur.
Vielleicht auch interessant...
In seinem Artikel "Continuous Deployment von Microservices" beschreibt Guido Steinacker, wie Sie mit Continuous Deployment die Komplexität von Microservices und die kontinuierliche Verbesserung der Entwicklungsprozesse in den Griff bekommen.
[1] otto.de
[2] github.com: Open Source Projekte
[3] speakerdeck.com: Sustainable Architecture
[4] martinfowler.com: Microservice
[5] Docker
[6] tools.ietf.org: Atom Feed
[7] inoio.de/blog
[8] thoughtworks.com