ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FastAPI生产部署实战:Uvicorn与Gunicorn多进程配置与性能调优

FastAPI生产部署实战:Uvicorn与Gunicorn多进程配置与性能调优 去年秋天有个晚上我盯着监控面板百思不得其解。服务在本地跑得好好的测试环境压测一上来直接大面积超时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 基于 StarletteStarlette 基于 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 基础内存占用是 500MB8 个 worker 就是 4GB。这解释了为什么 FastAPI 服务往往比同等承载量的 Node.js 服务更吃内存任意一个 worker 崩溃Gunicorn 会拉一个新的worker 的异常退出不会导致整个服务不可用这是单进程方案做不到的worker 之间不共享内存如果需要缓存数据、维护状态得用 Redis 之类的共享存储或者多 worker 之间通过消息机制不能像单进程那样依赖全局变量我用一个表格把单进程 Uvicorn、Uvicorn --workers、Gunicorn UvicornWorker 做个直观对比对比维度单进程 UvicornUvicorn --workers 4Gunicorn UvicornWorkerCPU 利用率单核多核多核worker 崩溃自动拉起不支持不支持支持worker 超时自动重启不支持不支持支持--timeout优雅停机需自己写逻辑有限完善--graceful-timeout预加载应用代码不支持有限支持--preload需慎用生产环境成熟度低中高3. 参数别拍脑袋workers、timeout、keepalive、backlog 的取值逻辑3.1 workers 数量的确定别迷信2xCPU1很多教程告诉你 worker 数 CPU 核心数 × 2 1这是一个来自 Gunicorn 文档的经验公式但它的前提是同步 worker 处理 IO 密集型任务。在 FastAPI 场景下Uvicorn worker 内部已经用了事件循环单个 worker 本身就能承载大量并发 IO所以 worker 数的选择逻辑完全不同。我现在的做法是分两步第一步判断应用的瓶颈类型IO 密集型场景频繁访问数据库、Redis、调用第三方 HTTP APIworker 数可以等于 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 5000worker 每处理完 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里一个变量的拼写错误会不会被静默忽略我见过不止一次因为DEBUGTrue被不小心带到生产环境导致性能骤降或敏感信息泄漏的事故。在 FastAPI 项目里我推荐使用pydantic-settings做统一配置管理。它基于 Pydantic 的类型校验能确保配置值在启动时就被校验而不是运行到一半才报错。这相当于给配置加了一层编译期检查。4.2 pydantic-settings 的实际用法首先安装依赖pip install pydantic-settings pydantic然后在项目里新建一个配置模块app/config.pyfrom 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_encodingutf-8, extraignore, ) 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( titlesettings.app_name, debugsettings.debug, )然后每个接口里如果需要访问配置直接from app.config import get_settings即可。因为有lru_cache整个生命周期内只会加载和解析一次配置不会对性能产生影响。4.3 环境变量、配置文件、默认值的三层优先级pydantic-settings 的读取优先级非常明确环境变量优先级最高DATABASE_URLxxx 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(extraignore)的作用是忽略掉.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和Histogramfrom 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(reasonexternal_system).inc() raise HTTPException(status_code502, detailstr(e)) from e return result用ORDER_FAILED.labels(reasonexternal_system).inc()这种方式可以按失败原因打标签到时候 Grafana 面板上能直接看到哪些原因导致的失败在上升定位速度会快很多。5.3 结构化日志告别 print一切日志都是 JSON日志和监控是两套互补的系统。监控回答发生了什么日志回答具体是哪一个请求、哪一行代码出的事。但 FastAPI 默认的输出格式是纯文本在分布式排查场景下很难用。我的做法是所有日志输出 JSON 格式统一到 stdout由日志采集器Promtail/Filebeat/Fluent Bit负责转发。这比应用直接写日志文件要方便得多因为换机器、扩容、挂盘都不影响日志采集。用 Python 的loggingjson-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 数据源里可以用{appfastapi} | order_id123456这种语法快速检索到某个订单的完整生命周期日志。5.4 请求 ID 链路透传把一次请求的所有日志串起来单看一条 JSON 日志还不够你更需要的是根据一个请求 ID找到这个请求经过的所有日志。这在高并发场景下几乎必需。实现方案是用 Starlette 中间件 contextvarsimport 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 16Nginx 和 Gunicorn 之间的连接池大小proxy_read_timeout 60s如果 Gunicorn 的 timeout 设的 60 秒这里也设成 60 秒保持一致。否则 Nginx 的 60 秒超时先触发客户端收到的还是 504而 Gunicorn 还没杀掉 workerFastAPI 这边也有一个点需要注意由于前面有 NginxFastAPI 获取客户端真实 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_codestatus.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 的terminationGracePeriodSecondsFastAPI 应用不要在收到 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 服务上生产心里就能踏实不少。
返回列表