githublinkedinemail
← /articles

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.

4 minperformance

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

  1. Confirma que é leak/bloat. RSS cresce monotonicamente? Estabiliza eventualmente?
  2. Snapshot inicial: GC.stat[:total_allocated_objects].
  3. Reproduz o cenário (request, job, loop).
  4. Snapshot final. O que cresceu?
  5. Profile alocações com memory_profiler no mesmo cenário.
  6. Identifica suspeito: cache, closure, constante, gem famosa por leak.
  7. 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 eval em 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:

  1. Medir
  2. Reproduzir
  3. Hipotetizar
  4. Profilar
  5. Corrigir
  6. 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.


Debuggar memory leak em Ruby sem chorar
Debuggar memory leak em Ruby sem chorar