Skip to content

About

Load balancer HTTP de camada 7 em Go, sem dependências externas: round-robin, least-connections e ip-hash plugáveis, health check ativo e passivo, failover automático com retry seguro do corpo e métricas por backend.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

loadbalancer

Um load balancer HTTP de camada 7 escrito em Go, sem dependência externa — só a biblioteca padrão. Round-robin, least-connections e ip-hash plugáveis, health check ativo e passivo, failover automático da requisição e um plano de administração com métricas por backend.

O objetivo não foi cobrir o espaço de features de um nginx, e sim implementar bem as duas coisas que definem um load balancer — distribuir carga e preservar disponibilidade — deixando explícito, no código e aqui, o porquê de cada decisão.

Contexto: o que um LB faz, e onde este se encaixa

Um load balancer é um proxy reverso que fica na frente de N instâncias da aplicação. Ele tem duas responsabilidades que costumam ser confundidas:

  • distribuição — espalhar as requisições para nenhuma instância saturar;
  • disponibilidade — perceber que uma instância morreu, tirá-la da rotação e mandar a requisição para outra antes que o usuário veja um erro.

Existem dois lugares onde isso pode ser feito:

Camada 4 Camada 7 (este projeto)
Enxerga conexão TCP/UDP requisição HTTP inteira
Pode rotear por IP e porta path, header, cookie, método
Pode fazer repassar bytes terminar TLS, repetir, reescrever, health check real
Custo mínimo parse de HTTP por requisição

Este é um LB de camada 7: ele entende HTTP, então consegue repetir uma requisição que falhou em outro backend e consegue perguntar GET /healthz em vez de só checar se a porta abriu.

Arquitetura

                        ┌──────────────────── loadbalancer ────────────────────┐
                        │                                                      │
   cliente ──:8080──────┼──► pool.ServeHTTP                                    │
                        │      │                                               │
                        │      │ 1. prepareReplay — o corpo cabe no buffer?    │
                        │      │ 2. pick ─────► Balancer.Pick(vivos, req)      │
                        │      │                  round-robin                  │
                        │      │                  least-connections            │
                        │      │                  ip-hash                      │
                        │      │ 3. forward ──► httputil.ReverseProxy ─────────┼──► backend
                        │      │                                               │
                        │      └─ falhou? ──► marca DOWN ──► tenta o próximo   │
                        │                            │                         │
                        │      ┌─────────────────────┴───────────┐             │
                        │      │  snapshot atômico de vivos      │             │
                        │      └─────────────────────┬───────────┘             │
                        │                            │                         │
                        │   healthChecker (goroutine)┘                         │
                        │     probe periódico ──────────────────────────────────┼──► backend
                        │                                                      │
   operador ──:8081─────┼──► /stats  /healthz                                  │
                        └──────────────────────────────────────────────────────┘
Arquivo Responsabilidade
main.go flags, wiring, dois servidores HTTP, shutdown gracioso
pool.go snapshot de backends vivos, retry/failover, buffer do corpo
backend.go uma instância de origem: proxy, contadores atômicos, estado
balancer.go a interface Balancer e os três algoritmos
health.go health check ativo (probe L7 ou L4, em paralelo)
admin.go /stats e /healthz do próprio LB, em porta separada
testbackend/ servidor de origem de brinquedo para a demo
scripts/demo.sh demonstração ponta a ponta dos dois tipos de health check
Makefile make check = go vet + go test -race

Rodando

Precisa de Go 1.23+. Se o go não estiver no PATH, tanto o make quanto o script de demo aceitam GO=/caminho/para/go.

go install github.com/cyroalves/load-balancer@latest
# três backends de brinquedo
go run ./testbackend -addr :9001 -name alpha &
go run ./testbackend -addr :9002 -name bravo &
go run ./testbackend -addr :9003 -name charlie &

# o load balancer
go run . -backends http://localhost:9001,http://localhost:9002,http://localhost:9003

curl localhost:8080/api/campanhas   # tráfego
curl localhost:8081/stats           # administração

Flags

Flag Padrão O que faz
-addr :8080 porta de tráfego
-admin-addr :8081 porta de administração
-backends — URLs separadas por vírgula (obrigatório)
-algo round-robin round-robin, least-connections ou ip-hash
-health-path /healthz path do probe; vazio faz probe por TCP dial
-health-interval 5s intervalo entre rodadas de health check
-health-timeout 2s timeout de cada probe
-max-attempts 3 quantos backends uma requisição tenta
-max-replay-body 1MiB teto do corpo guardado para permitir retry
-dial-timeout 3s timeout para abrir conexão com o backend
-response-header-timeout 30s espera pelo header antes de considerar falha
-shutdown-timeout 15s prazo das conexões em voo no shutdown

