Ruby

Thrusterとは?Rails 8のHTTP/2プロキシ設定とKamal 2との役割分担

Thrusterとは?Rails 8のHTTP/2プロキシ設定とKamal 2との役割分担

Thrusterは、Rails 8以降で新規アプリに既定で入るHTTP/2プロキシです。Pumaの前段に置いてアセットのHTTPキャッシュ・gzip圧縮・X-Sendfileによる静的ファイル配信を引き受けます。設定ファイルは持たず、環境変数だけで挙動を変える設計です。一方でKamal 2はサーバー側にkamal-proxyを立てるため、「Thrusterとkamal-proxyで二重にプロキシしているのでは」「TLSはどちらで終端するのか」で迷いやすい構成でもあります。この記事では Thruster 0.1.23 / Kamal 2.12.0 / Rails 8.1.3.1(いずれも2026年8月9日時点の最新公開版)のソースと実測をもとに、設定値と役割分担を整理します。

まとめ

  • Thrusterが担うのは、HTTP/2・アセットのHTTPキャッシュ・gzip圧縮・X-Sendfile・Let’s EncryptによるTLS取得の5つ。設定ファイルは無く、すべて環境変数で制御します。
  • 既定で無効なのはTLSと、平文でのHTTP/2(h2c)の2つです。TLS_DOMAIN を設定しない構成では H2C_ENABLED も既定 false のため、Thruster区間はHTTP/1.1で動きます。
  • Rails 8.1.3.1が生成するDockerfileの既定CMDは ["./bin/thrust", "./bin/rails", "server"]、EXPOSEは80。Thrusterが80で待ち受け、Pumaを3000で子プロセスとして起動します。
  • Kamal 2と併用する場合、TLSはkamal-proxyが終端します。Kamalの proxy.app_port 既定値80がThrusterの待ち受けポートと一致するため、既定構成では追加設定が要りません。
  • X-SendfileはThruster側で既定有効ですが、rack 3.2.3以降では config.action_dispatch.x_sendfile_header(既定 nil)を明示しない限り働きません。

以下、役割の切り分け、導入手順、環境変数、実測値、Kamal 2との接続点の順に見ていきます。

ThrusterがPumaに足す5つの機能

Thrusterは、Pumaを置き換えるものではなくPumaの前段に挟むGo製のプロキシです。Pumaを子プロセスとして起動して面倒を見るため、コンテナ内でsupervisordのようなプロセスマネージャを別途動かす必要がありません。基盤にあるRackの層とサーバーの役割分担についてはRackとは何か?基本概念とWebアプリケーションでの役割で整理しています。

v0.1.23のREADMEは機能を4項目で列挙していますが、そのうち1項目が「X-Sendfile support and compression」と2つを束ねているため、実質は5機能です。HTTP/2のサポート、Let’s Encryptによる証明書の自動取得と更新、公開アセットのHTTPキャッシュ、X-Sendfileによる静的ファイル配信、レスポンスのgzip圧縮。この5つになります。

ここで注意したいのが、既定で無効な設定が2つある点です。1つはTLSで、証明書を取るには対象ドメインが必要なため TLS_DOMAIN が未設定ならHTTP専用モードになります。もう1つが平文でのHTTP/2、いわゆるh2cです。v0.1.23の internal/config.godefaultH2CEnabled = false を持ち、H2C_ENABLED を明示しない限り有効になりません。つまり後述するKamal 2併用構成のようにTLSをThrusterに任せない場合、Thruster区間はHTTP/1.1で動きます。HTTP/2を名乗る製品ですが、既定でそれが効くのはTLSを張ったときだけです。

Pumaだけの構成と比べたときの差は、Rubyプロセスを消費せずに済む処理をGo側へ逃がせる点にあります。アセットのキャッシュヒットはRubyに到達せず、圧縮もPumaのワーカーを塞ぎません。逆に言えば、CDNやALBが前段にあってこれらを既に引き受けているなら、Thrusterの取り分は小さくなります。前段プロキシとの重複判断はリバースプロキシとは?仕組み・フォワードプロキシとの違いと導入判断を実装目線で解説の観点がそのまま使えます。

Thrusterの導入手順とthrustコマンド

Gemfileへの追加と起動コマンド

ThrusterはGoで書かれたバイナリをRubyのgemが配布する形を取ります。gemを入れるとプラットフォームに合ったバイナリが取得され、thrust コマンドが使えるようになる仕組みです。あとは既存の起動コマンドの前に thrust を付けるだけで、Thruster配下でPumaが起動します。

# Gemfile
gem "thruster", require: false

