首页
/ FastAPI 部署在代理之后:Forwarded 头与 root_path 的完整实战指南

FastAPI 部署在代理之后:Forwarded 头与 root_path 的完整实战指南

2026-09-06 12:51:40作者:余洋婵Anita

本文以 FastAPI 官方文档《Hinter einem Proxy》(代理之后部署)为主体,系统讲解 FastAPI 应用位于 Traefik、Nginx 等代理(Proxy)之后的两大核心问题:如何让 ASGI 服务器(Uvicorn)信任并解读代理注入的 X-Forwarded-* 头,以及当代理剥离(strip)路径前缀时如何通过 ASGI 标准的 root_path 机制让应用、重定向与内置文档界面(Swagger UI)继续正确工作。读完后,你将能够独立完成 --forwarded-allow-ips--root-path 的配置、编写 Traefik 的本地实验环境,并理解 FastAPI 源码中 root_path 如何参与 OpenAPI 生成。

通过 Traefik 代理访问 Swagger UI 成功加载 OpenAPI 截图

直接访问 Uvicorn 端口时文档界面因缺少路径前缀而无法获取 OpenAPI 的截图

文档界面中选择多个 Server(Staging/Production)的截图

为什么要在 FastAPI 应用前放置代理

在多数生产场景中,你不会让 FastAPI 应用直接暴露在公网,而是在其前面放置一个代理,例如 TraefikNginx。这类代理通常负责处理 HTTPS 证书终结、负载均衡、访问控制等事项,你的 FastAPI 应用(经由 Uvicorn 等 ASGI 服务器运行)则只在内网监听。

这种架构带来两个必须由应用侧配合处理的问题:

  1. 代理到应用之间的连接往往是明文 HTTP,应用无法仅凭 request 本身知道“外部客户端其实用的是 HTTPS、原始域名是什么”,这些信息要靠代理附加的 Forwarded 头传递;
  2. 代理有时会把 URL 中的一段路径前缀(如 /api/v1)剥掉再转发,应用必须借助 ASGI 的 root_path 机制“知道”自己被挂载在这个前缀之下,才能生成正确的链接与文档界面。

下文按这两条主线展开。

代理 Forwarded 头(Proxy Forwarded Headers)

当你部署一个代理在应用前面时,代理通常会在把请求转发给你的服务器之前,在请求上即时(on-the-fly)添加若干 HTTP 头,以告知服务器:该请求是从代理转发来的,并携带原始(对外)URL 信息,包括原始域名、外部是否使用了 HTTPS 等。

服务器程序(例如经由 FastAPI CLI 启动的 Uvicorn)具备解读这些头并把信息传给应用的能力。但从安全角度出发——服务器并不知道它自己是否真的运行在一个可信代理之后——默认情况下它不会解读这些头。这是为了防止外部攻击者自行伪造 Forwarded 头来欺骗应用。

/// 技术细节

涉及的三个代理头分别是:

  • X-Forwarded-For:原始客户端的 IP 地址
  • X-Forwarded-Proto:原始使用的协议(如 https
  • X-Forwarded-Host:原始的 Host(如 mysuperapp.com

///

启用 Forwarded 头:--forwarded-allow-ips

你可以用 CLI 选项 --forwarded-allow-ips 启动 FastAPI CLI,并指定哪些来源 IP 是被信任的,只有这些 IP 发来的请求中携带的 Forwarded 头才会被服务器读取和信任:

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

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

如果设置为 --forwarded-allow-ips="*",则会信任所有入站 IP。如果你的服务器位于一个可信代理之后,且只有该代理会与它通信,那么信任“代理的 IP 无论是什么”是可以接受的做法——这正是 "*" 的适用前提:内网隔离、外部流量必须经过代理。

从仓库源码结构看,fastapi/ 包内并未解析这个选项(对 fastapi/ 目录的正则检索无命中),也就是说它最终由底层 ASGI 服务器(Uvicorn)消费,FastAPI CLI 只是把它透传给 Uvicorn。这也解释了为什么官方同时提到 Hypercorn 也有对应的选项——这是 ASGI 服务器层面的通用能力,而非 FastAPI 框架本身的行为。

HTTPS 下的重定向问题

假设你定义了一个路径操作(Path Operation)/items/

from fastapi import FastAPI

app = FastAPI()


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

(完整代码见 tutorial001_01

当客户端访问 /items(少了一个斜杠)时,FastAPI 会默认将请求重定向到 /items/。但在设置 --forwarded-allow-ips 之前,服务器不知道外部用的是 HTTPS,重定向目标可能是 http://localhost:8000/items/ 这类“内部视角”的 URL。

而实际上你的应用可能托管在 https://mysuperapp.com 上,重定向应该指向 https://mysuperapp.com/items/

设置 --proxy-headers / --forwarded-allow-ips 之后,FastAPI 就能依据 Forwarded 头生成正确的重定向地址:

https://mysuperapp.com/items/

提示:关于 HTTPS 的更多背景,可参考仓库中的 关于 HTTPS 指南

Forwarded 头的工作流程

下面这张时序图展示了代理在客户端与应用服务器之间如何注入 Forwarded 头:

sequenceDiagram
    participant Client
    participant Proxy as Proxy/Loadbalancer
    participant Server as FastAPI Server

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

    Note over Proxy: 代理添加 Forwarded 头

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

    Note over Server: 服务器解读这些头<br/>(需设置 --forwarded-allow-ips)

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

    Proxy->>Client: HTTPS 响应

流程要点:

  • 代理拦截客户端的原始请求,在转发给应用服务器之前附加 X-Forwarded-* 头;
  • 这些头保存了原本会丢失的信息:客户端真实 IP(X-Forwarded-For)、原始协议(X-Forwarded-Proto)、原始域名(X-Forwarded-Host);
  • FastAPI CLI 配置了 --forwarded-allow-ips 后,服务器才会信任并使用这些头,例如用于生成正确的重定向 URL。

剥离路径前缀的代理(Proxy with a Stripped Path Prefix)

另一种常见的代理形态:代理会给你的应用添加一个路径前缀。此时可以使用 root_path 来配置你的应用。

root_pathASGI 规范提供的机制(FastAPI 经由 Starlette 构建于 ASGI 之上),专门用于处理这类场景;FastAPI 内部在挂载子应用时同样复用了它。

“剥离路径前缀的代理”具体含义是:你在代码里声明的路径是 /app,但在更高一层,代理把整个 FastAPI 应用放置在一个如 /api/v1 的前缀下。此时:

  • 原始的 /app 路径实际上对外暴露在 /api/v1/app
  • 代理会把路径前缀即时“剥掉”后再把请求转发给应用服务器(通常是经由 FastAPI CLI 的 Uvicorn),从而让应用以为它运行在 /app 之下——你无需把全部代码改成带 /api/v1 前缀。
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")}

(完整代码见 tutorial001

到这里一切照常工作。但是,当你打开内置文档界面(Swagger UI 前端)时,它会假设 OpenAPI 模式在 /openapi.json 而不是 /api/v1/openapi.json。于是运行在浏览器里的前端会去请求 /openapi.json,从而无法获取 OpenAPI 模式。

由于代理添加了 /api/v1 前缀,前端必须从 /api/v1/openapi.json 获取模式。整体链路如下:

graph LR

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

browser --> proxy
proxy --> server

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

要让文档界面正常工作,OpenAPI 模式需要声明该 API 的 server 位于 /api/v1(即代理之后)。例如:

{
    "openapi": "3.1.0",
    // 这里还有其他设置
    "servers": [
        {
            "url": "/api/v1"
        }
    ],
    "paths": {
            // 这里还有其他设置
    }
}

在这个示例中,“代理”可以是 Traefik 之类的软件,而服务器则是运行你 FastAPI 应用的 Uvicorn(FastAPI CLI)。

通过 --root-path 提供 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,它同样提供 --root-path 选项。

/// 技术细节

ASGI 规范为这类场景定义了 root_path。命令行选项 --root-path 正是由 ASGI 服务器把该值写入每个请求的 scope 字典中传给应用的。

///

读取当前请求的 root_path

你可以获取每个请求实际使用的 root_path。它是 ASGI 规范中 scope 字典的一部分。上面的示例代码把它(仅用于演示)放进了响应消息中:

return {"message": "Hello World", "root_path": request.scope.get("root_path")}

uv run fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1 启动后,响应大致为:

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

在 FastAPI 应用中直接设置 root_path

如果你没有机会传递 --root-path 之类的命令行选项(例如平台托管环境不允许你控制 ASGI 服务器参数),可以在创建 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")}

(完整代码见 tutorial002

FastAPIroot_path 与给 Uvicorn/Hypercorn 传 --root-path 选项是等效的。从源码实现看,这种等效性体现在 applications.py:当你在应用上设置了 self.root_path 时,FastAPI 会在每次请求进入时把它写入 ASGI scopescope["root_path"] = self.root_path),因此无论值来自命令行还是构造函数,最终都走同一条 ASGI scope 通道。

关于 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 上访问它,由代理负责在其上叠加 /api/v1 前缀。也就是说 root_path 只影响应用生成的 URL(OpenAPI、重定向、文档界面),不影响 Uvicorn 自身的路由匹配。

关于“非剥离前缀”的代理形态

剥离路径前缀只是众多代理配置中的一种。多数情况下,默认配置其实是代理不剥离任何前缀

  • 代理监听类似 https://myawesomeapp.com 的地址;
  • 当浏览器访问 https://myawesomeapp.com/api/v1/app 时;
  • 而你的服务器(如 Uvicorn)监听 http://127.0.0.1:8000,代理会按同样的路径访问 Uvicorn:http://127.0.0.1:8000/api/v1/app

这种模式下不存在“前缀被剥掉”的问题,root_path 通常保持默认即可,你主要需要关心的是上一节提到的 Forwarded 头(HTTPS 协议与原始域名信息)。

用 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"

这份文件做了三件关键事:

  1. stripPrefix 中间件 api-stripprefix 定义了要剥离的前缀 /api/v1
  2. 路由 app-http 匹配 PathPrefix(/api/v1) 的请求并应用该中间件;
  3. 服务 app 把请求转发到 http://127.0.0.1:8000(你的 Uvicorn)。

第三步:启动 Traefik 与 FastAPI 应用

$ ./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 端口http://127.0.0.1:8000/app),看到正常响应:

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

注意:虽然你访问的是不带前缀的 /app,但 root_path 显示为 /api/v1——它来自 --root-path 选项,而不是来自 URL。

再通过 Traefik 端口访问,带上路径前缀http://127.0.0.1:9999/api/v1/app),得到相同的响应:

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

但这次是在代理提供的 /api/v1 前缀路径之下。

设计意图是:所有用户都应经由代理访问应用,因此带 /api/v1 前缀的版本才是“正确”的入口;Uvicorn 直接提供的无前缀版本(http://127.0.0.1:8000/app)是专供代理(Traefik)访问的内部地址。这个实验同时演示了:代理(Traefik)如何使用路径前缀,服务器(Uvicorn)如何使用 --root-path 选项提供的 root_path

验证文档界面

接下来是关键验证。“官方”的访问方式是经由代理、带上定义的路径前缀:

  • 直接访问 Uvicorn 端口的文档界面(http://127.0.0.1:8000/docs)会预期地失败,因为前端会请求 /openapi.json 而服务器并没有这个带前缀路径的语义;
  • 但经由代理访问 http://127.0.0.1:9999/api/v1/docs一切正常

这正是我们期望的效果。原因是 FastAPI 使用 root_path 来创建 OpenAPI 中的默认 server,其 URL 就是 root_path 提供的值。源码中这一行为清晰可见:applications.py 中,Swagger UI 的 HTML 响应在渲染前会把 root_path 拼接到 openapi_url(以及 OAuth2 重定向 URL)前面,因此文档界面里嵌入的 OpenAPI 地址自动变成了 /api/v1/openapi.json

附加服务器列表(Additional Servers)

警告:这是一个进阶用法,初次阅读可以跳过。

默认情况下,FastAPI 会在 OpenAPI 模式中创建一个 server,其 URL 就是 root_path。但你也可以提供其他备选 servers,例如让同一个文档界面同时对接 Staging 与生产环境。

当你传入了自定义的 servers 列表、且存在 root_path(因为你的 API 运行在代理之后)时,FastAPI 会把一个使用该 root_pathserver 插入到列表最前面。例如:

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

(完整代码见 tutorial003

生成的 OpenAPI 模式为:

{
    "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": {
            // 这里还有其他设置
    }
}

注意那个由 root_path 自动生成的 url/api/v1 的 server。

提示:文档界面会与你选中的 Server 交互,切换下拉框即可让同一份文档对接不同环境。

/// 技术细节

OpenAPI 规范中 servers 属性是可选的。如果你不传 servers 参数、且 root_path 的值为 /,生成的 OpenAPI 模式中会完全省略 servers 属性,这等价于只有一个 url/ 的默认服务器。

///

对应实现可以参见 applications.py:在 /openapi.json 的响应处理函数中,FastAPI 从 req.scope 读取当前请求的 root_path(去掉尾部斜杠),若其非空且 root_path_in_servers 为真,并且该 URL 尚未出现在 servers 列表中,就会把 {"url": root_path} 插到列表首位——这正是上面 JSON 中第一项的来源。

禁用来自 root_path 的自动 server

如果你不希望 FastAPI 自动加入这个基于 root_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")}

(完整代码见 tutorial004

此时 root_path 对应的 server 就不会再出现在 OpenAPI 模式的 servers 列表里,你完全控制文档界面可选择的服务器清单。

与子应用(Mounts)组合使用

如果你需要挂载子应用(参见 子应用 – Mounts 一节),同时又在使用带 root_path 的代理,完全可以按常规方式组合使用:FastAPI 会在内部“聪明地”使用 root_path,使二者协同工作。

从路由源码结构看,这一点的支撑在于 routing.py 中重定向路径的拼接同样读取 request.scope.get("root_path"),因此子应用内部触发的路径修正与重定向也会自动包含代理前缀,而不需要手动处理。

小结:关键选项与行为对照

配置项 作用对象 解决的问题
--forwarded-allow-ips="*"(或具体 IP) ASGI 服务器(Uvicorn/Hypercorn,经 FastAPI CLI 透传) 信任代理来源,解读 X-Forwarded-For/Proto/Host,使重定向等生成正确的 HTTPS/域名 URL
--root-path /api/v1 ASGI 服务器写入请求 scope 告知应用“我运行在 /api/v1 前缀之后”,修正 OpenAPI servers、文档界面 URL
FastAPI(root_path="/api/v1") 应用自身,等效于上面的 CLI 选项 无法控制服务器参数时的替代方案(源码见 applications.py 的 scope 注入)
FastAPI(servers=[...]) OpenAPI 生成 为同一文档界面提供 Staging/Production 等多服务器选择
root_path_in_servers=False OpenAPI 生成 禁用自动插入 root_path 对应的 server

适用前提与限制:root_path 只影响应用层面生成的 URL,Uvicorn 自身仍按无前缀路径(如 /app)路由;--forwarded-allow-ips="*" 仅在“服务器只与可信代理通信”的隔离前提下安全,暴露面更大时应显式列出代理 IP。

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