Índice de contenidos
- Introducción
- Entorno
- Resiliencia y patrones de resiliencia
3.1 ¿Qué es resiliencia?
3.2 Patrones de resiliencia - Ejemplo
4.1 Infraestructura
4.2 Microservicio de transacciones
4.3 Microservicio de usuarios
4.4 Implementación de los patrones de resiliciencia
4.4.1 Circuit Breaker
4.4.2 Fallback
4.4.3 Compensating transaction
4.5 Test de resiliencia - Conclusiones
- Referencias
1. Introducción
En este tutorial vamos a ver cómo podemos testear la resiliencia de nuestra aplicación usando Toxiproxy.
En el ejemplo usaremos Micronaut pero se podría haber usado cualquier framework que dé soporte a resiliencia como Spring Cloud o Eclipse Microprofile. Puede verse un ejemplo de qué es o cómo se usa Micronaut aquí.
2. Entorno
El tutorial está escrito usando el siguiente entorno:
- Hardware: Portátil MacBook Pro 15′ (2,3 GHz Intel Core i7, 16GB DDR3).
- Sistema Operativo: Mac OS Mojave 10.14.1
- IntelliJ IDEA 2018.3.4
- Docker version 18.09.1
- Docker-compose version 1.23.2, build 1110ad01
- Java version OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.2+7, mixed mode)
- Micronaut 1.0.4
3. Resiliencia y patrones de resiliencia
3.1 ¿Qué es resiliencia?
Si buscamos la definición de la RAE para el término resiliencia encontramos lo siguiente:
Capacidad de adaptación de un ser vivo frente a un agente perturbador o un estado o situación adversos.
Extrapolando esta definición al software, la resiliencia es la capacidad que tiene nuestro sistema de recuperarse ante diferentes fallos.
3.2 Patrones de resiliencia
Para poder lidiar con estos fallos (latencia y caídas del sistema, entre otros) existen varios patrones que pueden ayudarnos. Aunque la lista es larga (aquí hay algunos) para el ejemplo nos vamos a centrar en los siguientes:
- Circuit Breaker: Previene contra continuas llamadas a un servicio que está fallando o que tiene problemas de rendimiento.
- Fallback: Proporciona un mecanismo a través del cual ofrecer una alternativa ante un servicio que está fallando.
- Compensating Transaction: Se encarga de deshacer una operación previa para poder dejar el sistema en un estado consistente.
4. Ejemplo
Como ejemplo se expone el caso de una operativa común que podemos encontrar en nuestro día a día:
- Solicitar información de otro dominio
- Publicar información hacia otro dominio cambiando su estado
- Guardar información en el sistema de persistencia local
4.1 Infraestructura
Contaremos con dos microservicios uno que actuará como cliente (el SUT) que será el microservicio de usuarios y otro que actuará como sistema de terceros (el DoC) siendo éste el microservicio de transacciones y estando alojado dentro de un contenedor Docker. Además levantaremos una base de datos Postgres que usaremos para persistir datos, también como un contenedor Docker. El SUT no accederá directamente a ningún recurso sino que lo hará a través de un proxy, concretamente a través de un servidor de Toxiproxy también levantado como un contenedor docker.
El fichero docker-compose.yml define los servicios así:
version: "3" services: toxiproxy: image: shopify/toxiproxy ports: - 8474:8474 - 9090:9090 - 5432:5432 db: image: postgres:9.5 environment: - POSTGRES_DB=ms1-db - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres ms2: build: .
El servicio toxiproxy levantará los siguientes puertos:
- 8474: Necesario para poder conectar el cliente de Toxiproxy con el servidor
- 9090: Puerto por el que escucha el microservicio de transacciones
- 5432: Puerto por el que escucha la base de datos postgres
Destacar que el servicio db y ms2 exponen los puertos 5432 y 9090 solo de forma interna, siendo el servicio toxiproxy el que se encarga de exponer estos puertos externamente.
4.2 Microservicio de transacciones
El microservicio de transacciones contiene varios endpoints a través de los cuales poder realizar operaciones:
- GET /transactions/{userId}
- POST /transactions/{userId}
- DELETE /transactions/{userId}/{txId}
4.3 Microservicio de usuarios
Añadir la dependencia del cliente de Toxiproxy en el proyecto:
<dependency> <groupId>eu.rekawek.toxiproxy</groupId> <artifactId>toxiproxy-java</artifactId> <version>2.1.3</version> </dependency>
public List<Transaction> getTransactions(UUID someUserId) { LOG.info("getting new transactions..."); // 1º point of failure return transactionsClient.getNewTransactions(someUserId); }
La segunda acción será poder crear nuevas transacciones para un usuario, comunicándolo al microservicio dependiente y persistiendo los datos en una base de datos. Operativa común.
public void createUserTransaction(UUID someUserId, String concept) { // 2º point of failure final var newTx = transactionsClient.createTransaction(someUserId, new ConceptRequest(concept)); LOG.info("transaction created = {}, updating user transactions...", newTx); final var user = new User(someUserId, newTx); compensatingTransaction( // 3º point of failure () -> userRepository.save(user), () -> transactionsClient.removeTransaction(someUserId, newTx.getId()) ); }
4.4 Implementación de los patrones de resiliciencia
4.4.1 Circuit Breaker
Micronaut se integra con las librerías de Hystrix para implementar este patrón habilitando el uso de la anotación @HystrixCommand.
@HystrixCommand @Client("http://localhost:9090/transactions") public interface TransactionClient { @Get("/{userId}") List getNewTransactions(@PathVariable UUID userId); @Post("/{userId}") Transaction createTransaction(@PathVariable UUID userId, @Body ConceptRequest conceptRequest); @Delete("/{userId}/{txId}") void removeTransaction(@PathVariable UUID userId, @PathVariable UUID txId); }
4.4.2 Fallback
Micronaut nos permite implementar este patrón creando una implementación para la interfaz del cliente y se integra con Hystrix para invocar a estos métodos cuando el circuito se abre.
@Fallback public class TransactionClientFallback implements TransactionClient { private static final Logger LOG = LoggerFactory.getLogger(SomeService.class); @Override public List getNewTransactions(UUID userId) { LOG.warn("executing fallback method when getting new transactions"); return Collections.emptyList(); } @Override public Transaction createTransaction(UUID userId, ConceptRequest concept) { LOG.warn("executing fallback method when create transaction"); return Transaction.errorTransaction(); } @Override public void removeTransaction(UUID userId, UUID txId) { throw new IllegalStateException("compensation transaction has not been performed. Error occurred."); } }
4.4.3 Compensating transaction
La implementación de este patrón es manual ya que dependerá de cómo queramos compensar cada acción de forma específica:
private static void compensatingTransaction(Runnable action, Runnable compensating) { try { action.run(); } catch (Exception e) { LOG.warn("Error occurred. Compensating transaction..."); try { compensating.run(); } catch (Exception ignore) { } } }
4.5 Test de resiliencia
Los test se ejecutan con JUnit y se integra con Micronaut para levantar el contexto y ejecutar los test contra la aplicación real. Se crea el cliente de Toxiproxy y se crean los proxies en runtime ya que cuando levantamos el contenedor el servidor no tiene ningún proxy creado y nuestros servicios estarán totalmente aislados. Los proxies se pueden crear a través del cliente de Java o, manualmente, haciendo uso del api REST de Toxiproxy:
@Before public void init() { // initialize toxiproxy this.toxiproxyClient = new ToxiproxyClient("localhost", 8474); this.thirdPartyProxy = initProxy(toxiproxyClient, "ms2-proxy", "toxiproxy:9090", "ms2:9090"); this.postgresProxy = initProxy(toxiproxyClient, "postgres-proxy", "toxiproxy:5432", "db:5432"); }
Lo más importante aquí es tener en cuenta que el servidor de Toxiproxy se está ejecutando dentro de docker por lo que no podemos acceder a los servicios como localhost, sino que tendremos que referirnos a ellos con el nombre de servicio que hayamos indicado en el fichero docker-compose.yml.
Para poder probar cómo se comporta la aplicación ante distintos fallos de red vamos a realizar las siguientes pruebas.
Simular caída de red al intentar comunicarnos con el microservicio dependiente:
@Test public void shouldOpenCircuitOpenedOnThirdPartyWhenNetworkFailure() throws IOException { // GIVEN thirdPartyProxy.delete(); // simulate network failure // WHEN List transactions = someService.getTransactions(userId); // THEN assertEquals(Collections.emptyList(), transactions); }
Vemos cómo abre el circuito:
...
08:22:01.090 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
08:22:02.096 [HystrixTimer-1] WARN c.s.s.c.s.sample.domain.SomeService - executing fallback method when getting new transactions
...
Simular alta latencia de red al intentar comunicarnos con el servicio dependiente:
@Test public void shouldOpenCircuitOnThirdPartyWhenNetworkHighLatency() throws IOException { // GIVEN thirdPartyProxy.toxics().latency("high-latency", ToxicDirection.DOWNSTREAM, 10_000); // WHEN final var transactions = someService.getTransactions(userId); // THEN assertEquals(Collections.emptyList(), transactions); }
Vemos cómo abre el circuito también ante latencia:
...
08:22:01.090 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
08:22:02.096 [HystrixTimer-1] WARN c.s.s.c.s.sample.domain.SomeService - executing fallback method when getting new transactions
...
Simular caída de red contra la base de datos:
@Test public void shouldCompensateTransactionWhenDatabaseNetworkFail() throws IOException { // GIVEN postgresProxy.delete(); // WHEN someService.createUserTransaction(userId, "new tx concept"); // THEN final var transactions = transactionsClient.getNewTransactions(userId); assertEquals(0, transactions.size()); }
08:21:53.589 [main] WARN com.zaxxer.hikari.pool.PoolBase - HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@32f14274 (This connection has been closed.)
08:21:53.590 [main] WARN com.zaxxer.hikari.pool.PoolBase - HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@7af56b26 (This connection has been closed.)
08:21:53.591 [main] WARN com.zaxxer.hikari.pool.PoolBase - HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@c86c486 (This connection has been closed.)
...
08:21:58.586 [main] ERROR o.h.e.jdbc.spi.SqlExceptionHelper - HikariPool-1 - Connection is not available, request timed out after 5007ms.
08:21:58.586 [main] ERROR o.h.e.jdbc.spi.SqlExceptionHelper - El intento de conexión falló.
08:21:58.589 [main] WARN c.s.s.c.s.sample.domain.SomeService - Error occurred. Compensating transaction...
Aquí vemos cómo se compensa la transacción al no haber conexión a base de datos para poder dejar el sistema de terceros en un estado consistente.
Simular latencia añadiendo «nervio»
También existe una opción bastante interesante que nos permite simular «nervio» en la latencia de forma que ésta varía a lo largo del tiempo. Podemos ver cómo el circuito se abre y se cierra en función de la latencia de red en ese momento:
@Test public void shouldOpenCircuitOnThirdPartyWhenJitter() throws IOException { someService.createUserTransaction(userId, "jitter concept"); // GIVEN thirdPartyProxy.toxics().latency("latency-with-jitter", ToxicDirection.DOWNSTREAM, 10_000).setJitter(50_000); // WHEN for (int i = 0; i < 10; i++) { var transactions = someService.getTransactions(userId); System.out.println("transactions = " + transactions); } // some assertions here }
Aquí la salida por consola de los diez intentos de obtener las transacciones del usuario:
08:45:47.998 [main] INFO c.s.s.c.s.sample.domain.SomeService - transaction created = {"Transaction":{"id":ef04cab2-f132-4792-9eee-351594b9c79c, "date":2019-02-20T07:45:47.964227, "concept":"jitter concept", "transactionStatus":"CREATED"}}, updating user transactions...
feb. 20, 2019 8:45:48 A. M.
08:45:48.171 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
08:45:49.207 [HystrixTimer-1] WARN c.s.s.c.s.sample.domain.SomeService - executing fallback method when getting new transactions
transactions = []
08:45:49.208 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
transactions = [{"Transaction":{"id":ef04cab2-f132-4792-9eee-351594b9c79c, "date":2019-02-20T07:45:47.964227, "concept":"jitter concept", "transactionStatus":"CREATED"}}]
08:45:49.231 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
08:45:50.238 [HystrixTimer-2] WARN c.s.s.c.s.sample.domain.SomeService - executing fallback method when getting new transactions
transactions = []
08:45:50.240 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
transactions = [{"Transaction":{"id":ef04cab2-f132-4792-9eee-351594b9c79c, "date":2019-02-20T07:45:47.964227, "concept":"jitter concept", "transactionStatus":"CREATED"}}]
08:45:50.263 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
08:45:51.270 [HystrixTimer-3] WARN c.s.s.c.s.sample.domain.SomeService - executing fallback method when getting new transactions
transactions = []
08:45:51.271 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
transactions = [{"Transaction":{"id":ef04cab2-f132-4792-9eee-351594b9c79c, "date":2019-02-20T07:45:47.964227, "concept":"jitter concept", "transactionStatus":"CREATED"}}]
08:45:51.292 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
transactions = [{"Transaction":{"id":ef04cab2-f132-4792-9eee-351594b9c79c, "date":2019-02-20T07:45:47.964227, "concept":"jitter concept", "transactionStatus":"CREATED"}}]
08:45:51.309 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
transactions = [{"Transaction":{"id":ef04cab2-f132-4792-9eee-351594b9c79c, "date":2019-02-20T07:45:47.964227, "concept":"jitter concept", "transactionStatus":"CREATED"}}]
08:45:51.319 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
08:45:52.324 [HystrixTimer-6] WARN c.s.s.c.s.sample.domain.SomeService - executing fallback method when getting new transactions
transactions = []
08:45:52.324 [main] INFO c.s.s.c.s.sample.domain.SomeService - getting new transactions...
08:45:53.331 [HystrixTimer-4] WARN c.s.s.c.s.sample.domain.SomeService - executing fallback method when getting new transactions
transactions = []
5. Conclusiones
Aunque este tipo de test son más costosos que los test unitarios a los que estamos acostumbrados, sin duda tienen un gran potencial aportándonos una visión más cercana de cómo se comportará nuestra aplicación ante fallos reales que pueden ocurrir en nuestro sistema y que, como dice la ley de Murphy:
Si algo malo puede pasar, pasará.