프록시 뒤에서

프록시 뒤에서 (Behind a Proxy)

많은 상황에서 FastAPI 앱 앞에 Traefik이나 Nginx 같은 프록시(proxy) 를 두게 돼요.

이런 프록시들이 HTTPS 인증서나 다른 것들을 처리해 줄 수 있어요.

출처: 공식문서

프록시 전달 헤더 (Proxy Forwarded Headers)

애플리케이션 앞에 있는 프록시는 요청을 서버로 보내기 전에 일반적으로 몇 가지 헤더를 즉석에서 설정해요. 이 헤더들을 통해 서버에게 이 요청이 프록시에 의해 전달(forwarded) 됐다는 것, 그리고 원래(공개) URL(도메인 포함), HTTPS를 사용한다는 사실 등을 알려주는 거죠.

서버 프로그램(예: FastAPI CLI를 통한 Uvicorn)은 이 헤더들을 해석하고 그 정보를 애플리케이션에 전달할 수 있어요.

하지만 보안상, 서버는 자신이 신뢰하는 프록시 뒤에 있다는 것을 알지 못하므로 이런 헤더들을 해석하지 않아요.

기술적 세부 사항

프록시 헤더들은 다음과 같아요.

프록시 전달 헤더 활성화하기

FastAPI CLI를 CLI 옵션 --forwarded-allow-ips와 함께 시작하고, 그 전달 헤더들을 읽도록 신뢰할 IP 주소들을 전달할 수 있어요.

--forwarded-allow-ips="*"로 설정하면 들어오는 모든 IP를 신뢰해요.

서버가 신뢰하는 프록시 뒤에 있고 오직 그 프록시만 서버와 통신한다면, 이 설정은 그 프록시의 IP가 무엇이든 받아들이게 해 줘요.

$ uv run fastapi run --forwarded-allow-ips="*"

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

HTTPS로 리다이렉트하기

예를 들어 /items/라는 path operation 을 정의했다고 해볼게요.

from fastapi import FastAPI

app = FastAPI()


@app.get("/items/")
def read_items():
    return ["plumbus", "portal gun"]

클라이언트가 /items로 가려고 하면, 기본적으로 /items/로 리다이렉트돼요.

하지만 CLI 옵션 --forwarded-allow-ips를 설정하기 전에는 http://localhost:8000/items/로 리다이렉트될 수 있었죠.

어쩌면 여러분의 애플리케이션은 https://mysuperapp.com에 호스팅돼 있고, 리다이렉트는 https://mysuperapp.com/items/가 되어야 할 수도 있어요.

--proxy-headers를 설정하면 이제 FastAPI가 올바른 위치로 리다이렉트할 수 있게 돼요. 😎

https://mysuperapp.com/items/

HTTPS에 대해 더 알고 싶다면, About HTTPS 가이드를 확인해 보세요.

프록시 전달 헤더가 동작하는 방식

클라이언트와 애플리케이션 서버 사이에서 프록시가 전달 헤더를 어떻게 추가하는지 시각적으로 나타내면 이래요.

sequenceDiagram
    participant Client
    participant Proxy as Proxy/Load Balancer
    participant Server as FastAPI Server

    Client->>Proxy: HTTPS Request<br/>Host: mysuperapp.com<br/>Path: /items

    Note over Proxy: Proxy adds forwarded headers

    Proxy->>Server: HTTP Request<br/>X-Forwarded-For: [client IP]<br/>X-Forwarded-Proto: https<br/>X-Forwarded-Host: mysuperapp.com<br/>Path: /items

    Note over Server: Server interprets headers<br/>(if --forwarded-allow-ips is set)

    Server->>Proxy: HTTP Response<br/>with correct HTTPS URLs

    Proxy->>Client: HTTPS Response

프록시는 원래 클라이언트 요청을 가로채서, 요청을 애플리케이션 서버로 전달하기 전에 특별한 forwarded 헤더(X-Forwarded-*)를 추가해요.

이 헤더들은 원래 요청에 대한 정보를 보존해요. 이 정보가 없으면 유실되잖아요.

  • X-Forwarded-For: 원래 클라이언트의 IP 주소
  • X-Forwarded-Proto: 원래 프로토콜(https)
  • X-Forwarded-Host: 원래 호스트(mysuperapp.com)

FastAPI CLI--forwarded-allow-ips로 설정되면, 이 헤더들을 신뢰하고 사용해요. 예를 들어 리다이렉트에서 올바른 URL을 생성할 때 말이죠.

