我最早用 FastAPI 做后端时,最头疼的就是部署。代码写好了、测试全绿,一上生产服务器就出各种幺蛾子,有一次甚至因为运维顺手跑了一下 pip 升级,业务接口直接崩了。后来我把 Docker 部署 FastAPI 这套流程完整走通,才发现之前的折腾都源于没有把环境本身当成交付物。这篇不是官网文档的复述,而是我从开发到生产完整跑完一个项目的实操总结,重点讲 Dockerfile 怎么设计、docker-compose 怎么编排,以及部署过程中最容易踩的坑。无论你是刚接触 FastAPI 还没上过 Docker,还是已经在用 Docker 但经常遇到容器起不来、代码不更新、数据库连不上这类问题,这篇应该都能用得上。
1. 为什么我不再裸跑 FastAPI:容器化部署的真实收益
裸跑 FastAPI 听起来简单,实际坑非常多。先讲一个我反复遇到的场景。
1.1 从"在我机器上能跑"到"在哪都能跑"
最经典的矛盾就是:本地开发用的 Python 3.11,生产服务器可能是系统自带的 Python 3.7;本地 pip 装的依赖全是新的,生产环境还是带历史包袱的旧版本。拿 FastAPI 来说,它的依赖树里有 pydantic,而 pydantic 的 C 扩展在不同 Python 版本下的编译结果差别很大,版本差一点点,接口就可能直接 500。
传统做法是创建虚拟环境、装 requirements.txt、运维照着文档重复一遍。但文档永远滞后,人的操作也会有偏差。我见过太多次"按文档来还是不行"的情况,原因无非是漏了一行环境变量、少装了一个编译依赖、或者某条命令在新版本里已经废弃了。
Docker 的做法是把环境本身固化成镜像。你交付的不是一条命令,而是一个镜像。任何一台装有 Docker 的机器,不管底层是什么发行版、什么 Python 版本,跑起来的结果都一样。这个特性一开始感受不到价值,等你在生产环境被折磨过几回,就知道"在哪都能跑"有多重要了。
1.2 环境隔离与依赖锁定的真实价值
同一台服务器上往往跑着好几个服务。我在某个内部系统改造项目里,一台服务器上同时有老的服务、新的 FastAPI 服务和定时脚本。如果都往系统 Python 里装包,pip 的依赖解析器会把 site-packages 搅得一团糟。我记得有一次升级了一个通用依赖库,直接让另一个服务半夜开始报错,第二天数据小组才发现凌晨的任务全部失败了。
容器化之后,每个服务一个镜像,互不干扰。依赖锁定也顺带解决了:requirements.txt 里我不写 >=,而是固定完整版本号。FastAPI 和 pydantic 都是迭代很快的库,小版本升级都可能带来行为变化。固定版本号之后,哪怕过了半年重新构建镜像,依赖树和之前测试时一致,少了一半的排错路径。
提示:虚拟环境解决的是同一台机器上多项目的依赖隔离,Docker 解决的是交付环境的确定性。两者不是替代关系,而是在不同层面解决问题。
2. 一个标准的 FastAPI 容器化项目长什么样
前面的道理讲完,直接看落地的东西。
2.1 项目目录结构与依赖管理
推荐的最小化目录结构:
my_fastapi_project/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── routers/ │ ├── core/ │ └── models/ ├── requirements.txt ├── Dockerfile ├── docker-compose.yml ├── .dockerignore └── .envapp/main.py 就是最基本的 FastAPI 实例:
from fastapi import FastAPI app = FastAPI(title="My API") @app.get("/health") def health(): return {"status": "ok"}requirements.txt 我建议直接用 pip freeze 生成精确版本:
pip install fastapi uvicorn[standard] pydantic-settings pip freeze > requirements.txtpip freeze 会把所有传递依赖一起列出来,看起来长,但能保证生产构建时的依赖完全一致。我不推荐手写 requirements.txt 只写顶层依赖,因为 pydantic 这类传递依赖一旦在某次构建时被解析到不同版本,你很难快速定位是谁变了。
2.2 从零写一份靠谱的 Dockerfile
很多教程给的 Dockerfile 就几行,直接用 python:3.11 镜像然后 pip install,最后 CMD uvicorn。能用,但镜像体积大,而且每次改代码都要重新装一遍依赖,构建效率很低。
我一直在用的带多阶段构建的 Dockerfile:
# 构建阶段:只负责安装依赖 FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --prefix=/install -r requirements.txt # 运行阶段:只拷贝依赖和业务代码 FROM python:3.11-slim WORKDIR /app COPY --from=builder /install /usr/local COPY app ./app ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]几个关键点展开说明。
第一,基础镜像选 slim。FastAPI 的依赖几乎不需要编译工具链,python:3.11-slim 完全够用。选全量版本会把镜像体积抬到 GB 级别,纯属浪费。
第二,依赖安装用--prefix=/install。这样第三方包统一装到独立目录,运行阶段用COPY --from=builder /install /usr/local整体拷过来。不搞多阶段构建的话,镜像里会残留大量 pip 缓存和中间产物,又大又脏。
第三,COPY 的顺序直接影响镜像层缓存。requirements.txt 放在 COPY app 之前,代码改动时 Docker 只需要重建最后一层,pip install 层直接复用缓存。如果反过来先 COPY app 再装依赖,任何代码改动都会让依赖层缓存失效,每次都重新装包。
第四,CMD 里--host 0.0.0.0必须显式写。uvicorn 默认绑定 127.0.0.1,在容器里这样绑定后,宿主机通过端口映射访问不到容器,因为容器网络和宿主机是隔离的。0.0.0.0 表示监听容器所有网络接口,这样才能被宿主机访问。
再配一个 .dockerignore,避免把本地缓存和虚拟环境带进构建上下文:
__pycache__/ *.pyc .git/ .venv/ venv/ .env tests/.dockerignore 不只是加快构建速度,还有一个实际好处:如果你不小心把带数据库连接字符串的 .env 放进去,镜像层里会留下痕迹。提前忽略掉,省得以后操心。
2.3 多阶段构建到底省了多少
我在一个模拟项目里做过记录:基于 python:3.11 全量镜像、单阶段构建,依赖加业务代码,镜像体积约 950 MB;换成 python:3.11-slim 加多阶段构建之后,降到 156 MB。
差距主要来自三块:一是全量镜像自带一堆编译工具和系统头文件,运行时根本用不上;二是单阶段构建时 pip 把 wheel 包、缓存塞进同一层,多阶段只拷贝安装后的目录,天然丢弃这些副产品;三是 pydantic-core 这类包官方直接提供预编译 wheel,不需要在目标镜像里现场编译 C 扩展。
镜像体积不只是磁盘问题,它还影响推送拉取时间、启动速度,以及安全扫描的结果。内网几十个节点轮询拉取镜像的场景,950 MB 和 156 MB 的带宽和时间差距是肉眼可见的。
3. docker-compose 编排:从单容器到多服务联动
Dockerfile 解决的是"这个服务怎么跑",但正经项目通常不止一个服务。
3.1 为什么是 docker-compose 而不是 docker run
单容器场景用 docker run 就够了,但一旦引入数据库、缓存、消息队列,一条条 docker run 就变得很难维护。我见过别人的部署脚本里堆了七八条 docker run,每条十几个参数,改一个端口要翻半天。
docker-compose 的价值是声明式编排:把服务、网络、数据卷、环境变量写进一个 yaml 文件,docker compose up -d拉起整套环境,docker compose down干净回收。它解决的是多容器之间的依赖、网络互通、数据持久化问题。
3.2 一份可以照抄的 docker-compose.yml
以"FastAPI + PostgreSQL"为例:
services: api: build: . container_name: my_app_api ports: - "8000:8000" env_file: - .env environment: - DATABASE_URL=postgresql://myuser:mypass@db:5432/myapp volumes: - ./app:/app/app restart: unless-stopped depends_on: db: condition: service_healthy db: image: postgres:16-alpine container_name: my_app_db environment: - POSTGRES_USER=myuser - POSTGRES_PASSWORD=mypass - POSTGRES_DB=myapp volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U myuser -d myapp"] interval: 5s timeout: 5s retries: 10 volumes: pgdata:几个地方展开说明。
首先,服务名db在 compose 默认网络里就是可解析的域名。API 里 DATABASE_URL 的主机名用的是db,不是 127.0.0.1,也不是 localhost。容器之间靠服务名互相访问,这是 compose 内置的 DNS 解析机制,不需要额外配网络。
其次,volumes: - ./app:/app/app是开发环境下的源码挂载。宿主机上的 app 目录实时同步到容器里,配合 uvicorn 的 --reload,改代码不用重新构建镜像。但生产环境我不会挂载源码,而是使用镜像里固化的那份,保证和构建时一致。
最后,restart: unless-stopped是对每个服务的默认配置。容器里的进程崩了,Docker 会自动拉起;手动 stop 之后也不会反复重启,比 always 更符合日常操作直觉。
3.3 健康检查与启动顺序:别让 API 等着一个不存在的数据库
刚用 Docker 的人经常碰到:docker compose up 之后,API 容器秒退,或者起来后疯狂报数据库连接错误。
原因很简单:容器启动不代表里面的进程已经就绪。数据库容器虽然启动了,但 PostgreSQL 初始化需要几秒,而 FastAPI 如果启动时同步连数据库,很可能恰好碰到连接失败。无状态服务进程退出后,restart 策略会自动拉起,但此时数据库可能还没准备好,于是陷入"起来-失败-重启"的循环。
我在 compose 里用两种手段配合解决。一是数据库容器加 healthcheck,用 pg_isready 探测就绪状态;二是 API 服务的depends_on写成条件等待:
depends_on: db: condition: service_healthy这样 compose 会先等 db 的 healthcheck 通过,再启动 api 容器。从 Compose v2 开始,这种声明是原生支持的,不需要再写 sleep 之类的 hack。
不过要提醒一句:depends_on 只控制容器启动顺序,不保证应用内部连接一定成功。应用层最好还是写连接重试逻辑,这一点放到下一节细说。
4. 部署联调中踩过的坑:热重载、日志与数据库连接
这一节是真正让我长记性的部分。每个坑我都踩过至少一次,列出来希望你能绕开。
4.1 热重载在容器里没有想象中那么好用
开发环境里,我习惯把源码挂载进容器并用 uvicorn 的 --reload 参数。但有两个细节必须处理,否则热重载要么不生效,要么很慢。
第一个是挂载路径和代码路径要对上。容器里是 /app/app,挂载也要指向宿主机对应的目录,不能只挂一半。我踩过把目录挂到 /app 但 Dockerfile 里 WORKDIR 是 /code 的坑,结果改代码完全没反应,还以为是镜像缓存的问题。
第二个是文件监听性能。在 macOS 和 Windows 上,文件变更回调要经过一层虚拟化,热重载经常有几秒甚至十几秒延迟。项目目录文件特别多的时候更严重。我的方案是排除日志文件和临时目录,同时缩小监听范围:
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/app"]--reload-dir明确告诉 uvicorn 只监听 /app/app 目录,避免递归监听整个项目,性能好很多。
4.2 日志:靠 docker logs,别往文件里写
本地开发时习惯用 logging 写文件日志,部署进容器之后很容易继续沿用。但容器随时可能被销毁、重新创建,本地文件系统上的日志会跟着容器一起消失。
正确做法是让应用把日志全部输出到 stdout/stderr,然后用docker compose logs -f api或者统一的日志采集工具收集。FastAPI 的 uvicorn 默认日志就走 stdout,Dockerfile 里加上ENV PYTHONUNBUFFERED=1,就能保证日志不被 Python 缓冲,实时进入 Docker 的日志体系。
注意:日志量大的时候,建议给 uvicorn 配 JSON 格式输出。字段结构化之后,采集、过滤、告警都比纯文本方便很多。我的做法是把 access log 和 error log 分开处理,业务日志统一走标准 logging 输出到 stdout。
4.3 数据库连接:启动顺序只是第一道防线
healthcheck 和 depends_on 能控制启动顺序,但不够。API 容器可能一直存活,数据库连接池里的连接在重启后可能全部失效;数据库滚动升级时某个瞬间连接也可能失败。这些都不是启动顺序能解决的。
我现在的标准做法是在应用侧加连接重试。用 SQLAlchemy 的话,创建 engine 时设置超时参数,同时封装一个带重试的初始化函数:
import time from sqlalchemy import create_engine, text def wait_for_db(engine, retries=10, delay=3): for i in range(retries): try: with engine.connect() as conn: conn.execute(text("SELECT 1")) return except Exception: if i < retries - 1: time.sleep(delay) else: raise应用启动时先调用这个函数,确保数据库真正可用才开始接收流量。配合 compose 的 depends_on 条件等待,基本能覆盖容器编排下的启动竞态问题。
这里顺手放一张踩坑速查表,对应最常见的四种症状:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 容器启动后马上退出 | 数据库未就绪或端口冲突 | healthcheck + depends_on + restart 策略 |
| 改代码后容器内没变化 | 未挂载源码或 --reload-dir 写错 | 核对挂载路径,缩小文件监听范围 |
| 宿主机访问不到端口 | uvicorn 绑定 127.0.0.1 或端口未映射 | 显式写 --host 0.0.0.0,检查 ports |
| 日志看不到或丢失 | 往容器文件系统写日志文件 | 统一输出 stdout,配合 docker logs |
5. 上生产前的几个关键动作:配置、反向代理与版本回滚
开发环境的容器化只是第一步,真正交付到生产环境前,还有几件事必须做。
5.1 环境变量与配置管理:别把密码写进镜像
FastAPI 官方的配置管理推荐 pydantic-settings,和 Pydantic 的声明式风格很契合。我通常定义这样一个 Settings 类:
from pydantic_settings import BaseSettings class Settings(BaseSettings): app_name: str = "My API" database_url: str debug: bool = False allowed_hosts: list[str] = [] model_config = {"env_file": ".env", "extra": "ignore"} settings = Settings()这样做的好处是代码里没有任何硬编码配置项,运行时通过环境变量注入。在 compose 里我用 env_file 指向宿主机上的 .env,镜像本身不包含任何秘密信息。前面说 .dockerignore 忽略 .env,也是配合这一点。
生产环境的密钥、数据库地址,只放在部署服务器的 .env 里,不提交到代码仓库。Git 历史里残留过密钥的文件这种低级问题,第一次用 Docker 部署时尤其容易踩。
5.2 反向代理、HTTPS 与多 worker
生产环境不建议直接把 8000 端口暴露给公网。我习惯在前面加一层反向代理,比如 Nginx,负责 TLS 终止、静态资源缓存、安全头和访问日志。Docker 部署时 Nginx 也作为 compose 里的一个服务,通过内部网络转发到 API 容器。
流程是:公网流量 → Nginx(80/443) → FastAPI 容器(8000)。
FastAPI 应用本身,生产模式不用 --reload,而是按 CPU 核数启动多个 worker:
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4多 worker 能利用多核 CPU,但要注意:如果应用里用了进程内缓存或者共享内存状态,多 worker 模式下这些状态是不共享的。需要跨进程共享的状态应该放到 Redis 之类的外部组件里。
5.3 镜像版本管理与回滚:最容易被忽略的一环
很多新手以为"docker compose up -d --build"到这就结束了。但生产环境一旦要回滚,你就知道没有版本管理有多难受。
我的做法是对生产镜像打上带日期和提交标识的 tag:
docker build -t my_registry/my_app_api:20250210-abc123 . docker push my_registry/my_app_api:20250210-abc123然后 compose 文件里指定具体 tag,而不是 latest。回滚时只需要把 tag 指回上一个稳定版本,再docker compose pull && up -d就能恢复。如果一直用 latest,你永远不知道线上跑的是哪次代码,回滚也只是重新拉一遍 latest,很可能根本回不到想要的旧版本。
这个阶段我还会把restart: unless-stopped和日志采集联动起来,确保服务崩了能自动拉起,日志集中可查。到这一步,FastAPI 的容器化部署才算真正完整。