FastAPI 项目云服务器部署全指南:Docker Compose 与 Nginx 反向代理实战
很多人第一次把 FastAPI 项目从本地搬到云服务器,第一反应是“我是不是得从头学一套部署”?其实不用。只要你本地已经跑通了 Docker,剩下的就是把镜像推上去、再在服务器上用 Compose 拉起来。这篇文章就是我基于一套 FastAPI + Celery + MySQL + Redis + Nginx 写出来的完整部署笔记,覆盖从本地镜像构建、镜像推送、云服务器基础环境到 Nginx 反向代理的每一步,顺便把我在真实环境里踩过的坑也记下来。适合刚开始碰 Docker 部署的 FastAPI 学习者,也适合打算把个人项目正式放到云主机上的后端开发。
1. 架构梳理:为什么是这套组合
1.1 四个服务各干各的活
先想清楚这套系统里每个角色负责什么,后面配置才不会乱。
- FastAPI 是 HTTP 入口,负责接收请求、做参数校验、调业务逻辑,然后返回 JSON。它的异步特性适合处理 IO 密集型任务,但遇到发邮件、跑报表、调用第三方接口这类耗时操作时,如果直接在请求里同步等待,接口响应时间会非常难看。
- Celery 就是专门接“脏活累活”的。它自己独立运行 worker 进程,把耗时任务从请求链路里拆出去。FastAPI 只负责把任务消息写到队列里,Celery worker 再去慢慢消费。
- Redis 在系统里身兼两职:一是作为 Celery 的 broker 和 result backend,二是作为业务缓存。比如用户 token、热点数据,都可以先往 Redis 里放一层,减轻数据库压力。注意 Redis 作为 broker 时,任务消息的可靠性不如 RabbitMQ,但对大多数中小型项目来说完全够用。
- MySQL 负责最终的数据落盘。用户信息、任务结果、审计日志,全部存到这一层。容器里的 MySQL 需要注意字符集、时区、认证插件还有连接的持久化问题,后面会细说。
- Nginx 是面向用户的守门员。它对外暴露 80/443 端口,把请求转发到 FastAPI,同时统一做 TLS 证书终止、静态文件托管和访问日志记录。FastAPI 本身不需要直接面对公网。
这套组合的本质是“把进程拆开,再通过网络串起来”。Docker 最大的价值就是让每个服务都能以一致的镜像和参数在任何机器上跑起来,避免“在我电脑上是好的”这种尴尬。
1.2 本地和云服务器的差异在哪里
本地开发时,你用的是 localhost,MySQL 和 Redis 都直接暴露在宿主机端口上,怎么连都行。云服务器部署时,容器之间通过自定义网络互相访问,主机名就是 compose 里的服务名。比如 FastAPI 容器要连 MySQL,配置里的 host 不能写 127.0.0.1,而要写 mysql 这样的服务名。
另一个常见差异是路径。本地代码可以靠相对路径随意搞,容器里最好把工作目录固定成 /app,所有日志、配置文件、挂载路径都围绕这个目录设计。还有一个细节是绑定地址,uvicorn 在容器里必须监听 0.0.0.0,不能只监听 127.0.0.1,否则容器外部甚至容器网络里的 Nginx 都访问不到它。
1.3 项目目录结构规划
我建议从一开始就按下面的结构组织代码,避免后期部署时到处找文件:
这样分层的意义在于:API 代码、任务代码、部署配置完全分开。你甚至可以在 CI 阶段只测试 app 目录,不影响部署文件。.env.example 要提交到仓库,真实的 .env 文件写入云服务器的 .gitignore 列表。
2. 本地镜像构建:怎么写不翻车
2.1 FastAPI 的 Dockerfile
本地构建是整个部署流程的起点。镜像如果构建不干净,后面全部白搭。我习惯用 python:3.11-slim 作为基础镜像,体积小,系统依赖少。如果项目依赖里要装 cryptography、lxml 这类带 C 扩展的包,再考虑切到 python:3.11-bullseye,否则编译器很容易报错。
一个能用且不踩坑的 Dockerfile 大致长这样:
重点是 PYTHONUNBUFFERED=1。这个环境变量直接解决常见的“uvicorn FastAPI 日志丢失问题”。Python 的 stdout 默认是块缓冲的,在容器里 docker logs 经常看不到实时日志,把缓冲关掉后输出就能正常刷出来。很多部署问题排查不出来,就是因为日志没实时落盘。
我的经验是不要把 CMD 写成 shell 形式:
这种写法会启动一个 shell 子进程,收到 docker stop 信号时可能不会正确转发给 uvicorn,导致容器退出慢。用带方括号的 exec 形式,uvicorn 直接作为 PID 1 的子进程,能更好处理 SIGTERM。
2.2 Celery worker 的一份代码,两套 CMD
很多项目把 FastAPI 镜像和 Celery worker 镜像分开写,导致业务代码频繁复制、版本漂移。更省心的做法是共用同一个应用镜像,只是启动时传入不同的命令。
你只需要在 Dockerfile 里把代码 COPY 好,然后在 compose 文件里这样定义:
这里不需要为 worker 单独做一个 Dockerfile。如果 Celery Beat 需要定时任务,可以在 compose 里再加一个 beat 服务,命令改成:
共用镜像的好处是,你每次改代码只需要构建一次、推送一次,云服务器也只需要拉取一次。版本一致性在任何部署方案里都是最容易忽略又最致命的点。
2.3 MySQL 和 Redis 官方镜像的参数细节
本地开发环境可以用 docker-compose 直接编排起来,避免你去系统里单独安装 MySQL、Redis。这其实就是现代开发里推荐的“用容器当本地环境”的做法。MySQL 8.0 镜像启动时需要注意几点:
这里把初始化 SQL 挂到 /docker-entrypoint-initdb.d,MySQL 镜像首次启动时就会自动执行,初始化建库建表。注意这个目录只在首次初始化时生效,数据卷清空之前不会重复执行。
Redis 的配置也不复杂,单机模式用一个 Alpine 版本的镜像就够了:
Redis 配置里至少把 appendonly 打开,appendfsync 设成 everysec,这样可以做到最多丢一秒数据,同时性能损失可以接受。还有两个安全项值得加:requirepass 设一个强密码,bind 可以保持默认的 127.0.0.1,但由于容器网络是独立的,Redis 实际监听地址可以设成 0.0.0.0,只在容器内部被访问。
2.4 本地 docker compose 全套配置
本地开发最重要的是“一键启动”。把 api、worker、mysql、redis、nginx 都写进一个 docker-compose.yml。为了支持热重载,api 可以将代码目录挂载进去:
depends_on 加上 condition 是 Docker Compose 的进阶用法。默认的 depends_on 只控制启动顺序,不控制可用性。如果 MySQL 还没准备好 FastAPI 就启动,程序会在几个连接失败后直接崩溃,restart 策略会影响容器,但体验很糟糕。healthcheck 加 condition 才能真正等到数据库 ready。
本地 MySQL 端口如果需要暴露给宿主机上的 Navicat 使用,可以在 ports 里声明 3306:3306。如果宿主机本来已经有 MySQL 占用了 3306,就映射到别的端口,比如 33061:3306。这种冲突我遇到很多次,尤其是 Windows 上装了 MySQL 服务还没卸载干净的场景。
3. 从本地镜像到云服务器
3.1 镜像打标签和推送到仓库
本地镜像构建完成后,需要推到一个云服务器能够访问的镜像仓库。可以是 Docker Hub,也可以是云厂商自带的镜像仓库服务。我强烈建议把镜像的 tag 从 latest 改成有意义的版本号,比如 20250601 或带 Git 短提交:
给镜像打版本号这件事,看起来不起眼,实际部署时能救你很多次。云服务器上拉取特定版本,出了问题还能回滚到上一个 tag,而不是面对一个没有名字的 latest 含糊不清。
推送前记得先登录:
登录之后,如果使用云厂商的私有仓库,要先把仓库创建好。用 Docker Hub 的话,镜像名要带用户名,比如 username/myapp:20250601。
在云服务器上拉取并运行:
如果本地和云服务器使用的是同一个 compose 文件,可以直接通过环境变量来切换 image 和 tag。生产环境的 compose 文件建议不写死 build,让 CI 或手动命令把 tag 传进去。
3.2 云服务器安装 Docker 的注意事项
云服务器安装 Docker 这件事,不同系统差别不大。Ubuntu 和 Debian 用官方 apt 源安装 docker-ce,CentOS 用 yum,操作步骤网上都能找到。重点是安装完成后的这几项基础配置:
- 把当前用户加入 docker 组,避免每次都用 sudo。注意 docker 组拥有宿主机 root 权限,所以服务器上不要随便加普通用户。
- 设置 Docker daemon 日志清理。生产服务器上,容器的 JSON 日志文件会无限增长,长期不清理容易把磁盘写满。在 /etc/docker/daemon.json 里写上 log rotation 配置:
- 把 Docker 服务设为开机自启:
如果你在本地用 Windows Docker Desktop,遇到过 “Docker Desktop failed to start because virtualization support not detected” 之类的启动失败,那是因为 Hyper-V 或 WSL2 的虚拟化支持没有打开。这个和云服务器上的 Docker 引擎没有关系。服务器上只要内核支持,装完 docker-ce 就能直接跑容器。
仓库源这块,不要在公共博客上照抄来源不明的镜像加速配置。最稳妥的方式是用你所在云厂商的镜像仓库,或者直接用 Docker 官方仓库。云服务器拉取镜像失败时,先排查 DNS 和网络连通性,再检查镜像 tag 是否存在。把 push 和 pull 的网络路径梳理清楚比盲目换源重要得多。
3.3 生产环境的 Compose 覆盖文件
本地和生产环境的差异,不要写在同一个 compose 文件里,我习惯拆成 docker-compose.yml 和 docker-compose.prod.yml。部署时两个文件叠加使用:
生产覆盖文件里主要做这几件事:
关键的差异是:开发环境把代码目录挂载进容器,生产环境不挂载代码,只依赖镜像里的内容。MySQL 和 Redis 的端口不要暴露到公网,容器之间通过内部网络访问就够了。这样能减少非常大的安全暴露面。部署时只需要把 .env 文件放在 compose 文件旁边,由 docker compose 读取: APP_VERSION=20250601。
4. MySQL 和 Redis 移到容器后的配置细节
4.1 MySQL 8 初始化、认证和连接池
MySQL 8.0 安装配置教程网上很多,但放到 Docker 里最大的坑是认证插件。MySQL 8 默认使用 caching_sha2_password,很多老版本客户端和连接库不支持,会出现握手失败。如果你的数据库访问客户端比较旧,可以临时切换成 mysql_native_password:
不过我更建议把客户端升级到支持 caching_sha2_password 的版本,因为 mysql_native_password 在 MySQL 8.4 里已经被标记为废弃,未来版本会移除。修一时的连接问题,不能给未来埋雷。
另一个高频问题是“mysql ssl 连接错误”。这个现象通常表现为 Navicat 或别的 GUI 工具连接时报 SSL error,或者程序日志里出现 SSL connection error。可能的原因有两个:一是服务端设置了 require_secure_transport=ON,客户端却没有走 SSL;二是某些代理工具对 MySQL 协议进行了转发,导致客户端握手阶段收到的包莫名其妙。排查方法很简单,先用命令行客户端不带 SSL 试一次:
能连上说明问题就在 SSL 协商。如果确定服务端不需要强制 SSL,可以在 MySQL 配置里把 require_secure_transport 关掉。正常中小项目的内部数据库完全没必要强制 SSL,保持默认 PREFERRED 模式就行。
数据库连接池也需要重点说明。容器环境下 FastAPI 重试连接很方便,但高并发时频繁断开 MySQL 连接会触发 Too many connections 错误。SQLAlchemy 连接池的配置要重视:
pool_pre_ping=True 是在每次连接取出前做一个轻量的 SELECT 1,一旦发现连接已断就重新创建。pool_recycle=3600 是为了防止 MySQL 的 wait_timeout 回收掉连接后,客户端还在使用已经失效的 socket。这两个参数是容器部署里最容易漏掉的。
4.2 Redis 数据持久化和淘汰策略
Redis 作为 Celery broker 使用时,任务消息写进队列后等待 worker 消费。如果 Redis 容器只是默认配置,重启一次内存数据全丢,正在等 worker 处理的任务就没有了。所以生产环境至少打开 AOF:
但注意,Redis 持久化的作用是降低数据丢失风险,不等于可靠消息队列。如果你的业务里任务绝对不能丢,应该考虑 Celery 的 broker 换成 RabbitMQ,或者加上 Celery 的 task 状态和重试机制。
内存淘汰策略也要确认。开发环境 Redis 默认使用 noeviction,也就是内存满了直接拒绝写入。这对 Celery broker 来说是很危险的,队列一多内存爆掉,新任务就写不进去。建议根据业务模式选择:
- allkeys-lru:所有 key 按最近最少使用淘汰,适合纯缓存场景。
- volatile-lru:只淘汰设置了过期时间的 key,适合同时用 Redis 做队列和缓存的场景,避免把未过期的队列消息清掉。
我一般会把 Celery 的 result backend 和缓存数据库分开。比如业务缓存用 db0,Celery 结果用 db1,这样即使缓存被并发访问干扰也不会影响队列。Redis 里有 16 个逻辑数据库,大多数情况下完全够用。
4.3 备份恢复不是“将来再说”
Docker 卷里的数据不是永久保险。我见过不少人是直到数据库文件损坏,才发现自己从未备份。推荐在宿主机上写一个简单的定时任务,每天凌晨备份 MySQL:
mysqldump 的 --single-transaction 参数很重要,它用 InnoDB 事务保证备份期间不会锁表,在线备份不会影响业务。
恢复时更简单:
Redis 的备份没那么紧迫,如果开启了 AOF,最简单的备份方式是复制 AOF 文件。再进一步就用 RDB 快照,但中小企业单机 Redis 备份 AOF 就够了。恢复前先停掉 Redis 容器,再替换数据,否则写操作和恢复过程会互相干扰。
5. Nginx 在整套系统中的位置
5.1 反向代理 FastAPI 的配置模板
Nginx 镜像本身不需要构建,直接用官方镜像就行。FastAPI 容器在 Docker 网络中的地址是 http://api:8000,Nginx 配置里注意不要写 localhost 或 127.0.0.1。
一份能用于生产的反向代理配置:
keepalive 32 是让 Nginx 和 FastAPI 之间复用 TCP 连接,避免每次请求都重新握手。FastAPI 这边需要安装 uvicorn,启动参数里加上 --proxy-headers,或者用 uvicorn 的 ProxyHeadersMiddleware,这样 FastAPI 才能正确识别 X-Forwarded-Proto 里的 https 并生成正确的请求地址。
如果 FastAPI 要提供 WebSocket 接口,location 里要加上升级协议的头:
5.2 TLS 证书:Nginx 还是反代?
HTTPS 的终止我强烈建议放在 Nginx 这层。FastAPI 内部保持 HTTP 明文,数据库和 Redis 永远不暴露公网,Nginx 统一管理 80/443 端口。证书目录挂载进 Nginx 容器后,续期脚本可以直接操作宿主机上的证书文件,再 reload Nginx:
如果你用 certbot 自动续期,记得续期脚本里除了重新生成证书外,还要触发 Nginx 容器的 reload,否则新证书不会生效。
还有一个小细节:FastAPI 里的跳转逻辑最好用 request.url_for 生成绝对 URL,这样 Nginx 如果已经代理了 X-Forwarded-Proto,它就会自动生成 https 开头的链接,不会出现页面跳转到 http 的尴尬。
5.3 Nginx 容器日志的控制
Nginx 默认把日志输出到容器的 stdout,但生产环境最好单独挂载。把日志文件写到宿主机,用 logrotate 或第三方工具收集。如果选择不挂载,一定要靠我之前在 daemon.json 里配置的 log rotation 来控制大小,否则大型请求日志能在几天内把磁盘撑满。
还有一个容易踩的坑是动态配置。Nginx 镜像里的 /etc/nginx/conf.d/ 挂载了宿主机目录后,修改宿主机文件马上生效,但容器里可能需要执行 reload 才会重新读取。我通常用一个简短脚本一键完成:
开发环境无所谓,生产环境直接用 restart 会短暂断连。更平滑的命令是先检查配置再 reload:
6. 常见问题速查与实战排查记录
6.1 uvicorn 日志丢失
现象是 docker logs 里偶尔有输出,偶尔没有,或者任务处理到一半崩溃,日志完全空白。原因基本就是 Python stdout 缓冲。解决起来很简单:
- Dockerfile 设置 ENV PYTHONUNBUFFERED=1。
- 不用 shell 形式启动程序。
- 如果用了 gunicorn + uvicorn worker,同样检查 gunicorn 的 accesslog 和 errorlog 配置,设置成 '-'。
日志是运维排查的第一手信息,这步耽误不得。
6.2 MySQL SSL 连接错误
我在真实环境里遇到过的症状是,应用容器里通过 SQLAlchemy 连接数据库报 SSL error: WRONG_VERSION_NUMBER 或者 SSL connection error: unknown error number。排查路径:
- 先确认是否经过其他网络组件。Nginx 反向代理 MySQL 的做法极其少见,但团队内网代理工具却经常出现。
- 客户端连接串显式禁用 SSL 测试一次,排除 SSL 协商问题。
- 服务端关闭 require_secure_transport,如果业务并不要求内部流量必须加密。
- 检查 MySQL 8 的认证插件,若客户端过旧,再考虑调整兼容性。
不要在没搞清根因前直接重启数据库,那只是浪费时间。
6.3 Celery 任务消失或重复执行
Celery 和 Redis 的组合下,任务丢掉的场景通常有两种。第一种是 Redis 重启导致队列里还没被消费的消息直接丢失,如果你没有开启 AOF,丢消息概率很大。第二种是 worker 收到任务后,还没执行完就发生了崩溃或强制 kill,任务没有被确认,而且由于 acks_late 的配置,消息被重新投递或彻底消失。
想让任务更可靠,建议在 Celery 配置里做几处调整:
task_acks_late=True 表示任务执行完再确认,消息不会在 worker 中途崩溃时丢失。但副作用是同一个任务可能被执行两次,所以任务代码必须幂等。worker_prefetch_multiplier=1 让 worker 一次只预取一个任务,避免单个 worker 预取大量任务后崩溃造成批量丢失。
组合起来,合理配置下 Celery + Redis 对中小项目足够可靠,只要制定好重试和幂等策略。
6.4 容器启动顺序不等于服务可用
我刚开始用 compose 时就犯过错,以为加了 depends_on 就能保证 MySQL ready。实际上 depends_on 只保证容器创建顺序。如果不用 healthcheck,FastAPI 容器启动后数据库还在初始化,应用就会反复重连甚至崩溃。
解决方案是两个层面:
- 在 compose 里给 MySQL 和 Redis 加完整的 healthcheck。
- 在应用代码里加入启动重试逻辑。比如 FastAPI 启动连接 MySQL 时,用循环尝试,最多重试 10 次,每次间隔 3 秒。
很多部署问题不是代码 bug,而是初始化顺序没有做到位。
6.5 Docker 宿主机端口冲突和本地工具链问题
Windows/Mac 上,本地跑 Docker 最常见的两个问题,一个是 Docker Desktop 虚拟化没开启,另一个是宿主机本身已经装了 MySQL、Redis 服务,端口被占用。
如果你在用 Docker Desktop,启动失败报 virtualization support not detected,第一步去 BIOS 或系统设置里打开虚拟化功能,并启用 WSL2/Hyper-V。Mac 上如果觉得 Docker Desktop 太占资源,可以用 Colima 作为替代,核心命令都是 docker,但底层是轻量虚拟机。
端口冲突的解决办法很简单,宿主机服务能用系统服务管理就管理,不能的话就修改容器的对外映射端口。实际开发时我甚至建议 MySQL 完全不映射到宿主机,只通过 docker exec 或内部网络访问,这样反而更安全。
最后再分享一点个人经验
这套架构从本地到云服务器,我前后跑了十几遍,最大的感受是:部署环境的问题大多数不是某个工具多难学,而是你忽略了“状态”。镜像里不要放任何一个只有某台机器能访问的文件,运行时配置全部通过环境变量注入,数据全部走 Docker volume 持久化。
我踩过最深的坑是给镜像打 latest tag 然后无脑部署,结果有一次改坏缓存逻辑,线上回滚都没法回,因为 latest 已经被覆盖了。后来强制所有部署都用版本号 tag,本地 CI 和服务器拉取都明明白白。另一个让我印象深刻的是,把全部容器 restart 之后,某个服务因为连接池里的旧连接没有及时重连导致一连串 500,加了 pool_pre_ping 和健康检查之后彻底解决。
如果你刚开始迁移,不要急着上 Kubernetes,先把这个 5 个服务的 compose 跑通,理解每个容器的生命周期、网络、存储和日志,再谈编排扩展。部署这件事,理论看十遍不如动手挂一次服务。只要本地用 Docker 构建好,云端用 compose 拉起,整个栈的交付速度会快得超乎你想象。