
¡Hola Habr! Soy Artem Karamyshev, jefe del equipo de administración del sistema. . Hemos tenido muchos lanzamientos de productos nuevos durante el año pasado. Queríamos asegurarnos de que los servicios API fueran fácilmente escalables, tolerantes a fallos y estuvieran preparados para un rápido crecimiento en la carga de usuarios. Nuestra plataforma está implementada en OpenStack y quiero contarles qué problemas de tolerancia a fallas de componentes tuvimos que resolver para obtener un sistema tolerante a fallas. Creo que esto será interesante para quienes también desarrollan productos en OpenStack.
La tolerancia general a fallas de una plataforma consiste en la resiliencia de sus componentes. Así que iremos pasando gradualmente por todos los niveles donde identificamos riesgos y los cerramos.
Versión en video de esta historia, cuya fuente principal fue un informe de la conferencia Uptime Day 4, organizada por , puedes ver .
Resiliencia de la arquitectura física.
La parte pública de la nube MCS ahora se basa en dos centros de datos Tier III, entre ellos hay su propia fibra oscura, reservada a nivel físico por diferentes rutas, con un rendimiento de 200 Gbit/s. El nivel III proporciona el nivel necesario de tolerancia a fallos para la infraestructura física.
La fibra oscura está reservada tanto a nivel físico como lógico. El proceso de reserva de canales fue iterativo, surgieron problemas y estamos mejorando constantemente la comunicación entre los centros de datos.
Por ejemplo, no hace mucho, mientras trabajaba en un pozo cerca de uno de los centros de datos, una excavadora rompió una tubería y dentro de esta tubería había un cable óptico principal y uno de respaldo. Nuestro canal de comunicación tolerante a fallas con el centro de datos resultó ser vulnerable en un punto, en el pozo. En consecuencia, hemos perdido parte de la infraestructura. Sacamos conclusiones y tomamos una serie de acciones, incluida la instalación de ópticas adicionales en el pozo adyacente.
En los centros de datos existen puntos de presencia de proveedores de comunicación a quienes transmitimos nuestros prefijos vía BGP. Para cada dirección de la red, se selecciona la mejor métrica, lo que permite proporcionar a diferentes clientes la mejor calidad de conexión. Si la comunicación a través de un proveedor falla, reconstruimos nuestra ruta a través de los proveedores disponibles.
Si un proveedor falla, automáticamente cambiamos al siguiente. En caso de fallo de uno de los centros de datos, tenemos una copia espejo de nuestros servicios en el segundo centro de datos, que asume toda la carga.

