Escrevendo um balanceador simples no Go


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) // initialize your server and add this as handler http.HandlerFunc(rp.ServeHTTP) 

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:

 // GetNextPeer returns next active peer to take a connection func (s *ServerPool) GetNextPeer() *Backend { // loop entire backends to find out an Alive backend next := s.NextIndex() l := len(s.backends) + next // start from next and move a full cycle for i := next; i < l; i++ { idx := i % len(s.backends) // take an index by modding with length // if we have an alive backend, use it and store if its not the original one if s.backends[idx].IsAlive() { if i != next { atomic.StoreUint64(&s.current, uint64(idx)) // mark the current one } return s.backends[idx] } } return nil } 

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 .

 // SetAlive for this backend func (b *Backend) SetAlive(alive bool) { b.mux.Lock() b.Alive = alive b.mux.Unlock() } // IsAlive returns true when backend is alive func (b *Backend) IsAlive() (alive bool) { b.mux.RLock() alive = b.Alive b.mux.RUnlock() return } 

Solicitações de balanceamento


Agora podemos formular um método simples para equilibrar nossos pedidos. Só falhará se todos os servidores caírem.

 // lb load balances the incoming request func lb(w http.ResponseWriter, r *http.Request) { peer := serverPool.GetNextPeer() if peer != nil { peer.ReverseProxy.ServeHTTP(w, r) return } http.Error(w, "Service not available", http.StatusServiceUnavailable) } 

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 } // after 3 retries, mark this backend as down serverPool.MarkBackendStatus(serverUrl, false) // if the same request routing for few attempts with different backends, increase the count attempts := GetAttemptsFromContext(request) log.Printf("%s(%s) Attempting retry %d\n", request.RemoteAddr, request.URL.Path, attempts) ctx := context.WithValue(request.Context(), Attempts, attempts+1) lb(writer, request.WithContext(ctx)) } 

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.

 // lb load balances the incoming request func lb(w http.ResponseWriter, r *http.Request) { attempts := GetAttemptsFromContext(r) if attempts > 3 { log.Printf("%s(%s) Max attempts reached, terminating\n", r.RemoteAddr, r.URL.Path) http.Error(w, "Service not available", http.StatusServiceUnavailable) return } peer := serverPool.GetNextPeer() if peer != nil { peer.ReverseProxy.ServeHTTP(w, r) return } http.Error(w, "Service not available", http.StatusServiceUnavailable) } 

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.

 // GetAttemptsFromContext returns the attempts for request func GetRetryFromContext(r *http.Request) int { if retry, ok := r.Context().Value(Retry).(int); ok { return retry } return 0 } 

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.

 // isAlive checks whether a backend is Alive by establishing a TCP connection func isBackendAlive(u *url.URL) bool { timeout := 2 * time.Second conn, err := net.DialTimeout("tcp", u.Host, timeout) if err != nil { log.Println("Site unreachable, error: ", err) return false } _ = conn.Close() // close it, we dont need to maintain this connection return true } 

Agora você pode iterar os servidores e marcar seus status:

 // HealthCheck pings the backends and update the status func (s *ServerPool) HealthCheck() { for _, b := range s.backends { status := "up" alive := isBackendAlive(b.URL) b.SetAlive(alive) if !alive { status = "down" } log.Printf("%s [%s]\n", b.URL, status) } } 

Para executar esse código periodicamente, você pode executar o cronômetro no Go. Isso permitirá que você ouça eventos no canal.

 // healthCheck runs a routine for check status of the backends every 2 mins func healthCheck() { t := time.NewTicker(time.Second * 20) for { select { case <-tC: log.Println("Starting health check...") serverPool.HealthCheck() log.Println("Health check completed") } } } 

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 .

Source: https://habr.com/ru/post/pt476276/


All Articles