En este post vamos a tratar un punto fundamental dentro de las arquitecturas distribuidas: la trazabilidad entre los distintos componentes.

Para ello, vamos a empezar presentando algunos conceptos teóricos para aterrizarlos después con un ejemplo práctico. Empecemos por definir qué es observabilidad.

¿Qué es observabilidad?

Desde los inicios del software, siempre hemos tenido la necesidad de saber si nuestro software está funcionando como se espera y, en caso contrario, disponer de herramientas que nos avisen lo antes posible (incluso que lo puedan predecir con antelación) y que nos ayuden a encontrar el problema.

Con la evolución de las arquitecturas monolíticas hacia arquitecturas distribuidas orientadas a eventos y el auge del movimiento DevOps, se empieza a hablar mucho del término observabilidad y la imperiosa necesidad de disponer de ella en nuestro sistema.

Dentro de una arquitectura distribuida nos encontramos muchas piezas distintas interactuando entre sí, implementadas en diferentes lenguajes, que escalan horizontalmente (multiplicamos aún más el número de piezas en ejecución) y en las que podemos encontrar comportamientos síncronos y asíncronos. Esto hace que seguir la ejecución de una petición sea complicado, creando la necesidad saber de forma fácil y centralizada:

Partiendo de estos puntos, podemos definir observabilidad como la capacidad de disponer de herramientas que nos permitan conocer el estado del sistema a partir de la observación de su comportamiento, e incluso que nos alerten si detectan alguna anomalía o, en los más avanzados, que puedan predecir un posible fallo.

Estas herramientas se basan principalmente en disponer de estas capacidades:

En este post nos vamos a centrar en el último punto, la trazabilidad distribuida end-to-end.

¿Qué es la trazabilidad distribuida?

Podemos definir trazabilidad como la capacidad de saber exactamente por dónde pasa cada operación que se realiza en el sistema, desde su origen hasta su fin.

Si estamos trabajando con un monolito, la trazabilidad es sencilla, porque todo se queda en el monolito. Pero cuando trabajamos con arquitecturas distribuidas, como hemos comentado anteriormente, seguir el flujo de una petición llega a ser muy complicado y, por tanto, la trazabilidad es algo esencial.

La trazabilidad distribuida tiene su origen en este paper lanzado por Google en 2010, en el que hablan de Dapper:

“Modern Internet services are often implemented as complex, large-scale distributed systems. These applications are constructed from collections of software modules that may be developed by different teams, perhaps in different programming languages, and could span many thousands of machines across multiple physical facili- ties. Tools that aid in understanding system behavior and reasoning about performance issues are invaluable in such an environment.
Here we introduce the design of Dapper, Google’s production distributed systems tracing infrastructure, and describe how our design goals of low overhead, application-level transparency, and ubiquitous deployment on a very large scale system were met.”

Dapper sentó las bases de la trazabilidad distribuida, naciendo proyectos como OpenTracing, OpenCensus, Zipkin o Jaeger. Más adelante, nació OpenTelemetry, en el que nos vamos a centrar.

OpenTelemetry

Como hemos comentado anteriormente, a partir de Dapper nacen una serie de proyectos orientados a la observabilidad. Estos proyectos no comparten un mismo enfoque, lo que produce confusión de cara al usuario, que no tiene muy claro qué opción elegir. Esta “confusión” propicia el nacimiento de OpenTelemetry.

OpenTelemetry es el resultado de la unión de OpenTracing y OpenCensus. Fue aceptado por la CNCF (Cloud Native Computing Foundation) en 2019:

OpenTelemetry fue aceptado por la CNCF (Cloud Native Computing Foundation) en 2019.

OpenTelemetry trata de dar un enfoque único, no conseguido hasta ese momento:

A partir de ahí, y de las lecciones aprendidas de cada enfoque, OpenTelemetry intenta ofrecer un estándar de observabilidad. Esto es especialmente crítico en entornos cloud-native y contenerizados, en los que podemos disponer de aplicaciones de muy distinta naturaleza.