Resiliencia de la infraestructura física
Qué utilizamos para la tolerancia a fallos a nivel de aplicación
Nuestro servicio se basa en una serie de componentes de código abierto.
ExaBGP es un servicio que implementa una serie de funciones utilizando el protocolo de enrutamiento dinámico basado en BGP. Lo utilizamos activamente para anunciar nuestras direcciones IP incluidas en la lista blanca a través de las cuales los usuarios acceden a la API.
HAProxy es un equilibrador de alta carga que le permite configurar reglas de equilibrio de tráfico muy flexibles en diferentes niveles del modelo OSI. Lo usamos para equilibrar todos los servicios: bases de datos, intermediarios de mensajes, servicios API, servicios web, nuestros proyectos internos: todo está detrás de HAProxy.
aplicación API — una aplicación web escrita en Python, con la que el usuario gestiona su infraestructura y su servicio.
Solicitud de trabajador (en adelante, simplemente trabajador): en los servicios OpenStack, este es un demonio de infraestructura que le permite transmitir comandos API a la infraestructura. Por ejemplo, la creación del disco ocurre en el trabajador y la solicitud de creación ocurre en la API de la aplicación.
Arquitectura de aplicación OpenStack estándar
La mayoría de los servicios desarrollados para OpenStack intentan seguir un único paradigma. Un servicio normalmente consta de 2 partes: API y trabajadores (ejecutores backend). Como regla general, una API es una aplicación WSGI en Python, que se inicia como un proceso independiente (demonio) o mediante un servidor web Nginx o Apache ya preparado. La API procesa la solicitud del usuario y pasa más instrucciones a la aplicación de trabajo para su ejecución. La transferencia se realiza mediante un intermediario de mensajes, normalmente RabbitMQ; los demás no tienen soporte suficiente. Cuando los mensajes llegan al corredor, los trabajadores los procesan y, si es necesario, devuelven una respuesta.
Este paradigma implica puntos comunes aislados de falla: RabbitMQ y la base de datos. Pero RabbitMQ está aislado dentro de un servicio y, en teoría, puede ser individual para cada servicio. Entonces, en MCS separamos estos servicios tanto como sea posible; para cada proyecto individual creamos una base de datos separada, un RabbitMQ separado. Este enfoque es bueno porque en caso de accidente en algunos puntos vulnerables, no se avería todo el servicio, sino solo una parte.
La cantidad de aplicaciones de trabajo es ilimitada, por lo que la API puede escalar fácilmente horizontalmente detrás de los equilibradores para aumentar el rendimiento y la tolerancia a fallas.
Algunos servicios requieren coordinación dentro del servicio cuando se producen operaciones secuenciales complejas entre las API y los trabajadores. En este caso se utiliza un único centro de coordinación, un sistema cluster como Redis, Memcache, etcd, que permite a un trabajador decirle a otro que esa tarea le está asignada (“por favor no la tomes”). Usamos etc. Como regla general, los trabajadores se comunican activamente con la base de datos, escriben y leen información desde allí. Usamos mariadb como base de datos, que se encuentra en un clúster multimaestro.
Este servicio único clásico está organizado de una manera generalmente aceptada para OpenStack. Puede considerarse como un sistema cerrado, cuyos métodos de escalado y tolerancia a fallos son bastante obvios. Por ejemplo, para la tolerancia a fallos de la API, basta con colocar un equilibrador delante de ellas. El escalamiento de trabajadores se logra aumentando su número.
El punto débil de todo el esquema son RabbitMQ y MariaDB. Su arquitectura merece un artículo aparte. En este artículo quiero centrarme en la tolerancia a fallos de la API.

Arquitectura de aplicaciones Openstack. Equilibrio y tolerancia a fallos de la plataforma en la nube.
Hacer que el equilibrador HAProxy sea tolerante a fallas usando ExaBGP
Para que nuestras API sean escalables, rápidas y tolerantes a fallas, les colocamos un equilibrador de carga. Elegimos HAProxy. En mi opinión, tiene todas las características necesarias para nuestra tarea: equilibrio en varios niveles OSI, interfaz de gestión, flexibilidad y escalabilidad, una gran cantidad de métodos de equilibrio, soporte para tablas de sesiones.
El primer problema que había que resolver era la tolerancia a fallos del propio equilibrador. La simple instalación de un equilibrador también crea un punto de falla: el equilibrador se rompe y el servicio falla. Para evitar que esto suceda, utilizamos HAProxy junto con ExaBGP.
ExaBGP le permite implementar un mecanismo para verificar el estado de un servicio. Usamos este mecanismo para verificar la funcionalidad de HAProxy y, en caso de problemas, deshabilitar el servicio HAProxy desde BGP.
Esquema ExaBGP+HAProxy
- Instalamos el software necesario, ExaBGP y HAProxy, en tres servidores.
- Creamos una interfaz loopback en cada servidor.
- En los tres servidores asignamos la misma dirección IP blanca a esta interfaz.
- Una dirección IP blanca se anuncia en Internet a través de ExaBGP.
La tolerancia a fallos se logra anunciando la misma dirección IP en los tres servidores. Desde el punto de vista de la red, se puede acceder a la misma dirección desde tres próximos saltos diferentes. El enrutador ve tres rutas idénticas, selecciona la prioridad más alta de ellas según su propia métrica (esta suele ser la misma opción) y el tráfico va solo a uno de los servidores.
En caso de problemas con el funcionamiento de HAProxy o una falla del servidor, ExaBGP deja de anunciar la ruta y el tráfico cambia suavemente a otro servidor.
De este modo, logramos la tolerancia a fallos del equilibrador.

Tolerancia a fallos de los equilibradores HAProxy
El esquema resultó imperfecto: aprendimos cómo reservar HAProxy, pero no aprendimos cómo distribuir la carga dentro de los servicios. Por lo tanto, ampliamos un poco este esquema: pasamos al equilibrio entre varias direcciones IP blancas.
Equilibrio basado en DNS más BGP
El problema del equilibrio de carga para nuestro HAProxy sigue sin resolverse. Sin embargo, se puede solucionar de forma bastante sencilla, como hicimos aquí.
Para equilibrar tres servidores, necesitará 3 direcciones IP blancas y un buen DNS antiguo. Cada una de estas direcciones se determina en la interfaz loopback de cada HAProxy y se anuncia en Internet.
En OpenStack, para administrar recursos, se utiliza un directorio de servicios, que especifica la API de punto final de un servicio en particular. En este directorio registramos un nombre de dominio: public.infra.mail.ru, que se resuelve a través de DNS mediante tres direcciones IP diferentes. Como resultado, obtenemos una distribución de carga entre tres direcciones a través de DNS.
Pero como al anunciar direcciones IP blancas no controlamos las prioridades de selección del servidor, esto aún no se equilibra. Normalmente, solo se seleccionará un servidor según la antigüedad de la dirección IP y los otros dos estarán inactivos porque no se especifican métricas en BGP.
Empezamos a enviar rutas vía ExaBGP con diferentes métricas. Cada balanceador anuncia las tres direcciones IP blancas, pero una de ellas, la principal de este balanceador, se anuncia con la métrica mínima. Entonces, mientras los tres balanceadores están en funcionamiento, las llamadas a la primera dirección IP van al primer balanceador, las llamadas al segundo al segundo y las llamadas al tercero al tercero.
¿Qué pasa cuando uno de los equilibradores cae? Si algún equilibrador falla, su dirección principal aún se anuncia en los otros dos y el tráfico se redistribuye entre ellos. Así, le damos al usuario varias direcciones IP a la vez vía DNS. Al equilibrar mediante DNS y diferentes métricas, obtenemos una distribución uniforme de la carga entre los tres equilibradores. Y al mismo tiempo no perdemos la tolerancia a fallos.

Equilibrio de HAProxy basado en DNS + BGP
Interacción entre ExaBGP y HAProxy
Entonces, implementamos tolerancia a fallas en caso de que el servidor se salga, basándose en detener el anuncio de rutas. Pero HAProxy puede cerrarse por otras razones además de fallas del servidor: errores de administración, fallas dentro del servicio. También en estos casos queremos quitar el equilibrador roto de debajo de la carga y necesitamos un mecanismo diferente.
Por lo tanto, ampliando el esquema anterior, implementamos latidos entre ExaBGP y HAProxy. Esta es una implementación de software de la interacción entre ExaBGP y HAProxy, cuando ExaBGP usa scripts personalizados para verificar el estado de las aplicaciones.
Para hacer esto, necesita configurar un verificador de estado en la configuración de ExaBGP, que puede verificar el estado de HAProxy. En nuestro caso, configuramos el backend de salud en HAProxy, y desde el lado de ExaBGP lo comprobamos con una simple solicitud GET. Si el anuncio deja de realizarse, lo más probable es que HAProxy no funcione y no sea necesario anunciarlo.

