第 17 章:部署与生产实践
开发环境里
fastapi dev一跑就完事,生产环境要回答的是另一组问题:谁来管进程、配置从哪来、迁移什么时候跑、Nginx 后面怎么拿到真实客户端 IP、日志能不能定位到一次请求。本章把这些一次性讲清楚。
学习目标
- 理解开发与生产的差异清单,知道哪些开关必须切换
- 掌握
fastapi run/uvicorn/gunicorn三种启动方式,能算出一个合理的 worker 数 - 理解多进程带来的约束:进程内内存不共享、定时任务会重复执行
- 能用
pydantic-settings做分层配置,并把.env挡在仓库外 - 能写出多阶段、非 root、带健康检查的 Docker 镜像与 compose 编排,并配好 Nginx 反向代理(代理头 / WebSocket 升级 / 上传体积)
17.1 开发与生产的差异清单
| 维度 | 开发(fastapi dev) | 生产(fastapi run / uvicorn) |
|---|---|---|
| 热重载 | 默认开启 | 必须关闭(重载会重启进程、丢连接) |
| 进程数 | 单进程 | 多 worker(N 个独立进程) |
| 日志级别 | debug,只看终端 | info/warning,结构化输出并集中采集 |
| 错误信息 | 返回完整堆栈 | 只回统一错误体,堆栈只进日志 |
| 协议 | HTTP | HTTPS 由反向代理终止 |
| 配置来源 | 硬编码 / .env | 环境变量 + 密钥管理,.env 只用于本地 |
17.2 启动方式与 worker 数量
# ① FastAPI CLI:生产模式(关闭热重载、监听 0.0.0.0),多进程用 --workers 显式指定
fastapi run app/main.py --workers 4
# ② 直接调 Uvicorn(容器里最常用)
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# ③ Gunicorn 托管 Uvicorn worker:需要进程管理、优雅重启、平滑扩缩容时使用
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000- 只在一台机器上跑多个进程、又需要 gunicorn 的进程守护与优雅重启(
HUP平滑重载)时才加 gunicorn。容器编排(Docker Compose / K8s)已经负责进程管理,直接用 uvicorn 即可。 - 新版 uvicorn 把 worker 类移到了独立包:
uvicorn.workers已废弃,改用uvicorn-worker后写-k uvicorn_worker.UvicornWorker。 - worker 数量经验值:CPU 核数 × 1~2。FastAPI 是异步的,单个 worker 已能并发处理大量 I/O,加 worker 主要买的是 CPU 并行(Pydantic 校验、JSON 序列化、模板渲染)。纯 I/O 密集可略多,CPU 密集不要超过核数太多;在 Kubernetes 上通常每个容器一个 Uvicorn 进程,靠 Pod 副本数横向扩展。
17.3 多进程带来的约束(最容易踩)
每个 worker 是独立进程、独立内存:模块级变量在 N 个进程里各有一份,lifespan 里的启动代码会执行 N 次。因此下面这些东西不能放在进程内。
❌ 不推荐(进程内内存当共享状态):
ONLINE_USERS: dict[int, str] = {} # ❌ 每个 worker 各一份,负载均衡后数据随机缺失
RATE_LIMIT: dict[str, int] = {} # ❌ 限流计数在多进程下形同虚设✅ 推荐(共享状态放进程外):
# 在线状态:写 Redis 并设过期时间
await redis.set(f"user:{user_id}:online", "1", ex=60)
# WebSocket 广播:走 Redis Pub/Sub,各 worker 订阅后推给本进程的连接(第 15 章)
await redis.publish("ws:broadcast", payload)定时任务会执行 N 次:lifespan 里启动 APScheduler、while True 轮询、消费队列循环,都会在每个 worker 里各跑一份。两个正确做法:一是把调度器放进独立进程 / 独立容器(只跑一个副本);二是保留在应用里但加分布式锁,例如用 Redis SET key value NX PX 30000 抢锁,只有抢到的进程执行本轮任务。
17.4 配置管理:pydantic-settings
# app/core/config.py
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "FastAPI Tutorial"
debug: bool = False
database_url: str # 必填:缺失时启动即报错
secret_key: str = Field(min_length=32) # 必填且有长度下限
cors_origins: list[str] = []
@lru_cache
def get_settings() -> Settings:
return Settings()@lru_cache 让配置只解析一次;业务代码用 Annotated[Settings, Depends(get_settings)] 注入(第 6 章),测试里想换配置就 get_settings.cache_clear() 或直接覆盖依赖。
# .env.example —— 提交进仓库的模板;真实的 .env 必须写进 .gitignore
DEBUG=false
DATABASE_URL=postgresql+asyncpg://app:change-me@db:5432/app
SECRET_KEY=please-generate-a-32-byte-random-string
CORS_ORIGINS=["https://app.example.com"] # 复杂类型按 JSON 解析,别写成逗号分隔.env 里是数据库密码、JWT 密钥这类东西,一旦进仓库就得轮换全部密钥并清理 Git 历史。生产环境优先走平台的环境变量 / 密钥管理服务,.env 只留给本地开发。
17.5 Dockerfile:多阶段 + 非 root
# ---- 构建阶段:用 uv 按锁文件装依赖 ----
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY app ./app
COPY alembic.ini migrations ./
RUN uv sync --frozen --no-dev
# ---- 运行阶段:只拷贝虚拟环境与源码 ----
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:$PATH" TZ=Asia/Shanghai
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser \
&& apt-get update && apt-get install -y --no-install-recommends tzdata curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /app /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--proxy-headers", "--forwarded-allow-ips", "*"]要点:uv sync --frozen 严格按 uv.lock 安装,保证构建可复现;--no-dev 不把 pytest 装进生产镜像;运行阶段只带 .venv 与源码,镜像更小、攻击面更少;USER appuser 避免 root 运行;PYTHONUNBUFFERED=1 让容器日志实时输出。
.dockerignore 要排除 .venv/、.git/、__pycache__/、.env 与 tests/:它们要么让构建上下文暴涨,要么会把本地密钥带进镜像层。
17.6 docker-compose:api + postgres + 一次性迁移
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
retries: 10
migrate: # 一次性任务:迁移只跑一次,绝不放应用的 lifespan
build: .
command: ["alembic", "upgrade", "head"]
env_file: [.env]
depends_on:
db: {condition: service_healthy}
api:
build: .
env_file: [.env]
ports: ["8000:8000"]
depends_on:
db: {condition: service_healthy}
migrate: {condition: service_completed_successfully}
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000",
"--workers", "4", "--proxy-headers", "--forwarded-allow-ips", "*"]
volumes:
pgdata:三个关键点:condition: service_healthy 保证数据库真正可连再启动应用;service_completed_successfully 保证迁移跑完(且成功)才起 API;把迁移拆成独立服务,就不会出现多 worker 并发执行 alembic upgrade head 抢锁或重复插数据。
17.7 Nginx 反向代理
map $http_upgrade $connection_upgrade { # WebSocket 升级需要的连接头映射(第 15 章)
default upgrade;
'' close;
}
server {
listen 80;
server_name api.example.com;
client_max_body_size 20m; # 大文件上传,默认 1m 会直接 413
location / {
proxy_pass http://api:8000;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # WebSocket 长连接,别用默认 60s
}
}- 代理头必须被 Uvicorn 信任:Nginx 传了
X-Forwarded-For/X-Forwarded-Proto,但 Uvicorn 默认只信任127.0.0.1来的转发头,容器里 Nginx 的 IP 不在其中,于是请求被当成 HTTP 直连——结果是request.client.host拿到的是 Nginx 的 IP,/docs与重定向生成的 URL 是http://。解决方式是启动时加--proxy-headers --forwarded-allow-ips="*"(仅当只有可信代理能访问该端口时才用*)。 - WebSocket 必须带
Upgrade与Connection头,并用map兼容普通请求;proxy_read_timeout要覆盖心跳间隔。上传则要看client_max_body_size,它必须与第 14 章的UploadFile大小校验一致,否则用户拿到的是 Nginx 的 413 而不是你精心写的 422。
17.8 日志与可观测性
开发时看终端,生产时日志要能被机器解析:一行一条 JSON,并带上贯穿全链路的请求 ID(第 9 章的中间件把 ID 写进 ContextVar)。
import json
import logging
from contextvars import ContextVar
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"request_id": request_id_ctx.get(), # 第 9 章的中间件写入
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)用 uvicorn --log-config logging.yaml 挂载配置,并把访问日志与错误日志分成两个 logger / 两条输出流:访问日志量大、按天滚动;错误日志量小、需要立刻告警。接指标与链路时,prometheus-fastapi-instrumentator 暴露 /metrics,OpenTelemetry 的 FastAPIInstrumentor 负责生成 span——两者都挂在中间件层,不改业务代码。
健康检查要分两种:liveness 只回答「进程还活着吗」,不碰数据库,否则数据库抖动会让编排系统反复重启你的容器;readiness 回答「现在能接流量吗」,依赖不可用时返回 503。
@router.get("/healthz", include_in_schema=False) # liveness:不查库
async def liveness() -> dict[str, str]:
return {"status": "ok"}
@router.get("/readyz", include_in_schema=False) # readiness:依赖不可用就 503
async def readiness(session: AsyncSessionDep) -> dict[str, str]:
try:
await session.execute(text("SELECT 1"))
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail="database unavailable") from exc
return {"status": "ready"}17.9 上线检查清单
| 类别 | 检查项 |
|---|---|
| 配置 | .env 未进仓库、SECRET_KEY 为随机长串、DEBUG=false、CORS_ORIGINS 是白名单而非 * |
| 配置 | 镜像使用 uv.lock 冻结依赖;数据库连接串指向生产库,连接池上限 ≤ 数据库 max_connections |
| 安全 | 容器以非 root 运行;HTTPS 由反代终止并强制跳转;错误响应不含堆栈 |
| 安全 | 迁移以独立步骤执行;管理端点(/docs、/metrics)按需关闭或加访问控制 |
| 性能 | worker 数与 CPU 匹配;静态资源与压缩交给 Nginx;上传体积与超时与业务一致 |
| 性能 | 慢查询与 N+1 已排查(第 10 章 selectinload);必要时接入限流与缓存 |
| 可观测性 | /healthz 与 /readyz 已接入编排健康检查;日志为结构化 JSON 且含 request_id |
| 可观测性 | 指标 / 追踪已接入,告警有明确阈值与联系人;日志保留期符合合规要求 |
常见坑与排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 容器被 OOM Kill,或机器内存被吃满 | worker 数开得过多,每个 worker 都有独立内存与连接池 | 降到 CPU 核数 × 1~2;核对单 worker 常驻内存与数据库连接上限 |
HTTPS 下 /docs、重定向生成的链接是 http:// | Uvicorn 未信任代理转发头 | 启动加 --proxy-headers --forwarded-allow-ips="*"(仅限可信代理网络) |
上传稍大文件返回 413 Request Entity Too Large | Nginx 默认 client_max_body_size 1m | 调大 client_max_body_size,并与应用侧大小校验保持一致 |
| 容器起来了但外部访问不到 | 监听写成 127.0.0.1,只接受容器内回环 | 绑定 0.0.0.0(或 ::),端口映射到宿主机 |
| 数据库密码 / JWT 密钥泄露 | .env 被提交进仓库 | 加 .gitignore、轮换全部密钥、清理 Git 历史,改用平台密钥管理 |
| 启动时报迁移冲突或表已存在 | 多个 worker 同时执行 alembic upgrade head | 用一次性 migrate 服务;必要时加 Postgres advisory lock |
| 日志与数据库时间差 8 小时 | 容器时区为 UTC,业务按本地时间理解 | 统一存 UTC,展示层转换;确需本地时区就设 TZ=Asia/Shanghai 并装 tzdata |
| 非 root 用户对挂载卷没有写权限,启动即失败 | 构建时未把文件属主交给运行用户 | 构建时 --chown=appuser:appuser,卷目录同步设置属主 |
本章小结
| 要点 | 说明 |
|---|---|
| 开发 vs 生产 | 热重载关、多进程、监听 0.0.0.0、日志结构化、错误不暴露堆栈 |
| 启动方式 | fastapi run --workers 4、uvicorn --workers 4、需要进程管理时用 gunicorn |
| worker 数 | CPU 核数 × 1~2;I/O 密集可多,K8s 上建议一容器一进程 |
| 多进程约束 | 进程内内存不共享:会话、缓存、计数器、WebSocket 连接表都要放 Redis 等外部存储 |
| 定时任务 | 每个 worker 都会执行,需独立调度进程或分布式锁 |
| 配置管理 | pydantic-settings + SettingsConfigDict(env_file=".env") + @lru_cache 依赖注入;.env 不进仓库,只提交 .env.example |
| 镜像 | 多阶段构建、uv sync --frozen --no-dev、非 root 用户、HEALTHCHECK |
| 编排 | depends_on 用 service_healthy 与 service_completed_successfully;迁移独立成一次性服务 |
| 可观测性 | JSON 日志 + request_id;访问/错误日志分流;/healthz 与 /readyz 分离;OTel / Prometheus 挂中间件 |
练习题
- 把一个单 worker 的应用改成 4 worker,并把一个模块级
dict缓存换成 Redis 实现,写出改造前后的差异说明。 - 用
docker compose up跑通 17.6 的编排,制造一次「数据库未就绪」的场景,观察depends_on ... service_healthy是否生效。 - 故意去掉
--forwarded-allow-ips,在 Nginx 后面访问一次/docs,记录生成的服务器 URL;再加上该参数对比;然后为项目补一份/readyz,加入 Redis 与对象存储连通性,并说明为什么这些检查不该放进 liveness。
下一章预告
测试保障质量、部署保障可用,接下来的任务是把前面 17 章拼成一个完整的项目:带认证、权限、分页、测试与 Docker 的博客 REST API。