Tracciamento con OpenTelemetry e Jaeger

Tracciamento, un componente chiave, segue le richieste attraverso sistemi complessi. questa visibilità rivela i bottleneck e gli errori, permettendo risoluzioni più veloci. In un articolo precedente della nostra serie sui web service Go, abbiamo esplorato l’importanza dell’osservabilità. Oggi, ci concentriamo sul tracciamento. Jaeger raccoglie, memorizza e visualizza i tracciati da sistemi distribuiti. Fornisce una visione d’insieme fondamentale delle correnti di richiesta tra i servizi. Integrando Jaeger con OpenTelemetry, i sviluppatori possono unificare il loro approcio al tracciamento, garantendo visibilità coerente e completa. Questa integrazione semplifica la diagnosi di problemi di performance e migliora la affidabilità del sistema. In questo articolo, configureremo Jaeger, lo integriremo con OpenTelemetry nel nostro applicativo e esploreremo la visualizzazione dei tracciati per avere una migliore insight.

Motivazione

Cosa stiamo lavorando per ottenere è un dashboard Jaeger che assomiglia a questo:

Quando ci spostiamo in varie parti dell’app ( sul frontend di Onehub), i tracciati delle varie richieste vengono raccolti (da quando colpiscono il grpc-gateway) con una panoramica di ciascuno di essi. Anche qui possiamo approfondire uno dei tracciati per una visione più dettagliata. Guardate il primo POST richiesta (per creare/inviare un messaggio in un argomento):

Qui vediamo tutti i componenti che la Create richiesta tocca insieme ai loro tempi d’ingresso/uscita e ai tempi trascorsi in e fuori dai metodi. Assolutamente potente.

Inizio

TL;DR: Per vedere questo in azione e validate il resto del blog:

  1. La fonte di questo è nella branch PART11_TRACING.
  2. Costruisci tutte le cose necessarie (una volta che hai fatto il checkout della branch):
make build

  1. Abbiamo diviso il docker-compose in due parti (questo sarà spiegato più avanti), quindi assicurati di avere due finestre in esecuzione.
  • Terminale 1: make updb dblogs
  • Terminale 2: make up logs
  1. Naviga su localhost:7080, e via da li.

Panoramica alto livello 

Il nostro sistema attualmente è:

Con l’instrumentazione con OpenTelemetry, il nostro sistema evolverà in:

Come abbiamo notato prima, è piuttosto oneroso per ogni servizio utilizzare client separati per inviare ai specifici fornitori. Invece, con un collector OTel che gira separatamente, possiamo assicurarci che tutti i servizi (interessati) possano inviare semplicemente metriche/logs/tracce a questo collector, il quale può poi esportare ai vari backend richiesti — in questo caso, Jaeger per le tracce.

Iniziamo.

Impostazione del Collector OTel

Il primo passo è aggiungere il nostro collector OTel in esecuzione nell’ambiente Docker insieme a Jaeger in modo che siano accessibili.

Nota: Abbiamo diviso la nostra originale configurazione integrale docker-compose.yml in due parti:

  1. db-docker-compose.yml: Contiene tutti i componenti relativi ai database e all’infra (non-applicazione) come database (Postgres, Typesense) e servizi di monitoraggio (collector OTel, Jaeger, Prometheus, ecc).
  2. docker-compose.yml: Contiene tutti i servizi correlati all’applicazione (Nginx, gRPC Gateway, dbsync, frontend, ecc.)

I due ambienti docker-compose sono collegati attraverso una rete condivisa (onehubnetwork) attraverso la quale i servizi in questi ambienti possono comunicare tra loro. Con questa separazione, solo è necessario riavviare un sottoinsieme di servizi in caso di modifiche, velocizzando così il nostro sviluppo.

Ritorniamo alla nostra configurazione: nel nostro db-docker-compose.yml, aggiungiamo i seguenti servizi:

YAML

 

Pretty simple, questo impostazioni due servizi nel nostro ambiente Docker:

  1. otel-collector: Il sink per tutti i segnali (metriche/logs/tracce) inviati dai vari servizi in monitoraggio (aggiungeremo sempre di più a questa lista nel tempo), utilizza l’immagine standard di OTel insieme alla nostra configurazione OTel personalizzata (più sotto) che descrive diversi pipeline di osservabilità (ovvero come i segnali devono essere ricevuti, processati e esportati in vari modi).
  2. jaeger: Il nostro istanza di Jaeger che riceverà e terrà tracce (esportate da otel-collector), questo ospiterà sia il storage che il dashboard (UI) che esporteremo sul prefisso HTTP /jaeger per essere accessibili tramite nginx.
  3. prometheus: Non essendo richiesto per questo articolo, esporteremo anche metriche in modo che possa essere scaricato da Prometheus. Non discuteremo questo in dettaglio in questo articolo.

Si noti:

  • Anche se non richiesto per questo articolo, passiamo i dettagli di connessione di POSTGRES (come variabili d’ambiente) al otel-collector così da poter estrarre le metriche di salute di Postgres.
  • Jaeger (da v1.35 in poi) supporta OTLP in modo nativo.
  • La bellezza di OTLP è che i collector di OTel possono essere concatenati, formando una rete di collector/processori/forwarder/router, ecc.
  • OTLP può essere servito attraverso un endpoint GRPC o HTTP (su porte 4317 e 4318 rispettivamente).
  • Per default, i servizi OTLP vengono avviati su localhost:4317/4318. Questo è ottimale se Jaeger è eseguito sullo stesso host/pod dove sono in esecuzione i servizi (monitorati). Tuttavia, poiché Jaeger è in esecuzione in un pod separato, devono essere collegati agli indirizzi esterni (0.0.0.0). Questo non era chiaro nella documentazione e ha causato una significativa perdita di capelli.
  • COLLECTOR_OTLP_ENABLED: true ora è il default e non deve essere specificato esplicitamente.

Configurazione di OTel

OTel anche deve essere configurato con specifici ricevitori, processori e esportatori. lo faremo in configs/otel-collector.yaml.

Aggiunta di Ricevitori

Dobbiamo informare il collector di OTel quali ricevitori devono essere attivati. Questo è specificato nella sezione receivers:

YAML

 

Questo attiva un ricevitore OTLP sui porti 4317 e 4318 (grpc, http rispettivamente). Esistono molti tipi di ricevitori che possono essere avviati. Come esempio, abbiamo anche aggiunto un ricevitore “postgresql” che scrape attivamente Postgres per le metriche (sebbene questo non sia relevante per questo post). I ricevitori possono anche essere basati su pull o push. I ricevitori basati su pull scansionano periodicamente degli obiettivi specifici (ad esempio postgres), mentre i ricevitori basati su push ascoltano e “ricevono” le metriche/log/tracce dalle applicazioni utilizzando il OTel client SDK.

Ecco qui. Ora il nostro collector è pronto a ricevere (o scansionare) le metriche appropriate.

Aggiungi Processori

I processori in OTel sono un modo per trasformare, mappare, battere, filtrare e/o arricchire i segnali ricevuti prima di esportarli. Per esempio, i processori possono campionare le metriche, filtrare i log o persino battere segnali per incrementare l’efficienza. Per default, non sono aggiunti nessun processore (rendendo il collector un pass-through). Ignoriamo questo per ora.

Aggiungi Esportatori

Ora è il momento di identificare dove vogliamo che i segnali siano esportati: backend che sono i migliori adatti ai segnali rispettivi. Come i ricevitori, anche gli esportatori possono essere basati su pull o push. Gli esportatori basati su push sono usati per emettere segnali ad un altro ricevitore che agisce in modalità push. Questi sono esterni. Gli esportatori basati su pull espongono endpoint che possono essere scaricati da altri ricevitori basati su pull (ad esempio prometheus). Aggiungeremo un esportatore di ogni tipo: uno per la tracciatura e uno per Prometheus da cui scaricare (anche se Prometheus non è l’argomento di questo post):

YAML

 

Qui abbiamo un esportatore per Jaeger che utilizza il collector OTLP, come indicato da otlp/jaeger. Questo esportatore pushirà regolarmente tracce a Jaeger. Stiamo anche aggiungendo un endpoint “scraper” sulla porta 9090 che Prometheus scaricherà regolarmente da.

L’esportatore “debug” viene semplicemente utilizzato per scaricare segnali sui flussi di output/errore standard.

Definire Pipeline

Le sezioni ricevitore, processore e esportatore definiscono semplicemente i moduli che verranno abilitati dal collector. Non vengono ancora richiamati. Per richiamarli/attivarli, devono essere citati come “pipeline”. Le pipeline definiscono come i segnali fluiscono e vengono processati dal collector. Le nostre definizioni di pipeline (nella sezione services) chiariscono questo:

YAML

 

Qui definiamo due pipeline. Notate quanto siano simili ma consentano due modalità di esportazione differenti (Jaeger e Prometheus). Ora vediamo il potere di OTel e nella creazione di pipeline all’interno di esso.

  1. traces:

  • Ricevere segnali dai client SDK
  • Nessuna elaborazione
  • Esportare tracce nella console e Jaeger
  1. metriche:
  • Ricevere segnali dall’SDK client
  • Nessuna elaborazione
  • Esportare metriche nella console e in Prometheus (attraverso l’esposizione di un endpoint per consentirgli di scaricare).

Esporre Dashboard tramite Nginx

Jaeger fornisce un dashboard per visualizzare i dati di tracciamento relativi a tutte le nostre richieste. Questo può essere visualizzato in un browser abilitando il seguente nel nostro configurazione Nginx. Di nuovo, nonostante non sia l’argomento di questo post – noi stiamo anche esponendo l’UI di Prometheus tramite nginx nella prefisso percorso HTTP /prometheus.

YAML

 

Visualizzare Tracce in Jaeger

L’UI di Jaeger è piuttosto completo e offre molte funzionalità che puoi esplorare. Navigare all’UI di Jaeger nel browser. Vedrai una interfaccia completa per la ricerca e l’analisi delle tracce. Prosegui e familiarizza te stesso con le sezioni principali, incluso la barra di ricerca e la lista delle tracce. Puoi cercare varie tracce attraverso i criteri di ricerca e filtrare per servizio, durate di tempo, componenti, ecc.

Analizzare le linee di tempo delle tracce nelle diverse richieste per capire la sequenza delle operazioni. Ogni span rappresenta un unità di lavoro, mostrando tempi di inizio e fine, durata e metadata correlati. Questa visualizzazione dettagliata è molto utile per identificare i bottleneck di prestazioni e gli errori all’interno della traccia.

Integrazione dell’SDK client

Fino a questo punto abbiamo configurato i nostri sistemi per visualizzare, consumare segnali, ecc. tuttavia i nostri servizi non sono ancora aggiornati per emettere i segnali a OTel. qui integrateremo con il client SDK (Golang) in varie parti del nostro codice. La documentazione dell’SDK è un ottimo punto di partenza per familiarizzare con alcuni concetti.

I concetti chiave con cui ci occuperemo sono descritti qui sotto.

Risorse

Risorse sono le entità che producono il segnale. Nel nostro caso, l’ambito della risorsa è il binario che ospita i servizi. Attualmente abbiamo una sola risorsa per l’intero servizio Onehub, ma questo potrebbe essere diviso in più parti in futuro.

Questo è definito in cmd/backend/obs.go. Notare che l’SDK cliente non ci ha richiesto di entrare nei dettagli della definizione della risorsa esplicitamente. L’helper standard (sdktrace.WithResource) ci permette di creare una definizione della risorsa inferendo le parti più utili (come nome del processo, nome del pod, ecc.) in runtime.

Ci serve solo sostituire una cosa: l’environment variable OTEL_RESOURCE_ATTRIBUTES: service.name=onehub.backend,service.version=0.0.1 per il servizio onehub in docker-compose.yml.

Context Propagation

La propagazione del contesto è un argomento molto importante nell’osservabilità. I vari pali sono esponenzialmente potenti quando possiamo correlare i segnali da ciascuno dei pali per identificare problemi nel nostro sistema. Pensate ai contesti come ai pezzetti di dati aggiuntivi che possono essere legati ai segnali: ad esempio, possono essere “uniti” in un modo unico per relazionare i vari segnali a un determinato gruppo (ad esempio una richiesta).

Provider/Esportatori

Per ognuno dei segnali, OTel fornisce un’interfaccia Provider (ad esempio, TracerProvider per l’esportazione degli span/tracce, MeterProvider per l’esportazione delle metriche, LoggerProvider per l’esportazione dei log, e così via). Per ognuna di queste interfacce, può esistere più implementazioni, ad esempio, un provider Debug per l’invio a stream stdout/err, un provider OTel per l’esportazione ad un altro punto di ingresso OTel (in una catena), o persino direttamente tramite una varietà di esportatori. Tuttavia, nel nostro caso, vogliamo rimandare la scelta di eventuali fornitori esterni ai nostri servizi e, invece, inviare tutti i segnali al collector OTel in esecuzione nel nostro ambiente.

Per astrarre questo, creeriamo un tipo “OTELSetup” che tenga traccia dei vari provider che potremmo voler utilizzare o sostituire. In cmd/backend/obs.go, abbiamo:

Go

 

