Arquitectura del balanceador de carga de red en Yandex.Cloud

Arquitectura del balanceador de carga de red en Yandex.Cloud
Hola, soy Sergey Elantsev, desarrollo equilibrador de carga de red en Yandex.Cloud. Anteriormente, dirigí el desarrollo del equilibrador L7 para el portal Yandex; mis colegas bromean diciendo que, haga lo que haga, resulta ser un equilibrador. Les diré a los lectores de Habr cómo administrar la carga en una plataforma en la nube, cuál consideramos la herramienta ideal para lograr este objetivo y cómo avanzamos hacia la construcción de esta herramienta.

Primero, introduzcamos algunos términos:

  • VIP (IP virtual): dirección IP del equilibrador
  • Servidor, backend, instancia: una máquina virtual que ejecuta una aplicación
  • RIP (IP real): dirección IP del servidor
  • Healthcheck: comprobar la preparación del servidor
  • Zona de disponibilidad, AZ: infraestructura aislada en un centro de datos
  • Región: una unión de diferentes AZ

Los balanceadores de carga resuelven tres tareas principales: realizan el balanceo en sí, mejoran la tolerancia a fallas del servicio y simplifican su escalado. La tolerancia a fallos se garantiza mediante la gestión automática del tráfico: el equilibrador supervisa el estado de la aplicación y excluye del equilibrio las instancias que no pasan la verificación de actividad. El escalado se garantiza distribuyendo uniformemente la carga entre las instancias, así como actualizando la lista de instancias sobre la marcha. Si el equilibrio no es lo suficientemente uniforme, algunas de las instancias recibirán una carga que excede su límite de capacidad y el servicio será menos confiable.

Un equilibrador de carga suele clasificarse según la capa de protocolo del modelo OSI en el que se ejecuta. Cloud Balancer opera en el nivel TCP, que corresponde a la cuarta capa, L4.

Pasemos a una descripción general de la arquitectura del equilibrador de nube. Poco a poco iremos aumentando el nivel de detalle. Dividimos los componentes del equilibrador en tres clases. La clase del plano de configuración es responsable de la interacción del usuario y almacena el estado objetivo del sistema. El plano de control almacena el estado actual del sistema y administra los sistemas desde la clase del plano de datos, que son directamente responsables de entregar el tráfico de los clientes a sus instancias.

Plano de datos

El tráfico termina en costosos dispositivos llamados enrutadores fronterizos. Para aumentar la tolerancia a fallos, varios de estos dispositivos funcionan simultáneamente en un centro de datos. Luego, el tráfico va a los balanceadores, que anuncian direcciones IP de difusión directa a todas las zonas de disponibilidad a través de BGP para los clientes. 

Arquitectura del balanceador de carga de red en Yandex.Cloud

El tráfico se transmite a través de ECMP: esta es una estrategia de enrutamiento según la cual puede haber varias rutas igualmente buenas hacia el objetivo (en nuestro caso, el objetivo será la dirección IP de destino) y los paquetes se pueden enviar por cualquiera de ellas. También apoyamos el trabajo en varias zonas de disponibilidad según el siguiente esquema: anunciamos una dirección en cada zona, el tráfico va a la más cercana y no traspasa sus límites. Más adelante en la publicación veremos con más detalle lo que sucede con el tráfico.

Plano de configuración

 
El componente clave del plano de configuración es la API, a través de la cual se realizan operaciones básicas con balanceadores: crear, eliminar, cambiar la composición de instancias, obtener resultados de comprobaciones de estado, etc. Por un lado, esta es una API REST, y por el otro Por otro lado, nosotros en la nube usamos muy a menudo el marco gRPC, por lo que "traducimos" REST a gRPC y luego usamos solo gRPC. Cualquier solicitud conduce a la creación de una serie de tareas idempotentes asincrónicas que se ejecutan en un grupo común de trabajadores de Yandex.Cloud. Las tareas están escritas de tal manera que pueden suspenderse en cualquier momento y luego reiniciarse. Esto garantiza escalabilidad, repetibilidad y registro de operaciones.

Arquitectura del balanceador de carga de red en Yandex.Cloud

Como resultado, la tarea de la API realizará una solicitud al controlador del servicio del equilibrador, que está escrita en Go. Puede agregar y eliminar balanceadores, cambiar la composición de backends y configuraciones. 

Arquitectura del balanceador de carga de red en Yandex.Cloud

El servicio almacena su estado en Yandex Database, una base de datos administrada distribuida que pronto podrá utilizar. En Yandex.Cloud, como ya dicho, se aplica el concepto de comida para perros: si nosotros mismos utilizamos nuestros servicios, nuestros clientes también estarán felices de utilizarlos. Yandex Database es un ejemplo de la implementación de dicho concepto. Almacenamos todos nuestros datos en YDB y no tenemos que pensar en mantener y escalar la base de datos: estos problemas están resueltos para nosotros, usamos la base de datos como un servicio.

Volvamos al controlador del equilibrador. Su tarea es guardar información sobre el equilibrador y enviar una tarea para verificar la preparación de la máquina virtual al controlador de control de salud.

Controlador de control de salud

Recibe solicitudes para cambiar las reglas de verificación, las guarda en YDB, distribuye tareas entre los nodos de control de salud y agrega los resultados, que luego se guardan en la base de datos y se envían al controlador del balanceador de carga. Este, a su vez, envía una solicitud para cambiar la composición del clúster en el plano de datos al nodo del balanceador de carga, lo cual analizaré a continuación.

Arquitectura del balanceador de carga de red en Yandex.Cloud

Hablemos más sobre los controles de salud. Se pueden dividir en varias clases. Las auditorías tienen diferentes criterios de éxito. Las comprobaciones de TCP deben establecer con éxito una conexión dentro de un período de tiempo fijo. Las comprobaciones HTTP requieren tanto una conexión exitosa como una respuesta con un código de estado 200.

Además, los controles difieren en la clase de acción: son activos y pasivos. Los controles pasivos simplemente monitorean lo que sucede con el tráfico sin tomar ninguna medida especial. Esto no funciona muy bien en L4 porque depende de la lógica de los protocolos de nivel superior: en L4 no hay información sobre cuánto tiempo duró la operación o si la finalización de la conexión fue buena o mala. Las comprobaciones activas requieren que el equilibrador envíe solicitudes a cada instancia del servidor.

La mayoría de los balanceadores de carga realizan ellos mismos comprobaciones de actividad. En Cloud decidimos separar estas partes del sistema para aumentar la escalabilidad. Este enfoque nos permitirá aumentar la cantidad de balanceadores mientras mantenemos la cantidad de solicitudes de verificación de estado del servicio. Las comprobaciones se realizan mediante nodos de comprobación de estado independientes, a través de los cuales se fragmentan y replican los objetivos de comprobación. No puede realizar comprobaciones desde un host, ya que puede fallar. Entonces no obtendremos el estado de las instancias que comprobó. Realizamos comprobaciones en cualquiera de las instancias de al menos tres nodos de control de salud. Dividimos los propósitos de las comprobaciones entre nodos utilizando algoritmos hash consistentes.

Arquitectura del balanceador de carga de red en Yandex.Cloud

Separar el equilibrio y el control de salud puede generar problemas. Si el nodo de control de salud realiza solicitudes a la instancia, sin pasar por el equilibrador (que actualmente no atiende tráfico), surge una situación extraña: el recurso parece estar vivo, pero el tráfico no llegará a él. Resolvemos este problema de esta manera: tenemos la garantía de iniciar el tráfico de verificación de estado a través de balanceadores. En otras palabras, el esquema para mover paquetes con tráfico de clientes y de controles de salud difiere mínimamente: en ambos casos, los paquetes llegarán a los balanceadores, que los entregarán a los recursos de destino.

La diferencia es que los clientes realizan solicitudes a VIP, mientras que los controles de salud realizan solicitudes a cada RIP individual. Aquí surge un problema interesante: damos a nuestros usuarios la oportunidad de crear recursos en redes IP grises. Imaginemos que hay dos propietarios de nubes diferentes que han ocultado sus servicios detrás de equilibradores. Cada uno de ellos tiene recursos en la subred 10.0.0.1/24, con las mismas direcciones. Debe poder distinguirlos de alguna manera, y aquí debe sumergirse en la estructura de la red virtual Yandex.Cloud. Es mejor conocer más detalles en vídeo de about:evento en la nube, ahora es importante para nosotros que la red tenga varias capas y tenga túneles que se puedan distinguir por la identificación de la subred.

Los nodos de Healthcheck contactan a los balanceadores utilizando las llamadas direcciones cuasi-IPv6. Una cuasi dirección es una dirección IPv6 con una dirección IPv4 y una identificación de subred de usuario integrada en ella. El tráfico llega al equilibrador, que extrae la dirección del recurso IPv4, reemplaza IPv6 con IPv4 y envía el paquete a la red del usuario.

El tráfico inverso ocurre de la misma manera: el equilibrador ve que el destino es una red gris de los verificadores de salud y convierte IPv4 a IPv6.

VPP: el corazón del plano de datos

El equilibrador se implementa utilizando la tecnología Vector Packet Processing (VPP), un marco de Cisco para el procesamiento por lotes del tráfico de red. En nuestro caso, el marco funciona sobre la biblioteca de administración de dispositivos de red del espacio de usuario: el kit de desarrollo del plano de datos (DPDK). Esto garantiza un alto rendimiento del procesamiento de paquetes: se producen muchas menos interrupciones en el kernel y no hay cambios de contexto entre el espacio del kernel y el espacio del usuario. 

VPP va aún más lejos y exprime aún más el rendimiento del sistema al combinar paquetes en lotes. Las ganancias de rendimiento provienen del uso agresivo de cachés en los procesadores modernos. Se utilizan tanto cachés de datos (los paquetes se procesan en "vectores", los datos están cerca unos de otros) como cachés de instrucciones: en VPP, el procesamiento de paquetes sigue un gráfico, cuyos nodos contienen funciones que realizan la misma tarea.

Por ejemplo, el procesamiento de paquetes IP en VPP se produce en el siguiente orden: primero, los encabezados de los paquetes se analizan en el nodo de análisis y luego se envían al nodo, que reenvía los paquetes de acuerdo con las tablas de enrutamiento.

Un poco duro. Los autores de VPP no toleran compromisos en el uso de cachés del procesador, por lo que el código típico para procesar un vector de paquetes contiene vectorización manual: hay un bucle de procesamiento en el que se procesa una situación como "tenemos cuatro paquetes en la cola", luego lo mismo para dos, luego para uno. Las instrucciones de captación previa se utilizan a menudo para cargar datos en cachés para acelerar el acceso a ellos en iteraciones posteriores.

n_left_from = frame->n_vectors;
while (n_left_from > 0)
{
    vlib_get_next_frame (vm, node, next_index, to_next, n_left_to_next);
    // ...
    while (n_left_from >= 4 && n_left_to_next >= 2)
    {
        // processing multiple packets at once
        u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        // ...
        /* Prefetch next iteration. */
        {
            vlib_buffer_t *p2, *p3;

            p2 = vlib_get_buffer (vm, from[2]);
            p3 = vlib_get_buffer (vm, from[3]);

            vlib_prefetch_buffer_header (p2, LOAD);
            vlib_prefetch_buffer_header (p3, LOAD);

            CLIB_PREFETCH (p2->data, CLIB_CACHE_LINE_BYTES, STORE);
            CLIB_PREFETCH (p3->data, CLIB_CACHE_LINE_BYTES, STORE);
        }
        // actually process data
        /* verify speculative enqueues, maybe switch current next frame */
        vlib_validate_buffer_enqueue_x2 (vm, node, next_index,
                to_next, n_left_to_next,
                bi0, bi1, next0, next1);
    }

    while (n_left_from > 0 && n_left_to_next > 0)
    {
        // processing packets by one
    }

    // processed batch
    vlib_put_next_frame (vm, node, next_index, n_left_to_next);
}

Entonces, Healthchecks se comunica a través de IPv6 con el VPP, lo que los convierte en IPv4. Esto lo hace un nodo en el gráfico, al que llamamos NAT algorítmica. Para el tráfico inverso (y la conversión de IPv6 a IPv4) existe el mismo nodo NAT algorítmico.

Arquitectura del balanceador de carga de red en Yandex.Cloud

El tráfico directo de los clientes del balanceador pasa a través de los nodos del gráfico, que realizan el balanceo por sí mismos. 

Arquitectura del balanceador de carga de red en Yandex.Cloud

El primer nodo son las sesiones fijas. Almacena el hash de 5-tuplas para sesiones establecidas. 5-tupla incluye la dirección y el puerto del cliente desde donde se transmite la información, la dirección y los puertos de los recursos disponibles para recibir tráfico, así como el protocolo de red. 

El hash de 5 tuplas nos ayuda a realizar menos cálculos en el nodo de hash consistente posterior, así como a manejar mejor los cambios en la lista de recursos detrás del equilibrador. Cuando un paquete para el cual no hay sesión llega al balanceador, se envía al nodo hash consistente. Aquí es donde se produce el equilibrio mediante hash consistente: seleccionamos un recurso de la lista de recursos "en vivo" disponibles. A continuación, los paquetes se envían al nodo NAT, que en realidad reemplaza la dirección de destino y recalcula las sumas de verificación. Como puede ver, seguimos las reglas de VPP: me gusta, agrupando cálculos similares para aumentar la eficiencia de las cachés del procesador.

hash consistente

¿Por qué lo elegimos y qué es? Primero, consideremos la tarea anterior: seleccionar un recurso de la lista. 

Arquitectura del balanceador de carga de red en Yandex.Cloud

Con un hash inconsistente, se calcula el hash del paquete entrante y se selecciona un recurso de la lista dividiendo este hash por el número de recursos. Mientras la lista permanezca sin cambios, este esquema funciona bien: siempre enviamos paquetes con la misma tupla de 5 a la misma instancia. Si, por ejemplo, algún recurso dejó de responder a las comprobaciones de estado, la elección cambiará para una parte importante de los hashes. Las conexiones TCP del cliente se interrumpirán: un paquete que anteriormente llegó a la instancia A puede comenzar a llegar a la instancia B, que no está familiarizada con la sesión de este paquete.

El hash consistente resuelve el problema descrito. La forma más sencilla de explicar este concepto es la siguiente: imagina que tienes un anillo al que distribuyes recursos por hash (por ejemplo, por IP:puerto). Seleccionar un recurso es girar la rueda en un ángulo, que está determinado por el hash del paquete.

Arquitectura del balanceador de carga de red en Yandex.Cloud

Esto minimiza la redistribución del tráfico cuando cambia la composición de los recursos. La eliminación de un recurso solo afectará a la parte del anillo hash consistente en la que se encontraba el recurso. Agregar un recurso también cambia la distribución, pero tenemos un nodo de sesiones fijas, que nos permite no cambiar sesiones ya establecidas a nuevos recursos.

Analizamos lo que sucede con el tráfico directo entre el equilibrador y los recursos. Ahora veamos el tráfico de retorno. Sigue el mismo patrón que el tráfico de verificación: a través de NAT algorítmico, es decir, a través de NAT 44 inverso para el tráfico de clientes y a través de NAT 46 para el tráfico de comprobaciones de estado. Seguimos nuestro propio esquema: unificamos el tráfico de chequeos de salud y el tráfico de usuarios reales.

Nodo del equilibrador de carga y componentes ensamblados

La composición de los balanceadores y recursos en VPP la informa el servicio local: nodo del balanceador de carga. Se suscribe al flujo de eventos del controlador-equilibrador de carga y es capaz de trazar la diferencia entre el estado actual del VPP y el estado objetivo recibido del controlador. Obtenemos un sistema cerrado: los eventos de la API llegan al controlador del balanceador, que asigna tareas al controlador de control de salud para verificar la "vivacidad" de los recursos. Esto, a su vez, asigna tareas al nodo de verificación de salud y agrega los resultados, después de lo cual los envía de regreso al controlador del balanceador. El nodo Loadbalancer se suscribe a eventos del controlador y cambia el estado del VPP. En tal sistema, cada servicio sabe sólo lo que necesita sobre los servicios vecinos. El número de conexiones es limitado y tenemos la capacidad de operar y escalar diferentes segmentos de forma independiente.

Arquitectura del balanceador de carga de red en Yandex.Cloud

¿Qué problemas se evitaron?

Todos nuestros servicios en el plano de control están escritos en Go y tienen buenas características de escalamiento y confiabilidad. Go tiene muchas bibliotecas de código abierto para crear sistemas distribuidos. Usamos activamente GRPC, todos los componentes contienen una implementación de código abierto de descubrimiento de servicios: nuestros servicios monitorean el desempeño de cada uno, pueden cambiar su composición dinámicamente y vinculamos esto con el equilibrio de GRPC. Para las métricas, también utilizamos una solución de código abierto. En el plano de datos, obtuvimos un rendimiento decente y una gran reserva de recursos: resultó muy difícil montar un soporte en el que pudiéramos confiar en el rendimiento de un VPP, y no en una tarjeta de red de hierro.

Problemas y soluciones

¿Qué no funcionó tan bien? Go tiene administración automática de memoria, pero aún se producen pérdidas de memoria. La forma más sencilla de solucionarlos es ejecutar gorutinas y recordar finalizarlas. Conclusión: observe el consumo de memoria de sus programas Go. A menudo, un buen indicador es la cantidad de gorutinas. Hay una ventaja en esta historia: en Go es fácil obtener datos del tiempo de ejecución: consumo de memoria, cantidad de rutinas en ejecución y muchos otros parámetros.

Además, es posible que Go no sea la mejor opción para pruebas funcionales. Son bastante detallados y el enfoque estándar de "ejecutar todo en CI en un lote" no es muy adecuado para ellos. El hecho es que las pruebas funcionales requieren más recursos y provocan tiempos de espera reales. Debido a esto, las pruebas pueden fallar porque la CPU está ocupada con pruebas unitarias. Conclusión: si es posible, realice pruebas "pesadas" por separado de las pruebas unitarias. 

La arquitectura de eventos de microservicio es más compleja que un monolito: recopilar registros en docenas de máquinas diferentes no es muy conveniente. Conclusión: si crea microservicios, piense inmediatamente en el seguimiento.

Nuestros planes

Lanzaremos un equilibrador interno, un equilibrador de IPv6, agregaremos soporte para scripts de Kubernetes, continuaremos fragmentando nuestros servicios (actualmente solo están fragmentados healthcheck-node y healthcheck-ctrl), agregaremos nuevos controles de salud y también implementaremos una agregación inteligente de controles. Estamos considerando la posibilidad de hacer que nuestros servicios sean aún más independientes, de modo que no se comuniquen directamente entre sí, sino mediante una cola de mensajes. Recientemente ha aparecido en la Nube un servicio compatible con SQS Cola de mensajes de Yandex.

Recientemente, tuvo lugar el lanzamiento público de Yandex Load Balancer. Explorar documentación al servicio, administre los balanceadores de la manera que más le convenga y aumente la tolerancia a fallas de sus proyectos.

Fuente: habr.com

Añadir un comentario