---
title: "Rails Thrusterとは？gemの導入・環境変数・Kamal 2との役割分担【v0.1.27実測】"
url: "https://www.issoh.co.jp/tech/details/4298/"
published: 2024-11-25
updated: 2026-11-03
categories: ["Ruby on Rails"]
publisher: "株式会社一創"
---

# Rails Thrusterとは？gemの導入・環境変数・Kamal 2との役割分担【v0.1.27実測】

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】](/tech/details/5598/)で整理しています。

[v0.1.27のREADME](https://github.com/basecamp/thruster/blob/v0.1.27/README.md)が挙げる機能は、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](https://github.com/basecamp/thruster/blob/v0.1.27/internal/config.go) の `defaultH2CEnabled = false` により既定で無効です。HTTP/2を名乗るプロキシですが、HTTP/2で話すのはTLSを自分で張ったときか、`H2C_ENABLED=true` を指定したときだけです。

古い記事をなぞると引っかかる点が1つあります。2024年3月の[37signalsの公開告知](https://dev.37signals.com/thruster-released/)は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](https://github.com/basecamp/thruster/blob/v0.1.27/CHANGELOG.md)のうち、設定や挙動に影響する項目を抜き出しました（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](https://github.com/basecamp/thruster/blob/v0.1.27/internal/cacheable%5Fresponse.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実測】](/tech/details/2882/)で扱っています。

## 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](https://github.com/rack/rack/blob/v3.2.8/lib/rack/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](https://github.com/basecamp/thruster/blob/v0.1.27/internal/request%5Fid%5Fhandler.go) は、要求に `X-Request-ID` が無ければUUIDを振り、上流への要求と応答の両方に同じ値を付けます。クライアントから来た値をそのまま使うかどうかは `FORWARD_HEADERS` で決まり、TLSを張らない構成では既定で信頼します。

Kamal 2構成では、ここに前段がもう1つ加わります。[kamal-proxy v0.10.2](https://github.com/basecamp/kamal-proxy/blob/v0.10.2/internal/server/request%5Fid%5Fmiddleware.go)も同じく「無ければ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設定](https://github.com/basecamp/kamal/blob/v2.12.0/lib/kamal/configuration/docs/proxy.yml)では、`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秒以上かかってからヘッダを返すエンドポイントは前者に、大きなファイルのダウンロードのように本文の送信が長引く応答は後者に引っかかります。延ばすのは該当する側だけで構いません。切り替え時の取りこぼしを防ぐ手順は[ゼロダウンタイムデプロイとは？無停止を壊す発生源と設定順序・計測方法を実装者目線で解説](/tech/details/16200/)で詳しく扱っています。

なお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の既定値と理由コード](/tech/details/18067/)を参照してください。

もう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導入支援](/service/system/devops/)で相談を受けています。

## よくある質問

### 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への移行で壊れる設定](/tech/details/3362/)で整理しています。

## 関連記事

- [ゼロダウンタイムデプロイとは？無停止を壊す発生源と設定順序・計測方法を実装者目線で解説](/tech/details/16200/)
- [Rackとは？Rubyのサーバーとアプリをつなぐ仕様・config.ru・ミドルウェアの書き方【Rack 3.2】](/tech/details/5598/)
- [リバースプロキシとは？仕組み・フォワードプロキシとの違いと導入判断を実装目線で解説](/tech/details/13554/)
- [Rails 7.2のEOLは2026年8月9日｜最終版7.2.4と8.1への移行で壊れる設定](/tech/details/3362/)
- [rails assets:clobberとは？cleanとの違いと消える範囲【Rails 8.1・Propshaft 1.3実測】](/tech/details/2882/)

---

出典: [Rails Thrusterとは？gemの導入・環境変数・Kamal 2との役割分担【v0.1.27実測】](<https://www.issoh.co.jp/tech/details/4298/>)（株式会社一創）
