Thrusterは、Rails 8以降の新規アプリに既定で入るGo製のHTTP/2プロキシです。Pumaの前段でアセットのHTTPキャッシュ、gzip圧縮、X-Sendfileによるファイル配信を引き受け、設定はすべて環境変数で行います。この記事では thruster 0.1.27(2026年10月6日公開)、Rails 8.1.4、Kamal 2.12.0 のソースを読み、手元でThrusterを動かして測った結果から、導入手順・既定値・キャッシュされる条件・Kamal 2との分担を整理します。
まとめ
- 導入は
gem "thruster", require: falseを入れて、起動コマンドの先頭にthrustを付けるだけです。Rails 8.1.4の既定DockerfileはEXPOSE 80とCMD ["./bin/thrust", "./bin/rails", "server"]で、Thrusterが80番、Pumaが3000番で待ち受けます。 - 既定で無効なのはTLSと平文HTTP/2(h2c)の2つです。Kamal 2のようにTLSを前段に任せる構成では、Thruster区間はHTTP/1.1で動きます。
- キャッシュされるのは
Cache-Controlにpublicと1秒以上の有効期限(s-maxage、無ければmax-age)を持つGET/HEADの応答だけです。Railsの通常のレスポンスはprivateなので、キャッシュに乗るのは実質public/配下のアセットに限られます。 - X-SendfileはThruster側で既定有効でも、rack 3.2.3以降はRails側で
config.action_dispatch.x_sendfile_header = "X-Sendfile"を書かないと働きません。rack 3.2.8でも同じでした。 - 0.1.26から
X-Request-IDを付けるようになり、Kamal 2構成ではkamal-proxy・Thruster・Railsの3つのログが同じIDでつながります。
ThrusterがPumaの前段で担う機能
ThrusterはPumaを置き換えるものではありません。thrust の引数に渡したコマンドを子プロセスとして起動し、その手前でリクエストを受けるプロキシです。子プロセスが終了すればThrusterも終了するので、コンテナ内にsupervisordのようなプロセス管理を置く必要がありません。Rackとアプリサーバーの役割分担はRackとは?Rubyのサーバーとアプリをつなぐ仕様・config.ru・ミドルウェアの書き方【Rack 3.2】で整理しています。
v0.1.27のREADMEが挙げる機能は、HTTP/2、Let’s EncryptによるTLS証明書の自動取得と更新、公開アセットのHTTPキャッシュ、X-Sendfileとgzip圧縮です。READMEは「most features are automatically enabled」と書いていますが、TLSは TLS_DOMAIN を設定しない限りオフで、平文でのHTTP/2(h2c)も internal/config.go の defaultH2CEnabled = false により既定で無効です。HTTP/2を名乗るプロキシですが、HTTP/2で話すのはTLSを自分で張ったときか、H2C_ENABLED=true を指定したときだけです。
古い記事をなぞると引っかかる点が1つあります。2024年3月の37signalsの公開告知はTLSの有効化に SSL_DOMAIN を使うと書いていますが、この変数は0.1.4で TLS_DOMAIN へ改名されました(CHANGELOGに [BREAKING] と明記)。今の版で SSL_DOMAIN を設定しても何も起きません。
Thruster gemの導入とthrustコマンド
Gemfileへの追加と起動
ThrusterはGoのバイナリをRubyのgemとして配布しています。bundle install するとプラットフォーム別のバイナリが入り、bundle exec thrust で起動できます。Rails 8以降の新規アプリには bin/thrust も生成されますが、既存アプリでgemだけ足した場合は bundle binstubs thruster を実行するまで bin/thrust はありません。Rails 7.2以前のアプリでも、gemを足して起動コマンドを変えるだけで使えます。
# Gemfile(Rails 8以降の新規アプリには最初から入っている)
gem "thruster", require: false
# Puma を Thruster 配下で起動する
$ bundle exec thrust bin/rails server
# TLS を Thruster に任せる場合だけ(Kamal 2 併用時は設定しない)
$ TLS_DOMAIN=myapp.example.com bundle exec thrust bin/rails server
Rails 8.1.4のGemfileテンプレートは、このgemに「Add HTTP asset caching/compression and X-Sendfile acceleration to Puma」というコメントを添えています。コメントに書かれているのはキャッシュ・圧縮・X-Sendfileの3つで、TLSとHTTP/2には触れていません。
Rails 8.1.4が生成するDockerfileとポート
# Rails 8.1.4 の Dockerfile テンプレート末尾(thruster 有効時)
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
# rails new --skip-thruster で生成した場合
EXPOSE 3000
CMD ["./bin/rails", "server"]
Thrusterは TARGET_PORT(既定3000)を PORT 環境変数として子プロセスに渡し、Pumaはそのポートで待ち受けます。外向きは80番、Pumaは3000番という二段構えなので、コンテナ内で3000番へcurlすればPumaの生の応答、80番へcurlすればThrusterを通った応答が取れます。キャッシュや圧縮の切り分けはここから始めます。
環境変数の一覧と既定値
Thrusterに設定ファイルはありません。v0.1.27の internal/config.go が読む主な環境変数と既定値は次のとおりです。容量はバイト数、時間は秒数を単位なしの整数で指定します(CACHE_SIZE=134217728 のように書き、128MB や 90s と書くと整数として読めず既定値のまま動きます)。ACMEの接続先を変える ACME_DIRECTORY・EAB_KID・EAB_HMAC_KEY は、Let’s Encrypt以外の認証局を使うときだけ要るので省いています。
| 環境変数 | 既定値 | 用途 |
|---|---|---|
| TLS_DOMAIN | なし | 証明書を取るドメイン(カンマ区切り可) |
| H2C_ENABLED | 無効 | 平文HTTPでのHTTP/2 |
| TARGET_PORT | 3000 | Pumaを起動するポート |
| HTTP_PORT / HTTPS_PORT | 80 / 443 | 待ち受けポート |
| CACHE_SIZE | 64MB | キャッシュ全体の上限 |
| MAX_CACHE_ITEM_SIZE | 1MB | キャッシュ1件の上限 |
| GZIP_COMPRESSION_ENABLED | 有効 | gzip圧縮 |
| GZIP_COMPRESSION_JITTER | 32 | BREACH対策のパディング上限 |
| GZIP_COMPRESSION_DISABLE_ON_AUTH | false | Cookie等付きの要求で圧縮しない |
| X_SENDFILE_ENABLED | 有効 | X-Sendfileの解釈 |
| MAX_REQUEST_BODY | 0(無制限) | リクエストボディの上限 |
| FORWARD_HEADERS | TLS時は無効 | X-Forwarded-*とX-Request-IDの信頼 |
| STORAGE_PATH | ./storage/thruster | TLS証明書の保存先 |
| BAD_GATEWAY_PAGE | ./public/502.html | 502のときに返すHTML |
| HTTP_IDLE_TIMEOUT | 60秒 | アイドル接続の切断 |
| HTTP_READ_TIMEOUT / HTTP_WRITE_TIMEOUT | 30秒 / 30秒 | 受信・送信の上限 |
| LOG_REQUESTS | 有効 | リクエストごとのログ |
| DEBUG | 無効 | デバッグログ |
すべての変数は THRUSTER_ 接頭辞付きでも書け、両方あれば接頭辞付きが優先されます。DEBUG や TLS_DOMAIN のような名前をアプリ側でも使っているなら、THRUSTER_DEBUG のように接頭辞付きで書いて衝突を避けます。
ThrusterにTLSを任せる構成では、STORAGE_PATH の永続化が要ります。既定の ./storage/thruster はコンテナ内なので、ボリュームに載っていないとコンテナを作り直すたびにLet’s Encryptへ再申請します。Rails 8.1.4の config/deploy.yml は /rails/storage をボリュームにする設定が有効な状態で生成されるため(SQLite以外のデータベースを選び、かつ --skip-active-storage を付けた場合だけは生成されません)、その設定を残している限りThrusterの証明書もそこに収まります。なお、Kamal 2で proxy.ssl: true を使う通常の構成では証明書を管理するのはkamal-proxyで、Thrusterは証明書を持ちません。
0.1.15から0.1.27までの変更点
Thrusterは1.0前で、0.1系のパッチ版で挙動が変わります。CHANGELOGのうち、設定や挙動に影響する項目を抜き出しました(Goのビルド更新だけの版は省いています)。
| 版 | 日付 | 変更 |
|---|---|---|
| 0.1.27 | 2026-09-28 | 依存更新・Go 1.27.1でビルド |
| 0.1.26 | 2026-08-24 | X-Request-IDの付与とログ出力 |
| 0.1.25 | 2026-08-11 | キャッシュできない要求をすべて素通し |
| 0.1.22 | 2026-06-29 | 画像を圧縮対象から除外 |
| 0.1.20 | 2026-03-25 | HTTPSへのリダイレクトを許可ホストに限定 |
| 0.1.18 | 2026-02-02 | シグナル終了時の終了コードを修正 |
| 0.1.17 | 2025-12-16 | BREACH対策(ジッターと圧縮ガード) |
| 0.1.16 | 2025-10-19 | H2C_ENABLEDを追加・ポート確保失敗でエラー終了 |
| 0.1.15 | 2025-08-05 | SSEのためのフラッシュ対応・キャッシュキーにホストを追加 |
日付はCHANGELOGの記載で、RubyGemsでの公開日は数日ずれることがあります(0.1.27はRubyGemsでは2026年10月6日)。運用で影響が大きいのは3つです。0.1.15より前は、Server-Sent Eventsの応答がバッファされて届かないことがありました。0.1.22以降は image/png・image/webp・image/avif など圧縮済みの画像形式をgzipしません。0.1.26以降は応答に X-Request-ID が付きます(後述)。Gemfile.lock が0.1.15より古い版で止まっていてSSEやTurbo Streamsの配信が詰まるなら、まず版を上げてください。
キャッシュされるレスポンスの条件
READMEは「Basic HTTP caching of public assets」とだけ書いています。実際の条件は internal/cacheable_response.go と cache_handler.go で決まり、要求側と応答側の両方を満たしたときだけキャッシュに入ります。
- 要求側:GETかHEADであること。
Range付き、WebSocketへのUpgrade、ボディ付き、Connectionに close・keep-alive以外のヘッダ名を挙げた要求は対象外で、0.1.25以降はX-Cache: bypassを返して素通しします。 - 応答側:ステータスが200〜399(304を除く)で、
Cache-Controlにpublicがあり、private・no-store・no-cacheが無く、s-maxage(無ければmax-age)が1以上であること。 - 応答の
Varyが*か、X-Forwarded-For・X-Forwarded-Host・X-Forwarded-Protoを含む場合もキャッシュしません。1件がMAX_CACHE_ITEM_SIZE(1MB)を超える応答も対象外です。
Railsのコントローラが返す通常の応答は、何も指定しなければ Cache-Control: max-age=0, private, must-revalidate です(actionpack 8.1.4の DEFAULT_CACHE_CONTROL)。private なので、Thrusterはこれをキャッシュしません。一方、Rails 8.1.4の production.rb は public/ 配下に "cache-control" => "public, max-age=#{1.year.to_i}"(31,556,952秒)を付けるため、プリコンパイル済みアセットは追加設定なしでキャッシュに乗ります。ログイン後のページがThrusterに残って他人に見える、という事故は既定の設定では起きません。逆に、利用者によって中身が変わらないAPIの応答をThrusterでキャッシュしたいなら、expires_in 5.minutes, public: true のように明示します。Thrusterは要求にCookieや Authorization が付いていてもキャッシュの判定を変えないので、ログイン中の利用者ごとに中身が変わる応答には public: true を付けないでください。フィンガープリント付きアセットの扱いはrails assets:clobberとは?cleanとの違いと消える範囲【Rails 8.1・Propshaft 1.3実測】で扱っています。
v0.1.27の実測:gzip・X-Sendfile・リクエスト上限
検証環境とハーネス
v0.1.27をタグのソースからビルドし(go1.27.2、darwin-amd64)、Pumaの代わりに最小のHTTPバックエンドを置いて測りました。バックエンドは /public に Cache-Control: public, max-age=60、/private にRails既定と同じ private、/file に本文0バイトと X-Sendfile ヘッダを返し、受け取った X-Request-ID を記録します。
# backend.py(標準ライブラリだけで動く検証用バックエンド)
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BODY = ("<p>hello thruster</p>" * 400).encode() # 8,400 bytes
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
print(self.command, self.path, self.headers.get("X-Request-ID"), flush=True)
if self.path == "/file":
self.send_response(200)
self.send_header("X-Sendfile", "/tmp/big.txt") # 事前に置いたファイル
self.send_header("Content-Length", "0"); self.end_headers(); return
self.send_response(200)
self.send_header("Content-Type", "text/html")
if self.path == "/public":
self.send_header("Cache-Control", "public, max-age=60")
elif self.path == "/private":
self.send_header("Cache-Control", "max-age=0, private, must-revalidate")
self.send_header("Content-Length", str(len(BODY))); self.end_headers()
self.wfile.write(BODY)
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0)))
print(self.command, self.path, flush=True)
self.send_response(200)
self.send_header("Cache-Control", "public, max-age=60")
self.send_header("Content-Length", "2"); self.end_headers(); self.wfile.write(b"ok")
ThreadingHTTPServer(("127.0.0.1", int(os.environ["PORT"])), H).serve_forever()
# 端末1:backend.py を保存したディレクトリで起動する(前面で動き続ける)
$ HTTP_PORT=18480 TARGET_PORT=18481 thrust python3 backend.py
# 端末2:起動ログに "Server started" が出てから実行する
$ for i in 1 2; do curl -s -o /dev/null -D - http://127.0.0.1:18480/public | grep -i x-cache; done
X-Cache: miss
X-Cache: hit
$ for i in 1 2; do curl -s -o /dev/null -D - http://127.0.0.1:18480/private | grep -i x-cache; done
X-Cache: miss
X-Cache: miss
$ curl -s -o /dev/null -D - -X POST -d 'a=1' http://127.0.0.1:18480/public | grep -i x-cache
X-Cache: bypass
POSTは、応答が public でも bypass になりました。private の応答は何度呼んでも miss のままで、バックエンドに毎回届いています。
gzipとBREACH対策ジッターの実測値
検証データは <p>hello thruster</p> を400回繰り返した8,400バイトのHTMLで、Accept-Encoding: gzip を付けると107バイトになりました。同じ文の繰り返しという圧縮しやすいデータなので、この縮み方を実際のページにそのまま当てはめることはできません。同じ応答を5回取っても107バイトで変わりません。Thrusterが使うgzhttpのジッターはペイロードのチェックサムからパディング量を決める方式で、乱数で毎回揺らすものではないからです。GZIP_COMPRESSION_JITTER=0 で起動し直すと81バイトになったので、この本文に足されたパディングは26バイトです。既定値の32は上限で、足す量は本文ごとに変わります。
$ curl -s -o /dev/null -w '%{size_download}\n' -H 'Accept-Encoding: gzip' http://127.0.0.1:18480/
107
$ curl -s -o /dev/null -w '%{size_download}\n' -H 'Accept-Encoding: gzip' -H 'Cookie: s=1' http://127.0.0.1:18480/
107 # 既定。GZIP_COMPRESSION_DISABLE_ON_AUTH=true で起動すると 8400(非圧縮)
Cookie付きの要求も既定では圧縮されます。同じ内容の応答はサイズも同じなので、ジッターだけでは「秘密を含む応答の長さから推測する」攻撃の材料を消しきれません。ログイン後のページにCSRFトークンのような秘密値を載せていて圧縮の側路を塞ぎたいなら、GZIP_COMPRESSION_DISABLE_ON_AUTH=true で Cookie・Authorization・X-Csrf-Token 付きの要求の圧縮を止めます。帯域とのトレードオフなので、公開ページ中心のサイトなら既定のままで構いません。
X-Sendfileの有効化に必要なRails側設定
検証では、8,500バイトの乱数をbase64にした11,337バイトのテキストを /tmp/big.txt に置き、Thrusterを動かすユーザーが読める状態にしました。バックエンドが Content-Length: 0 と X-Sendfile だけを返すと、Thrusterはこのファイルを読み、11,337バイトを配信しました。プロキシ側の実装は期待どおり動きます。
問題はRails側です。Thrusterは上流への要求に X-Sendfile-Type: X-Sendfile を付け、Railsの Rack::Sendfile に拾わせる設計です。ところがrack 3.2.8の sendfile.rbは、方式はミドルウェアのコンストラクタで指定しなければならず、要求ヘッダは無視すると明記しています(3.2.3で、攻撃者がこのヘッダでx-accel-redirectを有効にできるとして削除)。actionpack 8.1.4の config.action_dispatch.x_sendfile_header の既定は nil で、rails new が生成する production.rb もこれを設定しません。
Rails 8.1.4と同じく Rack::Sendfile を積んだRackアプリをPuma 8.0.2で動かし、Thrusterの後ろに置いて確かめました。方式を指定しない既定では、Rack::Sendfile を通った応答に X-Sendfile ヘッダが無く、11,337バイトの本文をPumaが自分で送っていました。Rack::Sendfile に "X-Sendfile" を渡すと、アプリの応答は X-Sendfile: /tmp/.../big.txt と content-length: 0 になり、Thrusterがファイルを配信しました。ファイル配信をGo側へ逃がすには、次の1行を書きます。
# config/environments/production.rb
config.action_dispatch.x_sendfile_header = "X-Sendfile"
効くのは、Thrusterと同じコンテナからファイルを読める場合だけです。Active StorageでS3などにファイルを置いている構成では、ローカルにファイルが無いので使えません。
MAX_REQUEST_BODYの413応答と上流への中継タイミング
MAX_REQUEST_BODY=1024 で起動して2,000バイトのPOSTを送ると、クライアントには413が返りました。ただしバックエンドのログにはこのPOSTが残っています。Thrusterは要求を上流へ中継し始めてから、ボディの読み出しを上限で打ち切って413に差し替えるからです。大きなアップロードをアプリに一切届かせない用途には使えません。その目的なら前段のロードバランサーやkamal-proxyの buffering.max_request_body で切ります。
X-Request-IDによるkamal-proxy・Thruster・Railsのログ照合
0.1.26で入った request_id_handler.go は、要求に X-Request-ID が無ければUUIDを振り、上流への要求と応答の両方に同じ値を付けます。クライアントから来た値をそのまま使うかどうかは FORWARD_HEADERS で決まり、TLSを張らない構成では既定で信頼します。
Kamal 2構成では、ここに前段がもう1つ加わります。kamal-proxy v0.10.2も同じく「無ければUUIDを振る」実装で、振った値をアプリコンテナへ渡します。Thrusterの側はTLSを張っていないので FORWARD_HEADERS が有効で、その値を引き継ぎます。Railsは ActionDispatch::RequestId が X-Request-Id を読み、Rails 8.1.4の production.rb は config.log_tags = [ :request_id ] を有効にしています。つまり既定のままで、Railsまで届いた要求は1つのIDでkamal-proxy・Thruster・Railsの3つのログを引けます。例外が2つあります。Thrusterのキャッシュにヒットした要求はRailsに届かないので、Railsのログに行が無いのが正常です。また、クライアントが記号入りのIDを送ると、Railsは後述の正規化で文字を削るため、Rails側だけ値が変わります。
# Thruster の後ろで Rack::Sendfile と ActionDispatch::RequestId を積んだ Rack アプリを動かした結果
$ curl -s -o /dev/null -D - -H 'X-Request-ID: trace-from-lb-42' http://127.0.0.1:18484/hello | grep -i x-request-id
X-Request-Id: trace-from-lb-42
# Rails 側のログ(log_tags 相当)
INFO -- : [trace-from-lb-42] GET /hello
# Thruster 側のログ
{"level":"INFO","msg":"Request","request_id":"trace-from-lb-42","path":"/hello","status":200,...}
ヘッダを付けずに送ると、Thrusterが振った 004e1f38-... のようなUUIDがRails側のタグにもそのまま入りました。FORWARD_HEADERS=false で起動し直すと、クライアントが送った abc-123 は捨てられ、Thrusterが新しいUUIDに置き換えます。
注意点は、クライアントが送ったIDがkamal-proxyからRailsまで素通りすることです。ログを検索する人が攻撃者の付けたIDに惑わされないよう、ID単体を信用の根拠にしないでください。Rails側は英数字・_・-・@ 以外を削り255文字に切り詰めるので、ログの改行注入にはなりません。AWSのALBのように独自のトレースID(X-Amzn-Trace-Id)を持つ前段を置く場合は、そちらとの対応付けが別途要ります。
Kamal 2との役割分担と設定の落とし穴
kamal-proxyとThrusterの守備範囲
Kamal 2は、Kamal 1系のTraefikに代えて、kamal-proxyというコンテナをサーバーの80番と443番に常駐させ、アプリコンテナへ振り分けます。TLSの終端、ホスト名やパスでのルーティング、ヘルスチェックを見ながらの無停止切り替えはkamal-proxyの仕事です。Thrusterはアプリコンテナの中でキャッシュ・圧縮・X-Sendfileを受け持ちます。proxy.ssl: true を使う構成でThrusterにも TLS_DOMAIN を設定してはいけません。v0.1.27の internal/server.go は、TLSを有効にすると80番をアプリへ中継せず、許可したホスト宛てにはHTTPSへのリダイレクト、それ以外には421を返すだけになります。kamal-proxyは80番へ繋ぐので、要求もヘルスチェックもアプリに届かなくなります。
# config/deploy.yml(Rails 8.1.4 が生成。proxy ブロックはコメントアウトされている)
# proxy:
# ssl: true
# host: app.example.com
手元の deploy.yml に proxy: が見当たらないのは、このようにコメントアウトされているためです。HTTPSを使うならコメントを外し、テンプレートの注記どおり production.rb の config.assume_ssl と config.force_ssl を有効にします。Kamal 2.12.0のproxy設定では、ssl: true にするとkamal-proxyはクライアントが送ってきたX-Forwarded-*を引き継がず、自分が受けた接続の値で付け直します(forward_headers: true で引き継ぐ動作に戻せます)。
app_portの既定80とThrusterの関係
Kamalの proxy.app_port は既定80で、Thrusterの HTTP_PORT の既定80と一致します。既定同士がかみ合っているので、通常は何も書かずに済みます。事故が起きるのはThrusterを外したときです。--skip-thruster の構成ではPumaが3000番で待つため、app_port: 3000 を書かないとkamal-proxyは80番へ繋ぎに行き、ヘルスチェック(既定パス /up)が通らずデプロイが失敗します。
無停止切り替えのタイムアウト既定値
Kamal 2.12.0は deploy_timeout(新しいコンテナが正常になるまで待つ時間)と drain_timeout(古いコンテナの接続を抜く時間)をkamal-proxyの --deploy-timeout・--drain-timeout へ渡し、どちらも既定30秒です。readiness_delay(既定7秒)は、プロキシを使わずヘルスチェックも指定しないロールにだけ効く設定なので、webロールの切り替えには関係しません。タイムアウトはもう2つあり、どちらも既定30秒ですが、測っている区間が違います。kamal-proxyの response_timeout は、アプリが応答ヘッダを返し始めるまでの待ち時間です(v0.10.2はGoの ResponseHeaderTimeout に渡しています)。Thrusterの HTTP_WRITE_TIMEOUT は、要求を読み終えてから応答を書き終えるまでの上限です。処理に30秒以上かかってからヘッダを返すエンドポイントは前者に、大きなファイルのダウンロードのように本文の送信が長引く応答は後者に引っかかります。延ばすのは該当する側だけで構いません。切り替え時の取りこぼしを防ぐ手順はゼロダウンタイムデプロイとは?無停止を壊す発生源と設定順序・計測方法を実装者目線で解説で詳しく扱っています。
なおKamal 2.12.0とRails 8.1.4の deploy.yml は registry.server に localhost:5555 を置いており、外部レジストリを用意しなくても初回デプロイができます。Docker HubやGHCRを使うのは、複数台への配布やCIからのビルドが必要になってからで構いません。
Thrusterを外す判断基準
既定で入るからといって、どの構成でも得になるわけではありません。外したほうが構成が単純になるのは、次の2つの場合です。
1つは、圧縮とアセットのキャッシュを設定済みのCDNがアプリの前段にある場合です。ALBは負荷分散が役目でキャッシュも圧縮もしないので、ALB単体ではThrusterの代わりになりません。Thrusterのキャッシュは1コンテナのメモリ内(既定64MB)で、コンテナを増やせばキャッシュも分散します。外すかどうかは、Thrusterのログの cache 欄で hit の割合を見て、CDNを通った後にまだヒットがあるかで決めます。CloudFrontはオリジンが圧縮済みで返した応答を再圧縮しないので、Thrusterを残しても二重圧縮にはなりません。ALB配下のヘルスチェック設計はELBヘルスチェックの設定と失敗原因の調べ方|ALB・NLBの既定値と理由コードを参照してください。
もう1つは、KubernetesやECSで動かし、Ingressやサイドカーがルーティングと圧縮を持っている場合です。TLSは前段で終わり、h2cも既定で無効なので、Thrusterに残る仕事はアセットのキャッシュとX-Sendfileです。キャッシュをCDNが担い、X-Sendfileも使わない(ファイルをS3に置いている)なら、プロセスを1段挟む理由は薄いでしょう。
Nginxと比べると、Thrusterは設定ファイルを持たない代わりに、パスごとのルーティング、レート制限、Basic認証、細かいキャッシュ制御を持ちません。これらが要るならNginxを前段に置きます。パスごとの振り分けだけなら、Kamal 2のkamal-proxyのパス別ルーティング(path_prefix)で足ります。1台のVPSにKamalでコンテナを1つ置く構成なら、外す理由はほとんどありません。既定のまま使い、x_sendfile_header の明示と、TLSを任せる場合の STORAGE_PATH の永続化だけ押さえれば十分です。Kamalでの配備からCIでの自動化までまとめて整えたい場合は、DevOps・CI/CD導入支援で相談を受けています。
よくある質問
Thrusterとは何をするものですか?
Pumaの前段に置くGo製のプロキシで、Rails 8以降の新規アプリに既定で入ります。公開アセットのHTTPキャッシュ、gzip圧縮、X-Sendfileによるファイル配信を受け持ち、TLS_DOMAIN を設定すればLet’s Encryptで証明書も取ります。Pumaは子プロセスとしてThrusterが起動します。
ThrusterとPumaはどちらか一方だけで動きますか?
Pumaは単体で動きますが、Thrusterは単体では動きません。Thrusterはアプリを実行せず、thrust の引数に渡したコマンド(通常は bin/rails server)を起動して前段で要求を受けるだけです。
Kamal 2を使う場合もTLS_DOMAINを設定しますか?
設定しません。proxy.ssl: true にすると、証明書の取得とHTTPSの終端はkamal-proxyが行います。Thrusterは80番でHTTPだけを受ける既定のまま使います。
Thrusterを外すときはどこを直しますか?
Gemfileの gem "thruster"、DockerfileのCMDとEXPOSE、Kamalの proxy.app_port の3か所です。Pumaが3000番で待つようになるので、app_port: 3000 を書かないとヘルスチェックで失敗します。config.action_dispatch.x_sendfile_header を設定していた場合は、削除するか nil に戻します。Pumaは X-Sendfile を解釈しないので、設定が残ると send_file の応答が本文0バイトになります。
Rails 8より前のバージョンでもThrusterは使えますか?
使えます。gemを追加して起動コマンドの先頭に thrust を付けるだけで、Railsの版に依存する組み込みはありません。Rails 7.2から8.1へ上げる際に変わる設定はRails 7.2のEOLは2026年8月9日|最終版7.2.4と8.1への移行で壊れる設定で整理しています。