Einführung in OpenTracing

Keine Kommentare

In diesem Beitrag möchte ich einen ersten Einstieg in das Thema OpenTracing geben.

Egal, ob man ein monolithisches System modernisieren möchte oder auf der grünen Wiese startet – aktuell ist der Trend ganz klar eine Service- oder Microservice-Architektur. Einen Aspekt darf man dabei allerdings nicht außer Acht lassen: Eine auf vielen unterschiedlichen Services basierende Architektur ist nicht weniger komplex als ein Monolith. Die Komplexität verschiebt sich lediglich. Eine Herausforderung dabei ist, Prozesse über unterschiedliche Services hinweg nachvollziehen zu können. Wie sehen die Aufrufketten aus? Welches sind die beteiligten Systeme/Services? Welche Aufrufe innerhalb des Prozesses sind besonders zeitintensiv und welche Schritte waren fehlerhaft?

Ein Lösungsansatz ist es sogenannte Tracer einzusetzen. Das Prinzip ist einfach: Über einen Reporter melden Services Aufrufe ihrer zuvor definierten Endpunkte an eine zentrale Sammelstelle. Dort werden die Traces gesammelt und persistiert. In der Regel bieten diese Tools auch die Möglichkeit einer grafischen Aufbereitung.

Die OpenTracing Initiative hat es sich zur Aufgabe gemacht für dieses Vorgehen einen Standard zu etablieren. Das Projekt besitzt eine übersichtliche, klar strukturierte API und weist eine hohe Akzeptanz auf. Auf der Projektwebsite finden sich eine ganze Reihe unterstützter Tracer und vor kurzem wurde die API auch in das Eclipse MicroProfile (ab Version 1.3) aufgenommen. Grund genug sich OpenTracing einmal genauer anzusehen.

Was ist OpenTracing?

Das Ziel von OpenTracing ist es eine Framework- und herstellerunabhängige API bereitzustellen, die es erlaubt Traces zu protokollieren. Dabei soll sichergestellt werden, dass die darunterliegende Implementierung leicht ausgetauscht werden kann. Vergleichen lässt sich dies mit SLF4J, das ebenfalls nur die Fassade für einen Logger liefert. Die verwendete Implementierung kann frei gewählt werden und lässt sich durch das einfache Austauschen von Dependencies ohne zusätzliche Konfiguration bewerkstelligen.

Elemente

OpenTracing definiert einige Elemente, über die ich nachfolgend einen groben Überblick geben möchte. Eine sehr gute und ausführliche Beschreibung findet man in der offiziellen Spezifikation.

Span

Ein Span stellt die kleinste Einheit eines Traces dar. Ein Span beschreibt einen Prozessschritt. Spans können miteinander in Beziehung stehen. Um diese Beziehungen auszudrücken gibt es aktuell zwei Möglichkeiten. Über eine childOf Beziehung lässt sich ausdrücken, dass ein Span direkt von den Ergebnissen seiner Kindern abhängig ist. Mittels followsFrom lässt sich hingegen eine Beziehung abbilden, in der ein Span nicht abhängig von den Ergebnissen seiner Kinder ist. Es ist eine Reihenfolge ohne Abhängigkeit.

Schematische Darstellung eines Spans in OpenTracing

Ein Span besitzt einen frei wählbaren Namen, der die Operation oder den Prozessschritt beschreibt sowie einen Start- und einem Endzeitpunkt. Darüber hinaus lässt er sich mit Tags, die als Key-Value-Paar aufgebaut sind, anreichern. Diese gelten nur für diesen einen Span, aber über die gesamte Verarbeitungszeit. LogEvents lassen sich ebenfalls für einen Span erstellen. Anders als Tags gelten diese aber nur für einen bestimmten Zeitpunkt innerhalb des Ausführungszeitraumes des Spans. Prozess- und Systemgrenzen übergreifend lassen sich sogenannte BaggageItems nutzen. Auch hierbei handelt es sich um einfache Key-Value-Paare.

SpanContext

Ein SpanContext hängt an einem Span und enthält Daten, die übergreifend verfügbar gemacht werden sollen. Dabei handelt es sich aktuell um die oben erwähnten Baggage-Items.

Trace

Ein Trace besteht aus einer Menge von Spans.

Vereinfachte Darstellung eines Trace in OpenTracing

Tracer

Der Tracer übernimmt das Reporting an den Tracer-Server. Dieser Server ist ein separates System. Es übernimmt die Persistierung der Traces und erstellt Auswertungen. Eine Liste der Tracer, die OpenTracing unterstützen, findet man hier. Das Tracer-Objekt im Code wird abhängig von der eingesetzten Implementierung konfiguriert und instanziiert.
Wie das Reporting vom Service an den Tracer-Server genau stattfindet ist abhängig von der jeweiligen Implementierung und dem gewählten Reporter. Jaeger ist beispielsweise ein Tracer, den man als OpenTracing-Implementierung nutzen kann. Als Reporter bietet Jaeger unter anderem http, thrift oder auch UDP an.

