Resiliencia, clave en sistemas Cloud-Native

Daniel Pous Montardit    30 enero, 2023

En el primer post de la serie Cloud-Native, ¿Qué significa que mi software sea Cloud Native?, presentamos la resiliencia como uno de los atributos fundamentales que nos ayudan a conseguir que nuestros sistemas sean fiables y funcionen prácticamente sin interrupciones de servicio.

Empecemos por definir qué es resiliencia:

Es la capacidad de reaccionar ante un fallo y a su vez recuperarse del mismo para seguir funcionando, minimizando cualquier tipo de afectación sobre el negocio.

La resiliencia por tanto no trata de evitar fallos, sino de aceptarlos y construir un servicio de forma que sea capaz de recuperarse y volver a un estado de funcionamiento completo lo antes posible.

Los sistemas Cloud-Native se fundamentan en arquitecturas distribuidas y, por tanto, se exponen a un mayor conjunto de casuísticas de error en comparación al modelo clásico de aplicación en monolito. Algunos ejemplos de situaciones de fallo son:

  • Crecimientos inesperados en las latencias de red que pueden incurrir en timeouts de comunicación entre componentes y llegar a reducir la calidad del servicio.
  • Microcortes de red causantes de errores de conectividad.
  • Caída de un componente, con reinicio o cambio de localización, que debe gestionarse transparentemente al servicio.
  • Sobrecarga de un componente que desencadena un incremento progresivo en su tiempo de respuesta y puede desencadenar finalmente errores de conexión.
  • Orquestación de operaciones tales como rolling updates (estrategia de actualización del sistema que evita cualquier pérdida de servicio) o escalado/desescalado de servicios.
  • Fallos de hardware.

A pesar de que las plataformas cloud pueden detectar y mitigar muchos de los fallos en la capa de infraestructura sobre la que se ejecutan las aplicaciones, para obtener un nivel de resiliencia adecuado de nuestro sistema, es necesario implementar ciertas prácticas o patrones a nivel de la aplicación o sistema software desplegado.

Hablemos ahora de qué técnicas o tecnologías nos ayudan a conseguir resiliencia en cada una de las capas presentadas: capa infraestructura y capa software.

Infraestructura Resiliente

La resiliencia, a nivel hardware, puede conseguirse vía soluciones como por ejemplo fuentes de alimentación redundantes, unidades de almacenamiento con escritura duplicada (RAIDs), etc.

Sin embargo, sólo ciertos fallos quedarán cubiertos con estas protecciones, y tendremos que recurrir a otras técnicas para alcanzar los niveles de resiliencia deseados, como son la redundancia y la escalabilidad.

Redundancia

La redundancia consiste en, tal y como indica la misma palabra, replicar cada uno de los elementos que conforman el servicio, de manera que cualquier tarea o parte de una tarea siempre pueda ser realizada por más de un componente.

Para ello hemos de añadir un mecanismo que permite repartir la carga de trabajo entre estas «copias» duplicadas dentro de cada grupo de trabajo, como por ejemplo, podría ser un balanceador de carga. Por otro lado, determinar el nivel de replicación necesario en un servicio dependerá de los requerimientos de negocio del mismo, y afectará tanto a su coste como a la complejidad del mismo.

Se recomienda identificar los flujos críticos dentro del servicio y añadir redundancia en cada punto de los flujos, evitando así crear «puntos únicos de fallo» (single point of failure). Estos puntos se refieren a aquellos componentes de nuestro sistema que en caso de fallo provocaría una caída total del mismo.

Es también común añadir redundancia multiregión con geo-replicación de la información y distribuir la carga mediante balanceo por DNS, dirigiendo así cada petición a la región adecuada en función de la distancia a su origen geográfico.

Escalabilidad

Diseñar sistemas escalables es fundamental también para conseguir resiliencia.

La escalabilidad o capacidad de ajustar los recursos a la carga de trabajo, ya sea incrementando o decrementando su número, es fundamental para evitar situaciones de fallo como timeouts de comunicación por tiempos de respuesta demasiado elevados, caídas de servicio por colapso de trabajo, o la degradación de los subsistemas de almacenamiento por ingestión masiva de información,…

Existen dos tipos de escalado:

  1. Escalado vertical o scale up, que consiste en incrementar la potencia de una máquina (ya sea en CPU, memoria, especio de almacenamiento…)
  2. Escalado horizontal o scale out, que supone añadir más máquinas.

La capacidad de escalar horizontalmente un sistema está muy interrelacionada con disponer de redundancia. Podríamos ver la primera como un plano superior a la segunda, es decir, un sistema no-redundante no puede ser escalable horizontalmente. A su vez, podemos conseguir escalabilidad horizontal sobre redundancia si añadimos una retroalimentación que permita determinar a partir de la carga en tiempo real del sistema en qué medida se debe crecer o decrecer en recursos para ajustarse óptimamente a las necesidades demandadas en cada momento.

Fijémonos que en este punto estamos también estableciendo una relación con la capacidad de observabilidad, que será la encargada de proporcionar las métricas necesarias para monitorizar la carga y automatizar los sistemas de autoescalado.

Existen librerías en muchos lenguajes para implementar estas técnicas y también se pueden recurrir a soluciones más ortogonales como los Service Mesh para facilitarnos esta labor y desacoplar completamente nuestra lógica de negocio.

Software Resiliente

Cómo ya se avanzaba al inicio de este post, es imprescindible incorporar la resiliencia dentro del diseño del propio software para poder afrontar exitosamente todos los retos de los sistemas distribuidos.

La lógica del servicio debe tratar al fallo como un caso más y no como una excepción, debe definir cómo actuar en caso de fallo, y determinar la acción de contingencia cuando el camino preferente no esté disponible. Esto último se conoce cómo acción fallback o configuración de respaldo para ese caso de error.

Patrones arquitecturales

A parte del patrón fallback, existen un conjunto de patrones de arquitectura orientados a proveer de resiliencia un sistema distribuido, como por ejemplo son:

  • Cortocircuito (Circuit Breaker): este patrón contribuye a que un servicio pueda recuperarse o desacoplarse tanto de caídas de rendimiento por sobrecargas en un subsistema como ante apagones completos de partes de la aplicación.
    • Cuando el número de fallos continuados que un componente reporta supera un cierto nivel, es la antesala de que algo más grave está a punto de suceder: la caída total del subsistema afectado. Mediante el bloqueo temporal de nuevas peticiones el componente en apuros tendrá la oportunidad de recuperarse y evitar males mayores. Este colchón temporal puede ser suficiente para que el sistema de autoescalado haya podido intervenir y replicar el componente en sobrecarga, evitando así cualquier pérdida de servicio a sus clientes.
  • Timeouts: el mero hecho de limitar el tiempo en qué el emisor de una petición va esperar su respuesta, puede ser la clave que evite sobrecargas por acumulación de recursos facilitando así  la resiliencia del sistema.
    • Si un microservicio A requiere del microservicio B y éste no responde dentro del timeout definido, al no producirse ninguna espera indefinida, el microservicio A recuperará el control pudiendo decidir si sigue intentando o no. Si el problema ha sido provocado por un microcorte en la red o una sobrecarga del microservicio B, un reintento puede ser suficiente para dirigir de nuevo la petición hacia la instancia de B ya recuperada o hacia una nueva instancia libre de carga.. Y en caso de no realizar más intentos, el microservicio A puede liberar recursos y ejecutar el fallback definido.
  • Reintentos: las dos técnicas anteriores, cortocircuito y timeouts, han introducido ya indirectamente la importancia de realizar reintentos como concepto base para obtener resiliencia. Pero, ¿es posible incorporar reintentos en las comunicaciones entre componentes de forma gratuita?
    • Imaginemos siguiendo con el ejemplo anterior que un microservicio A hace una petición al B, y por un corte puntual de red la respuesta de B no llega a A. Si A incorpora reintentos, lo que sucederá es que cuando finalice el tiempo de espera de esa llamada (el timeout), éste recuperará el control y volverá a realizar la petición a B, con lo que B realizará el trabajo por duplicado con las consecuencia que puedan derivar. Por ejemplo, si esa petición fuera restar una compra del stock de productos, se estaría registrando por duplicado la salida y por tanto dejando un saldo incorrecto en los libros de stock. Es por esta situación, que se introduce el concepto de «idempotencia». Un servicio idempotente se caracteriza por ser inmune a peticiones duplicadas, es decir, el procesar repetidamente una misma petición no provoca incoherencias en el resultado final, dando pie a conseguir «reintentos seguros».

      La inmunidad se obtiene en base a un diseño que contemple la idempotencia desde su inicio, por ejemplo, en el caso anterior de la actualización del stock, la petición debería incluir un identificador de compra, y el microservicio B debería registrar y validar que dicho identificador no ha sido procesado completamente antes de intentarlo de nuevo.
  • Caché: ahora que conocemos el porqué de incorporar reintentos, podemos entender los poderes de este nuevo patrón que son las cachés aplicadas a resiliencia.
    • Si se usa una caché para almacenar automáticamente las respuestas de un microservicio, se estará ayudando tanto a reducir la presión sobre él como a generar una alternativa (fallback) en caso de ciertas anomalías. En el caso de un reintento, la caché ayuda a que el componente no tenga que volver a realizar un trabajo ya completado anteriormente, y así pueda devolver el resultado directamente.
  • Bulkhead: este último patrón consiste en dividir el sistema distribuido en partes «aisladas» e independientes, también llamadas pools, de forma que en caso que falle una de ellas las demás puedan seguir funcionando normalmente.
    • Esta herramienta de arquitectura puede verse cómo una técnica de contingencia, equiparable a un cortafuegos o a los compartimientos estancos que dividen en partes los barcos y evitan que el agua salte entre ellos. Es aconsejable, por ejemplo, para aislar a un conjunto de componentes críticos de otros estándares. Hay qué valorar también que estás divisiones a veces pueden provocar pérdidas de eficiencia en el uso de recursos, a parte de añadir más complejidad a la solución.

