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:

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.

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.

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.

¿Que puede hacer un span?

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?

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:

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:

[code light="true"]
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
[/code]

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:

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.

[code light="true"]

io.opentracing.contrib
opentracing-spring-cloud-starter
0.1.13

io.opentracing.contrib
opentracing-spring-cloud-starter-jaeger
0.1.13

[/code]

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:

[code light="true"]
opentracing.jaeger.udp-sender.host=$HOST
opentracing.jaeger.udp-sender.port=$PORT
[/code]

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.

[code light="true"]
curl localhost:8080/books/books-no-dep
curl localhost:8080/books
[/code]

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:

¿Qué representan estos tres spans?

¿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:

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

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-*".

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

¿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 ;-)

[code light="true"]
tracer, closer, err := config.Configuration{
ServiceName: "covers",
}.NewTracer()

defer closer.Close()
...
[/code]

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:

[code light="true"]
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
}
[/code]

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.

Cuéntanos qué te parece.

Enviar.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.