Cloud-native Anwendungen funktionieren verteilt und tauschen zwischen einer Vielzahl von Komponenten Informationen aus. Das erschwert die Fehlersuche erheblich, doch das Tracing-Framework Jaeger hilft Admins aus der Bredouille.
Mancher Administrator, der sich zum ersten Mal in seiner Karriere einem Container-basierten Setup mit darauf laufenden verteilten Applikationen gegenübersieht, mag denken, dass früher vieles zumindest anders war, vielleicht sogar besser. Aus seiner Perspektive ließe sich jedenfalls nachvollziehen, wie es zu diesem Fehlschluss kommt. Wer früher dafür zuständig war, die Ursache von Problemen zu finden, hatte ein paar offensichtliche Ansatzpunkte. Große Monolithen wie MySQL schreiben ganz banal ihre Fehlermeldungen auf. Ein Blick in die Log-Datei genügte also oft bereits, um dem Admin zumindest einen Fingerzeig in die richtige Richtung zu geben.
Wenn in der Log-Datei nichts Sinnvolles stand, blieb immer noch die Ebene darunter als Ansatzpunkt: Funktionierte beispielsweise die Kommunikation zwischen Server und Client nicht so, wie die Dokumentation es beschrieb, half sich so mancher Admin routiniert mit Werkzeugen wie Tcpdump aus der Patsche (Abbildung 1). Damit lässt sich bis auf die untersten Ebenen einer Netzwerkverbindung der Datenverkehr mitlesen, anschließend mittels Wireshark visualisieren und dann auf potenzielle Probleme abklopfen. Hinzu kommt die Perspektive des Clients: Der sieht ja ebenfalls noch eventuelle Fehler und gibt die passenden Meldungen bei Bedarf einfach im Terminal aus.

Abbildung 1: Werkzeuge wie Tcpdump genügten früher, um Netzwerkverbindungen auf der Applikationsebene zu untersuchen.
Moderne Anwendungen
Von derartig einfachen Debugging-Mechanismen können Admins und auch Entwickler bei moderneren, verteilten Anwendungen nur träumen. Zur Erinnerung: Die Cloud-native-Architektur bringt zwar einige Vorteile mit sich, etwa implizite Redundanz oder die Option, externe Lösungen wie Istio dynamisch einzubinden. Im Gegenzug wächst aber auch die Komplexität jeder einzelnen Anwendung sprunghaft. Ein unmittelbarer Vergleich von alter und neuer Welt verdeutlicht das schnell. Dazu eignet sich das erwähnte Beispiel einer Datenbank hervorragend.
Clients bauen persistente Verbindungen zur Datenbank auf. Sie stellen sie also einmal her und nutzen sie dann kontinuierlich, bis sie entweder eine Seite offiziell terminiert oder ein wie auch immer gearteter Fehler der Kommunikation den Garaus macht. In allen Fällen merken Server und Client sofort, dass sich die Gegenseite nicht mehr erreichen lässt, und quittieren das mit einer klaren Meldung.
Verteilte Anwendungen nach Cloud-native-Standard kommen hier schon bei der Art der Verbindung ins Schleudern. Cloud-native-Anwendungen sind statt als große Monolithen in Form von Mikrokomponenten gebaut. Es gibt in einer Cloud-ready-Umgebung also nicht die eine Anwendung, die alle Aufgaben abwickelt. Stattdessen laufen eine Vielzahl von kleinen, hochspezifisch für eine einzelne Aufgabe konzipierten Anwendungen.
Um die Rolle als Goldstandard für die Kommunikation zwischen diesen Komponenten streiten sich mehrere Ansätze. Weite Verbreitung genießen heute RESTful-APIs auf HTTP(S)-Basis. Auch Lösungen wie gRPC spielen eine Rolle. Sie haben gemeinsam, dass sie nicht ausschließlich auf Stateful-Verbindungen setzen wie das Datenbankbeispiel zuvor.
Mit der Anzahl von Mikrokomponenten in einer Anwendung steigt freilich auch die Anzahl der möglichen Kommunikationspunkte, und zwar exponentiell. Gerade Cloud-native-Anwendungen neueren Datums geben sich hinsichtlich der Anzahl der enthaltenen Mikroanwendungen alles andere als genügsam.
Ganz viele Apps
Eine Mikroanwendung dient dann beispielsweise als Kontaktpunkt für die Kommunikation mit Clients. Eine zweite im Hintergrund erhält Eingaben von der ersten weitergeleitet, wertet sie aus und sendet sie an eine dritte Mikroanwendung, die die Daten dann irgendwo auf einem Datenträger speichert.
Eine vierte Anwendung könnte den Inhalt der gespeicherten Daten überwachen und Alarm schlagen, wenn bestimmte Inhalte auftauchen oder beim Schreiben bestimmte Ereignisse eintreten. Mikro-App Nummer fünf könnte dazu dienen, die von der vierten Komponente generierten Alarme in Form von Textnachrichten per E-Mail, SMS oder über einen Messenger-Dienst auszuspielen.
Das noch relativ einfache Beispiel zeigt: Daten können im weiten Geflecht einer Microservices-Architektur lange Wege zurücklegen, dabei regelmäßig und in mannigfaltiger Art und Weise Transformationen durchlaufen und am Ende als Fragmente bei verschiedenen Empfängern landen.
Komplexes Netzwerk
Und das ist noch lange nicht das Ende der Fahnenstange in Sachen Komplexität. Im Linux-Magazin waren in der Vergangenheit regelmäßig Lösungen wie Sidecar und Istio Thema, die sich redlich bemühen, das Chaos noch etwas größer zu machen.
Istio zum Beispiel bringt einen Sidecar-Bestandteil mit, der sich dynamisch in die Kommunikation der Mikroarchitekturkomponenten einklinkt. Dabei erfüllt es eine ganze Reihe von Aufgaben: Istio kann Firewall-Regeln umsetzen, die Endpunkte der Kommunikation on the fly mit SSL-Verschlüsselung ausstatten und die eingehende Last für jede der einzelnen Komponenten der Anwendung auf die verfügbaren Instanzen verteilen (Abbildung 2).

Abbildung 2: Die Architekturzeichnung von Istio zeigt schnell: Schon das sich dynamisch verändernde Netz fällt so komplex aus, dass Werkzeuge wie Tcpdump hier nur wenig ausrichten. Quelle: Istio
Aus Client-Sicht bedeutet das freilich, dass unklar bleibt, mit welcher Instanz einer Komponente einer Anwendung sie gerade kommuniziert. Und weil in verteilten Anwendungen die meisten Komponenten auch untereinander Daten austauschen, bleibt die Mehrzahl der Kommunikationspfade für den Admin und die Entwickler einer Anwendung komplett im Dunkeln.
Problemfall Debugging
Die exemplarisch dargestellte Mikroarchitekturanwendung macht auch deutlich, wieso das Debugging von Problemen in solchen Umgebungen zur Herkulesaufgabe mutiert. Ein denkbar einfaches Beispiel macht das deutlich: Der Anwender greift durch eine Webapplikation hindurch auf eine verteile Anwendung zu und möchte dort hinterlegte Daten abfragen. Anstelle der erwarteten Daten bekommt er aber nur eine Fehlermeldung. Wo liegt nun also das Problem?
Hier kommen fast beliebig viele Fehlerquellen infrage. Möglicherweise funktioniert schon die Komponente nicht richtig, die die Anfrage der Webapplikation annimmt, oder der ihr vorgeschaltete Load Balancer. Denkbar wäre auch, dass der Load Balancer zwischen der ersten und der zweiten Komponente nicht richtig konfiguriert ist und die Anfrage verwirft. Es könnte aber ebenso gut sein, dass es Netzwerkprobleme zwischen den physischen Systemen gibt, auf denen die Komponenten der Anwendung laufen – meist noch in Container verpackt.
Oder kommt die Anfrage gar bei der Datenbank an, diese kann aber nicht passend antworten? Auch für dieses Szenario existieren etliche mögliche Ursachen. So könnte der persistente Speicher defekt sein, der der Datenbank zur Verfügung steht. Oder der Zugriff auf den Speicher über das Netzwerk klappt nicht, weil einer der Switches im Setup nicht mehr funktioniert und als Chaos Monkey fungiert. Eventuell liefert die Datenbank die gewünschten Daten sogar, sie bleiben aber auf dem Rückweg irgendwo hängen.
Wie soll der Admin oder der Entwickler, aus dessen Perspektive eine laufende Anwendung nach dem Cloud-ready-Prinzip wie die sprichwörtliche Blackbox aussieht, nun des Übels Wurzel finden?
Konventionelle Mittel versagen
Klassische Ansätze wie das Auslesen von Log-Dateien einzelner Teile der Anwendung laufen oft ins Leere, weil diese gar keine Log-Meldungen produzieren. Das liegt nicht etwa daran, dass ihre Entwickler übersehen hätten, die Funktion korrekt zu implementieren. Vielmehr steht in verteilten Systemen einzelnen Containern oft gar kein persistenter Speicher für Logs zur Verfügung. Selbst wenn die App also loggen könnte, wüsste sie zumindest lokal nicht, wo sie die Protokolle hinpacken soll.
Auch klassische Überwachungsansätze lassen sich schwer oder gar nicht implementieren, denn Event-Monitoring oder das bloße Überwachen von Metrikdaten hilft beim Debugging eines einzelnen Problems praktisch kaum weiter. Der Admin hat schließlich nichts davon, wenn das Monitoring auf Grün steht, weil es individuelle Probleme gar nicht zu erkennen vermag. Kurzum: In Mikroarchitekturanwendungen gilt es, die Themen Debugging und Monitoring komplett neu zu denken. An eben dieser Stelle kommen Open Telemetry und dessen Implementierung Jaeger ins Spiel.
Standard und Anwendung
Gleich ganz am Anfang sei darauf hingewiesen, dass Open Telemetry und Jaeger nicht identisch sind, obwohl die Begriffe oft synonym zum Einsatz kommen.
Vielmehr handelt es sich bei Open Telemetry um einen Standard, primär um eine Schnittstellendefinition [1]. Sie beschreibt ein Interface, das Anwendungsentwickler in ihre Produkte integrieren können, um Kommunikationsschnittstellen mit der Außenwelt zu schaffen, die ein definiertes Protokoll sprechen.
Das Ziel besteht dabei stets darin, Telemetriedaten (Log- und Metrikdaten, Tracing-Informationen von Datenströmen) in standardisierter Form zu erheben und zu exportieren. Obendrein bietet Open Telemetry mittlerweile auch eine Vielzahl von Clients für die Integration in Programmiersprachen an, etwa für Go oder Python.
Jaeger wiederum ist eine konkrete Implementierung von Tracing-Funktionen nach dem Open-Telemetry-Standard. Es stellt ein Framework bereit, das bei der Auswertung von Open-Telemetry-Daten hilft [2].
Was in der Theorie abstrakt und kompliziert klingt, erschließt sich in der praktischen Betrachtung viel schneller. Es sei daher nochmals an die fiktive Mikroarchitekturanwendung am Anfang dieses Artikels sowie das für sie beschriebene Fehlerszenario erinnert, um den Nutzen von Open Telemetry und Jaeger zu verdeutlichen.
Open Telemetry verstehen
Soll Open Telemetry zum Einsatz kommen, müssen die Entwickler einer Applikation das bereits beim Programmieren beachten. Open Telemetry bietet etwa Bindings für Go an, das heute bei einer Vielzahl von Anwendungen im Cloud-ready-Universum zum Einsatz kommt. Der erste Schritt auf dem Weg zu effektivem Tracing besteht im Beispiel darin, die Open-Telemetry-Bindings in die Komponenten der eigenen Applikation zu importieren.
Das ist ein guter Augenblick, sich etwas detaillierter mit den von Open Telemetry genutzten Begrifflichkeiten zu befassen. Insbesondere die Doku zum Standard verwendet diese nämlich so exzessiv, dass der Admin früher oder später ob der vielen Spans und Traces den Wald vor lauter Bäumen nicht mehr sieht. So komplex, wie die Doku es wirken lässt, ist das Thema aber gar nicht. Gerade anhand der beiden grundlegenden Begriffe Spans und Traces lassen sich die Open-Telemetry-Grundlagen gut verdeutlichen.
Spans und Traces
Als Span bezeichnet man im Open-Telemetry-Jargon einen Datensatz, der durch das Ausführen einer beliebigen Operation entsteht. Ist eine Anwendung per Client-Bibliothek für Open Telemetry vorbereitet, produziert jede Funktion bei jedem Aufruf einen Span. Der hat eine eindeutige ID (die den Namen der aufgerufenen Funktion umfasst) und enthält neben dem exakten Aufrufzeitpunkt der Funktion die Angabe der Zeitspanne, die sie bis zum Abschluss ihrer Arbeit benötigt hat. Spans können miteinander verbunden oder ineinander verschachtelt sein. Ruft Funktion A also Funktion B auf, entstehen zwei durch Verschachtelung logisch miteinander verbundene Spans.
Traces hingegen beschreiben eine Gruppe von Spans, die in direktem kausalen Zusammenhang stehen. Hier schimmert schon durch, warum Open Telemetry für das Debugging von Mikroarchitektur-Apps praktisch unumgänglich ist: Traces beschränken sich eben nicht auf einzelne Komponenten der Anwendung. Sorgt Ereignis 1 in Anwendung A dafür, dass in Anwendung B Ereignis 2 eintritt, entstehen je nach Art und Funktion der Applikation etliche Spans, aber nur jeweils ein Trace.
Der praktische Nutzen von Open Telemetry entsteht, wenn der Admin mittels eines Tracing-Frameworks die Spans und Traces visuell in Relation zueinander setzt. Denn dann wird sichtbar, welche Anwendungen wann wo welche Funktionen aufgerufen haben, welche Ereignisse daraus resultierten, welche Daten dabei zur Verfügung standen und was aus ihnen geworden ist.
Den Fokus erweitern
Wer sich heute erstmals mit Open Telemetry und Jaeger beschäftigt, findet deutlich mehr Funktionalität vor, als es noch vor ein paar Monaten der Fall gewesen wäre: Die Open-Telemetry-Entwickler haben den Umfang ihres Standards kontinuierlich erweitert. Ging es anfangs ausschließlich um Tracing-Informationen, legt der Standard heute auch Formate für das Sammeln von Metrikdaten und Log-Dateien fest. Tracing-Frameworks nach dem Open-Telemetry-Standard erweitern so die Datenbasis für das Debugging. Stehen nicht nur Traces zur Verfügung, sondern protokolliert die datenverarbeitende Applikation zeitgleich auch entsprechende Fehlermeldungen, ist das eine weitere Erleichterung.
Effektiv springt Open Telemetry damit freilich auch in die eingangs beschriebene Monitoring- und Logging-Lücke für Mikroarchitektur-Apps. Für beide Datenarten stehen im Standard Formate zur Verfügung, die es den Anwendungen erlauben, passende Daten zu generieren. Die für die Analyse nötigen Tracing-Frameworks sind zudem zahlreicher und vielfältiger geworden. Ein Teil der durch Open Telemetry erstellten Daten lässt sich etwa mittels Prometheus (für Metrikdaten) oder Loki (für Log-Daten) verarbeiten.
Jaeger als Framework
Im vorliegenden Beispiel verfügt der Admin oder Entwickler nach dem Einbinden des Open-Telemetry-Frameworks über eine Anwendung, die Metrik-, Logging- und Tracing-Daten produziert. Das allein ist aber nur die halbe Miete, denn der Admin möchte die Daten auch darstellen und sinnvoll verarbeiten. Hier kommt nun Jaeger als eine mögliche Implementation eines Tracing-Frameworks ins Spiel. Jaeger bot eingangs selbst Client-Bibliotheken an, hat diese mittlerweile aber zugunsten der Pendants von Open Telemetry in die Sunset-Phase geschickt.
Übrig bleibt damit der Jaeger-Server, der selbst aus mehreren Komponenten besteht. Interessanterweise laufen diese nicht nur auf einem einzelnen Host: Ein Jaeger-Agent eilt den oft in großer Menge vorhandenen Clients auf den Zielsystemen (oder in den Ziel-Containern) zur Hilfe. Seine denkbar einfache Aufgabe: Er empfängt die von den Clients erstellten und gesammelten Daten. In Container-Anwendungen integriert er sich üblicherweise als weiteres Sidecar. Praktisch sorgt er dafür, dass die Anwendung selbst nicht wissen muss, wohin die gesammelten Tracing-, Log- und Metrikdaten gelangen sollen.
Was wie eine Lappalie wirkt, hat in der Praxis große Bedeutung, denn die Jaeger-Konfiguration ist durch den Jaeger-Agenten autark und unabhängig von jener der Applikation selbst. Deshalb lässt sie sich auch im laufenden Betrieb ändern.
Von der App zum Nutzer
Was mit den Daten geschieht, sobald sie einmal beim Jaeger-Agenten gelandet sind, hängt vom jeweiligen Setup ab. Die Lösung bietet hier mehrere Optionen. Sie alle bemühen zunächst den Jaeger-Collector, der seinerseits alle verfügbaren Daten von den diversen Agent-Instanzen einsammelt und dann ein Sampling vornimmt. Letzteres dient vorrangig dazu, redundante Informationen aus den gesammelten Daten zu entfernen, um ihre Menge insgesamt zu reduzieren. Starre Regeln gibt es dafür nicht, stattdessen kommt im Collector adaptives Sampling zum Einsatz. Das beeinflusst der Admin oder der Entwickler bei Bedarf über eine separate Konfiguration.
Vom Collector aus wandern die Daten dann in eine Datenbank weiter. Jaeger setzt im Hintergrund auf persistenten Speicher, um die gesammelten Informationen zu sichern und zu verarbeiten. Zur Auswahl stehen hier aber Elasticsearch, Cassandra oder Kafka statt klassischer relationaler Datenbanken. Sind die Daten einmal hinterlegt, empfehlen die Entwickler den Einsatz von Apache Spark, um anhand von im Jaeger-Repository verfügbaren Spark-Jobs den Inhalt der Datenbank zu optimieren.
Hinzu gesellt sich schließlich eine Komponente namens Jaeger-Query, die Daten anhand vom User bestimmter Parameter aus der Traces-Datenbank ausliest. Sämtliche Visualisierungswerkzeuge, auch das von Jaeger selbst, greifen stets auf die Query-Komponente und nie unmittelbar auf die Datenbank zu: Es liegt auf der Hand, dass diese gewaltig unter Last steht, wenn einerseits neue Traces auf sie einprasseln und andererseits von der Query-Komponente Anfragen für vorhandene Daten eintrudeln. Jaeger-Query ist übrigens auch die Anwendung, die das Jaeger-eigene UI (Abbildung 3) nutzt, wenn es Daten aus Jaeger abfragt.

Abbildung 3: Jaeger arbeitet mit Spans, die es in Traces gruppiert. Das ermöglicht es, den Werdegang von Daten und von Ereignissen, die diese Daten verarbeiten, exakt nachzuvollziehen. Quelle: Jaeger
Cache oder nicht?
Die Jaeger-Entwickler haben wegen der potenziell hohen Grundlast die Möglichkeit geschaffen, der genutzten Datenbank eine (gegebenenfalls zusätzliche) Kafka-Instanz zur Seite zu stellen, die als Cache fungiert. Der Collector schreibt seine Daten dann dorthin statt direkt in die Hauptdatenbank, und aus der Mittlerinstanz von Kafka heraus landen die Daten asynchron in der persistierten Datenbank im Hintergrund.
In diesem Setup liegt die zusätzliche Last, die Spark produziert, nicht mehr unmittelbar bei der Backend-Datenbank an, sondern wird über Apache Flink an die cachende Kafka-Instanz ausgelagert. Die relativ komplexe Architektur hat in der Praxis durchaus ihren Nutzen: Große Mikroarchitekturanwendungen produzieren oft in kürzester Zeit große Mengen von Traces mit Millionen von Spans. Nur durch das Zusammenspiel der genannten Werkzeuge bleibt das Setup dann einfach nutzbar und stabil.
Jaeger in der Praxis
Im Alltag ist Jäger für Administratoren wie für Entwickler von Anwendungen gleichermaßen relevant. Admins setzen das Werkzeug wie beschrieben vorrangig zu Debugging-Zwecken ein, während Entwickler mit Jaeger auch herausfinden, ob Änderungen an den einzelnen Komponenten einer Microservice-Anwendung die gewünschten Resultate liefern. Wie aber setzt der Admin seine Jaeger-Instanz idealerweise auf, um aus ihr schnell und effizient den größtmöglichen Nutzen zu ziehen? Und wie lässt sich Jaeger mit anderen Werkzeugen wie Prometheus und Grafana sinnvoll kombinieren, um den Nutzen weiter zu steigern?
Die gute Nachricht: Für die gängigen Anwendungen der Container-Welt existieren heute fertige Anleitungen, die die Jaeger-Integration beschreiben. Viele Werkzeuge wie zum Beispiel Prometheus sind ihrerseits dazu übergegangen, Integration für Jaeger gleich selbst zu liefern (Abbildung 4).

Abbildung 4: Weil Jaeger mittlerweile auch ein Interface hat, um Daten im Prometheus-Format auszugeben, lassen sich gesammelte Metrikdaten aus Jaeger unmittelbar in Prometheus und mithin auch in Grafana darstellen. Quelle: Grafana
Jaeger und Prometheus gelten oft fälschlicherweise als Konkurrenten, weil beide etwas mit Metrikdaten und Monitoring zu tun haben. Tatsächlich lassen sich beide Werkzeuge aber hervorragend miteinander kombinieren, um eine Plattform effizienter zu überwachen. Die Query-Komponente von Jaeger etwa hat mittlerweile einen »/metrics«-Endpunkt, über den sie gesammelte Metrikdaten im Prometheus-Format ausgibt. Damit agiert die Query-Komponente als eine Art Exporter für Prometheus-Daten.
Kommen die Daten aus Jaeger erst einmal in Prometheus an, kann man dort mit ihnen alles anstellen, was das Tool – meist im Gespann mit Grafana – beherrscht. Das macht beispielsweise die Visualisierung zum Kinderspiel. Während das Jaeger-UI als eigenständige Komponente für die Darstellung von Traces und Spans optimiert ist, glänzt Grafana in Kombination mit Prometheus bei der Aufarbeitung der Metrikdaten.
Weitere herausragende Beispiele für eine rundherum gelungene Jaeger-Integration bieten Istio und Envoy. Das Service-Mesh Istio integriert mittlerweile ebenfalls Open-Telemetry-Funktionalität, sodass sich Istio-Operationen detailliert in Jaeger darstellen lassen (Abbildung 5). Die in Istio enthaltene Load-Balancer-Komponente Envoy lässt sich ebenfalls hervorragend an Jaeger ankoppeln (Abbildung 6). Genauere Informationen im Hinblick auf die notwendigen Schritte zur Integration finden sich in den jeweiligen Anleitungen.

Abbildung 5: Istio verfügt über eine native Jaeger-Anbindung, sodass sich etwa vom Istio-Ingress-Gateway initiierte Aktionen ohne weitere Schritte als Traces in Jaeger darstellen lassen. Quelle: Istio

Abbildung 6: Auch die Istio-Komponente Envoy lässt sich gut mit Jaeger kombinieren, um Verkehrsflüsse visuell zu repräsentieren und nachzuvollziehen. Quelle: Arvind Thangamani
Fazit
Wer schon einmal versucht hat, in einer verteilten Anwendung ein nicht ganz offensichtliches Problem zu lösen, weiß um die Komplexität dieser Aufgabe. Realistisch betrachtet, lässt sich der Job nur erledigen, wenn man es mit einer einigermaßen übersichtlichen Komponente mit wenigen Mikroanwendungen zu tun hat.
Den Entwicklern von Anwendungen sei darum wärmstens ans Herz gelegt, sich näher mit dem Open-Telemetry-Standard und Jaeger (oder einem seiner Konkurrenten) auseinanderzusetzen. Sieht der Programmierer Jaeger nicht schon auf der Entwicklungsebene vor, bringt einem Unternehmen die schönste Jaeger-Server-Instanz nichts.
Umgekehrt sollten Admins in ihrem Rechenzentrum von vornherein ein funktionierendes Jaeger-Framework miteinplanen: Die Praxis zeigt, dass sich das Hochziehen eines Jaeger-Setups alles andere als trivial gestaltet. Systemverwalter neigen zudem dazu, die Last zu unterschätzen, der ein solches Setup unterliegt. Sie kann beträchtlich ausfallen, wenn etliche Microservices gleichzeitig Traces produzieren, sodass man die Hardware des Setups passend dimensionieren muss. (jcb/jlu)
Infos
- Open Telemetry Standard: https://opentelemetry.io
- Jaeger Tracing: https://www.jaegertracing.io






