Rastreo con OpenTelemetry y Jaeger

Rastreo, un componente crítico, sigue las solicitudes a través de sistemas complejos. Esta visibilidad revela las bottlenecks y los errores, permitiendo resoluciones más rápidas. En un post anterior de nuestra serie de servicios web de Go, exploramos la importancia de la observabilidad. Hoy, nos centraremos en el rastreo. Jaeger recopila, almacena y visualiza rastros de sistemas distribuidos. Proporciona insumos cruciales en los flujos de solicitudes entre servicios. Al integrar Jaeger con OpenTelemetry, los desarrolladores pueden unificar su enfoque de rastreo, garantizando una visibilidad consistente y completa. Esta integración simplifica la diagnose de problemas de rendimiento y mejora la confiabilidad del sistema. En este post, configuraremos Jaeger, lo integraremos con OpenTelemetry en nuestra aplicación y exploraremos la visualización de rastros para obtener insights más profundos.

Motivo

Lo que estamos trabajando para es un panel de Jaeger que se parece a esto:

Mientras nos movemos a diferentes partes de la aplicación (en el frontend de Onehub), los rastros de las solicitudes varias se recopilan (desde el momento de golpear el grpc-gateway) con un resumen de cada uno de ellos. Incluso podemos adentrarnos en uno de los rastros para una visión más detallada. Mire el primer POST solicitud (para crear/enviar un mensaje en un tema):

Aquí vemos todos los componentes que toca la solicitud Create junto con sus tiempos de entrada/salida y el tiempo transcurrido dentro y fuera de los métodos. Realmente muy potente.

Iniciando

TL;DR: Para ver esto en acción y validar el resto de la blog:

  1. La fuente de esto se encuentra en el branch PART11_TRACING.
  2. Construya todas las cosas necesarias (cuando haya realizado el checkout del branch):
make build

  1. Hemos dividido el docker-compose en dos partes (esto se explicará más adelante), así que asegúrese de que están corriendo dos ventanas.
  • Terminal 1: make updb dblogs
  • Terminal 2: make up logs
  1. Vaya a localhost:7080 y aquí va.

Resumen alto nivel 

Actualmente nuestro sistema es:

Con instrumentación con OpenTelemetry, nuestro sistema evolucionará a:

Como mencionamos anteriormente, es muy oneroso que cada servicio use clientes separados para enviar a proveedores específicos. En su lugar, con un colector de OTel ejecutándose independientemente, podemos asegurarnos de que todos los servicios interesados sólo envíen métricas/logs/rastreos a este colector, que a continuación puede exportarlos a varios backends según sea necesario – en este caso, Jaeger para rastreos.

Vamos a empezar.

Configurar el Colector de OTel

El primer paso es agregar nuestro colector de OTel ejecutándose en el entorno de Docker junto con Jaeger para que estén accesibles.

Nota: Hemos dividido nuestra original docker-compose.yml en dos partes:

  1. db-docker-compose.yml: Contiene todos los componentes relacionados con bases de datos e infraestructura (no aplicación) como bases de datos (Postgres, Typesense) y servicios de monitoreo (colector de OTel, Jaeger, Prometheus, etc).
  2. docker-compose.yml: Contiene todos los servicios relacionados con la aplicación (Nginx, gRPC Gateway, dbsync, frontend, etc.)

Las dos configuraciones de docker-compose están conectadas a través de una red compartida (onehubnetwork) a través de la cual los servicios en estas configuraciones pueden comunicarse entre sí. Con esta separación, solo necesitamos reiniciar un subconjunto de servicios tras cambios, acelerando así nuestro desarrollo.

Volviendo a nuestra configuración: en nuestro db-docker-compose.yml, agregamos los siguientes servicios:

YAML

 

Bastante simple, esto configura dos servicios en nuestro entorno de Docker:

  1. otel-collector: El sumidero de todas las señales (métricas/logs/rastreos) enviadas por los diversos servicios monitorizados (seguiremos agregando a esta lista conforme avance el tiempo), utiliza la imagen estándar de OTel junto con nuestro configuración personalizado de OTel (inferior) que describe varias tuberías de observabilidad (es decir, cómo se deben recibir, procesar y exportar las señales de diversas maneras).
  2. jaeger: Nuestra instancia de Jaeger que recibirá y almacenará rastreos (exportados por el otel-collector), este hostea tanto el almacenamiento como el panel de instrumentos (UI) que exportaremos en el prefijo de URL HTTP /jaeger para que sea accesible a través de Nginx.
  3. prometheus: Aunque no es requerido para esta publicación, también exportaremos métricas para que puedan ser recolectadas por Prometheus. No discutiremos esto en detalle en esta publicación.