경로 접두사가 제거된(stripped) 프록시

여러분의 애플리케이션에 경로 접두사(path prefix)를 추가하는 프록시가 있을 수 있어요.

이런 경우 root_path를 사용해서 애플리케이션을 설정할 수 있어요.

root_path는 ASGI 명세(FastAPI가 Starlette을 통해 기반으로 하는)가 제공하는 메커니즘이에요.

root_path는 이런 특정한 경우들을 처리하는 데 사용돼요.

그리고 하위 애플리케이션(sub-application)을 마운트할 때도 내부적으로 사용돼요.

경로 접두사가 제거된 프록시가 있다는 건, 이 경우에는 코드에서 /app에 경로를 선언하되, 그 위에 (프록시라는) 레이어를 추가해서 FastAPI 애플리케이션을 /api/v1 같은 경로 아래에 놓는다는 뜻이에요.

이 경우 원래 경로 /app은 실제로 /api/v1/app에서 서비스돼요.

코드는 전부 그냥 /app만 있다고 가정하고 작성됐지만요.

from fastapi import FastAPI, Request

app = FastAPI()


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

그리고 프록시는 요청을 앱 서버(아마 FastAPI CLI를 통한 Uvicorn)로 전송하기 전에 경로 접두사를 즉석에서 "제거(stripping)" 해요. 애플리케이션이 /app에서 서비스된다고 믿게 유지해서, 접두사 /api/v1을 넣기 위해 코드를 모두 수정할 필요가 없게 만드는 거죠.

여기까지는 모든 것이 정상적으로 동작해요.

하지만 그다음에 통합 문서 UI(프론트엔드)를 열면, /api/v1/openapi.json이 아니라 /openapi.json에서 OpenAPI 스키마를 얻으려고 기대할 거예요.

그래서 (브라우저에서 실행되는) 프론트엔드는 /openapi.json에 접근하려다 OpenAPI 스키마를 얻지 못해요.

우리 앱에 /api/v1 경로 접두사를 가진 프록시가 있기 때문에, 프론트엔드는 /api/v1/openapi.json에서 OpenAPI 스키마를 가져와야 해요.

graph LR

browser("Browser")
proxy["Proxy on http://0.0.0.0:9999/api/v1/app"]
server["Server on http://127.0.0.1:8000/app"]

browser --> proxy
proxy --> server

IP 0.0.0.0은 일반적으로 그 머신/서버에서 사용 가능한 모든 IP에서 프로그램이 리슨(listen)한다는 뜻으로 사용돼요.

문서 UI는 이 API server가 (프록시 뒤의) /api/v1에 위치한다고 선언하기 위해 OpenAPI 스키마도 필요해요. 예를 들어 이렇게요.

{
    "openapi": "3.1.0",
    // More stuff here
    "servers": [
        {
            "url": "/api/v1"
        }
    ],
    "paths": {
            // More stuff here
    }
}

이 예시에서 "프록시"는 Traefik 같은 것이 될 수 있어요. 그리고 서버는 Uvicorn과 함께 FastAPI CLI같은 것으로, 여러분의 FastAPI 애플리케이션을 실행할 거예요.

root_path 제공하기

이걸 달성하려면 --root-path 커맨드 라인 옵션을 이렇게 사용할 수 있어요.

$ uv run fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Hypercorn을 사용한다면, Hypercorn에도 --root-path 옵션이 있어요.

기술적 세부 사항

ASGI 명세는 이런 사용 사례를 위해 root_path를 정의해요.

그리고 --root-path 커맨드 라인 옵션이 그 root_path를 제공해요.

현재 root_path 확인하기

각 요청에 대해 애플리케이션이 사용하는 현재 root_path를 얻을 수 있어요. (ASGI 명세의 일부인) scope dict에 포함돼 있죠.

여기서는 데모(scope["root_path"]) 목적으로 그 값을 메시지에 넣고 있어요.

from fastapi import FastAPI, Request

app = FastAPI()


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

그다음 Uvicorn을 이렇게 시작하면,

$ uv run fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

응답은 이렇게 될 거예요.

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

FastAPI 앱에서 root_path 설정하기

또는 --root-path 같은 커맨드 라인 옵션(또는 이에 상응하는 것)을 제공할 방법이 없다면, FastAPI 앱을 만들 때 root_path 파라미터를 설정할 수 있어요.

from fastapi import FastAPI, Request

app = FastAPI(root_path="/api/v1")


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

FastAPIroot_path를 전달하는 것은 Uvicorn이나 Hypercorn에 --root-path 커맨드 라인 옵션을 전달하는 것과 동등해요.

root_path에 대해

서버(Uvicorn)는 그 root_path를 앱에 전달하는 것 외에는 아무것도 하지 않는다는 점을 기억하세요.

하지만 브라우저에서 http://127.0.0.1:8000/app로 가면 정상적인 응답을 볼 수 있어요.

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

그러니까 http://127.0.0.1:8000/api/v1/app로 접근될 것이라고 기대하지 않아요.

Uvicorn은 프록시가 http://127.0.0.1:8000/app으로 Uvicorn에 접근하기를 기대해요. 그리고 추가로 /api/v1 접두사를 붙이는 것은 프록시의 책임이 되는 거예요.

경로 접두사가 제거된 프록시에 대해

경로 접두사가 제거된 프록시는 설정 방식 중 하나일 뿐이라는 점을 기억하세요.

아마 많은 경우 기본값은 프록시에 제거된 경로 접두사가 없다는 것일 거예요.

그런 경우(제거된 경로 접두사가 없을 때), 프록시는 https://myawesomeapp.com 같은 곳에서 리슨하고, 브라우저가 https://myawesomeapp.com/api/v1/app로 가면, 서버(예: Uvicorn)가 http://127.0.0.1:8000에서 리슨하고 있을 때 프록시(제거된 경로 접두사가 없는)는 같은 경로 http://127.0.0.1:8000/api/v1/app으로 Uvicorn에 접근해요.

Traefik으로 로컬에서 테스트하기

Traefik을 사용해서 경로 접두사가 제거된 프록시를 로컬에서 쉽게 실험할 수 있어요.

Traefik을 다운로드하세요. 단일 바이너리라서 압축 파일을 풀고 터미널에서 바로 실행할 수 있어요.

그다음 traefik.toml 파일을 이런 내용으로 만드세요.

[entryPoints]
  [entryPoints.http]
    address = ":9999"

[providers]
  [providers.file]
    filename = "routes.toml"

이 설정은 Traefik에게 포트 9999에서 리슨하고 routes.toml이라는 다른 파일을 사용하라고 알려줘요.

표준 HTTP 포트 80 대신 포트 9999를 쓰는 이유는 관리자(sudo) 권한으로 실행할 필요가 없게 하려는 거예요.

이제 그 다른 파일 routes.toml을 만드세요.

[http]
  [http.middlewares]

    [http.middlewares.api-stripprefix.stripPrefix]
      prefixes = ["/api/v1"]

  [http.routers]

    [http.routers.app-http]
      entryPoints = ["http"]
      service = "app"
      rule = "PathPrefix(`/api/v1`)"
      middlewares = ["api-stripprefix"]

  [http.services]

    [http.services.app]
      [http.services.app.loadBalancer]
        [[http.services.app.loadBalancer.servers]]
          url = "http://127.0.0.1:8000"

이 파일은 Traefik이 /api/v1 경로 접두사를 사용하도록 설정해요.

그리고 Traefik은 요청을 http://127.0.0.1:8000에서 실행 중인 Uvicorn으로 리다이렉트해요.

이제 Traefik을 시작하세요.

$ ./traefik --configFile=traefik.toml

INFO[0000] Configuration loaded from file: /home/user/awesomeapi/traefik.toml

그리고 이제 --root-path 옵션을 사용해서 앱을 시작하세요.

$ uv run fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

응답 확인하기

이제 Uvicorn 포트가 있는 URL인 http://127.0.0.1:8000/app로 가면 정상적인 응답을 볼 수 있어요.

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

http://127.0.0.1:8000/app으로 접근하고 있는데도 /api/v1이라는 root_path를 보여주는 점에 주목하세요. --root-path 옵션에서 가져온 값이죠.

그리고 이제 Traefik 포트가 있는 URL에 경로 접두사를 포함해 열어 보세요: http://127.0.0.1:9999/api/v1/app.

같은 응답을 받아요.

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

하지만 이번에는 프록시가 제공한 접두사 경로(/api/v1)가 있는 URL에서요.

물론 여기서의 아이디어는 모든 사람이 프록시를 통해 앱에 접근한다는 거예요. 그래서 경로 접두사 /api/v1이 있는 버전이 "올바른" 버전이에요.

그리고 경로 접두사가 없는 버전(http://127.0.0.1:8000/app, Uvicorn이 직접 제공하는)은 오직 프록시(Traefik)만 접근 전용이에요.

이것이 프록시(Traefik)가 경로 접두사를 사용하는 방식과 서버(Uvicorn)가 --root-path 옵션의 root_path를 사용하는 방식을 보여줘요.

문서 UI 확인하기

하지만 여기가 재미있는 부분이에요. ✨

앱에 접근하는 "공식적인" 방법은 우리가 정의한 경로 접두사가 있는 프록시를 통하는 거예요. 그래서 예상대로, URL에 경로 접두사 없이 Uvicorn이 직접 서비스하는 문서 UI를 시도하면 동작하지 않아요. 프록시를 통해 접근되기를 기대하니까요.

http://127.0.0.1:8000/docs에서 확인할 수 있어요.

하지만 포트 9999가 있는 프록시를 사용한 "공식" URL /api/v1/docs의 문서 UI에 접근하면 제대로 동작해요! 🎉

http://127.0.0.1:9999/api/v1/docs에서 확인할 수 있어요.

우리가 원하던 대로예요. ✔️

이건 FastAPI가 이 root_path를 사용해서 OpenAPI의 기본 serverroot_path가 제공한 URL로 만들기 때문이에요.

추가 서버 (Additional servers)

경고

이건 더 고급 사용 사례예요. 부담 없이 건너뛰어도 돼요.

기본적으로 FastAPI는 OpenAPI 스키마에 root_path의 URL을 가진 server를 하나 만들어요.

하지만 다른 대체 servers를 제공할 수도 있어요. 예를 들어 같은 문서 UI가 스테이징 환경과 프로덕션 환경 둘 다와 상호작용하길 원한다면 말이죠.

커스텀 servers 목록을 전달하고 root_path가 있다면(여러분의 API가 프록시 뒤에 살기 때문에), FastAPI는 이 root_path를 가진 "server"를 목록의 시작 부분에 삽입해요.

예를 들어 이렇게요.

from fastapi import FastAPI, Request

app = FastAPI(
    servers=[
        {"url": "https://stag.example.com", "description": "Staging environment"},
        {"url": "https://prod.example.com", "description": "Production environment"},
    ],
    root_path="/api/v1",
)


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

이렇게 하면 이런 OpenAPI 스키마가 생성돼요.

{
    "openapi": "3.1.0",
    // More stuff here
    "servers": [
        {
            "url": "/api/v1"
        },
        {
            "url": "https://stag.example.com",
            "description": "Staging environment"
        },
        {
            "url": "https://prod.example.com",
            "description": "Production environment"
        }
    ],
    "paths": {
            // More stuff here
    }
}

root_path에서 가져온 url 값이 /api/v1인 자동 생성된 server에 주목하세요.

http://127.0.0.1:9999/api/v1/docs의 문서 UI에서는 이렇게 보여요.

문서 UI는 여러분이 선택한 서버와 상호작용해요.

기술적 세부 사항

OpenAPI 명세의 servers 속성은 선택적(optional)이에요.

servers 파라미터를 지정하지 않고 root_path/와 같으면, 생성된 OpenAPI 스키마의 servers 속성은 기본적으로 완전히 생략돼요. 이는 url 값이 /인 서버 하나와 동등하죠.

root_path에서 자동 server 비활성화하기

FastAPIroot_path를 사용해 자동 server를 포함하지 않길 원한다면, root_path_in_servers=False 파라미터를 사용하면 돼요.

from fastapi import FastAPI, Request

app = FastAPI(
    servers=[
        {"url": "https://stag.example.com", "description": "Staging environment"},
        {"url": "https://prod.example.com", "description": "Production environment"},
    ],
    root_path="/api/v1",
    root_path_in_servers=False,
)


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

그러면 OpenAPI 스키마에 그걸 포함하지 않아요.

하위 애플리케이션 마운트하기

프록시를 root_path와 함께 사용하면서 하위 애플리케이션(sub-application)을 마운트해야 한다면(Sub Applications - Mounts에 설명된 대로), 예상하던 대로 평범하게 하면 돼요.

FastAPI가 root_path를 내부적으로 똑똑하게 사용해서, 그냥 동작해요. ✨

더 알아보기 (Learn more)

이 문서는 FastAPI 공식 문서 - Behind a Proxy를 한국어로 정리한 번역이에요. 원문에서 최신 내용과 더 다양한 예시를 확인하세요.