首页
/ FastAPI 反向代理部署指南:信任代理转发头与配置 root_path 的正确姿势

FastAPI 反向代理部署指南:信任代理转发头与配置 root_path 的正确姿势

2026-09-06 19:11:50作者:裘旻烁

在 FastAPI 应用真正上线时,几乎总是运行在 Nginx、Traefik 这类反向代理之后——由代理终结 HTTPS 证书、负载均衡并转发请求到 Uvicorn 应用服务器。本文基于当前仓库的 Behind a Proxy 指南(含 英文原版),系统讲解两个核心命题:如何安全地让 FastAPI CLI / Uvicorn 读取代理注入的 X-Forwarded-* 头,以及当代理剥离了 URL 路径前缀时如何用 root_path 让路由、OpenAPI 文档与自动重定向全部指到正确位置。读完你将能独立完成"HTTPS 代理 + 子路径托管"场景下 FastAPI 的端到端配置,并用 Traefik 在本地复现验证。

为什么要在 FastAPI 前面加一层代理

在很多生产部署中,你会用 Traefik 或 Nginx 作为 FastAPI 应用的"门面"。这些代理通常负责:

  • 终结 HTTPS 证书(TLS 卸载),应用服务器内部仍跑纯 HTTP;
  • 负载均衡、限流、缓存等边缘策略;
  • 对 URL 路径做改写(例如把 /api/v1/app 剥成 /app 再转发给后端)。

由于浏览器只与代理通信,应用服务器收到的请求实际上是被"转述"过的。如果 FastAPI 要感知真实客户端的 IP、真实协议与真实域名,就必须依赖代理在转发请求时临时写入的一组特殊请求头。

代理转发头(Proxy Forwarded Headers)与信任边界

三兄弟:X-Forwarded-For / Proto / Host

代理在把请求转发给应用服务器之前,通常会"顺手"写入以下标准转发头(详见 技术细节en/docs 对应代码块):

  • X-Forwarded-For:原始客户端的 IP 地址;
  • X-Forwarded-Proto:客户端与代理之间使用的原始协议(如 https);
  • X-Forwarded-Host:客户端访问的原始主机名/域名(如 mysuperapp.com)。

服务器端程序(例如通过 FastAPI CLI 驱动的 Uvicorn)具备解读这些头的能力,并把还原出的 URL 信息(域名、是否 HTTPS 等)传给应用。

为什么默认不信任这些头——安全边界

关键点在于:出于安全考虑,服务器并不知道自己身后是否存在可信代理,因此默认不会解读这些头。

道理很直接:任何客户端都可以自己伪造 X-Forwarded-For: 1.2.3.4 之类的头。如果服务器无脑信任,攻击者就能"冒充"任意来源 IP、伪造协议与主机,绕过基于来源 IP 的鉴权或产生错误的日志审计。因此必须显式告知服务器:哪些来源 IP 是"可信代理",来自它们的转发头才允许被采信。

开启转发头信任:--forwarded-allow-ips

启动 FastAPI CLI 时,使用 CLI 选项 --forwarded-allow-ips 传入应当被信任、允许读取这些转发头的 IP 地址:

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

说明与约束:

  • 设置为 --forwarded-allow-ips="*" 表示信任所有来源 IP
  • 当你的应用服务器只部署在可信代理之后、只有该代理能与它通信时,这么设置等价于"只要是这台代理转发来的请求,转发头都可信";
  • 若你的网络里存在其他可直连服务器的入口,则务必把通配符换成代理的具体 IP(如 --forwarded-allow-ips="10.0.0.5"),缩小信任面。

实现说明:fastapi run 是 FastAPI CLI 提供的子命令(仓库中 fastapi/cli.py 将其转引自外部 fastapi-cli 包,安装 fastapi[standard] 后可用),它会把 --forwarded-allow-ips--proxy-headers 等透传给 Uvicorn。Uvicorn 内部通过 ProxyHeadersMiddleware 读取这些头,且仅当对端 IP 落在 forwarded-allow-ips 白名单内时才应用。

HTTPS 下的重定向问题

假设你的代码里声明了一条 path operation /items/(对应示例 tutorial001_01_py310.py):

from fastapi import FastAPI

app = FastAPI()


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

当客户端访问不带尾斜杠的 /items 时,FastAPI/Starlette 默认会把它 重定向 到带尾斜杠的 /items/。问题来了:

  • 在配置 --forwarded-allow-ips 之前,服务器只能按自己看到的请求拼 URL,结果可能重定向到 http://localhost:8000/items/
  • 而你的应用真实托管在 https://mysuperapp.com,浏览器需要的重定向目标是:
https://mysuperapp.com/items/

配置了可信代理转发头后,服务器就知道原始协议是 https、原始主机是 mysuperapp.com,从而生成正确的重定向 Location,浏览器才不会跳到错误的内网地址。😎

想深入了解 HTTPS 本身的机制,可继续阅读指南 Acerca de HTTPS / About HTTPS

转发头如何流转:一张时序图

docs/en 版本中的 sequenceDiagram 可以直观看到完整链路:

sequenceDiagram
    participant Client as 客户端浏览器
    participant Proxy as 代理 / 负载均衡
    participant Server as FastAPI 服务器

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

    Note over Proxy: 代理写入转发头

    Proxy->>Server: HTTP 请求<br/>X-Forwarded-For: [客户端 IP]<br/>X-Forwarded-Proto: https<br/>X-Forwarded-Host: mysuperapp.com<br/>Path: /items

    Note over Server: 服务器解读转发头<br/>(前提:已配置 --forwarded-allow-ips)

    Server->>Proxy: HTTP 响应<br/>包含正确的 HTTPS URL

    Proxy->>Client: HTTPS 响应

整个流程中,代理拦截客户端的原始请求,在转发给应用服务器之前补上 X-Forwarded-* 头,从而"保存"了那些会丢失的原始信息;当 FastAPI CLI 配置了 --forwarded-allow-ips,应用就会信任并消费这些头——典型用途就是在重定向时生成正确的 URL。

代理剥离路径前缀:认识 root_path

除了 HTTPS,生产环境常见的另一类需求是 路径前缀剥离(stripped path prefix)。代理可以把你的应用挂到 /api/v1 这类子路径下对外发布。

问题描述:代码里只有 /app,实际对外却是 /api/v1/app

root_path 由 ASGI 规范(FastAPI 依托 Starlette 构建于其上的标准)定义,用于专门处理这类场景;它也会在挂载子应用时被内部使用。

"带剥离前缀的代理"意味着:你可以在代码里把路由声明为 /app,再在它上面叠一层代理,把整个 FastAPI 应用对外放到 /api/v1 之下——于是原本的 /app 实际上是以 /api/v1/app 对外服务的:

  • 你的所有代码都假设只有 /app 存在;
  • 代理在转发前实时"剥掉"路径前缀再传给应用服务器(通常是 FastAPI CLI 驱动的 Uvicorn);
  • 应用始终以为自己在 /app 被访问,你无需为了前缀改动任何业务代码

这样在单纯的路由匹配层面一切正常,但一旦打开内置的交互式文档 UI(前端)就会出问题:前端默认去请求 /openapi.json,而真实的 OpenAPI schema 位于 /api/v1/openapi.json。由于代理给应用加了 /api/v1 前缀,文档前端必须从带前缀的地址拉取 schema 才能工作。

请求链路如下图所示(graph LR 图示):

graph LR

browser("浏览器")
proxy["代理监听 http://0.0.0.0:9999/api/v1/app"]
server["应用服务器 http://127.0.0.1:8000/app"]

browser --> proxy
proxy --> server

提示:IP 0.0.0.0 常用来表示程序监听该机器/服务器上的所有可用 IP。

同时,文档 UI 还需要 OpenAPI schema 里显式声明:这个 API 的 server(服务器入口)位于代理背后的 /api/v1。于是生成的 /openapi.json 应类似(JSON 片段):

{
    "openapi": "3.1.0",
    // 其他内容省略
    "servers": [
        {
            "url": "/api/v1"
        }
    ],
    "paths": {
        // 其他内容省略
    }
}

在这个例子里,"代理"可以是 Traefik 之类的边缘网关,而"服务器"则是运行 FastAPI 应用的 FastAPI CLI + Uvicorn

提供 root_path 的两种方式

方式一:命令行选项 --root-path

最直接的方式是启动时传入命令行选项(FastAPI CLI 与 Hypercorn 都支持):

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

技术细节:ASGI 规范为这个用例定义了 root_path 字段,而命令行选项 --root-path 正是用来把它喂给应用的。

方式二:在 FastAPI() 构造时传入 root_path 参数

如果运行环境无法提供 --root-path 之类的命令行选项(比如直接以 ASGI 方式托管、或使用不支持该选项的平台),可以在创建应用时显式传参(对应示例 tutorial002_py310.py):

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")}

