The job Puma should not do

Free preview 3 min to read

Puma is a Ruby web server, and it's very good at running Ruby. Something else usually sits in front of it because serving a static file isn't running Ruby, and doing it in a Ruby thread is expensive in a specific way.

What a thread costs

Take a Puma sized for a small server: two workers with three threads each, which gives six request slots on the whole machine. The exact numbers vary by application; the reasoning doesn't.

A request for /assets/application-a1b2c3.css occupies one of those slots for as long as it takes to read the file and write it to a socket.

On a fast connection, that's milliseconds. On a slow mobile connection, it's seconds, and the thread is held the whole time, because Puma writes the response before the thread is free.

Six slots is a small number. A page that pulls a stylesheet, an import map and thirty preloaded modules can occupy all of them with work that involves no Ruby at all.

flowchart TB
  R["a request for a CSS file"] --> P["Puma takes one of six threads"]
  P --> F["read the file. write to the socket"]
  F --> S{"how fast is the client?"}
  S -->|fast| A["milliseconds. barely noticed"]
  S -->|slow| B["seconds. the thread is unavailable"]
  B --> C["six slots, and one is gone"]

The traditional answer

Put nginx in front. It serves files with an event loop instead of a thread per connection, so ten thousand slow clients cost it very little.

The cost is a second piece of software with its own configuration file, its own reload behavior and its own failure modes, and in a containerized deployment, its own container.

Where the compression question sits

A gzipped response is smaller and slower to produce. Compressing in Ruby spends CPU in those same request slots.

Compressing in a proxy spends it outside them. It's the same argument as serving files, applied to a different kind of work.

Both costs are real, and neither is a large problem at this application's traffic. What makes the fix worth taking is that it costs almost nothing.

What Thruster is for

Thruster is a Go binary that wraps Puma and takes this kind of work off it. It caches public assets in memory, hands file transfers off through X-Sendfile and compresses responses. It also speaks HTTP/2 and can manage TLS certificates, which this deployment leaves to kamal-proxy.

There's no configuration file, no second container and no routing rules, and this application already runs it.

The next lesson shows where, and the answer is one line of the Dockerfile that rarely gets a second look.

Key takeaways

  • A Puma process has a fixed number of request slots, workers times threads, and that's six in the lesson's example.
  • Serving a static file occupies one of those slots for as long as the client takes to receive it.
  • A slow client holds a Ruby thread doing work that involves no Ruby.
  • nginx solves it with an event loop, at the cost of a second piece of software to operate.
  • Thruster takes on the same work with no configuration and no second container.
Lesson 1 of 7 · free

Keep going with lesson 2

The line in the Dockerfile

Finds Thruster in the Gemfile and Dockerfile, traces the process tree and explains why the entrypoint check survives.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...