一、问题背景

手里有个小项目:Python FastAPI 写的后端 + MySQL 8.0 存数据 + Redis 7 做缓存 + Nginx 做反向代理和静态资源。之前部署靠手写脚本,先 docker run 起 MySQL,等它初始化完再起 Redis,再起后端,最后起 Nginx,中间还得手动 docker network createdocker volume create,一套流程下来十几分钟,换台机器就出错。

核心痛点有三个:
1. 启动顺序无保障:后端比 MySQL 先起来,连接池直接报 Connection refused,得手动重启后端容器。
2. 网络配置散乱:每个 docker run 都要带 --network,容器名解析偶尔出问题。
3. 数据卷管理混乱:MySQL 数据卷名字记不住,迁移时容易丢数据。

于是全面转向 Docker Compose 编排。本文的配置经过生产环境验证,跑了 3 个多月没出过编排层面的问题。

二、环境与版本

先说清楚版本,Docker Compose 的语法在 v2 和 v3 之间差异不小,网上很多老教程会误导人。

组件 版本
操作系统 Ubuntu 22.04 LTS
Docker Engine 24.0.7
Docker Compose v2.23.0(插件形式,命令是 docker compose 而非 docker-compose
Python 3.11
FastAPI 0.104.1
MySQL 8.0.35
Redis 7.2.3
Nginx 1.25.3

注意:Compose v2 已经不再需要 version: 字段,写了反而会有 deprecation warning。我下面配置里直接省略。

三、方案设计

整体架构分三层:

  • 接入层:Nginx 监听 80/443,反向代理到后端,同时挂载静态文件目录。
  • 应用层:FastAPI 后端,监听 8000,依赖 MySQL 和 Redis。
  • 数据层:MySQL 8.0 和 Redis 7,只在内网暴露,不映射到宿主机端口(安全考虑)。

网络设计:创建一个自定义 bridge 网络 app-net,子网 172.20.0.0/16。所有服务接入同一网络,通过服务名互相解析。Nginx 额外接入默认网络以便对外暴露端口——其实不必要,自定义网络也能映射端口,这里简化处理,全部走 app-net

卷设计:
- mysql-data:命名卷,持久化 MySQL 数据目录。
- redis-data:命名卷,持久化 Redis AOF 文件。
- ./nginx/conf.d./nginx/html./backend:绑定挂载,方便开发时热更新配置和代码。

启动顺序:用 depends_on + healthcheckcondition: service_healthy 实现。MySQL 健康检查通过 mysqladmin ping,Redis 通过 redis-cli ping,后端通过 curl 检查 /health 端点。Nginx 依赖后端健康。

四、核心实现

目录结构:

project/
├── docker-compose.yml
├── .env
├── backend/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       └── main.py
├── nginx/
│   ├── conf.d/
│   │   └── default.conf
│   └── html/
│       └── index.html
└── mysql/
    └── init/
        └── init.sql

4.1 docker-compose.yml 完整配置

services:
  mysql:
    image: mysql:8.0.35
    container_name: app-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      TZ: Asia/Shanghai
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init:/docker-entrypoint-initdb.d:ro
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 40s
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-authentication-plugin=mysql_native_password
      - --max_connections=500
      - --innodb_buffer_pool_size=512M

  redis:
    image: redis:7.2.3-alpine
    container_name: app-redis
    restart: unless-stopped
    volumes:
      - redis-data:/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: app-backend
    restart: unless-stopped
    environment:
      DATABASE_URL: mysql+aiomysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
      REDIS_URL: redis://redis:6379/0
      TZ: Asia/Shanghai
    volumes:
      - ./backend:/app
    networks:
      - app-net
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    expose:
      - "8000"

  nginx:
    image: nginx:1.25.3-alpine
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/html:/usr/share/nginx/html:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    networks:
      - app-net
    depends_on:
      backend:
        condition: service_healthy

networks:
  app-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16

volumes:
  mysql-data:
    driver: local
  redis-data:
    driver: local

4.2 后端 Dockerfile

FROM python:3.11-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

关键点:必须装 curl,否则健康检查的 curl -f 会失败,容器一直处于 unhealthy 状态,Nginx 永远起不来。这个坑我下面还会讲。

4.3 后端健康检查端点

# app/main.py
from fastapi import FastAPI
from sqlalchemy import text
import redis.asyncio as aioredis
import os

app = FastAPI()

@app.get("/health")
async def health():
    # 简单返回 200 即可,Compose 健康检查只看状态码
    return {"status": "ok"}

@app.get("/health/deep")
async def health_deep():
    # 深度检查,用于监控系统,不用于 Compose healthcheck
    result = {"mysql": False, "redis": False}
    try:
        async with engine.connect() as conn:
            await conn.execute(text("SELECT 1"))
        result["mysql"] = True
    except Exception:
        pass
    try:
        r = aioredis.from_url(os.environ["REDIS_URL"])
        await r.ping()
        result["redis"] = True
    except Exception:
        pass
    return result

健康检查端点要轻量,别在 /health 里查数据库,否则 MySQL 抖动会导致后端被误判为不健康,进而拖垮整个启动链。

4.4 .env 文件

MYSQL_ROOT_PASSWORD=RootP@ss2024
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=AppP@ss2024

.env 要加进 .gitignore,别提交到仓库。

五、踩坑与优化

坑1:健康检查的 start_period 太短

MySQL 8.0 首次启动要初始化数据目录,冷启动实测需要 25-35 秒。我一开始把 start_period 设成 15s,结果 MySQL 还没初始化完就被判定为 unhealthy,depends_on 直接失败,后端根本不启动。

解决start_period: 40s,给足冷启动时间。start_period 内的失败不计入 retries,这是关键。

坑2:$$ 转义

healthcheck 里写 -p$$MYSQL_ROOT_PASSWORD,两个 $ 是必须的。Compose 会先做变量插值,$$ 转义成单个 $ 传给容器内的 shell,由 shell 读取容器环境变量。写一个 $ 的话,Compose 会尝试从宿主机环境变量插值,拿到空值,健康检查永远失败。

坑3:绑定挂载覆盖了容器内文件

./backend:/app 这个绑定挂载,会把宿主机的 backend 目录挂到容器的 /app覆盖掉镜像里 COPY . . 复制进去的内容。如果你在 Dockerfile 里 pip install 的依赖装在了 /app 下(比如用 --target),就会被覆盖。

解决:依赖装在系统 site-packages 里(默认行为),只挂载代码目录。生产环境建议去掉这个挂载,用镜像里的代码。

坑4:Nginx 的 depends_on 不够

depends_on 只管启动顺序,不管后端是否真的能处理请求。后端健康检查通过后 Nginx 才启动,但 Nginx 启动后如果后端崩了,Nginx 会返回 502。

优化:在 Nginx 配置里加 proxy_next_upstream 和健康检查(开源版 Nginx 不支持主动健康检查,可以用 max_fails + fail_timeout 做被动检查):

upstream backend {
    server backend:8000 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

server {
    listen 80;
    server_name _;

    location /api/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }
}

坑5:MySQL 的 innodb_buffer_pool_size 默认太小

默认 128M,对于稍微有点数据量的场景不够。我设成 512M,QPS 从 800 提升到 2100(sysbench 测试,4 核 8G 机器)。具体数值根据机器内存调整,一般是可用内存的 50%-70%。

优化:日志驱动限制

Docker 默认的 json-file 日志驱动不限制大小,跑久了能把磁盘写满。在 docker-compose.yml 里加:

x-logging: &default-logging
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

然后在每个服务里加 logging: *default-logging。这是 YAML 锚点语法,Compose 支持。

六、效果数据

部署流程对比:

指标 手动部署 Docker Compose
冷启动耗时 ~15 分钟 90 秒
换机部署耗时 ~20 分钟 3 分钟(含镜像拉取)
启动失败率 约 30%(顺序问题) 0%(3 个月统计)
服务间延迟 0.3-0.8ms 0.4-0.6ms
数据迁移 手动 dump/restore 卷打包迁移

健康检查的实际效果:MySQL 冷启动平均 28 秒,健康检查在 start_period 40s 内完成状态转换,后端在后端健康检查通过后 2 秒内启动,Nginx 最后启动。整个链路从 docker compose up -d 到所有服务 healthy,实测 85-95 秒。

资源占用(4 核 8G 机器,空载):
- MySQL:约 450MB 内存
- Redis:约 15MB
- 后端:约 120MB(2 workers)
- Nginx:约 8MB
- 总计:约 600MB

七、总结

Docker Compose 编排的核心就三件事:网络隔离、卷持久化、启动顺序。网络用自定义 bridge 加固定子网,服务间用服务名通信;卷用命名卷存数据,绑定挂载放配置;启动顺序靠 healthcheck + depends_onconditionstart_period 一定要给足冷启动时间。

几个关键参数再强调一遍:MySQL 的 start_period 至少 40s,innodb_buffer_pool_size 按内存调,healthcheck 里的 $ 要写 $$,日志驱动要限制大小。

这套配置我已经在 3 个项目里复用,改改服务名和镜像就能用。完整配置在上面的代码块里,可以直接抄。有问题评论区聊。