Microservicios con Spring Cloud, Consul, Nomad y Traefik

Escrito por picodotdev el , actualizado el .
java planeta-codigo programacion software software-libre
Enlace permanente Comentarios

Sin entrar a si los microservicios son adecuados o no son adecuados en una aplicación, está claro que si se utilizan estos tienen varias necesidades. Un servicio de registro y descubrimiento, configuración centralizada, tolerancia a fallos, gateway/load balancer/reverse proxy, trazabilidad y métricas, autenticación, orquestación, … Los microservicios quiza no sean un gran monolito, quizá mas pequeños y con funcinalidad más acotada, pero el hecho de que se comuniquen a través de un medio más complejo y menos fiable como la red en vez de una llamada a un método y sean más numerosos hacen que la complejidad sea incluso mayor. Este artículo propone un ejemplo con Spring Cloud para los servicios, Consul para el registro y descubrimiento, Nomad para la orquestación y Traefik como gateway.

Java

Spring

En otro artículo mostraba un ejemplo de microservicios con Spring Cloud usando únicamente herramientas de Spring. Cada una de esas herramientas cubren una funcionalidad que necesitan los microservicios. Entre ellas:

  • Registro y descubrimiento, con Eureka. Los microservicios son numerosos, de vida efímera creándose y destruyéndose en diferentes ubicaciones por lo tanto necesitan una forma de localizarse unos a otros, la forma para encontrarse es acudiendo a un servicio donde se registran cuando se inician y se descubren cuando necesitan la ubicación de otro servicio.
  • Configuración centralizada, con Spring Cloud Config. Dado el número de microservicios actualizar la configuración de cada uno de ellos puede ser un problema, además dado que se pueden iniciar en diferentes ubicaciones aprovisionarles la configuración adecuada es un reto. En vez de aprovisionar la configuración otra técnica es hacer que cuando se inicien la obtengan de un servicio donde queda centralizada la configuración.
  • Tolerancia a fallos, con Hyxtrix y Resilience4j. El medio de comunicación de los microservicios es a través de la red un medio mucho menos confiable que una llamada a un método en un lenguaje de programación en una aplicación monolítica. De modo que los microservicios han de estar preparados para tolerar fallos en sus comunicaciones con otros servicios.
  • Gateway, load balancer y reverse proxy con tolerancia a fallos, con Zuul. Para aumentar la disponibilidad, escalabilidad y tolerar fallos en algunos servicios se suelen crear varias instancias de cada microservicio pero tener varias instancias hace que sea necesario balancear la carga entre cada una de las instancias. Para que los clientes sean agnósticos del número de instancias se emplea un gateway que proporciona balanceo de carga e implementa a su vez patrones de tolerancia a fallos.
  • Trazabilidad y correlación de trazas entre diferentes servicios, con Spring Cloud Sleuth. Una petición puede desencadenar una cadena de peticiones entre diferentes servicios ubicados en múltiples nodos, para tareas de diagnóstico en caso de querer investigar un bug o que ha ocurrido es necesario correlacionar todas las trazas que ha desencadenado una petición, se implementa asignado un identificativo global a la petición que es transmitido en las llamadas de microservicio a microservicio.

En otro ejemplo sobre OAuth con Spring mostraba otra funcionalidad:

Los microservicios también necesitan monitorización y métricas, en el ejemplo Monitorizar una aplicación Java de Spring Boot con Micrometer, Prometheus y Grafana:

  • Con Prometheus y Grafana. Nuevamente el número de instancias que requiere una arquitectura orientada a microservicios origina la necesidad en mayor medida de conocer el estado del sistema, ya sean métricas de los sistemas como uso de CPU, memoria o almacenamiento o de la aplicación como peticiones por segundo y porcentaje de correctas e incorrectas.

En esta lista falta un orquestador para el despliegue de los microservicios, que se encargue de su ciclo de vida, escalado de instancias y despliegue con estrategias rolling, blue/green y canary. Es una cosa que le faltaba al ejemplo de microservicios con Spring Cloud.

