한 Rails 앱에 서비스 여러 개 — catch-all 이 옆 서비스 요청을 먹는 이유
라우트에 lambda constraints 를 주면 바깥 호스트 조건이 사라진다
Rails 앱 하나로 도메인 여러 개를 받는 구성을 만들 때 읽으면 좋은 글이다. 끝까지 읽으면 호스트별로 라우트를 나누는 기본 모양과, 404 처리를 넣다가 옆 서비스 페이지까지 404 로 만드는 함정을 피하는 법을 알게 된다.
호스트로 나누는 기본 모양
우리 저장소는 Rails 앱 하나에 서비스가 수백 개 올라가 있다. app51.negabaro.com, app55.negabaro.com 같은 도메인이 전부 같은 프로세스로 들어오고, 라우트가 Host 헤더를 보고 갈라 준다.
constraints 에 matches?(request) 를 가진 객체를 넘기면, 그 블록 안 라우트는 조건이 true 일 때만 맞는다. 우리는 SimpleHostConstraint 라는 짧은 클래스를 쓴다. 요청 호스트가 허용 목록에 있는지 보는 게 전부다.
서비스마다 블록 끝에 match '*path' 로 404 를 붙여 둔다. 모르는 주소는 그 서비스의 404 화면으로 보내려고.
함정: lambda 하나 붙였더니 옆 서비스가 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 로 끝난다. 앞에 그려진 서비스는 멀쩡하다. 그래서 "어떤 앱은 되고 어떤 앱은 안 된다" 는 묘한 증상이 된다.
고치는 법 두 가지
하나는 lambda 안에서 호스트를 다시 보는 것. 우리 저장소의 add_catch_all_404_except_active_storage_v2(allowed_hosts:) 가 이 방식이고, config/routes.rb 에 workaround 라는 주석과 함께 남아 있다.
다른 하나는 lambda 를 라우트 옵션이 아니라 constraints ->(req) { ... } do 블록으로 감싸는 것. 블록으로 주면 조건이 바꿔치기되지 않고 바깥 목록에 덧붙는다(merge_blocks_scope).
둘 다 아래 재현 스크립트로 돌려 봤다. 옆 서비스 요청이 제대로 자기 라우트로 갔다. 개인적으론 블록으로 감싸는 쪽이 낫다고 본다. 호스트 목록을 두 군데서 들고 있을 필요가 없다.
재현은 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 을 고친 앱만 열어 보면 멀쩡하다. 망가지는 건 그 뒤에 그려진 다른 앱이다. 라우트를 바꿨으면 뒤쪽 앱 주소도 하나 열어 본다.
핵심 포인트
호스트별로 나누려면 matches?(request) 객체를 constraints 블록에 넘긴다
라우트 옵션 constraints: 에 lambda 를 주면 바깥 블록의 조건 목록이 통째로 바뀐다 (mapper.rb 의 @blocks = blocks(...))
Hash 로 준 constraints 는 바깥 조건을 이어받으니 괜찮다
증상: 그 catch-all 보다 뒤에 그려진 서비스만 404 가 난다
고치기: lambda 안에서 호스트를 다시 보거나, lambda 를 constraints 블록으로 감싼다
RouteSet + Rack::MockRequest 로 서버 없이 몇 초 만에 재현할 수 있다