Traceurs avec OpenTelemetry et Jaeger

Le traçage, un composant critique, suit les demandes à travers des systèmes complexes. Cette visibilité révèle les embouts de verre et les erreurs, permettant des résolutions plus rapides. Dans une précédente publication de notre série sur les services Web Go, nous avons exploré l’importance de l’observabilité. Aujourd’hui, nous nous concentrons sur le traçage. Jaeger collecte, stocke et visualise les trajets de systèmes distribués. Il fournit des insights cruciaux sur les flux de demandes entre les services. En intégrant Jaeger avec OpenTelemetry, les développeurs peuvent unifier leur approche de traçage, garantissant une visibilité cohérente et complète. Cette intégration simplifie la diagnose des problèmes de performance et améliore la fiabilité du système. Dans ce post, nous allons configurer Jaeger, l’intégrer à OpenTelemetry dans notre application et explorer la visualisation des trajets pour des aperçus plus profonds.

Motivation

Ce que nous cherchons à réaliser est un tableau de bord Jaeger qui ressemble à ceci:

Lorsque nous naviguons dans différentes parties de l’application (sur le frontend Onehub), les trajets des diverses demandes sont collectés (à partir du moment où elles atteignent le grpc-gateway) avec une synthèse de chacun d’entre eux. Nous pouvons même approfondir un des trajets pour une vue plus détaillée. Considérez la première POST demande (pour créer/envoyer un message dans un sujet):

Ici, nous voyons tout les composants que la demande Create touche ainsi que leurs temps d’entrée/de sortie et le temps mis à l’intérieur et à l’extérieur des méthodes. Très puissant, en effet.

Pour commencer

TL;DR : Pour voir cela en action et valider le reste du blog :

  1. La source de cela se trouve dans le branche PART11_TRACING.
  2. Construisez tout ce qui est nécessaire (une fois que vous avez vérifié la branche) :
make build

  1. Nous avons divisé le docker-compose en deux parties (cela sera expliqué plus bas), veillez à ce que vous ayez deux fenêtres en cours d’exécution.
  • Terminal 1 : make updb dblogs
  • Terminal 2 : make up logs
  1. Naviguez sur localhost:7080, et voilà.

Aperçu haut niveau

Notre système est actuellement :

Avec l’instrumentation OpenTelemetry, notre système évoluera vers :

Comme nous l’avons mentionné plus tôt, il est assez onéreux pour chaque service d’utiliser des clients distincts pour envoyer vers des fournisseurs spécifiques. Au lieu de cela, avec un collecteur OTel exécuté séparément, nous pouvons nous assurer que tous les services (intéressés) peuvent simplement envoyer des métriques/journaux/ traces à ce collecteur, qui peut ensuite les exporter vers divers backends selon besoin — dans ce cas, Jaeger pour les traces.

Commencez donc.

Configurer le Collecteur OTel

Le premier pas est d’ajouter notre collecteur OTel exécuté dans l’environnement Docker ainsi que Jaeger pour qu’ils soient accessibles.

Remarque : Nous avons divisé notre configuration initiale docker-compose.yml en deux parties :

  1. db-docker-compose.yml : Contient tout les composants liés aux bases de données et à l’infrastructure (non-application) comme les bases de données (Postgres, Typesense) et les services de surveillance (collecteur OTel, Jaeger, Prometheus, etc.).
  2. docker-compose.yml: Contient tous les services liés à l’application (Nginx, gRPC Gateway, dbsync, frontend, etc.)

Les deux environnements docker-compose sont connectés par une plateforme réseau partagée (onehubnetwork), grâce à laquelle les services de ces environnements peuvent communiquer les uns avec les autres. Avec cette séparation, nous n’avons besoin de redémarrer qu’une sous-sélection de services suite à des modifications, ce qui accélère notre développement.

Revenons à notre configuration : dans notre db-docker-compose.yml, ajoutons les services suivants :

YAML

 

Assez simple, cela configure deux services dans notre environnement Docker :

  1. otel-collector : Le point de convergence de tous les signaux (métriques/logs/ traces) envoyés par les divers services supervisés (nous continuerons à ajouter à cette liste au fil du temps), il utilise l’image standard d’OTel ainsi que notre configuration OTel personnalisée (ci-dessous) qui décrit diverses pipelines d’observabilité (c’est-à-dire comment les signaux doivent être reçus, traitées et exportées de diverses manières).
  2. jaeger : Notre instance Jaeger qui recevra et stockera les traces (exportées par l’otel-collector), il héberge à la fois le stockage ainsi que le tableau de bord (UI) que nous exportons sur le préfixe d’URL HTTP /jaeger pour être accessible via Nginx.
  3. prometheus : Malgré qu’il ne soit pas nécessaire pour cet article, nous exportons également les métriques afin qu’elles puissent être récupérées par Prometheus. Nous ne discuterons pas de cela en détail dans cet article.

Quelques choses à noter :

  • Malgré qu’il ne soit pas nécessaire pour cet article, nous passons les détails de la connexion POSTGRES (en tant que variables d’environnement) à l’otel-collector afin qu’il puisse scraper les métriques de santé de Postgres.
  • Jaeger (à partir de v1.35) supporte OTLP nativement.
  • La beauté de OTLP est qu’il est possible de chaîner des collecteurs OTel, formant ainsi une sorte de réseau de collecteurs/traitants/transferts/routes, etc.
  • OTLP peut être expédié soit via un point de terminaison GRPC ou HTTP (sur les ports 4317 et 4318 respectivement).
  • Par défaut, les services OTLP sont démarrés sur localhost:4317/4318. C’est bien si Jaeger est exécuté sur le même hôte/pod où les services (surveillés) sont en cours d’exécution. Cependant, comme Jaeger est exécuté dans un pod séparé, il doit être lié à des adresses externes (0.0.0.0). Cela n’était pas clair dans la documentation et a entraîné beaucoup de difficultés.
  • COLLECTOR_OTLP_ENABLED: true est désormais le paramètre par défaut et n’est pas nécessaire de le spécifier explicitement.

Configuration d’OTel

OTel doit également être configuré avec des récepteurs spécifiques, des traitements et des exportateurs. Nous ferons cela dans configs/otel-collector.yaml.

Ajout de Récepteurs

Nous devons indiquer au collecteur OTel quels récepteurs doivent être activés. Cela est spécifié dans la section receivers :

YAML

 

Ceci active un récepteur OTLP sur les ports 4317 et 4318 (grpc, http respectivement). Il existe de nombreux types de récepteurs qui peuvent être démarrés. Comme exemple, nous avons également ajouté un récepteur “postgresql” qui s’engage à extraire activement les métriques de Postgres ( bien que cela ne soit pas pertinent pour cet article). Les récepteurs peuvent également être basés sur le tirage ou sur l’envoi. Les récepteurs basés sur le tirage extraient périodiquement des cibles spécifiques (par ex., postgres), tandis que les récepteurs basés sur l’envoi écoutent et reçoivent les métriques/journaux/suivis envoyés par les applications en utilisant le SDK client OTel.

C’est tout. Maintenant, notre collecteur est prêt à recevoir (ou à scraper) les métriques appropriées.

Ajouter des Traitements

Les traitements dans OTel sont une manière de transformer, mapper, grouper, filtrer et/ou enrichir les signaux reçus avant d’exportation. Par exemple, les traitements peuvent effectuer un échantillonnage des métriques, filtrer les journaux ou même grouper les signaux pour assurer l’efficacité. Par défaut, aucun traitement n’est ajouté (le collecteur fonctionne donc en mode passage à travers). Nous ignorerons cela pour l’instant.

Ajouter des Exportateurs