OpenTelemetry se basa principalmente en las señales que emiten las aplicaciones y proporciona una serie de componentes para implementar la observabilidad. Las señales que puede emitir una aplicación son principalmente trazas, métricas y logs. Los componentes pueden ser, desde el propio SDK y librería de instrumentación para los diferentes lenguajes, hasta convenciones semánticas y el collector.

En este post no vamos a entrar en detalle porque la documentación oficial explica muy bien los diferentes conceptos y elementos.

Anatomía de una traza (distribuida)

Una traza no es más que la representación visual, en forma de diagrama temporal, de una serie de eventos enviados por los diferentes elementos por los que pasa una operación, desde que se inicia hasta que termina:

Una traza no es más que la representación visual, en forma de diagrama temporal,

Las trazas disponen de un identificador único, generado en el momento inicial, que se propaga a través de todos los elementos por los que pasa. Este identificador se conoce como “trace id”.

En cada elemento por los que pasa la petición se genera una unidad de información llamada span. Existe un span padre o root correspondiente al elemento que inicia la petición y unos spans hijos, que referencian al padre. Cada span contiene un identificador único, un nombre descriptivo y marcas de tiempo de inicio y fin de la ejecución de ese elemento.

Además, cada elemento de la cadena puede incluir información adicional o atributos, que ayuden a la hora de explotar la información generada en los spans.

Arquitectura de OpenTelemetry

Nos vamos a permitir la licencia de incorporar en este post el diagrama general de arquitectura que nos ofrece la documentación oficial, y que muestra muy bien cómo funciona OpenTelemetry:

Esquema en el que vemos las disittas partes que componen OpenTelemetry.

Podemos ver como hay una serie de microservicios en los que se añade una serie de componentes sin tocar el código fuente (OTel Auto. Inst., OTel API y OTel SDK). Estos componentes se encargan de gestionar la información de observabilidad y de mandarla a un backend de observabilidad (3rd party service) o al propio colector ofrecido por OpenTelemetry que, después lo enviará a otros elementos (por ejemplo, en este post, a Jaeger).

¿Por qué un colector?

OpenTelemetry se basa en la utilización de un colector que desacople el envío de las trazas a los diferentes backends de observabilidad desde las aplicaciones, eliminando problemas asociados a errores y reintentos. También que a aplicación se acople a un backend de observabilidad concreto.

La configuración del colector es muy sencilla. Se realiza con un fichero YAML y se basa en tres elementos principales:

Estos tres elementos se orquestan con un elemento “service”, que declara una “pipeline”. En el apartado “Manos a la obra” veremos un ejemplo de este fichero.

Manos a la obra

Ya llegó el momento de pasar a la práctica y de ver estos conceptos mediante un ejemplo. Todo el código fuente lo puedes encontrar en este repositorio.

Como hemos comentado anteriormente, nos encontramos con el problema de que seguir el flujo de una operación es muy complicado cuando trabajamos con arquitecturas distribuidas.

Caso de uso

Para ilustrarlo, vamos a trabajar con este ejemplo enrevesado en este post:

Ejemplo de cómo es trabajar con OpenTelemetry.

En la imagen podemos ver que el servicio A es el punto de entrada al sistema. Expone un API Rest, con una operación POST que recibe un valor inicial. A partir de ese valor inicial, cada servicio suma 10. Por lo tanto, si empezamos con un valor inicial de 10, veremos un valor de 40 al finalizar las ejecuciones en el topic “topic-d”.

Como vemos, hay cuatro servicios heterogéneos que interaccionan entre sí. Podemos encontrar un servicio implementado con NodeJS, otro con Spring Boot y otros dos con Quarkus. Además, vemos que existen tanto llamadas síncronas como llamadas asíncronas. Saber lo que está pasando en este tipo de sistemas es muy complicado si no se dispone de herramientas como OpenTelemetry.

Arquitectura de trazabilidad

El diagrama de arquitectura de observabilidad del ejemplo, basado en OpenTelemetry, es el siguiente:

El diagrama de arquitectura de observabilidad del ejemplo, basado en OpenTelemetry.

