Microservices aus einer monolithischen Webanwendung extrahieren
Da steht er nun, der seit Jahrzehnten gewachsene Monolith, und wird zunehmend schwieriger zu warten und erweitern. Ein undifferenzierter Rewrite wäre prohibitiv teuer und keine Garantie, dass es diesmal besser würde. Um den Monolithen dennoch zu bändigen, können wir ihn im großen Maßstab refactorn, d.h. verhaltenserhaltend aber strukturverbessernd überarbeiten. Beispielsweise indem wir Microservices extrahieren. Am Liebsten in kleinen Schritten.
In diesem Beitrag
Typischerweise ist die Unübersichtlichkeit des Monolithen ein großes Problem. Sein schierer Umfang, mangelhafte Dokumentation und die Abwesenheit seiner ursprünglichen Entwickler erschweren uns häufig das Verständnis seiner internen Komponenten und ihrer Abhängigkeiten. Vielleicht gibt es Indizien wie Namespaces - aber niemand kann garantieren, dass die Ordnungsprinzipien über alle Entwickler hinweg mit dem gleichen Elan befolgt wurden. Spätestens beim Einsatz schwarzer Magie hilft auch keine statische Code-Analyse mehr - und wir leben in ständiger Angst, dass eine Änderung unbeabsichtigte Nebeneffekte haben könnte.
Sobald wir den alten Code verstanden haben, jucken uns wahrscheinlich schon mehrere Refactorings in den Fingern. Hier schnell etwas gerade ziehen, jenes nebenher vereinfachen. Aber der Monolith ist komplex, und ehe wir uns versehen, grinst uns ein sehr, sehr zotteliges Yak an, das wir zumindest in dem Moment doch lieber nicht rasieren wollen.
Deshalb ist es wichtig, eine Strategie mit ganz vielen Reißleinen zu haben. Sobald wir bemerken, dass wir uns verrannt haben, müssen wir schnell wieder auf den letzten Commit fliehen, in Sicherheit durchatmen und dann noch einmal frisch anfangen können. Aber Moment mal - ist das nicht ein Standardproblem der Software-Entwicklung? Ja! Und es gibt auch eine Standardlösung: automatisierte Software-Tests.
Automatisierte Black-Box-Tests
Im besten Fall decken wir mit (z.B. in behat geschriebenen) Black-Box-Tests alle Funktionen des als Microservice zu extrahierenden Features ab. Wir klicken das Feature im Monolithen einmal komplett durch und halten unsere Beobachtungen als Test-Erwartungen fest. So können wir nicht nur später die Funktionalität absichern, sondern lernen auch nach und nach die Details des zu extrahierenden Features kennen.
Beispielsweise können wir HTTP-Status-Code und spezifische Seiteninhalte für die Feature-Startseite testen, für eine Login-Funktion, Erstellen-, Lesen-, Bearbeiten- und Löschen-Operationen, das Abschicken einer Suchmaske und die entsprechende Ergebnisliste. Bei diesem explorativen Ansatz sollten wir berücksichtigen, ob es verschiedene Benutzergruppen (z.B. Administratoren) und damit eingehende Rechte gibt und für jedes dieser Rechte den Zugriffsschutz testen.
Unit-Tests sind in diesem Kontext weniger relevant. Denn zu diesem Zeitpunkt wissen wir noch nicht genug über den Monolithen bzw. den zu extrahierenden Microservice, als dass wir zu jeder getesteten Unit sagen könnten: Dies ist ihr Kontext und daher ist sie ir/relevant für uns.
Tipp: Datenbank-Dumps mit Slimdump
Mit den Black-Box-Tests können wir schon einmal annähern, welche Tabellen der Microservice benötigt bzw. welche Datenzeilen wir für unsere Test-Fixtures benötigen. Später werden wir das noch weiter eingrenzen können, aber für den Moment wollen wir unser Wissen schon einmal festhalten. Dazu können wir beispielsweise in slimdump, einem Tool für hochgradig konfigurierbare MySQL-Dumps, eine Konfigurationsdatei anlegen, versionieren und mit unseren Kollegen teilen.
Neben der Tabellen- und Datenzeilenauswahl können wir beispielsweise konfigurieren, dass Benutzernamen und E-Mail-Adressen einer User-Tabelle nur anonymisiert gedumpt werden oder dass wir aus Performance-Gründen in jener Tabelle nur 10% der Datensätze und keine BLOBs dumpen wollen.
Grüne Wiese oder Klon?
Wie starten wir konkret mit dem Microservice? Bei Null auf der grünen Wiese oder als Klon des Monolithen, von dem wir dann alles wegschneiden, was nicht zum Microservice gehört? Für beide Varianten gibt es gute Gründe, aber für mich kocht die Entscheidung auf die Abwägung zwischen diesen drei wesentlichen Kriterien ein:
Menge der Einschränkungen: Auf der grünen Wiese starten wir mit minimalen Einschränkungen, im Klon nehmen wir erstmal dessen komplette technische Welt mit.
Nutzung alter Meta-Daten: Meiner Erfahrung nach ist insbesondere die Commit Message History oftmals die einzige Chance, eine Stelle mit besonders verrücktem Code zu verstehen. Insbesondere, wenn durch eine Ticket-Nummer der Kontext der letzten Code-Änderungen deutlich wird.
Latenz bis zur Live-Schaltung des Microservices: Starten wir unseren Microservice auf der grünen Wiese, haben wir eine sehr hohe Latenz, bis er live geschaltet werden kann: er muss erstmal von Grund auf entwickelt werden.
Wenn wir dagegen den Microservice als Klon des Monolithen erstellen und auf einem eigenen Host betreiben, kann der prinzipiell sofort live gehen. Wir richten einfach irgendeinen Proxy (z.B. Varnish oder mit Apache Rewrite-Rules) vor dem Original-Monolithen ein, der Requests an den Microservice an dessen Host und alle anderen Requests wie gehabt an den Monolithen-Host leitet. Vielleicht müssen wir uns noch um Details bzgl. Cookies, Sessions und URL-Rewriting kümmern - aber das ist offensichtlich immer noch erheblich weniger Latenz als die komplette Neuentwicklung auf der grünen Wiese.
In meiner Erfahrung überwiegen die Argumente für den Start mit dem Klon. Ich vermute, dieser Weg ist im Allgemeinen auch ökonomischer: denn so viel Spaß die grüne Wiese auch bereiten mag - sie scheint mir nur ein Euphemismus für einen teilweisen Rewrite zu sein, der Klon dagegen die Basis für ein Refactoring.
Wahrscheinlich gibt es auch Projekte mit besonderen Umständen, in denen die grüne Wiese die klar bessere Entscheidung ist - an solchen habe ich aber noch nicht gearbeitet. Daher behandelt der weitere Artikel den Klon-Weg.
Tipp: Lauffähigen Monolithen behalten
Falls wir den Monolithen bereits installiert haben, sollten wir den tunlichst bis zur Live-Schaltung des Microservices behalten. Denn wir werden im Laufe des Projekts anhand von Heuristiken Entscheidungen treffen, die sich erst Tage später als falsch herausstellen können. Vielleicht schneiden wir zu viel Code weg oder vereinfachen ihn zu stark und stellen erst im Nachhinein fest, dass uns ein Test fehlte, der genau das anzeigen würde.
In solchen Momenten ist es extrem praktisch, schnell in einer lauffähigen Version des Monolithen nachsehen zu können, wie eine bestimmte Verarbeitung konkret ablief.
Erkennung ungenutzter Ressourcen
Wenn wir unseren künftigen Microservice als Klon des Monolithen aufgesetzt haben - wie erkennen wir die ungenutzten Ressourcen, die wir wegschneiden müssen, damit nur der Microservice übrig bleibt? Allgemein gilt:
Ungenutzte Ressourcen = alle Ressourcen - genutzte Ressourcen
Alle Ressourcen einer Art stehen typischerweise bereits als Liste bereit (z.B. bei Dateien mit ls) und die genutzten Ressourcen ermitteln wir mithilfe unserer Black-Box-Tests. Dazu müssen wir nur eine passende Form des Coverage Loggings aktivieren, die Tests ausführen und dann die Coverage in ein aussagekräftiges Format bringen. Schließlich bilden wir deren Differenz und haben damit die ungenutzten Ressourcen.
Unsere Tests haben also eine Doppelrolle: Erstens sichern wir mit Ihnen die Korrektheit des Codes und zweitens verwenden wir ihre Coverage zur Ermittlung der ungenutzten Ressourcen.
Schauen wir uns mal im Detail an, wie die Ermittlung der genutzten Ressourcen bei verschiedene Ressourcen-Arten funktioniert.
Genutzte PHP-Dateien
Die meisten PHP-Frameworks verarbeiten Requests über einen einen FrontController. In einen solchen können wir uns leicht einhaken und z.B. xdebug die Code Coverage loggen und die Pfade der genutzten Dateien in einer Datei used-files.txt ausgeben lassen:
<?php
// Coverage sammeln lassen
xdebug_start_code_coverage();
// Original-FrontController
$app = new App();
$app->handle($_REQUEST);
// Pfade genutzter Dateien schreiben
$outFile = fopen('used-files.txt', 'a');
fwrite(
$outFile,
implode(PHP_EOL, array_keys(xdebug_get_code_coverage()))
);
fclose($outFile);
Wir könnten dafür beispielsweise auch sysdig einsetzen, ein Tool zur Überwachung und Analyse von system calls und Linux kernel events. Dessen großer Vorteil ist, dass wir damit nicht nur verwendete PHP-Dateien erfassen, sondern alle geöffneten Dateien - also z.B. auch Konfigurationsdateien und View-Templates. Größter Nachteil ist, dass der benötigte Umfang nur auf Linux verfügbar ist.
Genutzte Composer-Pakete
Wenn wir die used-files.txt aus dem vorigen Abschnitt auf die Dateien filtern, die in einem Unterverzeichnis von composers vendor-Verzeichnis liegen, können wir aus ihren Pfaden unmittelbar die genutzten Composer-Pakete ablesen.
Genutzte MySQL-Tabellen
Ein Coverage Logging ist leicht z.B. mit folgenden SQL-Statements zu aktivieren:
SET global general_log = 1;
SET global log_output = 'table';
Führen wir nun unsere Tests aus, werden die SQL-Queries in der Tabelle mysql.general_log geloggt (die wir deshalb vorher vermutlich TRUNCATEn möchten). Aus diesen können wir die Namen der genutzten Tabellen extrahieren. Das ist händisch allerdings schnell zu mühsam. Denn zum einen werden es typischerweise sehr viele Queries sein. Zum anderen müssten wir bei jedem Query genau hinschauen, an welchen Stellen Tabellennamen vorkommen können: kommagetrennt in der FROM-Klausel, in der JOIN-Klausel und in Sub-Queries.
Genutzte Frontend-Assets
Die Pfade zu genutzten Frontend-Assets wie Bildern, Schriftarten, Javascript- und CSS-Dateien finden wir als Hits in den Webserver-Access-Logs. Mit einem regulären Ausdruck (für das Apache Standard-Access-Log-Format z.B. #"(?:get|post) ([a-z0-9\_\-\.\/]*)#i) können wir sie herausfiltern. Doch dabei gibt es ein paar Probleme:
Assets-Download: Das Standard-behat-Setup verwendet Goutte als Webbrowser, der keine Bilder, kein Javascript und kein CSS runterlädt und ggf. ausführt. Das heißt, diese Hits fehlen im Logfile. Als Lösung können in behat aber auch andere Browser bzw. Browser-Treiber angebunden werden - mittels Selenium auch Firefox, Chrome oder sogar eine Armada von Browserstack.
Konkatenierungen: Jahrelang haben wir die Performance von Webanwendungen verbessert, indem wir Javascript und CSS in wenigen Dateien konkateniert haben, um so die Anzahl der TCP-Verbindungen an unseren Server zu senken. Dieser Ansatz hat sich mit HTTP/2 überholt, wird aber noch viel in Legacy-Monolithen zu finden sein. Dann ist eine Aussage wie “screen.css und app.js werden genutzt” nur wenig hilfreich.
Hier könnte es am Einfachsten sein, die Konkatenierung auszuschalten und die Quelldateien direkt im HTML einzubetten - wenn denn noch leicht herauszufinden ist, welche Datei auf welche Seite eingebunden gehört.
Falls nicht, könnte die Coverage auf Zeilenebene innerhalb der Dateien in Verbindung mit Sourcemaps ausgewertet werden. Die zeilenbasierte Coverage ist wiederum ein eigenes Problem.
Automatisierte Zeilen-Coverage in Javascript und CSS: Für Javascript gibt es eine Vielzahl von Coverage-Logging-Tools wie Istanbul, JSCover und Blanket.js (und bis dieser Artikel erscheint, gibt es vermutlich wieder drei neue). Diese können an JS-Testrunner wie Karma oder Jasmin angebunden werden. Zusammen mit den eigentlichen Javascript-Tests kommt hier möglicherweise einiges an Aufwand hinzu.
Bei CSS ist die Lage noch schwieriger, aber immerhin gerade in Bewegung: Beispielsweise hat Chrome seit der Version 59 ein eigenes Panel für die CSS Coverage. Die Ermittlung der Coverage funktioniert grob so, dass für alle Selektoren in den geladenen CSS-Dateien geprüft wird, ob sie im geladenen Dokument treffen. Falls ja, werden sie und die damit verbundenen Statements als benutzt markiert. Das ist zwar nicht perfekt, scheint aber eine brauchbare Heuristik zu sein. Leider sind weder Ein- noch Ausgabe für diesen Prozess leicht automatisierbar. Es bleibt zu hoffen, dass entsprechende Methoden mittelfristig in der puppeteer-API ergänzt werden.
Wer diesen Prozess unbedingt jetzt schon automatisieren will, kann sich z.B. mit dem Firefox Plugin “Dust-Me Selectors” notbehelfen. Dort können eine Sitemap eingegeben und die resultierende Coverage auf Datei- und Zeilenebene als JSON exportiert werden.
Automatisierung mit dem Zauberlehrling
Der Zauberlehrling ist ein Open Source-Tool, das bei der Extraktion von Microservices unterstützen soll. Insbesondere automatisiert es einige Schritte zur Erkennung ungenutzter Ressourcen:
bin/console show-unused-php-files --pathToInspect --pathToOutput --pathToBlacklist usedFiles
zeigt die ungenutzten PHP-Dateien an. Als Eingabe dient das weiter oben erstellte used-files.txt. Außerdem können der zu untersuchende Pfad auf dem Dateisystem, eine Ausgabe-Datei und eine Blacklist konfiguriert werden, um z.B. Temp-Verzeichnisse auszuschließen oder solche, von denen bekannt ist, dass sie nicht von den Black-Box-Tests abgedeckt werden (z.B. Pfade für Unit-Tests).
bin/console show-unused-composer-packages --vendorDir composerJson usedFiles
zeigt die vermeintlich ungenutzten Composer-Pakete. Die Qualität der Aussage korreliert unmittelbar mit dem Inhalt der usedFiles-Datei: Für den Zauberlehrling gilt ein Paket als genutzt, wenn mindestens eine Datei darin genutzt wird. Enthält die usedFiles-Datei beispielsweise nur mit xdebug ermittelte PHP-Dateien, werden Composer-Pakete, die ausschließlich aus View-Templates oder Konfiguration bestehen, niemals als genutzt erkannt und immer als vermeintlich ungenutzt ausgegeben werden. Mit sysdig erstellte usedFiles-Dateien sind daher hier vorteilhafter.
bin/console show-unused-mysql-tables
ermittelt mit einem SQL-Parser aus der MySQL-Log-Tabelle die genutzten Tabellen, bildet die Differenz zu allen und zeigt die vermeintlich ungenutzten Tabellen an.
bin/console show-unused-public-assets --regExpToFindFile --pathToOutput --pathToBlacklist pathToPublic pathToLogFile
zeigt die vermeintlich ungenutzten Frontend-Assets. Nimmt die Pfade des Public-Verzeichnisses und Access-Logs als Eingabe und kann mit dem regulären Ausdruck zur Erkennung der Dateipfade im Access-Log, der Ausgabe-Datei und einer Blacklist (wie bei den ungenutzten PHP-Dateien) konfiguriert werden.
Kurze Entwicklungs-Zyklen
Die Automatisierung durch den Zauberlehrling ermöglicht das Arbeiten in kurzen Entwicklungszyklen. Nach einem initialen Coverage-Durchlauf können diese wie folgt aussehen:
- Ungenutzte Ressource löschen
- Tests ausführen (der Geschwindigkeit halber ohne Coverage)
- ggf. gelöschte Ressource wiederherstellen, Code oder Tests fixen
- committen
- zurück zu 1. oder Abbruch.
Sind die erkannten ungenutzten Ressourcen gelöscht, sollte wieder ein Testlauf mit Code Coverage durchgeführt werden. Es lohnt sich auch ein Blick auf die Liste der genutzten Dateien - vielleicht sind hier noch niedrig hängende Früchte zu erkennen. Wenn beispielsweise in einem Composer-Paket nur noch wenige Dateien benötigt werden, können wir es vielleicht ganz überflüssig machen und als Abhängigkeit entfernen. Vielleicht finden wir auch im Kontext unseres Microservices überflüssige Abstraktionen, die wir jetzt vereinfachen können.
Und anschließend nicht vergessen: die Tests wieder ausführen :)
___
Dieser Artikel erschien zuerst im PHP Magazin 2.18. Er basiert auf einem ausführlicheren Vortrag auf der FrOSCon 2017, dessen Mitschnitt beim CCC und auf Youtube veröffentlicht ist.