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.
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.
┌──────────────────── 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 |
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| 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 |
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 }
]
}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.
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.
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.
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.
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.
| 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.
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.
Cada item abaixo é uma escolha, não um esquecimento:
- Terminação de TLS —
http.ServercomListenAndServeTLSresolve; 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-robincabe na interfaceBalancersem tocar em mais nada. É a prova de que a interface está no lugar certo. - Métricas Prometheus —
/statsentrega o mesmo dado para a demo sem puxar dependência. - Rate limiting — pertence a um API gateway, não ao LB.