Comprobación de estado de HAProxy
HAProxy Peers: sincronización de sesiones
Lo siguiente que había que hacer era sincronizar las sesiones. Cuando se trabaja con balanceadores distribuidos, es difícil organizar el almacenamiento de información sobre las sesiones de los clientes. Pero HAProxy es uno de los pocos equilibradores que puede hacer esto gracias a la funcionalidad Peers: la capacidad de transferir tablas de sesiones entre diferentes procesos HAProxy.
Existen diferentes métodos de equilibrio: algunos simples como , y extendido, cuando se recuerda la sesión del cliente, y cada vez termina en el mismo servidor que antes. Queríamos implementar la segunda opción.
HAProxy utiliza tablas adhesivas para guardar sesiones de clientes de este mecanismo. Guardan la dirección IP original del cliente, la dirección de destino seleccionada (backend) y cierta información del servicio. Normalmente, las tablas stick se utilizan para almacenar un par de IP de origen + IP de destino, lo que es especialmente útil para aplicaciones que no pueden transferir el contexto de la sesión del usuario al cambiar a otro equilibrador, por ejemplo, en el modo de equilibrio RoundRobin.
Si se enseña a una mesa de barras a moverse entre diferentes procesos HAProxy (entre los cuales se produce el equilibrio), nuestros equilibradores podrán trabajar con un grupo de tablas de barras. Esto permitirá cambiar sin problemas la red del cliente si uno de los balanceadores falla; el trabajo con las sesiones del cliente continuará en los mismos backends que se seleccionaron anteriormente.
Para un correcto funcionamiento se debe resolver el problema de la dirección IP de origen del balanceador desde el que se estableció la sesión. En nuestro caso, se trata de una dirección dinámica en la interfaz loopback.
El trabajo correcto de los compañeros se logra sólo bajo ciertas condiciones. Es decir, los tiempos de espera de TCP deben ser lo suficientemente grandes o la conmutación debe ser lo suficientemente rápida para que la sesión TCP no tenga tiempo de terminar. Sin embargo, permite un cambio perfecto.
En IaaS tenemos un servicio construido con la misma tecnología. Este , que se llama Octavia. Se basa en dos procesos HAProxy e inicialmente incluye soporte para pares. Han demostrado ser excelentes en este servicio.
La imagen muestra esquemáticamente el movimiento de tablas de pares entre tres instancias de HAProxy; se propone una configuración sobre cómo se puede configurar esto:

HAProxy Peers (sincronización de sesiones)
Si implementa el mismo esquema, se debe probar cuidadosamente su funcionamiento. No es un hecho que funcionará de la misma manera el 100% del tiempo. Pero al menos no perderá las tablas de memoria cuando necesite recordar la IP de origen del cliente.
Limitar el número de solicitudes simultáneas del mismo cliente
Cualquier servicio que esté disponible públicamente, incluidas nuestras API, puede estar sujeto a avalanchas de solicitudes. Los motivos pueden ser completamente diferentes, desde errores del usuario hasta ataques dirigidos. Periódicamente recibimos DDoS por direcciones IP. Los clientes a menudo cometen errores en sus scripts y nos provocan mini-DDoS.
De una forma u otra, se debe proporcionar protección adicional. La solución obvia es limitar la cantidad de solicitudes de API y no perder tiempo de CPU procesando solicitudes maliciosas.
Para implementar dichas restricciones, utilizamos límites de tasas, organizados según HAProxy, utilizando las mismas tablas de barras. Configurar límites es bastante simple y le permite limitar al usuario según la cantidad de solicitudes a la API. El algoritmo recuerda la IP de origen desde la que se realizan las solicitudes y limita la cantidad de solicitudes simultáneas de un usuario. Por supuesto, calculamos el perfil de carga API promedio para cada servicio y establecimos un límite de ≈ 10 veces este valor. Seguimos siguiendo de cerca la situación y mantenemos el dedo en el pulso.
¿Cómo se ve esto en la práctica? Tenemos clientes que utilizan nuestras API de escalado automático todo el tiempo. Crean aproximadamente de doscientas a trescientas máquinas virtuales por la mañana y las eliminan por la noche. Para OpenStack, crear una máquina virtual, también con servicios PaaS, requiere al menos 1000 solicitudes API, ya que la interacción entre servicios también se produce a través de la API.
Esta transferencia de tareas provoca una carga bastante grande. Evaluamos esta carga, recopilamos picos diarios, los multiplicamos por diez y este se convirtió en nuestro límite de velocidad. Mantenemos el dedo en el pulso. A menudo vemos bots y escáneres que intentan mirarnos para ver si tenemos algún script CGA que pueda ejecutarse y los estamos cortando activamente.
Cómo actualizar su código base sin que los usuarios se den cuenta
También implementamos tolerancia a fallos a nivel de procesos de implementación de código. Es posible que se produzcan fallos durante las implementaciones, pero se puede minimizar su impacto en la disponibilidad del servicio.
Actualizamos constantemente nuestros servicios y debemos asegurarnos de que el código base se actualice sin afectar a los usuarios. Logramos resolver este problema utilizando las capacidades de gestión de HAProxy y la implementación de Graceful Shutdown en nuestros servicios.
Para solucionar este problema, era necesario asegurar el control del equilibrador y el cierre “correcto” de los servicios:
- En el caso de HAProxy, el control se realiza a través de un archivo de estadísticas, que es esencialmente un socket y se define en la configuración de HAProxy. Puede enviarle comandos a través de stdio. Pero nuestra principal herramienta de control de configuración es ansible, por lo que tiene un módulo integrado para administrar HAProxy. Que utilizamos activamente.
- La mayoría de nuestros servicios de API y motor admiten tecnologías de apagado elegante: cuando se apagan, esperan a que se complete la tarea actual, ya sea una solicitud http o alguna tarea de servicio. Lo mismo sucede con el trabajador. Conoce todas las tareas que está realizando y finaliza cuando ha completado todo con éxito.
Gracias a estos dos puntos, el algoritmo seguro para nuestro despliegue queda así.
- El desarrollador ensambla un nuevo paquete de código (para nosotros esto es RPM), lo prueba en el entorno de desarrollo, lo prueba en el escenario y lo deja en el repositorio del escenario.
- El desarrollador establece la tarea de implementación con la descripción más detallada de los "artefactos": la versión del nuevo paquete, una descripción de la nueva funcionalidad y otros detalles sobre la implementación, si es necesario.
- El administrador del sistema comienza la actualización. Inicia el manual de estrategias de Ansible, que a su vez hace lo siguiente:
- Toma un paquete del repositorio provisional y lo utiliza para actualizar la versión del paquete en el repositorio del producto.
- Compila una lista de backends del servicio actualizado.
- Cierra el primer servicio que se actualizará en HAProxy y espera a que sus procesos terminen de ejecutarse. Gracias al cierre ordenado, estamos seguros de que todas las solicitudes actuales de los clientes se completarán con éxito.
- Una vez que la API y los trabajadores se detienen por completo y se desactiva HAProxy, el código se actualiza.
- Ansible ejecuta servicios.
- Para cada servicio, se activan ciertos "controles", que realizan pruebas unitarias en una serie de pruebas clave predefinidas. Se realiza una comprobación básica del nuevo código.
- Si no se encontraron errores en el paso anterior, se activa el backend.
- Pasemos al siguiente backend.
- Una vez actualizados todos los backends, se lanzan las pruebas funcionales. Si faltan, entonces el desarrollador analiza cualquier funcionalidad nueva que haya creado.
Esto completa la implementación.

Ciclo de actualización del servicio
Este esquema no funcionaría si no tuviéramos una regla. Admitimos tanto la versión antigua como la nueva en la batalla. De antemano, en la etapa de desarrollo del software, se establece que incluso si hay cambios en la base de datos del servicio, no romperán el código anterior. Como resultado, la base del código se actualiza gradualmente.
Conclusión
Compartiendo mis propios pensamientos sobre una arquitectura WEB tolerante a fallas, me gustaría señalar una vez más sus puntos clave:
- tolerancia a fallos físicos;
- tolerancia a fallos de red (equilibradores, BGP);
- Tolerancia a fallos del software utilizado y desarrollado.
¡Tiempo de actividad estable para todos!
Fuente: habr.com
