Docker 实战部署:从本地镜像到云服务器,一篇走通 FastAPI + Celery + MySQL + Redis + Nginx

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:

  1. 在开发机或 CI 构建新镜像:
bash 复制代码
docker build -t swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.1 .
  1. Push 到仓库:
bash 复制代码
docker push swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.1
  1. 服务器修改 .env 文件:
env 复制代码
SERVER_IMAGE=swr.cn-east-3.myhuaweicloud.com/example/writing-assistant-server:v1.0.1
  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 的优势远大于其缺点。它让部署变得更加可靠、可重复、可管理。

相关推荐
judezh1 小时前
api 容器一直 unhealthy?我把一个 Agent 运行时的健康检查逐条拆了,查出四个问题
运维·docker
暗不需求1 小时前
Docker 入门:从「光盘与 DVD」到全栈项目容器化实战
docker·容器·面试
创新技术阁1 小时前
FastapiAdmin插件介绍
前端·后端·fastapi
探索云原生1 小时前
一个 Deployment 就能跑 vLLM,为什么还需要 KServe?
docker·ai·云原生·kubernetes·go
创新技术阁1 小时前
FastapiAdmin 实战:演示模式开关失效的排查记录
前端·后端·fastapi
分布式存储与RustFS5 天前
MinIO 官方 Docker 镜像被移除:依赖它的项目该怎么办
docker·云原生·devops·对象存储·minio·分布式存储
玉&心6 天前
通过Arthas在线诊断K8S中的内存及JVM等使用情况
docker·k8s·arthas
xing-xing6 天前
Docker容器中Nginx站点根目录网页配置访问
nginx·docker
guo_wen_qiang6 天前
上传本地镜像到harbor中
docker·容器·持续部署