Unas cosas para notar:

  • Aunque no es requerido para esta publicación, estamos pasando los detalles de conexión de POSTGRES (como variables de entorno) al otel-collector para que pueda extraer las métricas de salud de Postgres.
  • Jaeger (desde la versión v1.35) admite OTLP de forma nativa.
  • La belleza de OTLP es que los recolectores de OTel pueden ser enlazados formando una red de recolectores/procesadores/enrutadores de OTel, etc.
  • OTLP puede ser proporcionado a través de un punto final GRPC o de un punto final HTTP (en los puertos 4317 y 4318 respectivamente).
  • Por defecto, los servicios de OTLP se inician en localhost:4317/4318. Esto es adecuado si Jaeger se ejecuta en el mismo host/pod donde están ejecutándose los servicios (monitoreados). Sin embargo, ya que Jaeger se ejecuta en un pod separado, deben estar enlazados a direcciones externas (0.0.0.0). Esto no estaba claro en la documentación y resultó en un considerable cabeceo.
  • COLLECTOR_OTLP_ENABLED: true ahora es el valor predeterminado y no tiene que ser especificado explícitamente.

Configuración de OTel

También se necesita configurar OTel con receptores específicos, procesadores y expositores. Eso lo haremos en configs/otel-collector.yaml.

Agregando Receptores

Necesitamos decirle al recolector de OTel qué receptores se deben activar. Esto se especifica en la sección receivers:

YAML

 

Esto activa un receptor OTLP en los puertos 4317 y 4318 (grpc, http respectivamente). Existen muchos tipos de receptores que se pueden iniciar. Como ejemplo, también hemos agregado un receptor “postgresql” que scrapeará activamente Postgres para las métricas (aunque esto no es relevante para este post). Los receptores también pueden ser de base de extracción o de envío. Los receptores de base de extracción periódicamente scrapean objetivos específicos (por ejemplo, postgres), mientras que los receptores de envío escuchan y “reciben” métricas/logs/rastreos de aplicaciones utilizando el SDK de cliente OTel.

Eso es todo. Ahora nuestro colector está listo para recibir (o scrapear) las métricas adecuadas.

Agregar Procesadores

Los procesadores en OTel son una manera de transformar, mapear, agrupar, filtrar y/o enriquecer señales recibidas antes de exportarlas. Por ejemplo, los procesadores pueden muestrear métricas, filtrar logs o incluso agrupar señales para aumentar la eficiencia. Por defecto, no se agregan procesadores (haciendo que el colector sea un paso a través). Ignoraremos esto por ahora.

Agregar Exportadores

Ahora es hora de identificar dónde queremos que se exporten las señales: backends que se adecuan mejor a cada señal. Al igual que los receptores, los exportadores también pueden basarse en pull o en push. Los exportadores basados en push se utilizan para emitir señales a otro receptor que actúa en modo push. Estos son salientes. Los exportadores basados en pull exponen puntos finales que pueden ser extraídos por otros receptores basados en pull (por ejemplo, prometheus). Agregaremos un exportador de cada tipo: uno para el seguimiento y uno para que Prometheus extraiga de él (aunque Prometheus no es el tema de este post):

YAML

 

Aquí tenemos un exportador a Jaeger que ejecuta el colector OTLP, como se indica en otlp/jaeger. Este exportador regularmente enviará señales de seguimiento a Jaeger. También agregamos un punto final “extractor” en el puerto 9090 que Prometheus extraerá regularmente de él.

El exportador “debug” simplemente se utiliza para volcar señales a las salidas estándar de entrada/error.

Defina Pipelines

Las secciones de receptor, procesador y exportador simplemente definen los módulos que serán habilitados por el colector. Aún no se invocan. Para invocar/activarlos realmente, deben referirse a ellos como “pipelines”. Las pipelines definen cómo las señales fluyen a través y se procesan por el colector. Nuestras definiciones de pipeline (en la sección services) clarificarán esto:

YAML

 

