Puma

Puma — Web Server written in Ruby
https://puma.io/
https://github.com/puma/puma
Current version: 5.6.5 (2022-09-13)

Web Server implements HTTP protocol. Client make HTTP Request and Web Server returns HTTP Response.

Conceptually

Client (cURL, browser, etc) -> request -> puma.io -> response
$ curl -s -I https://puma.io

HTTP/2 200
server: GitHub.com
content-type: text/html; charset=utf-8
last-modified: Tue, 24 May 2022 22:52:59 GMT
access-control-allow-origin: *
etag: "628d61cb-34e1"
expires: Sun, 18 Sep 2022 10:20:43 GMT
cache-control: max-age=600
x-proxy-cache: MISS
x-github-request-id: 59B0:2663:44A654:4B059D:6326EEA3
accept-ranges: bytes
date: Sun, 18 Sep 2022 10:54:42 GMT
via: 1.1 varnish
age: 0
x-served-by: cache-itm18843-ITM
x-cache: HIT
x-cache-hits: 1
x-timer: S1663498482.219915,VS0,VE184
vary: Accept-Encoding
x-fastly-request-id: 6dd9835997a5b559d511758820251e3876126e84
content-length: 13537

But actually, there is someone sitting in between.

Puma can serve static content. But for dynamic content:

Client (cURL, browser, etc) -> request -> puma.io -> rack -> response

Puma implements Rack. Rack invokes Ruby to dynamically generate response.

Rack response looks like this:

[200, {}, ["Hello World"]]

Status code. ??. Body.

$ puma git:(master) tree -L 2
.
├── Rakefile
├── benchmarks
│    ├── local
│    └── wrk
├── bin
│    ├── puma
│    ├── puma-wild
│    └── pumactl
├── config
├── examples
│    ├── CA
│    ├── plugins
│    ├── puma
│    └── qc_config.rb
├── ext
│    └── puma_http11
├── lib
│    ├── puma
│    ├── puma.rb
│    └── rack
├── puma.gemspec
├── tools
│    ├── Dockerfile
│    └── trickletest.rb
└── win_gem_test
    ├── Rakefile_wintest
    ├── package_gem.rb
    └── puma.ps1

Puma’s command-line program is puma.

Worker is a process.
Worker has many Threads.
Socket is a node listen to a Port.
Client makes request to Port.

client - socket - process

  • puma default
  • ENV
  • File Options: config/initializers/puma.rb
  • User Option: puma cli

2 modes: Single and Cluster. If you have workers > 1, you’re using Cluster mode.

Single mode: One Puma process.

Cluster mode: Master process and fork() many child processes.
child processes listen to the socket. Each child process has its own thread pool. You can preload the app.
Master process only cares for UNIX signals and kill/boot child processes.

Puma by default runs in Cluster mode with 2 workers and 5 threads from each worker.

Code as of puma/[email protected]

Rack::Handler.default -> Rack::Handler::Puma.run

class Rack::Handler::Puma
  def self.run(app, **options)
    conf = self.config(app, options)

    log_writer = options.delete(:Silent) ? ::Puma::LogWriter.strings : ::Puma::LogWriter.stdio

    launcher = ::Puma::Launcher.new(conf, :log_writer => log_writer)

    yield launcher if block_given?
    begin
      launcher.run
    rescue Interrupt
      puts "* Gracefully stopping, waiting for requests to finish"
      launcher.stop
      puts "* Goodbye!"
    end
  end
end

$ puma

require "puma/cli"
cli = Puma::CLI.new ARGV
cli.run

You can pass in options to puma or put all options in a file and use -C or --config to load the config file.

Puma Options
puma <options> <rackup file>
    -b, --bind URI                   URI to bind to (tcp://, unix://, ssl://)
        --bind-to-activated-sockets [only]
                                     Bind to all activated sockets
    -C, --config PATH                Load PATH as a config file
        --no-config                  Prevent Puma from searching for a config file
        --control-url URL            The bind url to use for the control server. Use 'auto' to use temp unix server
        --control-token TOKEN        The token to use as authentication for the control server
        --debug                      Log lowlevel debugging information
        --dir DIR                    Change to DIR before starting
    -e, --environment ENVIRONMENT    The environment to run the Rack app on (default development)
    -f, --fork-worker=[REQUESTS]     Fork new workers from existing worker. Cluster mode only
                                     Auto-refork after REQUESTS (default 1000)
    -I, --include PATH               Specify $LOAD_PATH directories
    -p, --port PORT                  Define the TCP port to bind to
                                     Use -b for more advanced options
        --pidfile PATH               Use PATH as a pidfile
        --preload                    Preload the app. Cluster mode only
        --prune-bundler              Prune out the bundler env if possible
        --extra-runtime-dependencies GEM1,GEM2
                                     Defines any extra needed gems when using --prune-bundler
    -q, --quiet                      Do not log requests internally (default true)
    -v, --log-requests               Log requests as they occur
    -R, --restart-cmd CMD            The puma command to run during a hot restart
                                     Default: inferred
    -s, --silent                     Do not log prompt messages other than errors
    -S, --state PATH                 Where to store the state details
    -t, --threads INT                min:max threads to use (default 0:16)
        --early-hints                Enable early hints support
    -V, --version                    Print the version information
    -w, --workers COUNT              Activate cluster mode: How many worker processes to create
        --tag NAME                   Additional text to display in process listing
        --redirect-stdout FILE       Redirect STDOUT to a specific file
        --redirect-stderr FILE       Redirect STDERR to a specific file
        --[no-]redirect-append       Append to redirected files
    -h, --help                       Show help

WEB_CONCURRENCY to set how many workers (0 or >= 2).
NOTIFY_SOCKET — enable systemd integration (requires sd_notify gem)

  • SIGUSR1 for phased restart.
  • SIGUSR2 for hot restart.

Phased restart requires Cluster mode.

  • Puma Worker Killer

Minimum: 5 threads
Maximum: 10 threads

puma -t 5:10

WEB_CONCURRENCY=2 puma -t 5:5

Total you will have 10 threads.

lowlevel_error_handler do |exception|
  Rails.error.handle(exception) rescue nil
  [500, { "Content-Type" => "text/html" }, [File.read("public/500.html")]]
end

Or replace Rails.error.handle with any error reporting service.