Teniendo una buen número de microservicios con múltiples instancias ofreciendo cada uno una API y en una ubicación diferente para simplificar la visión de los que actúen clientes de los microservicios se puede utilizar un proxy. Con un proxy es posible centralizar todas las peticiones, que sea éste el encargado de conocer la ubicación de todas las instancias de los microservicios y de hacer la llamada allí donde se encuentre cada una de ellas.
Entre las varias funcionalidades que proporcionar el proyecto Spring Cloud Netflix es esta de proxy mediante Zuul. Para hacer de proxy Zuul necesita tener una correspondencia entre URLs y servicios que realmente proporcionan la funcionalidad, una forma que tiene Zuul de conocer la ubicación de las instancias es utilizando el servicio de registro y descubrimiento Eureka. Además, Zuul como cliente de los microservicios posee la funcionalidad de Hystrix que implementa el patrón circuit breaker para tolerancia a fallos, Ribbon para hacer balanceo de carga entre varias instancias de los microservicios a nivel de servidor además de reintentos cuando una instancia falla.
En el ejemplo que he utilizado para esta serie de artículos sobre Spring Cloud hay un servicio que por defecto se inicia en el puerto 8080 y ofrece un endpoint / que devuelve un mensaje. Para crear un microservicio proxy con Zuul hay que crear una aplicación Spring Boot anotar la clase principal con la anotación @EnableZuulProxy y proporcionar la configuración para la correspondencia de rutas y microservicios, además de las propiedades para hacer reintentos en caso de que un microservicio falle y de timeouts en caso de que se comporte no como se espera en cuanto tiempos de respuesta.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
package io.github.picodotdev.blogbitix.springcloud.proxy;
...
@SpringBootApplication
@EnableDiscoveryClient
@EnableZuulProxy
public class Main {
public static void main(String[] args) throws Exception {
SpringApplication application = new SpringApplication(Main.class);
application.setApplicationContextClass(AnnotationConfigApplicationContext.class);
SpringApplication.run(Main.class, args);
}
}
|
Main (zuul).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
|
plugins {
id 'application'
id 'org.springframework.boot' version '2.1.12.RELEASE'
}
application {
mainClassName = 'io.github.picodotdev.blogbitix.springcloud.proxy.Main'
}
dependencies {
implementation(platform('org.springframework.boot:spring-boot-dependencies:2.1.12.RELEASE'))
implementation(platform('org.springframework.cloud:spring-cloud-dependencies:Greenwich.SR2'))
// Spring
def excludeSpringBootStarterLogging = { exclude(group: 'org.springframework.boot', module: 'spring-boot-starter-logging') }
implementation('org.springframework.boot:spring-boot-starter', excludeSpringBootStarterLogging)
implementation('org.springframework.boot:spring-boot-starter-web', excludeSpringBootStarterLogging)
implementation('org.springframework.boot:spring-boot-starter-log4j2', excludeSpringBootStarterLogging)
implementation('org.springframework.boot:spring-boot-starter-actuator', excludeSpringBootStarterLogging)
implementation('org.springframework.cloud:spring-cloud-starter-config', excludeSpringBootStarterLogging)
//implementation('org.springframework.cloud:spring-cloud-starter-bus-amqp', excludeSpringBootStarterLogging)
implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client', excludeSpringBootStarterLogging)
implementation('org.springframework.cloud:spring-cloud-starter-netflix-zuul', excludeSpringBootStarterLogging)
implementation('org.springframework.retry:spring-retry:1.2.2.RELEASE', excludeSpringBootStarterLogging)
runtimeOnly('com.google.code.gson:gson:2.8.5')
runtimeOnly('com.fasterxml.jackson.core:jackson-databind:2.9.6')
runtimeOnly('com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.6')
}
|
build.gradle
Se puede establecer un tiempo máximo para establecer la conexión, de tiempo de petición, el número de reintentos en la misma instancia si falla o en otro número de instancias, el número máximo de conexiones y el número máximo de conexiones al mismo host. Todas ellas definibles en cada servicio de forma individual bajo las propiedades hystrix.command.service y service.ribbon donde service es el identificativo del servicio. Las rutas se indican bajo la propiedad zuul.routes con la relación identificativo del servicio y path.
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
|
server:
port: ${port:8095}
zuul:
routes:
service: '/service/**'
retryable: true
hystrix:
command:
service:
execution:
isolation:
thread:
timeoutInMilliseconds: 20000
circuitBreaker:
requestVolumeThreshold: 4
errorThresholdPercentage: 50
service:
ribbon:
ConnectTimeout: 1000
ReadTimeout: 2000
MaxTotalHttpConnections: 100
MaxConnectionsPerHost: 100
MaxAutoRetries: 0
MaxAutoRetriesNextServer: 2
ServerListRefreshInterval: 2000
OkToRetryOnAllOperations: true
retryableStatusCodes: 500,404
|
proxy.yml
Dado que Zuul es un proxy para múltiples instancias de microservicios a cada microservicio hay que darle una ruta, cuando Zuul realiza la llamada a una instancia del microservicio se encarga de omitirla. En el ejemplo, la ruta en Zuul /service/** está asociada al microservicio service pero el servicio service ofrece su endpoint en /, Zuul se encarga de omitir la parte de la ruta para el proxy y hace la llamada a la ruta / como espera el microservicio.
Lógicamente los clientes deben contactar con el proxy en vez de con el microservicio directamente. Arrancado el servicio de descubrimiento y registro Eureka, el servidor de configuración de Spring Cloud, dos instancias del servicio y el proxy con Zuul haciendo las llamadas al proxy se observa que se obtiene el resultado del microservicio. Como en el ejemplo hay varias instancias del servicio Zuul realiza balanceo de carga entre ellas con Ribbon utilizando la política round-robin y el mensaje es diferente en cada una de las respuestas según la instancia invocada. Con Zuul además se consigue balanceo de carga a nivel de servidor que Ribbon solo ofrece a nivel de cliente.
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
|
package io.github.picodotdev.blogbitix.springcloud.client;
...
@SpringBootApplication
@EnableDiscoveryClient
@EnableCircuitBreaker
@EnableHystrixDashboard
public class Main implements CommandLineRunner {
@Autowired
private DefaultConfiguration configuration;
@Autowired
private ClientService service;
@Autowired
private ProxyService proxy;
@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());
System.out.printf("Valor de propiedad de configuración (%s): %s%n", "config.service", configuration.getService());
for (int i = 0; i < 20000; ++i) {
String response = (configuration.getService().equals("service")) ? service.get() : proxy.get();
System.out.printf("Service response: %s%n", response);
Thread.sleep(100);
}
}
public static void main(String[] args) throws Exception {
SpringApplication application = new SpringApplication(Main.class);
application.setApplicationContextClass(AnnotationConfigApplicationContext.class);
SpringApplication.run(Main.class, args);
}
}
|
Main (client).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
|
package io.github.picodotdev.blogbitix.springcloud.client;
...
@Component
public class ProxyService {
@Autowired
private LoadBalancerClient loadBalancer;
@HystrixCommand(fallbackMethod = "getFallback", commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "4"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"),
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "25000")
})
public String get() {
ServiceInstance instance = loadBalancer.choose("proxy");
URI uri = instance.getUri();
String resource = String.format("%s%s", uri.toString(), "/service");
return Client.create().resource(resource).get(String.class);
}
private String getFallback() {
return "Fallback";
}
}
|
ProxyService.java
1
2
3
4
5
6
7
|
$ ./gradlew discoveryserver:run --args="--port=8761"
$ ./gradlew configserver:run --args="--port=8090"
$ ./gradlew service:run --args="--port=8080"
$ ./gradlew service:run --args="--port=8081"
$ ./gradlew service:run --args="--port=8082"
$ ./gradlew proxy:run --args="--port=8085"
$ ./gradlew client:run --args="--service=proxy"
|
gradle-run.sh
Las URLs del servicio en el microservicio y en el proxy son.
1
2
3
4
5
|
# Microservicio
$ curl http://192.168.1.4:8080/
# Microservicio en el proxy
$ curl http://192.168.1.4:8085/service
|
curl.sh
El cliente de ejemplo realiza peticiones al proxy, en la salida se muestra el resultado del balanceo de carga cuando hay varias instancias, cuando se añade una nueva instancia entra a formar parte del balanceo de carga. Otro beneficio de Zuul es que ofrece la funcionalidad de reintentos de modo que si una instancia de un servicio falla la petición se reintenta en otra. En el artículo Balanceo de carga y resiliencia en un microservicio con Spring Cloud Netflix y Ribbon usando solo Ribbon se observaba que cuando una instancia falla se le siguen haciendo peticiones hasta que la lista de instancias del servicio en Eureka se actualiza quitando la fallida, con Hystrix se obtiene la respuesta fallback pero no se evita completamente el error. Zuul puede ocultar el error provocado por una instancia que falla reintentado la petición en la misma nuevamente, en otra u otras instancias según se configure. El comportamiento con Zuul cuando una instancia falla se puede comparar con el comportamiento incluido en el artículo anterior usando en el cliente los microservicios directamente.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
$ ./gradlew client:run --args="--service=proxy"
... # initially two service instances (8080, 8081)
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
... # new service instance, ./gradlew service:run --args="--port=8082"
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
Service response: Hello world (http://192.168.1.4:8082/, value)
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
Service response: Hello world (http://192.168.1.4:8082/, value)
... # kill service instance (8082), Ctrl+C
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
Service response: Hello world (http://192.168.1.4:8080/, value)
Service response: Hello world (http://192.168.1.4:8081/, value)
|
System.out
Zuul además es capaz de proporciona otras muchas funcionalidades como:
- Autenticación
- Seguridad
- Recolección de métricas y monitorización
- Pruebas de carga
- Pruebas de verificación o canary testing
- Enrutado dinámico
- Migración de servicio
- Abandono de carga o load shedding
- Manejo de respuesta estática
- Gestión de tráfico active/active
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:
./gradlew-run.sh