Discutiamo una questione importante: come monitoriamo i nostri servizi in caso di problemi?
Da una parte, abbiamo Prometheus con gli alert e Kibana per i cruscotti e altre utili funzionalità. Sappiamo anche come raccogliere i log — lo stack ELK è la nostra soluzione di riferimento. Tuttavia, la semplice registrazione non è sempre sufficiente: non fornisce una visione completa del percorso di una richiesta attraverso l’intero ecosistema dei componenti.
Puoi trovare ulteriori informazioni su ELK qui.
Ma cosa succede se vogliamo visualizzare le richieste? E se dobbiamo correlare le richieste che viaggiano tra i sistemi? Questo vale sia per i microservizi che per i monoliti — non importa quanti servizi abbiamo; ciò che conta è come gestiamo la loro latenza.
Infatti, ogni richiesta utente potrebbe passare attraverso una catena completa di servizi indipendenti, database, code di messaggi e API esterne.
In un ambiente così complesso, diventa estremamente difficile individuare esattamente dove si verificano ritardi, identificare quale parte della catena agisce come collo di bottiglia delle prestazioni e trovare rapidamente la causa principale dei guasti quando si verificano.
Per affrontare efficacemente queste sfide, abbiamo bisogno di un sistema centralizzato e coerente per raccogliere dati di telemetria — tracce, metriche e log. È qui che OpenTelemetry e Jaeger vengono in nostro soccorso.
Diamo un’occhiata ai Fondamentali
Ci sono due termini principali che dobbiamo capire:
ID Traccia
Un ID Traccia è un identificatore di 16 byte, spesso rappresentato come una stringa esadecimale di 32 caratteri. Viene generato automaticamente all’inizio di una traccia e rimane lo stesso attraverso tutti i segmenti creati da una particolare richiesta. Questo rende facile vedere come una richiesta viaggia attraverso diversi servizi o componenti in un sistema.
ID Segmento
Ogni singola operazione all’interno di una traccia ottiene il proprio ID Segmento, che è tipicamente un valore di 64 bit generato casualmente. I segmenti condividono lo stesso ID Traccia, ma ognuno ha un ID Segmento unico, in modo da poter individuare esattamente quale parte del flusso di lavoro rappresenta ciascun segmento (come una query al database o una chiamata a un altro microservizio).
Come Sono Relazionati?
ID Traccia e ID Segmento si integrano reciprocamente.
Quando una richiesta viene avviata, viene generato un ID Traccia e passato a tutti i servizi coinvolti. Ciascun servizio, a sua volta, crea un segmento con un ID Segmento unico collegato all’ID Traccia, consentendo di visualizzare l’intero ciclo di vita della richiesta dall’inizio alla fine.
Ok, quindi perché non usare semplicemente Jaeger? Perché abbiamo bisogno di OpenTelemetry (OTEL) e di tutte le sue specifiche? È una ottima domanda! Esaminiamola passo dopo passo.
Scopri di più su Jaeger qui.
TL;DR
- Jaeger è un sistema per memorizzare e visualizzare tracce distribuite. Raccoglie, memorizza, cerca e visualizza i dati che mostrano come le richieste “viaggiano” attraverso i tuoi servizi.
- OpenTelemetry (OTEL) è uno standard (e un insieme di librerie) per raccogliere dati di telemetria (tracce, metriche, log) dalle tue applicazioni e infrastrutture. Non è legato a uno strumento di visualizzazione o backend singolo.
In poche parole:
- OTEL è come un “linguaggio universale” e un insieme di librerie per la raccolta di telemetria.
- Jaeger è un backend e un’interfaccia utente per visualizzare e analizzare tracce distribuite.
Perché Abbiamo Bisogno di OTEL se Abbiamo Già Jaeger?
1. Uno Standard Unico per la Raccolta
In passato, c’erano progetti come OpenTracing e OpenCensus. OpenTelemetry unifica questi approcci alla raccolta di metriche e tracce in uno standard universale.
2. Facile Integrazione
Scrivi il tuo codice in Go (o un altro linguaggio), aggiungi le librerie OTEL per l’autoinserimento di intercettori e span, e il gioco è fatto. Dopo, non importa dove vuoi inviare quei dati—Jaeger, Tempo, Zipkin, Datadog, un backend personalizzato—OpenTelemetry si occupa della parte tecnica. Devi solo sostituire l’esportatore.
3. Non Solo Tracce
OpenTelemetry gestisce tracce, ma si occupa anche di metriche e log. Ottieni un unico set di strumenti per tutte le tue esigenze di telemetria, non solo il tracciamento.
4. Jaeger Come Backend
Jaeger è una scelta eccellente se sei principalmente interessato alla visualizzazione del tracciamento distribuito. Ma non fornisce l’strumentazione cross-language di default. OpenTelemetry, d’altra parte, ti offre un modo standardizzato per raccogliere dati, e poi decidi dove inviarli (compreso Jaeger).
In pratica, spesso lavorano insieme:
La tua applicazione utilizza OpenTelemetry → comunica tramite protocollo OTLP → passa al Collettore OpenTelemetry (HTTP o grpc) → esporta a Jaeger per la visualizzazione.
Parte Tecnica
Design di Sistema (Un Po’)
Proviamo rapidamente a delineare un paio di servizi che faranno quanto segue:
- Servizio Acquisto – elabora un pagamento e lo registra in MongoDB
- CDC con Debezium – ascolta i cambiamenti nella tabella MongoDB e li invia a Kafka
- Processore di Acquisti – consuma il messaggio da Kafka e chiama il Servizio di Autenticazione per cercare l’
user_id
per la convalida - Servizio di Autenticazione – un semplice servizio utente
In sintesi:
- 3 servizi Go
- Kafka
- CDC (Debezium)
- MongoDB
Parte del Codice
Cominciamo con l’infrastruttura. Per collegare tutto in un unico sistema, creeremo un ampio file Docker Compose. Inizieremo configurando la telemetria.
Nota: Tutto il codice è disponibile tramite un link alla fine dell’articolo, compresa l’infrastruttura.
services
jaeger
image jaegertracing/all-in-one1.52
ports
"6831:6831/udp" # UDP port for the Jaeger agent
"16686:16686" # Web UI
"14268:14268" # HTTP port for spans
networks
internal
prometheus
image prom/prometheus latest
volumes
./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports
"9090:9090"
depends_on
kafka
jaeger
otel-collector
command
--config.file=/etc/prometheus/prometheus.yml
networks
internal
otel-collector
image otel/opentelemetry-collector-contrib0.91.0
command'--config=/etc/otel-collector.yaml'
ports
"4317:4317" # OTLP gRPC receiver
volumes
./otel-collector.yaml:/etc/otel-collector.yaml
depends_on
jaeger
networks
internal
Configureremo anche il raccoglitore, il componente che raccoglie telemetria.
Qui scegliamo gRPC per il trasferimento dati, il che significa che la comunicazione avverrà tramite HTTP/2:
receivers
# Add the OTLP receiver listening on port 4317.
otlp
protocols
grpc
endpoint"0.0.0.0:4317"
processors
batch
# https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/memorylimiterprocessor
memory_limiter
check_interval 1s
limit_percentage80
spike_limit_percentage15
extensions
health_check
exporters
otlp
endpoint"jaeger:4317"
tls
insecuretrue
prometheus
endpoint 0.0.0.09090
debug
verbosity detailed
service
extensions health_check
pipelines
traces
receivers otlp
processors memory_limiter batch
exporters otlp
metrics
receivers otlp
processors memory_limiter batch
exporters prometheus
Assicurati di adattare eventuali indirizzi come necessario, e hai completato la configurazione di base.
Sappiamo già che OpenTelemetry (OTEL) utilizza due concetti chiave — ID traccia e ID span — che aiutano a tracciare e monitorare le richieste nei sistemi distribuiti.
Implementazione del Codice
Ora, vediamo come far funzionare tutto nel tuo codice Go. Abbiamo bisogno degli import seguenti:
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
Quindi, aggiungiamo una funzione per inizializzare il nostro tracer in main()
quando l’applicazione si avvia:
func InitTracer(ctx context.Context) func() {
exp, err := otlptrace.New(
ctx,
otlptracegrpc.NewClient(
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
),
)
if err != nil {
log.Fatalf("failed to create OTLP trace exporter: %v", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceNameKey.String("auth-service"),
semconv.ServiceVersionKey.String("1.0.0"),
semconv.DeploymentEnvironmentKey.String("stg"),
),
)
if err != nil {
log.Fatalf("failed to create resource: %v", err)
}
tp := trace.NewTracerProvider(
trace.WithBatcher(exp),
trace.WithResource(res),
)
otel.SetTracerProvider(tp)
return func() {
err := tp.Shutdown(ctx)
if err != nil {
log.Printf("error shutting down tracer provider: %v", err)
}
}
}
Con il tracciamento impostato, dobbiamo solo inserire span nel codice per tracciare le chiamate. Ad esempio, se vogliamo misurare le chiamate al database (poiché di solito è il primo posto in cui cerchiamo problemi di prestazioni), possiamo scrivere qualcosa del genere:
tracer := otel.Tracer("auth-service")
ctx, span := tracer.Start(ctx, "GetUserInfo")
defer span.End()
tracedLogger := logging.AddTraceContextToLogger(ctx)
tracedLogger.Info("find user info",
zap.String("operation", "find user"),
zap.String("username", username),
)
user, err := s.userRepo.GetUserInfo(ctx, username)
if err != nil {
s.logger.Error(errNotFound)
span.RecordError(err)
span.SetStatus(otelCodes.Error, "Failed to fetch user info")
return nil, status.Errorf(grpcCodes.NotFound, errNotFound, err)
}
span.SetStatus(otelCodes.Ok, "User info retrieved successfully")
Abbiamo il tracciamento a livello di servizio — ottimo! Ma possiamo andare ancora più a fondo, strumentando il livello del database:
func (r *UserRepository) GetUserInfo(ctx context.Context, username string) (*models.User, error) {
tracer := otel.Tracer("auth-service")
ctx, span := tracer.Start(ctx, "UserRepository.GetUserInfo",
trace.WithAttributes(
attribute.String("db.statement", query),
attribute.String("db.user", username),
),
)
defer span.End()
var user models.User
// Some code that queries the DB...
// err := doDatabaseCall()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "Failed to execute query")
return nil, fmt.Errorf("failed to fetch user info: %w", err)
}
span.SetStatus(codes.Ok, "Query executed successfully")
return &user, nil
}
Ora abbiamo una visione completa del percorso della richiesta. Vai all’interfaccia utente di Jaeger, cerca gli ultimi 20 tracciati sotto servizio-auth
, e vedrai tutti gli span e come si collegano in un unico punto.
Ora, tutto è visibile. Se hai bisogno, puoi includere l’intera query nei tag. Tuttavia, ricorda di non sovraccaricare la telemetria: aggiungi i dati in modo deliberato. Sto semplicemente dimostrando cosa è possibile, ma includere l’intera query in questo modo non è qualcosa che generalmente raccomanderei.
client-server gRPC
Se vuoi vedere un tracciato che coinvolge due servizi gRPC, è piuttosto semplice. Tutto ciò di cui hai bisogno è aggiungere gli interceptor pronti all’uso dalla libreria. Ad esempio, sul lato server:
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
pb.RegisterAuthServiceServer(server, authService)
Sul lato client, il codice è altrettanto breve:
shutdown := tracing.InitTracer(ctx)
defer shutdown()
conn, err := grpc.Dial(
"auth-service:50051",
grpc.WithInsecure(),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
logger.Fatal("error", zap.Error(err))
}
Ecco fatto! Assicurati che i tuoi esportatori siano configurati correttamente e vedrai un singolo ID di tracciamento registrato attraverso questi servizi quando il client chiama il server.
Gestione degli eventi del CDC e tracciamento
Vuoi gestire anche gli eventi del CDC? Un approccio semplice è incorporare l’ID di tracciamento nell’oggetto che MongoDB memorizza. In questo modo, quando Debezium cattura il cambiamento e lo invia a Kafka, l’ID di tracciamento fa già parte del record.
Ad esempio, se stai usando MongoDB, puoi fare qualcosa del genere:
func (r *mongoPurchaseRepo) SavePurchase(ctx context.Context, purchase entity.Purchase) error {
span := r.handleTracing(ctx, purchase)
defer span.End()
// Insert the record into MongoDB, including the current span's Trace ID
_, err := r.collection.InsertOne(ctx, bson.M{
"_id": purchase.ID,
"user_id": purchase.UserID,
"username": purchase.Username,
"amount": purchase.Amount,
"currency": purchase.Currency,
"payment_method": purchase.PaymentMethod,
// ...
"trace_id": span.SpanContext().TraceID().String(),
})
return err
}
Debzium quindi rileva questo oggetto (incluso il trace_id
) e lo invia a Kafka. Sul lato del consumatore, devi semplicemente analizzare il messaggio in arrivo, estrarre il trace_id
e unirlo nel tuo contesto di tracciamento:
// If we find a Trace ID in the payload, attach it to the context
newCtx := ctx
if traceID != "" {
log.Printf("Found Trace ID: %s", traceID)
newCtx = context.WithValue(ctx, "trace-id", traceID)
}
// Create a new span
tracer := otel.Tracer("purchase-processor")
newCtx, span := tracer.Start(newCtx, "handler.processPayload")
defer span.End()
if traceID != "" {
span.SetAttributes(
attribute.String("trace.id", traceID),
)
}
// Parse the "after" field into a Purchase struct...
var purchase model.Purchase
if err := mapstructure.Decode(afterDoc, &purchase); err != nil {
log.Printf("Failed to map 'after' payload to Purchase struct: %v", err)
return err
}
// If we find a Trace ID in the payload, attach it to the context
newCtx := ctx
if traceID != "" {
log.Printf("Found Trace ID: %s", traceID)
newCtx = context.WithValue(ctx, "trace-id", traceID)
}
// Create a new span
tracer := otel.Tracer("purchase-processor")
newCtx, span := tracer.Start(newCtx, "handler.processPayload")
defer span.End()
if traceID != "" {
span.SetAttributes(
attribute.String("trace.id", traceID),
)
}
// Parse the "after" field into a Purchase struct...
var purchase model.Purchase
if err := mapstructure.Decode(afterDoc, &purchase); err != nil {
log.Printf("Failed to map 'after' payload to Purchase struct: %v", err)
return err
}
Alternativa: Utilizzare gli Header di Kafka
A volte è più semplice memorizzare l’ID di tracciamento negli header di Kafka piuttosto che nel payload stesso. Per i flussi di lavoro CDC, questo potrebbe non essere disponibile di default – Debezium può limitare ciò che viene aggiunto agli header. Ma se controlli il lato produttore (o se stai utilizzando un produttore standard di Kafka), puoi fare qualcosa del genere con Sarama:
Inserire un ID di tracciamento negli Header
// saramaHeadersCarrier is a helper to set/get headers in a Sarama message.
type saramaHeadersCarrier *[]sarama.RecordHeader
func (c saramaHeadersCarrier) Get(key string) string {
for _, h := range *c {
if string(h.Key) == key {
return string(h.Value)
}
}
return ""
}
func (c saramaHeadersCarrier) Set(key string, value string) {
*c = append(*c, sarama.RecordHeader{
Key: []byte(key),
Value: []byte(value),
})
}
// Before sending a message to Kafka:
func produceMessageWithTraceID(ctx context.Context, producer sarama.SyncProducer, topic string, value []byte) error {
span := trace.SpanFromContext(ctx)
traceID := span.SpanContext().TraceID().String()
headers := make([]sarama.RecordHeader, 0)
carrier := saramaHeadersCarrier(&headers)
carrier.Set("trace-id", traceID)
msg := &sarama.ProducerMessage{
Topic: topic,
Value: sarama.ByteEncoder(value),
Headers: headers,
}
_, _, err := producer.SendMessage(msg)
return err
}
Estrarre un ID di tracciamento sul lato consumatore
for message := range claim.Messages() {
// Extract the trace ID from headers
var traceID string
for _, hdr := range message.Headers {
if string(hdr.Key) == "trace-id" {
traceID = string(hdr.Value)
}
}
// Now continue your normal tracing workflow
if traceID != "" {
log.Printf("Found Trace ID in headers: %s", traceID)
// Attach it to the context or create a new span with this info
}
}
A seconda del tuo caso d’uso e di come è configurato il tuo pipeline CDC, puoi scegliere l’approccio che funziona meglio:
- Incorpora l’ID di tracciamento nel record del database affinché fluisca naturalmente tramite CDC.
- Utilizza gli header di Kafka se hai più controllo sul lato produttore o se desideri evitare di gonfiare il payload del messaggio.
In entrambi i casi, puoi mantenere coerenti i tuoi tracciati attraverso più servizi, anche quando gli eventi vengono elaborati in modo asincrono tramite Kafka e Debezium.
Conclusione
Utilizzare OpenTelemetry e Jaeger fornisce tracciature dettagliate delle richieste, aiutandoti a individuare dove e perché si verificano ritardi nei sistemi distribuiti.
Aggiungere Prometheus completa il quadro con metriche – indicatori chiave delle prestazioni e della stabilità. Insieme, questi strumenti formano un completo stack di osservabilità, consentendo una più rapida individuazione e risoluzione dei problemi, ottimizzazione delle prestazioni e affidabilità complessiva del sistema.
Posso dire che questo approccio accelera significativamente il troubleshooting in un ambiente di microservizi ed è una delle prime cose che implementiamo nei nostri progetti.
Links
Source:
https://dzone.com/articles/control-services-otel-jaeger-prometheus