Trazabilidad de Extremo a Extremo con OpenTelemetry

Nicolas Fränkel

Nicolas Fränkel

August 31, 2022

Ecosystem

Ya sea que implementes o no microservicios (y probablemente no deberías), tu sistema está compuesto muy probablemente por múltiples componentes. El sistema más sencillo probablemente esté formado por un proxy inverso, una aplicación y una base de datos. En este caso, el monitoreo no solo es una buena idea; es un requisito. Cuanto mayor sea el número de componentes por los que puede fluir una solicitud, más fuerte será el requisito.

Sin embargo, el monitoreo es solo el comienzo del viaje. Cuando las solicitudes comienzan a fallar en masa, necesitas una vista agregada en todos los componentes. Esto se llama tracing, y es uno de los pilares de la observabilidad; los otros dos son métricas y logs.

En esta publicación, me centraré únicamente en los traces y describiré cómo puedes comenzar tu viaje hacia la observabilidad.

La especificación W3C Trace Context

Una solución de tracing debe proporcionar un formato estándar para trabajar en pilas tecnológicas heterogéneas. Dicho formato debe adherirse a una especificación, ya sea formal o de facto.

Es importante entender que una especificación rara vez aparece de la nada. En general, el mercado ya tiene un par de implementaciones distintas. La mayoría de las veces, una nueva especificación conduce a una implementación adicional, como describe el famoso cómic de XKCD:

el famoso cómic de XKCD describe

Sin embargo, a veces ocurre un milagro: el mercado se adhiere a la nueva especificación. Aquí, Trace Context es una especificación del W3C, y parece haber logrado el objetivo:

Esta especificación define encabezados HTTP estándar y un formato de valor para propagar información de contexto que permite escenarios de tracing distribuido. La especificación estandariza cómo se envía y modifica la información de contexto entre servicios. La información de contexto identifica de manera única solicitudes individuales en un sistema distribuido y también define un medio para agregar y propagar información de contexto específica del proveedor. -- https://www.w3.org/TR/trace-context/

Dos conceptos críticos emergen del documento:

  • Un trace sigue el camino de una solicitud que abarca múltiples componentes
  • Un span está vinculado a un solo componente y se relaciona con otro span mediante una relación padre-hijo

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

En el momento de escribir esto, la especificación es una recomendación del W3C, que es la etapa final.

Trace Context ya tiene muchas implementaciones. Una de ellas es OpenTelemetry.

OpenTelemetry como el estándar de oro

Cuanto más cerca estés de la parte operativa de TI, mayores serán las posibilidades de que hayas oído hablar de OpenTelemetry:

OpenTelemetry es una colección de herramientas, APIs y SDKs. Úsalo para instrumentar, generar, recopilar y exportar datos de telemetría (métricas, logs y traces) para ayudarte a analizar el rendimiento y comportamiento de tu software. OpenTelemetry está generalmente disponible en varios lenguajes y es adecuado para su uso. -- https://opentelemetry.io/

OpenTelemetry es un proyecto gestionado por la CNCF. Antes de OpenTelemetry existían dos proyectos:

  • OpenTracing, enfocado en traces como su nombre lo indica
  • OpenCensus, cuyo objetivo era gestionar métricas y traces

Ambos proyectos se fusionaron y agregaron logs. OpenTelemetry ahora ofrece un conjunto de "capas" enfocadas en la observabilidad:

  • APIs de instrumentación en varios lenguajes
  • Implementaciones canónicas, nuevamente en diferentes lenguajes
  • Componentes de infraestructura como recolectores
  • Formatos de interoperabilidad, como el Trace Context del W3C

Nota que, aunque OpenTelemetry es una implementación de Trace Context, hace más. Trace Context se limita a HTTP, mientras que OpenTelemetry permite que los spans crucen componentes no web, como Kafka. Esto está fuera del alcance de esta publicación.

El caso de uso

Mi caso de uso favorito es una tienda de comercio electrónico, así que no lo cambiemos. En este caso, la tienda está diseñada alrededor de microservicios, cada uno accesible a través de una API REST y protegido detrás de un API Gateway. Para simplificar la arquitectura para esta publicación, usaré solo dos microservicios: catalog gestiona los productos, y pricing maneja el precio de los productos.

Cuando un usuario llega a la aplicación, la página de inicio obtiene todos los productos, obtiene sus respectivos precios y los muestra.