Maintenant, il est temps d’identifier où nous voulons que les signaux soient exportés : vers les backends les mieux adaptés à chacun de ces signaux. Comme pour les récepteurs, les exportateurs peuvent également être basés sur le pull ou le push. Les exportateurs basés sur le push sont utilisés pour émettre des signaux vers un autre récepteur agissant en mode push. Ces exportateurs sont de type sortant. Les exportateurs basés sur le pull exposent des extrémités qui peuvent être scrappées par d’autres récepteurs basés sur le pull (par exemple, prometheus). Nous ajouterons un exporteur de chaque type : un pour le suivi et un pour que Prometheus puisse le scraper (bien que Prometheus ne soit pas le sujet de ce poste) :

YAML

 

Ici, nous avons un exporteur vers Jaeger exécutant le collecteur OTLP, comme indiqué par otlp/jaeger. Cet exporteur pushera régulièrement des traces vers Jaeger. Nous ajoutons également une extrémité « scraper » sur le port 9090 que Prometheus scrapera régulièrement.

L’exporteur « debug » est simplement utilisé pour déverser les signaux vers les flux d’entrée/erreur standards.

Définir des Pipelines

Les sections récepteur, traitement et exporteur définissent simplement les modules qui seront activés par le collecteur. Ils ne sont toujours pas invoqués. Pour les invoquer/activer concrètement, ils doivent être références comme des « pipelines ». Les pipelines définissent comment les signaux passent et sont traités par le collecteur. Notre définitions de pipeline (dans la section services) préciseront cela :

YAML

 

Ici, nous définissons deux pipelines. Notez combien ils sont semblables mais permettent deux modes d’exportation différents (Jaeger et Prometheus). Maintenant, nous voyons l’avantage d’OTel et de la création de pipelines à l’intérieur.

  1. traces:

  • Reçois des signaux des SDK clients
  • Pas de traitement
  • Exporter des traces vers la console et Jaeger
  1. metrics :
  • Recevoir des signaux des SDK clients
  • Aucun traitement
  • Exporter des métriques vers la console et Prometheus (en exposant un point d’entrée pour qu’il puisse s’y grappiller).

Exposer des tableaux de bord via Nginx

Jaeger fournit un tableau de bord pour visualiser les données de traces sur toutes nos demandes. Cela peut être visualisé dans un navigateur en activant ce qui suit dans notre configuration de Nginx Nginx config. Encore une fois, ce n’est pas le sujet de cet article – nous exposons également l’interface utilisateur de Prometheus via nginx au préfixe d’chemin HTTP /prometheus.

YAML

 

Visualiser les traces dans Jaeger

L’interface utilisateur de Jaeger est assez complète et offre plusieurs fonctionnalités que vous pouvez explorer. Naviguez vers l’interface utilisateur de Jaeger dans le navigateur. Vous verrez une interface complète pour rechercher et analyser les traces. allez-y et familiarisez-vous avec les principales sections, y compris la barre de recherche et la liste des traces. Vous pouvez rechercher différentes traces en utilisant des critères de recherche et filtrer par service, durées de temps, composants, etc.

Analysez les lignes de temps des traces dans les différentes demandes pour comprendre la séquence d’opérations. Chaque étendue représente une unité de travail, montrant les temps de début et de fin, la durée et les métadonnées associées. Cette vue détaillée est très utile pour identifier les goulets d’étranglement de performance et les erreurs au sein de la trace.

Intégrer le SDK client

Jusqu’à présent, nous avons configuré nos systèmes pour visualiser, consommer des signaux, etc. Cependant, nos services ne sont toujours pas mis à jour pour émettre les signaux vers OTel. Ici, nous integrons le client SDK (Golang) dans différentes parties de notre code. La documentation du SDK est un excellent endroit pour vous familiariser avec certains des concepts.

Les concepts clés avec lesquels nous traiterons sont décrits ci-dessous.

Ressources

