本地开发时,我们只关心一件事:代码能不能马上跑起来。到了生产环境,问题会突然变多。域名由谁接收?HTTPS 在哪里解密?请求怎样找到某个应用进程?新版本还没准备好时,谁来挡住流量?进程退出前,正在处理的订单怎么办?
把这些问题串起来看,部署其实不是“把代码放到服务器”这么简单,而是给一次请求安排一条完整、可观察、可恢复的路线。浏览器发出的加密请求先到公网入口,在可信代理处完成 TLS 终止,再经过服务入口进入某个 FastAPI 实例;应用访问数据库或缓存,得到结果后,响应沿着这条路返回。任何一段没配置对,都可能表现成用户眼里的超时、错误重定向或偶发失败。

这一章我们就沿着请求路线,把启动进程、容器镜像、反向代理、配置密钥、健康检查、优雅停机和水平扩展连成一套可以落地的部署方案。你不需要一开始就上复杂集群,但应该知道每一层替你解决了什么,以及出了问题该往哪里查。
开发命令通常长这样:
uvicorn app.main:app --reload--reload 会启动文件监视器。代码变化时,它结束旧的应用进程,再拉起一个新进程。这个反馈循环非常适合写代码,却不适合生产:它要持续观察文件系统,重启时会出现短暂空窗,而且它和 --workers 不能同时使用。生产机器上的代码本来也不应该被人直接改动,新版本应当通过新的制品发布。
最小生产启动可以去掉自动重载,并明确监听地址和端口:
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--timeout-graceful-shutdown 30如果是一台机器直接承载应用,可以让 Uvicorn 管理多个工作进程:
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--workers 4 \
--timeout-graceful-shutdown 30这里的 4 只是展示参数,不是通用答案。每个工作进程都有独立的 Python 解释器、事件循环、内存和数据库连接池。进程增加后,CPU 核心能得到更充分的利用,但内存与数据库连接数也会一起增加。假设每个进程的连接池上限是 20,四个进程就可能占用 80 条数据库连接;如果你又启动五个容器,理论上限会变成 400。数据库往往比应用更早到达极限。

