Microservices: Mit Tools wie Jaeger die Übersicht behalten
Die Microservice-Architektur wird derzeit als Allheilmittel für fast alle Probleme in der Softwareentwicklung gehandelt. Tatsächlich löst die Aufteilung einer komplexen Anwendung in zahlreiche isolierte und unabhängige Komponenten viele Probleme, schafft aber auch neue. Schwierig wird es beispielsweise, wenn Microservices sich gegenseitig aufrufen. Abbildung 1 zeigt, wie eine einzelne Nutzeranfrage eine Vielzahl an Anfragen auslöst, die von einem Microservice zum anderen gestellt werden.
In komplexeren Architekturen wird dieses Geflecht irgendwann ziemlich unübersichtlich. Ein Entwickler, der im obigen Beispiel den „Bestellabwicklungs“-Microservice aufruft, weiß womöglich gar nicht mehr, dass dieser wiederum mit dem „USt.ID“-Microservice kommuniziert, der wiederum eine externe API anbindet.
Diese Komplexität wird genau dann zum Problem, wenn einmal etwas nicht so läuft, wie es soll: Wenn beispielsweise die Verarbeitungsdauer für „Zahlungsdaten aktualisieren“-Anfragen immer länger und länger wird oder diese Anfragen nur noch Fehlermeldungen auslösen. Dann muss zunächst einmal die Ursache (der „Root Cause“) des Problems gefunden werden – und das kann schwierig werden.
Deshalb ist es hilfreich, den vollständigen Weg einer einzelnen Nutzeranfrage über alle beteiligten Services hinweg nachvollziehen zu können. Idealerweise mit den auftretenden Fehlern und mit Informationen darüber, wie lange die einzelnen Operationen gedauert haben. Genau diese Aufgabe erfüllen Tracing-Tools wie etwa das quelloffene Jaeger.
Wie funktioniert ein Tracer?
Tracing-Tools können feingranulare Informationen über den genauen Ablauf einer einzelnen Abfrage verarbeiten. Sie stellen dem Entwickler Informationen darüber zur Verfügung, wie lange ein Service für externe Aufrufe, etwa zu anderen Services oder externen API oder für Datenbankabfragen braucht.
Um diese Details bereitstellen zu können, muss in der Regel der Quelltext des jeweiligen Services angepasst werden. So kann jeder Service zunächst für sich selbst sogenannte „Spans“ erfassen, also die Zeit, die er mit bestimmten Operationen verbracht hat. Spans können auch verschachtelt werden, um sichtbar zu machen, dass eine übergeordnete Span (beispielsweise die Bearbeitung einer einzelnen HTTP-Anfrage in einem Service) aus mehreren untergeordneten Einzeloperationen (wie etwa einzelnen Datenbankabfragen) besteht.
Der Anbieter des Tracers, also etwa des Jaeger-Projekts, stellt die Bibliotheken bereit, mit denen der Nutzer die Informationen in seinem Programm selbst erfassen kann. Die gesammelten Spans werden vom Service regelmäßig nach jedem Request an den Tracer übermittelt. Diese kann je nach gewähltem Anbieter entweder selbst gehostet werden oder als SaaS-Produkt in der Cloud laufen.
Um eine Nutzeranfrage serviceübergreifend nachverfolgen zu können, muss der Tracer die Spans, die er von verschiedenen Services empfangen hat, anhand der ursprünglichen Nutzeranfrage wieder zusammenfügen. Abbildung 2 zeigt den üblichen Ablauf.
Hierbei erstellt der erste Service, der eine eingehende Nutzeranfrage verarbeitet (im Beispiel oben also der Stammdaten-Service) eine eindeutige Trace-ID. Diese ID muss der Service dann in Anfragen einbetten, die er an nachgelagerte Services stellt. Wie genau das funktioniert, hängt vom Kommunikationsprotokoll ab, über das die Services miteinander kommunizieren – beispielsweise also ein HTTP-Header oder ein gRPC-Metadatum.
Nachgelagerte Services können diese Trace-ID dann wieder aus den eingehenden Anfragen extrahieren und ihrerseits weiterverarbeiten und in andere Anfragen einbetten. Auf diese Weise ist es möglich, dass sämtliche Spans, die von irgendeinem Service erfasst werden, mit derselben Trace-ID zurück an den Tracer übermittelt werden. Dieser kann dann einen serviceübergreifenden Trace erstellen. Abbildung 3 zeigt, wie das in der Jaeger-Nutzeroberfläche aussieht.
Neben Jaeger gibt es noch weitere Werkzeuge am Markt, die ähnliche Funktionen anbieten. Andere Open-Source-Vertreter sind beispielsweise Elastic APM oder Apache Skywalking. Neben den Lösungen zum Selbst-Hosten gibt es auch SaaS-Angebote aus der Cloud, wie etwa Datadog oder Instana.
Um die eigene Software dazu zu bringen, Tracing-Informationen zu einem Tracer zu senden, sind – je nach gewünschtem Detailgrad – einige Anpassungen am Quelltext notwendig. Damit sich Entwickler damit nicht auf alle Zeiten auf eine bestimmte Tracing-Software festlegen müssen, gibt es den Opentracing-Standard, verfügbar in neun Programmiersprachen. Dabei handelt es sich um einen Satz an Programmierschnittstellen, die als Bibliothek in der eigenen Software installiert werden können. Die Client-Bibliotheken aller Tracer, die den Standard unterstützen, implementieren diese Interfaces, sodass es auch im Nachhinein recht einfach möglich ist, einen Tracer durch einen anderen auszutauschen.
Lokale Installation von Jaeger
Die Architektur von Jaeger selbst ist nicht ganz einfach: Ein für den Produktivbetrieb geeignetes Jaeger-Setup besteht aus mehreren einzelnen Jaeger-Komponenten, und benötigt zudem ein Elasticsearch- oder Cassandra-Cluster als Datenspeicher sowie optional ein Kafka-Cluster für den Datenaustausch.
Wer Jaeger ausprobieren oder schnell eine Instanz zum Entwickeln und Testen aufsetzen möchte, kann ein spezielles Docker-Image installieren, mit dem alle benötigten Komponenten in einem einzelnen Container gestartet werden:
$ docker run -d --name jaeger \ -p 16686:16686 \ -p 14268:14268 \ jaegertracing/all-in-one:1.8
Nach dem Starten dieses Containers kann die Jaeger-Benutzeroberfläche über die URL http://localhost:16686 aufgerufen werden.
Einstieg in OpenTracing mit Go
Die Integration von Jaeger in ein eigenes Programm ist wiederum nicht besonders schwer. Dieser Abschnitt zeigt eine Beispiel-Implementierung in Go. Die Beispieldateien finden sich in GitHub. Zunächst müssen die Jaeger-Clientbibliothek und Opentracing per go get
installiert werden:
$ go get github.com/opentracing/opentracing-go $ go get github.com/uber/jaeger-client-go
Im Anschluss kann gleich zu Programmstart, etwa direkt in der main
-Funktion, der Tracer initialisiert werden:
package main import ( "github.com/opentracing/opentracing-go" "github.com/uber/jaeger-client-go/config" ) func main() { cfg, err := config.FromEnv() if err != nil { panic(err) } cfg.ServiceName = "t3n-example" tracer, closer, err := cfg.NewTracer() if err != nil { panic(err) } defer closer.Close() opentracing.SetGlobalTracer(tracer) // eigener Programmcode hier... }
Der Funktionsaufruf config.FromEnv()
liest die gewünschte Konfiguration des Jaeger-Clients aus den Umgebungsvariablen des Prozesses aus. Über die kann beispielsweise ganz genau konfiguriert werden, zu welcher Netzwerkadresse die gesammelten Spans geschickt werden sollen.
Für einen lokal per Docker gestarteten Jaeger reicht die Umgebungsvariable JAEGER_ENDPOINT
, die beim Programmstart mitgegeben werden kann:
$ JAEGER_ENDPOINT=http://localhost:14268/api/traces JAEGER_SAMPLER_PARAM=1 go run main.go
Die Variable JAEGER_SAMPLER_PARAM=1
sorgt dafür, dass jeder erfasste Span auch an den Tracer übermittelt wird. Dies ist zu Entwicklungszwecken sehr sinnvoll – in einer Produktivumgebung mit vielen Anfragen kann hier ein Wert <1 gewählt werden, um die vom Tracer verarbeitete Datenmenge zu verkleinern.
Nachdem ein Tracer erstellt und registriert wurde, werden im Programm die neuen Spans erfasst:
span := opentracing.StartSpan("some-operation-name") span.SetTag("tag", "some-value") defer span.Finish() // [...hier eine zeitaufwändige Operation...]
Über SetTag
ist es möglich, die Opentracing-Spans zu verschlagworten, um sie in der Nutzeroberfläche des Tracers einfacher zu finden. Als Tag dienen beispielsweise der Name eines aufgerufenen Services, die IP-Adresse oder die User-ID des Nutzers, der empfangene oder gesendete HTTP-Statuscode.
Um verschachtelte Spans zu erstellen, eignet sich in Go am besten die Context-API. Mit der opentracing.ContextWithSpan(...)
-Methode ist es möglich, einen bestehenden Span in ein context.Context-Objekt
einzubetten. Dieses Objekt kann dann an nachfolgende Methodenaufrufe durchgereicht werden:
span := opentracing.StartSpan(„some-operation-name“) span.SetTag("tag", "some-value") defer span.Finish() ctx := opentracing.ContextWithSpan( context.Background(), span) executeLongRunningOperation(ctx) executeLongRunningOperation(ctx)
Mit der Methode opentracing.StartSpanFromContext(...)
kann dann ein neuer untergeordneter Span erstellt werden:
func executeLongRunningOperation(ctx context.Context) { span, subCtx := opentracing. StartSpanFromContext(ctx, "long-running-operation") defer span.Finish() // "subCtx" kann nun WIEDER an weitere Funktionen weitergereicht werden time.Sleep(5 * time.Second) }
Fazit
In komplexer verteilten Architekturen ist ein Tracer ein ungemein wichtiges Hilfsmittel, um Fehlern und Performanceproblemen auf den Grund zu gehen. Aber auch viel einfachere Fragen lassen sich damit beantworten: Häufig hat niemand einen kompletten Überblick darüber, welcher Service in einer MicroserviceArchitektur eigentlich mit welchem kommuniziert. Der Tracer kann diese Dependency Map ganz einfach generieren, denn er muss nur schauen, welche Services gemeinsam in einem Trace aufgerufen wurden.
Das oben genutzte Docker-Image ist nützlich, um Jaeger für Entwicklungs- und Testzwecke schnell an den Start zu bekommen. Für einen robusten Produktivbetrieb eignet sich dieses Image jedoch nicht, denn Jaeger besteht eigentlich aus vielen einzelnen Komponenten und benötigt einen skalierbaren Datenspeicher, wie einen Elasticsearch-Cluster, um die erhobenen Daten auch in größeren Mengen langfristig speichern zu können.
Die ausführliche Beschreibung einer Produktiv-Installation von Jaeger ginge zu weit. Aber so viel sei gesagt: Kubernetes-Nutzer haben Glück! Mit dem Jaeger-Operator steht eine Kubernetes-Automatisierung zur Verfügung, die das Setup und den Betrieb einer Produktivumgebung nahezu vollständig automatisiert. Zusammen mit dem Elasticsearch-Operator, der dasselbe für Elasticsearch übernimmt, können Entwickler schnell und unkompliziert mit Jaeger arbeiten.