Aquí estamos definiendo dos pipelines. Nota cómo los pipelines son similares pero permiten dos modos de exportación diferentes (Jaeger y Prometheus). Ahora vemos el poder de OTel y en la creación de pipelines dentro de él.

  1. traces:

  • Recibe señales de los SDKs del cliente
  • Sin procesamiento
  • Exportar rastreos a la consola y Jaeger
  1. metrics:
  • Recibir señales de los SDKs de cliente
  • Sin procesamiento
  • Exportar métricas a la consola y Prometheus (exposición de un punto final para que él pueda recopilar).

Exposición de paneles a través de Nginx

Jaeger proporciona un panel para visualizar datos de rastreo sobre todas nuestras solicitudes. Esto se puede visualizar en un navegador habilitando lo siguiente en nuestra configuración de Nginx. De nuevo, sin embargo, no es el tema de este post – también estamos exponiendo la interfaz de usuario de Prometheus a través de Nginx en el prefijo de la ruta HTTP /prometheus.

YAML

 

Visualización de rastreos en Jaeger

La interfaz de usuario de Jaeger es bastante completa y tiene varias características que puede explorar. Navegue a la interfaz de usuario de Jaeger en el navegador. Verá una interfaz amplia para buscar y analizar rastreos. Avance y familiaricece con las secciones principales, incluyendo la barra de búsqueda y la lista de rastreos. Puede buscar varios rastreos mediante criterios de búsqueda y filtrar por servicio, duraciones de tiempo, componentes, etc.

Analizar las líneas de tiempo de los rastreos en las diferentes solicitudes para entender la secuencia de operaciones. Cada españa representa una unidad de trabajo, mostrando fechas de inicio y finalización, duración y metadatos relacionados. Esta vista detallada es muy útil para identificar los cuellos de botella de rendimiento y errores dentro del rastreo.

Integración del SDK de cliente

Hasta el momento hemos configurado nuestros sistemas para visualizar, consumir señales, etc. Sin embargo, nuestros servicios aún no están actualizados para emitir señales a OTel. Aquí integraremos con el cliente SDK en varias partes de nuestro código (Golang). La documentación del SDK es un lugar fantástico para familiarizarse primero con algunos de los conceptos.

Los conceptos clave con los que nosremos enfrentando están descritos a continuación.

Recursos

Los recursos son la entidad que produce la señal. En nuestro caso, el alcance del recurso es el binario que aloja los servicios. Actualmente, tenemos un solo recurso para toda la aplicación Onehub, pero esto podría dividirse más adelante.

Esto está definido en cmd/backend/obs.go. Tenga en cuenta que el cliente SDK no necesitó que entraramos en detalles explícitos de la definición del recurso. La herramienta estándar (sdktrace.WithResource) nos permite crear una definición de recurso infiriendo las partes más útiles en tiempo de ejecución (como el nombre del proceso, el nombre del pod, etc.).

Solo tuvimos que anular una cosa: la variable de entorno OTEL_RESOURCE_ATTRIBUTES: service.name=onehub.backend,service.version=0.0.1 para el servicio onehub en docker-compose.yml.

Contextos de propagación

Context propagation es un tema muy importante en la observabilidad. Los diferentes pilares son de potencia exponencial cuando podemos correlacionar señales de cada uno de los pilares para identificar problemas con nuestro sistema. Piensa en los contextos como extraños bits de datos que pueden atarse a las señales: es decir, pueden “unirse” de alguna manera única para relacionar las diferentes señales con un determinado grupo (por ejemplo, una solicitud).

Proveedores/Exportadores

Para cada señal, OTel proporciona una interfaz de Proveedor (por ejemplo, TracerProvider para exportar spans/rastreos, MeterProvider para exportar métricas, LoggerProvider para exportar registros, y así sucesivamente). Para cada una de estas interfaces, pueden haber varias implementaciones, por ejemplo, un proveedor de Debug para enviar a los flujos stdout/err, un proveedor de OTel para exportar a otro punto final de OTel (en una cadena), o incluso directamente a través de una variedad de exportadores. Sin embargo, en nuestro caso, queremos deferir la elección de cualquier proveedor fuera de nuestros servicios y, en su lugar, enviar todas las señales al colector de OTel que se ejecuta en nuestro entorno.

Para abstractizar esto, crearemos un tipo “OTELSetup” que mantenga un seguimiento de los diferentes proveedores que podríamos querer utilizar o sustituir. En cmd/backend/obs.go, tenemos:

Go

 

Este es un envoltorio simple que mantiene un seguimiento de aspectos comunes necesarios por el SDK OTel. Aquí tenemos proveedores (Logger, Tracer y Metric) así como maneras de proporcionar contexto (para la trazabilidad). El recurso general utilizado por todos los proveedores también se especifica aquí. Las funciones de apagado son interesantes. Son funciones llamadas por los proveedores cuando el exportador subyacente ha finalizado (gracias o debido a una salida). El envoltorio en sí toma un tipo genérico, de modo que los instanciadores específicos de este Setup puedan usar sus propios datos personalizados.

El repositorio contiene dos implementaciones de esto:

Instanciaremos el segundo en nuestra aplicación. No entraremos en detalles de las implementaciones específicas ya que provienen de los ejemplos en el SDK con pequeños errores y refactorizaciones. Específicamente, echar un vistazo al ejemplo otel-collector para obtener inspiración.

Inicializar los proveedores OTel.

La esencia de habilitar el colector en nuestros servicios es que se inicia una especie de “contexto” relacionado con OTel en todos los “puntos de entrada”. Si este contexto se crea al inicio, entonces se enviará a todos los destinatarios llamados aquí, que a su vez se propagan posteriormente (mientras hacemos lo correcto).

Tomando la llamada API simple ListTopics (api/vi/topics), nuestra solicitud sigue el siguiente camino y regresa:

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

En nuestro caso, los puntos de entrada aquí son al inicio cuando el gRPC Gateway recibe una solicitud API de Nginx (podríamos iniciar las trazas desde el punto en que la solicitud HTTP impacta a Nginx para destacar incluso latencias en Nginx, pero lo dejaremos para un momento).

Lo que se necesita es:

  • El gRPC Gateway recibe una solicitud.
  • Crea una instancia “personalizada” de context.Context específica de OTel.
  • Crea una conexión personalizada con el servicio gRPC correspondiente (p. ej., TopicService) pasando este contexto en lugar del predeterminado.
  • El servicio correspondiente utiliza este contexto al emitir las trazas.

Vamos a pasar por esto paso a paso.

Inicializar y Preparar el SDK de OTel para su Uso

En main.go, vamos a inicializar primero la conexión con el colector:

Go

 

  • Líneas 8-16: Creamos una conexión con el otel-collector que se ejecuta en nuestro entorno de Docker.
  • Líneas 17-21: Posteriormente, configuramos el setup de OTel con proveedores de trazas y métricas para que nuestro coleccionador pueda ahora enviar todas las trazas y métricas (recuerde que anteriormente definimos receptores en la configuración de OTel).
  • Líneas 23-25: Configuramos finalizadores para limpiar conexiones/proveedores de OTel, etc., al apagado.
  • Línea 27: Configuramos nuestra base de datos y conexiones como antes.
  • Líneas 29 +: anteriormente simplemente iniciamos los servicios GRPC y Gateway en segundo plano y ya estaba. No nos preocupábamos realmente por sus estados de retorno o salida. Para un sistema más resistente, es importante tener una mejor visión de los ciclos de vida de los servicios que estamos iniciando. Así que ahora pasamos el canal de “callback” para cada uno de los servicios que estamos iniciando. Cuando los servidores salen, los métodos respectivos llamarán de vuelta en estos canales disponibles para indicar que salieron gracefully. Nuestro binario entero salirá cuando salga cualquiera de estos servicios.

Por ejemplo, veamos cómo nuestro servicio Gateway aprovecha este canal.

En lugar de iniciar el servidor HTTP (para el grpc-gateway) como:

1    http.ListenAndServe(gw_addr, mux)

Ahora tenemos:

Go

 

Preste atención a líneas 9-14 donde se supervisa el apagado del servidor en un goroutine separado y a línea 15 donde, si hubo un error cuando el servidor salió, se envía mediante el canal de “notificación” que se pasó como argumento a este método.

Ahora las diferentes partes de nuestros servicios tienen acceso a una conexión “activa” de OTLP para usar cada vez que se envían señales.

Middleware de OTel para gRPC Gateway

Anteriormente, la instancia de http.Server utilizada para iniciar el gRPC Gateway utiliza un manejador personalizado: el http.Handler del paquete OTel HTTP. Este manejador toma una instancia existente de http.Handler, la decora con el contexto de OTel y asegura su propagación a cualquier otra parte en el flujo descendente que se llame.

Go

 

