A la hora de hacer frente a los fallos en un sistema de microservicios, siempre se han utilizado mecanismos de mitigación localizados, como la desconexión de la carga y los disyuntores, pero pueden no ser tan eficaces como un enfoque más globalizado. Estos mecanismos localizados(como se demuestra en un estudio sistemático sobre el tema publicado en SoCC 2022) son útiles para evitar la sobrecarga de servicios individuales, pero no son muy eficaces para hacer frente a fallos complejos que implican interacciones entre servicios, que son característicos de los fallos de microservicios.
Una forma novedosa de hacer frente a estos fallos complejos adopta una visión globalizada del sistema: cuando surge un problema, se activa automáticamente un plan de mitigación global que coordina las acciones de mitigación en todos los servicios. En este post, evaluamos el proyecto de código abierto Aperture y cómo permite un plan global de mitigación de fallos para nuestros servicios. Primero describimos los tipos comunes de fallos que hemos experimentado en DoorDash. A continuación, nos sumergimos en los mecanismos existentes que nos han ayudado a capear los fallos. Explicaremos por qué los mecanismos localizados pueden no ser la solución más eficaz y argumentaremos a favor de un enfoque de mitigación de fallos globalmente consciente. Además, compartiremos nuestras experiencias iniciales utilizando Aperture, que ofrece un enfoque global para abordar estos retos.
Clases de fallos en la arquitectura de microservicios
Antes de explicar lo que hemos hecho para hacer frente a los fallos, vamos a explorar los tipos de fallos de microservicios que experimentan las organizaciones. Analizaremos cuatro tipos de fallos que DoorDash y otras empresas han encontrado.
En DoorDash, vemos cada fallo como una oportunidad de aprendizaje y, a veces, compartimos nuestras ideas y lecciones aprendidas en entradas de blog públicas para mostrar nuestro compromiso con la fiabilidad y el intercambio de conocimientos. En esta sección, vamos a discutir algunos patrones de fallo comunes que hemos experimentado. Cada sección va acompañada de interrupciones reales extraídas de nuestras entradas de blog anteriores que se pueden explorar con mayor detalle.
Estos son los fallos que vamos a detallar:
- Fallo en cascada: una reacción en cadena de diferentes servicios interconectados que fallan.
- Tormenta de reintentos: cuando los reintentos ejercen una presión adicional sobre un servicio degradado.
- Espiral de la muerte: algunos nodos fallan, lo que provoca que se dirija más tráfico a los nodos sanos, haciendo que éstos también fallen.
- Fallo metaestable: término general que describe los fallos que no pueden autorrecuperarse debido a la existencia de un bucle de retroalimentación positiva.
Fallo en cascada
Por fallo en cascada se entiende el fenómeno por el cual el fallo de un único servicio provoca una reacción en cadena de fallos en otros servicios. En nuestro blog documentamos un grave fallo de este tipo. En ese caso, la cadena de fallos se inició a partir de un mantenimiento de la base de datos aparentemente inocuo, que aumentó la latencia de la base de datos. La latencia se propagó a los servicios anteriores, causando errores por tiempos de espera y agotamiento de recursos. El aumento de las tasas de error desencadenó un disyuntor mal configurado, que detuvo el tráfico entre un montón de servicios no relacionados, dando lugar a una interrupción con un amplio radio de explosión.
El fallo en cascada describe un fenómeno general en el que el fallo se propaga a través de los servicios, y existe una amplia gama de formas en las que un fallo puede transmitirse a otro. La tormenta de reintentos es un modo común de transmisión entre otros, en los que nos sumergiremos a continuación.
Tormenta de reintentos
Debido a la naturaleza poco fiable de las Llamadas a Procedimientos Remotos (RPC), los sitios de llamada RPC a menudo se instrumentan con tiempos de espera y reintentos para hacer que cada llamada tenga más probabilidades de éxito. Reintentar una petición es muy eficaz cuando el fallo es transitorio. Sin embargo, los reintentos empeorarán el problema cuando el servicio descendente no esté disponible o sea lento, ya que en ese caso, la mayoría de las peticiones acabarán siendo reintentadas varias veces y seguirán fallando en última instancia. Este escenario en el que se aplican reintentos excesivos e ineficaces se denomina amplificación del trabajo, y hará que un servicio ya degradado se degrade aún más. A modo de ejemplo, este tipo de interrupción se produjo en una etapa temprana de nuestra transición a los microservicios: un aumento repentino de la latencia de nuestro servicio de pago provocó el comportamiento de reintento de la Dasher App y su sistema backend, lo que agravó la situación.
Espiral de la muerte
Con frecuencia, los fallos pueden propagarse verticalmente a través de un gráfico de llamadas RPC entre servicios, pero también pueden propagarse horizontalmente entre nodos que pertenecen al mismo servicio. Una espiral de la muerte es un fallo que comienza con un patrón de tráfico que hace que un nodo se bloquee o se vuelva muy lento, por lo que el equilibrador de carga enruta las nuevas peticiones a los nodos sanos restantes, lo que los hace más propensos a bloquearse o sobrecargarse. Esta entrada de blog describe una interrupción que comenzó con algunos pods que fallaron la sonda de preparación y por lo tanto fueron eliminados del clúster, y los nodos restantes fallaron ya que no eran capaces de manejar las cargas masivas por sí solos.
Fallos metaestables
Un artículo reciente propone un nuevo marco para estudiar los fallos de los sistemas distribuidos, que se denomina "fallo metaestable". Muchos de los apagones que experimentamos pertenecen a esta categoría. Este tipo de fallo se caracteriza por un bucle de retroalimentación positiva dentro del sistema que proporciona una carga elevada sostenida debido a la amplificación del trabajo, incluso después de que desaparezca el desencadenante inicial (por ejemplo, un mal despliegue; una oleada de usuarios). El fallo metaestable es especialmente malo porque no se autorrecupera, y los ingenieros tienen que intervenir para detener el bucle de retroalimentación positiva, lo que aumenta el tiempo que se tarda en recuperarse.
Contramedidas locales
Todos los fallos documentados en la sección anterior son tipos de contramedidas que intentan limitar el impacto del fallo localmente dentro de una instancia de un servicio, pero ninguna de estas soluciones permite una mitigación coordinada entre servicios para garantizar la recuperación global del sistema. Para demostrarlo, nos sumergiremos en cada uno de los mecanismos de mitigación existentes que desplegamos y, a continuación, analizaremos sus limitaciones.
Las contramedidas que discutiremos son:
- Reducción de la carga: impide que los servicios degradados acepten más solicitudes.
- Interruptor de circuito: que detiene las solicitudes salientes cuando se degradan.
- Escalado automático: puede ayudar a gestionar cargas elevadas en picos de tráfico, pero sólo es útil si está configurado para ser predictivo en lugar de reactivo.
A continuación explicaremos cómo funcionan todas estas estrategias de tolerancia a fallos y después discutiremos sus inconvenientes y ventajas.
Desconexión de la red
La descongestión de la carga es un mecanismo de fiabilidad que rechaza las solicitudes entrantes a la entrada del servicio cuando el número de solicitudes en curso o concurrentes supera un límite. Al rechazar sólo parte del tráfico, maximizamos el rendimiento del servicio, en lugar de permitir que se sobrecargue por completo hasta el punto de que ya no pueda realizar ningún trabajo útil. En DoorDash, instrumentamos cada servidor con un "límite de concurrencia adaptable" de la biblioteca de Netflix concurrency-limit. Funciona como un interceptor gRPC y ajusta automáticamente el número máximo de solicitudes concurrentes según el cambio en la latencia que observa: cuando la latencia aumenta, la biblioteca reduce el límite de concurrencia para dar a cada solicitud más recursos informáticos. Además, el load shedder puede configurarse para que reconozca las prioridades de las peticiones a partir de su cabecera y sólo acepte las de alta prioridad durante un periodo de sobrecarga.
El deslastre de cargas puede ser eficaz para evitar la sobrecarga de un servicio. Sin embargo, dado que el deslastre de carga se instala a nivel local, sólo puede gestionar los cortes de servicio locales. Como hemos visto en la sección anterior, los fallos en un sistema de microservicios suelen ser el resultado de una interacción entre servicios. Por lo tanto, sería beneficioso tener una mitigación coordinada durante una interrupción. Por ejemplo, cuando un importante servicio descendente A se vuelve lento, un servicio ascendente B debería empezar a bloquear las peticiones antes de que lleguen a A. Esto evita que la latencia aumentada de A se propague dentro del subgrafo, causando potencialmente un fallo en cascada.
Además de la limitación de la falta de coordinación, el load shedding también es difícil de configurar y probar. Configurar correctamente un repartidor de carga requiere pruebas de carga cuidadosamente orquestadas para comprender el límite de concurrencia óptimo de un servicio, lo cual no es una tarea fácil porque en el entorno de producción, algunas peticiones son más caras que otras, y algunas peticiones son más importantes para el sistema que otras. Como ejemplo de un load shedder mal configurado, una vez tuvimos un servicio cuyo límite de concurrencia inicial estaba configurado demasiado alto, lo que provocó una sobrecarga temporal durante el tiempo de arranque del servicio. Aunque el limitador de carga pudo reducir el límite con el tiempo, la inestabilidad inicial fue grave y demostró lo importante que es configurar correctamente el limitador de carga. Sin embargo, los ingenieros suelen dejar estos parámetros en sus valores por defecto, lo que a menudo no es óptimo para las características de cada servicio.
Disyuntor
Mientras que la supresión de carga es un mecanismo para rechazar el tráfico entrante, un disyuntor rechaza el tráfico saliente, pero al igual que un disyuntor de carga sólo tiene una visión localizada. Los disyuntores suelen implementarse como un proxy interno que gestiona las peticiones salientes a los servicios descendentes. Cuando la tasa de errores del servicio descendente supera un umbral, el disyuntor se abre y rechaza rápidamente todas las peticiones al servicio con problemas sin amplificar ningún trabajo. Transcurrido un cierto tiempo, el disyuntor permite gradualmente el paso de más tráfico, volviendo finalmente al funcionamiento normal. En DoorDash hemos incorporado un disyuntor en nuestro cliente gRPC interno.
En situaciones en las que el servicio descendente está experimentando un fallo pero tiene capacidad para recuperarse si se reduce el tráfico, un disyuntor puede ser útil. Por ejemplo, durante una espiral de muerte en la formación, los nodos no sanos son sustituidos por nodos recién iniciados que no están preparados para recibir tráfico, por lo que el tráfico se dirige a los nodos sanos restantes, lo que hace más probable que se sobrecarguen. Un disyuntor abierto, en este caso, da tiempo y recursos extra para que todos los nodos vuelvan a estar sanos.
Los disyuntores tienen el mismo problema de ajuste que la desconexión por carga: no hay una buena forma de que los autores del servicio determinen el umbral de disparo. Muchas fuentes en línea sobre este tema utilizan una "tasa de error del 50%" como regla general. Sin embargo, para algunos servicios, una tasa de error del 50% puede ser tolerable. Cuando un servicio al que se llama devuelve un error, puede deberse a que el propio servicio no esté en buen estado o a que un servicio situado más abajo tenga problemas. Cuando se abre un disyuntor, el servicio que se encuentra detrás queda efectivamente inalcanzable durante un periodo de tiempo, lo que puede considerarse aún menos deseable. El umbral de desconexión depende del SLA del servicio y de las implicaciones de las solicitudes aguas abajo, que deben considerarse cuidadosamente.
Manténgase informado con las actualizaciones semanales
Suscríbase a nuestro blog de ingeniería para estar al día de los proyectos más interesantes en los que trabaja nuestro equipo.
Please enter a valid email address.
Gracias por suscribirse.
Autoescalado
Todos los orquestadores de clúster pueden configurarse con autoescalado para gestionar los aumentos de carga. Cuando está activado, un controlador comprueba periódicamente el consumo de recursos de cada nodo (por ejemplo, CPU o memoria) y, cuando detecta un uso elevado, lanza nuevos nodos para distribuir la carga de trabajo. Aunque esta función puede parecer atractiva, en DoorDash recomendamos que los equipos no utilicen el autoescalado reactivo (que escala el clúster en tiempo real durante un pico de carga). Dado que esto es contrario a la intuición, a continuación enumeramos los inconvenientes del autoescalado reactivo.
- Los nodos recién lanzados necesitan tiempo para calentarse (llenar cachés, compilar código, etc.) y mostrarán una mayor latencia, lo que reduce temporalmente la capacidad del clúster. Además, los nuevos nodos ejecutarán costosas tareas de arranque, como abrir conexiones a bases de datos y activar protocolos de adhesión. Estos comportamientos son poco frecuentes, por lo que un aumento repentino de los mismos puede dar lugar a resultados inesperados.
- Durante una interrupción que implique una carga elevada, añadir más capacidad a un servicio a menudo sólo desplazará el cuello de botella a otro lugar. No suele resolver el problema.
- El autoescalado reactivo dificulta el análisis post-mortem, ya que la cronología de las métricas se ajusta de diversas maneras tanto al incidente, como a las acciones que los humanos están tomando para mitigarlo y al autoescalado.
Por lo tanto, aconsejamos a los equipos que eviten utilizar el autoescalado reactivo y prefieran, en su lugar, el autoescalado predictivo, como el cron de KEDA, que ajusta el tamaño de un clúster en función de los niveles de tráfico previstos a lo largo del día.
Todos estos mecanismos localizados son buenos para tratar diferentes tipos de fallos. Ahora vamos a analizar por qué las soluciones localizadas tienen un límite y por qué sería preferible una observación e intervención globalizadas.
Deficiencias de las contramedidas existentes
Todas las técnicas de fiabilidad que empleamos tienen una estructura similar que consta de tres componentes: medición de las condiciones operativas, identificación de problemas mediante reglas y ajustes, y medidas a tomar cuando surgen problemas. Por ejemplo, en el caso de la desconexión de la carga, los tres componentes son:
- Medida: calcula el historial reciente de latencia o errores del servicio.
- Identificar: utiliza fórmulas matemáticas y parámetros preestablecidos para determinar si el servicio corre el riesgo de sobrecargarse.
- Acción: deniega el exceso de solicitudes entrantes
Para disyuntor, son:
- Medida: evalúa la tasa de errores del servicio descendente.
- Identificar: comprueba si supera un umbral
- Acción: detiene todo el tráfico saliente hacia ese servicio.
Sin embargo, los mecanismos localizados existentes adolecen de deficiencias similares en el sentido de que:
- Utilizan las métricas que son locales al servicio para medir las condiciones de funcionamiento; sin embargo, muchas clases de interrupciones implican una interacción entre muchos componentes, y se requiere tener una visión global del sistema para tomar buenas decisiones sobre cómo mitigar los efectos de una condición de sobrecarga.
- Emplean una heurística muy general para determinar la salud del sistema, que a menudo no es lo suficientemente precisa. Por ejemplo, la latencia por sí sola no puede decir si un servicio está sobrecargado; una latencia alta podría estar causada por un servicio descendente lento.
- Sus acciones correctoras son limitadas. Como los mecanismos se instrumentan localmente, sólo pueden tomar medidas locales. Las acciones locales no suelen ser óptimas para devolver el sistema a un estado saludable, ya que el verdadero origen del problema puede estar en otra parte.
Vamos a discutir cómo superar estas deficiencias y hacer que la mitigación sea más eficaz.
Utilización de controles globalizados: Apertura para la gestión de la fiabilidad
Un proyecto que va más allá de las contramedidas locales para implementar un control de carga globalizado es Aperture, un sistema de gestión de la fiabilidad de código abierto. Proporciona una capa de abstracción de la fiabilidad que facilita su gestión en una arquitectura de microservicios distribuidos. A diferencia de los mecanismos de fiabilidad existentes, que sólo pueden reaccionar ante anomalías locales, Aperture ofrece un sistema de gestión de la carga centralizado que le permite coordinar muchos servicios en respuesta a una interrupción en curso.
El diseño de Aperture
Al igual que las contramedidas existentes, Aperture supervisa y controla la fiabilidad del sistema con tres componentes clave.
- Observar: Aperture recopila métricas relacionadas con la fiabilidad de cada nodo y las agrega en Prometheus.
- Analizar: Un controlador Aperture que funciona de forma independiente supervisa constantemente las métricas y realiza un seguimiento de la desviación con respecto al SLO.
- Actuar: Si se produce alguna anomalía, el controlador de Aperture activará las políticas que coincidan con el patrón observado y aplicará acciones en cada nodo, como la eliminación de carga o la limitación de velocidad distribuida.
Nuestra experiencia con Aperture
Aperture es altamente configurable en la forma en que detecta y actúa ante las anomalías del sistema. Adopta políticas escritas en archivos YAML que guían sus acciones durante una interrupción. Por ejemplo, el siguiente código, extraído de la documentación de Aperture y simplificado, calcula la latencia media móvil exponencial (EMA). Toma las métricas de latencia de Prometheus y activa una alerta cuando el valor calculado supera un umbral.
circuit:
components:
- promql:
evaluation_interval: 1s
out_ports:
output:
signal_name: LATENCY
query_string:
# OMITTED
- ema:
ema_window: 1500s
in_ports:
input:
signal_name: LATENCY
out_ports:
output:
signal_name: LATENCY_EMA
warm_up_window: 10s
- decider:
in_ports:
lhs:
signal_name: LATENCY
rhs:
signal_name: LATENCY_SETPOINT
operator: gt
out_ports:
output:
signal_name: IS_OVERLOAD_SWITCH
- alerter:
alerter_config:
alert_name: overload
severity: crit
in_ports:
signal:
signal_name: IS_OVERLOAD_SWITCH
evaluation_interval: 0.5s
Cuando se activa una alerta, Aperture ejecuta automáticamente acciones de acuerdo con las políticas configuradas. Algunas de las acciones que ofrece actualmente son la limitación de la tasa distribuida y la limitación de la concurrencia (también conocida como eliminación de carga). El hecho de que Aperture tenga una visión y un control centralizados de todo el sistema abre numerosas posibilidades para mitigar las interrupciones. Por ejemplo, se puede configurar una política que descargue un servicio ascendente cuando un servicio descendente esté sobrecargado, permitiendo que las peticiones excesivas fallen antes de llegar al subgrafo problemático, lo que hace que el sistema responda mejor y ahorre costes.
Para probar la capacidad de Aperture, realizamos un despliegue de Aperture y lo integramos en uno de nuestros servicios principales, todo ello dentro de un entorno de pruebas, y comprobamos que era un eficaz liberador de carga. A medida que aumentábamos el RPS de las peticiones artificiales enviadas al servicio, observamos que la tasa de error aumentaba, pero el goodput se mantenía estable. En una segunda ejecución, redujimos la capacidad de cálculo del servicio, y esta vez observamos que el rendimiento se reducía, pero la latencia sólo aumentaba ligeramente. Entre bastidores de ambas ejecuciones, el controlador de Aperture notó un aumento de la latencia y decidió reducir el límite de concurrencia. En consecuencia, la integración de la API en el código de nuestra aplicación rechazó algunas de las solicitudes entrantes, lo que se refleja en un aumento de la tasa de errores. El límite de concurrencia reducido garantiza que cada solicitud aceptada obtenga suficientes recursos informáticos, por lo que la latencia sólo se ve ligeramente afectada.
Con esta sencilla configuración, Aperture actúa básicamente como un limitador de carga, pero es más configurable y fácil de usar que nuestras soluciones actuales. Podemos configurar Aperture con un sofisticado algoritmo de limitación de concurrencia que minimiza el impacto de la carga inesperada o la latencia. Aperture también ofrece un panel Grafana todo en uno que utiliza métricas Prometheus, lo que proporciona una visión rápida de la salud de nuestros servicios.
Todavía tenemos que probar las características más avanzadas de Aperture, incluyendo la capacidad de coordinar las acciones de mitigación a través de los servicios y la posibilidad de tener políticas de escalado en el que el autoescalado se activa después de una carga sostenida. Evaluar estas características requiere configuraciones más elaboradas. Dicho esto, una solución de fiabilidad se prueba mejor en el entorno de producción, donde se producen las interrupciones reales, que siempre son impredecibles.
Detalles de la integración de Aperture
Merece la pena profundizar en cómo se integra Aperture en un sistema existente. Un despliegue de Aperture consta de los siguientes componentes:
- Controlador de ApertureEste módulo es el cerebro del sistema Aperture. Supervisa constantemente las métricas de fiabilidad y decide cuándo ejecutar un plan de mitigación. Cuando se activa un proyecto, envía las acciones apropiadas (por ejemplo, reducción de carga) al agente de Aperture.
- Agente Aperture: cada clúster Kubernetes ejecuta una instancia del agente Aperture, que se encarga de rastrear y garantizar la salud de los nodos que se ejecutan en el mismo clúster. Cuando una solicitud llega a un servicio, será interceptada por un punto de integración, que reenvía los metadatos relativos a un agente Aperture. El agente de Aperture registra los metadatos y responde con una decisión sobre la aceptación de la solicitud. Dicha decisión se basa en la información facilitada por el controlador de Aperture.
- Punto de integración: los servicios que deseen beneficiarse de una gestión centralizada de la fiabilidad pueden integrarse con Aperture de tres maneras. Si los servicios están construidos sobre una malla de servicios (actualmente sólo soporta Envoy), Aperture puede desplegarse en la malla de servicios directamente sin cambiar el código de la aplicación. También existen SDKs de Aper ture que se pueden utilizar para integrar el código de la aplicación con los endpoints de Aperture. Para aplicaciones Java, también se puede utilizar Java Agent para inyectar automáticamente la integración de Aperture en Netty. Para ilustrar lo que hace esta integración, a continuación se muestra un fragmento de código que demuestra cómo utilizar el SDK de Aperture en Java.
- Prometheus y etcd: son bases de datos que almacenan las métricas de fiabilidad y son consultadas por el controlador Aperture para obtener una medida del estado de funcionamiento actual.
private String handleSuperAPI(spark.Request req, spark.Response res) {
Flow flow = apertureSDK.startFlow(metadata);
if (flow.accepted()) {
res.status(202);
work(req, res);
flow.end(FlowStatus.OK);
} else {
res.status(403);
flow.end(FlowStatus.Error);
}
return "";
}
Conclusión
Los mecanismos de fiabilidad existentes se instrumentan a nivel local de servicios individuales, y hemos demostrado que los mecanismos globalizados funcionan mejor a la hora de hacer frente a las interrupciones. En este blog, mostramos por qué mantener un sistema de microservicios funcionando de forma fiable es un problema difícil. También ofrecemos una visión general de nuestras contramedidas actuales. Estas soluciones existentes evitan eficazmente muchas interrupciones, pero los ingenieros a menudo no comprenden bien su funcionamiento interno y no las configuran de forma óptima. Además, sólo pueden observar y actuar dentro de cada servicio, lo que limita su eficacia para mitigar los cortes en un sistema distribuido.
Para probar la idea de utilizar mecanismos globalizados para mitigar las interrupciones, investigamos el proyecto de gestión de la fiabilidad de código abierto Aperture. Este proyecto eleva la gestión de la fiabilidad a componente primario del sistema al centralizar las responsabilidades de supervisión y control en lugar de que sean gestionadas por servicios individuales. De este modo, Aperture permite aplicar métodos automatizados, eficaces y rentables para hacer frente a las averías. Tuvimos una experiencia positiva durante nuestra prueba inicial, y estamos entusiasmados con su potencial.