Railsの非同期I/OベースWebサーバー(Falcon)で見えてきたRails APIの可能性
マイクロサービスの技術選定でRailsが話題に上がると、I/O待ちの多い構成でのスループットを理由に選択肢から外れることがあります。RubyKaigi 2026でShopifyの発表を聞いたことをきっかけに、その前提を自分で確かめてみました。この記事はその実験と考察の記録です。
PumaとFalconのスループット差を3パターンの実験で比較し、Railsマイクロサービスへの示唆を考えます。希望的観測を含む個人的な考察として読んでいただけるとありがたいです。
この記事の結論
- 実際のAPIに近い複合I/Oの条件では、WebサーバーをFalconに変えるだけでPumaの約4倍のスループットが出た
-
Net::HTTPを使った既存のコードはそのまま動く。コード変更は不要 - 外部API連携・BFF・Webhookなど、I/Oバウンドはマイクロサービスでむしろ典型的なユースケース。Railsが現実的な選択肢になりうる土壌が整いつつある
ただし、この結論には以下の前提があります。
- DBはPostgreSQLが前提。MySQLには対応する非同期ドライバが存在しない
- 今回はローカル単一ノードでの計測。本番環境では別途検証が必要
- 新規マイクロサービスでRails+Falconを採用する場合でも、Rack互換・gem依存の確認が必要
詳細な実験データと考察は以下に続きます。
Railsがマイクロサービスで選ばれにくい背景
マイクロサービスの話になると、Railsが選択肢から外れることが多いです。
RailsのデフォルトサーバーであるPumaはスレッドベースのモデルを採用しています。外部APIの呼び出しやDBアクセスの待ち時間中も、スレッドがブロックされたまま他のリクエストを処理できません。
結果として、スループットを上げるためにプロセスやインフラを積む必要があり、コスト効率の面で不利とされてきました。
Falconはこの問題を、非同期I/Oによる並行処理で解決します。I/O待ち中にスレッドをブロックせず、Fiber Schedulerが処理を切り替えることで他のリクエストを並行して進めます。では、その効果が実際のAPIでどの程度出るのか。実験で確かめてみました。
RubyKaigi 2026のShopify事例がきっかけだった
RubyKaigi 2026で、Falconの作者Samuel WilliamsとShopifyのチームが登壇しました。
セッションタイトルは「Surviving Black Friday: 329 billion requests with Falcon!」。ShopifyがストアフロントのインフラをFalcon(非同期I/OベースのRuby製Webサーバー) に移行し、Black Friday 2025を8500万リクエスト/分・ドロップゼロで乗り切った話でした。
Shopifyが移行を決断した背景として語っていたのは、「外部決済APIや在庫サービスへの大量のHTTPリクエストが発生するワークロードで、スレッドブロッキングがボトルネックになっていた」という点でした。I/O待ちの多い構成でFalconの非同期I/Oが有効に働くという話は、マイクロサービスでよく見るワークロードそのものだと感じました。
この話を聞いて、実際に自分でも実験をして確かめてみたくなりました。
実験設計:PumaとFalconを3パターンで比較した
同一のRails APIをPumaとFalconで動かし、スループットとレイテンシを計測しました。
| 項目 | 内容 |
|---|---|
| Ruby | 4.0.1 |
| Rails | 8.1.3 APIモード |
| DB | PostgreSQL 17 |
| 負荷ツール | wrk(-t4 -c1000 -d30s) |
| プロセス構成 | 1プロセス(Phase 1)/ 4プロセス(Phase 2) |
| 実行環境 | macOS(ローカル単一ノード)、Apple M3 Max |
実験の前提として、PumaとFalconではDB接続の管理方法が異なります。
Pumaはスレッド数がそのままDB pool数の上限として機能します。スレッドが増えすぎないため、pool枯渇が起きにくい構造です。
Falconは並行処理を無制限に生成できるため、制限なしでは全リクエストが同時にDB接続を取得しようとしてpoolが枯渇します。そのためAsync::Semaphoreで同時接続数を明示的に制限する必要があります。
# config/initializers/db_semaphore.rb
# Falcon起動時のみDB同時接続数を制限するセマフォを定義する
if defined?(Async::Semaphore)
DB_SEMAPHORE = Async::Semaphore.new(ENV.fetch("DB_CONCURRENCY", 10).to_i)
end
# app/controllers/application_controller.rb
# DB_SEMAPHOREの有無をPuma/Falcon両対応で吸収する
def with_db_semaphore(&block)
defined?(DB_SEMAPHORE) ? DB_SEMAPHORE.acquire(&block) : block.call
end
with_db_semaphoreはPuma環境ではDB_SEMAPHOREが定義されないためブロックをそのまま実行し、Falcon環境ではセマフォで同時接続数を制限します。パターンCのコードでこのヘルパーを使っているのはこのためです。
比較した3パターンは以下のとおりです。
- パターンA:単純なDBアクセスのみ(ベースライン)
-
パターンB:
sleep(0.5s)による簡易I/O待ちの原理確認。Falconの効果がなぜ出るかをシンプルに示すための人工的なパターン -
パターンC:実際のマイクロサービスAPIを模した実用検証。
Net::HTTPによる外部HTTPリクエスト(300ms × 2回)+DBクエリ5本を直列実行。このパターンの結果がメインの結論
結果:I/O待ちがあると劇的な差が出た
1. パターンA(単純DBアクセス):ベースライン
エンドポイントはシンプルなUser取得です。before_action でDBからユーザーを取得してJSONを返します。
# GET /users/:id
def show
render json: @user # before_action :set_user で取得済み
end
| 構成 | Puma | Falcon | 差 |
|---|---|---|---|
| 1プロセス × 20スレッド | 339 req/s | 338 req/s | ほぼ同等 |
| 4プロセス × 20スレッド | 1,005 req/s | 1,627 req/s | +62% |
1プロセス構成ではほぼ差がありません。4プロセスに増やすとFalconが62%上回りました。
DBクエリ数ms程度の短いI/O待ちでも、4プロセス構成ではFalconが上回ります。ただし差は限定的で、I/O待ち時間が長いほど差は広がります。
次に、I/O待ちを加えたケースで原理を確認します。
2. パターンB(原理確認):仕組みを理解するための人工実験
sleep(0.5s) で外部API待ちを簡易的に再現したパターンです。実際の外部接続は使わず、シンプルなI/O待ちのケースでPumaとFalconの性能差を確認することが目的です。
# GET /users/:id/pattern_b
def pattern_b
if Async::Task.current?
Async::Task.current.sleep(0.5)
else
sleep(0.5)
end
render json: @user
end
| 構成 | Puma | Falcon | 差 |
|---|---|---|---|
| 1プロセス × 20スレッド | 34.68 req/s | 349.14 req/s | 約10倍 |
| 4プロセス × 20スレッド | 150.15 req/s | 1,375.87 req/s | 約9倍 |
Pumaの場合、スレッドがsleep中にブロックされるため処理できるリクエスト数に上限があります。
| 計算式 | 値 | |
|---|---|---|
| Puma 理論上限 | 20スレッド ÷ 0.5s | 40 req/s |
| Puma 実測値(1プロセス) | — | 34.68 req/s |
理論値と実測値がほぼ一致しており、スレッド数の壁がそのままスループットの天井になっていることがわかります。
Falconはsleep中に非同期I/Oで処理を切り替えるため、1000接続が並行して待機・処理されます。4プロセス構成の詳細は以下のとおりです。
| 指標 | Puma(4プロセス × 20スレッド) | Falcon(4プロセス × DB接続上限20/プロセス) |
|---|---|---|
| Req/sec | 150.15 | 1,375.87 |
| Latency Avg | 5.87s | 706ms |
| Latency Max | 7.20s | 1.32s |
| 成功率 | 100% | 100% |
あくまで私個人の感想ですが、0.5秒の待ちがこれほどの差を生むとは思っていませんでした。
これはsleepという人工的な条件での結果です。実際のAPIに近い条件ではどうなるか。パターンCで確認します。
3. パターンC(実用検証):実際のAPIに近い条件での結果
パターンBがsleepによる簡易な擬似だったのに対し、パターンCは実際のマイクロサービスに近い構成です。Net::HTTP で外部サーバーへのHTTPリクエスト(300ms × 2回)と、DBクエリ5本をすべて直列で処理します。
外部サーバーはDockerで起動したhttpbinを利用して、HTTP通信を再現しています。
# GET /users/:id/pattern_c
def pattern_c
user = with_db_semaphore { User.find(params[:id]) }
posts = with_db_semaphore { Post.where(user_id: params[:id]).order(created_at: :desc).to_a }
comments = with_db_semaphore { Comment.where(user_id: params[:id]).order(created_at: :desc).to_a }
stats = with_db_semaphore do
{
post_count: Post.where(user_id: params[:id]).count,
comment_count: Comment.where(user_id: params[:id]).count
}
end
uri = URI("http://localhost:8888/delay/0.3")
externals = []
fetch_count = 0
while fetch_count < 2
externals << Net::HTTP.start(uri.host, uri.port) do |http|
JSON.parse(http.get(uri.request_uri).body)
end
fetch_count += 1
end
render json: { user:, posts:, comments:, stats:, externals: }
end
1リクエストあたりの外部HTTP待ちは合計600ms(300ms × 2回)です。直列なので1リクエスト自体が速くなるわけではありません。しかし、Falconは各I/O待ちのたびに非同期で処理を切り替え、他のリクエストを並行して進めます。
このAPIは1リクエストあたり外部HTTP 600ms+DBクエリで合計700ms前後かかります。wrkのtimeout 2sはAPIの処理時間に対して余裕が少なく、高負荷時にタイムアウトが発生しやすい条件です。そのため、より公平な比較としてtimeout 10sでも計測しました。
timeout 2s
| 指標 | Puma(4プロセス × 20スレッド) | Falcon(4プロセス × DB接続上限20/プロセス) |
|---|---|---|
| Req/sec | 125.33 | 495.78(約4倍) |
| 完了リクエスト数(30s) | 3,767 | 14,897 |
| 成功率(2xx) | 50.9%(3,740 / 7,345) | 73.2%(14,897 / 20,338) |
| Latency Avg | 1.18s | 1.56s |
| Latency Max | 2.00s | 2.00s |
timeout 10s
| 指標 | Puma(4プロセス × 20スレッド) | Falcon(4プロセス × DB接続上限20/プロセス) |
|---|---|---|
| Req/sec | 123.39 | 487.36(約4倍) |
| 完了リクエスト数(30s) | 3,708 | 14,641 |
| 成功率(2xx) | 99.7%(3,696 / 3,708) | 99.6%(14,641 / 14,693) |
| Latency Avg | 6.89s | 1.83s |
| Latency Max | 8.86s | 2.73s |
ここで重要なのは、PumaとFalconで全く同じコードのAPIを起動している点です。Net::HTTP はFalconの非同期I/O機構(Fiber Scheduler)にフックされるため、whileループで直列に書かれた既存のHTTP呼び出しも、各イテレーション中にFalconが処理を切り替えます。既存のRailsコードをそのまま動かすだけで恩恵を受けられます。
※ただし、すべてのI/O操作が完全にノンブロッキングになるわけではありません。ライブラリの実装によってはブロッキングI/Oが混在する可能性があります。本番導入前にgem依存を個別に確認することをお勧めします。
では、この結果はマイクロサービスの文脈でどう解釈すべきでしょうか。
マイクロサービスの典型的なワークロードに利用できる
パターンCで差が出た「外部HTTP+複数DBクエリの直列処理」は、マイクロサービスのユースケースそのものだと感じました。
- 決済・金融系:外部決済API、与信照会
- 通知系:SendGrid、Twilio、Push通知
- 認証・認可系:外部IdP呼び出し
- データ集約系:複数サービスのAPIを束ねるBFF
- Webhook処理:受信して外部に転送するもの
Rails APIモード + Falconであれば、ActiveRecordや認証・バリデーションといった既存のRailsエコシステムをそのまま活かした上で、I/OバウンドなAPIでも実用的な構成が取れます。すでにRailsで開発しているチームにとっては、言語やスタックを切り替えることなくマイクロサービスを現実的な選択肢として検討できるようになりつつある、という話です。
もちろん、スループット以外にも考慮すべき点はあります。メモリ使用量・オブザーバビリティ(分散トレーシング、ログの集約)・コンテナ化との相性など、実運用では別途検証が必要な領域が残ります。この記事はあくまでスループットに絞った実験であり、「マイクロサービスとしてすべて解決済み」という意味ではありません。
では、実際にRailsをマイクロサービスで選ぶかどうか、どう判断すればよいでしょうか。
Rails API + Falconが現実的な選択肢になるケース
スループット面でI/OバウンドなAPIにRails APIが不利とされてきた前提が、少なくともI/OバウンドなAPIに関してはかなり改善が見込めそうな実験結果でした。
ただし、すべての構成で有効かというと、そうではありません。条件によって向き・不向きがあります。
Rails API + Falconが現実的な選択肢になるケース
- BFF(複数サービスのAPIを束ねる中間層)
- 外部API集約(決済、通知、IdP呼び出しなど)
- Webhook受信・転送
- I/O待ち時間が支配的なエンドポイント全般
- すでにRailsを採用していて社内の知見がRailsで充分にあるチーム
他のスタックを検討すべきケース
- CPUバウンドな処理(画像処理、暗号化など)
- 単純なCRUDが中心でI/O待ちが短いサービス(非同期I/Oの恩恵が少ない)
Rails+Falconを選ぶ前に確認が必要なケース
- MySQL利用が前提のプロジェクト(非同期ドライバが存在しないため、PostgreSQLへの切り替えが前提になる)
まずは新規の小さなマイクロサービス、特にBFFや外部API集約のようなI/Oバウンドな用途から試してみるのが現実的な一歩だと思います。Shopifyの事例がなければ、自分でこの実験をしようとは思いませんでした。RubyKaigiで聞いた話を自分の手で検証し、体験として理解できたことは、自分にとって価値ある学習経験でした。
ここまでお読み頂き、ありがとうございます!