1つのRailsアプリに複数サービス — catch-allが隣のサービスのリクエストを食べる理由
ルートにlambdaのconstraintsを渡すと外側のホスト条件が消える
1つのRailsアプリで複数ドメインを受ける構成を作るときに読んでほしい。最後まで読めば、ホスト別にルートを分ける基本の形と、404処理を足したら隣のサービスまで404になる罠の避け方がわかる。
ホストで分ける基本の形
うちのリポジトリは1つのRailsアプリに数百のサービスが載っている。app51.negabaro.com、app55.negabaro.com のようなドメインが全部同じプロセスに入ってきて、ルートがHostヘッダーを見て振り分ける。
constraints に matches?(request) を持つオブジェクトを渡すと、そのブロック内のルートは条件がtrueのときだけマッチする。うちは SimpleHostConstraint という短いクラスを使っている。リクエストのホストが許可リストにあるかを見るだけだ。
サービスごとにブロックの最後に match '*path' で404を付けておく。知らないURLはそのサービスの404画面に飛ばしたいからだ。
罠: lambdaを1つ付けたら隣のサービスが404
ブログアプリは記事の画像をActive Storage(/rails/active_storage/...)で配信している。ところがcatch-allがこのパスまで食うので画像が壊れる。そこでcatch-allに constraints: ->(req) { ... } を付けて、このパスだけ外したくなる。
これをやると、他のサービスの正常なページが404になる。
直感的には、外側のブロックがホストを絞ってくれるから内側のlambdaはパスだけ見ればいいと思いがちだ。そうはならない。
原因: ルートのlambdaが外側の条件を上書きする
Rails 8.0.4 の action_dispatch/routing/mapper.rb を開くと答えがある。ルートにHash以外のconstraint(lambdaや matches? オブジェクト)を渡すと、@blocks = blocks(options_constraints) で条件リストを丸ごと置き換える。外側の constraints ... do ブロックで積んだホスト条件は捨てられる。
Hashを渡した場合は @blocks = scope_params[:blocks] で外側の条件をそのまま引き継ぐ。だから constraints: { slug: /[a-z-]+/ } のようなものは問題なく、lambdaだけがまずい。
結果、そのcatch-allはホストを見ない *path になる。ルートは上から順に照合されるので、この行より後に定義されたサービスのリクエストは全部ここに引っかかって404で終わる。前に定義されたサービスは無事だ。だから「あるアプリは動いて、あるアプリは動かない」という妙な症状になる。
直し方は2つ
1つ目はlambdaの中でホストをもう一度見ること。うちのリポジトリの add_catch_all_404_except_active_storage_v2(allowed_hosts:) がこの方式で、config/routes.rb に workaround というコメント付きで残っている。
2つ目はlambdaをルートのオプションではなく constraints ->(req) { ... } do ブロックで包むこと。ブロックで渡すと条件は置き換わらず、外側のリストに追加される(merge_blocks_scope)。
どちらも下の再現スクリプトで試した。隣のサービスへのリクエストはちゃんと自分のルートに届いた。個人的にはブロックで包む方がいいと思う。ホストのリストを2か所で持たなくて済む。
再現は20行でできる
サーバーを立てる必要もない。ActionDispatch::Routing::RouteSet を自分で作って Rack::MockRequest でリクエストを流せばいい。ルートの挙動がおかしいとき、こうやって絞り込めば再起動なしで数秒で確認できる。そのまま動くコードは下の詳細にある。
constraintsの渡し方別、外側のホスト条件は残るか
| 書き方 | 外側のホスト条件 |
|---|---|
match ..., constraints: { slug: /[a-z-]+/ } | 残る |
match ..., constraints: ->(req) { ... } | 消える |
constraints ->(req) { ... } do match ... end | 残る(追加される) |
再現スクリプト(そのまま動く)
Railsプロジェクト内で bundle exec ruby repro.rb。Rails 8.0.4 で確認した。
require "action_dispatch"
require "rack/mock"
class HostC
def initialize(h) = @h = h
def matches?(r) = r.host == @h
end
ok = ->(name) { ->(env) { [200, {}, [name]] } }
set = ActionDispatch::Routing::RouteSet.new
set.draw do
constraints HostC.new("a.localhost") do
get "/hello", to: ok.("A hello")
match "*path", to: ok.("A 404"), via: :all,
constraints: ->(req) { !req.path.start_with?("/rails/active_storage") }
end
constraints HostC.new("b.localhost") do
get "/hello", to: ok.("B hello")
end
end
%w[a.localhost b.localhost].each do |host|
env = Rack::MockRequest.env_for("http://" + host + "/hello")
puts host + " -> " + set.call(env)[2].join
end
a.localhost -> A hello
b.localhost -> A 404 ← bへのリクエストをaのcatch-allが食べた
直した形 — ブロックで包む
constraints HostC.new("a.localhost") do
get "/hello", to: ok.("A hello")
constraints ->(req) { !req.path.start_with?("/rails/active_storage") } do
match "*path", to: ok.("A 404"), via: :all
end
end
a.localhost -> A hello
b.localhost -> B hello
lambda内に req.host == "a.localhost" && を入れても同じ結果になる。
⚠ 他のアプリが動くかまで見る
catch-allを直したアプリだけ開くと問題なく見える。壊れるのはその後に定義された別のアプリだ。ルートを触ったら、後ろの方のアプリのURLも1つ開いてみる。
キーポイント
ホスト別に分けるにはmatches?(request)を持つオブジェクトをconstraintsブロックに渡す
ルートオプションconstraints:にlambdaを渡すと外側ブロックの条件リストが丸ごと置き換わる(mapper.rbの@blocks = blocks(...))
Hashで渡したconstraintsは外側の条件を引き継ぐので問題ない
症状: そのcatch-allより後に定義されたサービスだけが404になる
直し方: lambda内でホストを見直すか、lambdaをconstraintsブロックで包む
RouteSet + Rack::MockRequestでサーバーなしに数秒で再現できる