Traçabilité de bout en bout avec OpenTelemetry

Nicolas Fränkel

Nicolas Fränkel

August 31, 2022

Ecosystem

Que vous implémentiez ou non des microservices (et vous ne devriez probablement pas), votre système est très probablement composé de plusieurs composants. Le système le plus simple est probablement constitué d'un proxy inverse, d'une application et d'une base de données. Dans ce cas, la surveillance n'est pas seulement une bonne idée ; c'est une nécessité. Plus le nombre de composants par lesquels une requête peut passer est élevé, plus cette nécessité est forte.

Cependant, la surveillance n'est que le début du voyage. Lorsque les requêtes commencent à échouer en masse, vous avez besoin d'une vue agrégée sur tous les composants. C'est ce qu'on appelle le tracing, et c'est l'un des piliers de l'observabilité ; les deux autres sont les métriques et les logs.

Dans cet article, je me concentrerai uniquement sur les traces et décrirai comment vous pouvez commencer votre voyage dans l'observabilité.

La spécification W3C Trace Context

Une solution de tracing devrait fournir un format standard pour fonctionner à travers des piles technologiques hétérogènes. Un tel format doit adhérer à une spécification, qu'elle soit formelle ou de facto.

Il faut comprendre qu'une spécification n'apparaît rarement de nulle part. En général, le marché a déjà quelques implémentations distinctes. La plupart du temps, une nouvelle spécification conduit à une implémentation supplémentaire, comme le décrit la célèbre bande dessinée XKCD :

la célèbre bande dessinée XKCD décrit

Parfois, cependant, un miracle se produit : le marché adhère à la nouvelle spécification. Ici, Trace Context est une spécification W3C, et elle semble avoir fait l'affaire :

Cette spécification définit des en-têtes HTTP standard et un format de valeur pour propager des informations de contexte qui permettent des scénarios de tracing distribué. La spécification standardise la manière dont les informations de contexte sont envoyées et modifiées entre les services. Les informations de contexte identifient de manière unique les requêtes individuelles dans un système distribué et définissent également un moyen d'ajouter et de propager des informations de contexte spécifiques au fournisseur. -- https://www.w3.org/TR/trace-context/

Deux concepts critiques émergent du document :

  • Une trace suit le chemin d'une requête qui traverse plusieurs composants
  • Un span est lié à un seul composant et relié à un autre span par une relation parent-enfant