Ressources sont les entités qui produisent le signal. Dans notre cas, la portée de la ressource est l’exécutable hébergeant les services. Actuellement, nous avons une seule ressource pour l’ensemble du service Onehub, mais cela pourrait être divisé par la suite.

Cela est défini dans cmd/backend/obs.go. Notez que le client SDK n’a pas besoin que nous entrions dans les détails de la définition de la ressource explicitement. L’assistant standard (sdktrace.WithResource) nous permet de créer une définition de ressource en inférissant les parties les plus utiles (comme le nom du processus, le nom du pod, etc.) à runtime.

Nous avons seulement dû remplacer une chose : la variable d’environnement OTEL_RESOURCE_ATTRIBUTES: service.name=onehub.backend,service.version=0.0.1 pour le service onehub dans docker-compose.yml.

La propagation des contextes

Propagation des contextesest un sujet très important dans le domaine de l’observabilité. Les différents pilars sont exponentiellement puissants lorsque nous pouvons corréler les signaux issus de chacun des pilars pour identifier les problèmes affectant notre système. Envisagez les contextes comme des extraits de données qui peuvent être rattachés aux signaux : c’est-à-dire, qui peuvent être « rejoints » de manière unique pour relier les différents signaux à un groupe particulier (par exemple une requête).

Fournisseurs/Exportateurs

Pour chacun des signaux, OTel fournit une interface fournisseur (par exemple, TracerProvider pour l’exportation de traces, MeterProvider pour l’exportation de métriques, LoggerProvider pour l’exportation des journaux et ainsi de suite). Pour chacune de ces interfaces, il peut y avoir plusieurs implémentations, par exemple, un fournisseur de débogage pour envoyer vers les flux stdout/err, un fournisseur OTel pour exporter vers un autre point de terminaison OTel (dans une chaîne) ou même directement via une varieté d’exportateurs. Cependant, dans notre cas, nous voulons déferler le choix de tous les fournisseurs hors de nos services et, à la place, envoyer tous les signaux au collecteur OTel exécuté dans notre environnement.

Pour abstraire cela, nous créerons un type « OTELSetup » qui garde trace des différents fournisseurs que nous pourrions souhaiter utiliser ou remplacer. Dans cmd/backend/obs.go, nous avons :

Go

 

Ceci est un simple enveloppeur qui suit les aspects communs nécessaires par l’API OTel. Ici, nous avons des fournisseurs (Journaliste, Traceur et Métrique) ainsi que des manières de fournir du contexte (pour la trace). Le Resource parent utilisé par tous les fournisseurs est également spécifié ici. Les fonctions de déchargement sont intéressantes. Il s’agit de fonctions appelées par les fournisseurs lorsque l’exporteur sous-jacent est terminé (avec grâce ou à cause d’une sortie). L’enveloppeur lui-même prend un genre générique donc les instantiateurs spécifiques de ce Setup peuvent utiliser leurs propres données personnalisées.

Le dépôt contient deux implémentations de ceci :

Nous instantiâmes le second dans notre application. Nous ne ferons pas d’explications sur les implémentations spécifiques car elles ont été prises de l’exemple dans l’API avec des petites corrections et des refactorings. En particulier, jetez un œil à l’exemple otel-collector pour de l’inspiration.

Initialisez les fournisseurs OTel.

L’essence de l’activation du collecteur dans nos services est que quelque chose de lié au « contexte » OTel est démarré à tous les points d’entrée. Si ce contexte est créé au début, il sera envoyé à tous les cibles appelées ici, qui est ensuite propagé par la suite ( tant que nous faisons le bon choix).

En prenant l’appel API simple ListTopics (api/vi/topics), notre requête prend le chemin suivant et revient en arrière :

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

Dans notre cas, les points d’entrée ici sont au début lorsque le gRPC Gateway reçoit une requête API de Nginx (nous pourrions démarrer la traces d’entrée dès que la requête HTTP atteint Nginx pour mieux mettre en évidence les latences à Nginx, mais nous repousserons pour un peu).

