About

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つ開いてみる。

キーポイント

1

ホスト別に分けるにはmatches?(request)を持つオブジェクトをconstraintsブロックに渡す

2

ルートオプションconstraints:にlambdaを渡すと外側ブロックの条件リストが丸ごと置き換わる(mapper.rbの@blocks = blocks(...))

3

Hashで渡したconstraintsは外側の条件を引き継ぐので問題ない

4

症状: そのcatch-allより後に定義されたサービスだけが404になる

5

直し方: lambda内でホストを見直すか、lambdaをconstraintsブロックで包む

6

RouteSet + Rack::MockRequestでサーバーなしに数秒で再現できる

ユースケース

1つのRailsアプリで複数ドメイン(マルチテナント・マルチサービス)を受ける構成 catch-all 404からActive Storageなど特定パスだけ外したいとき 「一部のアプリだけ404」のようなルーティングバグをサーバーなしで絞り込むとき
← create_or_find_byとは?同時リクエストで重複作成を防ぐ APIキーはなぜ一度しか見せないのか — 「もう一度見る」ボタンを作らなかった理由 →