En cada servicio se añaden los componentes “cliente” de OpenTelemetry para que recojan la información de observabilidad y la manden al colector (OTEL COLLECTOR) que, en nuestro caso, será Jaeger.

Colector

En el repositorio de código podrás encontrar un fichero docker-compose.yml con todas las piezas. Veamos la pieza del colector:

 otel-collector:
    image: otel/opentelemetry-collector:latest
    command: [ "--config=/etc/otel-collector-config.yaml" ]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:Z
    ports:
      - "13133:13133" 
      - "4317:4317"   
      - "4318:4318"   
    depends_on:
      - jaeger

Para el colector, necesitamos un fichero de configuración (otel-collector-config.yaml) en el que indicamos que deben enviar la información a Jaeger. En nuestro caso, el fichero es el siguiente:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: otel-collector:4317

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

processors:
  batch:

extensions:
  health_check:

service:
  extensions: [health_check]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger]

En este fichero podemos observar un nodo “pipelines” dentro de “service”. Indica cómo será el flujo de receivers, processors y exporters para las trazas. En nuestro caso tenemos:

Instrumentación de los servicios

Para instrumentar los servicios, simplemente seguimos la documentación oficial. Veamos cómo lo hacemos con cada uno.

Servicio A: NodeJS

En este caso, tal y como nos indica la documentación de OpenTelemetry, creamos un fichero tracing.ts ajeno al código fuente real, que contiene toda la información para la gestión de la observabilidad. Este fichero es el siguiente:

import * as opentelemetry from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { ExpressInstrumentation, ExpressLayerType } from "@opentelemetry/instrumentation-express";
import { KafkaJsInstrumentation } from "opentelemetry-instrumentation-kafkajs";

const otelTracerExporterEndpoint = process.env.OPENTELEMETRY_TRACER_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
const sdk = new opentelemetry.NodeSDK({
    traceExporter: new OTLPTraceExporter({
        url: otelTracerExporterEndpoint,
        headers: {},
    }),
    instrumentations: [
        new HttpInstrumentation(),
        new ExpressInstrumentation({ignoreLayersType: [ExpressLayerType.MIDDLEWARE]}),
        new KafkaJsInstrumentation()
    ],
    resource: new Resource({
        [SemanticResourceAttributes.SERVICE_NAME]: "service-a (Node)",
        [SemanticResourceAttributes.SERVICE_VERSION]: "0.1.0",
    })
});

sdk.start().then(r => console.log("Tracer started"));

Como se puede ver, utilizamos el SDK de OpenTelemetry en el que configuramos la URL del collector (otelTracerExporterEndpoint), la instrumentación automática que queremos utilizar (en este caso Http, Express y KafkaJs) e indicamos cómo se va a llamar el servicio.

Servicio B: Spring Boot 3

De forma similar al anterior, utilizamos el SDK que proporciona OpenTelemetry para Java. Tal y como nos indica la documentación de OpenTelemetry, descargamos el agente y lo configuramos. El agente lo incorporamos como dependencia Maven:

<dependency>
   <groupId>io.opentelemetry.javaagent</groupId>
   <artifactId>opentelemetry-javaagent</artifactId>
   <version>1.22.1</version>
   <scope>runtime</scope>
</dependency>

Y lo configuramos mediante variables de entorno en docker-compose.yml:

service-b:
 image: spring/service-b
 depends_on:
   - otel-collector
   - kafka
 environment:
   JAVA_TOOL_OPTIONS: "-javaagent:/app/lib/opentelemetry-javaagent-1.22.1.jar"
   OTEL_SERVICE_NAME: "service-b(Spring)"
   OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317"
   OTEL_METRICS_EXPORTER: "none"
   SPRING_KAFKA_PRODUCER_BOOTSTRAP-SERVERS: "kafka:9092"
 ports:
   - "8081:8081"

Como podemos ver, no es nada intrusivo con el código de nuestra aplicación.

Servicio C y D: Quarkus

