Debugging memory leaks en Ruby sin perder la cabeza
TL;DR
derailed_benchmarkspara medir baseline de memoria al arrancar y bajo cargamemory_profilerpara análisis por request — te dice exactamente qué está allocando- ObjectSpace para snapshots en producción sin reiniciar el proceso
- Los sospechosos habituales: memoización en class-level, string mutation, AR result sets grandes sin scope
- La estrategia: medir primero, siempre. No adivines dónde está el leak.
El problema que llega en silencio
Lanzas tu app Rails. Todo funciona. Un mes después, tus pods de Kubernetes están usando 800MB cada uno cuando antes usaban 300MB. Dos meses después, el proceso se reinicia solo cada 6 horas — y nadie lo nota porque el reinicio automático “lo arregla”. Bienvenido al mundo de los memory leaks en Ruby, donde el síntoma desaparece solo el tiempo suficiente para que lo ignores.
El punto donde la mayoría se rinde: abren el profiler, ven “ActiveRecord” como el culpable número uno, se encojen de hombros y aumentan el límite de memoria del pod. Solución válida. Solución cara. Solución que vuelve a ser insuficiente en dos meses.
La buena noticia: Ruby tiene herramientas decentes para encontrarlos. La mala: requieren que las uses en orden, no al azar. Vamos a ver el toolkit de mayor a menor granularidad.
Paso 1: Establece un baseline con derailed_benchmarks
Antes de buscar el leak, necesitas saber cuánta memoria usa tu app normalmente. derailed_benchmarks te da eso sin complicaciones:
# Gemfile (solo en development/test)
gem 'derailed_benchmarks', group: :development
bundle install
# Cuánta memoria usa tu app al arrancar (sin requests)
bundle exec derailed bundle:mem
# Cuánta memoria usa al cargar todas las rutas
bundle exec derailed bundle:objects
# Cuánta memoria usa bajo carga real (simula requests)
bundle exec derailed perf:mem
El output de bundle:mem te muestra qué gemas contribuyen más al footprint inicial:
TOP: 54.4492 MiB
mail: 7.4297 MiB
active_record: 4.2891 MiB
action_view: 3.8203 MiB
sprockets: 3.1289 MiB
Si mail está usando 7MB solo por estar cargada, considera cargarla lazy. Pero más importante: tienes un número de referencia. Si mañana la memoria base sube 20MB sin que hayas agregado gemas, algo cambió.
Paso 2: Analiza por request con memory_profiler
Una vez que sabes que hay un leak real (la memoria crece con cada request y no baja), memory_profiler te dice exactamente dónde:
# Gemfile
gem 'memory_profiler', group: :development
# En un script de diagnóstico, o en un endpoint temporal
require 'memory_profiler'
report = MemoryProfiler.report do
# El código sospechoso — puede ser una action, un servicio, lo que sea
result = ProductCatalog.all_with_prices
end
report.pretty_print
El output se ve así:
Total allocated: 45.23 MB (423891 objects)
Total retained: 12.87 MB (98234 objects)
allocated memory by gem
-----------------------------------
34567234 activerecord
8234123 activesupport
1234567 your_app/app/services
allocated objects by location
-----------------------------------
98234 /app/models/product.rb:47
34521 /app/services/catalog_service.rb:23
La diferencia clave: allocated es todo lo que se creó. retained es lo que sigue en memoria después del GC. Si retained es alto, tienes un leak real. Si allocated es alto pero retained es bajo, tienes ineficiencia (muchos objetos temporales), que es diferente.
Paso 3: ObjectSpace para snapshots en producción
A veces el leak solo aparece en producción, no en development. Para esos casos, ObjectSpace te permite tomar snapshots sin reiniciar:
# config/routes.rb (protegido con autenticación básica en producción)
if Rails.env.production? && ENV['ENABLE_MEMORY_DEBUG']
namespace :debug do
get 'memory_snapshot', to: 'memory#snapshot'
end
end
# app/controllers/debug/memory_controller.rb
class Debug::MemoryController < ApplicationController
before_action :require_admin
def snapshot
require 'objspace'
ObjectSpace.trace_object_allocations_start
GC.start
snapshot = {}
ObjectSpace.each_object do |obj|
klass = obj.class.name rescue 'Unknown'
snapshot[klass] ||= 0
snapshot[klass] += 1
end
render json: snapshot.sort_by { |_, v| -v }.first(50).to_h
end
end
// Output típico que indica un problema
{
"String": 2847392,
"Array": 394823,
"Hash": 189234,
"Product": 48392, // ← ¿48K instancias de Product? Eso no debería pasar
"ActionView::OutputBuffer": 23841
}
Si ves 48K instancias de Product cuando tienes 1000 productos en tu catálogo, hay algo reteniendo esos objetos. Hora de buscar.
Los sospechosos de siempre — y el que nadie quiere admitir que escribió
Memoización en class-level objects
Este es el más común. Y casi siempre lo escribiste tú mismo con la mejor intención del mundo. Memoizar en una instancia es fine. Memoizar en un class-level object es un leak garantizado:
# MAL — este hash vive para siempre en la clase
class ProductCache
def self.cache
@cache ||= {} # Este hash nunca se limpia
end
def self.find(id)
cache[id] ||= Product.find(id) # Cada producto que busques queda aquí para siempre
end
end
# BIEN — con expiración
class ProductCache
def self.find(id)
Rails.cache.fetch("product/#{id}", expires_in: 1.hour) do
Product.find(id)
end
end
end
ActiveRecord result sets sin scope
# MAL — carga todos los usuarios en memoria
def send_newsletter
User.all.each do |user| # Si tienes 500K usuarios, tienes 500K objetos en RAM
NewsletterMailer.weekly(user).deliver_later
end
end
# BIEN — batches de 1000
def send_newsletter
User.find_each(batch_size: 1000) do |user|
NewsletterMailer.weekly(user).deliver_later
end
end
String mutation con <<
# Puede generar strings compartidos de formas inesperadas
GREETING = "Hola"
def build_message(name)
msg = GREETING # msg y GREETING apuntan al mismo objeto
msg << ", #{name}!" # Esto muta GREETING también!
msg
end
build_message("Alice") # => "Hola, Alice!"
GREETING # => "Hola, Alice!" — oops
# FIX: usa + o dup
def build_message(name)
"#{GREETING}, #{name}!" # Nuevo string, sin mutación
end
Observers y callbacks que acumulan
# Si registras callbacks en cada request sin limpiarlos:
class ReportJob
def perform
# MAL: cada vez que el job corre, agrega otro callback al array
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
@queries ||= []
@queries << args
end
run_report
# Los callbacks nunca se desregistran
end
end
El flujo de debugging que funciona
1. derailed_benchmarks → ¿hay un leak? ¿cuánto crece?
↓
2. memory_profiler → ¿qué se está allocando?
↓
3. ObjectSpace → ¿qué objetos se están reteniendo?
↓
4. Bisect → comenta código sospechoso hasta aislar el problema
↓
5. Fix → aplica el fix, mide de nuevo para confirmar
No saltes al paso 4 sin los primeros tres. Adivinar qué está causando un memory leak sin datos es como operar a ciegas — puedes remover cosas que no tienen nada que ver y el leak sigue ahí.
Antes vs después: ejemplo real
Un endpoint de reportes en un proyecto que pasaba de 280MB a 920MB después de 50 requests:
# Antes — el leak
def monthly_report
orders = Order.where(month: params[:month]).includes(:items, :customer)
# orders tiene potencialmente 50K registros, todos en RAM
@summary = {
total: orders.sum(&:total),
count: orders.count,
customers: orders.map(&:customer).uniq
}
end
# Después — sin leak
def monthly_report
orders = Order.where(month: params[:month])
@summary = {
total: orders.sum(:total), # Query SQL, no Ruby
count: orders.count, # Query SQL, no Ruby
customers: orders.joins(:customer)
.distinct
.pluck('customers.id', 'customers.name')
}
end
Resultado: de 920MB a 310MB. La diferencia fue dejar que la base de datos hiciera el trabajo en lugar de cargar 50K objetos en Ruby para hacer math con .sum(&:total).
Los memory leaks en Ruby no son misteriosos — son patrones predecibles que se repiten. Mide primero, usa las herramientas correctas en orden, y no te rindas a la primera que el profiler señale una gema de terceros. Más del 80% de las veces el problema está en tu código.
Y si después de todo esto el leak sigue ahí: revisa los callbacks. Siempre es un callback que alguien registró dos veces.