Arquitetura de um balanceador de carga de rede em Yandex.Cloud

Arquitetura de um balanceador de carga de rede em Yandex.Cloud
Olá, sou Sergey Elantsev, desenvolvo balanceador de carga de rede em Yandex.Cloud. Anteriormente, liderei o desenvolvimento do balanceador L7 para o portal Yandex - os colegas brincam que não importa o que eu faça, ele acaba sendo um balanceador. Direi aos leitores do Habr como gerenciar a carga em uma plataforma em nuvem, o que consideramos a ferramenta ideal para atingir esse objetivo e como estamos caminhando para a construção dessa ferramenta.

Primeiro, vamos apresentar alguns termos:

  • VIP (IP Virtual) - endereço IP do balanceador
  • Servidor, back-end, instância – uma máquina virtual executando um aplicativo
  • RIP (IP Real) - endereço IP do servidor
  • Healthcheck - verificando a prontidão do servidor
  • Zona de Disponibilidade, AZ – infraestrutura isolada em um data center
  • Região - uma união de diferentes AZs

Os balanceadores de carga resolvem três tarefas principais: realizam o próprio balanceamento, melhoram a tolerância a falhas do serviço e simplificam seu escalonamento. A tolerância a falhas é garantida por meio do gerenciamento automático de tráfego: o balanceador monitora o estado da aplicação e exclui do balanceamento as instâncias que não passam na verificação de atividade. O escalonamento é garantido pela distribuição uniforme da carga entre as instâncias, bem como pela atualização dinâmica da lista de instâncias. Se o balanceamento não for suficientemente uniforme, algumas instâncias receberão uma carga que excede seu limite de capacidade e o serviço se tornará menos confiável.

Um balanceador de carga geralmente é classificado pela camada de protocolo do modelo OSI no qual ele é executado. O Cloud Balancer opera no nível TCP, que corresponde à quarta camada, L4.

Vamos passar para uma visão geral da arquitetura do Cloud Balancer. Aumentaremos gradualmente o nível de detalhe. Dividimos os componentes do balanceador em três classes. A classe do plano de configuração é responsável pela interação do usuário e armazena o estado alvo do sistema. O plano de controle armazena o estado atual do sistema e gerencia os sistemas da classe do plano de dados, que são diretamente responsáveis ​​por entregar o tráfego dos clientes às suas instâncias.

Plano de dados

O tráfego acaba em dispositivos caros chamados roteadores de fronteira. Para aumentar a tolerância a falhas, vários desses dispositivos operam simultaneamente em um data center. Em seguida, o tráfego vai para balanceadores, que anunciam endereços IP anycast para todas as AZs via BGP para os clientes. 

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

O tráfego é transmitido via ECMP - esta é uma estratégia de roteamento segundo a qual pode haver várias rotas igualmente boas para o destino (no nosso caso, o destino será o endereço IP de destino) e os pacotes podem ser enviados por qualquer uma delas. Apoiamos também o trabalho em diversas zonas de disponibilidade de acordo com o seguinte esquema: publicitamos um endereço em cada zona, o tráfego vai para a mais próxima e não ultrapassa os seus limites. Posteriormente neste post veremos com mais detalhes o que acontece com o tráfego.

Plano de configuração

 
O componente chave do plano de configuração é a API, por meio da qual são realizadas operações básicas com balanceadores: criação, exclusão, alteração da composição de instâncias, obtenção de resultados de verificações de saúde, etc. Por um lado, esta é uma API REST, e por outro por outro lado, nós na nuvem usamos muito frequentemente o framework gRPC, então “traduzimos” REST para gRPC e depois usamos apenas gRPC. Qualquer solicitação leva à criação de uma série de tarefas idempotentes assíncronas que são executadas em um pool comum de trabalhadores Yandex.Cloud. As tarefas são escritas de forma que possam ser suspensas a qualquer momento e depois reiniciadas. Isso garante escalabilidade, repetibilidade e registro de operações.

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

Como resultado, a tarefa da API fará uma solicitação ao controlador de serviço do balanceador, que está escrito em Go. Ele pode adicionar e remover balanceadores, alterar a composição de back-ends e configurações. 

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

O serviço armazena seu estado no banco de dados Yandex, um banco de dados gerenciado distribuído que você poderá usar em breve. No Yandex.Cloud, como já contado, aplica-se o conceito de comida para cães: se nós próprios utilizamos os nossos serviços, os nossos clientes também ficarão felizes em utilizá-los. O banco de dados Yandex é um exemplo da implementação de tal conceito. Armazenamos todos os nossos dados no YDB e não precisamos pensar em manter e dimensionar o banco de dados: esses problemas estão resolvidos para nós, usamos o banco de dados como um serviço.

Vamos voltar ao controlador balanceador. Sua tarefa é salvar informações sobre o balanceador e enviar uma tarefa para verificar a prontidão da máquina virtual ao controlador de verificação de integridade.

Controlador de verificação de integridade

Ele recebe solicitações para alterar regras de verificação, salva-as no YDB, distribui tarefas entre nós de verificação de integridade e agrega os resultados, que são então salvos no banco de dados e enviados ao controlador do balanceador de carga. Ele, por sua vez, envia uma solicitação para alterar a composição do cluster no plano de dados para o nó do balanceador de carga, que discutirei a seguir.

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

Vamos falar mais sobre verificações de saúde. Eles podem ser divididos em várias classes. As auditorias têm diferentes critérios de sucesso. As verificações de TCP precisam estabelecer uma conexão com êxito dentro de um período fixo de tempo. As verificações HTTP exigem uma conexão bem-sucedida e uma resposta com um código de status 200.

Além disso, as verificações diferem na classe de ação - elas são ativas e passivas. As verificações passivas simplesmente monitoram o que está acontecendo com o tráfego sem realizar nenhuma ação especial. Isso não funciona muito bem em L4 porque depende da lógica dos protocolos de nível superior: em L4 não há informações sobre quanto tempo a operação demorou ou se a conclusão da conexão foi boa ou ruim. As verificações ativas exigem que o balanceador envie solicitações para cada instância do servidor.

A maioria dos balanceadores de carga realizam verificações de atividade por conta própria. Na Cloud, decidimos separar essas partes do sistema para aumentar a escalabilidade. Esta abordagem nos permitirá aumentar o número de balanceadores enquanto mantém o número de solicitações de verificação de saúde para o serviço. As verificações são realizadas por nós de verificação de integridade separados, nos quais os alvos de verificação são fragmentados e replicados. Você não pode realizar verificações de um host, pois ele pode falhar. Então não obteremos o estado das instâncias que ele verificou. Realizamos verificações em qualquer uma das instâncias de pelo menos três nós de verificação de integridade. Fragmentamos os propósitos das verificações entre nós usando algoritmos de hash consistentes.

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

Separar o balanceamento e a verificação de integridade pode causar problemas. Se o nó de verificação de integridade fizer solicitações à instância, ignorando o balanceador (que não está atendendo ao tráfego no momento), surge uma situação estranha: o recurso parece estar ativo, mas o tráfego não o alcançará. Resolvemos esse problema desta forma: temos a garantia de iniciar o tráfego de verificação de integridade por meio de balanceadores. Em outras palavras, o esquema de movimentação de pacotes com tráfego de clientes e de healthchecks difere minimamente: em ambos os casos, os pacotes chegarão aos balanceadores, que os entregarão aos recursos de destino.

A diferença é que os clientes fazem solicitações ao VIP, enquanto os healthchecks fazem solicitações a cada RIP individual. Surge aqui um problema interessante: damos aos nossos usuários a oportunidade de criar recursos em redes IP cinzas. Vamos imaginar que existem dois proprietários de nuvens diferentes que ocultaram seus serviços atrás de balanceadores. Cada um deles possui recursos na sub-rede 10.0.0.1/24, com os mesmos endereços. Você precisa ser capaz de distingui-los de alguma forma e aqui precisa mergulhar na estrutura da rede virtual Yandex.Cloud. É melhor descobrir mais detalhes em vídeo de about:cloud event, é importante para nós agora que a rede seja multicamadas e tenha túneis que podem ser distinguidos pelo ID da sub-rede.

Os nós de verificação de integridade contatam balanceadores usando os chamados endereços quase IPv6. Um quase-endereço é um endereço IPv6 com um endereço IPv4 e um ID de sub-rede do usuário incorporado nele. O tráfego chega ao balanceador, que extrai dele o endereço do recurso IPv4, substitui o IPv6 por IPv4 e envia o pacote para a rede do usuário.

O tráfego reverso segue o mesmo caminho: o balanceador vê que o destino é uma rede cinza dos verificadores de integridade e converte IPv4 em IPv6.

VPP – o coração do plano de dados

O balanceador é implementado usando a tecnologia Vector Packet Processing (VPP), uma estrutura da Cisco para processamento em lote de tráfego de rede. No nosso caso, a estrutura funciona sobre a biblioteca de gerenciamento de dispositivos de rede do espaço do usuário - Data Plane Development Kit (DPDK). Isso garante alto desempenho de processamento de pacotes: ocorrem muito menos interrupções no kernel e não há trocas de contexto entre o espaço do kernel e o espaço do usuário. 

O VPP vai ainda mais longe e extrai ainda mais desempenho do sistema combinando pacotes em lotes. Os ganhos de desempenho vêm do uso agressivo de caches em processadores modernos. São utilizados caches de dados (os pacotes são processados ​​​​em “vetores”, os dados estão próximos uns dos outros) e caches de instruções: no VPP, o processamento de pacotes segue um gráfico, cujos nós contêm funções que executam a mesma tarefa.

Por exemplo, o processamento de pacotes IP no VPP ocorre na seguinte ordem: primeiro, os cabeçalhos dos pacotes são analisados ​​​​no nó de análise e, em seguida, são enviados ao nó, que encaminha os pacotes posteriormente de acordo com as tabelas de roteamento.

Um pouco hardcore. Os autores do VPP não toleram compromissos no uso de caches de processador, portanto o código típico para processamento de um vetor de pacotes contém vetorização manual: há um loop de processamento no qual uma situação como “temos quatro pacotes na fila” é processada, então o mesmo para dois, então - para um. As instruções de pré-busca são frequentemente usadas para carregar dados em caches para acelerar o acesso a eles em iterações subsequentes.

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);
}

Assim, os Healthchecks se comunicam por IPv6 com o VPP, que os transforma em IPv4. Isso é feito por um nó no gráfico, que chamamos de NAT algorítmico. Para tráfego reverso (e conversão de IPv6 para IPv4) existe o mesmo nó NAT algorítmico.

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

O tráfego direto dos clientes do balanceador passa pelos nós do gráfico, que realizam o próprio balanceamento. 

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

O primeiro nó são sessões fixas. Ele armazena o hash de 5 tupla para sessões estabelecidas. 5 tuplas incluem o endereço e a porta do cliente de onde as informações são transmitidas, o endereço e as portas dos recursos disponíveis para recebimento de tráfego, bem como o protocolo de rede. 

O hash de 5 tuplas nos ajuda a realizar menos cálculos no nó de hashing consistente subsequente, bem como a lidar melhor com as alterações na lista de recursos por trás do balanceador. Quando um pacote para o qual não há sessão chega ao balanceador, ele é enviado ao nó de hashing consistente. É aqui que ocorre o balanceamento usando hashing consistente: selecionamos um recurso da lista de recursos “ativos” disponíveis. Em seguida, os pacotes são enviados para o nó NAT, que na verdade substitui o endereço de destino e recalcula as somas de verificação. Como você pode ver, seguimos as regras do VPP - like to like, agrupando cálculos semelhantes para aumentar a eficiência dos caches do processador.

Hash consistente

Por que o escolhemos e o que é? Primeiro, vamos considerar a tarefa anterior - selecionar um recurso da lista. 

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

Com hash inconsistente, o hash do pacote recebido é calculado e um recurso é selecionado da lista dividindo esse hash pelo número de recursos. Desde que a lista permaneça inalterada, este esquema funciona bem: sempre enviamos pacotes com as mesmas 5 tuplas para a mesma instância. Se, por exemplo, algum recurso parar de responder às verificações de saúde, então, para uma parte significativa dos hashes, a escolha mudará. As conexões TCP do cliente serão interrompidas: um pacote que chegou anteriormente à instância A pode começar a chegar à instância B, que não está familiarizada com a sessão deste pacote.

O hash consistente resolve o problema descrito. A maneira mais fácil de explicar esse conceito é: imagine que você tem um anel no qual distribui recursos por hash (por exemplo, por IP:porta). Selecionar um recurso é girar a roda em um ângulo determinado pelo hash do pacote.

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

Isto minimiza a redistribuição do tráfego quando a composição dos recursos muda. A exclusão de um recurso afetará apenas a parte do anel de hash consistente em que o recurso estava localizado. Adicionar um recurso também altera a distribuição, mas temos um nó de sessões fixas, que nos permite não mudar sessões já estabelecidas para novos recursos.

Vimos o que acontece com o tráfego direto entre o balanceador e os recursos. Agora vamos dar uma olhada no tráfego de retorno. Segue o mesmo padrão do tráfego de verificação - através de NAT algorítmico, ou seja, através de NAT reverso 44 para tráfego de cliente e através de NAT 46 para tráfego de verificações de saúde. Aderimos ao nosso próprio esquema: unificamos o tráfego de verificações de saúde e o tráfego de usuários reais.

Nó do balanceador de carga e componentes montados

A composição de balanceadores e recursos no VPP é relatada pelo serviço local - loadbalancer-node. Ele assina o fluxo de eventos do controlador do balanceador de carga e é capaz de traçar a diferença entre o estado atual do VPP e o estado de destino recebido do controlador. Obtemos um sistema fechado: os eventos da API chegam ao controlador do balanceador, que atribui tarefas ao controlador de verificação de saúde para verificar a “vivacidade” dos recursos. Este, por sua vez, atribui tarefas ao nó de verificação de saúde e agrega os resultados, após o que os envia de volta ao controlador do balanceador. O nó Loadbalancer assina eventos do controlador e altera o estado do VPP. Nesse sistema, cada serviço sabe apenas o que é necessário sobre os serviços vizinhos. O número de conexões é limitado e temos capacidade de operar e escalar diferentes segmentos de forma independente.

Arquitetura de um balanceador de carga de rede em Yandex.Cloud

Quais problemas foram evitados?

Todos os nossos serviços no plano de controle são escritos em Go e possuem boas características de escalabilidade e confiabilidade. Go possui muitas bibliotecas de código aberto para construção de sistemas distribuídos. Usamos ativamente o GRPC, todos os componentes contêm uma implementação de código aberto de descoberta de serviços - nossos serviços monitoram o desempenho uns dos outros, podem alterar sua composição dinamicamente e vinculamos isso ao balanceamento do GRPC. Para métricas, também usamos uma solução de código aberto. No plano de dados, obtivemos um desempenho decente e uma grande reserva de recursos: acabou sendo muito difícil montar um estande no qual pudéssemos contar com o desempenho de um VPP, ao invés de uma placa de rede de ferro.

Problemas e soluções

O que não funcionou tão bem? Go tem gerenciamento automático de memória, mas ainda ocorrem vazamentos de memória. A maneira mais fácil de lidar com eles é executar goroutines e lembrar de encerrá-los. Conclusão: observe o consumo de memória dos seus programas Go. Muitas vezes, um bom indicador é o número de goroutines. Há uma vantagem nesta história: em Go é fácil obter dados de tempo de execução - consumo de memória, número de goroutines em execução e muitos outros parâmetros.

Além disso, Go pode não ser a melhor escolha para testes funcionais. Eles são bastante detalhados e a abordagem padrão de “executar tudo em CI em lote” não é muito adequada para eles. O fato é que os testes funcionais exigem mais recursos e causam timeouts reais. Por causa disso, os testes podem falhar porque a CPU está ocupada com testes de unidade. Conclusão: Se possível, realize testes “pesados” separadamente dos testes unitários. 

A arquitetura de eventos de microsserviços é mais complexa que um monólito: coletar logs em dezenas de máquinas diferentes não é muito conveniente. Conclusão: se você faz microsserviços, pense imediatamente em rastrear.

Nossos planos

Lançaremos um balanceador interno, um balanceador IPv6, adicionaremos suporte para scripts Kubernetes, continuaremos a fragmentar nossos serviços (atualmente apenas healthcheck-node e healthcheck-ctrl são fragmentados), adicionaremos novas verificações de integridade e também implementaremos agregação inteligente de verificações. Estamos considerando a possibilidade de tornar nossos serviços ainda mais independentes - para que eles não se comuniquem diretamente entre si, mas por meio de uma fila de mensagens. Um serviço compatível com SQS apareceu recentemente na nuvem Fila de mensagens Yandex.

Recentemente, ocorreu o lançamento público do Yandex Load Balancer. Explorar documentação ao serviço, gerencie balanceadores da maneira mais conveniente para você e aumente a tolerância a falhas de seus projetos!

Fonte: habr.com

Adicionar um comentário