はじめに
「Rails をデプロイした」というけど、サーバーで実際に動いているプロセスって何か、パッと答えられますか。
「Rails と Puma、何が違うの?」と聞かれると意外に詰まる。ここを分解していくと、他フレームワークにも通じる基本の型が見えてきます。
「Rails をデプロイした」の実態
ps を叩くと Puma しか見えない
本番サーバーで ps を叩くと、このような出力が出てきます。
$ ps aux | grep -E 'puma|rails' | grep -v grep
deploy 12345 puma 6.4.2 (tcp://0.0.0.0:3000) [my_app]
deploy 12346 puma: cluster worker 0: 12345 [my_app]
deploy 12347 puma: cluster worker 1: 12345 [my_app]
Puma しかいません。「Rails」という名前のプロセスは、どこにも存在しない。
見えているのは Puma のマスタープロセスと、その子プロセスの worker が2本。実際にリクエストを処理しているのは worker たちです。「Rails のコード」はこの worker の中に、ただロードされているだけ。プロセスとしては Puma、というのが実像です。
Gemfile を見ると別々に書かれている
新規 Rails アプリを作ると、Gemfile にこう書いてあります。
gem "rails"
gem "puma"
別々の gem として書かれている。Rails と Puma は、別のプロダクトで、別のリポジトリで開発されてます。
-
rails/rails— Rails 本体 -
puma/puma— Puma 本体
チームも別。しかも Rails 本体の gemspec を見ると、Puma は必須依存ではなく "デフォルトの選択肢" として Gemfile に書かれているだけです。要するに、Rails は Puma がなくても gem としてはインストールできる。
Puma は差し替えられる
gem "puma" を消して gem "unicorn" に置き換えても、Rails は動きます。設定ファイル(config/puma.rb → config/unicorn.rb)を用意し直せば、サーバーだけを丸ごと入れ替えられる。
この差し替え可能性が、両者が別物であることの何よりの証拠です。もし密結合していたら、こんな交換は不可能なはず。
Rails 本体は HTTP をしゃべれない
そもそも TCP を listen できない
Rails 単体では、こういうことができません。
- TCP ポート(3000番など)を listen する
- 生の HTTP バイト列(
GET /users/1 HTTP/1.1\r\n...)をパースする - クライアントに HTTP バイト列を送り返す
Rails のソースコードには、これらの実装が存在しない。
試しに bin/rails console を叩いてみると、Rails のクラスは全部ロードされるのに、ポートは何も開きません。つまり Rails は、サーバー機能を持たない Ruby ライブラリの集合体、というのが正しい姿です。
HTTP は全部 Puma が扱ってる
TCP を listen して、HTTP を解釈して、Ruby が扱える形に変換するのは、全部 Puma の仕事です。
ブラウザ
│ HTTP over TCP
▼
┌──────────────────────┐
│ Puma プロセス │ ← HTTP をしゃべる担当
│ │
│ ┌─────────────────┐ │
│ │ Rails のコード │ │ ← Ruby メソッド呼び出しに反応
│ │ (Ruby の世界) │ │
│ └─────────────────┘ │
└──────────────────────┘
Puma が外の HTTP の世界と内側の Ruby の世界の境目に立って、翻訳係をやっている。
役割分担を並べるとこんな感じ。
| やること | Puma | Rails |
|---|---|---|
| TCP ポートを listen する | ○ | × |
| HTTP バイト列をパースする | ○ | × |
| Ruby のメソッド呼び出しに変換する | ○ | × |
| ルーティング(URL からアクション決定) | × | ○ |
| DB からデータを取る | × | ○ |
| Ruby オブジェクトを返す | × | ○ |
| レスポンスを HTTP バイト列に組み立てる | ○ | × |
| TCP で書き戻す | ○ | × |
入りと出はいつも Puma、真ん中の Ruby の仕事だけが Rails。この分担を覚えておくと、いろんな場面で効きます。
Rails ができるのは "呼ばれたら返す" だけ
Rails 側から見ると、外の世界はこう見えます。
- 誰か(=Puma)が Ruby のメソッドを呼んでくれる
- リクエスト情報が Ruby のデータで渡される
- Rails は Ruby のオブジェクトを返す
これだけ。HTTP のことは何も知らないし、扱えない。
技術的には Rails.application.call(env) がこの "入口" になっています。試しに rails console から自分でこれを呼び出すと、HTTP サーバーを起動していないのに Rails のレスポンスが返ってくるのが確認できます。Rails が本当に "Ruby メソッド呼び出し" にしか反応しないことの証明です。
それでも両者はつながっている、Rack という薄い規約
じゃあ Puma と Rails、どうやってやり取りしてるのか。
その間には Rack という薄い規約が挟まっています。
HTTP 世界 Ruby 世界
│ │
[Puma] ─── Rack ─── [Rails]
↑
両者をつなぐ薄い規約
Rack は「データの形式だけを決めた約束事」です。「渡すもの」と「返すもの」の形がキッチリ決まっているだけ。この規約に従っていれば、どのサーバーとどのフレームワークでも組み合わせられます。
- 同じ Puma で Rails でも Sinatra でも Hanami でも動く
- 同じ Rails を Puma でも Unicorn でも Passenger でも動かせる
規約が挟まっているから、両者は疎結合になっている。「Puma と Rails の間には橋がある」という感じです。
まとめ
- 「Rails をデプロイする」の実態は、Puma プロセスの中に Rails のコードをロードして立てているだけ
- Rails 本体は HTTP をしゃべれない。TCP listen も HTTP パースも Puma がやっている
- Puma と Rails は 別の gem、別リポジトリ、差し替え可能なくらい疎結合
- 両者は Rack という薄い規約でつながっている
「Rails は魔法の巨大なフレームワーク」というイメージから、少し輪郭が見えたんじゃないかと思います。実態は「Puma プロセスの中にロードされている、Ruby オブジェクトの集まり」です。
この分解ができていると、実務でも役に立ちます。「500 エラーが出た」ときも、HTTP レイヤーの問題(Puma 側の設定?)か Ruby レイヤーの問題(Rails のコード?)かで切り分けられる。「Rails が遅い」も、Puma のスレッド不足なのか、Rails の中の DB クエリなのかで見るところが変わる。
そしてこの構造は他フレームワークでも同じ。Django なら Gunicorn、Node.js なら Express の実行環境、Java なら Tomcat。「サーバー」と「フレームワーク」は必ず分かれていて、規約でつながっている。言語が変わっても、この形は繰り返し出てきます。