Vereinfachte Darstellung der Kommunikation in einer Servicelandschaft, die OpenTracing nutzt

Spans kommen aus verschiedenen Services und müssen einem Trace zugeordnet werden können. Dazu müssen Daten zwischen zwei Services ausgetauscht werden, die es erlauben einen Trace mit einem neuen Span fortzuführen. Der Tracer übernimmt auch die Aufgabe diese Daten zu serialisieren (genannt “inject”) und zu deserialisieren (genannt “extract”). Wie das genau aussieht, sehen wir später.

GlobalTracer

Der GlobalTracer ist eine einfache Möglichkeit eine konkrete Instanz eines Tracers überall im Projekt verfügbar zu machen unabhängig davon, ob Dependency Injection verwendet wird oder nicht. Dies betrifft nicht nur eigene Projektklassen, sondern auch Instrumentations wie zum Beispiel Filter für JAX-RS2, okhttp3 oder Spring.

Hat man ein konkretes Tracer-Objekt instanziiert, wird dieses im GlobalTracer registriert. Ab jetzt können Third-Party-Dependencies und weitere Klassen im eigenen Projekt via GlobalTracer.get() auf die konkrete Tracerimplementierung zugreifen.

Funktionsweise

Zur Veranschaulichung habe ich mich eines Beispielprojektes meines Kollegen Benjamin Wilms bedient und dieses leicht abgewandelt. Es handelt sich um ein Projekt mit drei Services, geschrieben in Kotlin, die via REST kommunizieren. Für den REST-Anteil nutze ich SparkJava. Da der Fokus auf dem Tracing liegt, habe ich das Projekt so einfach wie möglich gehalten.

Microservices des Beispielprojektes, das OpenTracing verwendet

Es gibt einen Service assistant, der dem Aufrufer sowohl Notizen als auch Erinnerungen zurückgeben soll. Beide erhält der assistant Service aus jeweils einem eigenen Service (note und reminder). Der Client wird also bei der Anfrage des REST-Endpunktes vom assistant Service einen neuen Span erzeugen. Dieser Span wird für uns transparent über das Tracer-Objekt mit all seinen Daten an den Tracer-Server (in diesem Fall jaeger von uber) übertragen. Dies geschieht unabhängig von unserem Request. Hierfür gibt es bei jaeger intern eine Queue, die eigenständig abgearbeitet wird. Die HTTP-Aufrufe an die nachgelagerten Services erhalten Header-Parameter, die notwendig sind, um Spans einem bestimmten Trace zuzuordnen und BaggeItems zwischen zwei Spans übertragen zu können. Letztere nur, sofern diese auch gesetzt wurden. Die Header-Parameter sind das Ergebnis der jeweiligen Implementierung der inject() und extract() Methoden. Ihre Ausprägung ist damit anbieterspezifisch. Bei jaeger sehen diese Header wie folgt aus:

uber-trace-id: 806fbeed61957d1a%3A38468ef939718a67%3A806fbeed61957d1a%3A1
uberctx-process: showDashboard

Bei Zipkin hingegen sehen die Header ganz anders aus:
X-B3-ParentSpanId: a33c27ae31f3c9e9
X-B3-Sampled: 1
X-B3-SpanId: 42483bbd28a757b4
X-B3-TraceId: a33c27ae31f3c9e9

Läuft die Kommunikation zwischen den Systemen über eine andere Technologie, so müssen diese Informationen auf andere Art und Weise serialisiert werden.

OpenTracing einbinden

Der Code für das gesamte Beispielprojekt ist auf GitHub gehostet.
Zunächst erstellen wir unsere Tracer-Instanz und registrieren sie im GlobalTracer. Für jaeger sieht das im assistant wie folgt aus:

Wir holen uns den mithilfe der Konfiguration erstellten Tracer.

Dieser stellt uns einen SpanBuilder zur Verfügung. Über diesen setzen wir unseren OperationName sowie die Tags. Als erstes erstelle ich einen Span, der die einzelnen Aufrufe kapseln soll.

Die Methode start() startet den Span und gibt uns diesen als Objekt wieder zurück. An diesem können wir nun auch die BaggeItems setzen.

Als nächstes rufen wir die jeweiligen Services. Für jeden Aufruf erstellen wir einen eignen Span. Das geschieht analog zu dem obigen Beispiel, jedoch mit einem Unterschied: Wir setzen noch via asChildOf() den Parent-Span.

So setzen wir beide Spans in Beziehung.
Da wir nun einen entfernten notes-Service via REST aufrufen, müssen wir ihm noch in den Headerparametern die TraceId und das BaggaeItem mitgeben.

Die Map mit den Header-Parametern geben wir unserem REST-Call mit. Ist die Ausführung beendet, werden die Spans jeweils mittels finish() beendet.

Jetzt schauen wir uns die andere Seite an, nämlich die des notes-Service, der jetzt den Aufruf vom assistant-Service entgegennimmt. Auch hier haben wir unseren Tracer initialisiert. Im GET holen wir uns zunächst die Header-Parameter aus dem Request:

Diese reichen wir in die extract() Methode des Tracers, der uns daraus einen SpanContext erstellt:

Diesen SpanContext geben wir als unseren Parent an, wenn wir einen neuen Span erstellen, da wir den im assistant-Service begonnenen Prozess weiterführen möchten.

Anschließend fügen wir manuell die BaggeItems hinzu:

Auch hier rufen wir am Ende der Verarbeitung finish() auf dem Span auf.
Im Frontend von jaeger sieht das dann so aus:

Im reminder-Service nutze ich Filter, um die Definition eines Spans nur ein Mal machen zu müssen. Anders als im notes-Service nutze ich hier auch einen ActiveSpan, um über den GlobalTracer auf den Span zugreifen zu können.

Darüber hinaus habe ich im Git-Repository auch einen Branch, in dem ich Zipkin als Tracer verwende.

OpenTracing mittels Instrumentation verwenden

Jetzt haben wir gesehen wie man OpenTracing einbinden kann. Wir mussten allerdings auch feststellen, dass unser Code aufgebläht wurde. Jetzt könnte man darüber nachdenken den Code irgendwie auszulagern oder man prüft, ob es nicht schon eine Instrumentation für das im Projekt verwendete Framework gibt. So bietet jaeger zum Beispiel vorgefertigte Instrumentations für JAX-RS 2, Dropwizard und Thrift. Auch Zipkin bietet mit brave, seiner Java-Implementierung, Instrumentations für eine ganze Reihe von Frameworks. Darunter sind Spring, Apache HttpClient, JAX-RS 2, MySQL und Kafka, um nur ein paar zu nennen.
Der Vorteil liegt klar auf der Hand. Je nach Framework und Art der Einbindung müssen wir unsere Businesslogik nicht mit Code für das Tracing kapseln. Ein weiterer Vorteil ist, dass je nach Instrumentation keine Aufrufe vergessen werden und auch neue Aufrufe automatisch mit abgedeckt sind, sofern wirklich immer alle Aufrufe getraced werden sollen. Ich denke hier insbesondere an ServletFilter.
Der Nachteil ist aber, dass wir uns nicht nur mit der Intersystem-Kommunikation, sondern auch mit dem Tracing eng an ein bestimmtes Framework koppeln. Zum einen hat die generische API hier keinen direkten Nutzen mehr für uns als Anwender und zum anderen ist das Wechseln von Tracer und Framework aufgrund der Abhängigkeit ggf. nicht mehr so einfach.

In einem kommenden Artikel zeigt mein Kollege Benjamin Wilms wie man OpenTracing in Spring Cloud Sleuth zusammen mit Instana als Tracer nutzen kann.

OpenTracing vs. Monitoring

Tracing sollte nicht als reines Monitoring verstanden werden. Es geht vielmehr darum es gezielt einzusetzen. Wie schon erwähnt möchte man ggf. einen Überblick über einen bestimmten Geschäftsprozess erlangen. Ein anderer Anwendungsfall wäre zum Beispiel zu prüfen, ob die Service Discovery oder das Client-Side-Load-Balancing, wenn man es einsetzt, auch richtig funktionieren. Aus diesem Grund sollte auch nicht jeder einzelne Aufruf als Trace gemeldet werden. Sinnvoller ist es Stichproben zu erheben. Tracer bieten hier in der Regel Möglichkeiten der Steuerung. In der Konfiguration des von mir verwendeten Beispielprojektes habe ich jaeger mit einem Sampler konfiguriert, der „const“ heißt. Er ist auf 1 gestellt und meldet daher jeden Aufruf. In einem produktiven Setup würde man den Sampler auf „probabilistic“ (also basierend auf einer Wahrscheinlichkeit mit einem Wert zwischen 0 und 1) oder auf „ratelimiting“ (eine Anzahl Samples pro Zeiteinheit) stellen. Entsprechend sollte man auch eine niedrige Retention-Policy wählen.

Fazit

Ich hoffe ich konnte einen guten Überblick über OpenTracing geben.
Wir haben gesehen wie man Prozesse über mehrere Systeme nachverfolgen kann. Außerdem haben wir die grundlegenden Typen von OpenTracing kennengelernt. Darüber hinaus haben wir ein einfaches Tracing über drei Services mithilfe der OpenTracing API und einem konkreten Tracer umgesetzt. Wir sind darauf eingegangen, dass es unterschiedliche Arten der Einbindung gibt und vieles stark abhängig von der gewählten Implementierung und den verwendeten Frameworks ist. Zudem haben wir uns angesehen inwiefern sich Tracing vom Monitoring abgrenzt.
Über Fragen, Kommentare und Erfahrungsberichte würde ich mich sehr freuen.

Jannes Heinrich

Jannes arbeitet als Software-Engineer und IT-Consultant bei der codecentric. Sein Schwerpunkt liegt im Java-Umfeld. Er ist aber immer an neuen Technologien und Frameworks interessiert und daran diese sinnvoll und gewinnbringend in Projekten einzusetzen.

Weitere Inhalte zu Microservices

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.