Además, en este ejemplo reemplazo varias de estas herramientas de Spring. Sustituyo el servicio de registro y descubrimiento proporcionado por Eureka por Consul, el gateway, load balancer y reverse proxy proporcionado por Zuul por Traefik y añado el orquestador de microservicios Nomad.

Traefik

Traefik se configura con los servicios iniciados en los contenedores de Docker utilizando junto con los bloques o stanzas de config y tags en la definición de los servicios de Nomad. Según el criterio definido por el servicio Traefik es capaz de redirigir el tráfico que le llegue al servicio apropiado, entre las posibilidades que puede realizar Traefik es balanceo de carga entre las múltiples instancias que se hayan definido pero también implementa patrones de tolerancia a fallos con reintentos, el patrón circuit breaker o limitar el tráfico para evitar saturar a un servicio con demasiadas peticiones.

El esquema de servicios sería el siguiente. Los job son enviados a Nomad desde la linea de comandos que inicia los contenedores en Docker y registra los servicios en Consul, Traefik monitoriza el catálogo de servicios registrados en Consul y se autoconfigura según los tags asociados a los servicios, los tags se especificar en los archivos de los servicios para Nomad. Una vez iniciados los servicios desde la terminal con un curl o desde la aplicación cliente que accede a Consul para conocer la ubicación del servicio de Traefik envían una petición a Traefik que haciendo balanceo de carga la reenvía a una de las instancias del servicio, el servicio responde y Traefik envía la respuesta al cliente.

Esquema arquitectura

Esquema arquitectura

La ejecución del ejemplo requiere Docker ya que es en este caso el driver empleado en Nomad para ejecutar los servicios del servicio de configuración, el gateway, el servicio y el cliente del servicio. Nomad además se encarga de registrar los servicios en el servicio de registro y descubrimiento de Consul.

Los contenedores de Docker se añade a una misma red para que puedan comunicarse entre ellos, ha de ser así hasta que no se resuelva una petición de Docker para que los contenedores puedan comunicarse con la máquina host que los alberga.

1
$ docker network create --subnet 172.30.0.0/16 nomad
docker-network-create.sh

Poteriormente hay que ejecutar Consul y Nomad tras lo cual se puede acceder a sus consolas de estado.

1
2
$ consul agent -dev -ui -client=0.0.0.0
$ nomad agent -dev
nomad-consul-run.sh

Enviar a Nomad los job de Traefik tras lo cual se puede acceder a su consola de estado. El siguiente paso es enviar el job del servicio que proporciona la configuración a los microservicios. Lo anterior únicamente es infraestructura aún no hay ningún servicio que proporcione alguna funcionalidad, la funcionalidad que proporciona el servicio implementado con Spring es simplemente devolver un mensaje como respuesta a la petición que se le realice, se envía el job del servicio a Nomad. Finalmente, el servicio es consumido por un cliente que realiza una petición al servicio cada 1 segundo.

1
2
3
4
5
6
$ ./gradlew assemble

$ nomad job run nomad/traefik.nomad
$ nomad job run nomad/configserver.nomad
$ nomad job run nomad/service.nomad
$ nomad job run nomad/client.nomad
nomad-job-run.sh

Definición de un servicio en un job para Nomad. count define cuantas instancias del servicio se inicia, la stanza update define como será la actualización cuando se actualice el servicio, la stanza labels contiene la configuración para Traefik, check define los parámetros para la monitorización.

 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
job "service" {
  datacenters = ["dc1"]

  group "service" {
    count = 2

    update {
      max_parallel      = 1
      health_check      = "checks"
      min_healthy_time  = "20s"
      healthy_deadline  = "10m"
      progress_deadline = "20m"
      canary            = 1
      stagger           = "15s"
    }

    task "service" {
      driver = "docker"
      config {
        image = "openjdk:11-jdk"
        args = [
          "bash",
          "-c",
          "(cd /app && java -jar /app/service/build/libs/service-1.0.jar --port=8080)"
        ]
        port_map {
          http = "8080"
        }
        network_mode = "nomad"
        extra_hosts = [
          "traefik:172.30.0.3"
        ]
        volumes = [
          "/home/picodotdev/Software/personal/blog-ejemplos/SpringCloudConsulNomadTraefik/:/app"
        ]
      }

      service {
        name = "service"
        port = "http"

        check {
          type     = "http"
          port     = "http"
          path     = "/actuator/health"
          interval = "5s"
          timeout  = "2s"
        }

        tags = [
          "traefik.http.middlewares.service1-stripprefix.stripprefix.prefixes=/service",
          "traefik.http.middlewares.service1-retry.retry.attempts=10",
          "traefik.http.routers.service1.middlewares=service1-stripprefix,service1-retry",
          "traefik.http.routers.service1.rule=PathPrefix(`/service`)",
          "traefik.http.services.service1.loadbalancer.server.port=8080"
        ]
      }

      resources {
        cpu    = 200
        memory = 1024
        network {
          mbits = 20
          port "http" {}
        }
      }
    }
  }
}
service.nomad

Tanto Consul, Nomad como Traefik ofrecen una consola para ver su estado ubicadas en las siguientes direcciones respectivamente accesibles con el navegador http://127.0.0.1:8500, http://127.0.0.1:4646, http://127.0.0.1:8092.

Consul Nomad Nomad

Traefik Traefik

Consolas de administración de Consul, Nomad y Traefik

El código del servicio, del cliente implementados con Spring y la salida del cliente son los siguientes.

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

    public DefaultController(MeterRegistry registry) {
        this.random = new Random();
        this.counter = Counter.builder("service.invocations").description("Total service invocations").register(registry);
    }

    @RequestMapping("/")
    public String home(HttpServletRequest request) throws Exception {
        counter.increment();

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

        TraceContext.Extractor<HttpServletRequest> extractor = tracing.propagation().extractor((HttpServletRequest carrier, String key) -> { return carrier.getHeader(key); });
        Span span = tracer.nextSpan(extractor.extract(request));
        System.out.printf("Service Span (traceId: %s, spanId: %s)%n", span.context().traceIdString(), span.context().spanIdString());

        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
 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
package io.github.picodotdev.blogbitix.springcloud.client;

...

@Component
public class ProxyService {

    @Autowired
    private LoadBalancerClient loadBalancer;

    @Autowired
    private Tracing tracing;

    @Autowired
    private Tracer tracer;

    private CircuitBreakerConfig circuitBreakerConfiguration;
    private TimeLimiterConfig timeLimiterConfiguration;
    private HttpClient client;

    public ProxyService() {
        circuitBreakerConfiguration = CircuitBreakerConfig.custom()
                .failureRateThreshold(50)
                .recordExceptions(IOException.class, TimeoutException.class)
                .build();

        timeLimiterConfiguration = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofMillis(2500))
                .cancelRunningFuture(true)
                .build();

        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");        
        HttpRequest.Builder r = null;
        try { 
            r = HttpRequest.newBuilder(new URI(resource)).GET();
        } catch (Exception e) {
            return getFallback();
        }
        final HttpRequest.Builder request = r;

        Span span = tracer.newTrace().start();
        TraceContext.Injector<HttpRequest.Builder> injector = tracing.propagation().injector((HttpRequest.Builder carrier, String key, String value) -> { carrier.header(key, value); });
        injector.inject(span.context(), request);
        System.out.printf("Client Span (traceId: %s, spanId: %s)%n", span.context().traceIdString(), span.context().spanIdString());

        CircuitBreaker circuitBreaker = CircuitBreaker.of("resilience4jCircuitBreakerProxyService", circuitBreakerConfiguration);
        TimeLimiter timeLimiter = TimeLimiter.of(timeLimiterConfiguration);

        Supplier<CompletableFuture<String>> get = () -> {
            return CompletableFuture.supplyAsync(() -> {
                try { 
                    HttpResponse<String> response = client.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    return response.body();
                } catch (Exception e) {
                    return getFallback();
                }
            });
        };
        Callable<String> getLimiter = TimeLimiter.decorateFutureSupplier(timeLimiter, get);
        Callable<String> getCircuitBreaker = CircuitBreaker.decorateCallable(circuitBreaker, getLimiter);

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

    private String getFallback() {
        return "Fallback";
    }
}
ProxyService.java

Como hay 2 instancias del servicio y Traefik realiza balanceo de carga utilizando el algoritmo round robbin se observa en la salida con las respuestas que la dirección IP que ha atendido la petición es alternativamente una de las dos instancias del servicio.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
Service response: Hello world (url: http://172.30.0.3/, remoteAddress_172.30.0.3, localAddress: 172.30.0.4, traceId: 63afa4d0cd4f466c, spanId: 4719dfcc16b6104e, key: value)
Client Span (traceId: 57eeaa436aa09238, spanId: 57eeaa436aa09238)
Service response: Hello world (url: http://172.30.0.3/, remoteAddress_172.30.0.3, localAddress: 172.30.0.5, traceId: 57eeaa436aa09238, spanId: 26dc213be2d933ac, key: value)
Client Span (traceId: 23c748bf222052a6, spanId: 23c748bf222052a6)
Service response: Hello world (url: http://172.30.0.3/, remoteAddress_172.30.0.3, localAddress: 172.30.0.4, traceId: 23c748bf222052a6, spanId: 0404d949c6e04c18, key: value)
Client Span (traceId: c45d66a4ec9cf14c, spanId: c45d66a4ec9cf14c)
Service response: Hello world (url: http://172.30.0.3/, remoteAddress_172.30.0.3, localAddress: 172.30.0.5, traceId: c45d66a4ec9cf14c, spanId: e7f6ccf2efb8234b, key: value)
Client Span (traceId: 2fdb3b71a682d2e6, spanId: 2fdb3b71a682d2e6)
Service response: Hello world (url: http://172.30.0.3/, remoteAddress_172.30.0.3, localAddress: 172.30.0.4, traceId: 2fdb3b71a682d2e6, spanId: 24ac2a8d2bfb1e6e, key: value)
Client Span (traceId: a33b010e02709c6a, spanId: a33b010e02709c6a)
Service response: Hello world (url: http://172.30.0.3/, remoteAddress_172.30.0.3, localAddress: 172.30.0.5, traceId: a33b010e02709c6a, spanId: 0abe6074fc277af6, key: value)
...
System.out

En un momento posterior si surge la necesidad de querer desplegar una nueva versión del microservicio basta con generar de nuevo el artefacto del microservicio, cambiando la versión en el archivo build.gradle. El despliegue de la nueva versión se realizan mediante la estrategia canary, manteniendo las instancias con la versión anterior del servicio y añadiendo una nueva con la nueva versión. Si se descubre algún error en la instancia canary se puede revertir el estado a la versión anterior, que consiste en detener la instancia canary. Una vez se comprueba que la instancia con la nueva versión funciona correctamente analizando sus trazas y métricas se envía la order a Nomad de promocionar las instancias de forma progresiva con la versión antigua a la nueva versión.

1
2
3
$ nomad job run nomad/service.nomad
$ nomad job promote service
$ nomad job revert service 0
nomad-job-promote.sh

El servicio exporta métricas en formato para Prometheus que con Grafana. Según se realizan peticiones al servicio el valor de métrica de contador de llamadas al servicio aumenta de forma progresiva.

1
2
3
4
$ curl http://127.0.0.1:8093/service/actuator/prometheus | grep "service.invocations"
# HELP service_invocations_total Total service invocations
# TYPE service_invocations_total counter
service_invocations_total 20.0
service-prometheus.sh
Terminal

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

  1. Introducción a Nomad para gestionar aplicaciones y microservicios
  2. Estrategias de despliegue para microservicios con Nomad
  3. Servicios con persistencia en el orquestador de microservicos Nomad
  4. Crear de forma sencilla y rápida máquinas virtuales de VirtualBox con Vagrant
  5. Registro y descubrimiento de servicios en contenedores de Docker con Consul y Registrator
  6. Administrar secretos y proteger datos sensibles con Vault
  7. Generar credenciales de conexión a base de datos bajo demanda con Vault
  8. Utilizar credenciales de conexión a la base de datos generadas por Vault en una aplicación de Spring
  9. Microservicios con Spring Cloud, Consul, Nomad y Traefik

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