
Hola Habr! Soy Artyom Karamyshev, jefe del equipo de administración de sistemas de
Mail.Ru Cloud Solutions (MCS) . Durante el año pasado, hemos tenido muchos lanzamientos de nuevos productos. Queríamos que los servicios API se escalaran fácilmente, fueran tolerantes a fallas y estuvieran listos para un aumento rápido en la carga de usuarios. Nuestra plataforma está implementada en OpenStack, y quiero decirle qué problemas de tolerancia a fallas de componentes tuvimos que cerrar para obtener un sistema tolerante a fallas. Creo que esto será interesante para quienes también desarrollan productos en OpenStack.
La tolerancia a fallos global de la plataforma consiste en la estabilidad de sus componentes. Entonces, pasaremos gradualmente por todos los niveles en los que descubrimos los riesgos y los cerramos.
Se puede ver una versión en video de esta historia, cuya fuente original fue un informe en la conferencia Uptime day 4 organizada por
ITSumma ,
en el canal de YouTube Uptime Community .
Tolerancia a fallos de la arquitectura física
La parte pública de la nube MCS se basa ahora en dos centros de datos de Nivel III, entre ellos hay una fibra oscura propia, reservada en la capa física por diferentes rutas, con un rendimiento de 200 Gb / s. El nivel de nivel III proporciona el nivel necesario de resistencia de 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 tiempo, cuando trabajaba en un pozo al lado de uno de los centros de datos, una excavadora perforaba una tubería, 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. Llegamos a conclusiones, tomamos una serie de acciones, incluida la colocación de ópticas adicionales a lo largo de un pozo vecino.
En los centros de datos hay puntos de presencia de proveedores de comunicación a los que transmitimos nuestros prefijos a través de BGP. Para cada dirección de red, se selecciona la mejor métrica, lo que permite a diferentes clientes proporcionar la mejor calidad de conexión. Si se desconecta la comunicación a través de un proveedor, reconstruimos nuestra ruta a través de los proveedores disponibles.
En caso de falla de un proveedor, cambiamos automáticamente al siguiente. En caso de falla de uno de los centros de datos, tenemos una copia espejo de nuestros servicios en el segundo centro de datos, que se llevan toda la carga.
Resistencia de infraestructura físicaLo que usamos para la tolerancia a fallas 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 usamos activamente para anunciar nuestras direcciones IP blancas a través de las cuales los usuarios obtienen acceso a la API.
HAProxy es un equilibrador altamente cargado que le permite configurar reglas muy flexibles para equilibrar el tráfico en diferentes niveles del modelo OSI. Lo usamos para equilibrar todos los servicios: bases de datos, corredores 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 cual el usuario controla su infraestructura, su servicio.
Aplicación de trabajador (en lo sucesivo, simplemente denominado trabajador): en los servicios de OpenStack es un demonio de infraestructura que le permite traducir comandos de API a la infraestructura. Por ejemplo, se crea un disco en trabajador, y una solicitud de creación se encuentra en la API de la aplicación.
Arquitectura de aplicación estándar de OpenStack
La mayoría de los servicios desarrollados para OpenStack intentan seguir un solo paradigma. Un servicio generalmente consta de 2 partes: API y trabajadores (ejecutores de fondo). Por lo general, una API es una aplicación WSGI de Python que se ejecuta como un proceso independiente (daemon) o utilizando un servidor web Nginx, Apache. La API procesa la solicitud del usuario y pasa más instrucciones a la aplicación del trabajador. La transmisión se realiza utilizando un intermediario de mensajes, generalmente RabbitMQ, el resto está mal soportado. Cuando los mensajes llegan al agente, los trabajadores los procesan y, si es necesario, devuelven una respuesta.
Este paradigma implica puntos comunes de falla aislados: 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 compartimos 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 el caso de un accidente en algunos puntos vulnerables, no todos los servicios se rompen, sino solo una parte.
El número de aplicaciones de los trabajadores es ilimitado, por lo que la API puede escalar fácilmente horizontalmente detrás de los equilibradores para aumentar la productividad y la tolerancia a fallas.
Algunos servicios requieren coordinación dentro del servicio, cuando ocurren operaciones secuenciales complejas entre API y trabajadores. En este caso, se utiliza un solo centro de coordinación, un sistema de clúster como Redis, Memcache, etc., que permite a un trabajador decirle al otro que esta tarea se le ha asignado ("por favor, no la tome"). Usamos etcd. Como regla general, los trabajadores se comunican activamente con la base de datos, escriben y leen información desde allí. Como base de datos, usamos mariadb, que tenemos en el clúster multimaster.
Tal servicio clásico de usuario único está organizado de una manera generalmente aceptada para OpenStack. Puede considerarse como un sistema cerrado, para el cual los métodos de escala y tolerancia a fallas son bastante obvios. Por ejemplo, para la tolerancia a fallos de la API, es suficiente poner un equilibrador delante de ellos. La escala de los trabajadores se logra aumentando su número.
Los puntos débiles en todo el esquema son RabbitMQ y MariaDB. Su arquitectura merece un artículo separado. En este artículo quiero centrarme en la tolerancia a fallos de la API.
Arquitectura de aplicación de OpenStack Equilibrio y resistencia de la plataforma en la nubeHacer que HAProxy Balancer sea resistente con ExaBGP
Para que nuestras API sean escalables, rápidas y tolerantes a fallas, configuramos un equilibrador frente a ellas. Elegimos HAProxy. En mi opinión, tiene todas las características necesarias para nuestra tarea: equilibrio en varios niveles de OSI, interfaz de gestión, flexibilidad y escalabilidad, una gran cantidad de métodos de equilibrio, soporte para tablas de sesión.
El primer problema que debía resolverse era la tolerancia a fallas del equilibrador en sí. Simplemente instalar el equilibrador también crea un punto de falla: el equilibrador se rompe, el servicio se cae. Para evitar esto, utilizamos HAProxy junto con ExaBGP.
ExaBGP le permite implementar un mecanismo para verificar el estado de un servicio. Utilizamos este mecanismo para verificar la funcionalidad de HAProxy y, en caso de problemas, deshabilitar el servicio HAProxy de BGP.
Esquema ExaBGP + HAProxy- Instalamos el software necesario en tres servidores, ExaBGP y HAProxy.
- En cada uno de los servidores creamos una interfaz de bucle invertido.
- En los tres servidores, asignamos la misma dirección IP blanca a esta interfaz.
- Se anuncia una dirección IP blanca en Internet a través de ExaBGP.
La tolerancia a fallas se logra al anunciar la misma dirección IP de los tres servidores. Desde el punto de vista de la red, se puede acceder a la misma dirección desde tres próximas esperanzas diferentes. El enrutador ve tres rutas idénticas, selecciona la mayor prioridad 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 la operación de HAProxy o falla del servidor, ExaBGP deja de anunciar la ruta y el tráfico cambia sin problemas a otro servidor.
Por lo tanto, hemos logrado la tolerancia a fallos del equilibrador.
Tolerancia a fallos de equilibradores de HAProxyEl esquema resultó ser 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 antes de nuestro HAProxy seguía sin resolverse. Sin embargo, se puede resolver de manera bastante simple, como lo hicimos en casa.
Para equilibrar los tres servidores, necesitará 3 direcciones IP blancas y un buen DNS antiguo. Cada una de estas direcciones se define en la interfaz de bucle invertido de cada HAProxy y se anuncia en Internet.
OpenStack utiliza un catálogo de servicios para administrar recursos, que establece la API de punto final de un servicio. En este directorio, prescribimos un nombre de dominio: public.infra.mail.ru, que se resuelve a través de DNS con tres direcciones IP diferentes. Como resultado, obtenemos un equilibrio de carga entre las tres direcciones a través de DNS.
Pero desde que anunciamos direcciones IP blancas, no controlamos las prioridades de selección del servidor, hasta ahora esto no es equilibrado. Como regla, solo se seleccionará un servidor por prioridad de la dirección IP, y los otros dos estarán inactivos, ya que no se especifican métricas en BGP.
Comenzamos a dar rutas a través de ExaBGP con diferentes métricas. Cada equilibrador anuncia las tres direcciones IP blancas, pero una de ellas, la principal para este equilibrador, se anuncia con una métrica mínima. Entonces, mientras los tres equilibradores están en funcionamiento, las llamadas a la primera dirección IP recaen en el primer equilibrador, las llamadas al segundo al segundo, al tercero al tercero.
¿Qué sucede cuando cae uno de los balanceadores? En caso de falla de cualquier equilibrador por su base, la dirección aún se anuncia de los otros dos, el tráfico entre ellos se redistribuye. Por lo tanto, le damos al usuario a través del DNS varias direcciones IP a la vez. Al equilibrar DNS y diferentes métricas, obtenemos una distribución de carga uniforme en los tres equilibradores. Y al mismo tiempo no perdemos la tolerancia a fallas.
Equilibrio HAProxy basado en DNS + BGPInteracción entre ExaBGP y HAProxy
Entonces, implementamos tolerancia a fallas en caso de que el servidor se fuera, en función de la terminación del anuncio de rutas. Pero HAProxy también se puede desconectar por otras razones además de la falla del servidor: errores de administración, fallas de servicio. Queremos eliminar el equilibrador roto debajo de la carga y en estos casos, y necesitamos otro mecanismo.
Por lo tanto, al expandir el esquema anterior, implementamos un latido 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, en la configuración ExaBGP, debe configurar un verificador de salud que pueda verificar el estado de HAProxy. En nuestro caso, configuramos el backend de salud en HAProxy, y desde el lado de ExaBGP verificamos con una simple solicitud GET. Si el anuncio deja de ocurrir, lo más probable es que HAProxy no funcione y no es necesario anunciarlo.
HAProxy Health CheckHAProxy Peers: sincronización de sesión
Lo siguiente que hizo fue sincronizar las sesiones. Cuando se trabaja a través de equilibradores distribuidos, es difícil organizar el almacenamiento de información sobre sesiones de clientes. Pero HAProxy es uno de los pocos equilibradores que puede hacer esto debido a la funcionalidad Peers: la capacidad de transferir tablas de sesión entre diferentes procesos de HAProxy.
Existen diferentes métodos de equilibrio: simples, como
round-robin , y avanzados, cuando se recuerda una sesión del cliente, y cada vez que llega al mismo servidor que antes. Queríamos implementar la segunda opción.
HAProxy utiliza tablas de palo para guardar sesiones de clientes para este mecanismo. Guardan la dirección IP de origen del cliente, la dirección de destino seleccionada (backend) y alguna información de servicio. Por lo general, las tablas de palo se usan para guardar el par de origen-IP + destino-IP, lo cual es especialmente útil para aplicaciones que no pueden transmitir el contexto de una sesión de usuario cuando se cambia a otro equilibrador, por ejemplo, en el modo de equilibrio RoundRobin.
Si se enseña a la tabla de palo a moverse entre diferentes procesos HAProxy (entre los cuales se produce el equilibrio), nuestros equilibradores podrán trabajar con un grupo de tablas de palo. Esto permitirá cambiar sin problemas la red del cliente cuando uno de los equilibradores caiga, el trabajo con las sesiones del cliente continuará en los mismos backends que se seleccionaron previamente.
Para un funcionamiento correcto, se debe resolver la dirección IP de origen del equilibrador desde el que se establece la sesión. En nuestro caso, esta es una dirección dinámica en la interfaz de bucle invertido.
El funcionamiento correcto de los compañeros se logra solo en ciertas condiciones. Es decir, los tiempos de espera de TCP deben ser lo suficientemente grandes o el conmutador debe ser lo suficientemente rápido para que la sesión de TCP no tenga tiempo de interrumpirse. Sin embargo, esto permite una conmutación perfecta.
En IaaS tenemos un servicio basado en la misma tecnología. Este es un
Load Balancer como un servicio para OpenStack llamado Octavia. Se basa en dos procesos HAProxy, originalmente incluía soporte de pares. Han demostrado su valía en este servicio.
La imagen muestra esquemáticamente el movimiento de las tablas de pares entre tres instancias de HAProxy, se sugiere una configuración, cómo se puede configurar esto:
HAProxy Peers (sincronización de sesión)Si implementa el mismo esquema, su trabajo debe probarse cuidadosamente. No es el hecho de que esto funcione de la misma manera en el 100% de los casos. Pero al menos no perderá 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 sea de dominio público, incluidas nuestras API, puede estar sujeto a avalanchas de solicitudes. Los motivos pueden ser completamente diferentes, desde errores de usuario hasta ataques dirigidos. Periódicamente, hacemos DDoS en direcciones IP. Los clientes a menudo cometen errores en sus scripts; nos hacen mini-DDoS.
De una forma u otra, se debe proporcionar protección adicional. La solución obvia es limitar el número de solicitudes de API y no perder el tiempo de CPU procesando solicitudes maliciosas.
Para implementar tales restricciones, usamos límites de velocidad, organizados en base a HAProxy, usando las mismas tablas de palo. Los límites se configuran de manera bastante simple y le permiten limitar al usuario por la cantidad de solicitudes a la API. El algoritmo recuerda la IP de origen desde la cual se realizan las solicitudes y limita el número de solicitudes simultáneas de un usuario. Por supuesto, calculamos el perfil de carga de API promedio para cada servicio y establecemos el límite ≈ 10 veces este valor. Hasta ahora, continuamos monitoreando de cerca la situación, mantenemos nuestro dedo en el pulso.
¿Cómo se ve en la práctica? Tenemos clientes que usan constantemente nuestras API de escalado automático. Crean aproximadamente doscientas o trescientas máquinas virtuales más cerca de la mañana y las eliminan más cerca de la tarde. Para OpenStack, cree una máquina virtual, también con servicios PaaS, al menos 1000 solicitudes API, ya que la interacción entre los servicios también se lleva a cabo a través de la API.
Tal lanzamiento de tareas provoca una carga bastante grande. Estimamos esta carga, recolectamos picos diarios, los incrementamos diez veces y este se convirtió en nuestro límite de velocidad. Mantenemos nuestro dedo en el pulso. A menudo vemos bots, escáneres, que intentan mirarnos, si tenemos scripts CGA que se pueden ejecutar, los cortamos activamente.
Cómo actualizar la base de código discretamente para los usuarios
También implementamos tolerancia a fallas a nivel de procesos de implementación de código. Hay bloqueos durante los despliegues, pero su impacto en la disponibilidad del servicio se puede minimizar.
Estamos constantemente actualizando nuestros servicios y deberíamos garantizar el proceso de actualización de la base del código sin efecto para los usuarios. Logramos resolver este problema utilizando las capacidades de administración de HAProxy y la implementación de Graceful Shutdown en nuestros servicios.
Para resolver este problema, era necesario proporcionar un control equilibrador y el cierre "correcto" de los servicios:
- En el caso de HAProxy, el control se realiza a través del 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 incorporado para administrar HAProxy. Que estamos usando activamente.
- La mayoría de nuestros servicios de API y motor admiten tecnologías de apagado elegantes: al apagarse, esperan a que se complete la tarea actual, ya sea una solicitud HTTP o algún tipo de tarea de utilidad. Lo mismo sucede con el trabajador. Él conoce todas las tareas que hace y termina cuando ha completado con éxito todo.
Gracias a estos dos puntos, el algoritmo seguro de nuestra implementación es el siguiente.
- El desarrollador construye un nuevo paquete de código (tenemos RPM), prueba en el entorno de desarrollo, prueba en la etapa y lo deja en el repositorio de la etapa.
- El desarrollador coloca la tarea en la 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 inicia la actualización. Lanza el libro de jugadas Ansible, que a su vez hace lo siguiente:
- Toma un paquete del repositorio de la etapa, actualiza la versión del paquete en el repositorio del producto con él.
- Hace una lista de backends del servicio actualizado.
- Apaga el primer servicio actualizado en HAProxy y espera el final de sus procesos. Gracias al apagado correcto, estamos seguros de que todas las solicitudes actuales de los clientes se completarán con éxito.
- Después de que la API, los trabajadores y HAProxy se detienen por completo, el código se actualiza.
- Ansible lanza servicios.
- Para cada servicio, extrae ciertos "bolígrafos" que realizan pruebas unitarias para una serie de pruebas clave predefinidas. Se produce una comprobación básica del nuevo código.
- Si no se encontraron errores en el paso anterior, se activa el backend.
- Ir al siguiente backend.
- Después de actualizar todos los backends, se inician las pruebas funcionales. Si no son suficientes, entonces el desarrollador analiza cualquier nueva funcionalidad que hizo.
En este despliegue se completa.
Ciclo de actualización del servicioEste esquema no funcionaría si no tuviéramos una regla. Apoyamos las versiones antiguas y nuevas en la batalla. De antemano, en la etapa de desarrollo de 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
Al compartir mis propios pensamientos sobre la arquitectura WEB tolerante a fallas, quiero señalar una vez más sus puntos clave:
- tolerancia a fallas físicas;
- tolerancia a fallos de red (equilibradores, BGP);
- tolerancia a fallas de software usado y desarrollado.
¡Todo el tiempo de actividad estable!