Capítulo 19 – Balanceamento de carga no frontend

Balanceamento de carga no frontend

Escrito por Piotr Lewandowski
Editado por Sarah Chavis

Atendemos muitos milhões de solicitações a cada segundo e, como você já deve ter adivinhado, usamos mais de um único computador para atender a essa demanda. Mas mesmo que tivéssemos um supercomputador que fosse de alguma forma capaz de lidar com todas essas solicitações (imagine a conectividade de rede que tal configuração exigiria!), ainda não empregaríamos uma estratégia que dependesse de um único ponto de falha; quando você está lidando com sistemas de grande escala, colocar todos os ovos na mesma cesta é uma receita para o desastre.

Este capítulo se concentra no balanceamento de carga de alto nível — como equilibramos o tráfego de usuários entre datacenters. O capítulo seguinte amplia a perspectiva para explorar como implementamos o balanceamento de carga dentro de um datacenter.

Poder não é a resposta

Para fins de argumentação, vamos supor que tenhamos uma máquina incrivelmente poderosa e uma rede que nunca falha. Essa configuração seria suficiente para atender às necessidades do Google? Não. Mesmo essa configuração ainda seria limitada pelas restrições físicas associadas à nossa infraestrutura de rede. Por exemplo, a velocidade da luz é um fator limitante nas velocidades de comunicação do cabo de fibra óptica, o que cria um limite superior na rapidez com que podemos fornecer dados com base na distância que ele precisa percorrer. Mesmo em um mundo ideal, contar com uma infraestrutura com um único ponto de falha é uma má ideia.

Na realidade, o Google tem milhares de máquinas e incontáveis usuários, muitos dos quais emitem várias solicitações ao mesmo tempo. O balanceamento de carga de tráfego é como decidimos qual das muitas máquinas em nossos datacenters atenderá a uma solicitação específica. Idealmente, o tráfego é distribuído em vários links de rede, datacenters e máquinas de uma forma “ótima”. Mas o que significa “ótimo” neste contexto? Na verdade, não há uma resposta única, porque a solução ideal depende muito de uma variedade de fatores:

  • O nível hierárquico em que avaliamos o problema (global versus local)
  • O nível técnico em que avaliamos o problema (hardware versus software)
  • A natureza do tráfego com o qual estamos lidando

Vamos começar analisando dois cenários de tráfego comuns: uma solicitação de pesquisa básica e uma solicitação de upload de vídeo. Os usuários desejam obter os resultados da consulta rapidamente, portanto, a variável mais importante para a solicitação de pesquisa é a latência. Por outro lado, os usuários esperam que os uploads de vídeo demorem um tempo não negligenciável, mas também desejam que essas solicitações sejam bem-sucedidas na primeira vez, portanto, a variável mais importante para o upload de vídeo é a taxa de transferência. As diferentes necessidades das duas solicitações desempenham um papel em como determinamos a distribuição ideal para cada solicitação em nível global:

  • A solicitação de pesquisa é enviada para o datacenter disponível mais próximo — conforme medido em tempo de ida e volta (RTT) — porque queremos minimizar a latência na solicitação.
  • O fluxo de upload de vídeo é roteado por um caminho diferente – talvez para um link que está subutilizado no momento – para maximizar a taxa de transferência em detrimento da latência.

Mas em nível local, dentro de um determinado datacenter, geralmente assumimos que todas as máquinas dentro do prédio estão igualmente distantes do usuário e conectadas à mesma rede. Portanto, a distribuição ideal da carga se concentra na utilização ideal dos recursos e na proteção de um único servidor contra sobrecarga.

Claro, este exemplo apresenta uma imagem muito simplificada. Na realidade, muitas outras considerações levam em conta a distribuição de carga ótima: algumas solicitações podem ser direcionadas para um datacenter um pouco mais distante para manter os caches aquecidos ou o tráfego não interativo pode ser roteado para uma região completamente diferente para evitar o congestionamento da rede. O balanceamento de carga, especialmente para sistemas grandes, é tudo menos simples e estático. No Google, abordamos o problema por meio do balanceamento de carga em vários níveis, dois dos quais são descritos nas seções a seguir. Para apresentar uma discussão concreta, consideraremos as solicitações HTTP enviadas por TCP. O balanceamento de carga de serviços sem estado (como DNS sobre UDP) difere um pouco, mas a maioria dos mecanismos descritos aqui também deve ser aplicável a serviços sem estado.

Balanceamento de carga usando DNS

Antes que um cliente possa enviar uma solicitação HTTP, muitas vezes ele precisa procurar um endereço IP usando o DNS. Isso oferece a oportunidade perfeita para apresentar nossa primeira camada de balanceamento de carga: o balanceamento de carga usando DNS. A solução mais simples é retornar vários registros A ou AAAA na resposta do DNS e permitir que o cliente escolha um endereço IP arbitrariamente. Embora conceitualmente simples e trivial de implementar, essa solução apresenta vários desafios.

O primeiro problema é que ele fornece muito pouco controle sobre o comportamento do cliente: os registros são selecionados aleatoriamente e cada um atrairá uma quantidade aproximadamente igual de tráfego. Podemos mitigar esse problema? Em teoria, poderíamos usar registros SRV para especificar pesos e prioridades de registro, mas os registros SRV ainda não foram adotados para HTTP.

Outro problema potencial decorre do fato de que normalmente o cliente não consegue determinar o endereço mais próximo. Podemos mitigar esse cenário usando um endereço anycast para nameservers autorizados e aproveitar o fato de que as consultas DNS fluirão para o endereço mais próximo. Em sua resposta, o servidor pode retornar endereços roteados para o datacenter mais próximo. Uma melhoria adicional cria um mapa de todas as redes e suas localizações físicas aproximadas e fornece respostas DNS com base nesse mapeamento. No entanto, essa solução tem o custo de ter uma implementação de servidor DNS muito mais complexa e de manter um pipeline que irá guardar o mapeamento de local atualizado.

É claro que nenhuma dessas soluções é trivial, devido a uma característica fundamental do DNS: os usuários finais raramente conversam diretamente com nameservers autorizados. Em vez disso, um servidor DNS recursivo geralmente fica em algum lugar entre os usuários finais e os nameservers. Esse servidor faz proxy de queries entre um usuário e um servidor e geralmente fornece uma camada de cache. O DNS intermediário tem três implicações muito importantes no gerenciamento de tráfego:

  • Resolução recursiva de endereços IP
  • Caminhos de resposta não determinísticos
  • Complicações adicionais de cache

A resolução recursiva de endereços IP é problemática, pois o endereço IP visto pelo nameserver autoritativo não pertence a um usuário; em vez disso, é o resolvedor recursivo. Esta é uma limitação séria, porque só permite a otimização da resposta para a distância mais curta entre o resolvedor e o nameserver. Uma possível solução é usar a extensão EDNS0 proposta por C. Contavalli, W. van der Gaast, D. Lawrence, e W. Kumari em “Client Subnet in DNS Queries, que inclui informações sobre a sub-rede do cliente na consulta DNS enviada por um resolvedor recursivo. Dessa forma, um nameserver autoritativo retorna uma resposta que é ótima sob a perspectiva do usuário, em vez da perspectiva do resolvedor. Embora este ainda não seja o padrão oficial, suas vantagens óbvias levaram os maiores resolvedores de DNS (como o OpenDNS e o Google) a já suportá-lo.

Não apenas é difícil encontrar o endereço IP ideal para retornar ao nameserver dada a solicitação de um determinado usuário, como esse nameserver pode ser responsável por atender milhares ou milhões de usuários, em regiões que variam de um único escritório a um continente inteiro. Por exemplo, um grande ISP nacional pode executar nameservers para toda a sua rede a partir de um datacenter, mas ter interconexões de rede em cada área metropolitana. Os nameservers do ISP então retornariam uma resposta com o endereço IP mais adequado para seus datacenters, apesar de haver melhores caminhos de rede para todos os usuários!

Por fim, os resolvedores recursivos geralmente armazenam em cache as respostas e encaminham essas respostas dentro dos limites indicados pelo campo TTL (tempo de vida útil) no registro DNS. O resultado final é que é difícil estimar o impacto de uma determinada resposta: uma única resposta autorizada pode atingir um único usuário ou vários milhares de usuários. Resolvemos este problema de duas maneiras:

  • Analisamos as alterações de tráfego e atualizamos continuamente nossa lista de resolvedores de DNS conhecidos com o tamanho aproximado da base de usuários por trás de um determinado resolvedor, o que nos permite rastrear o impacto potencial de qualquer determinado resolvedor.
  • Estimamos a distribuição geográfica dos usuários por trás de cada resolvedor rastreado para aumentar a chance de direcionar esses usuários ao melhor local.

Estimar a distribuição geográfica é particularmente complicado se a base de usuários estiver distribuída em grandes regiões. Nesses casos, fazemos compensações para selecionar a melhor localização e otimizar a experiência para a maioria dos usuários.

Mas o que “melhor localização” realmente significa no contexto do balanceamento de carga do DNS? A resposta mais óbvia é o local mais próximo do usuário. No entanto (como se determinar a localização dos usuários não fosse difícil por si só), existem critérios adicionais. O balanceador de carga DNS precisa garantir que o datacenter selecionado tenha capacidade suficiente para atender a solicitações de usuários que provavelmente receberão sua resposta. Ele também precisa saber que o datacenter selecionado e sua conectividade de rede estão em boas condições, porque direcionar as solicitações do usuário para um datacenter que está com problemas de energia ou de rede não é o ideal. Felizmente, podemos integrar o servidor DNS autoritativo com nossos sistemas de controle global que rastreiam o tráfego, a capacidade e o estado da nossa infraestrutura.

A terceira implicação do DNS intermediário está relacionada ao armazenamento em cache. Dado que os nameservers autoritativos não podem liberar os caches dos resolvedores, os registros DNS precisam de um TTL relativamente baixo. Isso efetivamente define um limite inferior para a rapidez com que as alterações de DNS podem ser propagadas para os usuários. (Nem todos os resolvedores de DNS respeitam o valor TTL definido por nameservers autoritativos). Infelizmente, pouco podemos fazer além de manter isso em mente enquanto tomamos decisões de balanceamento de carga.

Apesar de todos esses problemas, o DNS ainda é a maneira mais simples e eficaz de equilibrar a carga antes mesmo de a conexão do usuário ser iniciada. Por outro lado, deve ficar claro que o balanceamento de carga com DNS por si só não é suficiente. Tenha em mente que todas as respostas DNS servidas devem caber dentro do limite de 512 bytes definido pela RFC 1035. (Caso contrário, os usuários teriam que estabelecer uma conexão TCP apenas para obter uma lista de endereços IP). Esse limite define um limite superior para o número de endereços que podemos espremer em uma única resposta de DNS, e esse número é quase certamente menor do que nosso número de servidores.

Para realmente resolver o problema de balanceamento de carga de frontend, esse nível inicial de balanceamento de carga DNS deve ser seguido por um nível que aproveite os endereços IP virtuais.

Balanceamento de carga no endereço IP virtual

Os endereços IP virtuais (VIPs) não são atribuídos a nenhuma interface de rede específica. Em vez disso, eles geralmente são compartilhados em vários dispositivos. No entanto, do ponto de vista do usuário, o VIP continua sendo um endereço IP único e regular. Em teoria, essa prática permite ocultar detalhes de implementação (como o número de máquinas por trás de um determinado VIP) e facilita a manutenção, pois podemos agendar atualizações ou adicionar mais máquinas ao pool sem que o usuário saiba.

Na prática, a parte mais importante da implementação do VIP é um dispositivo chamado balanceador de carga de rede. O balanceador recebe os pacotes e os encaminha para uma das máquinas atrás do VIP. Esses backends podem processar ainda mais a solicitação.

Existem várias abordagens possíveis que o balanceador pode adotar para decidir qual backend deve receber a solicitação. A primeira (e talvez a mais intuitiva) abordagem é sempre preferir o backend menos carregado. Em teoria, essa abordagem deve resultar na melhor experiência do usuário final, pois as solicitações sempre são roteadas para a máquina menos ocupada. Infelizmente, essa lógica é quebrada rapidamente no caso de protocolos com estado, que devem usar o mesmo backend durante uma solicitação. Esse requisito significa que o balanceador deve acompanhar todas as conexões enviadas através dele para garantir que todos os pacotes subsequentes sejam enviados ao backend correto. A alternativa é usar algumas partes de um pacote para criar um ID de conexão (possivelmente usando uma função hash e algumas informações do pacote) e usar o ID de conexão para selecionar um backend. Por exemplo, o ID da conexão pode ser expresso como:


id(pacote) mod N

onde id é uma função que recebe o pacote como entrada e produz um ID de conexão e N é o número de backends configurados.

Isso evita o estado de armazenamento e todos os pacotes pertencentes a uma única conexão são sempre encaminhados para o mesmo backend. Sucesso? Ainda não. O que acontece se um backend falhar e precisar ser removido da lista de backend? De repente, N torna-se N-1 e, em seguida, id(packet) mod N torna-se id(packet) mod N-1. Quase todos os pacotes de repente são mapeados para um backend diferente! Se os backends não compartilharem nenhum estado entre si, esse remapeamento forçará uma redefinição de quase todas as conexões existentes. Esse cenário definitivamente não é a melhor experiência do usuário, mesmo que esses eventos sejam pouco frequentes.

Felizmente, existe uma solução alternativa que não requer manter o estado de todas as conexões na memória, mas não forçará todas as conexões a serem redefinidas quando uma única máquina ficar inativa: hash consistente. Proposto em 1997, o hash consistente descreve uma maneira de fornecer um algoritmo de mapeamento que permanece relativamente estável mesmo quando novos backends são adicionados ou removidos da lista. Essa abordagem minimiza a interrupção das conexões existentes quando o pool de backends é alterado. Como resultado, geralmente podemos usar o rastreamento de conexão simples, mas retornar ao hash consistente quando o sistema estiver sob pressão (por exemplo, durante um ataque contínuo de negação de serviço).

Voltando à questão maior: como exatamente um balanceador de carga de rede deve encaminhar pacotes para um backend VIP selecionado? Uma solução é realizar uma Network Address Translation. No entanto, isso requer manter uma entrada de cada conexão na tabela de rastreamento, o que impede ter um mecanismo de fallback completamente sem estado.

Outra solução é modificar as informações da camada de link de dados (camada 2 do modelo de rede OSI). Ao alterar o endereço MAC de destino de um pacote encaminhado, o balanceador pode deixar todas as informações nas camadas superiores intactas, de modo que o backend receba os endereços IP de origem e destino originais. O backend pode então enviar uma resposta diretamente ao remetente original — uma técnica conhecida como Direct Server Response (DSR). Se as solicitações do usuário forem pequenas e as respostas forem grandes (por exemplo, a maioria das solicitações HTTP), o DSR oferece uma economia enorme, pois apenas uma pequena fração do tráfego precisa passar pelo balanceador de carga. Melhor ainda, o DSR não exige manter o estado no dispositivo do balanceador de carga. Infelizmente, o uso da camada 2 para balanceamento de carga interno apresenta sérias desvantagens quando implantado em escala: todas as máquinas (ou seja, todos os balanceadores de carga e todos os seus backends) devem ser capazes de alcançar umas às outras na camada de link de dados. Isso não é um problema se essa conectividade puder ser suportada pela rede e o número de máquinas não crescer excessivamente, pois todas as máquinas precisam residir em um único domínio de broadcast. Como você pode imaginar, o Google superou essa solução há algum tempo e teve que encontrar uma abordagem alternativa.

Nossa solução de balanceamento de carga VIP atual usa encapsulamento de pacotes. Um balanceador de carga de rede coloca o pacote encaminhado em outro pacote IP com Generic Routing Encapsulation (GRE) e usa o endereço de um backend como destino. Um backend que recebe o pacote retira a camada IP+GRE externa e processa o pacote IP interno como se fosse entregue diretamente à sua interface de rede. O balanceador de carga de rede e o backend não precisam mais existir no mesmo domínio de transmissão; eles podem até estar em continentes separados, desde que exista uma rota entre os dois.

O encapsulamento de pacotes é um mecanismo poderoso que oferece grande flexibilidade na maneira como nossas redes são projetadas e evoluem. Infelizmente, o encapsulamento também tem um preço: tamanho inflado do pacote. O encapsulamento introduz sobrecarga (24 bytes no caso de IPv4+GRE, para ser preciso), o que pode fazer com que o pacote exceda o tamanho da Unidade Máxima de Transmissão (MTU) disponível e exija fragmentação.

Quando o pacote chega ao datacenter, a fragmentação pode ser evitada usando uma MTU maior dentro do datacenter; no entanto, essa abordagem requer uma rede que ofereça suporte a grandes unidades de dados de protocolo. Tal como acontece com muitas coisas em escala, o balanceamento de carga parece simples na superfície – balanceamento de carga antecipado e balanceamento de carga com frequência – mas a dificuldade está nos detalhes, tanto para balanceamento de carga de frontend quanto para manipulação de pacotes quando eles chegam ao datacenter.

Fonte: Google SRE Book

Rolar para cima