工作进程数量应当从较小值开始,根据压测和生产监控调节。观察 CPU、内存、请求延迟、事件循环阻塞和数据库连接等待,再决定是增加进程、优化慢操作,还是把实例横向扩出去。不要套用一个只看 CPU 核数的固定公式。
下面这个启动过程展示了两个工作进程分别完成应用生命周期初始化,两个请求也都正常返回:
INFO: Uvicorn running on http://127.0.0.1:8765
INFO: Started parent process [15715]
INFO: Started server process [16268]
INFO: Started server process [16269]
INFO: Application startup complete.
INFO: Application startup complete.
INFO: 127.0.0.1:57927 - "GET /readyz HTTP/1.1" 200 OK
INFO: 127.0.0.1:57932 - "GET /orders/42 HTTP/1.1" 200 OK注意两次 Application startup complete。只要启用了多个工作进程,lifespan 的启动逻辑就会在每个进程里各执行一次。因此,建立“本进程自己的连接池”很合适,执行数据库迁移、创建管理员、发一封启动通知却不合适——这些动作只能由一个独立发布步骤执行一次。
旧教程常用 Gunicorn 加 uvicorn.workers.UvicornWorker。这条路径不是突然不能工作,但 Uvicorn 自带的 uvicorn.workers 模块已经进入弃用流程;若项目确实依赖 Gunicorn 的进程管理能力,应改用独立的 uvicorn-worker 包。新项目如果没有这层历史约束,直接使用 Uvicorn 或 FastAPI 命令的 --workers 更清楚。
部署系统不能靠“进程还在”判断服务能否接请求。一个进程可能仍在运行,却卡在初始化、连接池耗尽,或者已经无法处理新任务。我们先给应用准备两个职责不同的端点:
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
database_pool: dict[str, bool] = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.ready = False
# 这里建立数据库连接池、加载模型或预热必要资源
await asyncio.sleep(0)
database_pool["open"] = True
app.state.ready =
/livez 回答“事件循环还能不能处理一个最小请求”,应该便宜、稳定,通常不要查询数据库。数据库短暂抖动时,如果存活检查也失败,平台会重启所有应用实例,反而把连接风暴放大。
/readyz 回答“这个实例现在应不应该接收业务流量”。初始化未完成、准备下线,或者某个必需依赖长期不可用时,可以返回 503,让服务入口把它移出流量池。就绪失败不等于进程该被杀掉,它只是暂时不接新请求。
应用进入生命周期后,这三个请求得到的结果如下:
/livez 200 {"status": "alive"}
/readyz 200 {"status": "ready"}
/orders/42 200 {"order_id": 42, "state": "paid"}FastAPI 推荐使用 lifespan 组织启动与清理逻辑。yield 前准备资源,yield 后释放资源,获得和释放写在一起,不容易只记得创建连接池却忘了关闭。关闭逻辑也要有时间上限;如果一个清理动作永远等下去,平台最终仍会强制结束进程。
容器镜像解决的是“交付物一致”问题。开发、测试和生产使用同一个镜像,差异来自运行时配置。镜像里应该有应用代码、锁定后的依赖和明确的默认启动命令,不应该包含生产密码、运行时数据库文件或临时上传文件。
下面是一份以单应用进程为目标的 Dockerfile:
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /srv/app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt \
&& groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home app
COPY --chown=app:app app ./app
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/livez', timeout=2)"]
CMD [
这份文件有几处值得拆开看。
先复制 requirements.txt,安装依赖后再复制经常变化的应用代码。只改业务代码时,构建工具可以复用依赖层缓存。真实项目还应该配合 .dockerignore 排除 .git、虚拟环境、测试缓存、.env、本地数据库和编辑器文件,既缩小构建上下文,也避免把秘密误带进镜像。
USER 10001:10001 让应用以非 root 身份运行。即使应用被突破,攻击者得到的也不是容器里的最高权限。固定用户编号还便于和平台的 runAsUser 对齐。应用需要写文件时,只给特定目录写权限;不要为了省事让整个工作目录可写。
CMD 使用 JSON 数组形式,Uvicorn 直接成为容器主进程,可以正常收到 SIGTERM。如果写成一长串 shell 命令,中间的 shell 可能让信号转发和退出码变得含糊。容器是否能优雅退出,往往就差在这一个看似不起眼的细节上。
Dockerfile 中的 HEALTHCHECK 适合单机 Docker 或 Compose。到了 Kubernetes,健康状态通常由 Pod 探针声明,平台未必使用镜像里的健康检查,所以后面还会再配置一次。这里没有安装 curl,而是用 Python 标准库发请求,避免只为检查端点增加一个系统工具。
构建和启动命令可以写成:
docker build -t orders-api:2026-08-13 .
docker run --rm \
--name orders-api \
-p 8000:8000 \
--env-file ./production.env \
orders-api:2026-08-13镜像标签应当不可变并能追溯到一次构建,例如发布版本或提交摘要。不要让生产长期依赖含义会变化的 latest。发布出错时,明确的旧标签能让回滚变成“重新部署上一份制品”,而不是现场猜哪份代码才是上一版。
不要用 ARG、ENV 或 COPY .env 把构建密码写进镜像。构建过程需要访问私有依赖时,使用构建系统的临时 secret mount;应用运行时需要数据库密码时,由部署平台注入。镜像历史和缓存层都可能保留你以为已经删掉的秘密。
生产请求通常不会直接打到 Uvicorn。用户先和 Nginx、云负载均衡器或 Ingress 建立 TLS 连接,代理解密后,再通过内部网络把普通 HTTP 请求交给应用。于是 Uvicorn 直接看到的连接信息往往是:来源是代理地址、协议是 http、主机名是内部服务名。
如果应用完全不理解这层代理,最常见的现象是重定向到了 http://,生成的绝对链接域名不对,日志里所有客户端都变成同一个代理地址。代理需要覆盖并传递公开请求信息:
events {}
http {
upstream fastapi_backend {
server 127.0.0.1:8000;
keepalive 32;
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/privkey.pem;
location /assets/ {
alias /srv/public-assets/;
expires 1h;
}

应用侧要同时声明“解释代理头”和“只信任哪些代理来源”:
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--proxy-headers \
--forwarded-allow-ips="10.0.0.0/8"--forwarded-allow-ips="*" 只有在应用端口完全不对公网开放、任何请求都必经受控代理,而且代理会丢弃客户端伪造的转发头时才安全。否则攻击者可以自己填入来源地址和公开协议,污染审计日志,甚至影响依赖来源地址的安全判断。更稳妥的做法是让 Uvicorn 只监听内部网络,并把可信范围收窄到代理网段或 Unix socket。
上面的 Nginx 配置把公开的 /api/orders/42 转发成应用里的 /orders/42,因为 location /api/ 和带尾斜杠的 proxy_pass 会剥掉 /api/。这时应用需要知道自己公开挂在 /api 下:
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--proxy-headers \
--forwarded-allow-ips="10.0.0.0/8" \
--root-path /apiroot_path 不会替你注册一套带 /api 的路由,也不会让直连 Uvicorn 的 /api/orders/42 自动工作。它告诉 ASGI 应用:代理已经处理了这段公开前缀。FastAPI 会据此生成正确的 OpenAPI 服务地址,/api/docs 才能找到 /api/openapi.json。
如果代理不剥前缀,而是把 /api/orders/42 原样转给应用,那么路由本身就应当包含 /api,此时通常不需要用 root_path 修补。路径问题要先画清楚“代理收到什么、转发什么、应用注册什么”,不要一看到文档打不开就盲加参数。
FastAPI 可以用 StaticFiles 挂载少量静态文件,这对简单内部工具很方便。但大量图片、前端构建产物和用户上传文件不适合跟应用进程绑在一起。多副本部署后,用户把文件上传到实例一的本地磁盘,下一个请求却可能落到实例二;滚动发布替换容器时,本地文件还会一起消失。
比较清楚的边界是:带指纹的前端资源和公开图片交给对象存储、CDN 或 Nginx;用户上传直接进入持久化对象存储;FastAPI 负责鉴权、签名 URL、元数据和业务接口。静态资源访问量不会挤占应用工作进程,缓存策略也可以独立调整。
同一份镜像从测试环境搬到生产环境时,代码不变,数据库地址、日志级别和外部服务地址会变。可以用 pydantic-settings 在启动时读取环境变量,并让缺失的关键配置直接阻止应用启动:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "orders-api"
environment: str = "development"
log_level: str = "INFO"
database_url: str
model_config = SettingsConfigDict(env_file=None, extra="ignore")
settings = Settings()这里没有给 database_url 生产兜底值。忘记注入数据库地址时,进程会在启动阶段明确失败,而不是悄悄在容器里创建一份 SQLite 文件,直到流量进来才发现数据彼此隔离。
普通配置和秘密应当分开管理。环境名称、日志级别可以放 ConfigMap 或平台配置;数据库密码、签名密钥应当来自 Secret 或专用密钥服务。Kubernetes Secret 的值使用 base64 表示并不等于自动加密,仍要限制读取权限、开启静态加密、控制审计日志,并制定轮换方案。只把秘密注入真正需要它的容器,也不要在启动日志里打印完整配置对象。
日志则走相反方向:不要写进容器文件,直接输出到标准输出和标准错误,让平台集中采集。每条访问日志至少带上时间、级别、请求标识、方法、路径、状态码、耗时和实例标识。代理生成或透传 X-Request-ID,应用把同一个值写回响应并带进业务日志,一次跨层请求就能串起来。
import json
import logging
import time
import uuid
from fastapi import Request
logger = logging.getLogger("orders")
@app.middleware("http")
async def access_log(request: Request, call_next):
request_id = request.headers.get("x-request-id", str(uuid.uuid4()))
started = time.perf_counter()
response =
日志里不要记录密码、令牌、Cookie、完整请求体和敏感查询参数。高流量接口还要控制访问日志量,错误日志与追踪信息则保留足够上下文。日志能告诉你“发生了什么”,指标适合回答“多久发生一次”,链路追踪适合回答“时间花在哪一段”,三者用途不同。
一次滚动发布不是瞬间把旧进程替换成新进程。新实例经历创建、启动检查、就绪检查,通过后才进入流量池;旧实例先被移出流量池,停止接新请求,等待进行中的请求结束,再关闭连接池并退出。

三种检查的职责可以这样记:
关闭时,平台向容器主进程发送 SIGTERM。Uvicorn 停止接受新连接,等待进行中的请求和后台任务,然后触发 ASGI lifespan 的清理部分。如果超过 --timeout-graceful-shutdown 或平台的终止宽限期,进程才会被强制终止。
容器编排平台从服务端点移除实例到各处代理感知更新,可能存在短暂传播时间。可以用 preStop 留出几秒,让流量入口先完成摘除,再把停止信号交给应用。平台的总终止宽限期要覆盖这段等待、最长正常请求时间和资源清理时间。
业务接口还要考虑强制终止真的发生时怎么办。付款、发券、写订单这类动作不能依赖“请求一定能在关机前完成”。使用数据库事务、幂等键、唯一约束和可重试任务队列,才能让客户端重试时不重复扣款,也能让未完成任务由其他实例接手。
WebSocket 和长轮询会占用很长时间。发布时如果无限等待旧连接,版本永远下不掉;如果直接强杀,客户端会突然断线。要明确最大连接时长、服务端关闭通知、客户端退避重连和发布宽限期,让这类连接也有可预期的退出方式。
多工作进程已经说明了一个事实:进程内全局变量不共享。水平扩展到多个容器后,这个边界更明显。登录会话、限流计数、任务状态、文件和业务缓存如果放在某个实例内存或本地磁盘里,请求换一个实例就会“失忆”。

适合外置的状态包括:
实例自身只保留可丢弃的进程状态,例如本进程连接池、编译后的模板、可重建的本地缓存。这样任何一个实例都能被杀掉、替换或扩容,下一次请求落到谁都不影响业务结果。
水平扩展也不等于副本越多越好。先确认瓶颈在哪里:CPU 已满适合增加副本;数据库慢查询导致的高延迟,增加副本只会制造更多慢查询;外部接口有限流时,盲目扩容还会让失败更快。自动扩缩指标也不应只盯 CPU。异步接口可能在等待外部 I/O 时 CPU 很低,但请求队列和尾延迟已经升高,可以结合并发请求数、队列长度、延迟分位数和业务吞吐设计策略。
不要让每个实例在启动时都执行 alembic upgrade head。滚动发布会同时创建多个实例,它们可能争抢迁移锁;某个实例迁移失败又被平台反复重启,数据库就会不断遭遇同一个变更。
更稳妥的发布顺序是:
先构建一次不可变镜像,完成依赖、单元测试、安全扫描和配置语法检查。镜像进入仓库后不再现场修改。
用一个独立任务执行数据库迁移,并确保迁移可以重复判断当前版本。破坏性变更拆成“先扩展、再迁移数据、最后收缩”的多次发布,让新旧应用能短暂共存。
迁移成功后滚动更新应用。新实例通过启动和就绪检查才接流量,旧实例完成优雅下线。
观察错误率、尾延迟、数据库连接与业务指标。达到停止条件时暂停发布或回滚应用镜像;数据库变更则按事先设计的兼容方案处理,不能假设所有迁移都可直接回滚。
比如,给大表增加一个必填字段时,可以先添加允许为空的新列,发布同时兼容新旧结构的代码,后台分批回填,再加约束,最后删除旧字段。这样每一步都小,出现问题时也有退路。
Kubernetes 没有改变请求的基本路线,只是把进程拉起、服务发现、健康检查、滚动发布和副本管理变成了声明式资源。常见组合是:Ingress 或云负载均衡器接收外部流量,Service 发现可用 Pod,Deployment 维持一组无状态应用副本,ConfigMap 和 Secret 注入配置,Job 单独执行迁移,HorizontalPodAutoscaler 根据指标调节副本数。

下面这份清单把核心关系放在一起。镜像内仍然只运行一个 Uvicorn 应用进程,副本交给 Deployment;这样一个 Pod 对应一个容量与故障单元,资源限制、探针、日志和扩缩统计都更直观。
apiVersion: v1
kind: ConfigMap
metadata:
name: fastapi-config
data:
ENVIRONMENT: production
LOG_LEVEL: INFO
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fastapi-app
spec:
strategy:
type: RollingUpdate
rollingUpdate
配置 HPA 时,容器必须有 resources.requests.cpu,因为 CPU 利用率是当前用量相对 request 的比例。让 HPA 管理副本数后,清单里不要再固定 spec.replicas,否则下一次应用清单可能把 HPA 已经调好的副本数重置。
迁移任务使用同一镜像,却有独立命令和生命周期:
apiVersion: batch/v1
kind: Job
metadata:
name: fastapi-migrate-20260813
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/fastapi-app:2026-08-13
command: ["alembic", "upgrade", "head"
生产环境还应补充镜像拉取凭据、命名空间级权限、网络策略、Pod 分散策略和中断预算。它们解决的是供应链、权限和节点故障问题,不能由 FastAPI 代码替代。
部署问题最怕“所有层一起猜”。更快的方法是沿请求路径逐段缩小范围。
先查 DNS 是否指向当前公网入口,再查安全组、防火墙和负载均衡监听端口。TLS 握手失败时看证书域名、有效期、证书链和续期任务。请求还没到代理,修改 FastAPI 路由没有意义。
确认 Uvicorn 是否监听 0.0.0.0:8000,代理使用的上游地址和端口是否正确,容器网络与网络策略是否允许访问。502 常表示代理无法获得有效上游响应;504 常表示上游连接或响应超时。接着查应用在同一时间点的错误日志和耗时。
检查代理是否覆盖 X-Forwarded-Proto、X-Forwarded-Host 和 X-Forwarded-For,Uvicorn 是否启用代理头解释,--forwarded-allow-ips 是否包含真正的最后一跳代理。不要先把可信范围改成 *,先确认网络边界。
/api/docs 能打开,接口尝试却请求错地址写下公开路径、代理转发路径和应用路由三列。代理剥除了 /api 时配置 root_path=/api;代理保留前缀时让应用路由包含前缀。重点是三者一致,不是参数越多越好。
先看退出原因和上一次容器日志,再区分应用启动异常、内存超限和存活检查失败。启动需要 40 秒而存活检查 10 秒就开始杀进程,应增加启动检查窗口;数据库短暂不可用导致存活失败,应重新划分存活与就绪职责。
检查新实例是否在就绪前进入流量池,旧实例是否先摘流再收到停止信号,服务端点传播时间、preStop、Uvicorn 优雅关闭超时和平台终止宽限期是否互相覆盖。再检查最长请求与 WebSocket 是否有明确上限。
看数据库连接总数、慢查询、锁等待、外部 API 限流和缓存命中率。多个进程与多个副本会放大连接池和下游压力。应用 CPU 不高不代表还能继续扩;等待型瓶颈需要先处理下游容量或并发控制。
寻找进程内字典、本地 SQLite、本地上传目录和只存在单实例的定时任务。把业务状态迁到数据库、缓存、对象存储或队列,并让定时任务有单独的选主或调度机制。
最后留一份发布前检查清单:镜像标签是否可追溯,秘密是否只在运行时注入,应用是否非 root,代理头信任范围是否收紧,健康检查是否分工,终止宽限期是否够用,迁移是否独立且兼容回滚,日志能否用请求标识串起来,副本增加后数据库连接是否仍在预算内。逐项回答清楚,部署就从“试着把它跑起来”变成了一条可以解释、验证和恢复的请求通道。