Microservices-Ansatz für Data-Science-Projekte
Microservices als moderne Software-Architektur polarisieren die IT-Welt. Nach großer Begeisterung und entsprechendem Hype wurden viele Unternehmen auf den Boden der Tatsachen zurückgeholt und mit technischen Schwierigkeiten und Problemen konfrontiert. Einerseits bieten Microservices-Ansätze durch ihren modularen Aufbau langfristig eine bessere Skalierbarkeit von Anwendungen. Anderseits haben sie für Unternehmen oft eine hohe Einstiegskomplexität. Weitere Eigenschaften wie Wartbarkeit wurden im Vergleich zu monolitischen Systemen honoriert. In der Realität stehen viele Unternehmen allerdings vor der Herausforderung, bei wachsenden Service-Systemen die Wartbarkeit tatsächlich sicherzustellen. Bei der Suche nach dem richtigen Ansatz spielen unterschiedliche Faktoren eine Rolle: von den technischen Anforderungen bis hin zu menschlichen und organisatorischen Aspekten wie Team-Strukturen, Management und interne Kommunikation.
Dieser Artikel wird die Entscheidungswege, die beim Einsatz von Microservices-Architektur notwendig sind, aus der Sicht von Data Scientists diskutieren. Bei der Umsetzung einer Microservice-Architektur auf die Data-Science-Projekte war unserem Team wichtig, die Vorteile des Ansatzes zu unterstreichen und die Nachteile zu relativieren.
Anforderungen bei den Data-Science-Projekten
Im Vordergrund der Tätigkeit als Data Scientist steht in unserem Team die Entwicklung der Pipelines, welche die Daten unterschiedlicher Quellen und Formate verarbeiten und analysieren. Dabei liegt der Fokus unter anderem auf den Aufgaben, relevante Informationen automatisiert zu extrahieren, versteckte Verbindungen und Abhängigkeiten zu identifizieren sowie Anomalien zu erkennen. Die Anzahl der Anwendungsmöglichkeiten ist enorm.
Wie passt nun Data Science mit dem Microservices-Ansatz zusammen?
Neben den allgemeinen Vorteilen wie Skalierung der Entwicklung, Performanz und Belastbarkeit bieten sich Microservices ausgezeichnet dafür an, flexible Analyse-Pipelines zu erstellen und verschiedene Algorithmen gegeneinander zu testen. Darüber hinaus kann ein Entwickler-Team fertige Services wie einen Bausatz verwenden. Dies führt in Unternehmen zu einem Ressourcen-Verbrauch, der sich dicht an dessen Bedarf orientiert. Ein letzter, aber sehr zentraler Punkt ist die einfache Erweiterbarkeit. Neu entwickelte Analyse-Methoden werden als Services schnell ohne große Anpassungen in ein Deployment übernommen.
Bevor die Entscheidung für die Microservices-Architektur gefallen ist, haben wir folgende Anforderungen identifiziert:
- Möglichkeit, schnell aus den existierenden Services eine Pipeline zu bauen und die explorative Herangehensweise an die Datenanalyse zu ermöglichen,
- Möglichkeit, schnell die Entwicklungsumgebung aufzusetzen und damit auf die Entwicklung von Services zu konzentrieren,
- Möglichkeit, schnell die Prototypen und Produktionslösungen fertigzustellen,
- Möglichkeit, die bereits entwickelten Komponenten ohne zusätzlichen Aufwand in anderen Projekten/Anwendungsfällen einzeln oder in Kombination zu verwenden,
- Erstellung von Pipelines ohne zusätzliche Anpassungen und
- Sprachunabhängigkeit bei der Entwicklung der Services (in Python, Java oder anderen Programmiersprachen).
Wie können Microservices die genannten Anforderungen erfüllen?
Damit die Vorteile des Microservices-Ansatzes zum Tragen kommen und nicht die Nachteile überwiegen, sind solide Protokolle für die Kommunikation zwischen Services und klar definierte Datenstandards notwendig. Diese sollen möglichst generische Anknüpfungspunkte zwischen Services bieten – so wird gewährleistet, dass eine bestehende Plattform effizient weiterentwickelt und gewartet werden kann.
Falls die Entwicklung einer Datenverarbeitungspipeline im Vordergrund steht, die aus mehreren Services zusammengebaut wird, muss die Kommunikation innerhalb der Pipeline reibungslos ablaufen. Zusätzlich muss berücksichtigt werden, dass Services von unterschiedlichen Pipelines angesprochen werden können. Und natürlich, dass einzelne Services in unterschiedlichen Programmiersprachen geschrieben werden können. Es ist nicht ungewöhnlich, dass unterschiedliche Data Scientists oder sogar Teams an der Entwicklung der Services arbeiten.
Betrachten wir das Beispiel der Tonalitätsanalyse von Twitter-Daten, und zwar in Bezug auf den positiven, neutralen oder negativen Tenor der Themen. Ein Team arbeitet für die Entwicklung des Services zur Themenerkennung und das andere Team an einem neuronalen Netzwerk zur Erkennung der Tonalität. Dabei kann es vorkommen, dass sich ein Team für die Programmiersprache Java und das andere für Python entscheidet.
Deshalb stand für uns als erste Aufgabe bei der Umsetzung die Definition eines Kommunikationsprotokolls:
- Es soll ein Kommunikationsprotokoll stehen, welches ein sehr flexibles Routing zwischen Services erlaubt und von jeder Programmiersprache unterstützt wird.
Bei der Entwicklung hat jeder seinen Stil und seine Logik, wie ein Programm aussehen soll. Dies führt erfahrungsgemäß dazu, dass beim Bau einer Datenverarbeitungspipeline viel Zeit darauf verwendet wird, die Services zusammen zu bringen. Demnach wird an den Schnittstellen der Services Kapazität verschwendet. Aus diesem Grund beschloss unser Team, ein Grundgerüst für die Entwicklung der Services zu entwickeln:
- Es soll eine Service-Basisklasse mit diversen Schnittstellen vorhanden sein, auf Basis derer man sehr leicht eigene Services implementieren kann.
Ein weiterer wichtiger Aspekt ist Transparenz darüber, was in den Pipelines und im System passiert. Unser Team aus Data Scientists, Software-Architekten und System-Administratoren hat gemeinsam definiert, wie die Informationen zu den Services und Pipelines gespeichert werden sollen. Dabei war uns wichtig, eine flexible Informationsgestaltung zu gewährleisten. Ebenso war wichtig, ein Grundgerüst zur Verfügung zu haben, um Logging und Monitoring zu ermöglichen.
- Der Transport der Nachricht, Logging- und Konfigurations-Schnittstellen und ein Message-Tracing sollen in der Basisklasse enthalten sein. Beim Message-Protokoll sollen sämtliche Informationen zur Pipeline und zum Daten-Payload in der Nachricht selbst enthalten sein.
In der Microservice-Architektur sollen die Services miteinander kommunizieren und Informationen austauschen. Um dies zu ermöglichen, haben wir uns für einen schlanken und flexiblen API-Server, geschrieben in Node.js, entschieden. Er soll uns erlauben, RESTful APIs mittels Konfigurationsdateien zu beschreiben und bereitzustellen. Zusätzlich soll er über Plugins problemlos erweiterbar sein.
- Wichtig für uns war, dass die Definition der Endpunkte hierbei über die Konfigurationsdatei des Servers stattfindet und nicht durch Code.
Um dies automatisiert zu übermitteln, soll ein API-Gateway hinzugefügt werden, welches die Informationen über eine REST-API bereitstellt.
Service-Struktur
Wie kann die einheitliche Service-Struktur aussehen, die gleichzeitig genug Flexibilität in der Gestaltung bietet?
Dafür wurde eine Service-Basisklasse mit mehreren Schnittstellen entwickelt, um folgende technische Aspekte abzudecken:
- Kommunikationsprotokoll zum flexiblen Routing zwischen Services.
- Message Transport und Message Tracing.
- Interface für Konfiguration.
Das bedeutet, dass jeder Entwickler auf dieser Grundlage eigene Services entwickeln und zur Verfügung stellen kann. Gleichzeitig wird eine Flexibilität in der internen Gestaltung des jeweiligen Service und auch in der Gestaltung der Messages, Logs und weiteren Konfigurationen gewährleistet. Folgender Code zeigt die Struktur der Service-Basisklasse [1].
import base_service
class MyService(base_service.BaseService):
def __init__(self, args):
super().__init__(args)
def on_message(self, msg, msg_id):
new_msg = do_stuff_with(msg)
self.dispatch(new_msg)
args = json_loads(sys.argv[1])
ms = MyService(args)
ms.listen()
Der msg
-Parameter ist die Message Payload, die zu der Pipeline hinzugefügt wird. Die msg_id
ist die eindeutige Kennung für die spezifische Message. Diese ID wird für Protokollierungszwecke (Thema Logging) verwendet. Jeder Service braucht diese drei Parameter: name, ip und port. Zusammen mit anderen zusätzlichen Parametern stehen sie via the self.params
Dictionary zur Verfügung.
Diese Elemente sind zum Schreiben eines Service notwendig. Innerhalb der Klasse MyService kann die Logik der Implementierung nach der Nutzervorstellung beliebig gestaltet werden.
Damit der Service verwendet werden kann, wird listen
aufgerufen. Somit wird der Service unter der definierten Adresse gelauscht und nach dem Empfang der Message wird Message Payload zur on_
message
-Funktion hinzugefügt.
Falls weitere Ausbaumöglichkeiten für den Message-Transport benötigt werden, wird eine on_message_ext
-Funktion verwendet. Diese erhält das ganze Message-Objekt und nicht nur die Payload. Dies ist hilfreich, falls z. B. zusätzliche Pipeline-Informationen verfolgt werden sollen. Damit die Payload Message zum nächsten Service in der Pipeline weitergeleitet werden kann, wird die dispatch
-Funktion verwendet. Zur Verarbeitung der eingehenden Message wird die do_stuff_
with
-Funktion verwendet. Hier kann der Nutzer selbst entscheiden und flexibel gestalten, wie die Message verarbeitet werden soll.
Datentransport
Sind die Services zu einer Pipeline gebaut und gestartet, werden unterschiedliche Informationen durchgereicht. Nehmen wir folgendes Beispiel: Wir möchten Twitter-Daten verarbeiten und analysieren. Die Analyse soll die Tonalität (positiv, negativ, neutral) und das Thema jedes einzelnen Tweets erkennen. Um dies zu ermöglichen, können wir folgende Services zu einer Pipeline bauen:
- Pre-processing-Service,
- Sentiment-Service und
- Topic-Modelling-Service.
Mit jedem weiteren Schritt in der Pipeline werden Informationen angereichert und weitergegeben. Am Ende haben wir ein Dokument, welches die Ergebnisse der Analyse enthält: Original-Tweet, Tweet nach dem Pre-processing (hier wurden alle Wörter kleingeschrieben und Zahlen entfernt), Tonalität des Tweets und die dazugehörige Wahrscheinlichkeit sowie die Themen des Tweets.
Zusätzlich können wir einer Pipeline weitere Parameter mitteilen, z. B. Sprache "English". Dies führt dazu, dass unsere Services sprach-spezifische Elemente in der Analyse verwenden. Das bedeutet, dass der Sentiment-Service zur Erkennung der Tonalität den englischen Trainingsdatensatz benutzt. Gleiches gilt für den Topic-Modelling-Service.
Wenn man die sprachspezifischen Analysen durchführen möchte, sich aber nicht auf eine Sprache einschränken will, kann dem Pre-Processing-Service auch ein Service zur Language-Identifizierung vorgeschaltet und somit die notwendigen Informationen über die Sprache jedes Tweets zu den Services weitergeben werden.
Deployment
Wie können wir nun in einer Microservices-Architektur aus den vorhandenen Services eine Pipeline erstellen?
Beim Message-Protokoll sind sämtliche Informationen zu Pipeline und Daten-Payload in der Nachricht selbst enthalten. Sobald die Pipeline erzeugt und die Nachricht an den ersten Service in der Pipeline geschickt wird, wird sie aktiviert und die Datenverarbeitung gestartet.
Um diesen Prozess zu vereinfachen, haben wir ein API-Gateway hinzugefügt, welches diese und andere Funktionen mittels einer REST-API bereitstellt. Entscheidend für den API-Server [2] ist die Erweiterbarkeit über Plugins. Ein solches Plugin erlaubt uns, Services zu starten und zu stoppen, Pipelines zu definieren und Daten in diese Pipelines zu schicken. Wir erhalten dadurch Informationen über den Stand unseres Systems und können Message-Traces abfragen. Zusätzlich gibt es uns die Möglichkeit, unsere Infrastruktur in Form eines JSON-Dokuments zu persistieren und wiederherzustellen.
Folgendes Beispiel illustriert die Nutzung einer REST-API, um mit den Services zu interagieren:
curl -XPOST localhost/start_service -H 'Content-Type: application/json' -d '{"path": "/path/to/myservice.py", "params": {"name": "myservice_instance", "ip": "127.0.0.1", "port": 1337}}'
Systemüberblick beibehalten: Logging & Monitoring
Bei der wachsenden Anzahl an Services und laufenden Pipelines sollte das Thema Logging und Monitoring nicht außer Acht gelassen werden. Es ist enorm wichtig, diese Themen anzugehen und entsprechende Lösungen zu definieren, bevor man mit der wachsenden Anzahl der Services und Pipelines konfrontiert wird.
Wir unterscheiden zwischen zwei Log-Typen: Service logs und System logs.
Service logs beinhalten sämtliche Informationen über Service-Aktivität, z. B. wann eine Message beim Service angekommen ist. Diese Logs haben folgende Parameter:
- Service - Name des Service.
- Message - Messages, die der Service verschickt.
- Level - log level.
System logs haben keine festgelegte Struktur und sind von der verwendeten Infrastruktur abhängig. Ein Beispiel für ein System log wäre eine Exception, wenn der Service abgestürzt ist.
Für die Logging-Funktionalität haben wir ein Basic-Logger implementiert [3]:
'{
"name": "myservice_instance",
"ip": "127.0.0.1",
"port": 1337,
"logging": {
"logpath": "/path/to/file.log",
"level": "info"
}
}
Basic-Logger bietet die Möglichkeit, unterschiedliche Log-Stufen zu haben: debug, info, warn, error und critical [3]:
self.logger.info("Hello World!")
self.logger.error('something went wrong', message_id='c534efc0-5065-40ba-8ec8-1186e85a14ef', additional={'stuff': 42})
self.logger.info({"key": "value"}, description="received message") # Use 'description' to separate a parsable JSON-message from its context
Die Log-Ergebnisse selbst sehen dann wie folgt aus [3]:
{"level": "INFO", "msg": "Hello World!", "time": "2018-07-05T10:28:19+0000", "service": "myservice"}
{"level": "ERROR", "msg": "something went wrong", "time": "2018-07-05T11:55:19+0000", "message_id": "c534efc0-5065-40ba-8ec8-1186e85a14ef", "additional": {"stuff": 42}, "service": "myservice"}
{"level": "INFO", "msg": {"key": "value"}, "time": "2019-06-04T13:34:14+0000", "service": "testservice", "description": "received message"}
Die Logs werden auch über das API-Gateway überwacht. Dazu wird der Endpunkt log_observer
verwendet.
Bei Peripherie-Diensten wie Logging und Monitoring haben wir uns für einen Injection-Mechanismus entschieden, welcher es erlaubt, mittels Konfiguration andere Implementierungen zu verwenden. Unsere Überlegung dabei war, dass es auch Aspekte geben wird, die wir nicht mit der bestehenden Infrastruktur abdecken können oder falls wir in einem oder anderem Projekt auf eine von uns nicht direkt unterstütze Infrastruktur zugreifen müssen.
Beim Thema Monitoring waren wir uns weiterhin in der Herangehensweise treu und haben als erstes eine einfache Monitoring-Lösung in der Basisklasse abgebildet. Diese Lösung speichert die Message Traces in ein File.
Die Monitoring-Messages von der ganzen Pipeline werden im monitoring.log gespeichert [3]:
{
"name": "myservice_instance",
"ip": "127.0.0.1",
"port": 1337,
"logging": {
"logpath": "/path/to/file.log",
"level": "info"
},
"monitoring": {
"filename": "/path/to/monitoring/file.log"
}
}
Das Basic-Monitoring deckt unterschiedliche Situationen ab: Wenn die Message automatisch erstellt, erhalten und versendet wurde oder das Endziel erreicht hat.
Die Monitoring-Message kann wie folgt aussehen [3]:
{"event": "message_dispatched", "status": "in_transit", "time": 1513596460.0961697, "args": {"service_name": "service1", "message_id": "dfa156e3-a8f6-4968-a255-ebd44e41d846", "destination": "127.0.0.1:10000"}}
An dieser Stelle war uns aber auch wichtig, die Möglichkeit zu haben, benutzerdefinierte Metriken zu erstellen. Deshalb gibt es in der Basis-Monitoring-Klasse die Funktion custom_metric
. Diese Funktion erwartet den Service Name, Metrik Name und Metrik Dictionary mit den Keywords als Argumenten [3].
custom = {"service_name": "serv1", "metric_name": "some-metric", "metric_dictionary":
{"metric": "entry", "metric2": "another-entry"}}
monitor.custom_metric(**custom)
Als Gegenstück zur Basic-Monitoring-Klasse gibt es die Basic-Reporting-Klasse. Diese Klasse beinhaltet Funktionen, um Monitoring-Informationen für spezifische Events zu erhalten, z. B. auch die komplette Monitoring-History oder nur den letzten bekannten Status.
Zusammenfassung
Beim Design der Microservice-Architektur für Data-Science-Projekte haben wir uns zum Ziel gesetzt, Einheitlichkeit und Flexibilität in der Gestaltung zu ermöglichen. Diese Idee wurde mithilfe der Basis-Klasse mit Erweiterungsmöglichkeiten an unterschiedlichsten Stellen umgesetzt:
- Beim Entwickeln von einzelnen Services,
- beim Logging und
- beim Monitoring.
Desweiteren haben wir uns dafür entschieden, den Zusammenbau und die Anbindung einzelner Komponenten über Plugins zu ermöglichen.
Für uns steht außerdem die Unabhängigkeit in unterschiedlichsten Formen im Vordergrund:
- bei der Wahl der Programmiersprache,
- bei der Wahl der Tools und Infrastruktur und
- bei der Wahl der Datenspeicherung (Datenbank, Index, Files, etc.).
War aber die Entscheidung für den Microservice-Ansatz richtig? Aus der Sicht der Anforderungen, die wir uns am Anfang gestellt haben, spricht sehr vieles dafür. Wir können generische Services entwickeln, die in die unterschiedlichsten Pipelines eingeschaltet werden können. Wir können neu entwickelte Services schnell einbinden. Die entwickelte Infrastruktur ermöglicht schnelle und effiziente Entwicklung von Prototypen und Lösungen zur explorativen Datenanalyse. Außerdem können die entwickelten Prototypen ohne große Aufwände in ein Produktions-Deployment übernommen werden.
Die Kehrseite der Medaille ist der Entwicklungsaufwand, um diese Anforderungen zu erfüllen. Es muss klar sein, dass dieser Aufwand ein starker Kostenfaktor ist, der sich erst zu einem späteren Zeitpunkt rentieren kann. Ob man den Weg der eigenen Entwicklung vornimmt oder auf bestehende Lösungen zugreifen kann, zählt zum Schluss vor allem:
"The golden rule: can you make a change to a service and deploy it by itself without changing anything else?"[4]
Genau ab diesem Moment kommt die Effizienz und Schnelligkeit in der Entwicklung bei der Datenanalyse zum Tragen.
- Github: Writing a service
- Github: API-Server
- Github: Logging & Monitoring
- Sam Newman. 2015. Building Microservices (1st ed.). O'Reilly Media, Inc..