1. 问题背景:手工容器管理已到极限

上个月,我们团队负责的电商后台系统需要从单机部署迁移到容器化方案。最开始图省事,我写了一个bash脚本,里面堆了十几条docker run命令,每个服务都要手动指定网络、端口映射、环境变量。结果维护了不到两周就崩溃了:

  • 启动顺序完全靠sleep 5这种玄学等待,Redis没起来后端API就报连接错误
  • 每次重启都要翻历史命令找端口映射参数
  • 日志分散在各个容器里,排查问题要开五六个终端窗口
  • 更糟的是,有一次误操作删除了存储卷,几天的订单数据差点丢失

我意识到必须引入编排工具。之所以选择Docker Compose而不是Kubernetes,原因很简单:我们只有一台4核8G的云服务器,K8s的组件开销太大了。Compose足够轻量,一条命令就能拉起全部服务,符合当前阶段的需求。

2. 环境与版本说明

先交代一下我使用的具体版本,避免大家踩版本兼容的坑:

Docker version 24.0.7
Docker Compose version v2.23.0
操作系统: Ubuntu 22.04.3 LTS
服务器配置: 4核CPU / 8GB内存 / 100GB SSD

项目技术栈:Python 3.11 + FastAPI(后端),PostgreSQL 14(数据库),Redis 7.2(缓存/消息代理),Celery 5.3(异步任务),Nginx 1.24(反向代理)。

3. 方案设计:分层解耦与依赖管理

在设计docker-compose.yml时,我确定了三个核心原则:

第一:所有服务走自定义网络,不暴露到宿主机。 只有Nginx映射80端口到宿主机,其他服务之间通过服务名互相访问。这样既隔离了内部通信,又减少了端口冲突的风险。

第二:数据卷持久化必须单独声明。 PostgreSQL的数据目录、Redis的持久化文件都要挂载到宿主机指定路径,防止容器重建时数据丢失。

第三:健康检查是启动顺序的基石。 不要用depends_on的裸形式——它只能保证容器启动了,不能保证服务就绪了。必须配合healthcheck条件使用。

整体架构图如下:

宿主机:80 → Nginx → backend_api(8000) → PostgreSQL(5432)
                          ↓
                        Redis(6379) ← Celery Worker

4. 核心实现:完整docker-compose.yml配置

下面是我们生产环境的docker-compose.yml,我删掉了敏感的环境变量值,但保留全部结构:

version: "3.8"

networks:
  app_network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

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

services:
  postgres:
    image: postgres:14.10-alpine
    container_name: ecommerce_postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ecommerce
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
    networks:
      app_network:
        ipv4_address: 172.28.0.10
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ecommerce"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7.2-alpine
    container_name: ecommerce_redis
    restart: unless-stopped
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks:
      app_network:
        ipv4_address: 172.28.0.11
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

  backend_api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: ecommerce_backend
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/ecommerce
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
      CELERY_BROKER_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      app_network:
        ipv4_address: 172.28.0.12
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s

  celery_worker:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: ecommerce_celery
    restart: unless-stopped
    command: celery -A app.tasks worker --loglevel=info --concurrency=2
    environment:
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/ecommerce
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
      CELERY_BROKER_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
    depends_on:
      backend_api:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      app_network:
        ipv4_address: 172.28.0.13

  nginx:
    image: nginx:1.24-alpine
    container_name: ecommerce_nginx
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./static:/usr/share/nginx/html/static:ro
    depends_on:
      backend_api:
        condition: service_healthy
    networks:
      app_network:
        ipv4_address: 172.28.0.14
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
      interval: 10s
      timeout: 5s
      retries: 3

对应的.env文件(关键变量):

DB_USER=ecommerce_app
DB_PASSWORD=Str0ng!Passw0rd
REDIS_PASSWORD=Redis_Secret_2024

5. 踩坑记录:两个让我头疼的问题

5.1 健康检查的start_period是个隐形炸弹

最初我给PostgreSQL配置健康检查时,没有设置start_period。结果第一次启动时,PostgreSQL初始化数据目录需要大约15秒,但healthcheck在容器启动后立刻执行pg_isready,连续失败5次(每次间隔10秒)后,Compose直接判定容器不健康。

更坑的是,depends_on只认service_healthy状态,导致backend_api永远等不到PostgreSQL就绪,整个服务组卡死。后来在healthcheck里加了start_period: 30s——这个参数的意思是:在30秒内,健康检查的失败不计入重试次数。这样初始化期间的失败就被忽略了。

5.2 固定IP地址的利与弊

为了调试方便,我给每个容器指定了固定的IP地址(172.28.0.x)。这在早期确实有帮助——比如直接psql -h 172.28.0.10连数据库。但后来发现一个问题:如果某个容器异常退出,Docker会保留它的IP,新容器启动时可能冲突。

解决办法是把ipv4_address的分配从服务配置中移到网络配置里,或者干脆删掉固定IP,改用服务名访问。最终我保留了固定IP,但增加了docker compose down && docker compose up -d的完整重建流程,避免IP残留。

6. 优化效果:从数据看收益

改造完成后,我对整个部署流程做了基准测试。在同样4核8G的服务器上:

指标 手工脚本 Docker Compose
全量启动时间 ~3分钟(含sleep等待) 40秒(健康检查串行)
服务重启恢复 需要人工确认顺序 docker compose restart 一条命令
日志查看 docker logs + 多个终端 docker compose logs -f --tail=100
数据安全 手动备份,易遗漏 卷挂载 + 定时备份脚本
磁盘占用 未统计 镜像总大小2.3GB

最直观的感受是:以前凌晨数据库挂了,我要爬起来手动拉容器、等启动、看日志。现在只要docker compose up -d,依赖关系自动处理,healthcheck确认就绪后才启动下游服务——我可以在被窝里用手机完成操作。

7. 总结与建议

Docker Compose不是万能的,但在单机多服务场景下,它确实是性价比最高的编排工具。回顾这次改造,我认为最值得关注的设计决策有三个:

  1. 健康检查必须配合start_periodretries调优——这是保证启动顺序可靠的前提
  2. 网络隔离要彻底——只有入口服务(Nginx)暴露端口,内部通信全走容器网络
  3. 数据卷单独声明——不要让容器存储任何运行时产生的关键数据

如果你正在考虑从手工docker run迁移到Compose,建议先画出服务依赖图,明确每个服务的就绪条件,再动手写配置。这套方案目前已经稳定运行了三个月,服务可用性达到99.9%。如果你的场景也是单机多服务,完全可以参考这套配置做裁剪。