Debuggar memory leak em Ruby sem chorar
Ruby tem GC. Então leak clássico não existe. O que existe é bloat — e a diferença entre os dois é o que separa o senior do junior nesse assunto.
Debuggar memory leak em Ruby sem chorar
Sintomas clássicos:
- aplicação começa em 200MB
- 1h depois, 800MB
- 6h depois, 2GB
- alguém reinicia o processo
- ciclo se repete
Você não tem leak.
Você tem memory bloat.
E são coisas diferentes.
Leak ≠ Bloat
Leak: você está alocando memória que nunca é liberada porque algo está segurando referência indevida.
Bloat: você aloca demais (em pico ou continuamente) e o Ruby não devolve essa memória pro SO mesmo depois do GC.
Ruby tem GC, então leak clássico (estilo C) não existe.
O que existe é:
- objeto retido sem você perceber
- glibc que segura memória em arenas
- fragmentação que impede GC compactar
Saber diferenciar é o primeiro passo.
Ferramentas que valem aprender
GC.stat
GC.stat
# => {:count=>10, :heap_allocated_pages=>200, :total_allocated_objects=>1_000_000, ...}
Te dá o panorama: quantos GC rodaram, quantos objetos alocados, quantos slots livres.
Olhe em intervalos. Compare.
ObjectSpace
ObjectSpace.each_object(String).count
# => 187_432
Conta objetos vivos de uma classe específica.
Cresce sem parar entre requests? Cheirou leak.
memory_profiler
Gem que mostra alocação por arquivo/linha:
require 'memory_profiler'
report = MemoryProfiler.report do
Rails.application.call(env)
end
report.pretty_print
Mostra exatamente onde sua aplicação está alocando.
derailed_benchmarks
Pra Rails. Roda um cenário e te diz quanto cresceu.
$ bundle exec derailed exec perf:mem_over_time
Gráfico de consumo ao longo do tempo. Você vê se estabiliza ou só sobe.
Padrão 1: cache que cresce sem parar
class Service
CACHE = {}
def self.fetch(key)
CACHE[key] ||= expensive_compute(key)
end
end
Cada key nova adiciona um entry.
CACHE é constante. Vive enquanto o processo vive.
Memória sobe pra sempre.
Solução: LRU, TTL, ou simplesmente Rails.cache (que tem política de evicção).
Padrão 2: Active Record carregando demais
User.all.each do |user|
process(user)
end
100k usuários = 100k objetos AR carregados em memória.
Use find_each:
User.find_each(batch_size: 1000) do |user|
process(user)
end
Processa em batch. Libera entre eles.
E pra report agregado, geralmente pluck resolve:
User.pluck(:email) # só array de strings
ao invés de carregar AR completo só pra pegar uma coluna.
Padrão 3: closure segurando coisa demais
def setup
large_data = load_huge_file
background_job = ->{ process(large_data) }
Thread.new(&background_job)
end
A lambda captura large_data.
A thread vive enquanto a app vive.
large_data nunca é coletado.
Solução: passa só o que precisa. Evita capturas grandes em closures de longa vida.
Padrão 4: glibc malloc
Ruby usa malloc por padrão. O malloc do glibc tem comportamento ruim: ele cria arenas por thread e raramente devolve memória pro SO.
Resultado: você vê processo Ruby com 2GB, mesmo depois do GC.
Solução popular: jemalloc ou MALLOC_ARENA_MAX=2.
# rodando com jemalloc
LD_PRELOAD=libjemalloc.so ruby app.rb
Em produção real, muita gente reportou cair 30-50% de RSS só trocando o allocator.
Não resolve leak. Mas resolve bloat.
Passo a passo de investigação
- Confirma que é leak/bloat. RSS cresce monotonicamente? Estabiliza eventualmente?
- Snapshot inicial:
GC.stat[:total_allocated_objects]. - Reproduz o cenário (request, job, loop).
- Snapshot final. O que cresceu?
- Profile alocações com
memory_profilerno mesmo cenário. - Identifica suspeito: cache, closure, constante, gem famosa por leak.
- Mede de novo após corrigir. Sem medição, suposição.
A regra de ouro: se não mediu, não corrigiu.
Heap dumps
Pra casos sérios, dump da heap:
require 'objspace'
ObjectSpace.trace_object_allocations_start
# ... reproduz cenário ...
GC.start
File.open("heap.json", "w") { |f| ObjectSpace.dump_all(output: f) }
Depois você analisa com ferramentas tipo heapy ou scripts próprios.
Permite achar:
- objeto vivo que deveria ter morrido
- quem está segurando referência
- linha exata de alocação
É o último recurso. Mas é o que resolve casos impossíveis.
Os suspeitos famosos
- Sidekiq + middleware mal escrito (closure retida)
- Rails cache fragments crescendo demais
- gem que faz
evalem runtime acumulando bytecode - background thread que nunca termina
- log de erro com objetos serializados gigantes
- views renderizando árvore enorme de partials
Você não vai memorizar. Mas vai reconhecer com tempo.
A grande virada de chave
Debug de memória em Ruby não é tentativa e erro.
É processo:
- Medir
- Reproduzir
- Hipotetizar
- Profilar
- Corrigir
- Medir de novo
Quem pula etapa adivinha.
Adivinhação debugando memória custa caro — você reinicia o processo, finge que resolveu, e o problema volta.
Conclusão
Memory leak em Ruby moderno é raríssimo.
Memory bloat é cotidiano.
A diferença entre os dois é a primeira coisa que separa o senior do junior nesse assunto.
E quem aprende a investigar memória aprende, junto, a respeitar o GC.
Que faz um trabalho impressionante. Silencioso. Constante.
Até o dia que você decide brigar com ele.
