35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Memory Leaks Debuggen in Ruby on Rails: Een Praktische Productiegids

Memory Leaks Debuggen in Ruby on Rails: Een Praktische Productiegids

TTB Software
ruby-on-rails

Memory leaks in Ruby on Rails-applicaties komen bijna nooit door daadwerkelijke C-extensie leaks. In acht jaar Rails in productie heb ik misschien twee problemen herleid tot echte memory leaks in native code. De rest? Onbegrensde groei — hashes die nooit opgeschoond worden, strings die per ongeluk vastgehouden worden, callbacks die sneller referenties ophopen dan de GC ze kan opruimen.

Het verschil is belangrijk omdat de aanpak verschilt. Een echt lek vereist een gem-update of patch. Onbegrensde groei vereist dat je de code vindt die accumuleert en er een limiet op zet.

De Symptomen Herkennen

Je Rails-app heeft een geheugenprobleem wanneer worker RSS monotoon groeit over requests heen. Gezonde Ruby-processen stabiliseren na een opwarmperiode — meestal 50-200 requests afhankelijk van de complexiteit van je app. De GC ruimt objecten op, RSS vlakt af, en alles draait door.

Wanneer RSS blijft stijgen zonder te stabiliseren, heb je onbegrensde groei. Een snelle diagnose:

# Bekijk Puma worker RSS over tijd (Linux)
while true; do
  ps -o pid,rss,command -p $(pgrep -f 'puma.*worker') | tail -n +2
  sleep 30
done

Als RSS 10-50 MB per uur stijgt bij stabiel verkeer, is dat je signaal. Alles onder 5 MB/uur kan gewoon fragmentatie zijn.

Stap 1: Meet Voordat Je Zoekt

Installeer derailed_benchmarks (gem versie 2.2+, Ruby 3.2+):

# Gemfile
group :development, :test do
  gem 'derailed_benchmarks'
  gem 'stackprof'
end

Voer eerst de statische geheugenanalyse uit — dit vangt de makkelijke winsten:

bundle exec derailed bundle:mem

Dit toont geheugenverbruik bij het opstarten per gem. Ik heb apps gezien waar één ongebruikte gem 40 MB aan dependencies binnentrok. Bij een recent Rails 8-project daalde het bootgeheugen met 28 MB over 4 Puma workers door mini_magick te verwijderen (vervangen door ActiveStorage’s ingebouwde verwerking).

Voor analyse op request-niveau:

bundle exec derailed exec perf:mem_over_time

Dit stuurt herhaaldelijk requests naar je app en volgt geheugengroei. Een vlakke lijn betekent geen lek. Een stijgende lijn zegt: verder graven.

Stap 2: Heap Dumps met ObjectSpace

Ruby’s ObjectSpace-module is je primaire onderzoekstool. Schakel heap dump-ondersteuning in:

# config/initializers/memory_debug.rb (tijdelijk — verwijder na onderzoek)
if ENV['MEMORY_DEBUG']
  require 'objspace'
  ObjectSpace.trace_object_allocations_start
end

Maak een heap dump van een draaiend proces:

# Via rails console gekoppeld aan een productie-worker, of via een debug-endpoint
GC.start(full_mark: true, immediate_sweep: true)
GC.start # Twee keer uitvoeren om weak references op te ruimen

file = "/tmp/heap_dump_#{Process.pid}_#{Time.now.to_i}.json"
ObjectSpace.dump_all(output: File.open(file, 'w'))
puts "Heap dump geschreven naar #{file} (#{File.size(file) / 1024 / 1024} MB)"

De dump is een JSON-lines bestand waarin elke regel één levend Ruby-object representeert. De velden die ertoe doen:

  • type: Object type (STRING, HASH, ARRAY, OBJECT, etc.)
  • file: Bronbestand waar het object gealloceerd werd
  • line: Regelnummer
  • memsize: Geheugenverbruik in bytes
  • generation: GC-generatie bij allocatie (lager = ouder = verdachter)

Stap 3: Analyseer de Heap Dump

Parseer de dump om accumulatiepatronen te vinden:

# analyze_heap.rb
require 'json'

counts = Hash.new(0)
sizes = Hash.new(0)
locations = Hash.new(0)

File.foreach(ARGV[0]) do |line|
  obj = JSON.parse(line)
  type = obj['type']
  counts[type] += 1
  sizes[type] += obj['memsize'].to_i

  if obj['file']
    loc = "#{obj['file']}:#{obj['line']}"
    locations[loc] += 1
  end
end

puts "=== Object counts per type ==="
counts.sort_by { |_, v| -v }.first(10).each { |k, v| puts "  #{k}: #{v}" }

puts "\n=== Geheugen per type (MB) ==="
sizes.sort_by { |_, v| -v }.first(10).each { |k, v| puts "  #{k}: #{(v / 1024.0 / 1024).round(2)} MB" }

puts "\n=== Top allocatielocaties ==="
locations.sort_by { |_, v| -v }.first(20).each { |k, v| puts "  #{k}: #{v} objecten" }

De “Top allocatielocaties” output is waar het onderzoek echt begint. Als je 500.000 strings ziet gealloceerd vanaf één regel in je codebase, heb je de schuldige gevonden.

De Gebruikelijke Verdachten

