FastAPI+Celery+MySQL+Redis容器化部署:从Docker编排到云服务器实战
做后端的人迟早要面对这么一个问题:本地跑得好好的服务,一上服务器就各种幺蛾子。版本对不上、依赖缺失、端口冲突、环境变量丢失,很多时候排查到最后发现只是 Python 版本差了 0.1,或者 MySQL 字符集没配对。我自己从 Docker 普及之后,就把 FastAPI、Celery、MySQL、Redis 这套技术栈的部署整个搬进容器,从本地镜像到云服务器一步到位,稳定性和省心程度和从前完全不一样。这篇内容把我跑通的一套方案完整拆开讲,重点不只是命令长什么样,还包括每一步为什么要这么设计、哪些参数必须改、哪些坑是我实际踩过之后才明白的。
如果你正在用 FastAPI 写接口,项目里又有 Celery 异步任务、MySQL 存业务数据、Redis 做缓存和消息中间件,卡在“怎么把它正经部署到服务器上”这一步,那这篇文章正好对味。完全没接触过容器的朋友也能看懂,我会把概念用生活化的方式讲清楚;已经熟悉 Docker 的人可以从第二节直接看编排细节,第四节和第六节也有不少线上才会遇到的坑。
1. 整体架构与方案设计:为什么用容器编排这套组合
1.1 FastAPI + Celery + MySQL + Redis 各自承担什么角色
很多人把这套技术栈拼在一起,第一反应是“不就是把服务跑起来吗”。实际上一旦涉及异步任务和多个组件协作,每个角色都得想清楚边界。FastAPI 在这里只负责 HTTP 入口:接收请求、参数校验、返回响应,它天生异步,适合高并发 IO 密集场景。但接口层讲究的是快速响应,邮件发送、报表生成、外部接口调用这类耗时操作如果直接在请求里同步执行,用户会一直卡在“加载中”,数据库连接也被白白占着,并发稍微上来就全堵死。
Celery 就是来解决这个问题的。FastAPI 收到任务后把任务参数丢给 Redis,Celery worker 再从 Redis 取出来异步执行。这个过程我平时喜欢类比成“快餐店点单”:服务员只负责记单、收银、把单子贴到后厨,后厨慢慢做,顾客拿到小票不用在后厨门口死等。Celery 就是那个后厨调度系统,Redis 是贴满单子的白板,worker 是真正干活的厨师。
MySQL 管的是强一致的业务数据,用户信息、订单、账单这些不能丢的东西都放这里。Redis 管的是热点数据和中间状态,比如接口缓存、验证码、临时 token,它读写快但也是内存,断电数据就没了,所以只适合放允许丢失的东西。Nginx 站在最前面做反向代理,静态资源、HTTPS 证书、请求体大小限制都在这一层解决,后端服务不用裸奔在公网上。
一句话总结这套组合的设计原则:接口层短平快,异步层慢慢磨,业务数据进 MySQL,临时和热数据进 Redis,所有外部访问统一从 Nginx 进。
1.2 容器组网与依赖关系怎么安排
用 Docker Compose 编排时,所有服务默认会在同一个自定义 bridge 网络里,容器之间可以直接用服务名互通。也就是说,API 容器里连接数据库根本不需要写服务器的公网 IP,直接写 db:3306 就行。这个细节很多人第一次上手会栽跟头——把宿主机 IP 或者 localhost 写进连接串,容器之间当然连不上。
依赖关系要理清楚:Nginx 依赖 API,API 依赖 MySQL 和 Redis,worker 和 beat 也依赖 Redis,worker 还要访问 MySQL 保存任务结果。在实际编排里,我通常只把 Nginx 的 80 和 443 端口映射到宿主机,MySQL、Redis、API 的端口都不直接暴露出去。这样安全边界非常清晰:公网流量只能打到 Nginx,后端服务在 Docker 内部网络里通信,外人根本摸不到数据库端口。
目录结构方面,我建议项目中至少要有这样的分层:
这个结构不是强制的,但越早把配置、模型、任务分清楚,后面写 Dockerfile 和 compose 文件就越顺手。
2. 从零开始:构建本地镜像与核心配置
2.1 Dockerfile 这样写,拿捏镜像大小和构建速度
先直接给一份我常用的 FastAPI Dockerfile,注释里写清楚了每行的理由:
选 python:3.11-slim 而不是完整版,镜像小很多,跑起来占内存也少。PYTHONUNBUFFERED=1 是关键中的关键,如果不设置,容器里的 Python 输出会被缓冲,logs 经常卡住不刷新,你用 docker logs 看半天什么都没有,还以为是服务没起来。PYTHONDONTWRITEBYTECODE=1 是防止生成 __pycache__,避免容器里到处是编译缓存文件。
先 COPY requirements.txt 再安装依赖,是利用 Docker 的层缓存。只要 requirements.txt 没变,即使代码改了几十次,pip install 这一步也不会重新执行,本地构建速度会快非常多。代码是最后才 COPY 进去的,改动源码之后只有最后一层失效,重新构建基本是秒级。
用非 root 用户跑服务是我自己比较坚持的一点。容器里默认是 root,一旦应用被攻破,攻击者就拿下了容器的最高权限。创建一个 appuser 并切换过去,成本很低,但安全收益很实在。执行 useradd 必须写在 COPY . . 之后,因为 COPY 的文件默认所有者是 root,先创建用户再 COPY 的话还得额外改权限,反而麻烦。
2.2 Celery Worker 与 Beat 如何复用同一个镜像
FastAPI 的镜像构建好之后,Celery worker 和 beat 完全可以复用同一个镜像,只是启动命令不同。这样做的好处很明显:依赖完全一致,不会出现 API 和 worker 的第三方库版本不一致的情况,也省去了单独维护一个 worker 镜像的麻烦。
在 docker-compose.yml 里面,API、worker、beat 三个服务都指向同一个 build:,区别只是 command::
为什么要把 worker 单独起一个容器,而不是在 API 进程里用后台线程跑任务?根本原因是故障隔离。接口进程崩了可以由 Docker 自动重启,但如果它内部还挂着 worker 线程,进程一崩所有线程都跟着没,任务队列里的活没人接。分开之后,API 挂了不影响 worker,worker 崩溃也不会拖垮 API,而且扩容的时候可以单独 docker compose up -d --scale worker=3,接口容器一个都不用动。
-E 参数建议加上,它会开启事件发送,方便在 Flower 或监控面板里看到任务的实时状态。调试阶段我还经常用 celery -A app.celery_app inspect registered 查看 worker 注册了哪些任务,确认任务文件真的被加载了。
2.3 docker-compose.yml 完整编排与 .env 多环境配置
Compose 文件是整个部署的核心,我直接把能跑通的版本贴出来,然后逐个说明:
对应的 .env 文件长这样:
所有密码都用环境变量引用,而不是直接把明文写进 compose 文件。.env 不要提交到 Git 仓库,我在 .gitignore 里永远会加一行 .env。本地开发和生产环境用不同的 .env 文件,代码本身不需要任何修改,这就是 12 Factor 里“配置与代码分离”的思路。
MySQL 的 command 里指定 utf8mb4 字符集是为了避免中文乱码。MySQL 8.0 默认字符集虽然已经是 utf8mb4,但保险起见我还是显式指定一次,顺便把排序规则也固定下来,省得以后 DBA 来问你为啥字段排序结果怪怪的。
3. 基础设施容器化:MySQL 与 Redis 的初始化与调优
3.1 MySQL 8.0 容器初始化要点
MySQL 容器第一次启动时会自动执行 /docker-entrypoint-initdb.d 目录下的 .sql 脚本,这个机制非常适合初始化库表结构或者预置数据。我会把建表语句放到 docker/mysql/init/ 目录下,第一次启动的时候自动执行,后面重启不会重复执行,因为数据目录已经有了。
数据持久化必须用命名卷或者宿主机目录。我把 mysql_data 这个命名卷挂到 /var/lib/mysql,这样无论容器怎么删怎么重建,数据都还在。唯一要注意的是:如果某天你想删掉全部数据重新初始化,得先 docker compose down -v,这个 -v 会把命名卷一并删掉,请务必确认你不是在误删生产数据。
连接串方面,PyMySQL 连 MySQL 8.0 时经常遇到 SSL connection error 一类的问题。MySQL 8.0 默认开启 SSL,部分客户端握手协商时会有兼容性问题。我的做法是在连接串里显式关闭 SSL:
如果你的应用逻辑并不需要加密数据库连接,这种方式最省事。当然,如果坚持要 SSL,那就得配置 CA 证书,复杂度高一个量级,一般场景没这个必要。
3.2 Redis 容器配置与 Celery broker 的配合
Redis 在这里身兼两职:给 FastAPI 提供缓存,给 Celery 当消息中间件。给容器传参启动 redis --requirepass xxx --appendonly yes,这两项是我一定会开的。appendonly yes 会把写操作追加到 AOF 文件,Redis 意外挂掉重启后任务数据还在,不至于丢了异步任务。appendfsync everysec 是性能和数据安全之间的折中,每秒刷一次盘,最坏只丢一秒的数据。
Redis 密码有个特别容易踩的坑:如果密码里带了 @、#、: 这些特殊字符,直接拼到 URL 里会解析错,导致连接时显示密码错误或者无法解析 URL。我这里给的是 redis://:密码@redis:6379/0,中间那个冒号是 Redis URL 格式要求的空用户名占位符。一旦密码里真有特殊字符,需要用 URL 编码,比如 @ 要写成 %40,不然 Celery 半天连不上,而你还一脸懵。
还有个小机器上的实操经验:如果服务器内存只有 2G,最好给 Redis 加上 maxmemory 256mb 和 maxmemory-policy allkeys-lru,否则 Redis 吃满内存会导致整台机器 OOM,MySQL 跟着一起遭殃。Redis 定位是缓存,内存不够时淘汰一部分旧 key 是合理行为,把这个策略显式写出来总比系统强制杀进程好看。
3.3 健康检查与服务启动顺序的正确姿势
我见过太多人只用 depends_on,结果 API 容器启动时 MySQL 还没就绪,连接直接失败,进程退出,然后又因为 restart: unless-stopped 无限重启循环。depends_on 只保证启动顺序,不保证服务已经可用。解决办法就是健康检查 + condition: service_healthy。
Compose 文件里 MySQL 的 healthcheck 用的是 mysqladmin ping,Redis 用的是 redis-cli ping,都很轻量。关键参数是 start_period,它给容器一个“预热时间”,在这个时间段内健康检查失败不会计入重试次数,MySQL 首次初始化可能要几十秒,把它设成 30 秒比较稳。
API 容器自己也应该暴露一个 /health 端点,返回 200 才代表服务真正可用。这个不只是给 Docker 健康检查用的,云服务器上配负载均衡、监控告警时都用得上。我一般会在这个接口里顺手检查一下数据库连接和 Redis ping,做成一个真正的“依赖探活”,而不是只返回一个写死的 "ok"。
4. Nginx 反向代理与 HTTPS 落地
4.1 Nginx 容器配置文件拆解
Nginx 容器本身不复杂,核心就是挂载一份配置文件进来。下面这份配置是我线上一直在用的基础版:
upstream 里写的是 api:8000,也就是 Docker 网络里 FastAPI 容器的主机和端口。请求头里的 X-Forwarded-* 必须带上,否则 FastAPI 里拿到的客户端 IP 全是 Nginx 容器的 IP,日志审计和限流都会失效。X-Forwarded-Proto 也很关键,如果后端需要判断请求是 HTTP 还是 HTTPS,没有这个头它就会以为所有请求都是 HTTP。
client_max_body_size 20m 是根据业务需求设的,默认只有 1m,如果你有文件上传接口,不调这个参数的话,大一点的文件直接 413 错误。location /static/ 是把静态文件交给 Nginx 处理,Python 不需要碰这些请求,性能差距非常明显。
4.2 HTTPS 证书申请与自动续期
部署到云服务器后,第一件事就是把 HTTPS 挂上。主流做法是 certbot 或 acme.sh 申请 Let's Encrypt 免费证书,证书有效期 90 天,所以要配置自动续期。我的做法是在宿主机上用 acme.sh 申请证书,然后把证书目录挂载给 Nginx 容器,续期完成后再 reload Nginx 容器。
443 端口的 server 块大概是这样的:
我的习惯是再加一个 80 端口的 server 块,把普通 HTTP 访问 301 跳转到 HTTPS,这样用户无论输入什么地址都会被引导到安全链接。证书续期后 Nginx 不重新加载配置是不会生效的,所以在 crontab 里要加一条自动 reload 的指令。如果某天访问域名提示证书过期,先检查挂载的证书文件路径有没有写对,再检查续期后有没有重载容器。
4.3 端口暴露边界与安全基线
很多人在云服务器上部署后,把 MySQL、Redis、API 端口全部映射到宿主机,然后用安全组规则“限制来源 IP”。我的建议是干脆不映射,只在 compose 里给这些服务建内部网络,宿主机端口只留 22、80、443。这样即使安全组配置失误,外部也扫不到 3306 和 6379。
调试的时候怎么访问内部端口?用 SSH 隧道。本地执行:
然后浏览器打开本地 8000 端口就能访问远端 API 的未经过 Nginx 的原始服务,非常方便,还不用在云厂商的安全组里开放一堆端口,每次审核安全规则都轻松很多。
5. 云服务器部署实战:从本地镜像到线上
5.1 镜像打包、传输与构建缓存优化
从本地镜像到云服务器,有三种常见的镜像迁移方式。
第一种,本地构建好镜像直接打包传输:
第二种,推到镜像仓库再拉取,适合团队协作和正式发布流程。第三种,直接在服务器上构建。很多个人项目从零开始部署时,其实我更推荐直接在服务器上拉代码、写 .env、然后 docker compose up -d --build。省掉镜像传输这一步,反正首次构建之后每次变更都是增量构建。
但不管哪种方式,项目里都要有一个像样的 .dockerignore:
如果不写,COPY . . 会把本地 venv 和 .git 全塞进构建上下文,镜像体积直接翻几倍,构建耗时也会肉眼可见地变慢。
本地开发还有个常见问题:Windows 上 Docker Desktop 因为虚拟化未开启启动失败,报类似 Virtualization support not detected 的错。这种情况你不用死磕本地 Docker,直接把代码推到服务器上构建运行也行,或者换一台已经装好 Docker Engine 的 Linux 机器。反正生产环境最终跑在 Linux 上,本地只要能写代码就够了。
5.2 服务器初始化与一键部署流程
我先大概列一下我在一台干净云服务器上的完整步骤,方便你照着走。
第一步,安装 Docker 引擎和 Compose 插件。用官方脚本最省事:
第二步,把代码搞到服务器上。可以从 Git 仓库 clone,也可以直接 scp 整个项目目录。
第三步,准备 .env 文件。这一步不要偷懒,把上一节里的变量全部写上,密码改成强口令。
第四步,启动服务:
第五步,执行数据库迁移。很多人的项目用 Alembic 管理表结构,需要在容器环境里执行迁移:
这一步强烈建议在启动服务之前或者部署脚本里单独执行,而不是让应用代码启动时自动跑迁移。否则多副本同时启动会出现迁移竞争,轻则报错,重则把表结构搞乱。
最后用 docker compose ps 检查所有服务状态,再用之前配好的 Nginx 域名访问一下首页接口,部署就完成了。整个过程我通常会写成一个 deploy.sh,把上面几条命令串起来,每次发布只需要运行一个脚本。
5.3 上线后的验证与日常运维命令
上线不是结束,验证才是开始。我的习惯是部署完后做一轮完整的“体检”:
如果 API 容器在启动后几分钟内反复退出,多半是环境变量缺失或者数据库连接失败。这时候别只看 compose 的 status,直接看日志最实在:
想确认 worker 真的注册了任务,可以进容器里发一条 inspect 命令:
磁盘空间也值得留意。容器跑久了会产生大量日志文件和镜像缓存,我一般隔一段时间跑一次 docker system df,看看到底谁在吃空间。日志文件大起来是很恐怖的,一个没做轮转的容器几个月能写满整个磁盘,所以 compose 里我会给每个服务加上日志大小限制:
不加这一条,等到服务器 100% 磁盘告警的时候再处理,就已经晚了。
6. 常见问题与排查技巧实录
6.1 高频故障速查表
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 容器启动后立刻退出 | 环境变量缺失、端口被占用、连接串错误 | 用 docker compose config 检查最终配置,用 docker compose logs 看退出前日志 |
| MySQL 容器反复重启 | 小内存主机触发 OOM | 加 swap,或者给 MySQL 加 --innodb-buffer-pool-size=128M 限制内存 |
| API 连不上 MySQL | 连接串写成 localhost:3306 |
容器内一律用服务名 db:3306 |
| Redis 连接报密码错误 | 密码含 @ 等特殊字符未做 URL 编码 |
对特殊字符做 percent-encoding |
| 中文写入数据库后乱码 | 连接串少了 charset=utf8mb4 |
连接串显式指定字符集,并确认库表字符集统一 |
| uvicorn 日志在容器里丢失或看不到 | 没有设置 PYTHONUNBUFFERED=1 |
Dockerfile 里设置 ENV PYTHONUNBUFFERED=1 |
| Celery 任务一直不执行 | worker 没启动、队列名不匹配、时间配置不对 | 检查 worker 容器和 celery inspect registered |
| Nginx 返回 502 Bad Gateway | API 容器未就绪或 upstream 名字写错 | docker compose ps 看 API 状态,看 Nginx error 日志 |
每条我都实际碰到过。比如第 4 条,我之前密码里带了个 #,Redis 服务端明明起来得好好的,Celery 就是连不上,排查了一下午才发现是 URL 解析把 # 当成了锚点。换成 %23 之后一分钟解决。
6.2 我自己踩过的最深的几个坑
第一个坑是关于数据卷的误操作。有段时间我改坏了 MySQL 的表结构,想着“哎,直接把数据卷删了重新初始化一遍”,执行了 docker compose down -v,然后才发现 -v 会把所有命名卷一起清掉,包括 Redis 里缓存的数据。那次之后我养成了习惯:删卷之前一定先 docker volume ls 看清有哪些卷,再逐个确认是不是真的要删。
第二个坑是依赖缓存导致的“新旧代码混跑”。worker 和 API 复用同一个镜像本身没问题,但你重新构建镜像之后必须保证所有相关容器都重启了。有几次我只重启了 API,忘了 worker 还在用旧镜像跑,结果新旧代码逻辑不一致,数据错乱后我开始怀疑人生,最后才发现是 worker 没有一起 up -d。现在我的发布脚本里一定是 docker compose up -d --build 一把梭,不会分服务单独搞。
第三个坑跟测试有关。本地开发时不要只在宿主机上跑 pytest,因为你的 venv 依赖可能和容器里的不一致。更靠谱的做法是:在项目里配置好 pytest,然后用 docker compose exec api pytest 直接跑测试,保证 CI 和你本地环境完全一致。
第四个坑比较隐蔽,是 Redis 持久化对 Celery 任务可靠性的影响。Redis 默认 AOF 关闭,如果服务器意外断电,还在 Redis 里排队但没被 worker 拿走的任务会全部丢失。这是消息中间件级别的数据丢失,不是缓存数据丢失,影响面完全不同。所以给 Celery 当 broker 的 Redis 一定要开 appendonly yes,这个选择值得你用一点性能损失去换。
最后再分享一个小技巧:排查任何容器问题时,第一件事不要去看网上的“标准答案”,先执行 docker inspect <container_name>,看挂载卷、环境变量、网络配置三个字段。大部分部署问题都藏在这三个地方,一个命令就能让很多“灵异事件”现出原形。我的体会是,容器化部署的调试路径其实是很线性的——环境变量对不对、网络通不通、依赖全不全、持久化在不在,把这四条线捋清了,绝大多数问题都能在十分钟内定位。