一、问题背景:单体脚本启动的痛

上个月接手一个内部业务系统,原来部署方式是五个shell脚本依次启动,每个脚本里sleep 5到10秒。结果就是:每次发布要手动盯日志,PostgreSQL还没就绪Golang就开始连库,报一堆connection refused;Redis偶尔启动慢半拍,Nginx反代到不存在的API容器,502刷屏。最崩溃的是有一次服务器重启,我花了半小时一个个容器拉起来,还要检查它们之间的网络通了没。

这事让我下定决心,必须把服务编排统一到Docker Compose上。目标很明确:一条docker compose up -d命令搞定全部服务,容器间网络隔离干净,数据卷持久化不丢,启动顺序有保障。

二、环境与版本

先说下我用的环境,踩坑都是基于这个版本组合:

  • 操作系统:Ubuntu 22.04 LTS
  • Docker Engine:24.0.2
  • Docker Compose:v2.20.0(注意是插件版,不是老旧的docker-compose v1)
  • 项目技术栈:Golang 1.21(API服务)、PostgreSQL 15.3、Redis 7.0.12、RabbitMQ 3.12-management、Nginx 1.25

整个项目结构是标准的微服务风格:Nginx做入口网关,Golang API处理业务逻辑,PostgreSQL存业务数据,Redis做缓存,RabbitMQ处理异步任务队列。

三、方案设计:五个服务怎么编排

设计核心就三点:

网络隔离:我建了两个自定义网络。frontend网络让Nginx和Golang API互通,backend网络让Golang API连接数据库、缓存和消息队列。API容器同时挂两个网络,相当于唯一的桥梁。这样即使用户误暴露了数据库端口,外部网络也访问不到,因为PostgreSQL只在backend网络里。

卷持久化:PostgreSQL和RabbitMQ的数据绝对不能放容器可写层,容器一删全没了。我用命名卷把PG的数据目录和RabbitMQ的mnesia目录挂出来。Redis纯缓存可以不挂,但为了重启后快速热身,也给它挂了个卷存RDB快照。

健康检查与启动顺序:这是最关键的。以前用sleep硬等,现在用healthcheck让每个服务报告自己的健康状态,再用depends_on的condition: service_healthy来控制谁先启动。Golang API依赖PG、Redis、RabbitMQ全部健康后才启动,Nginx依赖API健康后才启动。

四、核心实现:docker-compose.yml全解析

直接上配置文件,每个细节我都会拆开讲:

version: "3.8"

networks:
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: "172.20.0.0/24"
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: "172.21.0.0/24"

volumes:
  pg_data:
    driver: local
  rabbitmq_data:
    driver: local
  redis_data:
    driver: local

services:
  postgres:
    image: postgres:15.3-alpine
    container_name: app-postgres
    restart: always
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${PG_PASSWORD:-apppass123}
      POSTGRES_DB: appdb
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./init-sql:/docker-entrypoint-initdb.d:ro
    networks:
      - backend
    ports:
      - "127.0.0.1:5432:5432"  # 仅本机可访问,外部无法直接连
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 5s

  redis:
    image: redis:7.0.12-alpine
    container_name: app-redis
    restart: always
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redispass456}
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "redispass456", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    container_name: app-rabbitmq
    restart: always
    environment:
      RABBITMQ_DEFAULT_USER: admin
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-rabbitpass789}
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    networks:
      - backend
    ports:
      - "15672:15672"  # 管理界面,按需开放
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 20s  # RabbitMQ启动慢,给足热身时间

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    container_name: app-api
    restart: always
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USER: appuser
      DB_PASSWORD: ${PG_PASSWORD:-apppass123}
      DB_NAME: appdb
      REDIS_ADDR: redis:6379
      REDIS_PASSWORD: ${REDIS_PASSWORD:-redispass456}
      RABBITMQ_URL: amqp://admin:${RABBITMQ_PASS:-rabbitpass789}@rabbitmq:5672/
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    networks:
      - frontend
      - backend
    expose:
      - "8080"

  nginx:
    image: nginx:1.25-alpine
    container_name: app-nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./web-static:/usr/share/nginx/html:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
      interval: 10s
      timeout: 3s
      retries: 5

4.1 网络配置细节

两个bridge网络手动指定了子网段,好处是容器IP固定,防火墙规则好写。注意我没在容器上设置固定IP,而是让Docker自动分配,但子网是可控的。Golang API容器同时加入两个网络,它内部访问PostgreSQL直接就用主机名postgres,Compose自带的DNS解析会处理,不需要关心IP。

4.2 健康检查命令的讲究

每个健康检查的命令都是有针对性的:

  • PostgreSQL用pg_isready,这是官方推荐的,不要用psql -c "SELECT 1",太重了。
  • Redis用redis-cli ping,注意我加了-a参数传密码,否则ping会报NOAUTH。
  • RabbitMQ用rabbitmq-diagnostics -q ping,这是RabbitMQ 3.8.7+推荐的健康检查方式。
  • Nginx健康检查我写的是wget请求/healthz端点,这个端点是Nginx配置里反向代理到API的,如果API挂了Nginx返回502,wget会失败,说明整个链路断了。

4.3 启动顺序控制

depends_on长语法是Compose v2.20的标配:

depends_on:
  postgres:
    condition: service_healthy

这意味着API容器会在PostgreSQL健康检查通过后才开始创建。比老版本的短语法(只等容器启动不等就绪)可靠得多。RabbitMQ的start_period设了20秒,因为它的健康检查命令需要等Erlang虚拟机完全起来才能响应,这段时间内即使检查失败也不会计入重试次数。

五、踩坑与优化:三个大坑

坑一:healthcheck命令里的密码硬编码

一开始我把Redis的密码直接写死在healthcheck里:redis-cli -a redispass456 ping。这样虽然能用,但密码一换就得改compose文件。后来我改用环境变量替换:

healthcheck:
  test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping | grep -q PONG"]

注意要用$$转义,因为Compose会先做一次变量插值,$$会变成$传给容器内shell执行。这个坑花了我一下午才搞清楚。

坑二:RabbitMQ健康检查一直失败

RabbitMQ容器起来了,但healthcheck显示unhealthy。排查发现rabbitmq-diagnostics ping返回非零退出码。原因是容器里rabbitmq用户没有执行权限,需要指定用户:

healthcheck:
  test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
  user: "rabbitmq"

坑三:Golang API启动时连不上数据库

就算depends_on设了service_healthy,Golang API启动时还是会偶发连不上PG。因为健康检查通过只代表PostgreSQL进程接受了连接,不代表数据库已经完成初始化脚本执行。我的解决方式是在Golang代码里加重试逻辑:

func waitForDB(db *sql.DB) error {
    for i := 0; i < 10; i++ {
        err := db.Ping()
        if err == nil {
            return nil
        }
        time.Sleep(2 * time.Second)
    }
    return fmt.Errorf("database not ready after 20s")
}

双保险:Compose层面等健康,应用层面再重试。

六、效果数据

改造成Compose编排后,部署流程变化明显:

  • 原来五个shell脚本+手动sleep,完整启动耗时约45秒(有运气成分,sleep时间保守了)。
  • 现在docker compose up -d,从执行命令到Nginx返回200,平均28秒。RabbitMQ的20秒热身占了大头,但这是必要的。
  • 服务器重启后恢复:原来30分钟人工干预,现在一条docker compose start,所有容器按依赖顺序自动拉起,10秒内服务恢复可用。
  • 网络隔离效果:外网扫描不到PostgreSQL和RabbitMQ的端口,只有Nginx的80/443暴露在外。

七、总结

Docker Compose编排的关键就三件事:网络规划要清晰(业务流量和数据流量分开)、数据持久化要挂卷(别信容器可写层)、服务依赖要显式声明(别用sleep碰运气)。这套配置我已经跑了两个月,期间经历三次发布、两次服务器重启,没有一次因为服务启动顺序出问题。如果你也在用多容器部署,建议直接抄作业改项目名就能用。

最后提醒一句:健康检查的命令一定要用服务自带的诊断工具,别用nc -z去探测端口,那只能说明端口开了,不代表服务就绪了。