Trazabilidad en servicios distribuidos con Sleuth y Zipkin

Escrito por picodotdev el .
java planeta-codigo
Enlace permanente Comentarios

En un sistema complejo como una arquitectura de microservicios medir los tiempos de respuesta de cada uno de ellos ayuda a identificar si alguno se está comportando de forma anómala. Sleuth permite asignar un identificador global que es compartido por todos los microservicios invocados en la misma transacción, permite exportar los tiempos de respuesta a Zipkin que ofrece un panel web en el que identificar que llamadas se han hecho entre microservicios y cuales han sido sus tiempos de respuesta.

El un sistema basado en microservicios unos servicios depende de otros y se comunican haciendo llamadas entre ellos. Las llamadas entre los servicios son un punto de fallo y problemas que conviene monitorizar para que el conjunto de la aplicación funcione correctamente. Con un número importante de servicios la monitorización y la trazabilidad es una de las funcionalidades de las aplicaciones basadas en microservicios, muchas de estas funcionalidades son proporcionadas de forma específica por una herramienta.

En el caso de trazabilidad en microservicios con Sleuth proporciona un identificador global que permite correlacionar todas las trazas desencadenadas por una petición en los diferentes servicios, con el identificador global de una traza se puede obtener el resto de trazas del mismo servicio. Con el identificador global es posible acceder al registro de trazas y obtener todas las correlacionadas con el identificador global como sería el caso de guardar las trazas en Elasticsearch, Logstash y Kibana. Pero las trazas emitidas por los microservicios no ofrecen métricas de cuánto tiempo ha tardado cada uno de los microservicios en su ejecución en devolver la respuesta y sin nada adicional no permite correlacionar las trazas de un servicio con las trazas del servicio llamado.

Otro aspecto de las llamadas entre los microservicios es medir los tiempos de respuesta y latencia entre los servicios, si un servicio tiene un bajo rendimiento y tarda en responder es posible que produzca errores en los servicios que lo usan, provocando otros errores en el sistema. Para que el mal funcionamiento de un servicio provoque errores en otros los servicios han de estar preparados y admitir tolerancia a fallos con la librería Resilience4j. Micrometer, Prometheus y Grafana son la forma de obtener métricas y monitorizar una aplicación Java con Spring Boot que incluye los tiempos de respuesta de los microservicios.

Zipkin es una herramienta que recolecta las transacciones creadas por Sleuth en la ejecución de los microservicios e información de los tiempos de respuesta de las invocaciones que han intervenido en una transación. Ofrece las dos funcionalidades la recolección de datos y la obtención de los mismos. Tanto la recolección como el almacenamiento ofrecen diferentes herramientas para implementarlo, la recolección puede ser mediante peticiones HTTP, RabbitMQ o Kafka y el almacenamiento en memoria, MySQL, Cassandra o Elasticsearch.

En el ejemplo hay un microservicio que hace de servidor y otro de cliente que se comunican mediante peticiones HTTP con REST. Tanto el microservicio cliente como el microservicio servidor usan Sleuth que genera identificativos globales que permiten correlacionar todas las peticiones entre los diferentes servicios.

Sleuth proporcionar trazabilidad con identificativos globales que podrían ser recuperados a través del sistema de logging centralizado como Elasticsearch, Logstash y Kibana. Usando una librería de logging como Log4j se puede configurar Log4j para que emita en las trazas los identificadores globales.

Ejemplo de microservicio con Spring Boot, Sleuth y Zipkin

En el ejemplo de trazabilidad de microservicios con Sleuth consistía en un servicio implementado con Spring Boot, también tiene un cliente que realiza de forma periódica peticiones al servicio. Ambos tienen Sleuth integrado y en las trazas de la consola en ambos aparece el identificador global traceId de la traza. En este ejemplo se configura Sleuth para que envíe a Zipkin las transacciones de estos dos pequeños microservicios.

Para que Sleuth envíe al servidor las métricas de las llamadas entre los microservicios basta con añadir al proyecto del cliente y microservicio la dependencia spring-cloud-starter-zipkin y configurar el medio de transporte que se utiliza para enviar las métricas al servidor, en este caso mediante peticiones HTTP y la dirección de Zipkin. Esto se configura con las propiedades de Spring Boot spring.zipkin.enabled, spring.zipkin.baseUrl y spring.zipkin.sender.type.

1
2
3
4
5
6
7
8
9
...

dependencies {
    ...
    implementation('org.springframework.cloud:spring-cloud-starter-zipkin', excludeSpringBootStarterLogging)
    ...
}

...
build.gradle
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...

spring:
  application:
    name: client
  zipkin:
    enabled: true
    baseUrl: http://zipkin:9411/
    sender:
      type: web

...

client.yml

Los siguientes comandos inician el registro y descubrimiento de servicios, el orquestador de contenedores, el servidor de configuración, el servicio, un proxy del servicio que balancea la carga si hubiera diferentes instancias del servicio y admite tolerancia a fallos si alguna de las instancias falla y finalmente el cliente del servicio. En el ejemplo se utiliza Nomad y Docker junto a Consul y Traefik. El ejemplo requiere iniciar Consul y Nomad y tener instalado, configurado y en ejecución Docker.

1
2
3
4
5
6
7
#!/usr/bin/env bash
docker network create --subnet 172.30.0.0/16 nomad

consul agent -dev -ui -client=0.0.0.0
nomad agent -dev -config=nomad/nomad.conf
# http://127.0.0.1:8500
# http://127.0.0.1:4646
run-1.sh

Nomad se encarga de orquestar los contenedores de Docker para crear las instancias de los microservicios. Las definiciones de los microservicios se envian a Nomad para que inicie las instancias.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/env bash
docker network create --subnet 172.30.0.0/16 nomad

./gradlew assemble

nomad job run nomad/traefik.nomad
nomad job run nomad/zipkin.nomad
# http://127.0.0.1:8092
# http://127.0.0.1:9411

nomad job run nomad/configserver.nomad
nomad job run nomad/service.nomad
nomad job run nomad/client.nomad
run-2.sh

Sleuth exporta los datos de las transacciones a Zipkin donde se observa los tiempos de respuesta de los microservicios para una petición determinada dado su identificador global. Pero también permite observar que microservicios han sido llamados lo que ayuda a conocer cual ha sido el comportamiento del sistema, dependiendo de la lógica de negocio que implementen no tienen por que ser siempre los mismos.

Zipkin ofrece una aplicación web con la que consultar las llamadas desencadenadas por una petición y los tiempos de respuesta de los servicios involucrados, la aplicación web está en la dirección http://localhost:9411/zipkin/ o a través de Traefik con http://localhost:8093/zipkin/. Utilizando uno de los identificadores globales de petición e introduciéndolo en el cuadro de búsqueda de Zipkin se observan los tiempos de respuesta de cada uno de los servicios.

1
2
3
4
...
Client Span (traceId: 4605e7da594f8a1c, spanId: 4605e7da594f8a1c)
Service response: Hello world (url: http://172.30.0.3/, remoteAddress_172.30.0.3, localAddress: 172.30.0.7, traceId: 4605e7da594f8a1c, spanId: 8b42ba6118ff60e9, key: value)
...
client.out

Tanto el microservicio cliente como el microservicio servidor tienen Sleuth integrado y envían de forma asíncrona cuando terminan las peticiones HTTP la información de trazabilidad a Zipkin. Con el traceId de una transacción se observa en Zipkin la cadena de llamadas entre los microservicios y sus tiempos de respuesta. En el ejemplo solo hay dos pero podrían en un caso real quizá sean tres, cuatro o más ya sea porque cada servicio utiliza otro o porque un mismo servicio utiliza varios, ver esta información de forma gráfica es mucho más fácil de analizar que solo con las trazas correlacionadas en ELK.

Interfaz web de Nomad y Zipkin

Este es el código del cliente que hace la petición al servidor y el código del servidor. Spring ya proporciona integración con Sleuth en sus utilidades como RestTemplate. Si se utiliza el cliente HTTP de Java añadido junto con otras novedades de Java 11 hay que añadir el soporte para que en la petición se añadan las cabeceras cuando se haga la petición. La forma que tiene Sleuth de compartir los identificativos de las transacciones entre el cliente y el servidor es a través de las cabeceras en las peticiones HTTP.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package io.github.picodotdev.blogbitix.springcloud.client;

...

@SpringBootApplication
@EnableDiscoveryClient
public class Main implements CommandLineRunner {

	@Autowired
	private DefaultConfiguration configuration;

	@Autowired
	private ProxyService proxyService;

	@Bean
	public RestTemplate restTemplate(RestTemplateBuilder builder) {
		return builder.build();
	}

	@Override
	public void run(String... args) throws Exception {
		System.out.printf("Valor de propiedad de configuración (%s): %s%n", "config.key", configuration.getKey());
		System.out.printf("Valor de propiedad de configuración (%s): %s%n", "config.password", configuration.getPassword());

		for (int i = 0; i < 20000; ++i) {
			String response = get();
			System.out.printf("Service response: %s%n", response);
			Thread.sleep(1000);
		}
	}

	private String get() {
		return proxyService.get();
	}

	public static void main(String[] args) {
		SpringApplication application = new SpringApplication(Main.class);
		application.setApplicationContextClass(AnnotationConfigApplicationContext.class);
		SpringApplication.run(Main.class, args);
	}
}
Main.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package io.github.picodotdev.blogbitix.springcloud.client;

...

@Component
public class ProxyService {

    ...

    @Autowired
    private Tracing tracing;

    @Autowired
    private Tracer tracer;

    @Autowired
    private RestTemplate restTemplate;

    ...
    private HttpClient client;

    public ProxyService() {
        ...

        client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build();
    }

    public String get() {
        ServiceInstance instance = loadBalancer.choose("traefik");
        URI uri = instance.getUri();
        String resource = String.format("%s%s", uri.toString().replace("127.0.0.1", "traefik"), "/service");
        final URI resourceUri = URI.create(resource);

        ...

        Supplier<CompletableFuture<String>> get = () -> {
            return CompletableFuture.supplyAsync(() -> {
                Span span = tracer.newTrace().kind(Span.Kind.CLIENT).name("CLIENT").start();
                System.out.printf("Client Span (traceId: %s, spanId: %s)%n", span.context().traceIdString(), span.context().spanIdString());

                String result = getRequest(client, span, resourceUri);
                //String result = getRequest(restTemplate, span, resouceUri);

                span.finish();
                return result;
            });
        };
        ...

        return Try.of(getCircuitBreaker::call).recover((throwable) -> getFallback()).get();
    }

    private String getFallback() {
        return "Fallback";
    }

    private String getRequest(HttpClient client, Span span, URI resourceUri) {
        HttpRequest.Builder request = HttpRequest.newBuilder(resourceUri).GET();
        Span serviceSpan = tracer.newChild(span.context());

        try (Tracer.SpanInScope ws = tracer.withSpanInScope(serviceSpan)) {
            TraceContext.Injector<HttpRequest.Builder> injector = tracing.propagation().injector((HttpRequest.Builder carrier, String key, String value) -> {
                carrier.header(key, value);
            });
            injector.inject(tracer.currentSpan().context(), request);

            HttpResponse<String> response = client.send(request.build(), HttpResponse.BodyHandlers.ofString());
            return response.body();
        } catch (Exception e) {
            return getFallback();
        } finally {
            serviceSpan.finish();
        }
    }

    private String getRequest(RestTemplate restTemplate, Span span, URI resourceUri) {
        try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
            return restTemplate.getForObject(resourceUri, String.class);
        } catch (RestClientException e) {
            return getFallback();
        }
    }
}
ProxyService.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package io.github.picodotdev.blogbitix.springcloud.service;

...

@RestController
public class DefaultController {

    @Autowired
    private DefaultConfiguration configuration;

    @Autowired
    private Tracing tracing;

    @Autowired
    private Tracer tracer;

    private Random random;
    private Counter counter;

    ...

    @RequestMapping("/")
    public String home(HttpServletRequest request) throws Exception {
        Span span = tracer.currentSpan();

        System.out.printf("Service Span (traceId: %s, spanId: %s)%n", span.context().traceIdString(), span.context().spanIdString());
        counter.increment();

        // Timeout simulation
        //Thread.sleep(random.nextInt(4000));

        return String.format("Hello world (url: %s, remoteAddress_%s, localAddress: %s, traceId: %s, spanId: %s, key: %s)", request.getRequestURL(),
                request.getRemoteAddr(), request.getLocalAddr(), span.context().traceIdString(), span.context().spanIdString(), configuration.getKey());
    }
}
DefaultController.java

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./run.sh

Este artículo forma parte de la serie spring-cloud:

  1. Datos de sesión externalizados con Spring Session
  2. Aplicación Java autocontenida con Spring Boot
  3. Configuración de una aplicación en diferentes entornos con Spring Cloud Config
  4. Información y métricas de la aplicación con Spring Boot Actuator
  5. Registro y descubrimiento de servicios con Spring Cloud y Consul
  6. Aplicaciones basadas en microservicios
  7. Registro y descubrimiento de servicios con Spring Cloud Netflix
  8. Servicio de configuración para microservicios con Spring Cloud Config
  9. Recargar sin reiniciar la configuración de una aplicación Spring Boot con Spring Cloud Config
  10. Almacenar cifrados los valores de configuración sensibles en Spring Cloud Config
  11. Tolerancia a fallos en un cliente de microservicio con Spring Cloud Netflix y Hystrix
  12. Balanceo de carga y resilencia en un microservicio con Spring Cloud Netflix y Ribbon
  13. Proxy para microservicios con Spring Cloud Netflix y Zuul
  14. Monitorizar una aplicación Java de Spring Boot con Micrometer, Prometheus y Grafana
  15. Exponer las métricas de Hystrix en Grafana con Prometheus de una aplicación Spring Boot
  16. Servidor OAuth, gateway y servicio REST utilizando tokens JWT con Spring
  17. Trazabilidad en microservicios con Spring Cloud Sleuth
  18. Implementar tolerancia a fallos con Resilience4j
  19. Iniciar una aplicación de Spring Boot en un puerto aleatorio
  20. Utilizar credenciales de conexión a la base de datos generadas por Vault en una aplicación de Spring
  21. Microservicios con Spring Cloud, Consul, Nomad y Traefik
  22. Trazabilidad en servicios distribuidos con Sleuth y Zipkin
Comparte el artículo: