Variti se especializa en protección contra bots y ataques DDoS, y también realiza pruebas de estrés y carga. Dado que trabajamos como un servicio internacional, es extremadamente importante para nosotros asegurar el intercambio ininterrumpido de información entre servidores y clústeres en tiempo real. En la conferencia Saint HighLoad ++ 2019, el desarrollador de Variti, Anton Barabanov, dijo cómo usamos UDP y Tarantool, por qué tomamos tanto y cómo tuvimos que reescribir el módulo Tarantool de Lua a C.También puede leer el
resumen del informe a
través del enlace y ver el video a continuación debajo del spoiler.
Cuando comenzamos a hacer un servicio de filtrado de tráfico, inmediatamente decidimos no tratar con el tránsito IP, sino proteger los servicios HTTP, API y de juegos. Por lo tanto, terminamos el tráfico en el nivel L7 en el protocolo TCP y lo transmitimos. La protección en L3 y 4 al mismo tiempo se produce automáticamente. El siguiente diagrama muestra el diagrama de servicio: las solicitudes de las personas pasan por un clúster, es decir, los servidores y equipos de red, y los robots (que se muestran como fantasmas) se filtran.

Para el filtrado, es necesario dividir el tráfico en solicitudes separadas, analizar las sesiones de manera precisa y rápida, y dado que no bloqueamos por direcciones IP, definimos bots y personas dentro de la conexión desde la misma dirección IP.
¿Qué sucede dentro del clúster?
Dentro del clúster, tenemos nodos de filtro independientes, es decir, cada nodo funciona solo y solo con su propio tráfico. El tráfico se distribuye aleatoriamente entre los nodos: si, por ejemplo, se reciben 10 conexiones de un usuario, todas ellas divergen en diferentes servidores.
Tenemos requisitos de rendimiento muy estrictos ya que nuestros clientes se encuentran en diferentes países. Y si, por ejemplo, un usuario de Suiza visita un sitio francés, entonces ya se enfrenta a 15 milisegundos de retraso de la red debido a un aumento en la ruta del tráfico. Por lo tanto, no tenemos derecho a agregar otros 15-20 milisegundos dentro de nuestro centro de procesamiento; la solicitud continuará durante mucho tiempo. Además, si procesamos cada solicitud HTTP durante 15-20 milisegundos, entonces un simple ataque de 20 mil RPS sumará todo el clúster. Esto, por supuesto, es inaceptable.
Otro requisito para nosotros no era solo rastrear la solicitud, sino también comprender el contexto. Supongamos que un usuario abre una página web y envía una solicitud de barra diagonal. Después de eso, la página se carga, y si es HTTP / 1.1, entonces el navegador abre 10 conexiones al backend y en 10 flujos estáticos y dinámicos de solicitudes, realiza solicitudes ajax y subconsultas. Si, en lugar de enviar una subconsulta, en el proceso de enviar la página, comienza a interactuar con el navegador e intenta darle, por ejemplo, JS Challenge para la subconsulta, lo más probable es que rompa la página. En la primera solicitud, puede dar CAPTCHA (aunque esto es malo) o desafíos JS, hacer una redirección, y luego cualquier navegador procesará todo correctamente. Después de la prueba, es necesario difundir información en todos los clústeres de que la sesión es legítima. Si no hay intercambio de información entre los clústeres, los otros nodos recibirán la sesión desde el medio y no sabrán si omitirla o no.
También es importante responder rápidamente a todas las sobrecargas de carga y cambios en el tráfico. Si algo saltó en un nodo, entonces, después de 50-100 milisegundos, se producirá un salto en todos los demás nodos. Por lo tanto, es mejor si los nodos conocen los cambios de antemano y establecen los parámetros de protección por adelantado para que no se produzca ningún salto en todos los demás nodos.
Un servicio adicional para protegerse contra los bots fue el servicio posterior al marcado: colocamos un píxel en el sitio, escribimos información de bot / persona y enviamos estos datos a través de API. Estos veredictos deben mantenerse en algún lugar. Es decir, si antes hablábamos de la sincronización dentro de un clúster, ahora también estamos agregando sincronización de información entre clústeres. A continuación mostramos el esquema del servicio en el nivel L7.

Entre racimos
Después de hacer el clúster, comenzamos a escalar. Trabajamos a través de BGP anycast, es decir, nuestras subredes se anuncian desde todos los clústeres y el tráfico llega al más cercano. En pocas palabras, se envía una solicitud desde Francia a un grupo en Frankfurt y desde San Petersburgo a un grupo en Moscú. Los grupos deberían ser independientes. Los flujos de red son permitidos independientemente.
¿Por qué es esto importante? Supongamos que una persona conduce un automóvil, trabaja con un sitio web desde Internet móvil y cruza un cierto Rubicon, después de lo cual el tráfico cambia repentinamente a otro grupo. U otro caso: la ruta de tráfico se reconstruyó porque en algún lugar se quemó el conmutador o enrutador, algo se cayó y el segmento de red se desconectó. En este caso, proporcionamos al navegador (por ejemplo, en cookies) información suficiente para que al cambiar a otro clúster sea posible informar los parámetros necesarios sobre las pruebas aprobadas o reprobadas.
Además, debe sincronizar el modo de protección entre clústeres. Esto es importante en el caso de ataques de bajo volumen, que a menudo se llevan a cabo bajo la cobertura de inundaciones. Dado que los ataques se ejecutan en paralelo, las personas piensan que su sitio está rompiendo la inundación y no ven un ataque de bajo volumen. Para el caso en que un volumen bajo llega a un clúster y se inunda a otro, es necesaria la sincronización del modo de protección.
Y como ya se mencionó, sincronizamos entre los clústeres los veredictos que se acumulan y son dados por API. En este caso, puede haber muchos veredictos y deben sincronizarse de manera confiable. En el modo de protección, puede perder algo dentro del clúster, pero no entre los clústeres.
Vale la pena señalar que hay una gran latencia entre los grupos: en el caso de Moscú y Frankfurt, esto es 20 milisegundos. Las solicitudes síncronas no se pueden realizar aquí; toda la interacción debe ir en modo asíncrono.
A continuación mostramos la interacción entre los grupos. M, l, p son algunos parámetros técnicos para un intercambio. U1, u2 es el marcado del usuario como ilegítimo y legítimo.

Interacción interna entre nodos
Inicialmente, cuando realizamos el servicio, el filtrado en el nivel L7 se inició en un solo nodo. Esto funcionó bien para dos clientes, pero no más. Al escalar, queríamos lograr la máxima capacidad de respuesta y la latencia mínima.
Era importante minimizar los recursos de CPU gastados en el procesamiento de paquetes, por lo que la interacción a través de, por ejemplo, HTTP no sería adecuada. También era necesario garantizar un consumo mínimo de gastos generales no solo de los recursos informáticos, sino también de la velocidad de los paquetes. Sin embargo, estamos hablando de ataques de filtrado, y estas son situaciones en las que obviamente no hay suficiente rendimiento. Por lo general, al construir un proyecto web, x3 o x4 es suficiente para la carga, pero siempre tenemos x1, ya que siempre puede venir un ataque a gran escala.
Otro requisito para la interfaz de interacción es la presencia de un lugar donde escribiremos información y desde donde podamos calcular en qué estado nos encontramos ahora. No es ningún secreto que C ++ se usa a menudo para desarrollar sistemas de filtrado. Pero desafortunadamente, los programas escritos en C ++ a veces fallan. A veces, dichos programas deben reiniciarse para actualizarse o, por ejemplo, porque la configuración no se volvió a leer. Y si reiniciamos el nodo bajo ataque, entonces debemos llevar a algún lado el contexto en el que existía este nodo. Es decir, el servicio no debe ser apátrida, debe recordar que hay un cierto número de personas a las que bloqueamos, a quienes verificamos. Debe haber la misma comunicación interna para que el servicio pueda recibir un conjunto primario de información. Pensamos poner cerca de una determinada base de datos, por ejemplo, SQLite, pero rápidamente descartamos esa solución, porque es extraño escribir Entrada-Salida en cada servidor, esto funcionará mal en la memoria.
De hecho, trabajamos con solo tres operaciones. La primera función es "enviar" a todos los nodos. Esto se aplica, por ejemplo, a los mensajes sobre la sincronización de la carga actual: cada nodo debe conocer la carga total en el recurso dentro del clúster para rastrear los picos. La segunda operación es "guardar", se trata de veredictos de verificación. Y la tercera operación es una combinación de "enviar a todos" y "guardar". Aquí estamos hablando de mensajes de cambio de estado que enviamos a todos los nodos y luego guardamos para poder restar. A continuación se muestra el esquema de interacción resultante, en el que necesitaremos agregar parámetros para guardar.

Opciones y resultados
¿Qué opciones para preservar los veredictos hemos analizado? En primer lugar, estábamos pensando en los clásicos, RabbitMQ, RedisMQ y nuestro propio servicio basado en TCP. Rechazamos estas decisiones porque funcionan lentamente. El mismo TCP agrega x2 a la tasa de paquetes. Además, si enviamos un mensaje de un nodo a todos los demás, entonces necesitamos tener muchos nodos de envío, o este nodo puede envenenar 1/16 de esos mensajes que 16 máquinas pueden enviarle. Está claro que esto es inaceptable.
Como resultado, tomamos la multidifusión UDP, porque en este caso el centro de envío es un equipo de red, que no está limitado en rendimiento y le permite resolver completamente los problemas con la velocidad de envío y recepción. Está claro que en el caso de UDP, no pensamos en formatos de texto, sino que enviamos datos binarios.
Además, agregamos inmediatamente el empaque y una base de datos. Tomamos Tarantool, porque, en primer lugar, los tres fundadores de la compañía tenían experiencia trabajando con esta base de datos, y en segundo lugar, es lo más flexible posible, es decir, también es algún tipo de servicio de aplicaciones. Además, Tarantool tiene CAPI, y la capacidad de escribir en C es una cuestión de principios para nosotros porque se requiere la máxima protección para proteger contra DDoS. Ningún lenguaje interpretado puede proporcionar un rendimiento suficiente, a diferencia de C.
En el diagrama a continuación, agregamos una base de datos dentro del clúster, en la que se almacenan los estados para la comunicación interna.

Agregar base de datos
En la base de datos, almacenamos el estado en forma de un registro de llamadas. Cuando se nos ocurrió cómo guardar información, había dos opciones. Fue posible almacenar algún estado con actualizaciones y cambios constantes, pero es bastante difícil de implementar. Por lo tanto, usamos un enfoque diferente.
El hecho es que la estructura de los datos enviados a través de UDP está unificada: hay sincronización, algún tipo de código, tres o cuatro campos de datos. Entonces comenzamos a escribir esta estructura en el espacio Tarantool y agregamos un registro TTL allí, lo que deja en claro que la estructura está desactualizada y necesita ser eliminada. Por lo tanto, se acumula un registro de mensajes en Tarantool, que borramos con el tiempo especificado. Para eliminar datos antiguos, inicialmente expiramos. Posteriormente, tuvimos que abandonarlo, porque causó ciertos problemas, que discutiremos a continuación. Hasta ahora, el esquema: en él se agregaron dos bases de datos a nuestra estructura.

Como ya mencionamos, además de almacenar estados de clúster, también es necesario sincronizar los veredictos. Veredictos sincronizamos intercluster. En consecuencia, fue necesario agregar una instalación adicional de Tarantool. Sería extraño usar otra solución, porque Tarantool ya está allí y es ideal para nuestro servicio. En la nueva instalación, comenzamos a escribir veredictos y a replicarlos con otros clústeres. En este caso, no utilizamos maestro / esclavo, sino maestro / maestro. Ahora en Tarantool solo hay un maestro / maestro asíncrono, que para muchos casos no es adecuado, pero para nosotros este modelo es óptimo. Con una latencia mínima entre los clústeres, la replicación sincrónica estaría en el camino, mientras que la replicación asincrónica no causa problemas.
Los problemas
Pero tuvimos muchos problemas.
El primer bloque de complejidad está relacionado con UDP : no es ningún secreto que el protocolo puede vencer y perder paquetes. Resolvimos estos problemas por el método del avestruz, es decir, simplemente escondimos nuestras cabezas en la arena. Sin embargo, el daño de los paquetes y la reorganización de sus lugares es imposible con nosotros, ya que la comunicación se lleva a cabo dentro del marco de un solo conmutador, y no hay conexiones inestables ni equipos de red inestables.
Puede haber un problema de pérdida de paquetes si una máquina se congela, se produce una entrada-salida en algún lugar o se sobrecarga un nodo. Si tal bloqueo se produjo por un corto período de tiempo, digamos, 50 milisegundos, entonces esto es terrible, pero se resuelve mediante el aumento de las colas sysctl. Es decir, tomamos sysctl, configuramos el tamaño de las colas y obtenemos un búfer en el que todo se encuentra hasta que el nodo comienza a funcionar nuevamente. Si se produce una congelación más prolongada, el problema no será la pérdida de conectividad, sino parte del tráfico que va al nodo. Hasta ahora, simplemente no hemos tenido tales casos.
Los problemas de replicación asincrónica de Tarantool fueron mucho más complejos. Inicialmente, no tomamos maestro / maestro, sino un modelo más tradicional para operar maestro / esclavo. Y todo funcionó exactamente hasta que el esclavo se hizo cargo de la carga maestra durante mucho tiempo. Como resultado, los datos caducados y eliminados en el maestro, pero en el esclavo, no fue así. En consecuencia, cuando cambiamos varias veces de maestro a esclavo y viceversa, se acumularon tantos datos en el esclavo que en algún momento todo se rompió. Entonces, para una tolerancia total a fallas, tuve que cambiar a replicación maestro / maestro asíncrono.
Y aquí nuevamente surgieron dificultades. En primer lugar, las claves pueden cruzarse entre diferentes réplicas. Supongamos que, dentro del clúster, escribimos datos en un maestro, en este punto la conexión se rompió, escribimos todo al segundo maestro, y después de realizar la replicación asincrónica, resultó que la misma clave primaria en el espacio y la replicación se desmoronaron.
Resolvimos este problema simplemente: tomamos un modelo en el que la clave primaria necesariamente contiene el nombre del nodo Tarantool en el que estamos escribiendo. Debido a esto, los conflictos dejaron de surgir, pero una situación se hizo posible cuando se duplicaron los datos del usuario. Este es un caso extremadamente raro, por lo que nuevamente simplemente lo descuidamos. Si la duplicación ocurre con frecuencia, Tarantool tiene muchos índices diferentes, por lo que siempre puede hacer deduplicación.
Otro problema se refiere a la preservación de veredictos y surge cuando los datos registrados en un maestro aún no han aparecido en otro, y una solicitud ya ha llegado al primer maestro. Para ser sincero, aún no hemos resuelto este problema y simplemente estamos retrasando el veredicto. Si esto es inaceptable, organizaremos una especie de impulso sobre la disponibilidad de datos. Así es como lidiamos con la replicación maestro / maestro y sus problemas.
Hubo un bloque de problemas relacionados directamente con Tarantool , sus controladores y el módulo caducado. Algún tiempo después del lanzamiento, los ataques comenzaron a llegar a nosotros todos los días, respectivamente, el número de mensajes que guardamos en la base de datos para sincronización y almacenamiento de contexto se ha vuelto muy grande. Y durante la eliminación, se comenzaron a eliminar tantos datos que el recolector de basura dejó de hacer frente. Resolvimos este problema escribiendo en C nuestro propio módulo caducado llamado IExpire.
Sin embargo, con la fecha de vencimiento hay una dificultad más con la que aún no nos hemos manejado y que radica en el hecho de que la fecha de vencimiento solo funciona en un maestro. Y si el nodo caducado cae, el clúster perderá la funcionalidad crítica. Supongamos que limpiamos todos los datos anteriores a una hora: está claro que si un nodo miente, por ejemplo, cinco horas, la cantidad de datos será x5 a la habitual. Y si en ese momento llega un gran ataque, es decir, dos casos graves coinciden, entonces el grupo caerá. Todavía no sabemos cómo lidiar con esto.
Finalmente, seguía habiendo dificultades con el conductor de Tarantool para C. Cuando interrumpimos el servicio (por ejemplo, debido a la condición de la carrera), nos llevó mucho tiempo encontrar el motivo y la depuración. Por lo tanto, acabamos de escribir nuestro controlador Tarantool. Nos llevó cinco días implementar el protocolo junto con las pruebas, la depuración y el lanzamiento en producción, pero ya teníamos nuestro propio código para trabajar con la red.
Problemas afuera
Recuerde que ya tenemos preparada la replicación de Tarantool, ya sabemos cómo sincronizar los veredictos, pero no existe una infraestructura para transferir mensajes sobre ataques o problemas entre grupos.
Teníamos muchas ideas diferentes sobre la infraestructura, incluida la idea de escribir nuestro propio servicio TCP. Pero todavía hay un módulo de Cola de Tarantool del equipo de Tarantool. Además, ya teníamos Tarantool con replicación de clúster cruzado, los "agujeros" estaban retorcidos, es decir, no había necesidad de ir a los administradores y pedir abrir puertos o conducir tráfico. Nuevamente, la integración en la filtración de software estaba lista.
Hubo una dificultad con el nodo host. Suponga que hay n nodos independientes dentro de un clúster y necesita elegir el que interactuará con la cola de escritura. Porque de lo contrario se enviarán 16 mensajes o se restará 16 veces el mismo mensaje de la cola. Resolvimos este problema simplemente: registramos un nodo responsable en el espacio Tarantool, y si el nodo se quema, simplemente cambiamos el espacio si no lo olvidamos. Pero si lo olvidamos, entonces este es un problema que también queremos resolver en el futuro.
A continuación se muestra un diagrama ya detallado de un clúster con una interfaz de interacción.

Lo que quiero mejorar y agregar
En primer lugar, queremos publicar en código abierto IExpire. Nos parece que este es un módulo útil, ya que le permite hacer todo lo mismo que caducado, pero con casi cero gastos generales. Allí debe agregar un índice de clasificación para eliminar solo la tupla más antigua. Hasta ahora, no hemos hecho esto, ya que la operación principal en Tarantool para nosotros es "escribir", y un índice adicional implicará una carga adicional debido a su soporte. También queremos reescribir la mayoría de los métodos en CAPI para evitar doblar la base de datos.La pregunta permanece con la elección de un maestro lógico, pero parece que este problema es completamente imposible de resolver. Es decir, si el nodo con caducidad cae, solo queda seleccionar manualmente otro nodo y ejecutarlo caducado. Es poco probable que esto suceda automáticamente, porque la replicación es asíncrona. Aunque probablemente consultaremos sobre esto con el equipo de Tarantool.En caso de un crecimiento exponencial de grupos, también tendremos que pedir ayuda al equipo de Tarantool. El hecho es que la replicación de todos a todos se usa para Tarantool Queue y el guardado de veredictos entre grupos. Esto funciona bien, mientras que hay tres grupos, por ejemplo, pero cuando hay 100 de ellos, la cantidad de conexiones que deben monitorearse será increíblemente grande y algo se romperá constantemente. En segundo lugar, no es un hecho que Tarantool pueda soportar tal carga.Conclusiones
Las primeras conclusiones se refieren a UDP multicast y Tarantool. La multidifusión no necesita tenerle miedo; su uso dentro del clúster es bueno, correcto y rápido. Hay muchos casos cuando hay una sincronización constante de estados, y después de 50 milisegundos, no importa lo que sucedió antes. Y en este caso, lo más probable es que la pérdida de un estado no sea un problema. Por lo tanto, el uso de la multidifusión UDP está justificado, ya que no limita el rendimiento y obtiene la tasa de paquetes óptima.El segundo punto es Tarantool. Si tiene un servicio en marcha, php, etc., lo más probable es que Tarantool sea aplicable tal como está. Pero si tiene cargas pesadas, necesitará un archivo. Pero para ser sincero, en este caso, el archivo es necesario para todo: tanto para Oracle como para PostgeSQL.Por supuesto, existe la opinión de que no necesita reinventar la rueda, y si tiene un equipo pequeño, entonces debe tomar una solución preparada: Redis para sincronización, go estándar, python, etc. Esto no es verdad Si está seguro de que necesita una nueva solución, si trabajó con código abierto, descubrió que nada le conviene o sabe de antemano que ni siquiera tiene sentido intentarlo, entonces considerar su decisión es útil. Otra conversación que es importante parar a tiempo. Es decir, no necesita escribir su Tarantool, no necesita implementar su mensaje, y si solo necesita un corredor, tome Redis ya y estará feliz.