ce qui est nécessaire est :

  • gRPC Gateway Reçoit une requête.
  • Il crée une instance « personnalisée » de context.Context spécifique à OTel.
  • Créer une connexion personnalisée au service gRPC correspondant (par exemple, TopicService) en passant ce contexte au lieu de celui par défaut
  • Le service correspondant utilise ensuite ce contexte lors de l’émission des traces.

Allons-y pas à pas.

Initialiser et préparer l’SDK OTel pour l’utilisation

Dans main.go, initialisons d’abord la connexion au collecteur :

Go

 

  • Lignes 8-16 : Nous créons une connexion au otel-collector exécuté dans notre environnement Docker.
  • Lignes 17-21 : Nous configurons ensuite le système OTel avec des fournisseurs de trace et de métriques afin que notre collecteur puisse maintenant envoyer toutes les traces et les métriques (n’oubliez pas que nous avons défini des récepteurs dans la configuration OTel plus tôt).
  • Lignes 23-25 : Nous configurons des finalisateurs pour nettoyer les connexions/fournisseurs OTel, etc., lors du redémarrage.
  • Ligne 27 : Nous configurons notre base de données et les connexions comme avant.
  • Lignes 29 + : Précédemment, nous avions simplement démarré les services GRPC et Gateway en arrière-plan, et c’était tout. Nous n’étaient pas vraiment préoccupés par leur état de retour ou de sortie. Pour un système plus résilient, il est important de disposer de meilleures informations sur le cycle de vie des services que nous démarrons. Ainsi, nous passons maintenant le canal de « callback » pour chacun des services que nous démarrons. Lorsque les serveurs se terminent, les méthodes respectives appellent vers ces canaux disponibles pour indiquer qu’elles se sont arrêtées avec succès. Notre binaire entier quittera lorsque l’un de ces services se termine.

Prenons l’exemple de la manière dont notre service Gateway utilise ce canal.

Ainsi que de démarrer le serveur HTTP (pour le grpc-gateway) :

1    http.ListenAndServe(gw_addr, mux)

Nous avons maintenant :

Go

 

Attention aux lignes 9-14 où la fermeture du serveur est surveillée dans un goroutine séparé et à la ligne 15 où, si il y avait une erreur lorsque le serveur s’est arrêté, elle est retournée via le canal « notification » passé en argument de cette méthode.

Maintenant, les différentes parties de nos services ont accès à une connexion « active » OTLP pour envoyer des signaux quand ils le font.

Middleware OTel pour gRPC Gateway

Au-dessus, l’instance de http.Server utilisée pour démarrer le gRPC Gateway utilise un gestionnaire personnalisé : le http.Handler dans le package OTel HTTP. Ce gestionnaire prend une instance existante de http.Handler, la décore avec le contexte OTel et s’assure de sa propagation à tout autre downstream appelé.

Go

 

Notre gestionnaire HTTP est simple :

  • Ligne 4 : Nous créons un nouveau wrapper spécifique OTel pour gérer les requêtes HTTP.
  • Ligne 5 : Nous définissons l’option SpanFormatter afin que les traces puissent être identifiées de manière unique par la méthode et les chemins de requête HTTP. Sans ce SpanNameFormatter, la définition par défaut du « nom » pour nos traces au gateway serait simplement "gateway", ce qui donnerait toutes les traces une apparence comme ceci :

Enveloppement du Gateway vers les appels gRPC avec OTel

Par défaut, la bibliothèque gRPC Gateway crée un « plain » context lors de la création/gestion des connexions vers les services gRPC sous-jacents. Après tout, le gateway ne sait rien de OTel. En mode standard, une connexion (de l’utilisateur/navigateur) vers le gRPC Gateway et la connexion du gateway vers le service gRPC seront traitées comme deux traces différentes.

Ainsi, il est important de retirer de la responsabilité de la création des connexions gRPC du Gateway et de fournir plutôt une connexion déjà compatible OTel. Nous ferons cela maintenant.

