Verfolgung, ein wesentlicher Bestandteil, verfolgt Anfragen durch komplexe Systeme. Diese Sichtbarkeit offenbart Engpässe und Fehler, was zu schnelleren Lösungen führt. In einem früheren Beitrag unserer Reihe über Go-Webdienste untersuchten wir die Bedeutung der Beobachtbarkeit. Heute konzentriert sich dieser Artikel auf die Verfolgung. Jaeger sammelt, speichert und visualisiert Spuren aus verteilten Systemen. Es bietet entscheidende Einsichten in den Anfragefluss zwischen Diensten. Durch die Integration von Jaeger mit OpenTelemetry können Entwickler ihr Verfolgungsverfahren vereinigen, um eine konsequente und umfassende Sichtbarkeit zu gewährleisten. Diese Integration vereinfacht die Diagnose von Leistungsproblemen und verbessert die Systemzuverlässigkeit. In diesem Beitrag werden wir Jaeger einrichten, mit OpenTelemetry in unserer Anwendung integrieren und die Visualisierung von Spuren für tiefere Einsichten erkunden.
Motivation
Was wir erreichen wollen, ist ein Jaeger-Dashboard, das wie folgt aussieht:
Während wir verschiedene Teile der App (auf der Onehub-Frontend-Seite) besuchen, werden die Spuren verschiedener Anfragen gesammelt (ab dem Zeitpunkt, an dem der grpc-gateway getroffen wird) mit einer Zusammenfassung jedes einzelnen. Wir können sogar in eine der Spuren eingehen, um einen detaillierteren Blick zu erhalten. Schauen Sie sich an der ersten POST
-Anfrage (für die Erstellung/Senden einesnachrichten in einem Thema) an:
Hier sehen wir alle Komponenten, die der Create
-Anfrage berührt, zusammen mit ihren Eintritts- und Ausgangszeiten sowie der Zeit, die sie in und aus den Methoden verbringen. Sehr mächtig.
Erste Schritte
TL;DR: Um dies zu beobachten und die restliche Blogposts zu validieren:
- Die Quelle für dieses liegt im PART11_TRACING-Zweig.
- Bau all die Dinge, die benötigt werden (sobald du den Zweig ausgecheckt hast):
make build
- Wir haben das docker-compose in zwei Teile aufgeteilt (das wird weiter unten erklärt), also stelle sicher, dass du zwei Fenster läuft.
- Terminal 1:
make updb dblogs
- Terminal 2:
make up logs
- Navigiere zu localhost:7080 und los geht’s.
Hoheitsübersicht
Unser System ist derzeit:
Mit der Instrumentierung von OpenTelemetry wird unser System evolvieren zu:
Wie wir zuvor erwähnt haben, ist es für jeden Dienst recht belastend, separate Cliente zu verwenden, um an bestimmte Anbieter zu senden. Stattdessen mit einem separaten OTel-Kollektor, können wir sicherstellen, dass alle (interessierten) Dienste einfach Metriken/Protokolle/Spuren an diesen Kollektor senden können, der dann die erforderlichen Backends exportieren kann — in diesem Fall Jaeger für Spuren.
Lass uns beginnen.
Richten Sie den OTel-Kollektor ein
Der erste Schritt besteht darin, unser OTel-Kollektor, der in der Docker-Umgebung läuft, zusammen mit Jaeger hinzuzufügen, sodass sie zugänglich sind.
Hinweis: Wir haben unser ursprüngliches allumfassendes docker-compose.yml
-Konfiguration in zwei Teile aufgeteilt:
- db-docker-compose.yml: Enthält alle Datenbank- und Infrastrukturkomponenten (nicht-Anwendung) wie Datenbanken (Postgres, Typesense) und Überwachungsdienstleistungen (OTel-Kollektor, Jaeger, Prometheus usw.).
- docker-compose.yml: Enthält alle Services, die mit der Anwendung in Verbindung stehen (Nginx, gRPC Gateway, dbsync, Frontend usw.)
Die beiden Docker-Compose-Umgebungen sind über eine gemeinsame Netzwerk (onehubnetwork
) verbunden, durch das Services in diesen Umgebungen untereinander kommunizieren können. Mit dieser Trennung müssen wir nur einen Teil der Services neu starten, wenn es Änderungen gibt, was unser Entwicklungstempo beschleunigt.
Zurück zu unserer Setup: Fügen wir in unserem db-docker-compose.yml
die folgenden Services hinzu:
services
...
otel-collector
networks
onehubnetwork
image otel/opentelemetry-collector-contrib0.105.0
command"--config=/etc/otel-collector.yaml"
volumes
./configs/otel-collector.yaml:/etc/otel-collector.yaml
environment
POSTGRES_DB $ POSTGRES_DB
POSTGRES_USER $ POSTGRES_USER
POSTGRES_PASSWORD $ POSTGRES_PASSWORD
jaeger
networks
onehubnetwork
image jaegertracing/all-in-one1.59
container_name jaeger
environment
QUERY_BASE_PATH'/jaeger'
COLLECTOR_OTLP_GRPC_HOST_PORT'0.0.0.0:4317'
COLLECTOR_OTLP_HTTP_HOST_PORT'0.0.0.0:4318'
COLLECTOR_OTLP_ENABLEDtrue
prometheus
networks
onehubnetwork
image prom/prometheus v2.53.1
command
'--config.file=/etc/prometheus/prometheus.yml'
'--web.external-url=/prometheus/'
'--web.route-prefix=/prometheus/'
volumes
./configs/prometheus.yaml:/etc/prometheus/prometheus.yml
ports
9090:9090
Das ist relativ einfach, es setzt zwei Services in unserer Docker-Umgebung auf:
otel-collector
: Der Sink für alle Signale (Metriken/Logs/Spuren), die von den einzelnen überwachten Diensten gesendet werden (wir werden diese Liste im Verlauf der Zeit erweitern), verwendet er die Standard-OTel-Docker-Image zusammen mit unserer benutzerdefinierten OTel-Konfiguration (unten) und beschreibt verschiedene Observabilitätspipelines (d.h., wie Signale empfangen, verarbeitet und auf verschiedene Weise exportiert werden sollen).jaeger
: Unser Jaeger-Instanz, der Spuren empfangen und speichern wird (exportiert von demotel-collector
), verwaltet sowohl den Speicher als auch das Dashboard (UI), das wir auf dem HTTP-Pfadpräfix/jaeger
exportieren und über Nginx zugänglich machen werden.prometheus
: Obwohl es für diesen Artikel nicht erforderlich ist, exportieren wir auch Metriken, damit sie von Prometheus abgefragt werden können. Wir werden dies hier nicht detailliert behandeln.
Einige Dinge, die Sie beachten sollten:
- Obwohl es für diesen Artikel nicht erforderlich ist, geben wir die
POSTGRES
-Verbindungsdetails (als Umgebungsvariablen) an denotel-collector
weiter, sodass er Postgres-Health-Metriken abrufen kann. - Jaeger (ab Version 1.35) unterstützt OTLP natürlich.
- Die Schönheit von OTLP besteht darin, dass OTel-Sammlern eine Kette aufgebaut werden kann, um ein Netz von OTel-Sammlern/Verarbeitern/Weiterleitern/Routern usw. zu bilden.
- OTLP kann entweder über einen GRPC- oder einen HTTP-Endpunkt bedient werden (auf den Ports 4317 bzw. 4318).
- Standardmäßig werden die OTLP-Dienste auf
localhost:4317/4318
gestartet. Dies ist in Ordnung, wenn Jaeger auf demselben Rechner/Pod ausgeführt wird, auf dem die gelesenen Dienste laufen. Allerdings, da Jaeger auf einem separaten Pod läuft, müssen sie auf externe Adressen (0.0.0.0) gebunden werden. Dies war in der Dokumentation nicht klar und führte zu erheblichen zusammenbrechenden Versuchen. COLLECTOR_OTLP_ENABLED: true
ist nun die Voreinstellung und muss nicht explizit angegeben werden.
OTel-Konfiguration
OTel muss auch mit bestimmten Empfängern, Verarbeitern und Exportierern konfiguriert werden. Wir werden das in configs/otel-collector.yaml tun.
Empfänger hinzufügen
Wir müssen dem OTel-Samler sagen, welche Empfänger aktiviert werden sollen. Dies wird in der Abschnitt receivers
angegeben:
receivers
otlp
protocols
http
endpoint 0.0.0.04318
grpc
endpoint 0.0.0.04317
postgresql
endpoint postgres5432
transport tcp
username $ POSTGRES_USER
password $ POSTGRES_PASSWORD
databases
$ POSTGRES_DB
collection_interval 10s
tls
insecuretrue
Das aktiviert einen OTLP-Empfänger auf den Ports 4317 und 4318 (grpc
, http
jeweils). Es gibt viele Arten von Empfängern, die gestartet werden können. Als Beispiel haben wir auch einen „postgresql
“-Empfänger hinzugefügt, der aktiv Postgres nach Metriken durchsucht ( obwohl das für diesen Beitrag nicht relevant ist). Empfänger können auch pull-basierend oder push-basierend sein. Pull-basierte Empfänger periodisch bestimmte Ziele (z.B., postgres
) durchsuchen, während push-basierte Empfänger auf Empfang warten und Metriken/Protokolle/Spuren von Anwendungen mit dem OTel-Client-SDK empfangen.
Das ist es. Jetzt ist unser Sammler bereit, die passenden Metriken zu empfangen (oder zu durchsuchen).
Füge Prozessoren hinzu
Prozessoren in OTel sind eine Methode, um empfangene Signale vor ihrer Exportierung zu transformieren, zu mapen, zu gruppieren, zu filtern und/oder zu verfeinern. Zum Beispiel können Prozessoren Metriken abtasten, Protokolle filtern oder sogar Signale für Effizienz gruppieren. Standardmäßig werden keine Prozessoren hinzugefügt (was den Sammler zu einem Durchlauf-Modus macht). Wir werden dies für jetzt ignorieren.
Füge Exporteure hinzu
Es ist nun Zeit, zu bestimmen, wo wir die Signale exportieren möchten: auf Backends, die jeweils am besten für die entsprechenden Signale geeignet sind. Genau wie Empfänger können auch Exporteure pull- oder pushbasiert sein. Push-basierte Exporteure werden verwendet, um Signale an einen anderen Empfänger weiterzugeben, der im Push-Modus agiert. Diese sind ausgehend. Pull-basierte Exporteure bieten Endpunkte an, die von anderen pull-basierten Empfängern (z.B. prometheus
) abgerufen werden können. Wir werden einen Exporteur für jeden Typ hinzufügen: einen für Tracing und einen, von dem Prometheus abgreifen kann ( obwohl Prometheus nicht das Thema dieses Beitrags ist):
exporters
otlp/jaeger
endpoint jaeger4317
tls
insecuretrue
prometheus
endpoint 0.0.0.09090
namespace onehub
debug
Hier haben wir einen Exporteur für Jaeger, der den OTLP-Kollektor ausführt, wie an otlp/jaeger
erkennbar ist. Dieser Exporteur wird regelmäßig Spuren an Jaeger senden. Wir haben auch einen „scraper“-Endpunkt auf Port 9090 hinzugefügt, von dem Prometheus regelmäßig abgreifen wird.
Der „debug“-Exporteur wird einfach dazu verwendet, Signale in die Standard-Ausgabestrom/-Fehlerströme zu ablegen.
Pipelines definieren
Die Empfänger-, Prozessor- und Exporteur-Abschnitte definieren einfach die Module, die vom Kollektor aktiviert werden. Sie werden noch nicht aufgerufen. Um sie tatsächlich aufzurufen/aktivieren, müssen sie als „Pipelines“ bezeichnet werden. Pipelines definieren, wie Signale durch den Kollektor fließen und verarbeitet werden. Unsere Pipeline-Definitionen (in der services
-Section) werden dies verdeutlichen:
service
extensions health_check
pipelines
traces
receivers otlp
processors
exporters otlp/jaeger debug
metrics
receivers otlp
processors
exporters prometheus debug
Hier definieren wir zwei Pipelines. Note, wie die Pipelines ähnlich sind, aber zwei unterschiedliche Export- Modi (Jaeger und Prometheus) zulassen. Jetzt sehen wir die Kraft von OTel und der Pipeline-Erstellung innerhalb von ihr.
Spuren
:
- Empfangene Signale von Client-SDKs
- Keine Verarbeitung
- Exportiere Spuren in die Konsole und Jaeger
metrics
:
- Empfange Signale von Client-SDKs
- Keine Verarbeitung
- Exportiere Metriken in die Konsole und Prometheus (indem ein Endpunkt für diesen geöffnet wird, um ihn abzuschneiden).
Exposere Dashboards über Nginx
Jaeger stellt ein Dashboard für die Visualisierung von Spuren-Daten über all unseren Anfragen bereit. Dies kann in einem Browser angezeigt werden, indem Sie die folgende Einstellung in unserem Nginx-Konfiguration aktivieren. Erneut ist dies nicht das Thema dieses Beitrags – wir stellen das Prometheus-UI auch über Nginx an der HTTP-Pfadpräfix-Präfix /prometheus
sichtbar.
...
location ~ ^/jaeger
if ($request_method = OPTIONS ) return 200;
proxy_pass http://jaeger:16686; # Note that JaegerUI starts on port 16686 by default
proxy_pass_request_headers on;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header Host-With-Port $http_host;
proxy_set_header Connection '';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-HTTPS on;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Prefix /;
proxy_http_version 1.1;
chunked_transfer_encoding off;
location ~ ^/prometheus
if ($request_method = OPTIONS ) return 200;
proxy_pass http://prometheus:9090;
proxy_pass_request_headers on;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header Host-With-Port $http_host;
proxy_set_header Connection '';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-HTTPS on;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Prefix /;
proxy_http_version 1.1;
chunked_transfer_encoding off;
...
Visualisierung von Spuren in Jaeger
Die Jaeger-UI ist recht umfassend und bietet verschiedene Features, die Sie erkunden können. Navigieren Sie zum Jaeger-UI im Browser. Sie werden eine umfassende Oberfläche für die Suche und Analyse von Spuren sehen. Erkunden Sie die Hauptbereiche, einschließlich der Suchleiste und der Spurliste. Sie können verschiedene Spuren durch Suchkriterien suchen und nach Dienst, Zeitdauer, Komponenten usw. filtern.
Analysieren Sie die Spur zeitlinien in den verschiedenen Anfragen, um die Abfolge von Operationen zu verstehen. Jeder Span repräsentiert eine Arbeits Einheit, zeigt Start- und Endzeiten, Dauer und zugehörige Metadaten an. Diese detaillierte Ansicht ist sehr hilfreich, um Leistungsengpässe und Fehler innerhalb der Spur zu identifizieren.
Integrieren des Client-SDKs
Bisher haben wir unsere Systeme eingerichtet, um Signale可视化, Verarbeitung等等. Allerdings werden unsere Dienste noch nicht aktualisiert, um Signale an OTel auszusenden. Hier werden wir uns mit dem (Golang-)Client-SDK in verschiedenen Teilen unseres Codes integrieren. Die SDK-Dokumentation ist eine fantastische Quelle, um sich zunächst mit einigen der Konzepte vertraut zu machen.
Die wichtigsten Konzepte, mit denen wir uns befassen werden, sind unten beschrieben.
Ressourcen
Ressourcen sind die Entität, die das Signal erzeugt. In unserem Fall ist der Bereich der Ressource das Binär, das die Dienste hostet. Derzeit haben wir eine einzige Ressource für die gesamte Onehub-Dienst, aber dies könnte später geteilt werden.
Dies ist in cmd/backend/obs.go definiert. Beachten Sie, dass das Client-SDK uns nicht dazu gefordert hat, die Ressourcendefinition ausführlich zu behandeln. Der Standard-Helper (sdktrace.WithResource
) lässt uns eine Ressourcendefinition erzeugen, indem er die am wirkungsvollsten ist (wie Prozessname, Podname usw.) zur Laufzeit abliest.
Wir mussten nur eine Sache überschreiben: die OTEL_RESOURCE_ATTRIBUTES: service.name=onehub.backend,service.version=0.0.1
Umgebungsvariable für den onehub
-Dienst in docker-compose.yml.
Kontextpropagation
Kontextpropagation ist ein sehr wichtiges Thema in der Beobachtbarkeit. Die verschiedenen Pfeiler sind exponentiell leistungsfähiger, wenn wir Signale aus jedem der Pfeiler korrelieren können, um Probleme mit unserem System zu erkennen. Denken Sie an Kontexten als zusätzliche Daten, die mit Signalen verknüpft werden können: d.h., sie können auf eine Art und Weise zusammengeführt werden, um die verschiedenen Signale einer bestimmten Gruppe (z.B. einer Anfrage) zuzuordnen.
Anbieter/Exporteure
Für jedes Signal bietet OTel einen Anbieter-Interface (z.B., TracerProvider
für die Exportierung von Spans/Spuren, MeterProvider
für die Exportierung von Metriken, LoggerProvider
für den Export von Protokollen und so weiter). Für jedes dieser Interfaces kann es mehrere Implementierungen geben, z.B. einen Debug-Anbieter für den Sendung an stdout/err-Streams, einen OTel-Anbieter für den Export an ein anderes OTel-Endpunkt (in einer Kette) oder sogar direkt über eine Vielzahl von Exportoren. Allerdings möchten wir in unserem Fall die Wahl jedes Dienstleisters aus unseren Dienstleistungen verschieben und stattdessen alle Signale an den OTel-Kollektor senden, der in unserem Umfeld läuft.
Um dies abzuschöpfen, werden wir einen „OTELSetup
„-Typ erstellen, der sich der verschiedenen Anbieter erinnern kann, die wir möglicherweise verwenden oder austauschen möchten. In cmd/backend/obs.go, haben wir:
type OTELSetup[C any] struct {
ctx context.Context
shutdownFuncs []ShutdownFunc
Resource *resource.Resource
Context C
SetupPropagator func(o *OTELSetup[C])
SetupTracerProvider func(o *OTELSetup[C]) (trace.TracerProvider, ShutdownFunc, error)
SetupMeterProvider func(o *OTELSetup[C]) (otelmetric.MeterProvider, ShutdownFunc, error)
SetupLogger func(o *OTELSetup[C]) (logr.Logger, ShutdownFunc, error)
SetupLoggerProvider func(o *OTELSetup[C]) (*log.LoggerProvider, ShutdownFunc, error)
}
Dies ist ein einfacher Wrapper, der die von dem OTel SDK benötigten gängigen Aspekte verwaltet. Hier haben wir Provider (Logger, Tracer und Metrik) sowie Methoden, um Kontext zu bereitstellen (für die Spuren). Der von allen Providern verwendete Oberflächenressourcen ist hier auch definiert. Herunterfahrensfunktionen sind interessant. Diese Funktionen werden von den Providern aufgerufen, wenn der zugrunde liegende Exporter beendet wurde (einladend oder aufgrund eines Exits). Der Wrapper selbst nimmt eine generische Datenstruktur auf, sodass spezifische Instanzen dieser Setup ihre eigenen benutzerdefinierten Daten verwenden können.
Das Repository enthält zwei Implementierungen davon:
- Signale für die Protokollierung an Standardausgaben/Fehler – cmd/backend/stdout.go
- Signale für die Exportierung an einen anderen OTel-Kolector – cmd/backend/otelcol.go
Wir werden in unserer App die zweite Implementierung verwenden. Wir werden nicht in die Details der spezifischen Implementierungen gehen, da sie aus den Beispielen im SDK stammen und lediglich mit geringen Korrekturen und Refaktoring bearbeitet wurden. Insbesondere sollten Sie sich an das otel-collector-Beispiel für die Inspiration erinnern.
Initialisieren Sie die OTelProvider.
Der Grund, warum wir den Collector in unseren Diensten aktivieren, besteht darin, dass an allen „Eingangs“punkten ein von OTel verwandter „Kontext“ gestartet wird. Wenn dieser Kontext beim Start erstellt wird, wird er an alle hier aufgerufenen Ziele weitergeleitet, die anschließend fortgeführt werden (solange wir das Richtige tun).
Nehmen wir beispielsweise den einfachen API-Aufruf ListTopics
(api/vi/topics
), der Pfad des unseren Anfrages geht und zurück:
[ Browser ] ---> [ Nginx ] ---> [ gRPC Gateway ] ---> [ gRPC Service ] ---> [ Database ]
In unserem Fall sind die Eingangs-punkte dort, wo der gRPC Gateway eine API-Anfrage von Nginx empfängt (wir könnten die Spuren von dort beginnen, wenn die HTTP-Anfrage auf Nginx zuakzeptiert wird, um Verzögerungen auf Nginx zu betonen, aber wir werden das für eine Weile aufhalten).
Was benötigt wird:
- Der gRPC Gateway erhält eine Anfrage.
- Es erstellt eine „benutzerdefinierte“ OTel-spezifische
context.Context
-Instanz. - Erstellt eine benutzerdefinierte Verbindung zum entsprechenden gRPC-Dienst (z.B.
TopicService
), und gibt diesen Kontext anstelle des Standard-Kontexts weiter. - Der jeweilige Dienst verwendet dann diesen Kontext, wenn er Spuren aussendet.
Lassen Sie uns dies Schritt für Schritt durchgehen.
Initialisieren und Vorbereiten Sie das OTel-SDK für die Verwendung
In main.go, initialisieren wir zunächst die Collector-Verbindung:
func main() {
flag.Parse()
// Handle SIGINT (CTRL+C) gracefully.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
collectorAddr := cmdutils.GetEnvOrDefault("OTEL_COLLECTOR_ADDR", "otel-collector:4317")
conn, err := grpc.NewClient(collectorAddr,
// Note the use of insecure transport here. TLS is recommended in production.
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Println("failed to create gRPC connection to collector: %w", err)
return
}
setup := NewOTELSetupWithCollector(conn)
err = setup.Setup(ctx)
if err != nil {
log.Println("error setting up otel: ", err)
}
defer func() {
err = setup.Shutdown(context.Background())
}()
ohdb := OpenOHDB()
srvErr := make(chan error, 2)
httpSrvChan := make(chan bool)
grpcSrvChan := make(chan bool)
go startGRPCServer(*addr, ohdb, srvErr, httpSrvChan)
go startGatewayServer(ctx, *gw_addr, *addr, srvErr, grpcSrvChan)
// Wait for interruption.
select {
case err = <-srvErr:
log.Println("Server error: ", err)
// Error when starting HTTP server or GRPC server
return
case <-ctx.Done():
// Wait for first CTRL+C.
// Stop receiving signal notifications as soon as possible.
stop()
}
// When Shutdown is called, ListenAndServe immediately returns ErrServerClosed.
httpSrvChan <- true
grpcSrvChan <- true
...
}
- Zeilen 8-16: Wir stellen eine Verbindung zum
otel-collector
, der in unserer Docker-Umgebung läuft, her. - Zeilen 17-21: Wir setzen anschließend das OTel-Setup mit Tracer- und Metrik-Anbietern ein, sodass unser Collector fortan alle Spuren und Metriken (erinnern Sie sich, haben wir früher in der OTel-Konfiguration Empfänger definiert) senden kann.
- Zeilen 23-25: Wir setzen Abschlussroutinen ein, um beim Herunterfahren Verbindungen/Anbieter von OTel etc. aufzulösen.
- Zeile 27: Wir stellen wie gehabt unsere Datenbank und Verbindungen her.
- Zeile 29 +: Früher starteten wir lediglich die GRPC- und Gateway-Dienste im Hintergrund und das war es. Wir konnten uns nicht sonderlich anschließen, was mit ihren Rückgaben oder Exit-Statuses passierte. Um ein widerstandsfähigeres System zu erhalten, ist es wichtig, besseren Insights in den Lebenszyklus der von uns gestarteten Dienste zu haben. Daher geben wir jetzt den „Callback“-Kanal für jeden der Dienste weiter, die wir starten. Wenn die Server beenden, ruft die jeweilige Methode auf diese Kanäle auf, die ihnen verfügbar sind, um anzuzeigen, dass sie graziös beendet haben. Unser gesamtes Binärprogramm wird beendet, wenn entweder eines dieser Dienste beendet wird.
Als Beispiel sehen wir, wie unser Gateway-Dienst diesen Kanal nutzt.
Anstatt das HTTP-Server (für das grpc-gateway) wie folgt zu starten:
1 http.ListenAndServe(gw_addr, mux)
Starten wir ihn jetzt:
server := &http.Server{
Addr: gw_addr,
BaseContext: func(_ net.Listener) context.Context { return ctx },
Handler: otelhttp.NewHandler(mux, "gateway", otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
return fmt.Sprintf("%s %s %s", operation, r.Method, r.URL.Path)
})),
}
go func() {
<-stopChan
if err := server.Shutdown(context.Background()); err != nil {
log.Fatalln(err)
}
}()
srvErr <- server.ListenAndServe()
Auf Zeilen 9-14 kann man merken, dass der Serverabbau in einer separaten Go-Routine überwacht wird und auf Zeile 15, dass bei einem Fehler beim Serverabbau dieser auf dem als Argument für diese Methode übergebenen „Benachrichtigungskanal“ zurückgeschickt wird.
Nun haben verschiedene Teile unserer Dienste Zugriff auf eine „aktive“ OTLP-Verbindung, die verwendet werden kann, wann immer Signale gesendet werden sollen.
OTel Middleware für gRPC Gateway
Oben ist der http.Server
-Instanz verwendet, um den gRPC Gateway zu starten, ein benutzerdefiniertes Handler: der http.Handler
im OTel HTTP-Paket. Dieser Handler nimmt eine bestehende http.Handler
-Instanz auf, dekorieren Sie es mit dem OTel-Kontext und stellen sicher, dass seine Weitergabe an jeden anderen downstream, der aufgerufen wird.
server := &http.Server{
Addr: gw_addr,
BaseContext: func(_ net.Listener) context.Context { return ctx },
Handler: otelhttp.NewHandler(mux, "gateway", otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
return fmt.Sprintf("%s %s %s", operation, r.Method, r.URL.Path)
})),
}
Unser HTTP-Handler ist einfach:
- Zeile 4: Wir erzeugen einen neuen OTel-spezifischen Wrapper, um HTTP-Anfragen zu behandeln.
- Zeile 5: Wir setzen die
SpanFormatter
-Option, sodass die Spuren durch die Methode und die HTTP-Anfrage- Pfade eindeutig identifiziert werden können. Ohne diesenSpanNameFormatter
, wäre der Standard-„Name“ für unsere Spuren am Gateway nur"gateway"
, was dazu führt, dass alle Spuren wie folgt aussehen:
OTel-basierte Verbindung Wrapper für gRPC-Aufrufe
Standardmäßig erstellt die gRPC Gateway-Bibliothek einen „einfachen“ Kontext, wenn Verbindungen zum unterliegenden GRPC-Dienst erstellt/verwaltet werden. Schließlich kennt das Gateway nichts von OTel. In diesem Modus werden eine Verbindung (von dem Benutzer/Browser) zum gRPC Gateway und die Verbindung vom Gateway zum gRPC-Dienst als zwei verschiedene Spuren behandelt.
Es ist also wichtig, die Verantwortung für die Erstellung von gRPC-Verbindungen vom Gateway wegzunehmen und stattdessen einen bereits OTel-fähigen Verbindung bereitstellen. Dies tun wir nun.
Vor der OTel-Integration registrierten wir einen Gateway-Handler für unsere gRPCs mit:
ctx := context.Background()
mux := runtime.NewServeMux() // Not showing the interceptors
opts := []grpc.DialOption{grpc.WithInsecure()}
// grpc_addr = ":9090"
v1.RegisterTopicServiceHandlerFromEndpoint(ctx, mux, grpc_addr, opts)
v1.RegisterMessageServiceHandlerFromEndpoint(ctx, mux, grpc_addr, opts)
// And other servers
Jetzt ist es einfach, eine andere Verbindung zu übergeben:
mux := // Creat the mux runtime as usual
// Use the OpenTelemetry gRPC client interceptor for tracing
trclient := grpc.WithStatsHandler(otelgrpc.NewClientHandler())
conn, err := grpc.NewClient(grpc_addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
trclient)
if err != nil {
srvErr <- err
}
err = v1.RegisterTopicServiceHandler(ctx, mux, conn)
if err != nil {
srvErr <- err
}
// And the Message and User server too...
Was wir gemacht haben, ist zunächst ein client
(Zeile 6) erstellt haben, der als Verbindungs Fabrik für unser gRPC-Server agiert. Der Client ist recht einfach. Es wird nur der gRPC ClientHandler
(otelgrpc.NewClientHandler
) verwendet, um die Verbindung zu erzeugen. Dies stellt sicher, dass der Kontext im aktuellen Spuren, der mit einem neuen HTTP-Request begonnen wurde, nun über diesen Handler zum gRPC-Server weitergeleitet wird.
Das ist es. Jetzt sollten wir die neuen Anfrage am Gateway und die Gateway->gRPC-Anfrage in einer einzigen konsolidierten Spur anstatt als zwei verschiedene Spuren sehen.
Anfang und Ende von Spans
Wir sind fast fertig. Bisher:
- Wir haben den OTel-Collector und Jaeger aktiviert, um Spuren (span) Daten zu empfangen und zu speichern (in docker-compose).
- Wir haben eine grundlegende OTel-Collector eingerichtet (als separates Pod läuft), der als unser „Anbieter“ von Tracern, Metriken und Protokollen fungiert (d.h., OTel-Integration unserer Anwendung verwendet diesen Endpunkt, um alle Signale abzuspeichern).
- Wir haben den Gateway-HTTP-Handler verwund, um OTel-fähig zu werden, sodass Spuren und ihre Kontexte erstellt und weitergeleitet werden.
- Wir haben die (gRPC)-Client im Gateway überschrieben, sodass er jetzt den OTel-Kontext von unserer OTel-Setup verwendet, anstatt einen Standard-Kontext zu verwenden.
- Wir haben globale Tracer/Messer/Logger-Instanzen erstellt, sodass wir tatsächliche Signale mit ihnen senden können.
Nun müssen wir für alle „interessanten“ Stellen in unserem Code Spans erzeugen. Nehmen wir den ListTopics
-Methode beispielsweise (in services/topics.go):
func (s *TopicService) ListTopics(ctx context.Context, req *protos.ListTopicsRequest) (resp *protos.ListTopicsResponse, err error) {
results, err := s.DB.ListTopics(ctx, "", 100)
if err != nil {
return nil, err
}
resp = &protos.ListTopicsResponse{Topics: gfn.Map(results, TopicToProto)}
return
}
Wir rufen die Datenbank auf, um Themen abzurufen und sie zurückzugeben.Ähnlich wie die Datenbankzugriffsmethode (in datastore/topicds.go):
func (tdb *OneHubDB) ListTopics(ctx context.Context, pageKey string, pageSize int) (out []*Topic, err error) {
query := tdb.storage.Model(&Topic{}).Order("name asc")
if pageKey != "" {
count := 0
query = query.Offset(count)
}
if pageSize <= 0 || pageSize > tdb.MaxPageSize {
pageSize = tdb.MaxPageSize
}
query = query.Limit(pageSize)
err = query.Find(&out).Error
return out, err
}
Hier wären wir hauptsächlich interessiert, wie viel Zeit in jedem dieser Methoden verbracht wird. Wir erzeugen einfach Spans in jedem dieser und das ist das. Unsere Zusätze zu den Service- und Datenbankmethoden sind (entsprechend):
services/topics.go
:
func (s *TopicService) ListTopics(ctx context.Context, req *protos.ListTopicsRequest) (resp *protos.ListTopicsResponse, err error) {
ctx, span := Tracer.Start(ctx, "ListTopics")
defer span.End()
... rest of the code to query the DB and return a proto response
}
datastore/topicds.go
:
func (tdb *OneHubDB) ListTopics(ctx context.Context, pageKey string, pageSize int) (out []*Topic, err error) {
_, span := Tracer.Start(ctx, "db.ListTopics")
defer span.End()
... rest of the code to fetch rows from the DB and return them
}
Der allgemeine Muster ist:
1. Erzeugen eines Spans:
ctx, span := Tracer.Start(ctx, "<span name>")
Hier wird dem gegebenen Kontext (ctx
) eine „Verpackung“ verwendet und ein neuer Kontext zurückgegeben. Wir könnten (und sollten) diesen neuen verpackten Kontext weiterreichen, wenn wir die datenbankseitige ListTopics
-Methode aufrufen.
2. Beenden des Spans:
defer span.End()
Das Beenden eines Spans (wann immer die Methode zurückgeht) stellt sicher, dass die richtigen Zeitpunkte/Codes usw. aufgezeichnet werden. Wir könnten auch andere Dinge wie Tags und Status-Angaben hinzufügen, falls notwendig, um mehr Informationen zu integrieren, die der Debugging-Hilfe dienen.
Das ist es. Sie können Ihre wunderschönen Spuren in Jaeger sehen und erhalten immer mehr Einblicke in die Leistung Ihrer Anfragen, von Ende zu Ende!
Schlussfolgerung
Wir haben in diesem Beitrag viel behandelt und es ist kaum angefangen, alle Details hinter OTel und dem Tracing aufzuräumen. Anstatt diesen (bereits umfangreichen) Beitrag weiter zu belasten, werden wir in künftigen Beiträgen neue Konzepte und detaillierte Informationen vorstellen. Momentan probieren Sie das in Ihren eigenen Diensten aus und spielen mit anderen Exportern und Empfängern im otel-contrib Repository.
Source:
https://dzone.com/articles/tracing-with-opentelemetry-and-jaeger