Skip to content

第 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,结构化输出并集中采集
错误信息返回完整堆栈只回统一错误体,堆栈只进日志
协议HTTPHTTPS 由反向代理终止
配置来源硬编码 / .env环境变量 + 密钥管理,.env 只用于本地

17.2 启动方式与 worker 数量 ​

bash
# ① 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 次。因此下面这些东西不能放在进程内。

❌ 不推荐(进程内内存当共享状态):

python
ONLINE_USERS: dict[int, str] = {}   # ❌ 每个 worker 各一份,负载均衡后数据随机缺失
RATE_LIMIT: dict[str, int] = {}     # ❌ 限流计数在多进程下形同虚设

✅ 推荐(共享状态放进程外):

python
# 在线状态:写 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 ​

python
# 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() 或直接覆盖依赖。

ini
# .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 ​

dockerfile
# ---- 构建阶段:用 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 + 一次性迁移 ​

yaml
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 反向代理 ​

text
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)。

python
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。

python
@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 LargeNginx 默认 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 挂中间件

练习题 ​

  1. 把一个单 worker 的应用改成 4 worker,并把一个模块级 dict 缓存换成 Redis 实现,写出改造前后的差异说明。
  2. 用 docker compose up 跑通 17.6 的编排,制造一次「数据库未就绪」的场景,观察 depends_on ... service_healthy 是否生效。
  3. 故意去掉 --forwarded-allow-ips,在 Nginx 后面访问一次 /docs,记录生成的服务器 URL;再加上该参数对比;然后为项目补一份 /readyz,加入 Redis 与对象存储连通性,并说明为什么这些检查不该放进 liveness。

下一章预告 ​

测试保障质量、部署保障可用,接下来的任务是把前面 17 章拼成一个完整的项目:带认证、权限、分页、测试与 Docker 的博客 REST API。

👉 第 18 章:综合实战 —— 构建完整 REST API

📖本文阅读--次|📊全站访问--次|👥访客--人