배포는 성공했고, 호출은 404였다
관리 콘솔은 배포완료라고 했고, 생성 API 는 200 을 돌려줬고, 라우트는 실제로 등록돼 있었다. 그런데 그 라우트에는 요청이 닿을 수 없었다.
한 고객사에서 신규 에이전트의 API 호출이 항상 404 를 반환한다는 문의가 왔다.
POST /agent/api/v2/a1/main
-> {"detail":"Not Found"}확인할 수 있는 건 전부 정상이었다. 관리 콘솔의 상태는 배포완료였고, API 접근 토글은 켜져 있었고, 등록 API 의 응답은 200 이었다. 같은 사이트의 다른 에이전트는 멀쩡했다.
POST /agent/api/a10/test -> 200게이트웨이는 아니었다
404 의 첫 번째 용의자는 언제나 앞단이다. 라우팅 규칙이 잘못됐거나, 인그레스가 경로를 못 찾거나.
두 가지가 그걸 부정했다. 첫째, 파드 로그에 요청이 남아 있었다. 응답시간 24ms. 요청은 앞단을 통과해서 애플리케이션까지 도달했다. 둘째, 응답 바디가 {"detail":"Not Found"} 였다. 이건 우리가 쓰는 응답 봉투가 아니라 FastAPI 의 기본 404 포맷이다.
즉 앱이 요청을 받았고, 앱이 라우트를 못 찾았다고 말하고 있었다. 그런데 그 라우트는 등록돼 있었다.
마운트는 접두어로 매칭된다
이 엔진은 사용자가 등록한 에이전트마다 별도의 FastAPI 서브앱을 만들고, 별칭을 경로로 삼아 루트 앱에 붙인다.
app.mount(f"/{agent_alias}", subapp)문제의 사이트에는 별칭이 이런 두 에이전트가 있었다.
| 에이전트 | 별칭 | 마운트 경로 |
|---|---|---|
| 첫 번째 | api | /api |
| 두 번째 | api/v2 | /api/v2 |
Starlette 의 라우터는 등록된 라우트를 순서대로 순회하며 첫 매치를 사용한다. 그리고 Mount 는 완전 일치가 아니라 접두어 매칭이다.
01두 번째 에이전트를 호출하려는 요청이다.
배포는 성공했다. 라우트도 있다. 앞선 마운트에 가려져서 호출되지 않을 뿐이다.
순서를 뒤집으면 결과가 뒤집힌다
프로젝트 가상환경에서 열다섯 줄로 재현했다.
from fastapi import FastAPI
from fastapi.testclient import TestClient
sa = FastAPI()
@sa.post("/a10/test")
def _a(): return {"ok": 1}
sb = FastAPI()
@sb.post("/a1/main")
def _b(): return {"ok": 2}
app = FastAPI()
app.mount("/api", sa) # 짧은 경로 먼저
app.mount("/api/v2", sb)
c = TestClient(app)
c.post("/api/a10/test") # 200
c.post("/api/v2/a1/main") # 404여기서 이 버그의 진짜 성질이 드러난다. 정답이 마운트 순서에 달려 있다. 그리고 마운트 순서는 파드가 재시작할 때 배포 상태를 복원하는 순서에서 나온다. 그 순서는 보장되지 않는다.
두 가지 안, 하나를 버렸다
1안. 별칭을 검증한다. 등록 시점에 슬래시가 든 별칭을 거부한다.
2안. 마운트 순서를 경로 길이 내림차순으로 정렬한다. 긴 경로가 먼저 매칭되게 만든다.
2안이 더 근본적으로 보였다. 어떤 별칭이 들어와도 안전하니까. 하지만 대가가 있다. 에이전트는 런타임에 계속 추가되고 삭제된다. 그때마다 루트 앱의 라우트 목록을 재정렬해야 하고, 마운트 해제와 재마운트 사이에 요청이 들어오면 경합이 생긴다. 트래픽을 받는 중에 라우팅 테이블을 흔드는 코드를 상시로 안고 가야 한다는 뜻이다.
그래서 2안이 실제로 무엇을 막아주는지 재봤다.
app.mount("/api", ...) # 먼저
app.mount("/apiv2", ...) # 나중
-> /api/x 200
-> /apiv2/y 200Starlette 의 Mount 는 세그먼트 경계에서 매칭한다. /api 는 /apiv2/y 를 가로채지 않는다. 접두어라는 말이 문자열 접두어가 아니라 경로 접두어라는 뜻이었다.
그러면 충돌 조건이 하나로 좁혀진다. 별칭 하나가 다른 별칭의 경로 접두어여야 하고, 그건 별칭에 슬래시가 있어야만 가능하다. 별칭을 단일 세그먼트로 제한하면 충돌은 원천적으로 발생하지 않는다. 그 상태에서 2안의 정렬은 영원히 아무것도 바꾸지 않는 코드가 된다.
운영 중인 슬래시 별칭이 없다는 것도 확인했다. 문제의 api/v2 는 테스트하려고 처음 만들어진 것이었다.
정렬은 채택하지 않았다. 방어층 하나를 더 쌓는 대신, 방어할 것이 없는 상태를 만들었다.
등록에서 막아야 했던 이유
기존 검증은 이랬다. 별칭 필드는 문자열로만 선언돼 있었고 형식 검증이 없었다. 등록할 때 확인하는 건 같은 에이전트의 기존 별칭과 일치하는지 하나뿐이었다. 슬래시가 든 별칭이 그대로 통과했다.
그래서 이 결함의 비용이 컸다. 잘못된 입력이 성공으로 기록됐기 때문이다. 등록 API 는 200 을 돌려줬고, 콘솔은 배포완료를 표시했고, 라우트는 실제로 만들어졌다. 사용자가 볼 수 있는 모든 지표가 정상이었다. 틀렸다는 신호는 몇 시간 뒤 다른 에이전트를 호출할 때 404 라는 형태로 나타났고, 그건 원인과 아무 관련 없어 보이는 곳이었다.
값을 거부할 수 있는 유일한 시점은 그 값이 들어올 때다. 그 시점을 놓치면, 그 다음부터는 결과에서 원인을 역추적해야 한다.
남은 것
콘솔은 여전히 배포 성공이라고 말한다. 이번에는 등록 단계에서 막았으니 새로운 충돌은 생기지 않는다. 하지만 배포 상태를 "파일이 쓰였고 라우트가 등록됨"으로 정의하는 한, "그 라우트에 도달할 수 있음"은 아무도 확인하지 않는다. 배포 후에 자기 자신을 한 번 호출해 보는 검사가 있었다면 이 장애는 등록 직후에 드러났을 것이다. 아직 없다.
순서에 의존하는 결함은 테스트로 잡기 어렵다. 위 재현 코드는 열다섯 줄이지만, 그건 원인을 안 뒤에 쓴 것이다. 원인을 모르는 상태에서 이걸 잡으려면 두 에이전트를 특정 순서로 등록하는 테스트를 미리 상상해야 하는데, 그 상상 자체가 이미 답이다.
프레임워크의 매칭 규칙은 알아도 그 순간에 떠오르지 않는다. Mount 가 접두어 매칭이고 첫 매치를 쓴다는 건 문서에 있다. 문제는 그 사실이 "별칭에 슬래시를 허용하면 어떤 에이전트가 영영 도달 불가능해진다"와 연결되기까지의 거리다. 그 거리를 좁혀주는 건 문서가 아니라, 열다섯 줄짜리 재현 코드였다.