Avant l’intégration d’OTel, nousregistions un gestionnaire de passerelle pour nos gRPC avec :

Go

 

Maintenant, passer une connexion différente est simple :

Go

 

Ce que nous avons fait, c’est d’abord créer un client (Ligne 6) qui agit comme une usine de connexions pour notre serveur gRPC. Le client est assez simple. Seule la classe gRPC ClientHandler (otelgrpc.NewClientHandler) est utilisée pour créer la connexion. Cela garantit que le contexte de la trace en cours qui a commencé sur une nouvelle requête HTTP est maintenant propagé vers le serveur gRPC via ce gestionnaire.

C’est tout. Maintenant, nous devrions commencer à voir les nouvelles requêtes vers le passerelle et la requête Passerelle->gRPC dans une seule trace consolidée plutôt que de les voir comme deux traces différentes.

Débuter et terminer des Spans

Nous y avons presque. Jusqu’à présent :

  • Nous avons activé l’collecteur OTel et Jaeger pour recevoir et stocker les données de trace (spans) (dans docker-compose).
  • Nous avons configuré l’collecteur OTel de base (en cours d’exécution en tant que pod séparé) en tant que fournisseur de traceurs, de métriques et de journaux (c’est-à-dire que l’intégration OTel de notre application utilisera ce point de terminaison pour déposer toutes les signaux).
  • Nous avons enveloppé le gestionnaire HTTP de la passerelle pour qu’il soit compatible OTel, de sorte que les traces et leurs contextes sont créés et propagés.
  • Nous avons remplacé le client (gRPC) de la passerelle de sorte qu’il emballle maintenant le contexte OTel de notre configuration OTel au lieu d’utiliser un contexte par défaut.
  • Nous avons créé des instances de traceur/compteur/journaliste globales pour nous permettre d’envoyer des signaux réels en utilisant celles-ci.

Maintenant, nous devons émettre des étiquettes pour toutes les « endroits intéressants » dans notre code. Prenons l’exemple de la méthode ListTopics (dans services/topics.go) :

Go

 

Nous appelons la base de données pour récupérer les sujets et les retournons. Analoguement à la méthode d’accès à la base de données (dans datastore/topicds.go) :

Go

 

Ici, nous serions principalement intéressés à savoir combien de temps est passé dans chacune de ces méthodes. Nous créons simplement des étiquettes dans chacune d’entre elles, et c’est fini. nos ajouts aux méthodes du service et du stockage de données (respectivement) sont :

  • services/topics.go:
Go

 

  • datastore/topicds.go:
Go

 

Le schéma général est le suivant :

1. Créer une étiquette:

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

Dans ce cas, le contexte donné (ctx) est « emballé » et un nouveau contexte est retourné. Nous pouvons (et devrions) passer ce nouveau contexte enveloppé à d’autres méthodes. Nous faisons exactement cela lorsque nous appelons la méthode de stockage de données ListTopics.

2. Terminer l’étiquette:

defer span.End()

Terminer une étiquette (lorsque la méthode retourne) assure que les bonnes fins de temps/codes, etc. sont enregistrés. Nous pouvons également faire d’autres choses telles queajouter des étiquettes et des statuts à celui-ci si nécessaire pour transporter plus d’information à des fins de débugage.

C’est tout. Vous pouvez voir vos magnifiques traces dans Jaeger et obtenir de plus en plus d’insights sur la performance de vos requêtes, de bout en bout !

Conclusion

Nous avons couvert beaucoup de terrain dans ce billet et n’avons pas encore brossé le portrait de tous les détails derrière OTel et le suivi. Au lieu de surcharger ce billet (déjà très riche), nous présenterons de nouveaux concepts et détails complexes dans des billets futurs. Pour l’instant, essayez cela dans vos propres services et essayez de jouer avec d’autres exportateurs et récepteurs dans le dossier otel-contrib.

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