Trazabilidad distribuida con Opentracing y Jaeger

Hasta hace poco tiempo Zipkin parecía la única solución para implementar trazabilidad distribuida: era sencillo y venía de caja con el stack spring-cloud, pero en Paradigma Digital hemos descubierto que hay vida más allá del producto creado por Twitter.

En este post intentaremos ahondar en el ecosistema de trazabilidad distribuida y ver dónde encaja cada uno de los actores involucrados en esta “técnica” para monitorizar nuestras arquitecturas.

Historia de la trazabilidad distribuida

La trazabilidad distribuida no es una técnica nueva, existen muchas soluciones de APM (Application Performance Management) en el mercado que siempre la han tenido en cuenta para monitorizar sistemas.

La adopción de las arquitecturas de microservicios ha hecho que pase a un primer plano en los últimos años y ha habido colectivos que han intentado estandarizar su implantación creando documentación y productos opensource.

La trazabilidad distribuida es muy importante para detectar latencias y problemas en nuestro sistema distribuido y es especialmente útil cuando nuestro sistema se encuentra instalado en distintos data centers o incluso distintas regiones.

Vamos a revelar los actores que juegan un rol en la historia reciente de la trazabilidad distribuida:

  • Dapper: es un paper que aparece en el 2010 de la mano de Google, sienta las bases de la trazabilidad distribuida y empieza a hablar de conceptos clave como el span, que más tarde veremos.
  • Zipkin: sistema de trazabilidad distribuida basado en Dapper. Se encarga de almacenar los datos de trazas y permite visualizarlos y explotarlos en sus herramientas.

Este sistema ha sido usado ampliamente y se han creado multitud de librerías para instrumentar aplicaciones y hacer que éstas reporten al sistema Zipkin. A la sombra de este producto aparecieron librerías como Sleuth que se encarga de instrumentar aplicaciones spring-cloud/boot, en Paradigma ya hablamos en otro post sobre el binomio Zipkin – Sleuth.

  • Opentracing: estándar basado en el paper de Google y que se vende como un sistema de trazas independiente del proveedor. La especificación deja muchos aspectos abiertos como por ejemplo, la manera en la que se distribuyen los datos del span entre procesos.

Aunque es más reciente que Zipkin, está teniendo una buena acogida debido a su integración en la la CNCF y a su soporte a múltiples lenguajes.

  • Jaeger: Sistema de trazabilidad distribuida que soporta Opentracing de manera nativa. También está bajo el paraguas de la CNCF y, aunque se ha donado a la comunidad, fue creado inicialmente por la empresa Uber.

Opentracing, los conceptos

Antes de adentrarnos en este post, pensábamos que Opentracing era la enésima librería de trazabilidad… nada más lejos de la realidad.

Opentracing pretende ser un estándar, una guía para dar soporte a todos aquellos que quieran añadir trazabilidad a sus aplicaciones de una manera abierta. Opentracing es una especificación que define una serie de conceptos a implementar.

Su base establece que una traza puede ser un Gráfico Acíclico Dirigido (directed acyclic graph – DAG) de spans.

En esta imagen se representa la relación entre los spans, pero pasemos primero a describir los términos que se representan.

Span

Span es la unidad lógica de trabajo y se hereda del documento Dapper. Puede verse como un tramo en la ejecución de una petición en un sistema. Un span puede ser una llamada http, una conexión con una base de datos, etc.

El span contiene: tiempo de inicio, nombre, duración, tags, logs y relaciones con otros spans. También contiene un spancontext que veremos más adelante.

Los spans tienen relaciones entre ellos, Opentracing soporta, por el momento, dos tipos de relaciones, childoff y followsfrom.

  • Childoff para llamadas RPC típicas como http síncrono.
  • Followsfrom para asincronía/mensajería, en el que el resultado del span padre no depende de los span hijos.

¿Que puede hacer un span?

  • Obtener el contexto del span, datos relacionados con el mismo.
  • Finalizar el span.
  • Establecer y obtener propiedades propias del span, como pueden ser el nombre de operación, tags o baggage (pares clave valor con datos adicionales).

Tracer

Tracer es el encargado de crear spans y de transferirlos entre procesos mediante inject/extract. Existen varias implementaciones de tracer como pueden ser Jaeger, Zipkin o AppDash. Según la documentación de Opentracing, este es el listado de Tracers que soportan el estándar.

NOTA: aunque Zipkin no aparece en el listado, hemos encontrado que Zipkin es compatible cuando se usa junto a brave.

Opentracing persigue que el cambio entre implementaciones de Tracer en una aplicación sea transparente y tan solo requiera cambios de configuración, aunque veremos que la realidad es diferente…

¿Qué puede hacer un tracer?

  • Crear un nuevo span.
  • Extraer un contexto de span del “cable”.
  • Inyectar un span en el “cable”.

Si un tracer no puede extraer un span del contexto deberá crear uno nuevo de tipo root (ver “Span A” en el diagrama) ya que no tiene padre (parent span). Si un tracer recibe un span ya creado, creará otro a partir del recibido (ver “spans C, B, G, etc.” en el diagrama).

Cuando un tracer detecte la llamada a otro componente deberá inyectar el contexto del span en la comunicación.

El estándar define tres formatos necesarios a implementar para propagar los contextos y que se utilizarán en función del tipo de aplicación o interconexión:

  • Textmap: colección de key-value.
  • HTTPHeaders: un listado arbitrario de cabeceras HTTP, recomiendan que sea un juego bien definido, por ejemplo usando un prefijo.
  • Binary: un blob con el contexto del span.

SpanContext

El tercer concepto que define Opentracing es SpanContext, contendrá toda la información que sea dependiente de la implementación. Aquí encontraremos datos como los identificadores de los spans y trazas o los baggage items.

NOTA: toda esta información se puede encontrar ampliada en la especificación.

¿Por qué Jaeger?

La primera pregunta que nos viene a la cabeza es ¿otro sistema de trazabilidad? ¿Por qué? La comunidad de desarrolladores de microservicios estaba contenta con Zipkin… todos menos la buena gente de Uber.

Los ingenieros de Uber usaban partes del modelo Zipkin, pero un buen día decidieron cambiar componentes de su sistema de trazabilidad hasta reemplazarlo por completo. En palabras de los ingenieros de Uber esta fue la motivación para prescindir de Zipkin:

“The Zipkin model did not support two important features available in the OpenTracing standard and our client libraries: a key-value logging API and traces represented as more general directed acyclic graphs rather than just trees of spans”.

Jaeger se creó para soportar nativamente Opentracing, para funcionar en el cloud, para ser escalable, multilenguaje, “monitorizable” y con el propósito de tener un interfaz de usuario adaptado a los nuevos tiempos.

La arquitectura

Esta es la arquitectura diseñada por Uber, nos puede recordar a la de Zipkin aunque tiene peculiaridades.

La primera curiosidad que nos encontramos es que las trazas no se envían al recolector desde la librería cliente, integrada en la aplicación, sino que existe un middleware entre la librería y el componente “recolector”.

Este componente es un proceso de tipo demonio que se instalará en los nodos del sistema o en los contenedores. En entornos  Kubernetes se puede instalar como un daemonset, que asegura una instancia por nodo.

El agente facilita el desarrollo de clientes en distintos lenguajes, al delegarse parte del trabajo en este componente y mejora el rendimiento al usar técnicas de buffering para enviar los spans “en paquetes”.

El collector, por su parte, es independiente y escalable horizontalmente, al igual que query y UI, mientras que en zipkin, estos componentes forman parte de un solo binario.

Testeando el ecosistema

Aunque existe la posibilidad de instalarlo de manera distribuida en Kubernetes, para testear el producto y su integración con los diferentes lenguajes, lo instalaremos en local mediante docker.

Jaeger ofrece una imagen docker con todos los componentes de su arquitectura, lo lanzaremos con el siguiente comando:

docker run -d -e \
COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
--name jaeger \
-p 5775:5775/udp \
-p 6831:6831/udp \
-p 6832:6832/udp \
-p 5778:5778 \
-p 16686:16686 \
-p 14268:14268 \
-p 9411:9411 \
jaegertracing/all-in-one:latest

Una vez lanzado, podemos acceder al interfaz de usuario “Jagger UI” para verificar su funcionamiento (http://localhost:16686/search).

Integración con Spring-boot/cloud

Para instrumentar aplicaciones spring-boot/cloud haremos uso de dos librerías:

  • Opentracing contrib para spring-cloud: implementa el estándar de Opentracing para framework spring y está basada en Sleuth, proyecto que ayuda a la trazabilidad en spring-cloud, pero que no es compatible con Opentracing.

Esta librería no dispone de una implementación de tracer, por lo que se debe complementar con una librería que instrumente un tracer específico.

Ambas librerías son opensource y han sido contribuidas por la comunidad, aunque no son las oficiales de Jaeger. Para integrarlas, las añadiremos en nuestro descriptor de proyecto como se explica en la documentación, nosotros usamos maven.

<dependency>
  <groupId>io.opentracing.contrib</groupId>
  <artifactId>opentracing-spring-cloud-starter</artifactId>
  <version>0.1.13</version>
</dependency>

<dependency>
   <groupId>io.opentracing.contrib</groupId>
   <artifactId>opentracing-spring-cloud-starter-jaeger</artifactId>
   <version>0.1.13</version>
</dependency>

No es el caso pero, si nuestro agente no estuviera en la misma máquina o no usara el puerto por defecto (6831) tendríamos que configurar las siguientes propiedades:

opentracing.jaeger.udp-sender.host=$HOST
opentracing.jaeger.udp-sender.port=$PORT

En nuestra arquitectura de ejemplo tenemos dos microservicios en Java, así que una vez añadidas las librerías, lanzaremos los servicios y realizaremos varias peticiones con curl.

curl localhost:8080/books/books-no-dep
curl localhost:8080/books

La primera petición se realiza a un recurso que no tiene dependencias externas, mientras que la segunda petición se hace a un recurso de un primer microservicio books que depende de un segundo microservicio stars, veamos como se refleja esto en Jaeger.

El interfaz de usuario está dividido en el formulario de búsqueda, a la izquierda, donde podremos filtrar peticiones erróneas, peticiones con latencia, etc.

En la parte superior tenemos un histograma con las últimas peticiones y el tiempo de ejecución de las mismas. Finalmente, en la parte baja se muestra el detalle de las últimas trazas.

En la imagen podemos ver cómo hemos filtrado las peticiones correctas y en el detalle veremos las dos peticiones realizadas:

  • La petición al recurso “booksNoDep” tendrá un solo span, al no tener dependencias externas.
  • Mientras que la petición al recurso “books” genera 3 spans.

¿Qué representan estos tres spans?

  • El primer span identifica la recepción de una petición http en nuestro controlador y el proceso de los datos en el microservicio.
  • El segundo span representa la llamada http realizada desde books a stars.
  • Y el tercer span es la recepción y el proceso en microservicio stars.

¿Cómo se consigue tener una integración completa con tan poco esfuerzo? La magia está en los starters de spring-boot y la autoconfiguración.

Este paradigma permite, en tiempo de inicio de la aplicación, detectar los objetos que se pueden/deben instrumentar y se les inyectan los componentes responsables de la trazabilidad distribuida.

En el caso de la recepción de peticiones http se añade un filtro web delante de los controladores. Este filtro contiene un tracer Jaeger que creará un root span o un child span si es que puede extraer un parent de la petición recibida.

Si ejecutamos en modo debug veremos la información de autoconfiguración:

ServerTracingAutoConfiguration matched:
- @ConditionalOnWebApplication (required) found StandardServletEnvironment (OnWebApplicationCondition)
- @ConditionalOnBean (types: io.opentracing.Tracer; SearchStrategy: all) found bean 'tracer' (OnBeanCondition)

Para la salida http se instrumentan los beans de tipo RestTemplate añadiendo interceptores que añaden las cabeceras de propagación. En el caso de Jaeger estas cabeceras son “uber-trace-id” y “uberctx-*”.

RestTemplateAutoConfiguration matched:
- @ConditionalOnBean (types: io.opentracing.Tracer; SearchStrategy: all) found bean 'tracer' (OnBeanCondition)

¿Fácil no? En estos momentos estarás pensando que lo podría hacer un niño de tres años… y no te falta razón :-)

A nosotros nos ha parecido tan sencillo que nos hemos venido arriba y vamos a intentarlo con un microservicio desarrollado en Golang. Desarrollaremos un tercer servicio que devuelva un listado de portadas disponibles para un libro.

Integración con Golang

Optamos por hacer un microservicio simple usando librerías core incluidas en el propio lenguaje. Golang no tiene el mismo enfoque que spring-boot, aquí no existe “magia”, todo es declarativo y “verboso”, así que tocará ensuciarse las manos ;-)

tracer, closer, err := config.Configuration{
       ServiceName: "covers",
}.NewTracer()

defer closer.Close()
...

Con este código estamos creando un objeto de tipo Tracer de la implementación de Jaeger, hemos usado la patametrización por defecto pero estableciendo el nombre de nuestro servicio (“covers”), esto es útil a la hora de explotar los datos.

Una vez tenemos una instancia de tracer la usaremos para crear un span. A la hora de crear un span tenemos dos casos:

  • Que no se reciba un span anteriormente creado, en este caso debemos crear un root span que no tenga dependencias.
  • Que se reciba un span creado con anterioridad, en este caso debemos crear un span hijo (child span) a partir del recibido (parent span).
func trace(r *http.Request) opentracing.Span {
       wireContext, err := tracer.Extract(
              opentracing.HTTPHeaders,
              opentracing.HTTPHeadersCarrier(r.Header))

       var span opentracing.Span
       if err != nil {
              log.Println("Spancontext not found in carrier")
              span = tracer.StartSpan("listCovers")
       } else {
              log.Println("Using existing span")
              span = tracer.StartSpan(
                     "listCovers",
                     ext.RPCServerOption(wireContext))
       }
       return span
}

En el código podemos ver cómo se intenta extraer mediante el método “Extract” un span creado con anterioridad, y en función de si existe o no actuamos según lo descrito arriba.

El span tiene un nombre de operación que en nuestro caso establecemos a “listCovers”. En la integración con spring-boot este dato es inferido automáticamente mediante introspección.

Con estos dos pedazos de código ya tendremos la integración de opentracing + Jaeger en un servicio rest. No es nuestro caso pero, si estuviéramos ante un servicio con una dependencia externa, habría que hacer uso del método Inject para trasladar el contexto de span a la petición mediante cabeceras http.

Ahora que tenemos nuestro nuevo servicio instrumentado, solo nos queda enlazar el servicio books con el nuevo servicio covers, lanzar unas peticiones de ejemplo y ver cómo se representan los cambios realizados en el interfaz de usuario de Jaeger.

Jaeger UI es capaz de mostrarnos el árbol de dependencias de nuestro sistema, en la siguiente imagen vemos las dependencias entre servicios.

De la misma manera, las trazas se verán actualizadas con el cambio de arquitectura.

Podéis consultar el código en el siguiente repositorio de github.

Conclusión

Con este post hemos arrojado un poco de claridad sobre el ecosistema de trazabilidad distribuida. Tanto el sistema Zipkin como el dueto Opentracing – Jaeger son productos maduros y sencillos de integrar en nuestras aplicaciones.

¿Qué solución usar? Dependerá de cada situación, aunque tiene sentido darle más peso a Jaeger atendiendo a su mejor soporte a Opentracing, su integración con mayor número de lenguajes, su soporte a monitorización mediante “prometheus” y a su compatibilidad con Zipkin.

Escribe un comentario