Questo è un semplice wrapper che tiene traccia di aspetti comuni necessari dall’SDK OTel. qui abbiamo provider (Logger, Tracer, e Metric) nonché modi per fornire il contesto (per il tracciamento). Il Resource over-arching utilizzato da tutti i provider viene anche specificato qui. Le funzioni di arresto sono interessanti. Sono funzioni chiamate dai provider quando l’esportatore sottostante è terminato (in modo corretto o a causa di un’uscita). Il wrapper stesso richiede un generico così che i specifici istanziatori di questa Setup possano utilizzare i propri dati personalizzati.

Il repository contiene due implementazioni di questo:

Instanzieremo il secondo nella nostra app. Non entrerò nei dettagli delle implementazioni specifiche poiché sono state prese dagli esempi nell’SDK con piccole correzioni e riscritture. In particolare, guardate all’esempio otel-collector per ispirazione.

Inizializza i provider OTel.

L’essenza dell’abilitazione del collector nei nostri servizi è che una qualche forma di “contesto” relativo a OTel viene avviato in tutti i punti di “ingresso”. Se questo contesto è creato all’inizio, sarà inviato a tutti i destinazioni chiamate qui, che poi verranno propagate in seguito (provvedendo a fare la cosa giusta).

Prendendo l’API di chiamata semplice ListTopics (api/vi/topics), il nostro richiesta segue il seguente percorso e torna indietro:

[ Browser ] ---> [ Nginx ] ---> [ gRPC Gateway ] ---> [ gRPC Service ] ---> [ Database ]

Nel nostro caso, i punti di ingresso qui sono all’inizio quando il gRPC Gateway riceve una richiesta API da Nginx (potremmo iniziare a tracciare loro dal punto in cui la richiesta HTTP colpisce Nginx per evidenziare anche le latenze a Nginx, ma ciò ci farà attendere un po’).

Quello che serve è:

  • gRPC Gateway riceve una richiesta.
  • Crea una istanza ” personalizzata ” di context.Context specifica di OTel.
  • Crea una connessione personalizzata al servizio gRPC corrispondente (ad esempio, TopicService) passando questo contesto invece del default.
  • Il servizio corrispondente poi utilizza questo contesto quando emette le tracce.

Ciascuno di questi passi li consideriamo a fondo.

Inizializza e prepara l’SDK di OTel per l’uso

In main.go, prima di tutto inizializza la connessione al collector:

Go

 

  • 8-16 righe: Creiamo una connessione al otel-collector in esecuzione nel nostro ambiente Docker.
  • 17-21 righe: Poi configuriamo OTel con provider di tracciamento e metriche in modo che il nostro collector possa ora inviare tutte le tracce e le metriche (ricordate che precedentemente abbiamo definito i ricevitori nella configurazione OTel).
  • 23-25 righe: Impostiamo i finalizzatori per pulire le connessioni/provider OTel, ecc., durante l’ arresto.
  • 27 riga: Impostiamo il nostro DB e le connessioni come prima.
  • 29 riga e successive: Prima di tutto, avevamo semplicemente avviato i servizi GRPC e Gateway in background e così era. Non ci preoccupavamo molto dei loro stati di ritorno o di uscita. Per un sistema più robusto, è importante avere un migliore insight sull’intero ciclo di vita dei servizi che stiamo avviando. Quindi ora passiamo il canale “callback” per ciascuno dei servizi che stiamo avviando. Quando i server usciranno, i metodi rispettivi faranno un callback su questi canali disponibili loro per indicare che sono usciti in modo gradevole. Il nostro intero binario si uscirà quandouno di questi servizi uscirà.

Ad esempio, vediamo come il nostro servizio Gateway utilizza questo canale.

Invece di avviare il server HTTP (per il grpc-gateway) come:

1    http.ListenAndServe(gw_addr, mux)

Ora abbiamo:

Go

 

Attenzione alle 9-14 righe dove l’ arresto del server è monitorato in un goroutine separata e alla 15 riga dove, se c’è stato un errore quando il server è uscito, viene inviato indietro tramite il canale “notifica” che è stato passato come argomento a questo metodo.

Ora tutte le varie parti del nostro servizio hanno accesso a una “attiva” connessione OTLP da usare qualora ci sia la necessità di inviare segnali.

OTel Middleware per gRPC Gateway

Sopra, l’istanza di http.Server usata per avviare il gRPC Gateway utilizza un gestore personalizzato: il http.Handler presente nel pacchetto OTel HTTP. Questo gestore prende un’istanza esistente di http.Handler, la decora con il contesto OTel e garantisce la sua propagazione a qualsiasi altro downstream chiamato.

Go

 