Na het analyseren van tientallen Rails-geheugenproblemen bij klantprojecten zijn deze patronen goed voor zo’n 80% van de gevallen:

Onbegrensde Memoization

# Het klassieke lek
class ProductService
  def self.lookup(sku)
    @cache ||= {}
    @cache[sku] ||= Product.find_by(sku: sku)
  end
end

Deze class-level hash groeit met elke unieke SKU en krimpt nooit. Met 100.000 producten houd je 100.000 ActiveRecord-objecten permanent in het geheugen.

Oplossing: Gebruik Rails.cache met TTL, of een LRU-cache zoals lru_redux:

class ProductService
  @cache = LruRedux::TTL::ThreadSafeCache.new(1000, 15 * 60) # 1000 items, 15 min TTL

  def self.lookup(sku)
    @cache.getset(sku) { Product.find_by(sku: sku) }
  end
end

ActiveRecord Callback Accumulatie

Wanneer je bulkoperaties combineert met callbacks die referenties vasthouden, worden duizenden records tegelijk in het geheugen vastgehouden. Gebruik find_each met kleinere batches of omzeil callbacks:

Order.where(status: :pending).find_each(batch_size: 100) do |order|
  WarehouseNotifier.perform_later(order.id) # Geef ID door, niet het object
end

String-retentie door Logging

Rails.logger.info "Processing order #{order.inspect} with items #{order.items.map(&:inspect)}"

inspect op ActiveRecord-objecten genereert enorme strings. In productie met per ongeluk ingeschakeld debug-level logging heb ik dit 2 GB zien verbruiken in minder dan een uur.

Oplossing: Gebruik gestructureerde logging en lazy evaluatie:

Rails.logger.info { "Processing order #{order.id} with #{order.items.count} items" }

De blokvorm betekent dat de string nooit opgebouwd wordt als het logniveau het filtert.

Stap 4: Bevestig de Fix

Verifieer na het toepassen van een fix met een gecontroleerde test. Voor productiebevestiging volg je RSS per worker met een Prometheus-metric en bekijk je de grafiek 24-48 uur onder productieverkeer. RSS moet stabiliseren na opwarming.

Wanneer Het Echt een Native Lek Is

Als heap dumps stabiele Ruby object counts tonen maar RSS blijft groeien, zit het lek in een C-extensie. Controleer gem changelogs voor bekende memory fixes — nokogiri, mysql2 en image processing gems zijn veelvoorkomende boosdoeners. In mijn ervaring lost het upgraden van nokogiri ongeveer de helft van alle native geheugenproblemen in Rails-apps op.

Productieveilige Geheugenmonitoring

Configureer Puma’s worker killer als vangnet terwijl je onderzoekt:

# config/puma.rb
plugin :tmp_restart

before_fork do
  require 'puma_worker_killer'
  PumaWorkerKiller.config do |config|
    config.ram = 2048 # MB totaal voor alle workers
    config.frequency = 30
    config.percent_usage = 0.90
    config.rolling_restart_frequency = 6 * 3600 # Rolling restart elke 6 uur
  end
  PumaWorkerKiller.start
end

Veelgestelde Vragen

Hoeveel geheugen zou een Rails 8-app moeten gebruiken per Puma worker?

Een typische Rails 8-app gebruikt 150-300 MB per Puma worker na opwarming, afhankelijk van het aantal gems en de complexiteit. Apps met zware beeldverwerking of grote ActiveRecord-resultsets kunnen 500 MB+ bereiken. Als een enkele worker boven 1 GB komt bij normaal verkeer, heb je waarschijnlijk ergens onbegrensde groei.

Veroorzaakt Ruby’s garbage collector memory bloat?

Ruby’s GC beheert objectgeheugen goed, maar kan de proces-heap niet verkleinen. Eenmaal aangevraagd geheugen via malloc blijft permanent voor de levensduur van het proces. Dit is fragmentatie, geen lek. Jemalloc als malloc-vervanging vermindert fragmentatie met 10-30% in de meeste Rails-apps.

Kan ik ObjectSpace.dump_all veilig in productie gebruiken?

Ja, met kanttekeningen. De dump pauzeert het Ruby-proces 1-10 seconden afhankelijk van heap-grootte. Voer het uit op één worker tijdens rustig verkeer. De trace_object_allocations_start-aanroep voegt 5-10% overhead toe — schakel het tijdelijk in en weer uit na het verzamelen van je dump.

Wat is het verschil tussen RSS-groei en een memory leak?

RSS-groei na opwarming kan drie dingen betekenen: een echt C-extensie lek, onbegrensde Ruby-objectaccumulatie, of geheugenfragmentatie. Controleer eerst Ruby heap object counts — als die stabiel zijn maar RSS groeit, is het fragmentatie (probeer jemalloc) of een native lek. Als object counts proportioneel groeien met RSS, heb je Ruby-level accumulatie.

Moet ik puma_worker_killer gebruiken of gewoon meer server-RAM toevoegen?

Gebruik beide, maar behandel puma_worker_killer als vangnet. RAM toevoegen maskeert het probleem en kosten schalen lineair. Worker killers houden je app stabiel terwijl je de oorzaak oplost.

#ruby #rails #memory-leaks #debugging #productie #performance #objectspace #derailed_benchmarks
T

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation