Os balanceadores de carga desempenham um papel fundamental na arquitetura da web. Eles permitem que você distribua a carga por vários back-end, melhorando a escalabilidade. E como temos vários back-end configurados, o serviço se torna altamente disponível, pois, no caso de uma falha em um servidor, o balanceador pode escolher outro servidor em funcionamento.
Tendo tocado com balanceadores profissionais como o NGINX, tentei criar um balanceador simples por diversão. Eu escrevi no Go, é uma linguagem moderna que suporta paralelismo total. A biblioteca padrão no Go possui muitos recursos e permite gravar aplicativos de alto desempenho com menos código. Além disso, para facilitar a distribuição, ele gera um único binário estaticamente vinculado.
Como nosso balanceador funciona
Diferentes algoritmos são usados para distribuir a carga entre os back-ends. Por exemplo:
- Round Robin - a carga é distribuída uniformemente, levando em consideração o mesmo poder de computação dos servidores.
- Robin redondo ponderado - Dependendo da capacidade de processamento, os servidores podem receber pesos diferentes.
- Menos conexões - a carga é distribuída entre servidores com o menor número de conexões ativas.
Em nosso balanceador, implementamos o algoritmo mais simples - Round Robin.
Seleção na Round Robin
O algoritmo Round Robin é simples. Dá a todos os artistas a mesma oportunidade de concluir tarefas.
Selecione servidores no Round Robin para lidar com solicitações recebidas.Conforme mostrado na ilustração, o algoritmo seleciona os servidores em um círculo, ciclicamente. Mas não podemos selecioná-los
diretamente , certo?
E se o servidor estiver mentindo? Provavelmente não precisamos enviar tráfego para ele. Ou seja, o servidor não pode ser usado diretamente até o trazermos ao estado desejado. É necessário direcionar o tráfego apenas para os servidores que estão em funcionamento.
Definir a estrutura
Precisamos acompanhar todos os detalhes relacionados ao back-end. Você precisa saber se ele está vivo e acompanhar o URL. Para fazer isso, podemos definir a seguinte estrutura:
type Backend struct { URL *url.URL Alive bool mux sync.RWMutex ReverseProxy *httputil.ReverseProxy }
Não se preocupe, vou explicar o significado dos campos no back-end.
Agora, no balanceador, você precisa rastrear de alguma forma todos os back-ends. Para fazer isso, você pode usar o Slice e um contador variável. Defina-o no ServerPool:
type ServerPool struct { backends []*Backend current uint64 }
Usando ReverseProxy
Como já determinamos, a essência do balanceador é distribuir o tráfego para diferentes servidores e retornar resultados ao cliente. Como diz a documentação do Go:
ReverseProxy é um manipulador HTTP que recebe solicitações recebidas e as envia para outro servidor, proxyizando as respostas de volta ao cliente.Exatamente o que precisamos. Não há necessidade de reinventar a roda. Você pode simplesmente transmitir nossos pedidos através do
ReverseProxy
.
u, _ := url.Parse("http://localhost:8080") rp := httputil.NewSingleHostReverseProxy(u)
Utilizando o
httputil.NewSingleHostReverseProxy(url)
você pode inicializar o
ReverseProxy
, que transmitirá solicitações para o
url
passado. No exemplo acima, todas as solicitações foram enviadas para o host local: 8080 e os resultados foram enviados para o cliente.
Se você observar a assinatura do método ServeHTTP, poderá encontrar a assinatura do manipulador HTTP. Portanto, você pode passá-lo para
HandlerFunc
em
http
.
Outros exemplos estão na
documentação .
Para nosso balanceador, você pode iniciar o
ReverseProxy
com o
URL
associado no
Backend
para que o ReverseProxy roteie solicitações para o
URL
.
Processo de seleção do servidor
Durante a próxima seleção de servidores, precisamos pular os servidores subjacentes. Mas você precisa organizar a contagem.
Vários clientes se conectam ao balanceador e, quando cada um deles pede ao próximo nó para transferir tráfego, uma condição de corrida pode ocorrer. Para evitar isso, podemos bloquear o
ServerPool
com
mutex
. Mas será redundante, além de não querermos bloquear o
ServerPool
. Só precisamos aumentar o contador em um.
A melhor solução para atender a esses requisitos seria o incremento atômico. O Go o suporta com o pacote
atomic
.
func (s *ServerPool) NextIndex() int { return int(atomic.AddUint64(&s.current, uint64(1)) % uint64(len(s.backends))) }
Nós aumentamos atomicamente o valor atual em um e retornamos o índice alterando o comprimento da matriz. Isso significa que o valor deve sempre estar no intervalo de 0 ao comprimento da matriz. No final, estaremos interessados em um índice específico, não no contador inteiro.
Escolhendo um servidor ativo
Já sabemos que nossos pedidos são alternados ciclicamente em todos os servidores. E só precisamos pular o ocioso.
GetNext()
sempre retorna um valor que varia de 0 ao comprimento da matriz. A qualquer momento, podemos obter o próximo nó e, se estiver inativo, precisamos procurar mais na matriz como parte do loop.
Nós passamos pela matriz.Como mostrado na ilustração, queremos ir do próximo nó até o final da lista. Isso pode ser feito usando o
next + length
. Mas, para selecionar um índice, você precisa limitá-lo ao comprimento da matriz. Isso pode ser feito facilmente usando a operação de modificação.
Depois de encontrarmos um servidor ativo durante a pesquisa, ele deve ser marcado como atual:
Evitando a condição de corrida na estrutura de back-end
Aqui você precisa se lembrar de uma questão importante. A estrutura de
Backend
contém uma variável que várias goroutines podem modificar ou consultar ao mesmo tempo.
Sabemos que as goroutines irão ler a variável mais do que escrever nela. Portanto, para serializar o acesso ao
Alive
escolhemos o
RWMutex
.
Solicitações de balanceamento
Agora podemos formular um método simples para equilibrar nossos pedidos. Só falhará se todos os servidores caírem.
Este método pode ser passado para o servidor HTTP simplesmente como um
HandlerFunc
.
server := http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: http.HandlerFunc(lb), }
Roteamos o tráfego apenas para servidores em execução
Nosso balanceador tem um problema sério. Não sabemos se o servidor está em execução. Para descobrir, você precisa verificar o servidor. Existem duas maneiras de fazer isso:
- Ativo: executando a solicitação atual, descobrimos que o servidor selecionado não está respondendo e a marcamos como ocioso.
- Passivo: você pode executar ping nos servidores em algum intervalo e verificar o status.
Verificando ativamente os servidores em execução
Se
ReverseProxy
algum erro
ReverseProxy
inicia a função de retorno de chamada
ErrorHandler
. Isso pode ser usado para detectar falhas:
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, e error) { log.Printf("[%s] %s\n", serverUrl.Host, e.Error()) retries := GetRetryFromContext(request) if retries < 3 { select { case <-time.After(10 * time.Millisecond): ctx := context.WithValue(request.Context(), Retry, retries+1) proxy.ServeHTTP(writer, request.WithContext(ctx)) } return }
Ao desenvolver esse manipulador de erros, usamos os recursos de fechamentos. Isso nos permite capturar variáveis externas, como URLs do servidor, em nosso método. O manipulador verifica o contador de novas tentativas e, se for menor que 3, enviamos novamente a mesma solicitação para o mesmo servidor. Isso ocorre porque, devido a erros temporários, o servidor pode interromper nossas solicitações, mas logo fica disponível (o servidor pode não ter soquetes livres para novos clientes). Portanto, você precisa definir o timer de atraso para uma nova tentativa após cerca de 10 ms. A cada solicitação, aumentamos o contador de tentativas.
Após a falha de cada tentativa, marcamos o servidor como ocioso.
Agora você precisa atribuir um novo servidor para a mesma solicitação. Faremos isso usando o contador de tentativas usando o pacote de
context
. Depois de aumentar o contador de tentativas, passamos para
lb
para selecionar um novo servidor para processar a solicitação.
Não podemos fazer isso indefinidamente, portanto, verificaremos
lb
se o número máximo de tentativas foi atingido antes de continuar com o processamento da solicitação.
Você pode simplesmente obter o contador de tentativas da solicitação, se atingir o máximo, então interromperemos a solicitação.
Esta é uma implementação recursiva.
Usando o pacote de contexto
O pacote de
context
permite armazenar dados úteis em solicitações HTTP. Usaremos isso ativamente para rastrear dados relacionados a solicitações - contadores de
Attempt
e novas
Attempt
.
Primeiro, você precisa definir as chaves para o contexto. É recomendável usar não string, mas valores numéricos exclusivos. Go possui uma palavra-chave
iota
para implementação incremental de constantes, cada uma das quais contém um valor exclusivo. Esta é uma ótima solução para definir chaves numéricas.
const ( Attempts int = iota Retry )
Você pode então extrair o valor, como costumamos fazer com o
HashMap
. O valor padrão pode depender da situação atual.
Validação passiva do servidor
As verificações passivas identificam e recuperam servidores caídos. Nós os fazemos ping em um determinado intervalo para determinar seu status.
Para executar ping, tente estabelecer uma conexão TCP. Se o servidor responder, marcamos como funcionando. Este método pode ser adaptado para chamar pontos de extremidade específicos como
/status
. Certifique-se de fechar a conexão depois que ela for criada para reduzir a carga adicional no servidor. Caso contrário, ele tentará manter essa conexão e acabará esgotando seus recursos.
Agora você pode iterar os servidores e marcar seus status:
Para executar esse código periodicamente, você pode executar o cronômetro no Go. Isso permitirá que você ouça eventos no canal.
Nesse código, o canal
<-tC
retornará um valor a cada 20 segundos.
select
permite definir este evento. Na ausência de uma situação
default
, aguarda até que pelo menos um caso possa ser executado.
Agora execute o código em uma goroutine separada:
go healthCheck()
Conclusão
Neste artigo, examinamos muitas perguntas:
- Algoritmo Round Robin
- ReverseProxy da biblioteca padrão
- Mutexes
- Operações atômicas
- Curto-circuito
- Retornos de chamada
- Operação de seleção
Existem muitas outras maneiras de melhorar nosso balanceador. Por exemplo:
- Use heap para classificar servidores ativos para reduzir o escopo da pesquisa.
- Colete estatísticas.
- Implemente o algoritmo round-robin ponderado com o menor número de conexões.
- Adicione suporte para arquivos de configuração.
E assim por diante
O código fonte está
aqui .