Demo: failover acontecendo de verdade

Saída real de scripts/demo.sh (os dois caminhos de detecção de falha aparecem no log do final).

1. round-robin distribuindo:

{"backend":"alpha",  "path":"/api/campanhas","pid":15687}
{"backend":"bravo",  "path":"/api/campanhas","pid":15688}
{"backend":"charlie","path":"/api/campanhas","pid":15689}
{"backend":"alpha",  "path":"/api/campanhas","pid":15687}
{"backend":"bravo",  "path":"/api/campanhas","pid":15688}
{"backend":"charlie","path":"/api/campanhas","pid":15689}

2. bravo passa a reprovar no /healthz (processo continua vivo) — o health check ativo tira ele da rotação e o rodízio segue com dois:

{"backend":"alpha",  ...}
{"backend":"charlie",...}
{"backend":"alpha",  ...}
{"backend":"charlie",...}

3. alpha leva kill -9 — não há tempo para o probe: a própria requisição falha no transporte, o health check passivo marca DOWN e o cliente ainda recebe 200, servido por outro backend:

{"backend":"charlie",...}
{"backend":"charlie",...}
{"backend":"charlie",...}

4. bravo volta a passar no /healthz — só o probe ativo é capaz de perceber isso (backend fora da rotação não recebe tráfego que possa falhar):

{"backend":"bravo",  ...}
{"backend":"charlie",...}
{"backend":"bravo",  ...}
{"backend":"charlie",...}

Log do LB, as duas detecções lado a lado:

13:55:36 load balancer em :8080 | admin em :8081 | algoritmo round-robin | 3 backend(s)
13:55:37 backend http://localhost:9002: UP → DOWN (health check)
13:55:37 backend http://localhost:9001: UP → DOWN (falha de transporte: dial tcp [::1]:9001: connect: connection refused)
13:55:38 backend http://localhost:9002: DOWN → UP (health check)

No shutdown (SIGTERM), as conexões em voo têm prazo para terminar antes do processo sair:

13:57:40 sinal recebido, desligando…
13:57:40 até mais

/stats:

{
  "algorithm": "round-robin",
  "healthy_backends": 2,
  "total_backends": 3,
  "backends": [
    { "url": "http://localhost:9001", "healthy": false, "requests": 5, "failures": 1, "in_flight": 0 },
    { "url": "http://localhost:9002", "healthy": true,  "requests": 4, "failures": 0, "in_flight": 0 },
    { "url": "http://localhost:9003", "healthy": true,  "requests": 9, "failures": 0, "in_flight": 0 }
  ]
}

Decisões de design

Health check ativo e passivo

Os dois existem porque nenhum dos dois resolve sozinho:

  • passivo (backend.go) — a falha de transporte de uma requisição real já é a evidência de que o backend caiu. Custa zero, detecta na hora. Mas nunca recoloca ninguém: backend fora da rotação não recebe requisição que possa dar certo.
  • ativo (health.go) — probe periódico. Mais lento para detectar (limitado pelo intervalo), porém é o único capaz de trazer um backend de volta.

O probe é L7 por padrão (GET /healthz, prova que a aplicação respondeu) e cai para TCP dial quando -health-path é vazio (funciona com qualquer backend, mas só prova que a porta abriu). Os probes de uma rodada são disparados em paralelo — em série, N backends lentos somariam N × timeout e o intervalo viraria ficção.

Snapshot atômico em vez de lock no hot path

O pool guarda os backends vivos num atomic.Pointer[[]*backend] reconstruído por quem muda estado, não por quem serve requisição. O caminho quente faz um Load e itera: zero alocação, zero contenção, nenhuma varredura de liveness por requisição. Transições de estado são ordens de grandeza mais raras que requisições, então é do lado barato que o custo deve ficar.

Isso é uma aposta em atômicos, e aposta em atômico se valida com ferramenta, não com leitura: existe um teste de concorrência rodando sob -race justamente para cobrar essa conta.

Retry só é honesto se o corpo sobreviver

O erro clássico de um retry ingênuo: o proxy consome o corpo da requisição na primeira tentativa e a segunda chega vazia no backend. Aqui a decisão é tomada antes da primeira tentativa (prepareReplay):

  • corpo vazio → sempre repetível;
  • corpo até -max-replay-body → lido para memória e reenviado a cada tentativa;
  • corpo maior → uma tentativa só, e o que já foi lido é costurado de volta na frente do stream original (io.MultiReader) para o backend receber a requisição inteira mesmo sem direito a retry. Se essa tentativa falha, o cliente recebe 502 — melhor que uma requisição truncada.

Limitação assumida: repetir um POST não idempotente pode duplicar efeito colateral. Um LB de produção restringe retry a métodos idempotentes ou exige idempotency key. A escolha aqui está documentada, não escondida.

RemoteAddr, não X-Forwarded-For, no ip-hash

Na borda, X-Forwarded-For é um header que o cliente escolheu mandar. Confiar nele deixaria qualquer um escolher o próprio backend e furar rate limit por IP. Atrás de outro proxy confiável a decisão se inverte — aí o certo é ler o XFF descartando os N últimos saltos conhecidos.

Administração em porta separada

Na porta pública, /stats vazaria a topologia interna (host e porta de cada backend) para qualquer um, e o /healthz do LB colidiria com o /healthz que o cliente quer proxiar para o backend. Duas portas resolvem os dois problemas e ainda deixam o firewall expor só uma.

Algoritmos

Algoritmo Quando ganha Custo
round-robin backends homogêneos, requisições de custo parecido um incremento atômico
least-connections duração de requisição varia muito (upload, endpoint lento, hardware desigual) O(n) nos vivos
ip-hash precisa de sticky session sem estado compartilhado, ou afinidade de cache um hash FNV

O ip-hash usa hash % len(vivos), então quando um backend entra ou sai quase todos os clientes são remapeados. É exatamente o problema que consistent hashing (anel + virtual nodes) resolve; ficou de fora para manter o algoritmo em dez linhas legíveis.

A interface é a parte que importa:

type Balancer interface {
	Pick(live []*backend, r *http.Request) *backend
	Name() string
}

Pick recebe só os vivos: filtrar liveness é do pool, não do algoritmo. É o que mantém cada implementação pura e testável sem subir servidor nenhum.

Testes

go vet ./...
go test -race ./...
--- PASS: TestRoundRobinDistribuiIgualmente
--- PASS: TestRoundRobinComecaNoPrimeiro
--- PASS: TestLeastConnectionsEscolheOMaisOcioso
--- PASS: TestIPHashEhEstavelPorCliente
--- PASS: TestIPHashEspalhaClientesDiferentes
--- PASS: TestBalancerVazioDevolveNil
--- PASS: TestAlgoritmoDesconhecidoFalhaRapido
--- PASS: TestProxyAlternaEntreBackends
--- PASS: TestFailoverQuandoBackendCai
--- PASS: TestCorpoEhReenviadoNoFailover
--- PASS: TestCorpoGrandeDemaisNaoEhRepetido
--- PASS: TestTodosMortosDevolve503
--- PASS: TestHealthCheckRecolocaBackendNaRotacao
--- PASS: TestConcorrencia
--- PASS: TestPrepareReplay
ok  	loadbalancer	1.156s

Os testes cobram comportamento, não linha: distribuição justa em 300 requisições, failover com backend de porta fechada, corpo reenviado íntegro ao backend seguinte, corpo grande recusado em vez de truncado, 503 quando não sobrou ninguém, e recuperação via health check com um backend que volta a passar.

O que ficou de fora (de propósito)

Cada item abaixo é uma escolha, não um esquecimento:

  • Terminação de TLS — http.Server com ListenAndServeTLS resolve; não acrescenta nada conceitual ao que o projeto quer mostrar.
  • Consistent hashing — ver a seção de algoritmos.
  • Circuit breaker / outlier detection — hoje um backend sai da rotação por falha de transporte; ejetar por taxa de erro 5xx numa janela é o passo seguinte.
  • Pesos por backend — weighted round-robin cabe na interface Balancer sem tocar em mais nada. É a prova de que a interface está no lugar certo.
  • Métricas Prometheus — /stats entrega o mesmo dado para a demo sem puxar dependência.
  • Rate limiting — pertence a um API gateway, não ao LB.

About

Load balancer HTTP de camada 7 em Go, sem dependências externas: round-robin, least-connections e ip-hash plugáveis, health check ativo e passivo, failover automático com retry seguro do corpo e métricas por backend.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages