FastAPI性能与部署实战五:Uvicorn/Gunicorn、多环境配置、监控与日志,这期是整套系列里最“摸得着”的一篇。前面几期我们把接口设计、参数校验、中间件优化都过了一遍,但代码写得再漂亮,最后还是要落到一个能扛住线上流量的部署方案上。这一篇要解决的无非是三个事:用Uvicorn和Gunicorn把服务稳定跑起来、把开发测试生产的环境配置隔离开、把监控和日志这两个“眼睛”装上。适合刚把FastAPI项目写完准备部署的朋友,也适合那些服务已经上线、但遇到问题只能靠猜、靠翻终端的人。
先说一个我踩了不知道多少次的认知误区:很多新手直接uvicorn main:app --workers 4就跑生产了,然后过阵子发现某个worker莫名其妙僵死,请求超时,CPU飙到100%。这不是Uvicorn不行,而是你没搞清楚谁该管进程、谁该管协议。把这层关系理清楚,部署的很多问题都能提前避免。
1. 部署方案选型:为什么是Uvicorn+Gunicorn
1.1 Uvicorn与Gunicorn的分工关系
先看最本质的区别:Uvicorn是个ASGI服务器,它实现了ASGI协议,能直接和FastAPI应用通信,异步能力强;Gunicorn本来是个WSGI服务器,主要服务Django、Flask这类同步框架,但从20.1版本开始,Gunicorn支持了Uvicorn的worker类型,官方文档里也把gunicorn -k uvicorn.workers.UvicornWorker作为生产部署的标准方案。
你完全可以把这两者理解成“包工头”和“施工队”的关系。Gunicorn是包工头,负责从工地上拉来一堆工人(worker进程),管他们的入场、离场、健康状态,哪个工人干不动了就换一个;Uvicorn是施工队,真正执行砌墙、抹灰这些活计,也就是处理HTTP/ASGI协议、解析请求、再把任务丢给FastAPI的业务代码。包工头不亲自砌墙,所以他管进程管理特别在行,比如worker重启、pre-load、优雅停机;施工队如果自己又拉人又干活,很容易忙乱。
这里有个关键原因要说明白:为什么不直接uvicorn --workers 4?Uvicorn确实支持多worker,而且自带进程管理器,但它的进程管理能力比Gunicorn弱,例如没有--preload这种预热机制,也没有那么细致的worker超时和优雅重启策略。生产环境里,Gunicorn的--timeout、--graceful-timeout、--max-requests这一套配置,能让服务在异常情况下“自我抢救”,而不是整个瘫掉。实测下来,用Gunicorn管理进程、用Uvicorn worker处理协议,稳定性明显强于一上来就裸跑Uvicorn多进程。
1.2 性能参数与worker数量计算
worker数量不是拍脑袋定的,一般从CPU核心数出发。最常见的经验公式是2 * CPU核心数 + 1,这是基于大量同步worker场景的经验值,但FastAPI是异步应用,worker进程内部已经用事件循环处理了大量并发,并不需要开太多进程。更合适的做法是先把服务压测一遍,然后结合核心数来定。
举个例子,一台4核的云服务器,如果接口里大部分是异步IO(查数据库、调外部API),我会从4个worker开始跑,让事件循环去吸收并发,而不是开8个进程去抢占CPU。如果接口里面有严重依赖CPU计算的同步代码,比如图像处理、加密解密,那worker数量可以适当增多,避免一个计算任务阻塞事件循环。
内存也要算进去。每个Uvicorn worker大概会占50~150MB内存,取决于应用加载的依赖和缓存。4G内存的机器,跑4个worker通常已经比较紧张了,要监控着点,别把系统内存全吃光。
还有几个参数要配套设置:
--timeout:默认30秒。如果某个接口处理时间超过30秒,Gunicorn会认为worker卡死,直接把它杀掉重启。FastAPI里如果确实有长耗时任务,要把timeout调大,或者干脆丢到后台任务队列里,别让HTTP请求扛着。--graceful-timeout:默认也是30秒。优雅停机时等待worker处理完当前请求的时间,建议给足,不然正在处理中的请求会被强杀。--keep-alive:默认2秒。保持HTTP长连接的时间,面向WebSocket或大量复用连接的场景可以调大点,一般2~5秒够用。--max-requests:让worker处理完一定数量的请求后主动重启,能有效解决内存泄漏问题。我通常设成1000~2000,配合--max-requests-jitter加一点随机量,避免所有worker同时重启。
配置文件写成这样,放production.conf.py里:
python复制import multiprocessing
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "uvicorn.workers.UvicornWorker"
bind = "0.0.0.0:8000"
timeout = 60
graceful_timeout = 30
keepalive = 5
max_requests = 1500
max_requests_jitter = 300
accesslog = "-"
errorlog = "-"
accesslog = "-"是把访问日志打到标准输出,方便容器环境统一采集日志。如果你用Docker部署,这个设置几乎是必须的,别把日志写进容器里的文件,容器一销毁日志就全没了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多环境配置:从开发到生产的一次到位
2.1 为什么需要多环境配置
我见过很多项目把数据库连接串直接写在config.py里,然后部署时靠手动修改代码来切换环境。这种方式在项目刚起步时还能忍,一旦团队人多起来,你根本不知道线上跑的是哪个版本,一个不小心就把测试环境的数据库连接发上去了。
多环境配置要解决三个问题:第一,不同环境下的参数值不同,比如数据库地址、Redis地址、日志级别、调试开关;第二,敏感信息不能进代码仓库,比如密码、密钥;第三,切换环境不需要改代码,只需要改变量。
FastAPI生态里最顺手的方案是pydantic-settings,它能把环境变量自动解析成带类型校验的配置对象,而pydantic本来就是FastAPI的底层依赖,不用额外引入一整套新的配置框架。
2.2 基于pydantic-settings的配置体系
先装依赖:
bash复制pip install pydantic-settings
然后建一个app/config.py:
python复制from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_name: str = "fastapi-demo"
debug: bool = False
api_prefix: str = "/api/v1"
database_url: str = "sqlite:///./dev.db"
redis_url: str = "redis://localhost:6379/0"
log_level: str = "INFO"
cors_origins: list[str] = ["http://localhost:3000"]
@lru_cache
def get_settings() -> Settings:
return Settings()
几个要点说一下。lru_cache是必须的,不然每次调用get_settings()都会重新读一遍环境变量,性能有损耗。model_config里的env_file=".env"指定了本地开发用的环境变量文件,生产环境里如果你用Docker传环境变量,就不需要.env文件,环境变量本身的优先级高于.env文件,这是pydantic-settings默认的行为。
实际开发里,我用三个文件来管理不同环境:
.env.example:提交到代码仓库,记录所有可配置项和注释,方便新人复制成自己的环境。.env:开发环境使用,加入.gitignore,每个开发者自己维护。- Docker Compose或K8s里的环境变量:生产环境使用,直接从部署平台的secret管理功能注入。
接口里要使用配置时,直接在依赖系统里注册:
python复制from functools import lru_cache
from fastapi import Depends, FastAPI
from app.config import get_settings, Settings
app = FastAPI()
@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
return {"app_name": settings.app_name, "debug": settings.debug}
这样测试接口时还能把配置对象替换成mock值,可测试性也好很多。需要强调的是,debug这个配置项在生产环境里一定要设成False,开启debug会暴露完整的错误堆栈给客户端,这是线上安全事故的常见起点。
2.3 Docker下的环境区分与多阶段构建
Docker部署时,多环境配置要配合镜像构建一起设计。我的做法是:用多阶段构建把依赖打包好,启动时通过环境变量控制运行环境。
Dockerfile大致长这样:
dockerfile复制FROM python:3.11-slim as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app.main:app"]
启动时用-e参数覆盖环境变量:
bash复制docker run -d --name my-api \
-p 8000:8000 \
-e DATABASE_URL="postgresql://user:pass@pg-host:5432/mydb" \
-e REDIS_URL="redis://redis-host:6379/0" \
-e LOG_LEVEL="INFO" \
my-api:latest
如果你用docker-compose,还可以通过env_file指定不同环境:
yaml复制services:
api:
build: .
env_file:
- .env.production
这里有个坑:生产环境里不要把.env.production提交到镜像里。镜像应该是无状态的,配置全部来自运行时注入,这样换一台服务器部署,或者做蓝绿发布、横向扩容,都不会因为镜像里的旧配置出问题。我之前的团队就吃过这个亏,镜像里写死了测试环境Redis地址,结果生产环境缓存全串了,调试了一整天才发现是环境变量优先级的问题。
3. 监控体系:不能等用户告诉你服务挂了
3.1 监控分层:系统级、应用级、业务级
很多人一听监控就觉得要上一套特别庞大的平台,其实监控最核心的目的就是:在你还没被用户投诉之前,先发现异常。监控应该分三层来看。
第一层是系统级,看服务器本身的健康状态,比如CPU、内存、磁盘IO、网络。这一层用node_exporter就能搞定,容器环境还可以加cAdvisor采集容器指标。第二层是应用级,看FastAPI的请求量、错误率、P99延迟、活跃连接数,这一层需要应用主动暴露metrics接口。第三层是业务级,比如订单数量、注册用户数、支付成功率,这层指标往往需要业务代码里手动埋点。
三层监控缺一不可。我见过只监控了CPU内存,结果应用早就假死但CPU还正常的情况;也见过应用指标一切正常,但实际上数据库连接池被打满,业务已经走不通的场景。监控方案的重点不在于工具有多炫,而在于把所有关键环节的“信号灯”都点亮。
工具链方面,我推荐一套组合:Prometheus负责采集和存储指标数据,Grafana负责画面板和报警,Alertmanager负责把告警推送给钉钉、企业微信或邮件。这套架构在中小团队里完全够用,而且Prometheus的生态非常成熟,后续再扩展也方便。
3.2 为FastAPI接入Prometheus
FastAPI接入Prometheus最省事的方案是直接用现成的库,比如prometheus-fastapi-instrumentator。但它自动采集的指标比较固定,如果想控制粒度,手动埋点也很简单。
先安装依赖:
bash复制pip install prometheus-client
在FastAPI里加一个生命周期管理,启动Prometheus的metrics服务:
python复制from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
from fastapi import FastAPI, Request, Response
from starlette.responses import Response as StarletteResponse
import time
REQUESTS = Counter("http_requests_total", "Total HTTP Requests", ["method", "path", "status"])
REQUESTS_DURATION = Histogram(
"http_request_duration_seconds",
"HTTP Request Latency",
["method", "path"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
)
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
start_time = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start_time
REQUESTS.labels(request.method, request.url.path, response.status_code).inc()
REQUESTS_DURATION.labels(request.method, request.url.path).observe(duration)
return response
@app.get("/metrics")
def metrics():
return StarletteResponse(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
/metrics接口一定不要加在需要鉴权的路由里,因为Prometheus采集时不方便走复杂的鉴权流程,通常用网络策略限制访问。
关键指标看这几个:
http_requests_total:整体QPS,按method、path、status拆分,能快速定位哪个接口报错多。http_request_duration_seconds:延迟分布,重点看P99,如果P99长期超过接口的SLO,就要考虑优化或扩容。- process_cpu_usage_seconds_total、process_resident_memory_bytes:来自进程内的指标,方便观察单个worker的内存。
Grafana里直接导入node_exporter和Prometheus的官方面板,再自己建一张应用面板,把上面这些指标配上,基本就能做到“一目了然”。
3.3 告警规则与配置
监控指标有了,光靠人肉盯着Grafana看仍然不靠谱。必须配置告警规则,让Prometheus自己判断并触发通知。
一个基本的告警规则文件alerts.yml:
yaml复制groups:
- name: fastapi-alerts
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m])) by (path)
/
sum(rate(http_requests_total[5m])) by (path) > 0.05
for: 10m
labels:
severity: critical
annotations:
summary: "High error rate on {{ $labels.path }}"
description: "Error rate is above 5% for 10 minutes. Current value: {{ $value }}"
- alert: HighP99Latency
expr: |
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, path)) > 1
for: 15m
labels:
severity: warning
annotations:
summary: "P99 latency above 1s on {{ $labels.path }}"
- alert: ServiceDown
expr: up{job="fastapi"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "FastAPI service is down"
告警里有个容易踩的坑:for的持续时间不要设置太长。之前我把HighErrorRate的for设成30分钟,结果线上出了问题,用户已经炸锅了,告警还没触发。后来统一改成5~10分钟,既能过滤掉瞬时抖动,又能及时发现问题。
Alertmanager再把告警转发到钉钉机器人,配置一个webhook即可。如果团队没有专门的人盯告警,告警数量宁多勿少,等被“狼来了”折磨几次,再慢慢调精。
4. 日志体系:从stdout到结构化日志
4.1 为什么推荐json格式的日志
日志这个东西,平时不起眼,出了问题就是救命稻草。但如果你日志只输出一句话:INFO: 127.0.0.1:56234 - "GET /api/v1/users/123 HTTP/1.1" 200 OK,你会发现排查问题时根本不够用——你没法快速根据用户ID或请求ID把一次请求的完整链路串起来。
所以第一件事是让日志结构化。所谓结构化,就是每行日志都是一个固定schema的JSON,包含时间、级别、模块、请求ID、用户ID、耗时、状态码等字段。这样无论是人工搜索还是接入日志平台做分析,效率都会高很多。
Python标准库的logging配合python-json-logger包就能做到:
bash复制pip install python-json-logger
配置logger:
python复制import logging
from pythonjsonlogger import jsonlogger
logger = logging.getLogger("app")
handler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s %(request_id)s %(user_id)s"
)
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
StreamHandler是关键,它把日志写到标准输出。在容器环境里,日志会被Docker的日志驱动统一收集,再交给Loki或ELK。直接把日志写本地文件的方案,在容器、K8s环境里都是反面教材。
4.2 中间件与上下文的日志增强
日志有了结构化格式,还需要在请求处理链路里自动填充上下文,比如request_id、user_id、客户端IP、耗时。我的做法是在FastAPI的中间件里先生成request_id,再塞到日志的context变量里。
用contextvars可以让同一个请求内的所有日志都带上request_id:
python复制import contextvars
import uuid
import logging
request_id_var = contextvars.ContextVar("request_id", default="-")
class RequestIDMiddleware:
async def __call__(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)
业务代码里打印日志时,不需要手动塞request_id,直接用logger.info就能带上,前提是logger在格式化时把request_id_var.get()取出来的值加进去。
这个设计的好处非常明显:当用户在群里丢给你一条报错消息时,你只需要问一句“能把X-Request-ID发我吗”,然后在日志系统里一搜,整条链条(网关日志、应用日志、数据库慢查询日志)就全串起来了。要是没有request_id,排查分布式问题就是大海捞针。
记录访问日志时,我还会把客户端IP、请求路径、状态码、耗时、user-agent都放进去。但注意,日志里千万别记录token、密码、手机号这类敏感字段,防止日志泄露导致安全事故。
4.3 日志采集与存储选型
日志写到了stdout,下一步就是怎么收集、怎么查。小团队我推荐直接上Loki,因为Loki和Prometheus同门兄弟,部署轻量,查询语法和PromQL类似,存储成本也低。
Loki的采集架构一般都是这样:Promtail把容器stdout日志推给Loki,Grafana里配置Loki数据源,就能像查Prometheus指标一样查日志。
yaml复制# docker-compose里一个简易Loki
services:
loki:
image: grafana/loki:latest
ports:
- "3100:3100"
volumes:
- ./loki-config.yaml:/etc/loki/local-config.yaml
promtail:
image: grafana/promtail:latest
volumes:
- /var/log:/var/log
- /var/lib/docker/containers:/var/lib/docker/containers
command: -config.file=/etc/promtail/config.yml
团队规模更大、有专门的运维投入时,才能考虑ELK,因为它组件多、资源消耗高,小项目上ELK有点杀鸡用牛刀。当初我们一套ELK跑下来,光是Elasticsearch就吃了8G内存,换成Loki之后,服务成本直接降了一大截。选型没有绝对的好坏,只有合不合适。
慢查询日志这块也要做。FastAPI中间件里可以计算每个请求的耗时,超过阈值就单独打一条WARNING日志:
python复制SLOW_THRESHOLD = 0.5
@app.middleware("http")
async def slow_query_logging(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
if duration > SLOW_THRESHOLD:
logger.warning(
"slow request",
extra={
"path": request.url.path,
"duration_ms": round(duration * 1000, 2),
},
)
return response
这样每天扫一遍慢请求日志,就知道哪个接口需要优化,哪个接口该加缓存。
5. 常见问题与排查技巧实录
5.1 Uvicorn/Gunicorn启动失败的老坑
启动失败是最常见的问题,翻来覆去就那几个原因。
第一,端口被占用。Address already in use说明有另一个进程占着端口。排查命令:
bash复制netstat -tunlp | grep 8000
或者用lsof -i :8000。找到PID后,先确认是不是自己之前启动的服务,别乱杀。
第二,--workers 4 --reload同时使用会直接报错。Gunicorn的worker模式和reload模式不能同时启用,开发时只用单进程加--reload,生产环境才开多worker。我用gunicorn时,本地调试完全不经过gunicorn,直接uvicorn app.main:app --reload,生产再走gunicorn。
第三,worker failed to boot。这多半是代码里在模块导入时做了启动操作,比如初始化数据库连接、创建全局HTTP客户端,一旦worker预加载失败,整个服务就会不停重启。解决办法是用FastAPI的lifespan事件统一管理连接资源,而不是在模块顶层执行副作用。
第四,超时导致worker被频繁杀掉。如果接口本身处理慢,Gunicorn默认30秒超时会杀掉worker,日志里能看到Worker timed out。这时候要去查慢接口,而不是盲目调大超时,否则worker越积越多,内存会被拖垮。
5.2 日志与监控数据“对不上”的排查
监控面板显示P99延迟很高,但服务日志里每一条请求都很快;或者日志里压根找不到刚才那个报错的请求——这类“对不上”的问题,排查思路其实很固定。
先看时区。容器默认通常是UTC,如果你在Grafana里用的是东八区,而日志系统用UTC,同一时间点的数据就会错位。我踩过这个坑,排查了大半天才发现Prometheus和Loki显示的“最近15分钟”其实对应的不是一个时间窗口。解决方式是统一时区:容器里设置TZ=Asia/Shanghai,日志时间戳统一用ISO8601带时区偏移的格式,前端展示时由Grafana做时区转换。
再看Prometheus的抓取超时。scrape_timeout默认10秒,如果/metrics接口正好响应慢,这次抓取就会失败,面板上出现断点。可以用scrape_timeout: 30s或者优化metrics接口的性能。
还有日志驱动的坑。Docker默认的json-file日志驱动会容错,如果日志输出量太大,Docker可能静默丢日志。生产环境建议在daemon.json里配置:
json复制{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "5"
}
}
日志文件必须做轮转,不然一个容器跑几个月,日志文件能把磁盘塞满,到时候服务不是被打垮的,是被日志淹死的。
5.3 一个简单的线上部署检查清单
每次上线前,我都会照着这个清单过一遍,很多线上事故其实就是漏了其中一项。
检查项清单:配置里debug=False;环境变量全部注入,不用硬编码;worker数量和超时根据自己的压测结果设置;/metrics端口不对外网暴露;日志输出到stdout且为JSON格式;访问日志开启;慢请求日志阈值已设置;Prometheus抓取目标已新增;告警规则已生效;Docker限流和日志轮转已配置;健康检查接口/healthz已添加(Gunicorn里用--check-config配合健康检查)。
这里再说一下健康检查接口。它不只是给K8s用的,你在负载均衡后面加实例时,也必须有这么个接口来确认服务真的就绪。里面可以顺带检查数据库连接池、Redis连接这些关键依赖是否可用:
python复制@app.get("/healthz")
def healthz():
try:
db_ping()
redis_ping()
return {"status": "ok"}
except Exception as exc:
return JSONResponse(status_code=503, content={"status": "unhealthy", "detail": str(exc)})
我个人的习惯是:上线后第一个晚上,强制自己盯一下Grafana面板和日志搜索,别急着睡。等到P99曲线、错误率、日志量都稳定了,再放心。所以我的体会是,部署这一套熟练了之后,最耗时间的往往不是Uvicorn或Gunicorn的配置本身,而是监控和日志的细节打磨。比如日志里加一个request_id字段、告警规则里把for设成10分钟、Docker日志加上轮转,这些乍看不值一提,但真到线上出问题时,每一条都能帮你省下几小时甚至一整天的排查时间。
最后再分享一个实测好用的扩展方向:如果你把请求量、延迟这些指标接到Grafana之后,可以顺手做一个“请求量环比变化”的面板,连续几天观察同一时段的流量曲线,会对你的服务画像有非常直观的认知。哪个时段流量高、哪个接口拖慢了整体延迟、扩容该在什么时间点做,全都能从面板上看出规律来。整套部署、配置、监控、日志做完,FastAPI的项目才算真正有了“可以安稳上线”的底气。