root_path 传给 FastAPI(...) 等价于把 --root-path 命令行选项传给 Uvicorn 或 Hypercorn——两者最终都会把该值写进 ASGI scope

如何在请求中读取当前 root_path

root_path 不是藏在某个配置对象里,而是随每个请求存在——它是 ASGI scope 字典的一部分。示例 tutorial001_py310.py 里只是为了演示把它塞进了响应体:

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")}

随后以带 --root-path 的方式启动:

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

访问 http://127.0.0.1:8000/app 会得到类似响应:

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

这一行为在仓库测试中也有对应断言:tests/test_tutorial/test_behind_a_proxy/test_tutorial001.py 使用 TestClient(app, root_path="/api/v1"),验证响应中的 root_path/api/v1,且生成的 /openapi.jsonservers[{"url": "/api/v1"}]。另有对应 tutorial002/003/004 的测试文件覆盖应用内配置与 servers 相关行为。

关于 root_path 的一个重要事实

请记住:Uvicorn 本身不会用这个 root_path 做任何事,除了把它传给应用。

因此,即使你配置了 root_path="/api/v1",用浏览器直接访问 http://127.0.0.1:8000/app 依然能看到正常响应("root_path": "/api/v1" 只是从 --root-path 读取并回显)。也就是说:

  • Uvicorn 并不期望你在 http://127.0.0.1:8000/api/v1/app 访问它;
  • Uvicorn 期望代理以 http://127.0.0.1:8000/app 来访问它;
  • 额外的 /api/v1 前缀由代理负责"叠加"。

一句话:root_path声明性元数据,告诉下游(OpenAPI、文档 UI、重定向)"我的真实对外地址在哪",而不是让应用服务器自己去重写路由。

并非所有代理都会剥离前缀

需要澄清的是,带剥离前缀的代理只是众多配置形态之一——多数情况下默认并没有前缀剥离

在没有剥离前缀的场景下,代理监听例如 https://myawesomeapp.com,浏览器访问 https://myawesomeapp.com/api/v1/app 时,代理(不做任何改写)会以完全相同的路径去请求后端的 Uvicorn:http://127.0.0.1:8000/api/v1/app。此时前端/后端都能感知到完整路径,也就不需要额外讨论 root_path 的补偿机制了。理解这一差异有助于你判断自己的部署到底该配置哪些项。

动手实验:用 Traefik 在本地复现"前缀剥离"

可以用 Traefik 在本地轻松跑通整个实验。

准备 Traefik

下载 Traefik(它是一个单二进制文件,解压后即可在终端直接运行)。随后创建配置文件 traefik.toml配置来源):

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

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

这段配置让 Traefik 监听 9999 端口,并让它去读取另一个文件 routes.toml