# 起動(Puma を Thruster 配下で動かす)
$ bin/thrust bin/rails server

# TLS を Thruster に任せる場合のみ
$ TLS_DOMAIN=myapp.example.com bin/thrust bin/rails server

Rails 8以降の新規アプリでは、gem "thruster", require: false がGemfileテンプレートに最初から入っています。Rails 8.1.3.1のテンプレートに添えられたコメントは「Add HTTP asset caching/compression and X-Sendfile acceleration to Puma」で、TLSに触れていません。Kamal前提の既定構成ではTLSをThrusterに任せない、という設計意図が読み取れます。

Rails 8.1が生成するDockerfileの既定

生成されるDockerfileの末尾は次のとおりです。ポート80を公開し、bin/thrust 経由でRailsサーバーを起動します。

# Rails 8.1.3.1 の Dockerfile テンプレート(thruster 有効時)
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

Thrusterは TARGET_PORT(既定3000)を PORT 環境変数として子プロセスに渡します。つまりPumaは3000、外向きの受け口は80という二段構えです。コンテナに docker exec して3000番へcurlすればPumaの生レスポンス、80番へcurlすればThruster通過後のレスポンスが取れるので、切り分けの入口になります。なお rails new --skip-thruster で生成した場合はGemfileにgemが入らず、Dockerfileも EXPOSE 3000CMD ["./bin/rails", "server"] になります。

環境変数によるThrusterの設定

Thrusterに設定ファイルはありません。v0.1.23で参照される主な環境変数と既定値は次のとおりです。

環境変数 既定値 用途
TLS_DOMAIN なし TLS証明書の対象ドメイン。未設定でHTTP専用
H2C_ENABLED 無効 平文HTTPでのHTTP/2(h2c)
TARGET_PORT 3000 Pumaを起動するポート
HTTP_PORT 80 HTTPの待ち受けポート
HTTPS_PORT 443 HTTPSの待ち受けポート
CACHE_SIZE 64MB HTTPキャッシュ全体のサイズ
MAX_CACHE_ITEM_SIZE 1MB キャッシュ1件あたりの上限
GZIP_COMPRESSION_ENABLED 有効 gzip圧縮の有無
GZIP_COMPRESSION_JITTER 32 圧縮サイズに足すパディングの上限
GZIP_COMPRESSION_DISABLE_ON_AUTH false 認証付きリクエストの圧縮停止
X_SENDFILE_ENABLED 有効 X-Sendfileヘッダの解釈
MAX_REQUEST_BODY 0 リクエストボディ上限。0は無制限
FORWARD_HEADERS TLS時のみ無効 X-Forwarded-*の転送
STORAGE_PATH ./storage/thruster 内部状態(主にTLS証明書)の保存先
BAD_GATEWAY_PAGE ./public/502.html 502時に返すHTML
HTTP_IDLE_TIMEOUT 60 アイドル切断までの秒数
HTTP_READ_TIMEOUT 30 リクエスト受信の上限秒数
HTTP_WRITE_TIMEOUT 30 レスポンス送信の上限秒数
DEBUG 無効 デバッグログの出力

アプリ側の環境変数と名前が衝突しうる点には注意が要ります。Thrusterはすべての変数に THRUSTER_ 接頭辞版を受け付け、接頭辞付きが設定されていればそちらが優先されます。TLS_DOMAINDEBUG のような一般的な名前をアプリでも使っているなら、接頭辞付きで書いておくほうが安全です。

証明書の保存先も見落としやすい設定です。STORAGE_PATH の既定はコンテナ内の ./storage/thruster で、ThrusterにTLSを任せる構成ではここが永続化されていないとコンテナを作り直すたびにLet’s Encryptへ再申請することになります。Rails 8.1が生成する config/deploy.yml には volumes: - "myapp_storage:/rails/storage" が有効な状態で入っており、Dockerfileの WORKDIR/rails なので storage/thruster もこのボリュームに収まります。ただしこのボリュームはSQLiteのデータベースファイルとローカルのActive Storageのために用意されたもので、TLS証明書が乗るのは結果論です。

v0.1.23を実際に動かして測った挙動

環境変数表だけでは、どのレスポンスが実際にキャッシュされ、どの設定が本当に効くのかが分かりません。そこでThruster v0.1.23のバイナリをビルドし、Pumaの代わりに最小のHTTPバックエンドを置いて挙動を計測しました(go1.26.5、darwin-amd64)。

検証に使ったバックエンドと起動

バックエンドは3経路だけを返します。/assets/app.css はキャッシュ可能な16,400バイトのCSS、/dynamic は同じ本文を Cache-Control: no-store で返す経路、/sendfile は本文0バイトで X-Sendfile ヘッダだけを返す経路です。標準ライブラリのアクセスログが出るので、リクエストが上流へ届いたかどうかも読み取れます。

# backend.py
import os, http.server, socketserver
BODY = ("body{color:#333;background:#fff;margin:0}" * 400).encode()  # 16,400 bytes

class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def do_GET(self):
        if self.path.startswith("/assets/"):
            hdrs, body = [("Content-Type", "text/css"), ("Cache-Control", "public, max-age=31556952")], BODY
        elif self.path == "/sendfile":
            hdrs, body = [("Content-Type", "text/plain"), ("X-Sendfile", "/tmp/th2/big.txt")], b""
        else:
            hdrs, body = [("Content-Type", "text/html"), ("Cache-Control", "no-store")], BODY
        self.send_response(200)
        for k, v in hdrs:
            self.send_header(k, v)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

socketserver.ThreadingTCPServer.allow_reuse_address = True
socketserver.ThreadingTCPServer(("127.0.0.1", int(os.environ["PORT"])), H).serve_forever()
$ go install github.com/basecamp/thruster/cmd/[email protected]
$ mkdir -p /tmp/th2
$ python3 -c "open('/tmp/th2/big.txt','w').write('SENDFILE-PAYLOAD\n'*500)"   # 8,500 bytes
$ HTTP_PORT=8080 TARGET_PORT=8081 thrust python3 backend.py

キャッシュとgzipの実測値

公開アセットは2回目以降 X-Cache: hit となり、バックエンドのアクセスログにも1回しか現れませんでした。同じ本文でも Cache-Control: no-store を付けた経路は何度呼んでも X-Cache: miss のままです。キャッシュ可否はレスポンスヘッダで決まります。

$ for i in 1 2 3; do curl -s -o /dev/null -D - http://127.0.0.1:8080/assets/app.css | grep -i x-cache; done
X-Cache: miss
X-Cache: hit
X-Cache: hit

$ curl -s -H 'Accept-Encoding: gzip' http://127.0.0.1:8080/dynamic | wc -c
     136
$ curl -s http://127.0.0.1:8080/dynamic | wc -c
   16400

Rails 8.1が生成する production.rbconfig.public_file_server.headerscache-control: public, max-age=#{1.year.to_i}、すなわち31,556,952秒を設定します。public/ 配下のアセットは追加設定なしでキャッシュ経路に乗るということです。フィンガープリント付きアセットの運用はrails assets:clobberとは?precompile・cleanとの違いとアセットパイプライン運用【Rails 8 Propshaft対応】で扱っています。

gzipは16,400バイトの本文が136バイトになりました。Accept-Encoding: gzip を送らないと圧縮されない点は、計測時のつまずきどころです。ここで挙動が誤解されやすいのがBREACH対策のジッターでした。「ランダムなパディングを足す」と読むと同じ本文でもサイズが毎回ぶれる想定になりますが、同一レスポンスを5回取得しても136バイトで一定です。Thrusterが使うgzhttpの RandomJitter はペイロードのチェックサムからパディング量を決める実装で、乱数ではありません。THRUSTER_GZIP_COMPRESSION_JITTER=0 で起動し直すと128バイトになるので、この本文に対するパディングは8バイトと分かります。既定値32は上限で、実際に足す量は本文ごとに変わる点に注意してください。同一内容の応答サイズを揺らす対策ではないため、認証済みリクエストの側路を塞ぎたいなら GZIP_COMPRESSION_DISABLE_ON_AUTH=true で圧縮そのものを止める判断になります。

リクエスト側の上限も試しました。MAX_REQUEST_BODY=1024 を付けて起動し直し、2,000バイトのPOSTを投げるとクライアントには413が返ります。ただしバックエンドのアクセスログにはこのPOSTが記録されました。Thrusterはリクエストを上流へ中継したうえでボディの読み出しを上限で打ち切り、その時点で413へ差し替える実装です。上流に一切届かせない用途には使えません。

$ MAX_REQUEST_BODY=1024 HTTP_PORT=8080 TARGET_PORT=8081 thrust python3 backend.py
$ python3 -c "open('/tmp/th2/p2000','wb').write(b'x'*2000)"
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST --data-binary @/tmp/th2/p2000 http://127.0.0.1:8080/upload
413

X-Sendfileが既定では効かない理由

バックエンドが Content-Length: 0X-Sendfile ヘッダだけを返した経路では、Thrusterが指定ファイルを読んで8,500バイトを配信し、X-Sendfile ヘッダはクライアントへ漏れませんでした。プロキシ側の実装は期待どおりです。

ThrusterはRailsに協力を求める側の準備もしています。internal/sendfile_handler.go は上流へ渡すリクエストに X-Sendfile-Type: X-Sendfile を付与し、Railsのミドルウェア Rack::Sendfile に拾わせる設計です。問題はrack側の仕様変更でした。rack 3.2.2までの Rack::Sendfile#variation はこのリクエストヘッダを読んでいましたが、3.2.3で「攻撃者がこのヘッダでx-accel-redirectを有効化できてしまう」という理由から意図的に読まなくなっています。2026年8月時点の最新は3.2.6です。

その結果、rack 3.2.3以降ではRails側の設定だけが判断材料になります。actionpack 8.1.3.1の action_dispatch/railtie.rbconfig.action_dispatch.x_sendfile_header = nil を既定とし、rails new が生成する production.rb もこれを上書きしません。既定のRails 8アプリは send_file でX-Sendfileヘッダを出さず、Thruster側の X_SENDFILE_ENABLED が有効でも配信はRubyプロセスを通ります。ファイル配信をGo側へ逃がすなら、次の1行が要ります。

# config/environments/production.rb
config.action_dispatch.x_sendfile_header = "X-Sendfile"

この設定が効くのはThrusterがファイルを直接読める場合だけです。Active Storageでクラウドストレージへ委譲している構成では、そもそもローカルにファイルがありません。

Kamal 2との役割分担と設定の落とし穴

kamal-proxyとThrusterの守備範囲

Kamal 2はサーバー上でkamal-proxyというコンテナを80番と443番で常駐させ、そこからアプリコンテナへ振り分けます。Kamal 1系のTraefik構成から置き換わった部分です。TLSの終端、ホスト名によるルーティング、リクエストのドレインを含む無停止切り替えはkamal-proxyの担当で、Thrusterはアプリコンテナの中でアセットキャッシュと圧縮を担当します。役割が重ならないため、proxy.ssl: true を使う構成ではThrusterの TLS_DOMAIN は設定しません。設定すると443番の取り合いになります。

# Kamal 2.12.0 の deploy.yml テンプレート(bin/kamal init が生成)
proxy:
  ssl: true
  host: app.example.com
  # Proxy connects to your container on port 80 by default.
  # app_port: 3000

なおRailsの rails new が生成する config/deploy.yml では、この proxy ブロックが丸ごとコメントアウトされています。手元のファイルに proxy: が見当たらないのはそのためで、HTTPSを使うならコメントを外し、あわせて production.rbconfig.assume_sslconfig.force_ssl を有効にします。

app_portの既定80とThrusterの関係

Kamalの proxy.app_port は既定80です(lib/kamal/configuration/proxy.rbfetch("app_port", 80))。Thrusterの HTTP_PORT 既定も80なので、既定同士がかみ合っています。事故が起きるのはThrusterを外したときです。--skip-thruster の構成ではPumaが3000で待つため、app_port: 3000 を明示しない限りkamal-proxyは80番へ繋ぎに行き、ヘルスチェックが通らずデプロイが失敗します。テンプレートにコメントアウトで app_port: 3000 が置かれているのは、この差し替えを想定した用意です。

無停止切り替えのタイムアウト既定値

Kamal 2.12.0の既定値は、コンテナが ready になるまで待つ deploy_timeout が30秒、接続を抜くまでの drain_timeout が30秒です。readiness_delay の7秒も既定ですが、こちらはドキュメントに「プロキシを持たず、ヘルスチェックも指定しないコンテナにだけ適用される」と明記されているため、kamal-proxy配下のwebロールでは働きません。無停止切り替えを詰めるときに触るのは前者2つです。

ジョブ実行コンテナもこの30秒で止められるため、長時間ジョブは途中で切られます。Rails 8.1で入ったActive Job Continuationsは、この30秒制限を主眼に置いてジョブをステップ分割し再開可能にする機能です(Active Job Continuationsとは何か?長時間ジョブを中断・再開可能にする新機能の基本概念)。切り替え時に取りこぼしを出さないための設定順序はゼロダウンタイムデプロイとは?無停止を壊す発生源と設定順序・計測方法を実装者目線で解説で詳しく扱っています。

Rails 8.1で入ったレジストリ不要デプロイ

Rails 8.1のリリースノートは「Registry-Free Kamal Deployments」を主要変更として挙げています。Kamal 2.8以降、単純なデプロイではDocker HubやGHCRのような外部レジストリを用意せず、ローカルレジストリを既定で使えるようになりました。2.12.0のテンプレートも registry.serverlocalhost:5555 を置き、設定側は localhost: で始まるサーバー名をローカル判定します。初回デプロイまでの準備が1つ減った形です。大規模構成では従来どおり外部レジストリを使います。

Rails 8既定スタックとSolidツール群の現在地

Thrusterと同時にRails 8の既定へ入ったのが、データベースを裏側に使うSolidツール群です。テンプレートのコメントが示すとおり「database-backed adapters」であり、SQLite専用ではありません。新規アプリの既定データベースがSQLiteであるため、結果としてSQLiteで完結する構成が既定になります。

2026年8月9日時点の公開版は solid_queue 1.6.0、solid_cable 4.0.2、solid_cache 1.0.10 です。Rails 8.1が生成する database.yml は本番環境を primary / cache / queue / cable の4接続に分け、cache・queue・cable にはそれぞれ専用の migrations_paths を割り当てます。SQLiteは書き込み時にデータベース単位でロックするため、ジョブとキャッシュを同じファイルに相乗りさせると互いの書き込みを待つことになります。ファイルを分ける既定はその回避策です。ジョブ側の使い分けはRails Active Jobとは?非同期ジョブ処理の実装とSolid Queue・リトライ設定、WebSocket側の仕組みはAction Cableとは?リアルタイム通信を可能にする仕組みを参照してください。

Thrusterを外す判断基準

既定で入るからといって常に有利とは限りません。外したほうが構成が単純になるのは次の2つです。

1つは、CDNやALB、Cloud Load Balancingがアプリコンテナの前段にあり、そこで圧縮と静的配信のキャッシュを既に担っている場合。Thrusterのキャッシュは1コンテナ内のメモリ64MBで、エッジキャッシュと比べれば役割が小さく、二重圧縮の設定ミスを持ち込む分だけ不利になります。

もう1つは、KubernetesやECSでコンテナを運用し、Ingressやサイドカーがルーティングと圧縮を持っている場合です。この構成ではThrusterのTLS機能に出番がなく、h2cも既定で無効なのでHTTP/2終端も働きません。残るのはX-Sendfileだけで、それも明示設定が要ります。そこだけのためにプロキシを1段挟む判断は取りにくいでしょう。

逆に、1台のVPSにコンテナを1つ置いて公開するようなKamal本来の想定内なら、外す理由はほとんどありません。既定のまま使い、STORAGE_PATH の永続化と x_sendfile_header の明示だけ押さえれば済みます。

よくある質問

Thrusterとは何をするものですか?

Rubyのアプリケーションサーバー Puma の前段に置くGo製のHTTP/2プロキシです。HTTP/2、Let’s EncryptによるTLS証明書の自動取得、公開アセットのHTTPキャッシュ、gzip圧縮、X-Sendfileの5機能を担い、Pumaを子プロセスとして起動します。Rails 8以降の新規アプリには既定で組み込まれます。

ThrusterとPumaはどちらか一方だけで動きますか?

Pumaは単体で動きますが、Thrusterは単体では動きません。Thrusterはアプリを実行せず、thrust の引数として渡したコマンド(通常は bin/rails server)を起動して前段でリクエストを受けるだけだからです。Thrusterは TARGET_PORT(既定3000)を PORT として子プロセスへ渡します。

Kamal 2を使う場合もTLS_DOMAINを設定しますか?

設定しません。deploy.ymlproxy.ssl: true を指定すると、Let’s Encryptによる証明書取得とHTTPSの終端はサーバー上のkamal-proxyが行います。Thruster側でも TLS_DOMAIN を設定すると443番の待ち受けが競合します。Thrusterは HTTP_PORT の80でHTTPだけを受ける構成が既定です。

Thrusterを外すときはどこを直しますか?

Gemfileの gem "thruster"、DockerfileのCMDとEXPOSE、そしてKamalの proxy.app_port の3か所です。Pumaが3000で待つようになるため、app_port: 3000 を明示しないとkamal-proxyが80番へ接続を試みてヘルスチェックに失敗します。

Rails 8より前のバージョンでもThrusterは使えますか?

使えます。Thrusterはgemを追加して起動コマンドの先頭に thrust を付けるだけで、Railsのバージョンに依存する組み込みはありません。既定で生成されるのがRails 8以降というだけです。Rails 7.2以前からの移行を検討している場合はRails 7.2の主な変更点とサポート終了日|Rails 8.1への移行判断で移行時期の判断材料を確認してください。

関連記事

資料請求

RELATED POSTS 関連記事