去年秋天有个晚上,我盯着监控面板百思不得其解。服务在本地跑得好好的,测试环境压测一上来直接大面积超时,CPU利用率连10%都没到,但请求就是堆积、排队、然后超时。查了半天业务代码,最后才发现问题根本不在代码里,而是部署方式上:我直接用了uvicorn main:app --host 0.0.0.0 --port 8000这种单进程裸跑的方案就上了预发环境。
这个场景太典型了。FastAPI 开发体验确实爽,编码效率高、自带交互式文档、类型提示完整,但越是开发体验好的框架,越容易让人忽略"开发模式"和"生产模式"之间的巨大鸿沟。许多团队从 FastAPI 的 Hello World 直接跳到"部署上线",中间缺了关键的一课:到底该怎么跑这个应用,才能让它稳定支撑生产流量?
这篇文章是"FastAPI 性能与部署实战"的第五篇,我会把 Uvicorn/Gunicorn 的配合方式、多环境配置的管理思路、以及监控和日志的落地方法一次讲透。这篇文章里的每个参数、每段配置、每个坑,都是我在真实项目里验证过的。读完之后,你应该能把一个 FastAPI 服务从"本地能跑"推进到"生产可用"的状态。
1. 开发环境一切正常,上线却被压垮:一次 FastAPI 线上事故复盘
1.1 事故现场:CPU 没跑满,请求却超时了
先回到那个让我印象深刻的晚上。当时我负责的一个 FastAPI 服务要接一波活动流量,预估峰值 QPS 在 2000 左右。代码写完了,本地压测用wrk简单测了一下,单机请求延迟平均 80ms,感觉没问题。
结果活动当天,流量一上来,从监控面板看到的现象非常诡异:
- 单机 CPU 利用率只有 8%~12%
- 请求 P95 延迟从 80ms 飙升到 4000ms+
- 一部分请求直接 504 超时
- 服务进程还活着,没崩溃、没重启
第一反应是数据库扛不住了,查了一圈,数据库侧很正常,慢查询也没有明显增加。后来又怀疑是 Redis 连接池问题,但连接数也没到瓶颈。最后把排查方向转回到应用本身,才意识到问题就出在"Uvicorn 单进程"这个部署方式上。
1.2 根因:asyncio 是单线程的,一个进程只用一个 CPU 核
要理解这个事故,需要先明确一个底层机制:FastAPI 基于 Starlette,Starlette 基于 asyncio,而 asyncio 在 CPython 里的运行方式是单线程事件循环。也就是说,你启动一个 Uvicorn 进程,这个进程在任意时刻只能在一个 CPU 核上运行 Python 代码。
我用的服务器是 8 核 16G 的配置。单进程 Uvicorn 意味着:
- 8 核机器只用上了 1 个核,其余 7 个核在空闲
- 所有请求共享一个事件循环,任何一个耗时的协程阻塞,都会拖累其他所有请求
- 开发环境没什么并发,问题暴露不出来;生产环境一旦有并发,短板立刻显现
更隐蔽的是,FastAPI 的async def接口里如果有同步阻塞调用——比如requests.get()、time.sleep()、普通的文件读取——这些操作在没有使用run_in_executor或run_in_threadpool的情况下,会直接阻塞整个事件循环。开发时你感受不到,因为只有一个请求在跑。生产环境只要来一个慢请求,其他人全部排队等着。这次事故背后的真实原因,就是接口里有一段同步调用第三方服务的代码,单个请求耗时 3 秒,直接把事件循环卡死了。
1.3 解决方案的探索过程:从"Uvicorn --workers"到每日 Gunicorn
排查出根因之后,我的第一个想法是给 Uvicorn 加参数,因为 Uvicorn 本身支持--workers 4。但是翻文档和源码时发现,Uvicorn 自带的 workers 模式只是简单地把多个工作进程拉起来,它自己虽然也能做,但在进程管理上明显不如 Gunicorn 成熟。
我的第二个想法是上 Docker,容器内直接跑多个 Uvicorn 进程。这也能解决问题,但会引入新的复杂度:需要自己写进程守护脚本,处理 PID 1 的僵尸进程回收问题,还要管优雅停机、worker 崩溃后的自动拉起。这些场景 Gunicorn 已经帮你处理好了,没必要重复造轮子。
最后我选择了业界最常见的方案:Gunicorn 作为进程管理器,Uvicorn 的 worker 作为实际的 ASGI 协议处理者。这个组合在 Flask/ Django 时代就是标配(Gunicorn 管进程,应用服务器管协议),放到 FastAPI 场景同样适用,只是把原来的gunicorn.workers.ggevent换成了uvicorn.workers.UvicornWorker。
2. Uvicorn/Gunicorn 怎么配合:从单进程到多 worker 的生产形态
2.1 Uvicorn 的真实角色:不只是一个"带热重载的开发服务器"
很多初学者对 Uvicorn 的认知就是"开发时用的服务器",--reload能实现代码热更新,很方便。这导致他们觉得 Uvicorn 就是开发工具,上生产应该用别的。这个认知需要纠正。
Uvicorn 是ASGI 服务器实现,负责:
- HTTP/1.1 和 HTTP/2 协议的解析与响应
- WebSocket 协议的升级与帧处理
- 将 HTTP 请求转换为 ASGI 规范定义的 scope、receive、send 接口,交给 FastAPI 应用处理
- 维护事件循环,调度异步任务
简单说,它是 FastAPI 应用和网络协议栈之间的"翻译官"。而生产环境跑 Gunicorn + Uvicorn worker 的组合,相当于"多进程的翻译官管理机制":Gunicorn 负责分配请求给哪个翻译官,翻译官内部自己处理事件循环。
2.2 启动命令与 worker class 的含义
我在项目里使用的启动命令是这样的:
gunicorn app.main:app \ --workers 8 \ --worker-class uvicorn.workers.UvicornWorker \ --bind 0.0.0.0:8000 \ --timeout 60 \ --graceful-timeout 30 \ --keepalive 5 \ --log-level info \ --access-logfile -拆开看每个参数:
app.main:app:模块路径和应用实例名。app.main是 Python 模块路径,app是模块里创建 FastAPI 实例时的变量名。Gunicorn 会导入这个模块并调用这个对象作为 WSGI 应用,之所以能处理 ASGI 应用,是因为 Uvicorn worker 内部做了适配。--worker-class uvicorn.workers.UvicornWorker:指定 worker 的类型。默认的 Gunicorn worker 是同步的,只能处理 WSGI 应用。Uvicorn worker 让 Gunicorn 的每个 worker 进程内部跑一个 Uvicorn 服务器实例,这样既拥有了 Gunicorn 的进程管理能力,又保留 Uvicorn 的 ASGI 协议支持。--bind 0.0.0.0:8000:监听地址和端口。0.0.0.0表示监听所有网络接口,这样外部才能访问。- 其余参数在下一章详细说明。
这里有个常见的疑问:既然 Uvicorn 自己也有--workers参数,为什么不用它?因为 Uvicorn 的 workers 模式在 worker 管理上比较"轻量",缺少 Gunicorn 那种成熟的 worker 超时重启、预加载(preload)、优雅停机等机制。Gunicorn 作为 Python Web 领域的老牌进程管理器,在存活状态监测、信号处理、资源回收这些方面积累了大量经验。你可以把 Gunicorn 理解成一个"严格的监工",Uvicorn worker 是"认真干活的工人",监工负责盯着工人有没有偷懒、有没有累垮,工人负责把活干好。
2.3 每个 Uvicorn worker 内部的运行模型
再往深一层看,每创建一个 Uvicorn worker,它内部就是一个独立的 Python 进程,有自己的事件循环和 GIL(全局解释器锁)。因此:
- 多 worker 解决的是 CPU 多核利用率问题:8 个 worker 就能用满 8 核 CPU,每个进程一个核
- 内存是独立且冗余的:每个 worker 都会加载一次完整的 FastAPI 应用和所有依赖模块。如果单 worker 基础内存占用是 500MB,8 个 worker 就是 4GB。这解释了为什么 FastAPI 服务往往比同等承载量的 Node.js 服务更吃内存
- 任意一个 worker 崩溃,Gunicorn 会拉一个新的:worker 的异常退出不会导致整个服务不可用,这是单进程方案做不到的
- worker 之间不共享内存:如果需要缓存数据、维护状态,得用 Redis 之类的共享存储,或者多 worker 之间通过消息机制,不能像单进程那样依赖全局变量
我用一个表格把单进程 Uvicorn、Uvicorn --workers、Gunicorn + UvicornWorker 做个直观对比:
| 对比维度 | 单进程 Uvicorn | Uvicorn --workers 4 | Gunicorn + UvicornWorker |
|---|---|---|---|
| CPU 利用率 | 单核 | 多核 | 多核 |
| worker 崩溃自动拉起 | 不支持 | 不支持 | 支持 |
| worker 超时自动重启 | 不支持 | 不支持 | 支持(--timeout) |
| 优雅停机 | 需自己写逻辑 | 有限 | 完善(--graceful-timeout) |
| 预加载应用代码 | 不支持 | 有限 | 支持(--preload,需慎用) |
| 生产环境成熟度 | 低 | 中 | 高 |
3. 参数别拍脑袋:workers、timeout、keepalive、backlog 的取值逻辑
3.1 workers 数量的确定:别迷信"2xCPU+1"
很多教程告诉你 worker 数 = CPU 核心数 × 2 + 1,这是一个来自 Gunicorn 文档的经验公式,但它的前提是同步 worker 处理 IO 密集型任务。在 FastAPI 场景下,Uvicorn worker 内部已经用了事件循环,单个 worker 本身就能承载大量并发 IO,所以 worker 数的选择逻辑完全不同。
我现在的做法是分两步:
第一步,判断应用的瓶颈类型:
- IO 密集型场景(频繁访问数据库、Redis、调用第三方 HTTP API):worker 数可以等于 CPU 核心数,甚至略多于核心数。因为事件循环在执行
await时会让出 GIL,单核上的事件循环可以穿插处理大量 IO 等待。 - CPU 密集型场景(图像处理、加解密、复杂计算):worker 数建议等于 CPU 核心数,不宜再多。开多了会引发频繁的上下文切换,反而降低总吞吐量。
- 混合型:从 CPU 核数开始压测,逐步调整。
第二步,看实测数据而不是理论。我一般用wrk或k6做压测,观察请求延迟 P95 和错误率,而不是盯着 QPS 数字。QPS 再高,如果 P95 延迟涨了 10 倍,对用户体验来说就是灾难。
我的实测建议是:先设 worker = CPU 核心数,压测,然后增加到 2 倍,压测对比。多数 FastAPI 服务在 worker = CPU 核心数到 1.5 倍之间收益最大,超出之后收益递减。
3.2 timeout:这个参数的坑比想象中多
--timeout 60的意思是:Gunicorn 在 worker 启动后,如果在 60 秒内没有收到这个 worker 的任何心跳通知,就会认为这个 worker 卡死了,于是强制杀掉并启动一个新的 worker。
这里的"心跳"不是 Uvicorn 自己发的,而是 Gunicorn 的 worker 进程会定期向主进程报告状态。如果你的接口里有一个需要 90 秒才能执行完的同步操作,而这个操作阻塞了事件循环,Gunicorn 就会在 60 秒时将整个 worker 杀掉。请求断了,业务逻辑可能只执行了一半。
处理思路有两种:
- 用异步的方式重写耗时代码,避免长时间占用事件循环。比如把同步的第三方 HTTP 请求改成
httpx.AsyncClient,把耗时的计算丢到run_in_threadpool里执行 - 调大 timeout。这只能缓解,不能根治
在真实项目中,我建议保留一个相对保守的 timeout(比如 60 秒),然后通过监控主动发现慢接口,逐个优化,而不是无限调大 timeout 来掩盖问题。
3.3 keepalive:短连接变成长连接的关键配置
Gunicorn 在经历了 HTTP/1.0 时代之后,默认 http/1.1 模式下支持 keepalive。--keepalive 5的意思是:当服务器处理完一个请求后,TCP 连接保持 5 秒,在这个时间内同一个连接的后续请求直接复用这个 TCP 连接,省去反复三次握手和慢启动的时间。
兼容性地看,FastAPI 应用在高并发、短请求的场景下,keepalive 能带来 20%~50% 的性能提升。需要注意的是,--keepalive的值不能设得太大,否则空闲连接会占着 worker 的连接槽位。5~10 秒是比较合适的区间。
还有个更容易被忽略的点:如果你在前面挂了 Nginx 反向代理,那么 Nginx 在 upstream 里的 keepalive 配置要和 Gunicorn 的配合起来。Nginx 的 upstream keepalive 是"一批连接放在连接池里供复用",Gunicorn 的 keepalive 是"单个 TCP 连接的空闲存活时间"。两边不匹配会导致连接无法复用甚至报 502。
3.4 backlog:被忽略的连接排队长度
--backlog 2048(默认值是 2048,但我见过很多人的配置里没写这一项就默认生效了)决定的是操作系统内核 TCP accept 队列的长度。也就是说,如果 worker 进程都在忙着处理请求,新的传入连接会先在内核里排队,队列排满之后,新的连接请求会被直接拒绝。
这个值不能设太小。当瞬时流量脉冲到来时,假如 workers 有 8 个,每个 worker 事件循环里排着几百个待处理任务,此时 backlog 只有 128,那么大量连接会被拒之门外,客户端那边看到的就是连接被重置(ECONNRESET)或连接超时。
但也不能设得过大。backlog 过大意味着会有大量连接在排队等待,而这些请求已经占用了客户端的连接资源和服务端的文件描述符。我常用的取值是 2048。压测时如果发现请求失败,先看 backlog 是否够用,再考虑其他因素。
3.5 实战参数清单
我目前最常用的一套生产参数,可以当作模板使用:
gunicorn app.main:app \ --workers 8 \ --worker-class uvicorn.workers.UvicornWorker \ --bind 0.0.0.0:8000 \ --timeout 60 \ --graceful-timeout 30 \ --keepalive 5 \ --backlog 2048 \ --max-requests 5000 \ --max-requests-jitter 1000 \ --access-logfile - \ --error-logfile -这里有两个参数需要单独解释:
--max-requests 5000:worker 每处理完 5000 个请求之后,Gunicorn 会主动让它退出,并新起一个 worker。这是应对内存泄漏的常用手段。如果你的代码里有轻微的内存泄漏(比如在全局 dict 里不断塞数据、第三方库缓存了不该缓存的对象),worker 处理到几万请求后内存会涨上去。定期重启 worker 能把内存拉回基线。--max-requests-jitter 1000是为了避免所有 worker 在同一时刻一起重启,加一个随机偏移量。--graceful-timeout 30:收到停止信号后,Gunicorn 最多等 30 秒,之后强制杀掉 worker。这是因为 worker 里可能有正在处理的长请求,给它 30 秒"体面退场"的时间,处理不完的就直接掐断。这在发布新版本滚动更新时尤其重要,避免旧版本的进程长期挂着不退出。
4. 多环境配置的思路:用 pydantic-settings 把配置管理成可追溯的层次结构
4.1 大多数团队的做法:三个 .env 文件一会儿生效一会儿不生效
提到多环境配置,很多 Python 开发者第一反应是手动维护config/dev.py、config/prod.py,或者本地 abc 三个.env文件,然后用if判断当前环境去选择加载哪个文件。
这样做的最大问题是:配置和使用之间没有强制关系。你怎么知道某个环境加载了哪个文件?怎么知道.env.dev里一个变量的拼写错误会不会被静默忽略?我见过不止一次因为DEBUG=True被不小心带到生产环境,导致性能骤降或敏感信息泄漏的事故。
在 FastAPI 项目里,我推荐使用pydantic-settings做统一配置管理。它基于 Pydantic 的类型校验,能确保配置值在启动时就被校验,而不是运行到一半才报错。这相当于给配置加了一层"编译期"检查。
4.2 pydantic-settings 的实际用法
首先安装依赖:
pip install pydantic-settings pydantic然后在项目里新建一个配置模块,app/config.py:
from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=(".env", f".env.{os.getenv('FASTAPI_ENV', 'development')}"), env_file_encoding="utf-8", extra="ignore", ) app_name: str = "fastapi-service" debug: bool = False api_prefix: str = "/api/v1" database_url: str redis_url: str log_level: str = "INFO" otlp_endpoint: str | None = None class Config: validate_assignment = True @lru_cache def get_settings() -> Settings: return Settings()这段代码的关键点在于env_file的写法:
- 先加载通用的
.env文件,再加载环境特定的.env.development、.env.staging、.env.production - 后面的文件会覆盖前面的文件,所以环境特定的配置优先于通用配置
- 环境名从
FASTAPI_ENV环境变量读取,这也是最上层的优先级来源
在 FastAPI 入口文件里调用:
from fastapi import FastAPI from app.config import get_settings settings = get_settings() app = FastAPI( title=settings.app_name, debug=settings.debug, )然后每个接口里如果需要访问配置,直接from app.config import get_settings即可。因为有lru_cache,整个生命周期内只会加载和解析一次配置,不会对性能产生影响。
4.3 环境变量、配置文件、默认值的三层优先级
pydantic-settings 的读取优先级非常明确:
- 环境变量优先级最高:
DATABASE_URL=xxx uvicorn app.main:app这样启动,环境变量的值会覆盖.env文件里的值 - .env 文件优先级居中:前面说的那些
.env、.env.production文件 - 类属性默认值优先级最低:代码里直接写的默认值只用来兜底
这个设计很关键。它让"持续集成/持续部署(CI/CD)里用环境变量注入密钥"和"本地开发用 .env 文件"互补,你不用把生产数据库密码写进任何代码仓库。
4.4 敏感配置的隔离
多环境配置里最容易出问题的不是参数本身,而是密钥和密码的管理。我在团队里立过一个规矩:
.env、.env.production等文件一律加入.gitignore,严禁提交到 Git 仓库- 仓库里只放一个
.env.example,里面是去掉真实值的模板 - 生产环境的密钥通过 CI/CD 系统的 secret 注入,或者由部署平台(如 Kubernetes 的 Secret、Vault)统一管理
- 任何密钥一旦怀疑泄漏,立即轮换,不要抱着"先跑起来再说"的心态
另外一个容易被低估的细节:SettingsConfigDict(extra="ignore")的作用是忽略掉.env文件里定义但代码里没有声明的变量。如果不开这个选项,一旦.env文件多了一个拼写错误的变量,程序启动时会直接报错。这对排查问题来说是好事,但也会让刚接入 pydantic-settings 的团队觉得"怎么突然起不来了"。我建议保留这个参数,至少能让你的团队启动更顺畅,等大家养成了规范再加严格模式。
5. 监控先行,日志跟上:Prometheus 指标和结构化日志的落地方案
5.1 先用 prometheus-fastapi-instrumentator 把指标拉出来
监控这件事,我的建议是先有指标,再谈其他。没有指标的系统就像没有仪表盘的汽车,出了故障只能靠猜。
FastAPI 接入 Prometheus 指标非常方便,我使用的是prometheus-fastapi-instrumentator,安装后几行代码就能把默认指标暴露出来:
pip install prometheus-fastapi-instrumentator prometheus-client在 FastAPI 入口里:
from fastapi import FastAPI from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI() Instrumentator().instrument(app).expose(app, endpoint="/metrics")启动后访问http://your-server:8000/metrics,能看到一系列指标:
http_requests_total:总请求数,按 handler、method、status 等维度打了标签http_request_duration_seconds:请求耗时分布直方图,默认有 buckethttp_request_size_bytes/http_response_size_bytes:请求和响应体大小http_requests_inprogress:当前正在处理的请求数
在 Prometheus 里可以这样配置抓取任务:
scrape_configs: - job_name: "fastapi" static_configs: - targets: ["your-server-ip:8000"] metrics_path: /metrics scrape_interval: 15s抓取间隔别设太短,一般 15 秒就够了。设太短对服务本身影响不大,但会白白增加 Prometheus 的存储压力。
5.2 自定义业务指标:从"服务活着"到"业务正常"
默认指标只能告诉你"服务本身活着",但无法告诉你"业务是否正常"。举个例子,一个外卖配送服务,接口返回 200,但一台第三方配送系统的连接池全部耗尽,导致大量下单请求在等待重试——这时候 HTTP 状态码还是 200,延迟却已经飙升了。
所以我建议在接口里嵌入业务指标,用prometheus-client的Counter和Histogram:
from prometheus_client import Counter, Histogram ORDER_CREATED = Counter("orders_created_total", "Total order creation attempts") ORDER_FAILED = Counter("orders_failed_total", "Total failing order creation attempts", ["reason"]) CHECKOUT_LATENCY = Histogram("checkout_latency_seconds", "Checkout endpoint latency") @app.post("/orders") async def create_order(payload: OrderPayload): ORDER_CREATED.inc() try: result = await order_service.create(payload) except ExternalSystemError as e: ORDER_FAILED.labels(reason="external_system").inc() raise HTTPException(status_code=502, detail=str(e)) from e return result用ORDER_FAILED.labels(reason="external_system").inc()这种方式,可以按失败原因打标签,到时候 Grafana 面板上能直接看到"哪些原因导致的失败在上升",定位速度会快很多。
5.3 结构化日志:告别 print,一切日志都是 JSON
日志和监控是两套互补的系统。监控回答"发生了什么",日志回答"具体是哪一个请求、哪一行代码出的事"。但 FastAPI 默认的输出格式是纯文本,在分布式排查场景下很难用。
我的做法是:所有日志输出 JSON 格式,统一到 stdout,由日志采集器(Promtail/Filebeat/Fluent Bit)负责转发。这比应用直接写日志文件要方便得多,因为换机器、扩容、挂盘都不影响日志采集。
用 Python 的logging+json-formatter就能实现:
pip install python-json-logger然后在配置文件里设置:
import logging from pythonjsonlogger.json import JsonFormatter logger = logging.getLogger("uvicorn.access") logger.handlers.clear() handler = logging.StreamHandler() handler.setFormatter(JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")) logger.addHandler(handler) logger.propagate = False在业务代码里,统一通过一个logger模块打日志,并附带结构化字段:
logger.info("order created", extra={"order_id": order.id, "user_id": order.user_id, "amount": order.amount})这个日志最终在 Loki / Elasticsearch 里长这样:
{"asctime": "2025-01-20 14:30:22,123", "levelname": "INFO", "name": "app.api.orders", "message": "order created", "order_id": "123456", "user_id": "7890", "amount": 99.5}有了统一的 JSON 字段,在 Grafana 的 Loki 数据源里可以用{app="fastapi"} |= "order_id=123456"这种语法快速检索到某个订单的完整生命周期日志。
5.4 请求 ID 链路透传:把一次请求的所有日志串起来
单看一条 JSON 日志还不够,你更需要的是根据一个请求 ID,找到这个请求经过的所有日志。这在高并发场景下几乎必需。
实现方案是用 Starlette 中间件 +contextvars:
import uuid from contextvars import ContextVar from starlette.middleware.base import BaseHTTPMiddleware request_id_var: ContextVar[str] = ContextVar("request_id", default="") class RequestIDMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) token = request_id_var.set(request_id) try: response = await call_next(request) response.headers["X-Request-ID"] = request_id return response finally: request_id_var.reset(token)然后在日志格式里加入请求 ID 字段:
logger.info("order created", extra={"request_id": request_id_var.get(), "order_id": order.id})如果你的服务是微服务架构,在调用下游服务时,把X-Request-ID作为 Http Header 传给下游。下游服务同样读取该 Header,这样一条完整链路就能通过同一个 Request ID 串起来。
6. 把服务真正放到生产环境:反代配置、容器部署和健康检查
6.1 反向代理的配置:Nginx 里的两个参数就能踩翻
有了 Gunicorn 管理 Uvicorn worker,服务本身已经具备生产可用的基础,但如果直接让服务监听公网端口暴露出去,又会有几个问题:HTTP/2 支持、静态文件处理、安全响应头、最简单的负载均衡,这些最好交给反向代理来做。
我常用的 Nginx 反向代理配置如下,特别要注意的是 keepalive 相关参数:
upstream fastapi_backend { server 127.0.0.1:8000; keepalive 16; } server { listen 80; server_name api.example.com; location / { proxy_pass http://fastapi_backend; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 60s; } }proxy_http_version 1.1和proxy_set_header Connection "":告诉 Nginx 和后端之间保持长连接。如果少了这两行,Nginx 默认用短连接,每个请求都会重新建立到后端的 TCP 连接,在高并发下会浪费大量文件描述符,性能立刻掉一截keepalive 16:Nginx 和 Gunicorn 之间的连接池大小proxy_read_timeout 60s:如果 Gunicorn 的 timeout 设的 60 秒,这里也设成 60 秒,保持一致。否则 Nginx 的 60 秒超时先触发,客户端收到的还是 504,而 Gunicorn 还没杀掉 worker
FastAPI 这边也有一个点需要注意:由于前面有 Nginx,FastAPI 获取客户端真实 IP 要通过X-Forwarded-For或X-Real-IP头。如果直接读request.client.host,拿到的是 Nginx 的 IP,不是真实用户的 IP。在接口里如果需要审计客户端地址,记得正确解析这两个头。
6.2 容器化部署:Docker 里的 CPU 核心数陷阱
很多团队把服务打包进 Docker 时,启动命令一般是:
CMD ["gunicorn", "app.main:app", "--workers", "8", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"]这个做法本身没问题,但要警惕一个陷阱:worker 数量不要写死,除非你的容器资源配置是固定的。假设你本地开发机器是 8 核,写了--workers 8,但生产容器实际只分配了 2 核,那这 8 个 worker 就会在 2 个核上疯狂切换上下文,性能反而下降。
更隐蔽的问题是,Gunicorn 默认读取容器的 CPU 配额来判断核心数。如果用 Docker 的内置命令直接跑在宿主机上,os.cpu_count()可能读到宿主机的 CPU 核数,比如 64 核,但容器的 cgroup CPU 限制是 2 核。这时如果让 Gunicorn 自动判断,它会创建 64 个 worker,直接把容器内存和 CPU 打爆。
我的建议是:
- 在部署平台或 docker-compose 里通过环境变量
WEB_CONCURRENCY显式指定 worker 数,不要依赖自动检测 - worker 数根据容器的 CPU 限制来定,比如容器限了 4 核,就设 4 个 worker
- 内存也要核算:每个 Uvicorn worker 基础内存大约在 200MB~400MB 之间(取决于你 import 的库有多少),容器内存至少设 worker 数 × 单 worker 内存 × 1.5
在 docker-compose 里可以这样处理:
services: api: build: . environment: WEB_CONCURRENCY: "4" FASTAPI_ENV: "production" deploy: resources: limits: cpus: "4.0" memory: 2G启动脚本里读取环境变量:
gunicorn app.main:app \ --workers ${WEB_CONCURRENCY:-2} \ --worker-class uvicorn.workers.UvicornWorker \ --bind 0.0.0.0:80006.3 健康检查:Kubernetes 和负载均衡器的生命线
如果你的服务跑在 Kubernetes 里,或者挂在云负载均衡器后面,配置健康检查是必须的。但这里有个坑:很多人直接把/根路径当作健康检查端点,而根路径可能恰好是一个业务接口。如果业务接口因为某个外部依赖故障而返回 5xx,健康检查就会失败,K8s 会不断重启 Pod,反而掩盖了真实问题。
我建议专门设计一个独立的健康检查端点:
from fastapi import FastAPI, status from fastapi.responses import JSONResponse app = FastAPI() @app.get("/healthz", status_code=status.HTTP_200_OK) async def healthz(): # 此处可以做一个轻量级的 Redis/数据库连通性检查,但不要做重量级查询 return JSONResponse({"status": "ok"})健康检查有两种类型,务必要区分开:
- 存活探针(liveness):进程是不是活着。如果挂了,K8s 会杀掉重启。这个探针要轻,只检查进程响应即可
- 就绪探针(readiness):这个实例能不能接收流量。如果依赖的下游服务不可用,可以在这里返回 5xx,让流量分发到其他健康的实例
我自己对健康检查的取值是livenessProbe检查/healthz的 200 响应,readiness 检查/healthz加一个 5 秒的超时。不要把数据库的实时查询放进健康检查,慢查询或者连接池打满会让健康检查本身变成新的故障源。
6.4 优雅停机:发布新版本时请求不中断的细节
发布新版本时,旧版本的进程不能立刻杀掉,否则正在处理的请求会被切断。Gunicorn 对 SIGTERM 信号的默认行为是优雅停机:先停止接收新连接,然后等正在处理的请求完成(受--graceful-timeout限制),超时后强制杀。但在 Kubernetes 里还有一层逻辑需要处理。
Kubernetes 在 Pod 终止时的默认行为是:先发 SIGTERM,等待terminationGracePeriodSeconds(默认 30 秒),然后发 SIGKILL 强制杀。为了让请求能优雅完成,你需要确保:
- Gunicorn 的
--graceful-timeout小于 Kubernetes 的terminationGracePeriodSeconds - FastAPI 应用不要在收到 SIGTERM 后立刻把连接断开,保持 TCP 连接存活到处理完当前请求
在 Kubernetes Deployment 里可以设置:
spec: template: spec: terminationGracePeriodSeconds: 60 containers: - name: api image: your-image:tag ports: - containerPort: 8000 readinessProbe: httpGet: path: /healthz port: 8000这样发布时旧 Pod 先摘流量,再优雅退出,大概率不会出现设备抖动。不过要承认,Kubernetes 的优雅停机是全链路协作的,容器镜像的 entrypoint、应用本身的信号处理、探针的配合缺一不可,每一层都要验证。
7. 写在最后:关于性能调优的几点个人体会
调了这么多 FastAPI 服务的性能,也踩过不少坑,有几个体会和后面的实践关系比较大,想拿出来单独说说。
第一个体会:性能调优的目的不是把所有指标拉满,而是让服务在合理的代价内稳定承载预期流量。我在文章开头那个事故里,如果把 Uvicorn worker 开到 32 个,QPS 确实能上去,但内存占用会到 10GB 以上,单机成本直接翻倍。后来通过定位阻塞调用、优化代码逻辑,加上合理的 worker 数,4 核 8G 的机器就能稳稳扛住之前 8 核 16G 都扛不住的流量。调优的目标应该是"用最小的资源解决实际问题",而不是"数字看起来漂亮"。
第二个体会:监控和日志一定要在流量小的时候就开始做。不要等到服务已经上线、用户开始投诉了,才想着加 Prometheus、加 JSON 日志。没有历史基线数据,你根本没法判断"现在的延迟是正常的还是异常的"。我现在每次新服务上线前,都会先跑一遍压测,把"基础 QPS"和"P95 延迟"这两个基线记下来,后续每次代码变更后对比基线,很容易发现性能回退。
第三个体会:这个组合里的每个环节都不是孤立的。Gunicorn 的 timeout 要和 Nginx 的 proxy_read_timeout 配合,Kubernetes 的 terminationGracePeriodSeconds 要和 Gunicorn 的 graceful-timeout 配合,日志的请求 ID 要和下游服务的透传 Headers 约定配合。只调优单个组件而不看全局,往往会被"木桶效应"卡住。
最后分享一个排查问题的小技巧:当服务出现诡异的性能问题时,先别急着看代码,先去/metrics端点看一眼http_requests_inprogress指标。如果这个值持续大于 0 而且在高位徘徊,说明事件循环里堵着协程,优先排查同步阻塞调用;如果这个值不高但请求延迟很高,说明问题可能出在下游服务或数据库。这个指标能帮你快速缩小排查范围,而不是无头苍蝇一样乱翻代码。
下次如果你的 FastAPI 服务上线前有点心里没底,不妨按这篇文章里的步骤检查一遍:部署方式是不是 Gunicorn + UvicornWorker,参数和容量评估是否匹配,配置是不是用的 pydantic-settings 管理,指标和日志是否已经接入。这几件事都做到位了,FastAPI 服务上生产,心里就能踏实不少。