Con Quarkus podríamos hacer lo mismo que con el servicio B (Spring Boot) o seguir la guía oficial de Quarkus. Optamos por la segunda y, en este caso, nos basta con añadir una dependencia Maven:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-opentelemetry</artifactId>
</dependency>

A continuación, solo tenemos que configurar los datos del colector:

 service-c:
 image: quarkus/service-c
 depends_on:
   - otel-collector
   - kafka
   - service-d
 environment:
   QUARKUS_OPENTELEMETRY_TRACER_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317"

Trazando operaciones

Como hemos comentado, el código está disponible en este repositorio. Inicialmente, clonaremos la rama main.

Una vez que hemos clonado la rama, procedemos a levantar el entorno:

sh build.sh
docker-compose up -d

Cuando el entorno esté arriba, veremos todos los servicios en estado “started” o “healthy”:

Cuando el entorno esté arriba, veremos todos los servicios en estado “started” o “healthy”

Ahora lanzamos una petición al primer servicio indicando que, por ejemplo, se establece un valor inicial de 10 (como hemos visto antes, cada servicio suma 10 a ese valor):

curl --location --request POST 'https://www.paradigmadigital.com/' \
--header 'Content-Type: application/json' \
--data-raw '{
    "initialValue": 10
}'

A continuación, lanzamos el siguiente comando para escuchar el topic “topic-d” y ver cuándo ha publicado el servicio D el último mensaje:

docker-compose exec kafka bin/kafka-console-consumer.sh --topic topic-d --from-beginning --bootstrap-server localhost:9092

El comando deja en espera a la consola y, cuando el mensaje se haya publicado el mensaje y se consuma, veremos algo similar a:

 {"id":1675688332228,"initValue":10,"currentValue":40}

Accedemos ahora a Jaeger para ver, en modo visual, la traza de la operación. Para ello vamos a http://localhost:16686 y veremos la pantalla principal, en la que aparecen los 4 servicios:

En la pantalla principal aparecen los 4 servicios.

Seleccionamos el servicio A y pulsamos el botón “Find Traces”. Veremos que aparece una traza correspondiente a la ejecución que acabamos de hacer, con 11 spans:

Traza al pulsar el botón Find traces.

Hacemos click sobre ella para acceder a los detalles:

Detalles de la traza.

De un vistazo, podemos ver por todos los servicios por los que ha pasado. Además, vemos que ha tardado en total 4.99 segundos y también vemos lo que ha tardado cada servicio en realizar su parte de la operación, pudiendo detectar fácilmente cuellos de botella.

Operaciones con error

He añadido dentro del código unas líneas en el servicio B para provocar una excepción en el caso de que el valor inicial sea negativo. Por tanto, si lanzamos la siguiente petición inicial:

curl --location --request POST 'https://www.paradigmadigital.com/' \
--header 'Content-Type: application/json' \
--data-raw '{
    "initialValue": -10
}'

Veremos que se produce un error en el servicio B. Si accedemos a Jaeger, lo veremos de forma visual:

Error que produce la traza del servicio B.

Si hacemos click, podemos ver todo el contenido de la traza de error:

Si hacemos click, podemos ver todo el contenido de la traza de error.

Añadiendo detalle a las trazas

Con la instrumentación automática ya disponemos de toda la información acerca del flujo de una operación. Pero, si necesitamos más detalle, podemos añadir código de instrumentación y crear spans customizados. En este caso sí que vamos a ser intrusivos en el código fuente, al contrario que si utilizamos la instrumentación automática.

Para este ejemplo, es necesario que hagas checkout de la rama “adding-more-detail” del repositorio. A continuación, vemos las modificaciones realizadas en los servicios:

Servicio A

En el servicio A, implementado en NodeJS, vamos a detallar la parte en la que se consume el mensaje del topic “topic-d” para que aparezca en Jaeger, incluyendo el contenido del mensaje recibido. Para ello, añadimos el siguiente fragmento dentro del fichero index.ts:

consumer.run({
   eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {


       …...
       tracer.startActiveSpan('Extrayendo info del mensaje', span => {
           span.setAttribute('Mensaje', msg)

           span.end();
       });


       …..
   },
});

