Ruby GC Tuning: Minder Geheugenverbruik en Snellere Response Times 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_allocationsof dememory_profilergem. - 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_eachof 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.
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 TouchRelated Articles
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