About

한 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 을 고친 앱만 열어 보면 멀쩡하다. 망가지는 건 그 뒤에 그려진 다른 앱이다. 라우트를 바꿨으면 뒤쪽 앱 주소도 하나 열어 본다.

핵심 포인트

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 로 서버 없이 몇 초 만에 재현할 수 있다

사용 사례

Rails 앱 하나에 도메인 여러 개(멀티 테넌트·멀티 서비스)를 받는 구성 catch-all 404 에서 Active Storage 같은 특정 경로만 빼고 싶을 때 "어떤 앱만 404" 같은 라우팅 버그를 서버 없이 좁힐 때
← create_or_find_by란? 동시 요청에서 중복 생성 막기 API 키는 왜 한 번만 보여 줄까 — 「다시 보기」 버튼을 안 만든 이유 →