Tests de resiliencia

Como hemos dicho anteriormente, en un sistema distribuido hay tantos componentes interactuando entre ellos que la probabilidad de cosas que pueden salir mal es muy grande. Hardware, red, sobrecarga de tráfico, etc. pueden fallar.

Hemos comentado varias técnicas para conseguir que nuestro software sea resiliente y se minimice el impacto de esos fallos. Pero bien, ¿tenemos alguna forma de comprobar la resiliencia de nuestro sistema? La respuesta es sí, y se llama “Chaos Engineering”.

¿Qué es Chaos Engineering?

Es una disciplina de experimentación en infraestructura que saca a la luz las debilidades sistémicas. Este proceso empírico de verificación conduce a sistemas más resistentes y genera confianza sobre la capacidad de éstos a resistir situaciones turbulentas.

Experimentar en Chaos Engineering puede ser tan simple como ejecutar manualmente kill -9 (orden para finalizar inmediatamente un proceso en sistemas unix/linux) en una caja dentro de un entorno de pruebas para simular el fallo de un servicio. O puede ser tan sofisticado como diseñar y realizar experimentos automáticamente en un entorno de producción contra una fracción pequeña pero estadísticamente significativa del tráfico en vivo.

Existen además librerías y frameworks de soporte, como por ejemplo, Chaos-monkey que es un framework creado por Netflix que permite finalizar aleatoriamente máquinas virtuales o contenedores en entornos productivos, y que cumple los principios de Ingeniería del Caos.

Es necesario identificar las debilidades del sistema antes de que se manifiesten en comportamientos aberrantes con afectación generalizada a todo el sistema.

Las debilidades sistémicas pueden tomar la forma de: configuraciones de respaldo incorrectas cuando un servicio no está disponible; reintentos desmesurados por tiempos de espera mal ajustados; cortes de servicio cuando un componente de la cadena de procesado colapsa por saturación de tráfico; fallos en cascada masivos consecuencia de un único componente (permite detectar single-point of failure); etc

Conclusiones

El enfoque más tradicional a la hora de construir sistemas era tratar al fallo como un evento excepcional fuera del camino exitoso de ejecución, y por lo tanto no se contemplaba en el diseño base del corazón del servicio.

Esto ha cambiado radicalmente en el mundo Cloud-native, dado que en arquitecturas distribuídas las situaciones de fallo aparecen con normalidad y recurrentemente en alguna pieza del conjunto y eso debe considerarse y asumirse desde el primer momento y dentro del propio diseño.

Así cuando hablamos de resiliencia nos referimos a esta característica que permite a los servicios responder y recuperarse a fallos limitando al máximo los efectos sobre el conjunto del sistema y reduciendo a la mínima expresión la afectación sobre el mismo.

Conseguir sistemas resilientes no únicamente impacta en la calidad del servicio o aplicación, sino que también permite ganar más eficiencia en costes y, sobre todo, no perder oportunidades de negocio por pérdidas de servicio.