FastAPI 反向代理部署指南:信任代理转发头与配置 root_path 的正确姿势
在 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.json 中 servers 为 [{"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 的预期,界面是坏的:
- 改走"官方"路径,即通过 9999 端口的代理访问
/api/v1/docs,一切正常工作:
这正是我们要的效果:FastAPI 使用 root_path 在 OpenAPI schema 中构建默认 server(URL 即 root_path),文档前端据此在带前缀的地址上拉取 /api/v1/openapi.json。
补充 servers 与 root_path_in_servers
多环境 servers 的高级用法
警告:这是一个更进阶的用例,可以按需跳过。
默认情况下,FastAPI 会在生成的 OpenAPI schema 里创建一个 URL 等于 root_path 的 server。除此之外,你还可以显式提供一组备选 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=False(tutorial004_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.py 的 FastAPI.__init__ 签名中有完整 docstring 说明,并在 L1098/L1109-L1116 参与 schema 生成判定。
挂载子应用也没问题
如果你需要在配置了 root_path 的代理环境下挂载子应用(如何挂载参见 Sub Applications - Mounts / 子应用挂载),按常规方式操作即可:FastAPI 会智能地在内部处理 root_path,子应用与主应用的 OpenAPI 路由都会自动适配前缀,直接就能工作。✨
小结:部署时的检查清单
最后把整篇的核心决策点收敛成一份清单,方便对照自己的部署环境:
- 确认代理是否注入转发头:Traefik/Nginx 是否设置了
X-Forwarded-For/X-Forwarded-Proto/X-Forwarded-Host; - 显式信任代理 IP:用
uv run fastapi run --forwarded-allow-ips="<proxy_ip>"启动(若回源链路完全隔离才考虑"*"),否则 HTTPS 重定向与真实 IP 都拿不到; - 确认代理是否剥离了前缀:剥离则配置
--root-path /api/v1,或等效地在FastAPI(root_path=...)中声明; - 验证产出:带前缀访问
/api/v1/openapi.json应能看到servers: [{"url": "/api/v1"}],/api/v1/docs应能正常渲染,路由/app的请求体里能通过request.scope["root_path"]读到前缀; - 按需裁剪 servers:需要多环境共用一个文档 UI 时传自定义
servers,不想要自动 server 时加root_path_in_servers=False。
所有文中示例与断言均可在当前仓库的 docs_src/behind_a_proxy 源码与 tests/test_tutorial/test_behind_a_proxy 测试目录中找到并直接运行复现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00