提示:这里刻意选用 9999 端口而非标准 HTTP 的 80 端口,这样无需管理员(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"

逐段解读:

  • http.middlewares.api-stripprefix.stripPrefix.prefixes = ["/api/v1"]:定义一个名为 api-stripprefix 的中间件,负责把请求路径里的 /api/v1 前缀剥掉;
  • http.routers.app-http:路由规则为 PathPrefix(/api/v1),并应用上面的剥离中间件;
  • http.services.app:把处理后的请求负载均衡转发到运行在 http://127.0.0.1:8000 的 Uvicorn。

也就是说,Traefik 对外只认 /api/v1 前缀,并把请求重定向(改写后转发)到你本机的 Uvicorn。

启动 Traefik 与应用

先启动 Traefik:

$ ./traefik --configFile=traefik.toml

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

再带上 --root-path 启动应用(示例 main.py 即 tutorial001_py310.py):

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

检查响应:验证两条访问路径

直接访问 Uvicorn 端口 http://127.0.0.1:8000/app,得到正常响应:

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

注意:尽管你访问的 URL 是 http://127.0.0.1:8000/app,响应里的 root_path 依然是 /api/v1——它取自 --root-path 选项,与本次实际访问路径无关。

再访问 Traefik 端口、带上路径前缀 http://127.0.0.1:9999/api/v1/app,得到完全相同的响应,但这一次是经由代理的 /api/v1 前缀 URL 提供的。

设计意图很清晰:

  • 所有用户都应当通过代理访问应用,因此带 /api/v1 前缀的版本才是"正确"的入口;
  • 不带前缀、由 Uvicorn 直接提供的 http://127.0.0.1:8000/app,仅保留给代理(Traefik)内部回源使用。

这正是对"代理负责前缀叠加、服务器负责消费 root_path"这一分工的直接演示。

检查文档 UI:差别立现

按照预期,"官方"入口是经过代理、带前缀的 URL。因此:

  • 直接访问 Uvicorn 的文档 UI http://127.0.0.1:8000/docs 时,由于没有前缀、不符合文档前端拉取 schema 的预期,界面是坏的:

未经过代理前缀时,文档 UI 无法正常加载 OpenAPI schema

  • 改走"官方"路径,即通过 9999 端口的代理访问 /api/v1/docs,一切正常工作:

经过代理的 /api/v1/docs,文档 UI 正常工作

这正是我们要的效果:FastAPI 使用 root_path 在 OpenAPI schema 中构建默认 server(URL 即 root_path,文档前端据此在带前缀的地址上拉取 /api/v1/openapi.json

补充 servers 与 root_path_in_servers

多环境 servers 的高级用法

警告:这是一个更进阶的用例,可以按需跳过。

默认情况下,FastAPI 会在生成的 OpenAPI schema 里创建一个 URL 等于 root_pathserver。除此之外,你还可以显式提供一组备选 server——典型诉求是:让同一个文档 UI 既能指向预发布环境又能指向生产环境。

当同时传入了自定义 servers 列表且存在 root_path(即 API 位于代理之后)时,FastAPI 会把以 root_path 为 URL 的 server 自动插入到列表最前面。示例如下(tutorial003_py310.py):

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 schema 如下(注意自动插入的第一项):

{
    "openapi": "3.1.0",
    // 其他内容省略
    "servers": [
        {
            "url": "/api/v1"
        },
        {
            "url": "https://stag.example.com",
            "description": "Staging environment"
        },
        {
            "url": "https://prod.example.com",
            "description": "Production environment"
        }
    ],
    "paths": {
        // 其他内容省略
    }
}

在文档 UI 上会以下拉框形式呈现这些 server,UI 会与当前选中的那个 server 交互。

技术细节:OpenAPI 规范中 servers 属性是可选的。如果你没传 servers 参数且 root_path 等于 /,生成的 OpenAPI schema 会整体省略 servers 字段——这等价于单个 url/ 的 server。

这一插入逻辑在源码中可查证:见 fastapi/applications.py,FastAPI 在生成 schema 时会读取 scope 中的 root_path(先做 rstrip("/") 规范化),只要 root_path_in_servers 为真且该 URL 尚未出现在 servers 里,就会把 {"url": root_path} 拼接到列表头部。同理,同一文件 在处理 OpenAPI 文档与 OAuth2 重定向路由时,也会用 root_path 前缀拼出完整的对外 URL。

关闭自动 server:root_path_in_servers=False

如果不想让 FastAPI 基于 root_path 自动生成 server,可以传入参数 root_path_in_servers=Falsetutorial004_py310.py):

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 schema 中 servers不再包含那个基于 root_path 的自动项。该开关在 fastapi/applications.pyFastAPI.__init__ 签名中有完整 docstring 说明,并在 L1098/L1109-L1116 参与 schema 生成判定。

挂载子应用也没问题

如果你需要在配置了 root_path 的代理环境下挂载子应用(如何挂载参见 Sub Applications - Mounts / 子应用挂载),按常规方式操作即可:FastAPI 会智能地在内部处理 root_path,子应用与主应用的 OpenAPI 路由都会自动适配前缀,直接就能工作。✨

小结:部署时的检查清单

最后把整篇的核心决策点收敛成一份清单,方便对照自己的部署环境:

  1. 确认代理是否注入转发头:Traefik/Nginx 是否设置了 X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host
  2. 显式信任代理 IP:用 uv run fastapi run --forwarded-allow-ips="<proxy_ip>" 启动(若回源链路完全隔离才考虑 "*"),否则 HTTPS 重定向与真实 IP 都拿不到;
  3. 确认代理是否剥离了前缀:剥离则配置 --root-path /api/v1,或等效地在 FastAPI(root_path=...) 中声明;
  4. 验证产出:带前缀访问 /api/v1/openapi.json 应能看到 servers: [{"url": "/api/v1"}]/api/v1/docs 应能正常渲染,路由 /app 的请求体里能通过 request.scope["root_path"] 读到前缀;
  5. 按需裁剪 servers:需要多环境共用一个文档 UI 时传自定义 servers,不想要自动 server 时加 root_path_in_servers=False

所有文中示例与断言均可在当前仓库的 docs_src/behind_a_proxy 源码与 tests/test_tutorial/test_behind_a_proxy 测试目录中找到并直接运行复现。

登录后查看全文
热门项目推荐
相关项目推荐