Truques para debugar memory leaks em aplicações Go com pprof

1. Introdução ao Memory Leak em Go e ao pprof

Memory leaks em Go são mais sutis do que em linguagens sem garbage collector. O garbage collector (GC) gerencia a memória automaticamente, mas não consegue liberar objetos que ainda possuem referências ativas. Os padrões mais comuns de vazamento incluem:

  • Goroutines órfãs: goroutines iniciadas mas que nunca finalizam, retendo memória e recursos
  • Slices e maps com capacidade excedida: estruturas que crescem sem limite e nunca são limpas
  • Referências persistentes: variáveis globais ou closures que mantêm referências a objetos que deveriam ser coletados
  • Canais sem consumidores: goroutines bloqueadas em operações de envio/recebimento

O pacote net/http/pprof expõe endpoints HTTP com perfis de runtime, e o comando go tool pprof permite analisar esses perfis de forma interativa. Juntos, formam a principal ferramenta para diagnosticar problemas de memória em Go.

2. Configurando o pprof na sua aplicação

Para habilitar o pprof, importe o pacote e registre os handlers HTTP:

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    // Inicia servidor HTTP com pprof em porta dedicada
    go func() {
        http.ListenAndServe(":6060", nil)
    }()

    // Sua aplicação continua normalmente
    // ...
}

Por padrão, o pprof expõe vários endpoints:
- /debug/pprof/heap — perfil de heap (objetos vivos)
- /debug/pprof/goroutine — stack traces de todas as goroutines
- /debug/pprof/allocs — histórico de alocações
- /debug/pprof/profile — perfil de CPU

Dica importante: nunca exponha o pprof em produção sem autenticação ou em redes públicas. Use middleware de autenticação ou escute apenas em localhost:

http.ListenAndServe("127.0.0.1:6060", nil)

3. Capturando e comparando snapshots de heap

Para capturar um snapshot do heap:

# Captura o perfil de heap atual
curl -s http://localhost:6060/debug/pprof/heap > heap_antes.pprof

# Após executar operações que podem causar vazamento
curl -s http://localhost:6060/debug/pprof/heap > heap_depois.pprof

Análise interativa com go tool pprof:

# Análise básica
go tool pprof heap_depois.pprof

# Comandos dentro do pprof interativo:
# top - mostra as funções que mais alocam
# list NomeFuncao - detalha alocações por linha
# web - gera gráfico SVG das chamadas

Truque essencial: comparar snapshots para ver o que cresceu:

go tool pprof -base heap_antes.pprof heap_depois.pprof

Isso mostra apenas as alocações que ocorreram entre os dois snapshots, destacando exatamente onde a memória está vazando.

4. Identificando padrões comuns de vazamento

Goroutines sem controle de cancelamento

// PROBLEMA: goroutine nunca finaliza
func processItems(items []Item) {
    for _, item := range items {
        go func(i Item) {
            process(i) // se process nunca retorna ou trava
        }(item)
    }
}

// SOLUÇÃO: usar context com timeout
func processItems(ctx context.Context, items []Item) {
    for _, item := range items {
        go func(i Item) {
            select {
            case <-ctx.Done():
                return
            default:
                process(i)
            }
        }(item)
    }
}

Slices e maps globais que crescem indefinidamente

var cacheGlobal map[string]*Dados

func addToCache(key string, dados *Dados) {
    cacheGlobal[key] = dados // nunca é limpo
}

// SOLUÇÃO: usar cache com TTL ou LRU

Closures que retêm variáveis

// PROBLEMA: closure retém referência ao slice
func criarProcessadores(items []Item) []func() {
    processadores := make([]func(), 0, len(items))
    for _, item := range items {
        processadores = append(processadores, func() {
            processar(item) // item é capturado por referência
        })
    }
    return processadores
}

5. Analisando alocações suspeitas com alocação detalhada

O profile de alocações (/debug/pprof/allocs) mostra o histórico de todas as alocações, não apenas os objetos vivos:

# Captura perfil de alocações
curl -s http://localhost:6060/debug/pprof/allocs > allocs.pprof

# Análise
go tool pprof allocs.pprof

Diferença crucial: o heap profile mostra objetos que ainda estão na memória (vivos), enquanto o allocs profile mostra todas as alocações já feitas (incluindo as já coletadas pelo GC).

Truque avançado com -diff_base:

# Captura baseline
curl -s http://localhost:6060/debug/pprof/allocs > baseline.pprof

# Executa operação suspeita
curl -s http://localhost:6060/debug/pprof/allocs > apos_operacao.pprof

# Compara
go tool pprof -diff_base baseline.pprof apos_operacao.pprof

6. Depurando vazamentos de goroutines

Para listar todas as goroutines com detalhes:

curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt

O modo debug=2 mostra o stack trace completo de cada goroutine, permitindo identificar:

  • Goroutines presas em select sem caso pronto
  • Goroutines bloqueadas em canais sem receptor
  • Goroutines em loops infinitos

Ferramenta complementar: monitore runtime.NumGoroutine() periodicamente:

go func() {
    ticker := time.NewTicker(30 * time.Second)
    for range ticker.C {
        log.Printf("Goroutines ativas: %d", runtime.NumGoroutine())
    }
}()

Um crescimento contínuo no número de goroutines (sem picos esperados) é sinal claro de vazamento.

7. Automatizando a detecção com scripts e integração contínua

Script para coleta automatizada:

#!/bin/bash
# collect_profiles.sh

HOST="http://localhost:6060"
OUTPUT_DIR="./profiles/$(date +%Y%m%d_%H%M%S)"

mkdir -p "$OUTPUT_DIR"

echo "Coletando perfis em $OUTPUT_DIR"

# Perfil de heap
curl -s "$HOST/debug/pprof/heap" > "$OUTPUT_DIR/heap.pprof"

# Perfil de alocações
curl -s "$HOST/debug/pprof/allocs" > "$OUTPUT_DIR/allocs.pprof"

# Goroutines detalhadas
curl -s "$HOST/debug/pprof/goroutine?debug=2" > "$OUTPUT_DIR/goroutines.txt"

# Número de goroutines
echo "Goroutines: $(curl -s "$HOST/debug/pprof/goroutine?debug=1" | wc -l)" > "$OUTPUT_DIR/status.txt"

echo "Coleta concluída"

Integração com testes de carga usando vegeta:

# Simula carga enquanto coleta perfis
echo "GET http://localhost:8080/api/endpoint" | vegeta attack -rate=100 -duration=30s | vegeta report

# Coleta perfil após carga
curl -s http://localhost:6060/debug/pprof/heap > heap_pos_carga.pprof

Para CI/CD, crie um script que compara perfis e falha se detectar crescimento anormal:

#!/bin/bash
# check_memory_leak.sh

BASELINE="baseline.pprof"
CURRENT="current.pprof"

go tool pprof -top -diff_base=$BASELINE $CURRENT | grep -E "^[[:space:]]*[0-9]+\.[0-9]+%" | head -5

# Se alguma função mostrou crescimento > 10%, falha
if go tool pprof -top -diff_base=$BASELINE $CURRENT | awk '{if ($1 > 10) exit 1}'; then
    echo "Memory leak detectado!"
    exit 1
fi

Conclusão

Debuggar memory leaks em Go requer uma combinação de ferramentas e boas práticas. O pprof é seu principal aliado, mas é preciso saber interpretar os dados que ele fornece. Lembre-se:

  • Sempre compare snapshots (antes/depois) para isolar vazamentos
  • Monitore goroutines ativas como indicador precoce
  • Automatize a coleta de perfis em pipelines de CI
  • Use contextos e canais com cuidado para evitar goroutines órfãs

Com essas técnicas, você conseguirá identificar e corrigir memory leaks de forma sistemática, mantendo suas aplicações Go eficientes e estáveis.

Referências