En este caso, como queremos dar más detalle, sí que incluimos código referente a trazabilidad dentro del código fuente principal.

Servicio B, Servicio C y Servicio D

En el resto de servicios vamos a utilizar la anotación Java “@WithSpan” que nos proporciona OpenTelemetry para añadir más detalle a la información de trazabilidad. Anotaremos los métodos que queremos que aparezcan como un span hijo. Además, en estos métodos, anotaremos también los parámetros con “@SpanAttribute”.

Por ejemplo, en el servicio B, uno de los métodos que vamos a anotar es el método privado que publica en el topic “topic-b”:

@WithSpan(value = "Kafka Adapter: publish")
private void send(@SpanAttribute("processData") ProcessDataMessage processDataMessage) {
    kafkaTemplate.setObservationEnabled(true);
    kafkaTemplate.send(topicName, processDataMessage);
}

Además, anotaremos los métodos por capas para disponer de información de tiempos en cada una de ellas. Por ejemplo, en el servicio C:

@WithSpan(value = "Kafka Adapter: publish")
@Inject ServiceDPort serviceD;
@WithSpan(value = "Business layer: updateProcess")
public void updateProcess(ProcessData processData) {
  …
  …
}

Veamos el nuevo nivel de detalle

Volvemos a construir y levantar el entorno con el comando “sh build.sh”. Una vez levantado, volvemos a lanzar la petición POST al servicio A:

curl --location --request POST 'https://www.paradigmadigital.com/' \
--header 'Content-Type: application/json' \
--data-raw '{
    "initialValue": 10
}'

Y esperamos a que llegue de nuevo el mensaje al topic-d:

<lt-highlighter contenteditable="false" class="lt--mac-os" style="display: none;"><lt-div spellcheck="false" class="lt-highlighter__wrapper" style="width: 546.094px !important; height: 42.4716px !important; transform: none !important; transform-origin: 0px 0px !important; zoom: 1 !important; margin-top: 5px !important;"><lt-div class="lt-highlighter__scroll-element" style="top: 0px !important; left: 0px !important; width: 546px !important; height: 42px !important;"><canvas class="lt-highlighter__canvas" width="44" height="17" style="display: none; top: 0px !important; left: 175px !important;"></canvas></lt-div></lt-div></lt-highlighter>docker-compose exec kafka bin/kafka-console-consumer.sh --topic topic-d --from-beginning --bootstrap-server localhost:9092

Ahora abrimos Jaeger y observamos que disponemos de mucho más nivel de detalle en las trazas, pasando de 12 spans a 21:

abrimos Jaeger y observamos que disponemos de mucho más nivel de detalle en las trazas, pasando de 12 spans a 21.

Si hacemos click, podremos verificar que hay más nivel de detalle:

Al hacer click vemos mayor nivel de detalle.

Si analizamos la información, vemos que hay dos “huecos” que corresponden claramente al tiempo desde que se publica un mensaje en Kafka hasta que se recibe.

Si abrimos el último span, podremos comprobar cómo aparece el contenido del mensaje que consume el servicio A de Kafka:

Contenido del mensaje que consume el servicio A de Kafka:

O si abrimos el span del servicio B correspondiente a la publicación del mensaje en Kafka, podremos comprobar que aparece el contenido del mensaje en el tag “processData”:

En el servicio B, contenido del mensaje en el tag “processData”.

Conclusión

En este post hemos visto en qué consiste la observabilidad y, en concreto, la trazabilidad distribuida.

Mediante un ejemplo hemos visto cómo podemos implementarla utilizando OpenTelemetry con servicios heterogéneos, y en diferentes lenguajes.

También hemos visto cómo con la instrumentación automática es suficiente para obtener información útil de trazabilidad, pero que, si se necesita más detalle, existe la posibilidad de añadirlo utilizando la instrumentación manual.

Espero que os haya gustado y que os sea de utilidad.

Cuéntanos qué te parece.

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.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.