![Traces et spans](https://static-site.apiseven.com/wp-content/uploads/2022/08/trace-spans-1024x393.png "Traces et spans" "Traces et span")

Au moment de la rédaction de cet article, la spécification est une recommandation W3C, qui est l'étape finale.

Trace Context a déjà de nombreuses implémentations. L'une d'elles est OpenTelemetry.

OpenTelemetry comme standard de référence

Plus vous êtes proche de la partie opérationnelle de l'informatique, plus vous avez de chances d'avoir entendu parler d'OpenTelemetry :

OpenTelemetry est une collection d'outils, d'API et de SDK. Utilisez-le pour instrumenter, générer, collecter et exporter des données de télémétrie (métriques, logs et traces) pour vous aider à analyser les performances et le comportement de votre logiciel. OpenTelemetry est généralement disponible dans plusieurs langages et est prêt à l'emploi. -- https://opentelemetry.io/

OpenTelemetry est un projet géré par la CNCF. Avant OpenTelemetry, il y avait deux projets :

  • OpenTracing, axé sur les traces comme son nom l'indique
  • OpenCensus, dont l'objectif était de gérer les métriques et les traces

Les deux projets ont fusionné et ont ajouté les logs par-dessus. OpenTelemetry propose maintenant un ensemble de "couches" axées sur l'observabilité :

  • Des API d'instrumentation dans une variété de langages
  • Des implémentations canoniques, encore une fois dans différents langages
  • Des composants d'infrastructure tels que des collecteurs
  • Des formats d'interopérabilité, tels que le Trace Context du W3C

Notez que bien qu'OpenTelemetry soit une implémentation de Trace Context, il fait plus. Trace Context se limite à HTTP, tandis qu'OpenTelemetry permet aux spans de traverser des composants non web, tels que Kafka. Cela dépasse le cadre de cet article de blog.

Le cas d'utilisation

Mon cas d'utilisation préféré est une boutique e-commerce, alors ne changeons pas. Dans ce cas, la boutique est conçue autour de microservices, chacun accessible via une API REST et protégé derrière une passerelle API. Pour simplifier l'architecture pour l'article de blog, je n'utiliserai que deux microservices : catalog gère les produits, et pricing gère le prix des produits.

Lorsqu'un utilisateur arrive sur l'application, la page d'accueil récupère tous les produits, obtient leur prix respectif et les affiche.

Exemple de flux de requête à travers plusieurs composants

Pour rendre les choses plus intéressantes, catalog est une application Spring Boot codée en Kotlin, tandis que pricing est une application Python Flask.

Le tracing devrait nous permettre de suivre le chemin d'une requête à travers la passerelle, les deux microservices et, si possible, les bases de données.

Traces à la passerelle

Le point d'entrée est la partie la plus intéressante du tracing, car il devrait générer l'ID de trace : dans ce cas, le point d'entrée est la passerelle. J'utiliserai Apache APISIX pour implémenter la démo :

Apache APISIX fournit des fonctionnalités riches de gestion du trafic comme l'équilibrage de charge, l'amont dynamique, la publication canari, la rupture de circuit, l'authentification, l'observabilité, etc. -- https://apisix.apache.org/

Apache APISIX est basé sur une architecture de plugins et propose un plugin OpenTelemetry :

Le plugin opentelemetry peut être utilisé pour rapporter des données de tracing selon la spécification OpenTelemetry. Le plugin ne prend en charge que l'OLTP encodé en binaire sur HTTP. -- https://apisix.apache.org/docs/apisix/plugins/opentelemetry/

Configurons le plugin opentelemetry :

apisix:
  enable_admin: false #1
  config_center: yaml #1
plugins:
  - opentelemetry #2
plugin_attr:
  opentelemetry:
    resource:
      service.name: APISIX #3
    collector:
      address: jaeger:4318 #4
  1. Exécutez Apache APISIX en mode autonome pour rendre la démo plus facile à suivre. C'est une bonne pratique en production de toute façon
  2. Configurez opentelemetry comme un plugin global
  3. Définissez le nom du service. C'est le nom qui apparaîtra dans le composant d'affichage des traces
  4. Envoyez les traces au service jaeger. La section suivante le décrira.

Nous voulons tracer chaque route, donc au lieu d'ajouter le plugin à chaque route, nous devrions configurer le plugin comme un plugin global :

global_rules:
  - id: 1
    plugins:
      opentelemetry:
        sampler:
          name: always_on #1
  1. Le tracing a un impact sur les performances. Plus nous traçons, plus nous avons d'impact. Par conséquent, nous devrions soigneusement équilibrer l'impact sur les performances par rapport aux avantages de l'observabilité. Pour la démo, cependant, nous voulons tracer chaque requête.

Collecte, stockage et affichage des traces

Bien que Trace Context soit une spécification W3C et qu'OpenTelemetry soit un standard de facto, de nombreuses solutions existent sur le marché pour collecter, stocker et afficher les traces. Chaque solution peut fournir les trois capacités ou seulement une partie d'entre elles. Par exemple, la pile Elastic gère le stockage et l'affichage, mais vous devez vous appuyer sur autre chose pour la collecte. D'un autre côté, Jaeger et Zipkin fournissent une suite complète pour remplir les trois capacités.

Jaeger et Zipkin précèdent OpenTelemetry, donc chacun a son format de transport de traces. Ils fournissent cependant une intégration avec le format OpenTelemetry.

Dans le cadre de cet article de blog, la solution exacte n'est pas pertinente, car nous avons seulement besoin des capacités. J'ai choisi Jaeger car il fournit une image Docker tout-en-un : chaque capacité a son composant, mais ils sont tous intégrés dans la même image, ce qui rend la configuration beaucoup plus facile.

Les ports pertinents de l'image sont les suivants :

PortProtocoleComposantFonction
16686HTTPquerysert le frontend
4317HTTPcollectoraccepte le protocole OpenTelemetry (OTLP) sur gRPC, si activé
4318HTTPcollectoraccepte le protocole OpenTelemetry (OTLP) sur HTTP, si activé

La partie Docker Compose ressemble à ceci :

services:
  jaeger:
    image: jaegertracing/all-in-one:1.37 #1
    environment:
      - COLLECTOR_OTLP_ENABLED=true #2
    ports:
      - "16686:16686" #3
  1. Utilisez l'image all-in-one
  2. Très important : activez le collecteur au format OpenTelemetry
  3. Exposez le port de l'interface utilisateur

Maintenant que nous avons mis en place l'infrastructure, nous pouvons nous concentrer sur l'activation des traces dans nos applications.

Traces dans les applications Flask

Le service pricing est une simple application Flask. Il offre un seul point de terminaison pour récupérer le prix d'un seul produit à partir de la base de données.

@app.route('/price/<product_str>')                           #1-2
def price(product_str: str) -> Dict[str, object]:
    product_id = int(product_str)
    price: Price = Price.query.get(product_id)               #3
    if price is None:
        return jsonify({'error': 'Product not found'}), 404
    else:
        low: float = price.value - price.jitter              #4
        high: float = price.value + price.jitter             #4
        return {
            'product_id': product_id,
            'price': round(uniform(low, high), 2)            #4
        }
  1. Point de terminaison
  2. La route nécessite l'identifiant du produit
  3. Récupère les données de la base de données en utilisant SQLAlchemy
  4. Les moteurs de tarification réels ne renvoient jamais le même prix au fil du temps ; randomisons un peu le prix pour le plaisir

Avertissement : Récupérer un seul prix par appel est très inefficace. Cela nécessite autant d'appels que de produits, mais cela rend la trace plus intéressante. Dans la vraie vie, la route devrait être capable d'accepter plusieurs identifiants de produits et de récupérer tous les prix associés en une seule requête-réponse.

Il est maintenant temps d'instrumenter l'application. Deux options sont disponibles : l'instrumentation automatique et l'instrumentation manuelle. L'automatique est peu coûteuse et une victoire rapide ; la manuelle nécessite un temps de développement ciblé. Je conseille de commencer par l'automatique et de n'ajouter la manuelle que si nécessaire.

Nous devons ajouter quelques packages Python :

opentelemetry-distro[otlp]==0.33b0
opentelemetry-instrumentation
opentelemetry-instrumentation-flask

Nous devons configurer quelques paramètres :

pricing:
  build: ./pricing
  environment:
    OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
    OTEL_RESOURCE_ATTRIBUTES: service.name=pricing #2
    OTEL_METRICS_EXPORTER: none #3
    OTEL_LOGS_EXPORTER: none #3
  1. Envoyez les traces à Jaeger
  2. Définissez le nom du service. C'est le nom qui apparaîtra dans le composant d'affichage des traces
  3. Nous ne sommes intéressés ni par les logs ni par les métriques

Maintenant, au lieu d'utiliser la commande standard flask run, nous l'encadrons :

opentelemetry-instrument flask run

Avec cela seulement, nous collectons déjà des spans à partir des appels de méthode et des routes Flask.

Nous pouvons manuellement ajouter des spans supplémentaires si nécessaire, par exemple :

from opentelemetry import trace

@app.route('/price/<product_str>')
def price(product_str: str) -> Dict[str, object]:
    product_id = int(product_str)
    with tracer.start_as_current_span("SELECT * FROM PRICE WHERE ID=:id", attributes={":id": product_id}) as span: #1
        price: Price = Price.query.get(product_id)
    # ...
  1. Ajoutez un span supplémentaire avec l'étiquette et l'attribut configurés

Traces dans les applications Spring Boot

Le service catalog est une application Spring Boot Réactive développée en Kotlin. Il offre deux points de terminaison :

  • Un pour récupérer un seul produit
  • L'autre pour récupérer tous les produits

Les deux recherchent d'abord dans la base de données des produits, puis interrogent le service pricing ci-dessus pour le prix.

Comme pour Python, nous pouvons tirer parti de l'instrumentation automatique et manuelle. Commençons par les fruits à portée de main, l'instrumentation automatique. Sur la JVM, nous l'atteignons via un agent :

java -javaagent:opentelemetry-javaagent.jar -jar catalog.jar

Comme en Python, cela crée des spans pour chaque appel de méthode et point d'entrée HTTP. Il instrumente également les appels JDBC, mais nous avons une pile Réactive et utilisons donc R2DBC. Pour mémoire, un problème GitHub est ouvert pour ajouter le support.

Nous devons configurer le comportement par défaut :

catalog:
  build: ./catalog
  environment:
    APP_PRICING_ENDPOINT: http://pricing:5000/price
    OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
    OTEL_RESOURCE_ATTRIBUTES: service.name=orders #2
    OTEL_METRICS_EXPORTER: none #3
    OTEL_LOGS_EXPORTER: none #3
  1. Envoyez les traces à Jaeger
  2. Définissez le nom du service. C'est le nom qui apparaîtra dans le composant d'affichage des traces
  3. Nous ne sommes intéressés ni par les logs ni par les métriques

Comme pour Python, nous pouvons monter d'un cran en ajoutant une instrumentation manuelle. Deux options sont disponibles, programmatique et basée sur des annotations. La première est un peu complexe à moins que nous n'introduisions Spring Cloud Sleuth. Ajoutons des annotations.

Nous avons besoin d'une dépendance supplémentaire :

<dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-instrumentation-annotations</artifactId>
    <version>1.17.0-alpha</version>
</dependency>

Attention, l'artefact a été très récemment déplacé de io.opentelemetry:opentelemetry-extension-annotations.

Nous pouvons maintenant annoter notre code :

@WithSpan("ProductHandler.fetch")                                               //1
suspend fun fetch(@SpanAttribute("id") id: Long): Result<Product> {             //2
    val product = repository.findById(id)
    return if (product == null) Result.failure(IllegalArgumentException("Product $id not found"))
    else Result.success(product)
}
  1. Ajoutez un span supplémentaire avec l'étiquette configurée
  2. Utilisez le paramètre comme attribut, avec la clé définie sur id et la valeur la valeur du paramètre au moment de l'exécution

Le résultat

Nous pouvons maintenant jouer avec notre simple démo pour voir le résultat :

curl localhost:9080/products
curl localhost:9080/products/1

Les réponses ne sont pas intéressantes, mais regardons l'interface utilisateur de Jaeger. Nous trouvons les deux traces, une par appel :

Liste des traces dans l'interface utilisateur de Jaeger

Nous pouvons plonger dans les spans d'une seule trace :

Tous les spans qui constituent une seule trace

Notez que nous pouvons déduire le flux de séquence sans le diagramme UML ci-dessus. Encore mieux, la séquence affiche les appels internes à un composant.

Chaque span contient des attributs que l'instrumentation automatique a ajoutés et ceux que nous avons ajoutés manuellement :

Attributs d'un span

Conclusion

Dans cet article, j'ai présenté le tracing en suivant une requête à travers une passerelle API, deux applications basées sur des piles technologiques différentes et leurs bases de données respectives. J'ai effleuré seulement la surface du tracing : dans le monde réel, le tracing impliquerait probablement des composants sans rapport avec HTTP, tels que Kafka et les files d'attente de messages.

Cependant, la plupart des systèmes reposent sur HTTP d'une manière ou d'une autre. Bien que pas trivial à mettre en place, ce n'est pas trop difficile non plus. Tracer les requêtes HTTP à travers les composants est un bon début dans votre voyage vers l'observabilité de votre système.

Le code source complet de cet article peut être trouvé sur GitHub.

Pour aller plus loin :

Tags: