Kino: Een high-performance Ractor webserver voor Ruby 4.0+

Omdat Ruby-threads geen Ruby-code parallel kunnen uitvoeren, maken productie-omgevingen meestal gebruik van een fork per CPU-core, waarbij men betaalt voor elke kopie in het geheugen. Kino voert je code op elke core uit binnen één klein proces. Een front-end geschreven in Rust (tokio + hyper) beheert het netwerk, parallelle Ractors draaien je Rack 3-applicatie, en een threaded fallback-modus draait alles overig, inclusief Rails.

Belangrijkste kenmerken

  • Snel. Op een echte server met 8 cores is elke Kino-modus 1,5 tot 2 keer sneller dan een Puma fork-cluster op endpoints met weinig I/O. De Ractor-modus wint ook op pure CPU-taken met 30% of meer.
  • Minimaal geheugengebruik. Gebruikt ongeveer 7 keer minder geheugen bij een eenvoudige benchmark Ractor-app, en ongeveer 4 keer minder geheugen dan een Puma-cluster die Rails serveert in de threaded fallback-modus.
  • Parallel zonder forking. De Ractor-modus voert CPU-werk meer dan 5 keer sneller uit dan de GVL-gebonden threaded-modus van Kino, binnen hetzelfde kleine proces.
  • Productie-gereed. Inclusief graceful drain, crash-supervisie en respawn, begrensde wachtrijen met 503 backpressure, request-timeouts, beveiligde intake (deadlines voor slowloris en TLS-handshakes, limieten voor connecties en body-grootte), een on_error hook voor error-trackers, TLS (rustls), live statistieken, asynchrone toegang en app-logging.
  • Diagnostische tools. kino --check lijst precies op wat je applicatie blokkeert voor de Ractor-modus, zodat je niet zelf Ractor::IsolationError hoeft te decoderen.
  • Puma-compatibel. Gebruikt dezelfde workers × threads topologie, een vertrouwde config DSL en een kino CLI. Als je Puma kunt draaien, kun je Kino draaien.

N.B.: Ractors zijn officieel experimenteel in Ruby 4.0, en dat geldt ook voor deze server. De threaded-modus is stabiel. Kino streeft ernaar de beste manier te zijn om vandaag met Ractors te experimenteren, en de beste Ractor-server te worden zodra deze stabiel zijn.

---

Waarom Kino?

De GVL (Global VM Lock) staat toe dat slechts één Ruby-thread tegelijk draait. Om alle cores te benutten, maken Ruby-servers gebruik van forks, waarbij elke fork een volledige kopie van de applicatie kost. Ractors hebben deze limiet niet: elke Ractor heeft zijn eigen lock, waardoor één proces Ruby parallel kan uitvoeren. Wat ontbrak was een server die verzoeken naar deze Ractors kon distribueren. Ruby 4.0 heeft Ractors herzien (Ractor::Port, shareable_proc, minder lock-contention), waardoor de bouw van zo'n server zinvol is geworden.

---

Benchmarks

Gemeten op een AWS c7a.2xlarge server (8-core AMD EPYC 9R14, 16 GB, Amazon Linux 2023).

Doorvoersnelheid (Requests per seconde)

De onderstaande tabel toont een kleine synthetische Rack-app (plaintext, 10 KB body, CPU-bound fib, 5 ms wachttijd). De tests zijn uitgevoerd met Ruby 4.0.5 met YJIT.

EndpointKino :ractorKino :ractor (+ lanes, 32 workers)Kino :threadedPuma (cluster)
/plaintext229.534250.222182.997118.176
/10k178.083189.862151.034106.768
/cpu (fib)77.999¹70.88566.10058.006
/io (5 ms)1.5521.5515.8884.693
/io_native1.5701.5716.2744.691

¹ Standaardinstellingen, zonder tuning. Ractor-modus verslaat de fork-cluster op pure CPU met +34%.

Geheugengebruik (PSS)

Kleine benchmark app (Ractor-shareable):

ModusKino (één proces)Puma cluster (8 workers)Ratio
:ractor (8×1)148 MB1.068 MB~7×
:threaded (8×3)107 MB³1.068 MB~10×

Echte Rails app (niet Ractor-shareable, gebruikt :threaded fallback):

ModusKino :threadedPuma cluster (8 workers)Ratio
PSS92 MB389 MB~4×

³ Gemeten met MALLOCARENAMAX=2.

Vergelijking met Ractor wrappers

Het wrappen van een applicatie in een Ractor-pool binnen een bestaande server is minder efficiënt dan Kino:

EndpointKino :ractor (8×3)Puma + ractor wrapperFalcon + ractor wrapper
/plaintext193.82619.48099.776
/cpu (fib)68.06117.75548.721
/io (5 ms)4.5301.4541.549

---

Rails

Rails is momenteel niet Ractor-shareable, waardoor Kino dit serveert in de :threaded fallback-modus (één GVL-gebonden proces).

Rails hello-world vergelijking:

ModusRequests/sGeheugen (PSS)
Kino :threaded (één proces)2.63792 MB
Puma cluster (8 workers)12.138389 MB

De afweging is eerlijk: de Puma fork-cluster gebruikt alle 8 cores en haalt zo ongeveer 4,6x de doorvoer, maar tegen 4x de geheugenkosten. Ractor-mode Rails zou dit gat dichten tegen de geheugenkosten van één proces.

---

Installatie

Je hebt Ruby >= 4.0 nodig. Voeg Kino toe aan je bundle:

bundle add kino
# of: gem install kino (buiten een bundle)

Of voeg het toe aan je Gemfile:

gem "kino"

Genereer vervolgens een configuratie en start de server:

bundle exec kino --init    # schrijft kino.rb
bundle exec kino           # start server op poort :9292 via config.ru + kino.rb

Er is geen Rust-compiler nodig voor released versies; deze worden geleverd als voorgecompileerde native gems voor Linux (x86_64/aarch64, glibc en musl) en macOS (arm64).

---

Gebruik

Basisvoorbeeld

require "kino"

# Ractor-modus vereist een Ractor-shareable app: niets vastleggen, config bevriezen.
app = Ractor.shareable_proc do |env|
  [200, { "content-type" => "text/plain" }, ["Hello from #{Ractor.current}"]]
end

Kino::Server.run(app, port: 9292) # vangt INT/TERM op; Ctrl-C voert graceful drain uit

Geavanceerde configuratie

server = Kino::Server.new(app,
  bind: "127.0.0.1",
  port: 9292,                 # 0 = efemer; uit te lezen via server.port
  workers: Etc.nprocessors,   # ractors (parallellisme)
  threads: 1,                 # per worker; ractor default 1, threaded default 3
  mode: :auto,                # :auto | :ractor | :threaded
  queue_depth: 1024,          # begrensde wachtrij; overflow → 503
  queue_timeout: 5.0,         # seconden voor 503 bij volle wachtrij
  request_timeout: nil,       # seconden voor 504 (nil = uit)
  max_connections: 8192,      # max concurrent connecties
  max_body_size: 50 * 1024 * 1024,  # bytes voor 413; nil = proxy handleiding
  on_error: ->(e, env) { ErrorTracker.capture(e) },  # na verzenden van 500
  shutdown_timeout: 30,       # drain deadline
  control_bind: "127.0.0.1:9293",   # monitoring: /stats /metrics /ready /live
  control_token: ENV["KINO_CONTROL_TOKEN"],  # optionele Bearer auth
  tls: { cert: "cert.pem", key: "key.pem" },  # bestandspaden of inline PEM
)
server.start
server.shutdown               # graceful: drain → deadline → abort

Modi

  • :ractor: Gebruikt workers Ractors × threads Threads per stuk. De app moet Ractor.shareable? zijn. Een gecrashte Ractor geeft direct een 500 terug aan actieve verzoeken en wordt daarna opnieuw opgestart.
  • :threaded: Gebruikt dezelfde structuur op basis van normale Threads. Draait elke Rack-app, inclusief Rails. Parallel voor I/O, maar geserialiseerd door de GVL voor CPU.
  • :auto (standaard): Gebruikt :ractor als de app shareable is, anders een waarschuwing en :threaded. Let op: een class die als Rack-app wordt gebruikt, wordt altijd als "shareable" gezien. Forceer in dat geval :threaded.

---

Configuratiebestand en CLI

Instellingen kunnen in een Puma-stijl Ruby DSL-bestand worden geplaatst. Prioriteit: expliciete kwargs en CLI-vlaggen > configuratiebestand > standaardwaarden.

Voorbeeld kino.rb:

port 9292
workers 8
threads 1
mode :ractor

Handige commando's:

  • kino --init: Schrijft een volledig gedocumenteerd voorbeeld kino.rb.
  • kino: Start de server met config.ru + kino.rb op poort 9292.
  • kino --check: Legt uit of de app in :ractor modus kan draaien.
  • kino -C config/kino.rb -p 3000 -w 4 -m ractor my_app.ru: Start met specifieke parameters.

---

Analyse met kino --check

Wanneer een app niet in :ractor modus kan draaien, geeft Kino aan waarom dit zo is in plaats van een generieke Ractor::IsolationError. Het identificeert:

  • Vastgelegde variabelen en waar ze zijn gedefinieerd.
  • Instantievariabelen via hun pad.
  • Class-level instantievariabelen.

Voorbeeld output:

$ kino --check
check: app is NOT Ractor-shareable
- app (Proc at app.rb:12)—captures `cache` = {} (Hash) (unshareable)
- app (HelloApp).@instance—class-level ivar holds #<HelloApp…>
hints: freeze config at boot; build endpoints with Ractor.shareable_proc;
keep per-worker resources in Ractor.store_if_absent; or run mode :threaded.

---

Request Time-outs en Beveiliging

request_timeout begrenst hoe lang een app mag doen over een respons. Na de deadline krijgt de client een 504; de handler blijft draaien maar de late respons wordt weggegooid. De handler wordt niet geforceerd gestopt omdat dit in Ruby onveilig is.

Netwerkbeveiliging:

  • Nieuwe connecties boven max_connections wachten in de kernel backlog.
  • Request bodies boven maxbodysize (standaard 50 MB) krijgen een 413.
  • Vaste deadlines voor trage headers (15s), gestagneerde TLS-handshakes (10s) en uploads die halverwege stoppen (30s).

---

Lifecycle Hooks

Kino biedt verschillende hooks:

Worker-context (binnen de worker):

  • afterworkerboot { |workerid| }: Draait één keer voor de worker begint. In :ractor modus moet dit een Ractor.shareableproc zijn.
  • afterrequestcomplete { |env, status| }: Draait na elke succesvolle respons. In :ractor modus moet dit een Ractor.shareable_proc zijn.

Main-context (hoofdthread):

  • after_boot { }: Draait nadat de worker pool is opgestart.
  • onworkerexit { |worker_index, error| }: Draait wanneer een worker stopt.

---

Quarantaine voor vastgelopen workers

quarantinetimeout zet een dispatch-slot in quarantaine als een request langer duurt dan de deadline, en start een vervangende worker om de capaciteit te herstellen. Dit is verschillend van requesttimeout, die alleen de client een 504 geeft maar het slot bezet houdt.

quarantine_max begrenst het totaal aantal vervangingen. Een vastgelopen worker wordt nooit geforceerd gestopt; in :ractor modus lekt de wedged Ractor tot het proces stopt.

---

Statistieken (Stats)

server.stats geeft een live snapshot van de configuratie en tellers:

  • mode, workers, threads, served, rejected, timeouts, etc.

Externe monitoring via control_bind:

  • GET /stats: Snapshot als JSON.
  • GET /metrics: Prometheus text-formaat (bijv. kinorequestsserved_total).
  • GET /ready: 200 bij actieve service, 503 tijdens boot of drain (ideaal voor Kubernetes readiness probe).
  • GET /live: 200 zolang het proces leeft (liveness probe).

---

Logging

Kino gebruikt een asynchrone Rust-flusher thread, waardoor request-threads nooit op een log-mutex hoeven wachten.

  • Access log (log_requests true): Eén regel per request naar stdout, inclusief 503s. Kleurgecodeerd op status (2xx groen, 3xx geel, 4xx maroon, 5xx rood).
  • Kino::Logger: Een ::Logger subclass voor applicatie-logging.

Integratie met Rails:

# config/environments/production.rb
config.logger = Kino::Logger.new # stdout
# of
config.logger = Kino::Logger.new("log/production.log") # bestand

---

Overige technische details

Timer-wachtrijen

Kino.sleep(seconds) is een high-resolution sleep op de OS-clock waarbij de GVL wordt vrijgegeven. Gebruik dit in handlers voor expliciete wachttijden.

Rack 3 Compliance

De spec-suite is getest onder Rack::Lint via echte sockets, inclusief streaming request bodies, full-duplex response bodies en correcte HEAD/204 semantiek.

Ontwikkeling

Voor developers:

  • bin/setup
  • bundle exec rake: Compileert, draait Rust tests, specs, RBS en lint.
  • RBSYSCARGO_PROFILE=dev bundle exec rake compile: Voor snellere rebuilds tijdens ontwikkeling.