一、背景:单机部署的痛点,逼我上了Compose

先交代下项目背景。一个典型的电商后台,包含:
- Nginx(前端静态资源 + 反向代理)
- Django后端API(Gunicorn启动,8000端口)
- Celery Worker(异步任务,依赖Redis和PostgreSQL)
- Redis(缓存 + Celery Broker)
- PostgreSQL(主数据库)

之前是纯手工部署:写了一个200行的deploy.sh,按顺序启动服务,用sleep 5硬等依赖就绪。结果经常遇到:
1. 后端API启动时PostgreSQL还没就绪,导致连接失败,进程退出
2. Celery Worker连接Redis超时,任务队列堆积
3. 数据卷权限变成root,容器内用户无法写入
4. 手动kill进程时,残留的PID文件导致下次启动失败

后来决定用Docker Compose。当时Docker官方文档读了一遍,发现Compose v2已经原生支持depends_on + condition: service_healthy,这意味着可以做到真正的依赖就绪再启动,而不是靠盲目sleep。

二、环境与版本:别用太老的坑

我的环境:
- 操作系统:Ubuntu 22.04 LTS
- Docker Engine:24.0.7(2023年11月版本)
- Docker Compose:v2.24.0(独立二进制,非docker-compose老版)
- 服务器配置:4核8G,SSD磁盘

重要提示:如果你还在用docker-compose v1(Python写的),强烈建议升级到v2。因为condition: service_healthy在v1里支持不完整,且v1已经停止维护。检查版本命令:

docker compose version   # 输出 Docker Compose version v2.24.0

三、方案设计:网络、卷、健康检查、启动顺序四件套

我的设计思路遵循以下原则:

  1. 网络:创建一个自定义bridge网络,服务间用服务名通信(不用IP),隔离外部流量。
  2. 卷挂载:命名卷(named volume)用于持久化数据(PostgreSQL数据、Redis数据),绑定挂载(bind mount)用于代码热更新。
  3. 健康检查:每个依赖服务必须定义healthcheck,包括testintervaltimeoutretries。不能只检查端口,要检查真正可用的能力。
  4. 启动顺序:使用depends_on + condition: service_healthy,确保依赖链:PostgreSQL/Redis先健康 → 后端API启动 → Celery Worker启动 → Nginx最后。

四、核心实现:完整的docker-compose.yml

直接上配置,文件名为docker-compose.yml,放在项目根目录。

version: "3.9"

networks:
  backend_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24

volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local

services:
  postgres:
    image: postgres:16.1-alpine
    container_name: backend_postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}   # 从.env读取,不要硬编码
      POSTGRES_DB: ecommerce
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-sql:/docker-entrypoint-initdb.d:ro   # 初始化脚本
    networks:
      backend_net:
        ipv4_address: 172.20.0.10
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d ecommerce -h 127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
    ports:
      - "5432:5432"   # 仅开发环境暴露,生产环境建议去掉

  redis:
    image: redis:7.2-alpine
    container_name: backend_redis
    restart: unless-stopped
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      backend_net:
        ipv4_address: 172.20.0.11
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: backend_api:latest
    container_name: backend_api
    restart: unless-stopped
    env_file:
      - .env
    environment:
      DATABASE_URL: postgresql://app_user:${DB_PASSWORD}@postgres:5432/ecommerce
      REDIS_URL: redis://redis:6379/0
    volumes:
      - ./backend:/app   # 绑定挂载,代码热更新(开发模式)
    networks:
      backend_net:
        ipv4_address: 172.20.0.12
    depends_on:
      postgres:
        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: 20s   # 启动较慢,给20秒缓冲

  worker:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: backend_api:latest   # 复用同一镜像
    container_name: backend_worker
    restart: unless-stopped
    command: celery -A ecommerce worker -l info --concurrency=4
    env_file:
      - .env
    environment:
      DATABASE_URL: postgresql://app_user:${DB_PASSWORD}@postgres:5432/ecommerce
      REDIS_URL: redis://redis:6379/0
    depends_on:
      api:
        condition: service_healthy   # 确保API已就绪,因为Worker会调用API的迁移脚本
    networks:
      backend_net:
        ipv4_address: 172.20.0.13

  nginx:
    image: nginx:1.25-alpine
    container_name: backend_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./frontend/dist:/usr/share/nginx/html:ro
    networks:
      backend_net:
        ipv4_address: 172.20.0.14
    depends_on:
      api:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost/health/ >/dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5

对应的.env文件(记得加入.gitignore):

DB_PASSWORD=your_strong_password_here
SECRET_KEY=your_django_secret_key
DEBUG=True

五、踩坑与优化:这六个问题让我折腾了两天

坑1:健康检查的test命令不能用绝对路径的curl
alpine镜像里没有curl,只有wget。我在Nginx的健康检查里用了wget,但在API里用了curl,结果API镜像基于python:3.11-slim,也没装curl。解决办法:在Dockerfile里加上RUN apt-get update && apt-get install -y curl,或者改用Python的urllib。我最终选择在Dockerfile里装curl。

坑2:PostgreSQL数据卷权限
第一次启动后,容器内postgres用户创建的数据文件所有者为uid 999。第二次启动时,如果宿主机上该目录权限不对,会报chmod: changing permissions of '/var/lib/postgresql/data': Operation not permitted。解决办法:初始化时使用docker compose down -v清空卷,然后重新创建。更重要的是,确保宿主机目录(如果用bind mount)的uid/gid与容器内postgres用户一致。

坑3:depends_on只等容器启动,不等服务就绪
这是Compose v1最大的坑。v2的condition: service_healthy解决了这个问题。但注意,depends_on列表里,只有定义在列表中的服务才会被等待。比如celery worker依赖api,但如果api没在worker的depends_on里,它就不会等api。

坑4:启动超时设置
PostgreSQL在冷启动时可能需要恢复WAL日志,第一次启动可能要20秒以上。所以start_period一定要设置,否则健康检查会误报失败,导致依赖服务迟迟无法启动。我设置为start_period: 10s,但实际观察日志发现冷启动需要15秒,后来改成start_period: 20s

坑5:固定IP导致网络冲突
我在自定义网络里手工指定了静态IP(ipv4_address),但后来新增服务时忘记预留IP段,导致冲突。优化做法:不要手工指定IP,让Docker自动分配。只有需要外部访问固定IP的场景(比如iptables规则)才需要静态IP。我后来把IP指定全去掉了,只保留网络名。

坑6:Celery Worker启动时重复执行迁移
因为worker和api共用同一个镜像,启动命令不同。但worker启动时,Django的AppConfig.ready()方法可能触发数据库操作,如果数据库还没迁移完成就会报错。我的解决办法:在worker的启动命令前加一个等待脚本:

command: sh -c "python manage.py wait_for_db && celery -A ecommerce worker -l info --concurrency=4"

其中wait_for_db是一个自定义management command,循环检查数据库连接。

六、效果数据:部署时间缩短80%,可用性提升

改造完成后,我做了几次对比测试:

指标 手工部署 Docker Compose
冷启动部署时间(含依赖安装) 40分钟 8分15秒
增量部署时间(仅代码更新) 15分钟 2分30秒
服务可用性(30天观测) 99.2% 99.95%
迁移到新服务器时间 2小时+ 15分钟(docker compose up -d

关键收益
1. 启动顺序可靠:通过健康检查,PostgreSQL和Redis稳定后,API和Worker才会启动,日志中不再出现连接错误。
2. 环境一致性:团队新成员git clone + cp .env.example .env + docker compose up -d,5分钟就能跑起完整环境。
3. 回滚方便docker compose downgit checkout 旧版本代码,一条命令回滚。

七、总结与建议

Docker Compose不是万能的,但针对中小型多服务项目,它提供了性价比极高的编排能力。我的建议:

  1. 优先使用命名卷而非bind mount(生产环境),避免权限问题。
  2. 健康检查是核心:不要只检查端口,要检查真正可用的业务接口(比如/health/返回200)。
  3. 不要手工指定IP:让Compose管理网络,减少人为错误。
  4. .env文件管理密钥:不要把密码写死在compose文件里。
  5. 生产环境加限制:如果追求高可用,建议配合docker swarmk8s,但中小项目用Compose + restart: unless-stopped已经足够。

最后,贴出部署命令:

# 首次部署
docker compose up -d --build

# 查看日志
docker compose logs -f api

# 停止所有服务
docker compose down

如果你也在迁移到Compose的路上,希望这篇博客能帮你少踩几个坑。有问题欢迎评论区交流。