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
Rails Puma Tuning: Workers, Threads, Geheugen en Concurrency voor Productie Performance

Rails Puma Tuning: Workers, Threads, Geheugen en Concurrency voor Productie Performance

Roger Heykoop
Ruby on Rails, DevOps
Rails Puma tuning in productie: worker-aantal kiezen, threadpool grootte, geheugenbudget en de copy-on-write instellingen die echt verschil maken.

In februari belde een klant me omdat hun Rails applicatie zichzelf elke twintig minuten door de OOM-killer liet afschieten. Ze hadden net het aantal Puma workers verdubbeld “om meer verkeer aan te kunnen.” De machine had acht gigabyte RAM. Elke worker zat op 900MB. Twaalf workers keer 900MB is precies het soort rekensom dat het contact met Linux niet overleeft. Ik heb een uur aan hun Puma-config besteed en we kwamen die middagpiek door zonder één herstart.

Na negentien jaar Rails heb ik meer productiestoringen gezien door een verkeerd ingestelde Puma dan door welk ander stukje van de stack dan ook. Sidekiq doet het meestal gewoon. Postgres misdraagt zich luid genoeg dat je het merkt. Puma faalt stil, in de vorm van 502’s en trage responses die eruitzien als een applicatie die het over de hele linie slecht doet. Dit artikel is de Rails Puma tuning gids die ik met elke klant doorloop wanneer we hun productieconfiguratie auditen.

Wat Rails Puma Tuning Eigenlijk Aanstuurt

Puma is sinds versie 5 de standaard webserver voor Rails en draait je applicatie over twee dimensies van concurrency: processen (in Puma “workers” genoemd) en threads binnen elk proces. Rails Puma tuning is de kunst van kiezen hoeveel van elk je draait, hoeveel geheugen je ze laat opslokken en hoe ze resources delen met alles wat verder op de machine draait.

De knoppen die er echt toe doen zijn klein in aantal:

  • workers — hoeveel geforkte processen Puma draait.
  • threads — de minimum en maximum grootte van de threadpool in elke worker.
  • preload_app! — of het parent-proces je applicatie laadt voordat er geforkt wordt, waardoor copy-on-write geheugen kan worden gedeeld.
  • worker_timeout — hoe lang een request mag blijven hangen voordat Puma de worker afschiet.
  • nakayoshi_fork en out-of-band GC — historische instellingen die in Ruby 3.3+ vrijwel geen rol meer spelen.

Al het andere is detail. Krijg deze goed en je p95 latency en geheugenvoetafdruk regelen zichzelf.

Rails Puma Tuning: Workers versus Threads Afweging

De eerste beslissing die elk team moet nemen is hoe je concurrency verdeelt over workers en threads. Het eerlijke antwoord hangt af van de GVL — de Global VM Lock — en van waar je applicatie haar tijd aan besteedt.

Ruby threads binnen één proces kunnen niet tegelijk Ruby-code uitvoeren door de GVL. Ze kunnen wel concurrent draaien wanneer één van hen geblokkeerd is op I/O: een databasequery, een HTTP-call naar een derde partij, een Redis-lookup. Workers zijn losse OS-processen met hun eigen GVL en draaien dus Ruby-code parallel op verschillende CPU-cores.

De vuistregel die ik klanten geef:

  • Is je app I/O-bound — en dat zijn de meeste Rails apps, omdat de meeste requests wachten op Postgres — leun dan op threads. Ze zijn goedkoop, delen geheugen en vangen I/O-wait mooi op.
  • Is je app CPU-bound — grote JSON renderen, rapporten berekenen, PDF’s inline genereren — dan heb je workers nodig. Threads staan gewoon in de rij achter de GVL.
  • De meeste echte Rails apps zijn een mix, vandaar dat het standaardantwoord “een paar workers, een paar threads per worker” is.

Voor een typische Rails API met Postgres en een mix van wel- en niet-gecachte endpoints begin ik met workers = aantal CPU-cores, threads = 3 tot 5 per worker, en tune vanaf daar.

Worker-Aantal Instellen op Moderne Hardware

De naïeve regel “één worker per CPU-core” klopt meestal, maar je moet wel weten welke CPU’s je echt hebt.

# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { Etc.nprocessors }

Op een dedicated VM met vier vCPU’s is vier workers correct. Op een gedeelde Kubernetes-pod met een CPU request van 500m en een limit van 2 is vier workers fout — de scheduler gaat je throttlen en je ziet context-switch latency die eruitziet als een trage applicatie.

In containers gebruik ik dit patroon:

# config/puma.rb
def available_cpu_count
  quota = File.read("/sys/fs/cgroup/cpu.max").split.first rescue "max"
  return Etc.nprocessors if quota == "max"

  period = File.read("/sys/fs/cgroup/cpu.max").split.last.to_f
  [(quota.to_f / period).ceil, 1].max
end

workers ENV.fetch("WEB_CONCURRENCY") { available_cpu_count }

Dat leest de cgroup v2 CPU-quota en gebruikt die als worker-aantal. Onder Kubernetes met een CPU-limit van 2 krijg je twee workers, niet de zestien die de host feitelijk heeft. Alleen deze wijziging heeft klanten meer productiepages gescheeld dan enige andere ingreep die ik met Puma heb gedaan.

Draai je op Kamal of kale VM’s, dan is Etc.nprocessors prima omdat de host het echte CPU-aantal ziet. Het deploy-verhaal heb ik beschreven in de Kamal 2 gids.

Thread-Aantal per Worker Instellen

Het thread-aantal is waar teams te veel aan draaien. De default in config/puma.rb was van oudsher min: 5, max: 5, wat voor de meeste applicaties prima is. Ik zet het zelden hoger dan 10.

# config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i
threads threads_count, threads_count

Dezelfde waarde voor min en max gebruiken is bewust. Een Puma-worker die zijn threadpool laat groeien en krimpen verspilt geheugen, omdat threads in Ruby hun stackallocaties niet netjes vrijgeven. Zet hem vast.

Het plafond op het thread-aantal is je database connection pool. Heb je vijf threads per worker en vier workers, dan zijn dat twintig connecties per Puma-procesgroep — plus background jobs, plus de console, plus wat er verder nog is. De database connection pool moet minstens gelijk zijn aan RAILS_MAX_THREADS:

# config/database.yml
production:
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  checkout_timeout: 5

En de Postgres-server moet genoeg connecties hebben voor elke worker op elke machine. Zit je dicht tegen je Postgres max_connections limiet aan, zet dan pgbouncer ervoor — over dat patroon schreef ik in de pgbouncer-gids.

Een verwante valkuil: meer threads draaien dan je traagste externe dienst aankan. Time-out je third-party API na 10 seconden en heb je 20 threads per worker, dan zal een korte storing bij die provider je hele Puma laten volstromen. Threadpools zijn een resource; behandel ze ook zo.

Geheugenbudget: De Echte Beperking

De beperkende factor op de meeste Rails productieservers is niet CPU. Het is geheugen. Elke Puma-worker is een onafhankelijk Ruby-proces met zijn eigen heap, zijn eigen gecompileerde code-cache en zijn eigen geladen gems. Een vers geforkte worker op een redelijk grote Rails-app zit meestal op 250–400MB. Na een uur verkeer verwerken is dat vaak 700MB tot 1,2GB.

De formule die ik gebruik voor capaciteitsplanning:

max_memory_per_box = (worker_count * observed_worker_rss) + overhead

Waar observed_worker_rss is waar je workers naartoe groeien na warmup, niet vlak na de start. Ik meet dit met een tien-minuten loadtest tegen staging. De overhead is meestal 500MB–1GB voor het OS, de reverse proxy, Sidekiq en wat er verder op de box staat.

Bij die klant uit de openingsanekdote was het rekensommetje:

12 workers * 900MB + 500MB overhead = 11,3GB nodig
Box had 8GB → OOM-killer → 502's

We hebben het worker-aantal teruggebracht naar vier, de threads per worker verhoogd van drie naar vijf en de box zat comfortabel op 5GB. P95 latency verbeterde omdat er minder workers werden afgeschoten en herstart.

Het antwoord op “moet ik meer geheugen toevoegen?” is op cloudhardware bijna altijd ja. Het antwoord op “moet ik meer workers toevoegen?” is bijna altijd nee, tenzij je CPU-verzadiging hebt gemeten. Teams draaien deze twee vragen voortdurend in hun hoofd om.

Copy-on-Write en preload_app

Ruby ondersteunt copy-on-write-vriendelijke garbage collection sinds 2.0. Puma’s preload_app! directive maakt daar gebruik van: het parent-proces laadt je Rails-applicatie één keer en forkt daarna workers die de geladen codepages met de parent delen. Zolang een page niet wordt beschreven, wordt hij niet gedupliceerd in RAM.

# config/puma.rb
preload_app!

before_fork do
  ActiveRecord::Base.connection_pool.disconnect!
end

on_worker_boot do
  ActiveRecord::Base.establish_connection
end

De geheugenwinst is reëel. In de applicaties die ik heb gemeten bespaart preload_app! 150–300MB per worker na warmup. Met vier workers is dat een gigabyte RAM die je niet hoeft in te kopen.

De twee valkuilen zitten op de forkgrens. Elke socket, thread of connectie die in de parent is geopend, wordt ongeldig in het kind. De veelvoorkomende:

  • Databaseconnecties. Disconnect in before_fork, reconnect in on_worker_boot.
  • Redis-connecties. Gebruik je Sidekiq’s Redis vanuit het Rails-proces — bijvoorbeeld voor rate limiting — herverbind dan op dezelfde manier.
  • Background threads gestart bij boot. Elke gem die tijdens eager loading een thread opstart (New Relic, Datadog, sommige telemetry SDK’s) moet zich na de fork opnieuw initialiseren. De nette gems doen dit automatisch; de slordige stoppen stilletjes met rapporteren.

Zet je preload_app! aan en valt je monitoring stil, check dan de fork-hooks. Ik ben hier dagen aan kwijt geweest.

Praktische Puma-Configuratie voor Productie

Dit is de config/puma.rb die ik daadwerkelijk ship. Tune de getallen, maar houd de structuur.

# config/puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }.to_i
threads min_threads_count, max_threads_count

workers ENV.fetch("WEB_CONCURRENCY") { Etc.nprocessors }

preload_app!

port        ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "production" }
pidfile     ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }

worker_timeout 30
worker_shutdown_timeout 30

before_fork do
  ActiveRecord::Base.connection_pool.disconnect!
end

on_worker_boot do
  ActiveRecord::Base.establish_connection
end

plugin :tmp_restart

lowlevel_error_handler do |exception, env|
  Rails.error.report(exception, context: { path: env["PATH_INFO"] }, handled: false)
  [500, { "Content-Type" => "text/plain" }, ["Internal Server Error"]]
end

Een paar details die aandacht verdienen:

  • worker_timeout 30 is agressief en bewust. Duurt een request langer dan dertig seconden, dan is er iets mis en is de worker afschieten beter dan erachter blijven wachten. Verplaats lang werk naar background jobs — daar schreef ik een hele post over bij Solid Queue.
  • worker_shutdown_timeout 30 geeft lopende requests tijd om af te ronden tijdens een deploy. Zet dit op de langste acceptabele requesttijd.
  • lowlevel_error_handler vangt fouten op die Puma oplegt voordat je Rails-middleware erbij komt — vooral parsefouten en slowloris-achtig misgedrag. Log ze zodat je echt misbruik kunt onderscheiden van verkeerd geconfigureerde clients.

Monitoren en Itereren

Je kunt niet tunen wat je niet meet. De vier signalen die ik op elke productie-Puma in de gaten houd:

  1. Worker RSS door de tijd heen — is het geheugen vlak of stijgend? Een gestage klim wijst op een lek. Hoe je die opspoort beschreef ik in de memory leak gids.
  2. Bezette threads per worker — gebruik je de threads die je hebt geconfigureerd? Blijven er threads idle tijdens piek, dan heb je ruimte om workers te snijden en threads op te hogen.
  3. Request queue time — tijd van accept tot handoff. Groeit dit terwijl responsetijden plat blijven, dan zit je krap op workerniveau.
  4. Puma backlog — hoeveel requests staan er te wachten. De Puma control app toont dit op /stats als je hem activeert.
# config/puma.rb
activate_control_app "unix:///tmp/puma_control.sock", auth_token: ENV["PUMA_CONTROL_TOKEN"]

Je kunt de socket vervolgens met pumactl benaderen of in je metrics-systeem trekken. Alles wat als queue time binnenkomt, is latency die je gebruikers voelen maar die je APM misschien als “applicatie” wegschrijft.

Gebruik je OpenTelemetry, dan legt de Rack-instrumentatie queue time vast als span-attribuut. Hoe je dat opzet beschreef ik in de OpenTelemetry-gids.

Veelgemaakte Rails Puma Tuning Fouten

Dezelfde fouten kom ik in vrijwel elke audit tegen.

WEB_CONCURRENCY zetten zonder de hardware te kennen. Drie workers op een container met één vCPU is slechter dan één worker. Meet voordat je vermenigvuldigt.

RAILS_MAX_THREADS uit de pas laten lopen met de database pool. Bij elke deploy beweegt er één en de ander niet, en op een drukke middag krijg je ActiveRecord::ConnectionTimeoutError in productie. Koppel ze aan dezelfde ENV-variabele.

preload_app! aanzetten zonder de fork-hooks te fixen. Je krijgt winst in geheugen en een stille storing in monitoring cadeau. Test de hooks altijd op staging voordat je ship’t.

Puma als bottleneck behandelen terwijl het eigenlijk Postgres is. Negen van de tien keer is een “trage Puma” een Puma met alle threads vast op een trage query. Fix de query voordat je workers toevoegt.

Puma in cluster mode draaien op een piepkleine machine. Heb je minder dan ongeveer 1,5GB RAM voor je web-tier, draai Puma dan in single mode (zonder workers). Je verliest wat isolatie, maar bespaart veel op overhead.

FAQ

Hoeveel Puma workers en threads moet een Rails app gebruiken?

Begin met workers gelijk aan je CPU-aantal en 5 threads per worker. Meet. Is je app I/O-bound en blijven threads idle tijdens piek, verlaag dan workers. Is hij CPU-bound en lopen responsetijden op onder load, voeg workers toe en verlaag threads. De meeste Rails apps landen op 2–4 workers met 5 threads elk op een kleine productieserver.

Doet Rails Puma tuning er nog toe met YJIT aan?

Ja. YJIT verbetert de CPU-efficiëntie per request — daar schreef ik over in de YJIT-gids — maar verandert niets aan hoe workers en threads geheugen delen of hoeveel requests parallel kunnen draaien. YJIT laat elke thread meer werk per seconde doen. Puma-configuratie bepaalt nog steeds hoeveel threads je hebt.

Hoeveel geheugen gebruikt een Puma worker in productie?

Een typische Rails 7- of Rails 8-applicatie zit op 250–400MB per worker koud en 600MB tot 1,2GB warm, afhankelijk van gems en workload. Meet je eigen — een loadtest van tien minuten op staging geeft je een realistisch getal. Gebruik dat voor je capaciteitsplanning, niet het getal uit een blogpost.

Moet ik preload_app! in productie gebruiken?

Bijna altijd ja. Het bespaart echt geheugen via copy-on-write. De enige reden om het uit te zetten is wanneer je een fork-veiligheidsprobleem in een dependency debugt en dat niet snel kunt fixen. Zelfs dan: fix de dependency en zet preload_app! weer aan — enkele procenten geheugenwinst stapelen over een hele fleet flink op.


Draai je Rails en ben je het gokken met Puma-instellingen beu? TTB Software is gespecialiseerd in performance en operations voor Rails-applicaties — van Puma-tuning tot Postgres, background jobs en observability. We doen dit al negentien jaar.

#rails-puma-tuning #rails-puma-workers #rails-puma-threads #rails-concurrency #puma-memory #rails-performance #ruby
R

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