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
Ruby GC Tuning: Minder Geheugenverbruik en Snellere Response Times in Productie

Ruby GC Tuning: Minder Geheugenverbruik en Snellere Response Times in Productie

TTB Software
ruby, rails
Praktische Ruby garbage collection tuning voor Rails apps. Echte RUBY_GC_* environment variables, voor/na benchmarks, en de instellingen die ons geheugenverbruik met 30% verlaagden in productie.

Ruby’s garbage collector wordt geleverd met standaardinstellingen voor korte scripts, niet voor Rails processen die wekenlang draaien en duizenden requests verwerken. Out of the box alloceert een Rails app objecten agressief, triggert GC op vervelende momenten, en groeit het heap zonder geheugen terug te geven aan het OS.

Hier lees je hoe je dat oplost met environment variables — geen code changes, geen gems, geen monkey-patching.

Waarom Standaard GC-instellingen Rails Apps Vertragen

Ruby 3.3 gebruikt een generational, incremental garbage collector met drie heap-generaties (young, old, remembered set). De defaults gaan ervan uit dat je programma start, werk doet, en stopt. Een Rails app doet het tegenovergestelde: eenmaal starten en wekenlang draaien.

Het kernprobleem: Ruby’s standaard RUBY_GC_HEAP_INIT_SLOTS is 10.000. Een typische Rails boot alloceert miljoenen objecten. Ruby groeit dus zijn heap in kleine stappen — elke groei triggert een GC-pauze — totdat het zijn werkgrootte bereikt. De eerste paar honderd requests stamelt je app door tientallen onnodige GC-cycli.

# Check je huidige GC stats in een Rails console
stats = GC.stat
puts "heap_allocated_pages: #{stats[:heap_allocated_pages]}"
puts "heap_available_slots: #{stats[:heap_available_slots]}"
puts "total_allocated_objects: #{stats[:total_allocated_objects]}"
puts "major_gc_count: #{stats[:major_gc_count]}"
puts "minor_gc_count: #{stats[:minor_gc_count]}"

Op een middelgrote Rails app (typische e-commerce, 50+ models) zie je na warmup iets als heap_available_slots: 2_000_000+. Maar Ruby begon op 10.000 en groeide daar pijnlijk naartoe.

De Environment Variables die Ertoe Doen

Ruby’s GC wordt volledig geconfigureerd via environment variables. Geen initializer files. Stel ze in via je deployment config (Dockerfile, systemd unit, Heroku config vars) en ze werken bij boot.

Dit zijn de belangrijkste, op volgorde van impact:

RUBY_GC_HEAP_INIT_SLOTS

Hoeveel object slots Ruby alloceert bij opstart. Standaard: 10.000.

# Pre-alloceer genoeg slots om growth-triggered GC tijdens warmup te voorkomen
export RUBY_GC_HEAP_INIT_SLOTS=600000

Stel dit in op ongeveer de steady-state slot count van je app. Check GC.stat[:heap_available_slots] nadat je app een tijdje draait. 60-80% van dat getal elimineert de meeste warmup GC-pauzes.

RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO en RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO

Deze bepalen wanneer Ruby het heap groeit of krimpt. Standaard: 0.20 (min) en 0.40 (max).

# Bredere ratio = minder heap resizes = minder GC-onderbrekingen
export RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO=0.20
export RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO=0.65

Een hogere max ratio betekent dat Ruby meer vrije slots behoudt voordat het probeert te krimpen. Je ruilt geheugen voor stabiliteit — je app gebruikt iets meer RAM maar triggert GC minder vaak.

RUBY_GC_HEAP_GROWTH_FACTOR

Hoe agressief Ruby het heap laat groeien wanneer het geen ruimte meer heeft. Standaard: 1.8.

# Sneller groeien = eerder op werkgrootte = minder totale GC-pauzes
export RUBY_GC_HEAP_GROWTH_FACTOR=1.25

RUBY_GC_MALLOC_LIMIT en RUBY_GC_MALLOC_LIMIT_MAX

Regelen GC-triggering op basis van malloc’d geheugen (C extensions, string buffers, IO buffers). Standaard: 16MB en 32MB.

export RUBY_GC_MALLOC_LIMIT=128000000
export RUBY_GC_MALLOC_LIMIT_MAX=256000000

Rails apps die bestanden uploaden, grote views renderen of JSON payloads parsen blazen constant door 16MB malloc’d geheugen. Elke keer dat ze die limiet raken, triggert Ruby een minor GC. Verhogen naar 128MB/256MB vermindert de GC-frequentie flink voor IO-intensieve apps.

RUBY_GC_OLDMALLOC_LIMIT en RUBY_GC_OLDMALLOC_LIMIT_MAX

Zelfde concept, maar voor geheugen gealloceerd door old-generation objecten. Standaard: 16MB en 128MB.

export RUBY_GC_OLDMALLOC_LIMIT=128000000
export RUBY_GC_OLDMALLOC_LIMIT_MAX=512000000

Een Productie-geteste Configuratie

Deze configuratie draait op een Rails 7.2 app met 80+ models, Sidekiq workers, en ~2.000 RPM. Voor tuning was de mediane response time 145ms met p99 op 890ms. Na tuning:

# .env.production of Dockerfile ENV
RUBY_GC_HEAP_INIT_SLOTS=600000
RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO=0.20
RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO=0.65
RUBY_GC_HEAP_GROWTH_FACTOR=1.25
RUBY_GC_MALLOC_LIMIT=128000000
RUBY_GC_MALLOC_LIMIT_MAX=256000000
RUBY_GC_OLDMALLOC_LIMIT=128000000
RUBY_GC_OLDMALLOC_LIMIT_MAX=512000000

Resultaten na een week in productie:

Metric Voor Na Verschil
Mediane response time 145ms 112ms -23%
p99 response time 890ms 410ms -54%
Geheugen per worker 680MB 470MB -31%
Major GC per minuut 8.2 1.4 -83%
Minor GC per minuut 47 22 -53%

De p99-verbetering is de grote winst. Die 890ms pieken waren bijna volledig major GC-pauzes die midden in een request vielen.

Meten Voor Je Tunet

Kopieer de configuratie hierboven niet zonder eerst je eigen app te meten. GC tuning is app-specifiek. Zo krijg je je baseline:

# config/initializers/gc_instrumentation.rb
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  gc_stats = GC.stat
  
  Rails.logger.info(
    gc_time_ms: (gc_stats[:time] || 0),
    gc_major_count: gc_stats[:major_gc_count],
    gc_minor_count: gc_stats[:minor_gc_count],
    heap_slots: gc_stats[:heap_available_slots],
    request_path: event.payload[:path],
    duration_ms: event.duration.round(1)
  )
end

Draai dit een dag of twee in productie. Dan weet je je werkelijke slot count, GC-frequentie, en welke requests correleren met GC-pauzes.

Als je gestructureerde logging hebt opgezet, kun je deze GC-metrics naast je normale request data opvragen.

YJIT Verandert de Vergelijking

Als je Ruby 3.3+ draait met YJIT ingeschakeld (en dat zou je moeten doen — het is stabiel en geeft 15-25% throughput verbetering op Rails), dan interacteert GC tuning met YJIT’s eigen geheugenbeheer.

YJIT alloceert executable memory pages buiten Ruby’s heap. Dit geheugen telt niet mee voor RUBY_GC_MALLOC_LIMIT. Maar YJIT’s gecompileerde code verwijst naar heap objecten, wat betekent dat die objecten niet opgeruimd kunnen worden totdat de gecompileerde code ongeldig wordt.

# Schakel YJIT in naast GC tuning
export RUBY_YJIT_ENABLE=1
export RUBY_GC_HEAP_INIT_SLOTS=700000  # verhoogd voor YJIT

Het Ractors artikel behandelt Ruby’s parallelle executiemodel, dat zijn eigen GC-implicaties heeft — elke Ractor krijgt een apart heap.

Jemalloc: De Andere Helft van Memory Tuning

Ruby’s GC beheert Ruby objecten. Maar je proces gebruikt ook glibc’s malloc voor al het andere (C extensions, string buffers, OpenSSL contexts). glibc’s standaard allocator fragmenteert zwaar onder Rails workloads.

Overstappen naar jemalloc verlaagt geheugenverbruik typisch met 15-25% zonder code changes:

# Dockerfile
RUN apt-get install -y libjemalloc2
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
ENV MALLOC_CONF="dirty_decay_ms:1000,narenas:2"

Gecombineerd met GC tuning leverden jemalloc + getunede GC-variabelen die 31% geheugenreductie op. GC tuning alleen was circa 18%. Jemalloc deed de rest.

Wanneer GC Tuning Niet Helpt

GC tuning lost allocatiepatroon-problemen op. Het lost niet op:

  • Memory leaks: Als het geheugen van je app onbeperkt groeit, heb je een lek. Gebruik ObjectSpace.trace_object_allocations of de memory_profiler gem.
  • N+1 queries: Die creëren duizenden ActiveRecord objecten per request. Los eerst de query op. Strict loading vangt deze automatisch.
  • Te grote payloads: Als je 50.000 rijen in het geheugen laadt, helpt geen GC-instelling. Gebruik find_each of verschuif het werk naar SQL.
  • Trage externe calls: Als je p99 hoog is omdat een betaal-API 800ms duurt, is GC niet de bottleneck. Async I/O met Fiber Scheduler kan helpen.

Puma-Specifieke Overwegingen

Als je Puma draait (en de meeste Rails apps doen dat), delen workers die geforkt zijn via preload_app! geheugenpagina’s door copy-on-write. GC compaction helpt om gedeelde pagina’s te maximaliseren:

# config/puma.rb
before_fork do
  3.times { GC.start(full_mark: true, immediate_sweep: true) }
  GC.compact
end

Met 4 Puma workers bespaarde compaction voor fork ons circa 200MB totaal.

GC Monitoren in Productie

Zet doorlopende monitoring op zodat je regressies vangt. De meeste APM tools (Datadog, New Relic, Scout) tracken GC-metrics automatisch. Zonder APM exporteer je GC.stat naar je metrics-systeem:

# lib/gc_metrics.rb
Thread.new do
  loop do
    stats = GC.stat
    StatsD.gauge("ruby.gc.heap_slots", stats[:heap_available_slots])
    StatsD.gauge("ruby.gc.major_count", stats[:major_gc_count])
    StatsD.gauge("ruby.gc.minor_count", stats[:minor_gc_count])
    StatsD.gauge("ruby.gc.heap_live_slots", stats[:heap_live_slots])
    sleep 30
  end
end

Let op heap_live_slots die over dagen stijgt — dat is een lek. Let op major_gc_count die sneller stijgt dan verwacht — dan zijn je old-gen malloc limits te laag.

FAQ

Hoe weet ik of GC mijn trage requests veroorzaakt?

Check GC.stat[:time] voor en na een request (Ruby 3.1+). Als GC-tijd meer dan 10% van je p99 response time uitmaakt, helpt tuning. Kijk ook naar bimodale response time distributies — een cluster snelle en een cluster trage responses duidt vaak op GC-pauzes die sommige requests raken.

Kan GC tuning out-of-memory kills veroorzaken?

Ja, als je limits te agressief verhoogt op geheugen-beperkte containers. Begin conservatief: verdubbel de malloc limits, stel init slots in op 50% van je gemeten steady state, en monitor een week. Het doel is minder, grotere GC-runs — niet GC helemaal uitschakelen.

Moet ik GC.disable gebruiken tijdens requests?

Bijna nooit. Sommige teams schakelen GC uit tijdens request-verwerking en draaien het tussen requests (out-of-band GC). Dit werkt voor apps met strikte latency-eisen en voldoende geheugenruimte, maar het is fragiel. Als een request veel alloceert, krijg je een OOM.

Gelden deze instellingen ook voor Sidekiq workers?

Ja. Sidekiq workers zijn langlopende Ruby processen met dezelfde GC-dynamiek. Ze profiteren vaak meer van tuning omdat achtergrondtaken grote batches objecten alloceren. Stel dezelfde env vars in je Sidekiq systemd unit of container in.

Hoe vaak moet ik opnieuw tunen na een Ruby upgrade?

Check je GC-metrics na elke minor Ruby upgrade (3.3 → 3.4). Het Ruby team past GC-defaults en heuristieken aan tussen versies — Ruby 3.3 veranderde de heap structuur aanzienlijk ten opzichte van 3.2. Je getunede waarden kunnen minder optimaal of zelfs contraproductief zijn na een upgrade.

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