A Variti é especializada em proteção contra ataques de bots e DDoS e também realiza testes de estresse e carga. Como trabalhamos como um serviço internacional, é extremamente importante garantir a troca ininterrupta de informações entre servidores e clusters em tempo real. Na conferência Saint HighLoad ++ 2019, o desenvolvedor da Variti, Anton Barabanov, contou como usamos o UDP e o Tarantool, por que adotamos esse grupo e como tivemos que reescrever o módulo Tarantool de Lua para C.Você também pode ler o
resumo do relatório
através do link e ver o vídeo abaixo, no spoiler.
Quando começamos a criar um serviço de filtragem de tráfego, imediatamente decidimos não lidar com o tráfego IP, mas proteger HTTP, API e serviços de jogos. Assim, encerramos o tráfego no nível L7 no protocolo TCP e o repassamos. A proteção em L3 e 4 ocorre ao mesmo tempo automaticamente. O diagrama abaixo mostra o diagrama de serviço: solicitações de pessoas passam por um cluster, ou seja, servidores e equipamentos de rede, e bots (mostrados como fantasmas) são filtrados.

Para a filtragem, é necessário dividir o tráfego em solicitações separadas, analisar a sessão com precisão e rapidez e, como não estamos bloqueando por endereços IP, defina bots e pessoas dentro da conexão a partir do mesmo endereço IP.
O que acontece dentro do cluster
Dentro do cluster, temos nós de filtro independentes, ou seja, cada nó trabalha por conta própria e apenas com sua própria parte de tráfego. Entre os nós, o tráfego é distribuído aleatoriamente: se, por exemplo, 10 conexões são recebidas de um usuário, todas elas divergem em servidores diferentes.
Temos requisitos de desempenho muito rigorosos, pois nossos clientes estão localizados em diferentes países. E se, por exemplo, um usuário da Suíça visitar um site francês, ele já enfrentará 15 milissegundos de atraso na rede devido a um aumento na rota de tráfego. Portanto, não temos o direito de adicionar outros 15 a 20 milissegundos dentro de nosso centro de processamento - a solicitação continuará por um período criticamente longo. Além disso, se processarmos cada solicitação HTTP por 15-20 milissegundos, um ataque simples de 20 mil RPS adicionará todo o cluster. Isso, é claro, é inaceitável.
Outro requisito para nós não era apenas rastrear a solicitação, mas também entender o contexto. Suponha que um usuário abra uma página da Web e envie uma solicitação de barra. Depois disso, a página é carregada e, se for HTTP / 1.1, o navegador abre 10 conexões com o back-end e, em 10 fluxos, solicita estática e dinâmica, faz solicitações e subconsultas ajax. Se, em vez de fazer o proxy de uma subconsulta, no processo de envio da página, você começar a interagir com o navegador e tentar fornecê-lo, digamos, JS Challenge para a subconsulta, provavelmente você quebrará a página. Na primeira solicitação, você pode apresentar desafios de CAPTCHA (embora isso seja ruim) ou JS, fazer um redirecionamento e qualquer navegador processará tudo corretamente. Após o teste, é necessário disseminar informações em todos os clusters que a sessão é legítima. Se não houver troca de informações entre os clusters, os outros nós receberão a sessão do meio e não saberão se devem ignorá-la ou não.
Também é importante responder rapidamente a todas as oscilações de carga e mudanças no tráfego. Se algo pulou em um nó, depois de 50 a 100 milissegundos, um salto ocorrerá em todos os outros nós. Portanto, é melhor que os nós conheçam as alterações com antecedência e defina os parâmetros de proteção com antecedência para que não ocorra salto em todos os outros nós.
Um serviço adicional para proteção contra bots foi o serviço de pós-marcação: colocamos um pixel no site, escrevemos informações de bot / pessoa e enviamos esses dados via API. Esses veredictos devem ser mantidos em algum lugar. Ou seja, se falamos anteriormente sobre sincronização em um cluster, agora também estamos adicionando sincronização de informações entre os clusters. Abaixo, mostramos o esquema do serviço no nível L7.

Entre clusters
Depois que criamos o cluster, começamos a escalar. Trabalhamos com o BGP anycast, ou seja, nossas sub-redes são anunciadas de todos os clusters e o tráfego chega ao mais próximo. Simplificando, uma solicitação é enviada da França para um cluster em Frankfurt e de São Petersburgo para um cluster em Moscou. Os clusters devem ser independentes. Os fluxos de rede são permitidos de forma independente.
Por que isso é importante? Suponha que uma pessoa dirige um carro, trabalhe com um site da Internet móvel e atravesse um certo Rubicon, após o qual o tráfego muda repentinamente para outro cluster. Ou outro caso: a rota de tráfego foi reconstruída porque, em algum lugar, o switch ou o roteador queimaram, algo caiu, o segmento de rede desconectado. Nesse caso, fornecemos ao navegador (por exemplo, em cookies) informações suficientes para que, ao mudar para outro cluster, seja possível informar os parâmetros necessários sobre os testes aprovados ou reprovados.
Além disso, você deve sincronizar o modo de proteção entre os clusters. Isso é importante no caso de ataques de baixo volume, que geralmente são realizados sob a cobertura de inundações. Como os ataques são executados em paralelo, as pessoas pensam que o site está quebrando a enchente e não vêem um ataque de baixo volume. Para o caso em que o volume baixo chega a um cluster e a inundação para outro, a sincronização do modo de proteção é necessária.
E, como já mencionado, sincronizamos entre os clusters os próprios veredictos que se acumulam e são dados pela API. Nesse caso, pode haver muitos veredictos e eles devem ser sincronizados de maneira confiável. No modo de proteção, você pode perder algo dentro do cluster, mas não entre os clusters.
Vale ressaltar que existe uma grande latência entre os clusters: no caso de Moscou e Frankfurt, são 20 milissegundos. Solicitações síncronas não podem ser feitas aqui; toda interação deve ser executada no modo assíncrono.
Abaixo, mostramos a interação entre os clusters. M, l, p são alguns parâmetros técnicos para uma troca. U1, u2 é a marcação do usuário como ilegítima e legítima.

Interação interna entre nós
Inicialmente, quando prestamos o serviço, a filtragem no nível L7 foi iniciada em apenas um nó. Isso funcionou bem para dois clientes, mas não mais. Ao escalar, queríamos obter a máxima capacidade de resposta e a mínima latência.
Era importante minimizar os recursos da CPU gastos no processamento de pacotes, para que a interação através, por exemplo, de HTTP não fosse adequada. Também era necessário garantir um consumo mínimo de sobrecarga, não apenas dos recursos de computação, mas também da taxa de pacotes. No entanto, estamos falando de ataques de filtragem, e essas são situações em que obviamente não há desempenho suficiente. Normalmente, ao criar um projeto da Web, x3 ou x4 são suficientes para a carga, mas sempre temos x1, pois sempre pode ocorrer um ataque em larga escala.
Outro requisito para a interface de interação é a presença de um local onde iremos escrever informações e de onde podemos calcular em que estado estamos agora. Não é segredo que o C ++ é frequentemente usado para desenvolver sistemas de filtragem. Mas, infelizmente, os programas escritos em C ++ às vezes falham. Às vezes, esses programas precisam ser reiniciados para serem atualizados ou, por exemplo, porque a configuração não foi relida. E se reiniciarmos o nó sob ataque, precisamos levar para algum lugar o contexto em que esse nó existia. Ou seja, o serviço não deve ser apátrida; lembre-se de que existe um certo número de pessoas que bloqueamos e que verificamos. Deve haver a mesma comunicação interna para que o serviço possa receber um conjunto primário de informações. Pensamos em colocar perto de um determinado banco de dados, por exemplo, o SQLite, mas descartamos rapidamente essa solução, porque é estranho escrever Entrada-Saída em cada servidor, isso funcionará pouco na memória.
De fato, trabalhamos com apenas três operações. A primeira função é "enviar" para todos os nós. Isso se aplica, por exemplo, a mensagens na sincronização da carga atual: cada nó deve conhecer a carga total no recurso dentro do cluster para rastrear picos. A segunda operação é "salvar", que diz respeito a veredictos de verificação. E a terceira operação é uma combinação de "enviar para todos" e "salvar". Aqui estamos falando de mensagens de alteração de estado que enviamos a todos os nós e, em seguida, salvamos para poder subtrair. Abaixo está o esquema de interação resultante, no qual precisaremos adicionar parâmetros para salvar.

Opções e Resultado
Que opções de preservação de veredictos vimos? Em primeiro lugar, estávamos pensando nos clássicos, RabbitMQ, RedisMQ e nosso próprio serviço baseado em TCP. Rejeitamos essas decisões porque elas funcionam lentamente. O mesmo TCP adiciona x2 à taxa de pacotes. Além disso, se enviarmos uma mensagem de um nó para todos os outros, precisamos ter muitos nós de envio ou esse nó pode envenenar 1/16 dessas mensagens que 16 máquinas podem enviar para ele. É claro que isso é inaceitável.
Como resultado, adotamos o multicast UDP, pois, neste caso, o centro de envio é um equipamento de rede, que não tem desempenho limitado e permite resolver completamente problemas com a velocidade de envio e recebimento. É claro que, no caso do UDP, não pensamos em formatos de texto, mas enviamos dados binários.
Além disso, adicionamos imediatamente um pacote e um banco de dados. Pegamos o Tarantool porque, em primeiro lugar, todos os três fundadores da empresa tinham experiência em trabalhar com esse banco de dados e, em segundo lugar, é o mais flexível possível, ou seja, também é um tipo de serviço de aplicativo. Além disso, o Tarantool possui CAPI, e a capacidade de escrever em C é uma questão de princípio para nós, porque é necessária proteção máxima para proteger contra DDoS. Nenhuma linguagem interpretada pode fornecer desempenho suficiente, ao contrário de C.
No diagrama abaixo, adicionamos um banco de dados dentro do cluster, no qual os estados para comunicação interna são armazenados.

Adicionar banco de dados
No banco de dados, armazenamos o estado na forma de um log de chamadas. Quando descobrimos como salvar informações, havia duas opções. Foi possível armazenar algum estado com atualizações e alterações constantes, mas é bastante difícil de implementar. Portanto, usamos uma abordagem diferente.
O fato é que a estrutura dos dados enviados via UDP é unificada: há tempo, algum tipo de código, três ou quatro campos de dados. Então começamos a escrever essa estrutura no espaço Tarantool e adicionamos um registro TTL lá, o que deixa claro que a estrutura está desatualizada e precisa ser excluída. Assim, um log de mensagens é acumulado no Tarantool, que é limpo com o tempo especificado. Para excluir dados antigos, inicialmente utilizamos expirationd. Posteriormente, tivemos que abandoná-lo, porque causou certos problemas, que discutiremos abaixo. Até agora, o esquema: dois bancos de dados foram adicionados à nossa estrutura.

Como já mencionamos, além de armazenar estados de cluster, também é necessário sincronizar veredictos. Veredictos sincronizamos o intercluster. Consequentemente, foi necessário adicionar uma instalação adicional do Tarantool. Seria estranho usar outra solução, porque o Tarantool já está lá e é ideal para o nosso serviço. Na nova instalação, começamos a escrever veredictos e replicá-los com outros clusters. Nesse caso, não usamos mestre / escravo, mas mestre / mestre. Agora, no Tarantool, existe apenas um mestre / mestre assíncrono, o que, em muitos casos, não é adequado, mas para nós esse modelo é ideal. Com latência mínima entre os clusters, a replicação síncrona estaria no caminho, enquanto a replicação assíncrona não causa problemas.
Os problemas
Mas tivemos muitos problemas.
O primeiro bloco de complexidade está relacionado ao UDP : não é segredo que o protocolo pode vencer e perder pacotes. Resolvemos esses problemas pelo método da avestruz, ou seja, simplesmente escondemos nossas cabeças na areia. Não obstante, danos aos pacotes e reorganização de seus locais são impossíveis conosco, uma vez que a comunicação ocorre dentro da estrutura de um único switch e não há conexões instáveis nem equipamentos de rede instáveis.
Pode haver um problema de perda de pacotes se uma máquina congelar, ocorrer uma entrada / saída em algum lugar ou um nó estiver sobrecarregado. Se tal interrupção ocorreu por um curto período de tempo, digamos, 50 milissegundos, isso é terrível, mas é resolvido pelo aumento das filas sysctl. Ou seja, pegamos o sysctl, configuramos o tamanho das filas e obtemos um buffer no qual tudo fica até o nó começar a trabalhar novamente. Se ocorrer um congelamento mais longo, o problema não será a perda de conectividade, mas parte do tráfego que vai para o nó. Até agora, simplesmente não tivemos tais casos.
Os problemas de replicação assíncrona do Tarantool eram muito mais complexos. Inicialmente, não adotamos mestre / mestre, mas um modelo mais tradicional para operar mestre / escravo. E tudo funcionou exatamente até o escravo assumir a carga principal por um longo tempo. Como resultado, a expiração funcionou e excluiu os dados no mestre, mas no escravo não. Assim, quando trocamos várias vezes de mestre para escravo e retornamos, tantos dados foram acumulados no escravo que, em algum momento, tudo quebrou. Portanto, para tolerância total a falhas, tive que mudar para a replicação mestre / mestre assíncrona.
E aqui novamente surgiram dificuldades. Em primeiro lugar, as chaves podem se cruzar entre diferentes réplicas. Suponha que, dentro do cluster, gravemos dados em um mestre; nesse ponto, a conexão foi interrompida, escrevemos tudo no segundo mestre e, depois de executarmos a replicação assíncrona, ocorreu que a mesma chave primária no espaço e a replicação se separaram.
Resolvemos esse problema simplesmente: adotamos um modelo no qual a chave primária contém necessariamente o nome do nó Tarantool no qual estamos escrevendo. Devido a isso, os conflitos deixaram de surgir, mas uma situação se tornou possível quando os dados do usuário são duplicados. Este é um caso extremamente raro, por isso simplesmente o negligenciamos. Se a duplicação ocorrer com frequência, o Tarantool possui muitos índices diferentes, para que você sempre possa fazer a desduplicação.
Outro problema diz respeito à preservação de veredictos e surge quando os dados registrados em um mestre ainda não apareceram em outro e uma solicitação já chegou ao primeiro mestre. Para ser sincero, ainda não resolvemos esse problema e estamos simplesmente atrasando o veredicto. Se isso for inaceitável, organizaremos uma espécie de pressão sobre a prontidão dos dados. É assim que lidamos com a replicação mestre / mestre e seus problemas.
Houve um bloco de problemas diretamente relacionados ao Tarantool , seus drivers e módulo de expiração. Algum tempo após o lançamento, os ataques começaram a chegar a nós todos os dias, respectivamente, o número de mensagens que salvamos no banco de dados para sincronização e armazenamento de contexto se tornou muito grande. E durante a remoção, tantos dados começaram a ser excluídos que o coletor de lixo parou de lidar. Resolvemos esse problema escrevendo em C nosso próprio módulo de expiração chamado IExpire.
No entanto, com expirationd, há mais uma dificuldade com a qual ainda não lidamos e que reside no fato de que expirationd funciona apenas em um mestre. E se o nó expirationd cair, o cluster perderá a funcionalidade crítica. Suponha que limpemos todos os dados com mais de uma hora - é claro que, se um nó repousar, digamos, cinco horas, a quantidade de dados será x5 ao normal. E se nesse momento ocorrer um grande ataque, ou seja, dois casos ruins coincidirem, o cluster cairá. Ainda não sabemos como lidar com isso.
Finalmente, houve dificuldades com o motorista do Tarantool para C. Quando interrompemos o serviço (por exemplo, devido às condições da corrida), demorou muito tempo para encontrar o motivo e depurar. Portanto, acabamos de escrever nosso driver Tarantool. Levamos cinco dias para implementar o protocolo, juntamente com testes, depuração e execução na produção, mas já tínhamos nosso próprio código para trabalhar com a rede.
Problemas externos
Lembre-se de que já temos a replicação do Tarantool pronta, já sabemos como sincronizar veredictos, mas ainda não há infraestrutura para transmitir mensagens sobre ataques ou problemas entre clusters.
Tivemos muitos pensamentos diferentes sobre a infraestrutura, incluindo o pensamento de escrever nosso próprio serviço TCP. Mas ainda há um módulo Tarantool Queue da equipe Tarantool. Além disso, já tínhamos Tarantool com replicação entre clusters, “buracos” eram distorcidos, ou seja, não havia necessidade de ir aos administradores e pedir para abrir portas ou direcionar tráfego. Mais uma vez, a integração na filtragem de software estava pronta.
Houve uma dificuldade com o nó host. Suponha que haja n nós independentes dentro de um cluster e você precise escolher aquele que irá interagir com a fila de gravação. Porque, caso contrário, 16 mensagens serão enviadas ou 16 vezes a mesma mensagem será subtraída da fila. Resolvemos esse problema simplesmente: registramos um nó responsável no espaço Tarantool e, se o nó queimar, simplesmente alteramos o espaço se não esquecermos. Mas se esquecermos, esse é um problema que também queremos resolver no futuro.
Abaixo está um diagrama já detalhado de um cluster com uma interface de interação.

O que eu quero melhorar e adicionar
Em primeiro lugar, queremos publicar no IExpire de código aberto. Parece-nos que este é um módulo útil, pois permite que você faça tudo igual à expiração, mas com quase zero de sobrecarga. Lá, você deve adicionar um índice de classificação para remover apenas a tupla mais antiga. Até o momento, ainda não fizemos isso, uma vez que a principal operação do Tarantool para nós é "escrita", e um índice extra causará carga extra devido ao seu suporte. Também queremos reescrever a maioria dos métodos no CAPI para evitar dobrar o banco de dados.
A questão permanece com a escolha de um mestre lógico, mas parece que esse problema é completamente impossível de resolver. Ou seja, se o nó com expirationd cair, resta apenas selecionar manualmente outro nó e executar expirationd nele. É improvável que isso ocorra automaticamente, porque a replicação é assíncrona. Embora provavelmente possamos consultar sobre isso com a equipe Tarantool.
No caso de um crescimento exponencial de clusters, também precisaremos pedir ajuda à equipe Tarantool. O fato é que a replicação todos para todos é usada para a fila do Tarantool e para o salvamento de veredictos entre clusters. Isso funciona bem, embora existam três clusters, por exemplo, mas quando houver 100 deles, o número de conexões que precisam ser monitoradas será incrivelmente grande e algo será interrompido constantemente. Em segundo lugar, não é verdade que o Tarantool possa suportar essa carga.
Conclusões
As primeiras conclusões dizem respeito ao UDP multicast e ao Tarantool.
O multicast não precisa ter medo disso; seu uso dentro do cluster é bom, correto e rápido. Existem muitos casos em que há uma sincronização constante de estados e, após 50 milissegundos, não importa o que aconteceu antes. E, neste caso, provavelmente, a perda de um estado não será um problema. Portanto, o uso de multicast UDP é justificado, porque você não limita o desempenho e obtém a taxa ideal de pacotes.O segundo ponto é Tarantool. Se você tem um serviço em movimento, php e assim por diante, provavelmente o Tarantool é aplicável como está. Mas se você tiver cargas pesadas, precisará de um arquivo. Mas, para ser sincero, nesse caso, o arquivo é necessário para tudo: tanto para Oracle quanto para PostgeSQL.Obviamente, existe uma opinião de que você não precisa reinventar a roda e, se tiver uma equipe pequena, deve usar uma solução pronta: Redis para sincronização, padrão, python e assim por diante. Isto não é verdade. Se você tem certeza de que precisa de uma nova solução, se trabalhou com código aberto, descobriu que nada combina com você ou sabe de antemão que não há motivo para tentar, considerar útil sua decisão. Outra conversa que é importante parar a tempo. Ou seja, você não precisa escrever seu Tarantool, não precisa implementar suas mensagens e, se você só precisa de um corretor, já tome o Redis e ficará feliz.