Ejemplo de flujo de solicitud a través de varios componentes

Para hacer las cosas más interesantes, catalog es una aplicación Spring Boot codificada en Kotlin, mientras que pricing es una aplicación Flask en Python.

El tracing debería permitirnos seguir el camino de una solicitud a través del gateway, ambos microservicios y, si es posible, las bases de datos.

Traces en el gateway

El punto de entrada es la parte más emocionante del tracing, ya que debería generar el ID del trace: en este caso, el punto de entrada es el gateway. Usaré Apache APISIX para implementar la demo:

Apache APISIX proporciona ricas características de gestión de tráfico como Balanceo de Carga, Upstream Dinámico, Lanzamiento Canario, Circuit Breaking, Autenticación, Observabilidad, etc. -- https://apisix.apache.org/

Apache APISIX se basa en una arquitectura de plugins y ofrece un plugin de OpenTelemetry:

El plugin opentelemetry se puede usar para reportar datos de tracing según la especificación de OpenTelemetry. El plugin solo admite OLTP codificado en binario sobre HTTP. -- https://apisix.apache.org/docs/apisix/plugins/opentelemetry/

Configuremos el 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. Ejecuta Apache APISIX en modo independiente para facilitar la demo. Es una buena práctica en producción de todos modos.
  2. Configura opentelemetry como un plugin global.
  3. Establece el nombre del servicio. Es el nombre que aparecerá en el componente de visualización de traces.
  4. Envía los traces al servicio jaeger. La siguiente sección lo describirá.

Queremos rastrear cada ruta, por lo que en lugar de agregar el plugin a cada ruta, debemos configurar el plugin como global:

global_rules:
  - id: 1
    plugins:
      opentelemetry:
        sampler:
          name: always_on #1
  1. El tracing tiene un impacto en el rendimiento. Cuanto más rastreamos, más impactamos. Por lo tanto, debemos equilibrar cuidadosamente el impacto en el rendimiento frente a los beneficios de la observabilidad. Sin embargo, para la demo, queremos rastrear cada solicitud.

Recolección, almacenamiento y visualización de traces

Aunque Trace Context es una especificación del W3C y OpenTelemetry es un estándar de facto, existen muchas soluciones en el mercado para recopilar, almacenar y visualizar traces. Cada solución puede proporcionar las tres capacidades o solo parte de ellas. Por ejemplo, la pila Elastic maneja el almacenamiento y la visualización, pero debes confiar en otra cosa para la recolección. Por otro lado, Jaeger y Zipkin proporcionan una suite completa para cumplir con las tres capacidades.

Jaeger y Zipkin son anteriores a OpenTelemetry, por lo que cada uno tiene su formato de transporte de traces. Sin embargo, ambos proporcionan integración con el formato de OpenTelemetry.

En el alcance de esta publicación, la solución exacta no es relevante, ya que solo necesitamos las capacidades. Elegí Jaeger porque proporciona una imagen Docker todo en uno: cada capacidad tiene su componente, pero todos están integrados en la misma imagen, lo que facilita mucho la configuración.

Los puertos relevantes de la imagen son los siguientes:

PuertoProtocoloComponenteFunción
16686HTTPquerysirve el frontend
4317HTTPcollectoracepta OpenTelemetry Protocol (OTLP) sobre gRPC, si está habilitado
4318HTTPcollectoracepta OpenTelemetry Protocol (OTLP) sobre HTTP, si está habilitado

La parte de Docker Compose se ve así:

services:
  jaeger:
    image: jaegertracing/all-in-one:1.37 #1
    environment:
      - COLLECTOR_OTLP_ENABLED=true #2
    ports:
      - "16686:16686" #3
  1. Usa la imagen all-in-one.
  2. Muy importante: habilita el colector en formato OpenTelemetry.
  3. Expone el puerto de la interfaz de usuario.

Ahora que hemos configurado la infraestructura, podemos centrarnos en habilitar los traces en nuestras aplicaciones.

Traces en aplicaciones Flask

El servicio pricing es una aplicación simple de Flask. Ofrece un solo endpoint para obtener el precio de un solo producto desde la base de datos.

@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. Endpoint
  2. La ruta requiere el ID del producto.
  3. Obtiene datos de la base de datos usando SQLAlchemy.
  4. Los motores de precios reales nunca devuelven el mismo precio con el tiempo; aleatoricemos un poco el precio por diversión.

Advertencia: Obtener un solo precio por llamada es altamente ineficiente. Requiere tantas llamadas como productos, pero hace que el trace sea más interesante. En la vida real, la ruta debería poder aceptar múltiples IDs de productos y obtener todos los precios asociados en una sola solicitud-respuesta.

Ahora es el momento de instrumentar la aplicación. Hay dos opciones disponibles: instrumentación automática e instrumentación manual. La automática es de bajo esfuerzo y una victoria rápida; la manual requiere tiempo de desarrollo enfocado. Recomendaría comenzar con la automática y solo agregar la manual si es necesario.

Necesitamos agregar un par de paquetes de Python:

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

Necesitamos configurar un par de parámetros:

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. Envía los traces a Jaeger.
  2. Establece el nombre del servicio. Es el nombre que aparecerá en el componente de visualización de traces.
  3. No estamos interesados ni en logs ni en métricas.

Ahora, en lugar de usar el comando estándar flask run, lo envolvemos:

opentelemetry-instrument flask run

Solo con esto, ya recopilamos spans de llamadas a métodos y rutas de Flask.

Podemos agregar manualmente spans adicionales si es necesario, por ejemplo:

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. Agrega un span adicional con la etiqueta y el atributo configurados.

Traces en aplicaciones Spring Boot

El servicio catalog es una aplicación Reactiva de Spring Boot desarrollada en Kotlin. Ofrece dos endpoints:

  • Uno para obtener un solo producto.
  • Otro para obtener todos los productos.

Ambos primero buscan en la base de datos de productos y luego consultan el servicio pricing anterior para obtener el precio.

Al igual que en Python, podemos aprovechar la instrumentación automática y manual. Comencemos con lo más fácil, la instrumentación automática. En la JVM, lo logramos a través de un agente:

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

Al igual que en Python, crea spans para cada llamada a métodos y punto de entrada HTTP. También instrumenta llamadas JDBC, pero tenemos una pila Reactiva y, por lo tanto, usamos R2DBC. Para el registro, hay un problema en GitHub abierto para agregar soporte.

Necesitamos configurar el comportamiento predeterminado:

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. Envía los traces a Jaeger.
  2. Establece el nombre del servicio. Es el nombre que aparecerá en el componente de visualización de traces.
  3. No estamos interesados ni en logs ni en métricas.

Al igual que en Python, podemos mejorar el juego agregando instrumentación manual. Hay dos opciones disponibles, programática y basada en anotaciones. La primera es un poco complicada a menos que introduzcamos Spring Cloud Sleuth. Agreguemos anotaciones.

Necesitamos una dependencia adicional:

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

Ten cuidado, el artefacto fue recientemente reubicado desde io.opentelemetry:opentelemetry-extension-annotations.

Ahora podemos anotar nuestro código:

@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. Agrega un span adicional con la etiqueta configurada.
  2. Usa el parámetro como un atributo, con la clave establecida en id y el valor el valor en tiempo de ejecución del parámetro.

El resultado

Ahora podemos jugar con nuestra demo simple para ver el resultado:

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

Las respuestas no son interesantes, pero veamos la interfaz de usuario de Jaeger. Encontramos ambos traces, uno por llamada:

Lista de traces en la interfaz de usuario de Jaeger

Podemos profundizar en los spans de un solo trace:

Todos los spans que constituyen un solo trace

Nota que podemos inferir el flujo de secuencia sin el diagrama UML anterior. Incluso mejor, la secuencia muestra las llamadas internas a un componente.

Cada span contiene atributos que la instrumentación automática agregó y los que agregamos manualmente:

Atributos de un span

Conclusión

En esta publicación, he mostrado el tracing siguiendo una solicitud a través de un API gateway, dos aplicaciones basadas en diferentes pilas tecnológicas y sus respectivas bases de datos. Solo he rozado la superficie del tracing: en el mundo real, el tracing probablemente involucraría componentes no relacionados con HTTP, como Kafka y colas de mensajes.

Aún así, la mayoría de los sistemas dependen de HTTP de una forma u otra. Aunque no es trivial de configurar, tampoco es demasiado difícil. Rastrear solicitudes HTTP a través de componentes es un buen comienzo en tu viaje hacia la observabilidad de tu sistema.

El código fuente completo para esta publicación se puede encontrar en GitHub.

Para ir más allá:

Tags: