はじめに
Gakken LEAPで働いていますkoboriです。本稿は、Rubyを使ったWebアプリケーション開発の基盤となるRackを題材に書いていきます。私は日頃、Ruby on Railsを使ってWebアプリケーションを開発していて、Rackについて強く意識する機会はあまりありませんでした。しかし、先日参加した Kaigi on Rails 2024で聴講した基調講演を受け、システムに対する解像度をより高めたいと思い、今回はRackを題材に選ぶことにしました。応募が間に合わず参加できなかったRackのワークショップ に参加したかった心残りもあります。
Rackについて
現時点での私の大まかな解釈としては、RackはWebアプリケーションとWebサーバーの間のインターフェースを提供しており、各ライブラリはRackのインターフェースに従って実装されているので、アプリケーション開発者はWebアプリケーションフレームワークとWebサーバーを自由に選択することができている、という理解を持っています。
次に、RackのREADMEを見てみると、下記の記載があります。
Rack provides a minimal, modular, and adaptable interface for developing web applications in Ruby. By wrapping HTTP requests and responses in the simplest way possible, it unifies and distills the bridge between web servers, web frameworks, and web application into a single method call.
理解が概ね正しいことが分かりました。また、 into a single method call
という記述から、WebアプリケーションとWebサーバーのインターフェースとは、ある単一のメソッドのことを指していることが分かりました。
ほんの少し解像度が高まったところで、Rackアプリケーションを作ってみます。
Rackアプリケーションを作る
最小構成のRackアプリケーションを作ってみるということで、RackのGithubに記載されているサンプルを参考に一部改変をしつつ実装を進めていきます。
今回使用するRubyのバージョンは、下記の通りです。
$ ruby -v
ruby 3.3.3 (2024-06-12 revision f1c7b6f435) [x86_64-linux]
それではやっていきます。
はじめに、プロジェクトを作成します。
bundle init
Gemfileに下記の記述を追加し、インストールします。
gem "puma"
gem "rack"
gem "rackup"
bundle install
インストールされた各gemのバージョンは下記の通りです。
- puma (6.4.3)
- rack (3.1.8)
- rackup (2.2.1)
WebサーバーはPumaを使用し、rackupを使ってアプリケーションの起動を行います。
config.ru
というファイルを作成し、下記の内容を記述します。app変数に詰められたlambdaオブジェクトが、Rackアプリケーションになります。
それを run
メソッドに渡すことで、アプリケーションを起動することができます。
app = lambda do |env|
[200, { 'content-type' => 'text/html' }, ["Hello World\n"]]
end
run app
準備が整ったので、アプリケーションを起動します。
$ bundle exec rackup
Puma starting in single mode...
* Puma version: 6.4.3 (ruby 3.3.3-p89) ("The Eagle of Durango")
* Min threads: 0
* Max threads: 5
* Environment: development
* PID: 7818
* Listening on http://127.0.0.1:9292
* Listening on http://[::1]:9292
Use Ctrl-C to stop
良い感じに起動しました。
リクエストを投げてみると、config.ru
に記述した値が返ってきました。
$ curl http://localhost:9292 -i
HTTP/1.1 200 OK
content-type: text/html
Content-Length: 12
Hello World
Rackの仕様書
ここで、作成したRackアプリケーションとRack仕様書を照らし合わせてみます。
仕様書の冒頭には、下記の記載があります。
A Rack application is a Ruby object (not a class) that responds to call. It takes exactly one argument, the environment and returns a non-frozen Array of exactly three values: The status, the headers, and the body.
まとめると下記のように解釈できます。
-
call
を呼び出すことができるRubyオブジェクト(クラスではない) - 引数を1つ受け取ること
- HTTPステータス、ヘッダー、ボディの3つの要素を持つ凍結されていない配列を返すこと
仕様書を読みつつ解釈を進めていきます。
call
を呼び出すことができるRubyオブジェクトという制約を満たしていれば、今回のサンプルで利用したlambdaオブジェクト以外にも、ProcオブジェクトやクラスのインスタンスなどもRackアプリケーションとして利用することができます。
引数にはHTTPリクエストに関する情報や、それを処理するための情報、ログ出力に関する情報など、様々な情報が含まれています。
HTTPレスポンスとして返却される、戻り値の配列についても見ていきます。
1つ目の要素はHTTPステータスコードで、100以上の整数値である必要があります。
2つ目の要素のHTTPヘッダーにも制約が定められており、そのうちのいくつかを下記に抜粋します。
- 凍結されていないHashであること
- ヘッダーのキーは文字列であること
- ヘッダーのキーは大文字であること
- ヘッダーの値は文字列 または、文字列の配列であること
このうち、ヘッダーのキーが大文字でなければならない制約については、Rack3.0.0から追加されたもののようで、Rack2系を利用しているサンプルコードを流用すると、エラーが発生するので注意が必要です。
また、ヘッダーの値に配列が利用できるようになったのも、Rack3.0.0からの変更のようです。日々便利になっていってるのですね。
3つ目の要素のHTTPボディでは、文字列の配列だけでなく、文字列を生成するEnumerableなオブジェクト、Procオブジェクト、Fileのようなオブジェクトもサポートされています。これらのオブジェクトについて、each
メソッドを呼び出すことができるか、call
メソッドを呼び出すことができるか、その両方かで、それぞれに制約が定められています。
RackアプリケーションをPumaだけで動かす
Rackがインターフェースとして仕様を提供していることが分かってきたものの、具体的にどのような実装を提供しているのか、という疑問が残りました。
調べていく中で、PumaのQuick Startに、Pumaがconfig.ru
ファイルを探してくれる旨の記述を見つけました。
Without arguments, puma will look for a rackup (.ru) file in working directory called config.ru.
gemspecを確認すると、rackもrackupも含まれていないようです。Puma単独でRackアプリケーションを起動できるということでしょうか。ということで、試してみました。
ファイル構成は次の通りです。
$ ls -1
Dockerfile
Gemfile
config.ru
ローカル環境でグローバルにインストールされたgemの影響を排除するため、Docker環境を利用することにしました。
FROM ruby:3.3
WORKDIR /usr/src/app
COPY . .
RUN bundle install
EXPOSE 9292
CMD ["bundle", "exec", "puma"]
GemfileにはPumaのみをインストールします。config.ru
は先ほどと同様です。
Docker imageをビルドし、コンテナを起動します。Docker image名は、puma + app でpumappにしました。
$ docker build -t pumapp .
$ docker run -p 9292:9292 pumapp
Puma starting in single mode...
* Puma version: 6.4.3 (ruby 3.3.6-p108) ("The Eagle of Durango")
* Min threads: 0
* Max threads: 5
* Environment: development
* PID: 1
* Listening on http://0.0.0.0:9292
Use Ctrl-C to stop
起動したコンテナにリクエストを投げると、先ほどと同様にconfig.ru
に記述したレスポンスが返ってきました。
Rackとrackupを使わず、PumaのみでRackアプリケーションを起動することができました。
$ curl localhost:9292 -i
HTTP/1.1 200 OK
content-type: text/html
Content-Length: 12
Hello World
なぜPumaだけでRackアプリケーションを起動できたのかを調べていきます。
まず、config.ru
に記述された run
メソッドが気になります。これはRackが提供するメソッドだと思い込んでいましたが、Pumaが提供するメソッドなのでしょうか。
run app
の記述を削除し、アプリケーションを再起動した後、再度リクエストを投げてみると、エラーが出力されました。
手掛かりになりそうな箇所を下記に抜粋しています。
! Unable to load application: RuntimeError: missing run or map statement
bundler: failed to load command: puma (/usr/local/bundle/bin/puma)
/usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:277:in `to_app': missing run or map statement (RuntimeError)
from config.ru:5:in `<main>'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:172:in `eval'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:172:in `new_from_string'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:163:in `parse_file'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/configuration.rb:368:in `load_rackup'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/configuration.rb:290:in `app'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/runner.rb:162:in `load_and_bind'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/single.rb:44:in `run'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/launcher.rb:194:in `run'
from /usr/local/bundle/gems/puma-6.4.3/lib/puma/cli.rb:75:in `run'
出力されたbacktraceを起点にコードを追っていきます。
Pumaコマンドが呼ばれた後の実行の起点となる CLI#run
から始めていきます。
CLIクラスでは初期化時にLaunchクラスの初期化を行い、@launch
へ格納しています。またその際、設定情報が格納されているConfigurationクラスの初期化を行い@launch
に渡しています。
def run
@launcher.run
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/cli.rb#L75
Launch#run
を見ていきます。ここでは、@runner
に格納されたオブジェクトの rum
メソッドを呼び出しています。@runner
には、Launcherクラスの初期化時に、設定値に応じてClusterクラスまたは、Singleクラスのインスタンスが格納されています。
Pumaの起動時に出力されているログから、今回のアプリケーションはSingleモードで起動されていることが分かっているので、Single#run
を追っていきます。
def run
# ...
@runner.run
# ...
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/launcher.rb#L194
Single#run
では、最終的にstart_server
から取得される、Serverクラスのインスタンスに対して、run
を呼び出しています。
ログの出力内容とクラス名からここでサーバーが起動されていることが分かります。
def run
# ...
load_and_bind
# ...
@server = server = start_server
server_thread = server.run
log "Use Ctrl-C to stop"
# ...
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/single.rb#L44
start_server
は継承元のRunnerクラスで定義されており、Serverクラスを初期化する際の第一引数に、appを渡していることが分かります。
おそらくこれが、call
で呼び出すことができるRackアプリケーションではないか、という推測が立ち始めます。
def start_server
server = Puma::Server.new(app, @events, @options)
# ...
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/runner.rb#L177
app
を辿っていくと、load_and_bind
で代入されていることが分かりました。
load_and_bind
は、Single#run
の中でstart_server
の前に呼ばれていたメソッドです。
@config
には、launcherから渡された、Configurationクラスのインスタンスが格納されており、app
メソッドが呼ばれています。
def load_and_bind
# ...
@app = @config.app
# ...
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/runner.rb#L155C9-L155C22
Configuration#app
では、load_rackup
から取得した値を、ConfigMiddlewareクラスに渡して初期化しています。
def app
found = options[:app] || load_rackup
# ...
ConfigMiddleware.new(self, found)
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L289
ConfigMiddlewareクラスを見ると、見覚えのあるインターフェースがあります。
ソースコメントから推測して、Rackアプリケーションをラップし、configを注入しているのではないかと思います。
class ConfigMiddleware
def initialize(config, app)
@config = config
@app = app
end
def call(env)
env[Const::PUMA_CONFIG] = @config
@app.call(env)
end
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L264
Configuration#app
内の、load_rackup
の戻り値がRackアプリケーションの本体であることが分かってきました。
Configuration#load_rackup
では、rack_builder
の戻り値のオブジェクトに対して、rackup
を引数にparse_file
を呼び出しています。
def load_rackup
# ...
rack_app, rack_options = rack_builder.parse_file(rackup)
# ...
rack_app
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L365
順番に見ていきます。
まずはConfiguration#rackup
についてです。ここでは、@option
に格納されたオブジェクトから:rackup
をキーに値を取得しています。
Configurationクラス内の定数から、デフォルト値は 'config.ru'.freeze
に設定されていることが分かります。
def rackup
@options[:rackup]
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L282
次に Configuration#rack_builder
です。rackから提供されている rack/builder
を探し、見つからない場合はPumaで保持している Puma::Rack::Builder
を返しています。
Puma::Rack::Builder
がrack/builder
と同等のインターフェースを提供していることが分かりました。
def rack_builder
# ...
begin
require 'rack'
require 'rack/builder'
rescue LoadError
# ok, use builtin version
return Puma::Rack::Builder
else
return ::Rack::Builder
end
end
# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L344
ここまで見てきた内容から、rackがインストールされていない場合でも、Rackアプリケーションを起動することができる理由が分かったところで、今回は終了にします。
また、Rackが提供するインターフェースのひとつである、rack/builder
が参照されている箇所も知ることができました。
おわりに
最小構成のRackアプリケーションについて、Rackの仕様書、Pumaの実装を参照しながら理解を進んできたところで、本稿は締めに入ります。
今回は断念しましたが、いくつかの残課題も見えてきました。
1つ目に仕様書の掘り下げです。今回やりきれなかった部分について、理解を進めていきたいです。それほど文量は多くないので、時間をかければなんとか理解できるのではないかと思います。
それと併せて、rack/builder
以外にも、WebアプリケーションとWebフレームワークがRackの実装(仕様ではなく)に依存する箇所を見つけていきたいです。rack/builder
においても、RackとPumaでどのような差分があるのか気になります。
2つ目に、rackupの役割と実装についてです。本稿の前半でrackupはRackアプリケーションの起動を行うと書きましたが、後半でrackupが無くてもRackアプリケーションを起動できることが分かりました。rackupのREADMEには、(Soft) Deprecation という記載があり、rackupへの依存を推奨しない旨が書かれています。また、Ruby on Railsはrackupへの依存があり、Sinatraはrackupへの依存が無いようで、そのあたりの違いも気になるところです。
これらについては残課題として取り組んでいきたい所存で、テックブログ執筆の機会を利用して調べていこうと思います。
エンジニア募集
Gakken LEAP では教育をアップデートしていきたいエンジニアを絶賛大募集しています。
ぜひお気軽にカジュアル面談へお越しください。