Voltar para artigos

Goroutine Leak: como seu serviço Go vai crescendo de memória até morrer

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.