Goroutine leak é chato de diagnosticar porque o código parece correto. Sem deadlock, sem panic, o endpoint retorna 200. O processo só vai crescendo de memória até levar OOMKilled.
Um serviço nosso ficou seis meses rodando sem incidente e começou a vazar memória devagar, sem crash, sem erro óbvio. A causa era goroutines que abriam e nunca terminavam. Cada uma segura um stack mínimo de 2KB, referências a closures, buffers de channel. Multiplique por milhares de requisições por dia e você tem um vazamento silencioso que aparece na fatura da AWS antes de aparecer no log.
Primeiro passo: pare de adivinhar
Antes de qualquer hipótese, pprof. Não tem atalho.
Se você tem um servidor HTTP, adicionar o profiler é um import:
import _ "net/http/pprof"
Isso expõe /debug/pprof/goroutine automaticamente. Para tirar um snapshot:
go tool pprof http://localhost:6060/debug/pprof/goroutine
Dentro do shell interativo, top mostra o estado de todas as goroutines vivas:
(pprof) top
Showing nodes accounting for 8421 goroutines, 100% of 8421 total
flat flat% sum% cum cum%
8392 99.66% 99.66% 8392 99.66% runtime.gopark
runtime.gopark com 99% é o sinal. Goroutines em gopark estão bloqueadas, e a maioria nunca vai acordar. Para ver exatamente onde cada uma está parada:
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" | head -100
O que apareceu no meu caso foi algo assim:
goroutine 4821 [chan receive, 47 minutes]:
main.processWebhook(0xc000123400)
/app/workers/webhook.go:83 +0x94
created by main.startWorker
/app/workers/webhook.go:61 +0x58
47 minutos esperando em channel receive. Multiplicado por 8.392. Culpado encontrado.
context.Context passado, mas nunca cancelado
Esse foi o meu caso. O serviço recebia webhooks e disparava uma goroutine para processar cada evento de forma assíncrona. O código parecia razoável:
type WebhookPayload struct {
ID string `json:"id"`
Event string `json:"event"`
Data json.RawMessage `json:"data"`
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
go h.process(context.Background(), payload)
w.WriteHeader(http.StatusAccepted)
}
func (h *Handler) process(ctx context.Context, p WebhookPayload) {
resp, err := h.client.Post(
ctx,
"https://internal-api.svc/events",
p,
)
if err != nil {
log.Printf("forwarding webhook %s: %v", p.ID, err)
return
}
defer resp.Body.Close()
if _, err := h.db.ExecContext(ctx,
"UPDATE webhooks SET processed_at = NOW() WHERE id = $1",
p.ID,
); err != nil {
log.Printf("marking webhook %s as processed: %v", p.ID, err)
}
}
Quando o client cancela a requisição, seja por timeout ou retry, o r.Context() é cancelado automaticamente pelo net/http. Só que a goroutine está usando context.Background(), que nunca cancela. Ela continua executando, chama a API interna, escreve no banco, tudo isso para um evento que já foi reprocessado pelo retry do client. E se a API interna estiver lenta naquele momento, ela nunca termina.
A correção é propagar o contexto correto e garantir que ele tenha um deadline explícito:
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
go h.process(ctx, payload)
w.WriteHeader(http.StatusAccepted)
}
func (h *Handler) process(ctx context.Context, p WebhookPayload) {
select {
case <-ctx.Done():
return
default:
}
resp, err := h.client.Post(ctx, "https://internal-api.svc/events", p)
if err != nil {
log.Printf("forwarding webhook %s: %v", p.ID, err)
return
}
defer resp.Body.Close()
if _, err := h.db.ExecContext(ctx,
"UPDATE webhooks SET processed_at = NOW() WHERE id = $1",
p.ID,
); err != nil {
log.Printf("marking webhook %s as processed: %v", p.ID, err)
}
}
O defer cancel() garante que quando ServeHTTP retorna, o contexto filho é cancelado. A goroutine ainda precisa checar ctx.Done() no início para não começar um trabalho que já foi cancelado antes de chegar à execução.
Channel send sem saída alternativa
O segundo cenário acontece em workers que processam batches. O código abaixo tem um vazamento clássico que só aparece quando um dos jobs falha:
type Job struct {
ID string
URL string
}
type Result struct {
JobID string
Status int
Err error
}
func checkHealth(jobs []Job) {
results := make(chan Result)
for _, job := range jobs {
go func(j Job) {
resp, err := http.Get(j.URL)
if err != nil {
results <- Result{JobID: j.ID, Err: err}
return
}
resp.Body.Close()
results <- Result{JobID: j.ID, Status: resp.StatusCode}
}(job)
}
for i := 0; i < len(jobs); i++ {
r := <-results
if r.Err != nil {
log.Printf("job %s failed: %v", r.JobID, r.Err)
return
}
log.Printf("job %s: HTTP %d", r.JobID, r.Status)
}
}
Quando você retorna no meio do loop por causa de um erro, as goroutines que ainda não enviaram ficam bloqueadas em results <- Result{...} para sempre. O channel não tem buffer, não há mais ninguém lendo, e elas ficam lá até o processo morrer.
func checkHealth(ctx context.Context, jobs []Job) ([]Result, error) {
results := make(chan Result, len(jobs))
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(j Job) {
defer wg.Done()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.URL, nil)
if err != nil {
select {
case results <- Result{JobID: j.ID, Err: err}:
case <-ctx.Done():
}
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
select {
case results <- Result{JobID: j.ID, Err: err}:
case <-ctx.Done():
}
return
}
resp.Body.Close()
select {
case results <- Result{JobID: j.ID, Status: resp.StatusCode}:
case <-ctx.Done():
}
}(job)
}
go func() {
wg.Wait()
close(results)
}()
var out []Result
for r := range results {
if r.Err != nil {
return nil, fmt.Errorf("job %s: %w", r.JobID, r.Err)
}
out = append(out, r)
}
return out, nil
}
Buffer igual ao número de goroutines garante que nenhuma fica bloqueada no send mesmo que o consumer pare cedo. O select com ctx.Done() dá a saída alternativa caso o contexto seja cancelado no meio do processo.
http.Client sem timeout configurado
Esse é mais silencioso porque a maioria das pessoas acha que passar o ctx na requisição resolve tudo. Mas se o contexto nunca cancelar (exatamente o cenário do primeiro bug), o http.Client padrão vai esperar eternamente:
var httpClient = &http.Client{}
Qualquer chamada feita com esse client fica presa em net/http.(*Transport).roundTrip enquanto o servidor externo não responder, segurando a goroutine, o slot de connection pool e stack de memória.
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 8 * time.Second,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
},
}
O Timeout no client é o limite absoluto, independente do contexto. Os timeouts granulares no Transport permitem distinguir "servidor não aceitou a conexão" de "servidor aceitou mas nunca mandou o header de resposta", o que ajuda bastante a entender o ponto de falha nos logs.
Fechar esse loop antes de chegar em produção
A parte que mais me arrependei de não ter feito antes: goleak. É uma biblioteca da Uber que verifica se o seu teste deixou goroutines vazando.
go get go.uber.org/goleak
Com os tipos do exemplo anterior, o teste fica assim:
package worker_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestCheckHealth_DoesNotLeak(t *testing.T) {
defer goleak.VerifyNone(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
jobs := []Job{
{ID: "job-1", URL: srv.URL + "/health"},
{ID: "job-2", URL: srv.URL + "/health"},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results, err := checkHealth(ctx, jobs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != len(jobs) {
t.Fatalf("expected %d results, got %d", len(jobs), len(results))
}
}
O httptest.NewServer garante que o teste é completamente autocontido, sem depender de nenhum serviço externo. O output quando tem leak é preciso:
--- FAIL: TestCheckHealth_DoesNotLeak (5.01s)
leaks.go:78: found unexpected goroutines:
[Goroutine 18 in state chan receive, with worker.checkHealth.func1 on top of the stack:
goroutine 18 [chan receive]:
worker.checkHealth.func1()
/app/worker/health.go:47 +0x94
]
Você vê qual goroutine vazou, em qual estado e de onde foi criada. Isso é a diferença entre uma falha de CI às 10h da manhã e um OOMKilled às 2h47.
Resumo da ópera
Goroutine leak não é exótico. É o resultado direto de tratar goroutines como se não tivessem custo e de ignorar os contratos de cancelamento que o próprio Go estabelece. Qualquer goroutine que você abre sem uma saída clara é uma dívida técnica que vai aparecer no seu cartão de AWS mais cedo ou mais tarde.
pprof com ?debug=2 resolve o diagnóstico em produção. goleak fecha o loop no CI antes de você precisar do pprof.
Coloque o goleak agora, antes de precisar.