Nuestro manejador HTTP es simple:

  • Línea 4: Creamos un nuevo envoltorio específico de OTel para manejar solicitudes HTTP.
  • Línea 5: Configuramos la opción SpanFormatter para que las trazas se puedan identificar de forma única por el método y las rutas de solicitud HTTP. Sin este SpanNameFormatter, el nombre predeterminado de nuestras trazas en el gateway sería simplemente "gateway", resultando en que todas las trazas se parezcan a esto:

Envolviendo puertos de escucha a llamadas de gRPC con OTel

Por defecto, la biblioteca gRPC Gateway crea un “contexto plano” al crear/administrar conexiones a los servicios gRPC subyacentes. Después de todo, el gateway no sabe nada sobre OTel. En este modo, una conexión (del usuario/navegador) al gRPC Gateway y la conexión del gateway al servicio gRPC serán tratadas como dos trazas diferentes.

Así que es importante quitar la responsabilidad de crear conexiones gRPC del Gateway y proporcionar en su lugar una conexión que ya sea consciente de OTel. Lo haremos ahora.

Antes de la integración con OTel, registrabamos un manejador de Gateway para nuestros gRPCs con:

Go

 

Ahora es simplemente pasar una conexión diferente:

Go

 

Lo que hemos hecho es primero crear un client (Linea 6) que actúa como una fábrica de conexiones para nuestro servidor gRPC. El cliente es bastante simple. Solo se utiliza el gRPC ClientHandler (otelgrpc.NewClientHandler) para crear la conexión. Esto garantiza que el contexto en la actual traza que comenzó en una nueva solicitud HTTP se propaga ahora al servidor gRPC a través de este manejador.

Eso es todo. Ahora deberíamos comenzar a ver la nueva solicitud al Gateway y la solicitud Gateway->gRPC en una sola traza consolidada en lugar de dos trazas diferentes.

Iniciar y finalizar Spans

Estamos casi listos. hasta el momento:

  • Hemos habilitado el colector OTel y Jaeger para recibir y almacenar datos de traza (spans) (en docker-compose).
  • Configuramos el colector OTel básico (que se ejecuta como un pod separado) como nuestro “proveedor” de trazadores, métricas y logs (es decir, la integración de nuestra aplicación con OTel utilizaría este punto final para depositar todas las señales).
  • Envolvemos el manejador HTTP del Gateway para que sea compatible con OTel, de manera que se crean y se propagan trazas y sus contextos.
  • Sobreescribimos el cliente (gRPC) en el gateway para que ahora envuelva el contexto de OTel de nuestra configuración OTel en lugar de utilizar un contexto predeterminado.
  • Creamos instancias globales de trazador/medidor/registrador para que podamos enviar señales reales utilizándolas.

Ahora necesitamos emitir etiquetas “span” para todos los lugares “interesantes” en nuestro código. Tomemos el método ListTopics como ejemplo (en services/topics.go):

Go

 

Llamamos a la base de datos para obtener los temas y los devolvemos. Similar a la función de acceso a base de datos (en datastore/topicds.go):

Go

 

Aquí estaríamos principalmente interesados en cuánto tiempo se dedica a cada una de estas funciones. Simplemente creamos spans en cada una de estas y ya está. Nuestras adiciones a los métodos del servicio y del almacén de datos (respectivamente) son:

  • services/topics.go:
Go

 

  • datastore/topicds.go:
Go

 

El patrón general es:

1. Crear un span:

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

En este caso, el contexto proporcionado (ctx) es “envuelto” y se devuelve un nuevo contexto. Podemos (y debemos) pasar este nuevo contexto envuelto a métodos posteriores. Eso es lo que hacemos cuando llamamos al método de almacén de datos ListTopics.

2. Terminar el span:

defer span.End()

Terminar un span (cuando el método devuelve) asegura que se registren los correctos tiempos/códigos de finalización, etc. También podemos hacer otras cosas como agregar etiquetas y estados a esto si es necesario, para llevar más información que ayude con la depuración.

Eso es todo. Puede ver sus hermosos rastros en Jaeger y obtener más y más insights sobre la eficiencia de sus solicitudes, desde el principio hasta el final!

Conclusión

En este post hemos abarcado mucho contenido y aún así sólo hemos tocado la superficie de todos los detalles detrás de OTel y la trazabilidad. En lugar de sobrecargar este post (ya bastante comprensivo), en los próximos posts presentaremos conceptos más recientes e intricados. Por ahora, intenta aplicar esto en sus propios servicios y prueba jugando con otros expeditores y receptores en el repositorio otel-contrib.

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