Balanceamento de carga NGINX simplificado com Loadcat

Publicados: 2022-03-11

Os aplicativos da Web projetados para serem escaláveis ​​horizontalmente geralmente exigem um ou mais nós de balanceamento de carga. Seu objetivo principal é distribuir o tráfego de entrada pelos servidores da Web disponíveis de maneira justa. A capacidade de aumentar a capacidade geral de um aplicativo da Web simplesmente aumentando o número de nós e fazendo com que os balanceadores de carga se adaptem a essa mudança pode ser extremamente útil na produção.

O NGINX é um servidor web que oferece recursos de balanceamento de carga de alto desempenho, entre muitos de seus outros recursos. Alguns desses recursos estão disponíveis apenas como parte de seu modelo de assinatura, mas a versão gratuita e de código aberto ainda é muito rica em recursos e vem com os recursos de balanceamento de carga mais essenciais prontos para uso.

Balanceamento de carga NGINX simplificado com Loadcat

Balanceamento de carga NGINX simplificado com Loadcat
Tweet

Neste tutorial, exploraremos a mecânica interna de uma ferramenta experimental que permite configurar sua instância do NGINX em tempo real para atuar como um balanceador de carga, abstraindo todos os detalhes básicos dos arquivos de configuração do NGINX, fornecendo uma interface web organizada. interface de usuário baseada. O objetivo deste artigo é mostrar como é fácil começar a construir tal ferramenta. Vale ressaltar que o projeto Loadcat é fortemente inspirado nos NodeBalancers da Linode.

NGINX, servidores e upstreams

Um dos usos mais populares do NGINX é o proxy reverso de solicitações de clientes para aplicativos de servidor web. Embora os aplicativos da Web desenvolvidos em linguagens de programação como Node.js e Go possam ser servidores Web autossuficientes, ter um proxy reverso na frente do aplicativo de servidor real oferece vários benefícios. Um bloco “servidor” para um caso de uso simples como este em um arquivo de configuração NGINX pode ser algo assim:

 server { listen 80; server_name example.com; location / { proxy_pass http://192.168.0.51:5000; } }

Isso faria com que o NGINX escutasse na porta 80 para todas as solicitações que são apontadas para example.com e passaria cada uma delas para algum aplicativo de servidor web rodando em 192.168.0.51:5000. Também poderíamos usar o endereço IP de loopback 127.0.0.1 aqui se o servidor de aplicativos da Web estivesse sendo executado localmente. Observe que o trecho acima carece de alguns ajustes óbvios que são frequentemente usados ​​na configuração de proxy reverso, mas está sendo mantido dessa forma por brevidade.

Mas e se quisermos equilibrar todas as solicitações recebidas entre duas instâncias do mesmo servidor de aplicativos da web? É aqui que a diretiva “upstream” se torna útil. No NGINX, com a diretiva “upstream”, é possível definir vários nós de back-end entre os quais o NGINX balanceará todas as solicitações recebidas. Por exemplo:

 upstream nodes { server 192.168.0.51:5000; server 192.168.0.52:5000; } server { listen 80; server_name example.com; location / { proxy_pass http://nodes; } }

Observe como definimos um bloco “upstream”, chamado “nós”, composto por dois servidores. Cada servidor é identificado por um endereço IP e o número da porta em que estão escutando. Com isso, o NGINX se torna um balanceador de carga em sua forma mais simples. Por padrão, o NGINX distribuirá as solicitações recebidas de forma round-robin, onde a primeira será enviada para o primeiro servidor, a segunda para o segundo servidor, a terceira para o primeiro servidor e assim por diante.

No entanto, o NGINX tem muito mais a oferecer quando se trata de balanceamento de carga. Ele permite que você defina pesos para cada servidor, marque-os como temporariamente indisponíveis, escolha um algoritmo de balanceamento diferente (por exemplo, há um que funciona com base no hash de IP do cliente), etc. Esses recursos e diretivas de configuração estão bem documentados em nginx.org . Além disso, o NGINX permite que os arquivos de configuração sejam alterados e recarregados em tempo real com quase nenhuma interrupção.

A configurabilidade do NGINX e os arquivos de configuração simples tornam muito fácil adaptá-lo a muitas necessidades. E já existe uma infinidade de tutoriais na Internet que ensinam exatamente como configurar o NGINX como balanceador de carga.

Loadcat: Ferramenta de configuração NGINX

Há algo de fascinante nos programas que, em vez de fazer algo por conta própria, configuram outras ferramentas para fazer isso por eles. Eles não fazem muito além de talvez receber entradas do usuário e gerar alguns arquivos. A maioria dos benefícios que você obtém dessas ferramentas são, na verdade, recursos de outras ferramentas. Mas, eles certamente tornam a vida mais fácil. Ao tentar configurar um balanceador de carga para um dos meus próprios projetos, me perguntei: por que não fazer algo semelhante para o NGINX e seus recursos de balanceamento de carga?

O Loadcat nasceu!

Loadcat, construído com Go, ainda está em sua infância. Neste momento, a ferramenta permite configurar o NGINX apenas para balanceamento de carga e terminação SSL. Ele fornece uma GUI simples baseada na web para o usuário. Em vez de percorrer os recursos individuais da ferramenta, vamos dar uma olhada no que está por baixo. Esteja ciente, porém, que se alguém gosta de trabalhar com os arquivos de configuração do NGINX manualmente, pode achar pouco valor em tal ferramenta.

Existem algumas razões por trás da escolha do Go como linguagem de programação para isso. Uma delas é que Go produz binários compilados. Isso nos permite construir e distribuir ou implantar o Loadcat como um binário compilado para servidores remotos sem nos preocupar em resolver dependências. Algo que simplifica muito o processo de configuração. Claro, o binário assume que o NGINX já está instalado e existe um arquivo de unidade systemd para ele.

Caso você não seja um engenheiro Go, não se preocupe. Go é bastante fácil e divertido para começar. Além disso, a implementação em si é muito direta e você poderá acompanhar facilmente.

Estrutura

As ferramentas de construção Go impõem algumas restrições sobre como você pode estruturar seu aplicativo e deixa o resto para o desenvolvedor. No nosso caso, dividimos as coisas em alguns pacotes Go com base em seus propósitos:

  • cfg: carrega, analisa e fornece valores de configuração
  • cmd/loadcat: pacote principal, contém o ponto de entrada, compila em binário
  • data: contém “modelos”, usa um armazenamento de chave/valor incorporado para persistência
  • feline: contém a funcionalidade principal, por exemplo, geração de arquivos de configuração, mecanismo de recarregamento, etc.
  • ui: contém modelos, manipuladores de URL, etc.

Se olharmos mais de perto a estrutura do pacote, especialmente dentro do pacote feline, perceberemos que todo o código específico do NGINX foi mantido dentro de um subpacote feline/nginx. Isso é feito para que possamos manter o restante da lógica do aplicativo genérica e estender o suporte para outros balanceadores de carga (por exemplo, HAProxy) no futuro.

Ponto de entrada

Vamos começar pelo pacote principal do Loadcat, encontrado em “cmd/loadcatd”. A função principal, ponto de entrada do aplicativo, faz três coisas.

 func main() { fconfig := flag.String("config", "loadcat.conf", "") flag.Parse() cfg.LoadFile(*fconfig) feline.SetBase(filepath.Join(cfg.Current.Core.Dir, "out")) data.OpenDB(filepath.Join(cfg.Current.Core.Dir, "loadcat.db")) defer data.DB.Close() data.InitDB() http.Handle("/api", api.Router) http.Handle("/", ui.Router) go http.ListenAndServe(cfg.Current.Core.Address, nil) // Wait for an “interrupt“ signal (Ctrl+C in most terminals) }

Para manter as coisas simples e tornar o código mais fácil de ler, todo o código de tratamento de erros foi removido do snippet acima (e também dos snippets mais adiante neste artigo).

Como você pode ver pelo código, estamos carregando o arquivo de configuração com base no sinalizador de linha de comando “-config” (que tem como padrão “loadcat.conf” no diretório atual). Em seguida, estamos inicializando alguns componentes, a saber, o pacote feline principal e o banco de dados. Finalmente, estamos iniciando um servidor web para a GUI baseada na web.

Configuração

Carregar e analisar o arquivo de configuração é provavelmente a parte mais fácil aqui. Estamos usando TOML para codificar informações de configuração. Há um pacote de análise TOML puro disponível para Go. Precisamos de muito poucas informações de configuração do usuário e, na maioria dos casos, podemos determinar padrões sensatos para esses valores. A estrutura a seguir representa a estrutura do arquivo de configuração:

 struct { Core struct { Address string Dir string Driver string } Nginx struct { Mode string Systemd struct { Service string } } }

E aqui está a aparência de um arquivo “loadcat.conf” típico:

 [core] address=":26590" dir="/var/lib/loadcat" driver="nginx" [nginx] mode="systemd" [nginx.systemd] service="nginx.service"

Como podemos ver, há uma semelhança entre a estrutura do arquivo de configuração codificado em TOML e a estrutura mostrada acima. O pacote de configuração começa definindo alguns padrões sensatos para determinados campos do struct e, em seguida, analisa o arquivo de configuração sobre ele. Caso não encontre um arquivo de configuração no caminho especificado, ele cria um e primeiro despeja os valores padrão nele.

 func LoadFile(name string) error { f, _ := os.Open(name) if os.IsNotExist(err) { f, _ = os.Create(name) toml.NewEncoder(f).Encode(Current) f.Close() return nil } toml.NewDecoder(f).Decode(&Current) return nil }

Dados e persistência

Conheça Bolt. Um armazenamento de chave/valor incorporado escrito em Go puro. Ele vem como um pacote com uma API muito simples, suporta transações prontas para uso e é perturbadoramente rápido.

Dentro dos dados do pacote, temos structs representando cada tipo de entidade. Por exemplo, temos:

 type Balancer struct { Id bson.ObjectId Label string Settings BalancerSettings } type Server struct { Id bson.ObjectId BalancerId bson.ObjectId Label string Settings ServerSettings }

… em que uma instância do Balancer representa um único balanceador de carga. O Loadcat efetivamente permite balancear solicitações para vários aplicativos da Web por meio de uma única instância do NGINX. Cada balanceador pode ter um ou mais servidores por trás dele, onde cada servidor pode ser um nó de back-end separado.

Como o Bolt é um armazenamento de valor-chave e não oferece suporte a consultas avançadas de banco de dados, temos uma lógica do lado do aplicativo que faz isso por nós. O Loadcat não foi feito para configurar milhares de balanceadores com milhares de servidores em cada um deles, então, naturalmente, essa abordagem ingênua funciona muito bem. Além disso, o Bolt trabalha com chaves e valores que são fatias de byte, e é por isso que codificamos as estruturas em BSON antes de armazená-las no Bolt. A implementação de uma função que recupera uma lista de estruturas do Balancer do banco de dados é mostrada abaixo:

 func ListBalancers() ([]Balancer, error) { bals := []Balancer{} DB.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("balancers")) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { bal := Balancer{} bson.Unmarshal(v, &bal) bals = append(bals, bal) } return nil }) return bals, nil }

A função ListBalancers inicia uma transação somente leitura, itera sobre todas as chaves e valores dentro do bucket “balancers”, decodifica cada valor para uma instância da estrutura Balancer e os retorna em uma matriz.

Armazenar um balanceador no bucket é quase igualmente simples:

 func (l *Balancer) Put() error { if !l.Id.Valid() { l.Id = bson.NewObjectId() } if l.Label == "" { l.Label = "Unlabelled" } if l.Settings.Protocol == "https" { // Parse certificate details } else { // Clear fields relevant to HTTPS only, such as SSL options and certificate details } return DB.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("balancers")) p, err := bson.Marshal(l) if err != nil { return err } return b.Put([]byte(l.Id.Hex()), p) }) }

A função Put atribui alguns valores padrão a determinados campos, analisa o certificado SSL anexado na configuração HTTPS, inicia uma transação, codifica a instância struct e a armazena no bucket em relação ao ID do balanceador.

Ao analisar o certificado SSL, duas informações são extraídas usando a codificação de pacote padrão/pem e armazenadas em SSLOptions no campo Configurações : os nomes DNS e a impressão digital.

Também temos uma função que procura servidores por balanceador:

 func ListServersByBalancer(bal *Balancer) ([]Server, error) { srvs := []Server{} DB.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("servers")) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { srv := Server{} bson.Unmarshal(v, &srv) if srv.BalancerId.Hex() != bal.Id.Hex() { continue } srvs = append(srvs, srv) } return nil }) return srvs, nil }

Esta função mostra o quão ingênua nossa abordagem realmente é. Aqui, estamos efetivamente lendo todo o bucket de “servidores” e filtrando as entidades irrelevantes antes de retornar a matriz. Mas, novamente, isso funciona muito bem e não há motivo real para alterá-lo.

A função Put para servidores é muito mais simples do que a do Balancer struct , pois não requer tantas linhas de padrões de configuração de código e campos computados.

Controlando o NGINX

Antes de usar o Loadcat, devemos configurar o NGINX para carregar os arquivos de configuração gerados. O Loadcat gera o arquivo “nginx.conf” para cada balanceador em um diretório pelo ID do balanceador (uma string hexadecimal curta). Esses diretórios são criados em um diretório “out” em cwd . Portanto, é importante configurar o NGINX para carregar esses arquivos de configuração gerados. Isso pode ser feito usando uma diretiva “include” dentro do bloco “http”:

Edite /etc/nginx/nginx.conf e adicione a seguinte linha no final do bloco “http”:

 http { include /path/to/out/*/nginx.conf; }

Isso fará com que o NGINX escaneie todos os diretórios encontrados em “/path/to/out/”, procure por arquivos chamados “nginx.conf” dentro de cada diretório e carregue cada um que encontrar.

Em nosso pacote principal, feline, definimos uma interface Driver . Qualquer struct que forneça duas funções, Generate e Reload , com a assinatura correta se qualifica como um driver.

 type Driver interface { Generate(string, *data.Balancer) error Reload() error }

Por exemplo, o struct Nginx nos pacotes feline/nginx:

 type Nginx struct { sync.Mutex Systemd *dbus.Conn } func (n Nginx) Generate(dir string, bal *data.Balancer) error { // Acquire a lock on n.Mutex, and release before return f, _ := os.Create(filepath.Join(dir, "nginx.conf")) TplNginxConf.Execute(f, /* template parameters */) f.Close() if bal.Settings.Protocol == "https" { // Dump private key and certificate to the output directory (so that Nginx can find them) } return nil } func (n Nginx) Reload() error { // Acquire a lock on n.Mutex, and release before return switch cfg.Current.Nginx.Mode { case "systemd": if n.Systemd == nil { c, err := dbus.NewSystemdConnection() n.Systemd = c } ch := make(chan string) n.Systemd.ReloadUnit(cfg.Current.Nginx.Systemd.Service, "replace", ch) <-ch return nil default: return errors.New("unknown Nginx mode") } }

Generate pode ser invocado com uma string contendo o caminho para o diretório de saída e um ponteiro para uma instância de estrutura do Balancer . Go fornece um pacote padrão para modelagem de texto, que o driver NGINX usa para gerar o arquivo de configuração NGINX final. O template consiste em um bloco “upstream” seguido de um bloco “server”, gerado com base em como o balanceador está configurado:

 var TplNginxConf = template.Must(template.New("").Parse(` upstream {{.Balancer.Id.Hex}} { {{if eq .Balancer.Settings.Algorithm "least-connections"}} least_conn; {{else if eq .Balancer.Settings.Algorithm "source-ip"}} ip_hash; {{end}} {{range $srv := .Balancer.Servers}} server {{$srv.Settings.Address}} weight={{$srv.Settings.Weight}} {{if eq $srv.Settings.Availability "available"}}{{else if eq $srv.Settings.Availability "backup"}}backup{{else if eq $srv.Settings.Availability "unavailable"}}down{{end}}; {{end}} } server { {{if eq .Balancer.Settings.Protocol "http"}} listen {{.Balancer.Settings.Port}}; {{else if eq .Balancer.Settings.Protocol "https"}} listen {{.Balancer.Settings.Port}} ssl; {{end}} server_name {{.Balancer.Settings.Hostname}}; {{if eq .Balancer.Settings.Protocol "https"}} ssl on; ssl_certificate {{.Dir}}/server.crt; ssl_certificate_key {{.Dir}}/server.key; {{end}} location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://{{.Balancer.Id.Hex}}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; } } `))

Recarregar é a outra função na estrutura do Nginx que faz o NGINX recarregar os arquivos de configuração. O mecanismo usado é baseado em como o Loadcat é configurado. Por padrão, ele assume que o NGINX é um serviço systemd rodando como nginx.service, de modo que [sudo] systemd reload nginx.service funcionaria. No entanto, em vez de executar um comando shell, ele estabelece uma conexão com o systemd através do D-Bus usando o pacote github.com/coreos/go-systemd/dbus.

GUI baseada na Web

Com todos esses componentes no lugar, vamos encerrar tudo com uma interface de usuário simples do Bootstrap.

Recursos de balanceamento de carga NGINX, envoltos em uma GUI simples

Recursos de balanceamento de carga NGINX, envoltos em uma GUI simples
Tweet

Para essas funcionalidades básicas, alguns manipuladores simples de rotas GET e POST são suficientes:

 GET /balancers GET /balancers/new POST /balancers/new GET /balancers/{id} GET /balancers/{id}/edit POST /balancers/{id}/edit GET /balancers/{id}/servers/new POST /balancers/{id}/servers/new GET /servers/{id} GET /servers/{id}/edit POST /servers/{id}/edit

Percorrer cada rota individual pode não ser a coisa mais interessante a fazer aqui, já que essas são praticamente as páginas CRUD. Sinta-se à vontade para dar uma olhada no código da interface do usuário do pacote para ver como os manipuladores de cada uma dessas rotas foram implementados.

Cada função do manipulador é uma rotina que:

  • Busca dados do armazenamento de dados e responde com modelos renderizados (usando os dados buscados)
  • Analisa os dados do formulário de entrada, faz as alterações necessárias no armazenamento de dados e usa o pacote feline para gerar novamente os arquivos de configuração do NGINX

Por exemplo:

 func ServeServerNewForm(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bal, _ := data.GetBalancer(bson.ObjectIdHex(vars["id"])) TplServerNewForm.Execute(w, struct { Balancer *data.Balancer }{ Balancer: bal, }) } func HandleServerCreate(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bal, _ := data.GetBalancer(bson.ObjectIdHex(vars["id"])) r.ParseForm() body := struct { Label string `schema:"label"` Settings struct { Address string `schema:"address"` } `schema:"settings"` }{} schema.NewDecoder().Decode(&body, r.PostForm) srv := data.Server{} srv.BalancerId = bal.Id srv.Label = body.Label srv.Settings.Address = body.Settings.Address srv.Put() feline.Commit(bal) http.Redirect(w, r, "/servers/"+srv.Id.Hex()+"/edit", http.StatusSeeOther) }

Tudo o que a função ServeServerNewForm faz é buscar um balanceador do armazenamento de dados e renderizar um modelo, TplServerList neste caso, que recupera a lista de servidores relevantes usando a função Servidores no balanceador.

A função HandleServerCreate , por outro lado, analisa a carga útil POST recebida do corpo em uma estrutura e usa esses dados para instanciar e persistir uma nova estrutura do servidor no armazenamento de dados antes de usar o pacote feline para gerar novamente o arquivo de configuração NGINX para o balanceador.

Todos os templates de página são armazenados no arquivo “ui/templates.go” e os arquivos HTML de template correspondentes podem ser encontrados no diretório “ui/templates”.

Experimentando

A implantação do Loadcat em um servidor remoto ou até mesmo em seu ambiente local é super fácil. Se você estiver executando o Linux (64 bits), poderá obter um arquivo com um binário Loadcat pré-construído na seção Releases do repositório. Se você estiver se sentindo um pouco aventureiro, você pode clonar o repositório e compilar o código você mesmo. Embora, a experiência nesse caso possa ser um pouco decepcionante , pois compilar programas Go não é realmente um desafio. E caso você esteja executando o Arch Linux, então você está com sorte! Um pacote foi construído para a distribuição por conveniência. Basta baixá-lo e instalá-lo usando seu gerenciador de pacotes. As etapas envolvidas são descritas com mais detalhes no arquivo README.md do projeto.

Depois de configurar e executar o Loadcat, aponte seu navegador da Web para “http://localhost:26590” (supondo que ele esteja sendo executado localmente e escutando na porta 26590). Em seguida, crie um balanceador, crie alguns servidores, certifique-se de que algo esteja escutando nessas portas definidas e pronto, você deve ter solicitações de entrada de balanceamento de carga do NGINX entre esses servidores em execução.

Qual é o próximo?

Esta ferramenta está longe de ser perfeita e, na verdade, é um projeto bastante experimental. A ferramenta nem cobre todas as funcionalidades básicas do NGINX. Por exemplo, se você deseja armazenar em cache os ativos servidos pelos nós de back-end na camada NGINX, ainda terá que modificar manualmente os arquivos de configuração do NGINX. E é isso que torna as coisas emocionantes. Há muito que pode ser feito aqui e isso é exatamente o que vem a seguir: abrangendo ainda mais recursos de balanceamento de carga do NGINX - os básicos e provavelmente até os que o NGINX Plus tem a oferecer.

Experimente o Loadcat. Confira o código, faça um fork, mude, brinque com ele. Além disso, deixe-nos saber se você construiu uma ferramenta que configura outro software ou usou um que você realmente gosta na seção de comentários abaixo.