Il nostro gestore HTTP è semplice:

  • Riga 4: Creiamo un nuovo wrapper OTel-specifico per gestire le richieste HTTP.
  • Riga 5: Impostiamo l’opzione SpanFormatter in modo da identificare univocamente le tracce per metodo e per percorsi di richiesta HTTP. Senza questo SpanNameFormatter, il nome predefinito per le nostre tracce all’interno del gateway sarebbe semplicemente "gateway", il che porterebbe tutte le tracce a sembrare così:

Inserimento Gateway di gRPC con OTel

Per default, la libreria gRPC Gateway crea un “semplice” contesto quando crea/gestisce le connessioni ai servizi GRPC sottostanti. Dopo tutto, il gateway non sa nulla di OTel. In questo modo, una connessione (dall’utente/browser) al gRPC Gateway e la connessione dal gateway al servizio gRPC saranno trattate come due tracce differenti.

Quindi è importante togliere dalla Gateway la responsabilità di creare le connessioni gRPC e invece fornire una connessione già consapevole di OTel. Lo faremo ora.

Prima dell’integrazione con OTel, registravamo un gestore Gateway per i nostri gRPC con:

Go

 

Ora passare una connessione diversa è semplice:

Go

 

Quello che abbiamo fatto è stato prima di creare un client (Line 6) che agisce come una factory di connessioni per il nostro server gRPC. Il client è piuttosto semplice. Viene utilizzato solo il gRPC ClientHandler (otelgrpc.NewClientHandler) per creare la connessione. Questo garantisce che il context del trace corrente che è iniziato su una nuova richiesta HTTP sia ora propagato verso il server gRPC tramite questo handler.

Ecco qui. Ora dovremmo iniziare a vedere la nuova richiesta al Gateway e la richiesta Gateway->gRPC in un solo trace consolidato invece di due tracce differenti.

Inizio e Fine Spans

Stiamo quasi a metà. Fino ad ora:

  • Abbiamo abilitato il collector OTel e Jaeger per ricevere e memorizzare i dati del trace (span) (in docker-compose).
  • Abbiamo impostato il collector OTel di base (in esecuzione come un pod separato) come nostro “fornitore” di tracciatori, misure e log (ovvero la nostra integrazione OTel avrebbe usato questo endpoint per depositare tutte le segnali).
  • Ammesse le mani HTTP del Gateway per essere dotate di OTel in modo da creare e propagare tracce e i loro contesti.
  • Sovrascritto il client (gRPC) nel gateway in modo che ora avvolge il contesto di OTel dalla nostra configurazione di OTel invece di utilizzare un contesto predefinito.
  • Abbiamo creato istanze globali di tracciatore/misura/logger in modo che possiamo inviare segnali reali usando loro.

Ora dobbiamo generare span per tutti i “posti interessanti” nel nostro codice. Prendiamo ad esempio il metodo ListTopics (in services/topics.go):

Go

 

Chiamiamo il database per recuperare i topic e li restituiamo. Similmente al metodo di accesso al database (in datastore/topicds.go):

Go

 

Qui ci interesserà principalmente quanto tempo viene speso in ognuno di questi metodi. Creiamo semplicemente span in ognuno di questi e abbiamo finito. Le nostre aggiunte ai metodi di servizio e datastore sono rispettivamente:

  • services/topics.go:
Go

 

  • datastore/topicds.go:
Go

 

Il modello generale è:

1. Creare un span:

ctx, span := Tracer.Start(ctx, "<span name>")

Qui, il contesto fornito (ctx) è “avvolto” e viene restituito un nuovo contesto. Possiamo (e dovremmo) passare questo nuovo contesto avvolto a ulteriori metodi. Facciamo esattamente questo quando chiamiamo il metodo datastore ListTopics.

2. Chiudere il span:

defer span.End()

Chiudere un span (ovunque il metodo restituisca) garantisce che vengano registrate le giuste tempi/codici di fine ecc. Possiamo anche fare altre cose come aggiungere tag e stati a questo se necessario, per portare più informazioni utili alla diagnostica.

Ecco questo. Potete vedere le vostre tracce meravigliose in Jaeger e ottenere sempre più insights sulla performance delle vostre richieste, da capo a fondo!

Conclusione

In questo post abbiamo approfondito molti aspetti eppure abbiamo solo toccato la superficie di tutti i dettagli dietro OTel e la tracciatura. Invece di sovraccaricare questo post (già abbastanza lungo), ne introduceremo nuovi concetti e dettagli complessi nei prossimi articoli. Per ora, provate a metterli in pratica nei vostri servizi e sperimentare con altri esportatori e ricevitori nel repository otel-contrib.

Source:
https://dzone.com/articles/tracing-with-opentelemetry-and-jaeger