Docker 实战部署:从本地镜像到云服务器,一篇走通 FastAPI + Celery + MySQL + Redis + Nginx
一、前言
最近在部署自己的实际项目时,我将原本散落在本地开发环境中的 FastAPI、Celery、MySQL、Redis 等服务逐步 Docker 化,并最终部署到了云服务器。整个过程下来,我深刻体会到:Docker 部署真正麻烦的并非记住几个 docker 命令,而是理解下面这条完整链路:
text
本地开发
↓
Docker Build
↓
Docker Image
↓
镜像仓库
↓
云服务器 Pull
↓
Docker Compose
↓
FastAPI / Celery / MySQL / Redis
↓
Nginx
↓
域名
↓
HTTPS

尤其第一次真正把一个多服务项目部署到服务器时,很容易遇到这些问题:
- Docker 镜像到底是什么?
- 本地已经 Build 成功,怎么放到服务器?
- 要不要把源码传到服务器重新 Build?
- Docker Compose 到服务器以后应该怎么改?
- MySQL 和 Redis 为什么不能写
localhost? - 为什么服务器执行
docker compose up --build特别慢? - Docker 服务运行在
8000端口,怎么绑定自己的域名? - 数据库容器重新创建以后,数据会不会全部消失?
- Docker 镜像仓库到底解决了什么问题?
本文就根据一次真实部署过程,把这些问题完整串起来。项目使用的主要技术栈如下:
text
Backend
├── Python 3.12
├── FastAPI
├── Uvicorn
├── SQLAlchemy
├── Alembic
└── Celery
Infrastructure
├── MySQL 8.4
├── Redis 7.4
├── Docker
├── Docker Compose
└── Nginx
Registry
└── Huawei Cloud SWR
最终希望达到的效果非常简单:本地开发完成后构建 Docker 镜像,将镜像推送到镜像仓库,服务器只需 Pull 镜像并启动,不再手动安装 Python、项目依赖、Celery 等运行环境。
二、为什么这次决定使用 Docker?
如果不用 Docker,一个 Python 项目部署到服务器,大概需要经历:
text
安装 Python
↓
创建虚拟环境
↓
安装 pip 依赖
↓
配置 MySQL
↓
安装 Redis
↓
配置 FastAPI
↓
配置 Celery Worker
↓
配置 Celery Beat
↓
使用 systemd / Supervisor 守护进程
↓
配置 Nginx
如果只有一个 FastAPI 服务还好,但我的项目实际上已经包含多个进程:
text
API
Worker
Dispatcher
Scheduler
Migration
MySQL
Redis
如果全部直接部署在 Linux 上,就需要分别考虑每个服务如何启动、停止、重启以及开机自启。而 Docker Compose 可以把这些东西统一起来:
bash
docker compose up -d # 启动所有服务
docker compose ps # 查看状态
docker compose logs -f # 查看日志
docker compose down # 停止服务
这也是我最终决定将整个项目 Docker 化的重要原因。
三、项目的 Docker Compose 架构
项目不是单容器结构,而是一个典型的多服务应用。整体关系大致如下:

text
┌─────────────────┐
│ Nginx │
│ 80 / 443 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ FastAPI │
│ :8000 │
└────────┬────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ MySQL │ │ Redis │
│ :3306 │ │ :6379 │
└───────────────┘ └───────┬───────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
Worker Dispatcher Scheduler
本地使用的 Compose 核心结构如下:
yaml
services:
api:
image: ${SERVER_IMAGE:-writing-assistant-server:latest}
build:
context: ..
dockerfile: server/Dockerfile
env_file: .env
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000
depends_on:
migrate:
condition: service_completed_successfully
ports:
- "8000:8000"
volumes:
- server-storage:/data/storage
restart: unless-stopped
worker:
image: ${SERVER_IMAGE:-writing-assistant-server:latest}
env_file: .env
command: >
celery -A backend.workers.celery_app:celery_app
worker --loglevel=INFO --concurrency=1 -Q publish,celery
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
shm_size: 1gb
init: true
volumes:
- server-storage:/data/storage
restart: unless-stopped
dispatcher:
image: ${SERVER_IMAGE:-writing-assistant-server:latest}
env_file: .env
command: >
celery -A backend.workers.celery_app:celery_app
worker --loglevel=INFO --concurrency=1 -Q scheduler
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
restart: unless-stopped
scheduler:
image: ${SERVER_IMAGE:-writing-assistant-server:latest}
env_file: .env
command: >
celery -A backend.workers.celery_app:celery_app
beat --loglevel=INFO
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
volumes:
- server-storage:/data/storage
restart: unless-stopped
migrate:
image: ${SERVER_IMAGE:-writing-assistant-server:latest}
env_file: .env
command: alembic upgrade head
depends_on:
mysql:
condition: service_healthy
restart: "no"
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test:
[
"CMD-SHELL",
"mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD || exit 1"
]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
redis:
image: redis:7.4-alpine
command: redis-server --appendonly yes
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped
volumes:
mysql-data:
redis-data:
server-storage:
这里有一个很重要的设计:api、worker、dispatcher、scheduler、migrate 虽然是五个不同的服务,但实际上完全可以使用同一个业务 Docker Image 。区别只是每个 Container 启动时执行的 command 不一样:
text
writing-assistant-server
│
├── uvicorn → API
├── celery worker → Worker
├── celery worker -Q scheduler → Dispatcher
├── celery beat → Scheduler
└── alembic upgrade head → Migration
这也是 Docker 中非常常见的一种设计模式。
四、本地构建 Docker 镜像
本地执行构建命令:
bash
docker compose build
# 或者
docker compose up --build -d
构建完成后,查看镜像:
bash
docker images
可以看到类似输出:
text
REPOSITORY TAG IMAGE ID SIZE
writing-assistant-server latest 05f630b4a28e 3.23GB
mysql 8.4 0744ee5ef89c 1.1GB
redis 7.4-alpine 858f009f9709 58.2MB
其中 writing-assistant-server:latest 就是我们的业务镜像。这里需要注意:Docker Image 和 Docker Container 并不是同一个概念。
可以简单理解为:
text
Dockerfile
↓
docker build
↓
Image
↓
docker run
↓
Container
Image 更像一个只读模板,Container 则是 Image 的运行实例。同一个 Image 完全可以启动多个 Container。这也是为什么前面的 API、Worker、Dispatcher、Scheduler、Migration 可以共用 writing-assistant-server 的原因。
五、本地镜像怎么传到服务器?
这里主要有三种方案:
5.1 方案一:使用 Docker Registry
这是我最终采用的方式。流程如下:

text
Mac / 开发机
↓
docker build
↓
Docker Image
↓
docker push
↓
Huawei Cloud SWR
↓
docker pull
↓
Production Server
例如镜像仓库地址:swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server
服务器可以直接拉取:
bash
docker pull swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:latest
生产环境更推荐使用明确版本号:
bash
docker pull swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.0
5.2 方案二:docker save + SCP
Docker 镜像并非必须上传 Registry。可以直接导出并传输:
bash
# 导出镜像
docker save writing-assistant-server:latest | gzip > writing-assistant-server.tar.gz
# 传输到服务器
scp writing-assistant-server.tar.gz root@SERVER_IP:/opt/writing-assistant/
# 在服务器导入
gunzip -c writing-assistant-server.tar.gz | docker load
然后运行 docker images 即可看到镜像。这种方式比较适合临时部署、内网服务器、无镜像仓库环境或偶尔发布的场景,但如果频繁发布,使用 Registry 会舒服很多。
5.3 方案三:服务器直接 Build
还有一种最直接的方式:
bash
git clone ...
cd project
docker compose up --build -d
流程如下:
text
Git Repository
↓
Production Server
↓
Docker Build
↓
Docker Image
↓
Container
虽然能用,但对正式服务器来说,这通常不是最理想的方式,原因后面会详细讲。
六、服务器 Compose 和本地 Compose 不应该完全一样
本地开发时需要 build 配置,因为本地需要从源码 Build。但如果服务器已经从 Registry 获取构建好的 Image,就没有必要再 Build。因此生产 Compose 更推荐:
yaml
services:
api:
image: ${SERVER_IMAGE}
env_file: .env
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000
.env 文件:
env
SERVER_IMAGE=swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.0
然后执行:
bash
docker compose pull
docker compose up -d
正确的生产部署链路应该是:
text
开发机 / CI
│
│ Build
▼
Docker Image
│
│ Push
▼
Registry
│
│ Pull
▼
Production
│
│ Run
▼
Container
而不是:
text
Production
↓
Clone Source
↓
Download Dependencies
↓
Build
↓
Run
换句话说:Build 和 Run 最好分开。
七、为什么我在服务器执行 Docker Build 慢得离谱?
这次实际部署时,我直接在服务器执行了 docker compose up --build -d,结果发现 Build 非常慢。日志中出现:
text
[5/8] RUN pip install ...
772.5s
一个步骤就执行了 772 秒。进一步看:
text
4.5/4.5 MB 17.9 kB/s 0:04:06
一个只有 4.5MB 左右的 Python 包,竟然下载了 4 分钟。这时候问题其实已经非常明显:不是 Docker Build 本身慢,而是 pip 下载 Python Dependencies 太慢。
整个过程其实是:
text
docker build
↓
python:3.12-bookworm
↓
pip install
↓
PyPI
↓
网络速度极慢
↓
整个 Docker Build 被阻塞
这也是为什么 Docker Build ≠ 单纯编译代码 。Dockerfile 中所有 RUN ... 都会真实执行。如果里面有 RUN pip install ...,就需要真实访问 PyPI 下载依赖。
八、为什么生产服务器最好不要每次重新 Build?
这次经历以后,这个问题就非常直观了。假设服务器每次发布都执行 docker compose up --build -d,那么服务器需要承担:
text
拉基础镜像
下载系统依赖
下载 Python 依赖
构建镜像
占用 CPU
占用 RAM
占用 Disk
如果服务器网络访问 PyPI 又比较慢,部署一次可能十几分钟甚至几十分钟。而如果提前 Build:
text
开发机
↓
Build Once
↓
Registry
↓
Server Pull
服务器只负责:
text
下载 Image
启动 Container
这也是容器镜像非常重要的价值:把应用程序和运行环境一起打包成一个可分发的部署产物。
九、Docker Build Cache 也非常重要
Docker 并不是每次都从头 Build。例如 Dockerfile 中:
dockerfile
COPY server/pyproject.toml /workspace/server/pyproject.toml
RUN pip install ...
只要 pyproject.toml 没有发生变化,Docker 就可能直接复用之前的 Layer,显示 CACHED。流程相当于:
text
pyproject.toml 没变
↓
依赖没有变化
↓
pip install Layer 继续使用
↓
只重新 COPY 新代码
但如果修改 pyproject.toml,这一层 Cache 就会失效,需要重新安装全部 dependencies。因此 Dockerfile 的 Layer 顺序实际上也会直接影响构建效率。
十、Docker Compose 内部为什么不能乱写 localhost?
这是第一次部署 Docker 多服务项目非常容易踩的坑。假设 FastAPI 的数据库配置写:
env
MYSQL_HOST=localhost
在 Docker Compose 中往往是错的。因为 FastAPI Container 里面的 localhost 代表的是 FastAPI Container 自己,而不是 MySQL Container。

正确结构如下:
text
FastAPI Container
│
│ mysql:3306
▼
MySQL Container
因此应该:
env
MYSQL_HOST=mysql
MYSQL_PORT=3306
REDIS_HOST=redis
REDIS_PORT=6379
因为 Compose 中服务名本身就可以作为内部 DNS Hostname,所以 mysql:3306 和 redis:6379 就是容器之间的通信地址。
十一、depends_on 不等于服务真正可用了
例如配置:
yaml
depends_on:
mysql:
condition: service_healthy
为什么需要 service_healthy 而不是单纯"先启动 MySQL"?因为 Container Started 并不意味着 MySQL Ready。MySQL 容器启动后还需要初始化数据库、用户、权限等。因此应该设置 Health Check:
yaml
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
这样整个启动链路可以变成:
text
MySQL Start
↓
MySQL Health Check
↓
Healthy
↓
Alembic Migration
↓
Migration Success
↓
FastAPI / Worker / Scheduler Start
比所有服务同时启动稳定很多。
十二、为什么 Migration 单独做一个 Container?
我的 Compose 中还有一个专门的迁移服务:
yaml
migrate:
image: ${SERVER_IMAGE}
command: alembic upgrade head
它专门执行 alembic upgrade head,成功后直接退出。因此看到 migrate Exited (0) 并不是错误,反而意味着 Migration completed successfully 。真正有问题的是 Exited (1),这时候可以运行 docker compose logs migrate 查看失败原因。
这种设计还有一个好处:应用启动和数据库结构升级之间存在明确顺序。 不会出现:
text
FastAPI 已经启动
↓
开始接受请求
↓
数据库表还没升级
↓
SQL Error
十三、Volume:容器删了为什么数据还在?
数据库绝对不能直接把重要数据存在 Container 可写层里。因此 Compose 中使用:
yaml
volumes:
- mysql-data:/var/lib/mysql
- redis-data:/data
- server-storage:/data/storage
底部声明:
yaml
volumes:
mysql-data:
redis-data:
server-storage:
整个关系如下:
text
Container
│
│ Mount
▼
Docker Volume
所以 Container 可以被删除、更新、重新创建,但 Volume 继续保存数据 。这就是为什么 docker compose pull 和 docker compose up -d 重新创建业务 Container 时,数据库不会因此自动清空。
但是一定要注意:docker compose down -v 中的 -v 代表同时删除相关 Volume。生产环境执行这个命令之前一定要确认自己到底在做什么。同时 Volume 不是 Backup ,生产数据库仍然需要 mysqldump → 备份文件 → 对象存储/其他服务器,否则服务器磁盘损坏以后,Volume 一样救不了数据。
十四、FastAPI 运行在 8000,怎么绑定域名?
Docker 启动以后,ports: - "8000:8000" 意味着:
text
Server :8000
↓
Container :8000
理论上可以通过 http://SERVER_IP:8000 直接访问。但是生产环境一般不建议这么做。更推荐的链路是:
text
Internet
↓
Domain
↓
Nginx :80 / :443
↓
127.0.0.1:8000
↓
Docker
↓
FastAPI :8000
因此 Compose 可以改成:
yaml
ports:
- "127.0.0.1:8000:8000"
这里非常关键。它意味着 127.0.0.1:8000 只监听服务器本机,外部用户无法直接访问 SERVER_IP:8000,只能通过 Nginx。
十五、配置 Nginx 反向代理
假设域名 api.example.com,FastAPI 运行在 127.0.0.1:8000,Nginx 配置如下:
nginx
server {
listen 80;
listen [::]:80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1: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_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
}
检查配置:
bash
nginx -t
如果输出 syntax is ok 和 test is successful,重新加载:
bash
systemctl reload nginx
最终链路:
text
http://api.example.com
↓
Nginx
↓
127.0.0.1:8000
↓
Docker
↓
FastAPI
十六、为什么 MySQL 和 Redis 不应该暴露公网?
Compose 中我没有配置:
yaml
ports:
- "3306:3306"
- "6379:6379"
这是故意的。因为 FastAPI、Celery、MySQL、Redis 都处于 Compose Network 中,它们可以直接通信:
text
FastAPI → mysql:3306
FastAPI → redis:6379
Worker → redis:6379
因此根本不需要暴露到公网。公网真正需要暴露的通常只有 80 和 443 端口,甚至 API 的 8000 端口都只监听 127.0.0.1,这样整体暴露面会小很多。
十七、再进一步:配置 HTTPS
Nginx 反向代理完成以后,还可以使用 Let's Encrypt + Certbot:
bash
# 安装 Certbot
sudo apt install certbot python3-certbot-nginx -y
# 申请证书
sudo certbot --nginx -d api.example.com
最终访问 https://api.example.com,整个生产链路就变成:

text
Internet
│
│ HTTPS :443
▼
┌─────────────┐
│ Nginx │
└──────┬──────┘
│
127.0.0.1:8000
│
▼
┌─────────────┐
│ FastAPI │
└──────┬──────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ MySQL │ │ Redis │
└─────────┘ └────┬────┘
│
┌─────────┼─────────┐
▼ ▼ ▼
Worker Dispatcher Scheduler
十八、以后发布新版本应该怎么做?
假设 v1.0.0 已经在线,开发完成 v1.0.1:
- 在开发机或 CI 构建新镜像:
bash
docker build -t swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.1 .
- Push 到仓库:
bash
docker push swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.1
- 服务器修改
.env文件:
env
SERVER_IMAGE=swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.1
- 拉取并部署:
bash
docker compose pull
docker compose up -d
如果发现严重 Bug,可以改回 v1.0.0 并重新 docker compose up -d。这也是为什么生产环境我更推荐使用明确的版本号(v1.0.0、v1.0.1、v1.1.0),而不是永远使用 latest,明确的版本号会让部署记录和回滚清晰很多。
十九、Docker 部署到底有什么优势?
经过这次完整部署以后,我认为 Docker 对这种项目最大的价值不是"看起来专业",而是下面几点:
19.1 环境一致
开发环境已经运行成功的 Image(包括 Python、Python Dependencies、System Dependencies、Application)整体搬到服务器,减少"本地能跑,服务器不能跑"这种经典问题。
19.2 多服务管理简单
原来需要多个 systemd / Supervisor 配置来管理 FastAPI、Celery Worker、Celery Beat、Redis、MySQL,Docker Compose 后统一 docker compose up -d。
19.3 部署产物标准化
以前交付 Source Code、README、Python Version、requirements、Linux Dependency、Startup Script,现在交付 Docker Image,部署目标变得更加明确。
19.4 回滚方便
v1.0.0 → v1.0.1 → 发现出问题 → v1.0.0,不需要重新修改服务器代码。
19.5 更容易做 CI/CD
以后完全可以实现:
text
Git Push
↓
GitHub Actions / GitLab CI
↓
Docker Build
↓
Docker Push
↓
Deployment
开发完成以后自动完成镜像构建。
二十、Docker 也不是没有缺点
这次部署过程中也能明显感受到一些问题。首先是磁盘占用,例如业务镜像 3.23GB,MySQL 1.1GB。随着版本越来越多(v1.0.0、v1.0.1、v1.0.2、v1.1.0),服务器可能留下很多旧 Image。可以查看磁盘使用情况:
bash
docker system df
清理不用的镜像和容器:
bash
docker system prune -a
其次是学习曲线,对于初次接触 Docker 的开发者,需要理解镜像、容器、网络、卷、Compose 等概念。另外,调试容器内的问题有时比直接调试本地环境更复杂。
尽管如此,对于多服务、需要环境一致性的项目来说,Docker 的优势远大于其缺点。它让部署变得更加可靠、可重复、可管理。