一、问题背景:从裸机部署到容器化,我们踩了哪些坑

上个月我们团队接手了一个老项目,6个服务模块散落在三台物理机上,部署靠shell脚本,依赖靠人肉记忆。每次发版都要经历:手动启动数据库 → 等30秒 → 启动后端 → 再等20秒 → 启动前端网关……整个流程耗时5分钟以上,且经常因为忘记启动依赖服务导致联调环境崩溃。

更头疼的是,不同服务的配置文件散落各处,端口冲突时有发生——有一次Redis和某个监控服务同时抢6379端口,排查了两个小时。我们决定引入Docker Compose统一编排,目标很明确:

  1. 一条命令拉起全部服务,且保证依赖顺序正确
  2. 数据必须持久化,容器重启不丢数据
  3. 服务间通信走内网,不暴露多余端口
  4. 能自动检测服务健康状态,失败自动重启

二、环境与版本

本次实践基于以下环境,请确保版本不低于这些,否则某些语法不支持:

  • Docker Engine: 24.0.7(含buildx插件)
  • Docker Compose: v2.24.2(注意,不再支持version:字段,直接写服务定义即可)
  • 操作系统: Ubuntu 22.04 LTS(内核5.15+)
  • 目标镜像:OpenJDK 17、PostgreSQL 16.2-alpine、Redis 7.2.4-alpine、RabbitMQ 3.13-management

三、方案设计:网络、卷、健康检查的协同

3.1 网络拓扑设计

使用自定义bridge网络 app-network,所有服务加入该网络。这里没有用默认网络,是因为自定义网络提供内置DNS解析,服务名就是主机名,且可以设置网络别名(alias),便于未来服务迁移。

app-network (bridge, subnet: 172.28.0.0/16)
├── nginx-gateway (映射80端口)
├── backend-service (仅内网)
├── postgres-db (仅内网)
├── redis-cache (仅内网)
├── rabbit-mq (仅内网)
└── scheduler-job (仅内网)

只有nginx暴露80端口到宿主机,其余服务全部内网互联。这样端口冲突问题直接从根上消失。

3.2 卷挂载策略

服务 卷挂载 用途
postgres-db pg_data:/var/lib/postgresql/data 数据库文件持久化
rabbit-mq rabbitmq_data:/var/lib/rabbitmq 消息队列持久化
backend-service ./app/logs:/app/logs 日志输出到宿主机,便于ELK采集
nginx-gateway ./nginx/conf.d:/etc/nginx/conf.d:ro 配置文件只读挂载

命名卷由Compose管理,存放于/var/lib/docker/volumes/,适合数据库这种需要备份恢复的场景。绑定挂载适合配置文件,因为我们要经常改nginx路由规则。

3.3 健康检查与启动顺序

这是本次方案的核心。Docker Compose的depends_on有几种形式:

  • 简单形式:仅控制启动顺序,不关心服务是否就绪
  • 条件形式:condition: service_healthy,要求依赖服务健康后才启动

我们采用条件形式,配合各服务的healthcheck命令。例如PostgreSQL的检查命令:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
  interval: 5s
  timeout: 3s
  retries: 10
  start_period: 10s

start_period很关键,它给了服务首次启动的宽限期,期间失败的检查不计入重试次数。比如PostgreSQL首次启动可能需要8秒初始化数据目录,如果立即开始检查,可能在retries: 5内还没就绪就误判为unhealthy。

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

下面这个文件就是我们线上在用的配置,敏感信息用环境变量替换了:

name: multi-service-app

x-common-env: &common-env
  TZ: Asia/Shanghai
  JAVA_OPTS: "-Xms512m -Xmx1g -XX:+UseG1GC"

networks:
  app-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

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

services:
  postgres-db:
    image: postgres:16.2-alpine
    container_name: postgres-db
    environment:
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app_main
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
    networks:
      app-network:
        aliases:
          - db.host
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d app_main"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
    restart: unless-stopped

  redis-cache:
    image: redis:7.2.4-alpine
    container_name: redis-cache
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb"]
    volumes:
      - redis_data:/data
    networks:
      app-network:
        aliases:
          - cache.host
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  rabbit-mq:
    image: rabbitmq:3.13-management
    container_name: rabbit-mq
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    networks:
      app-network:
        aliases:
          - mq.host
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 20s
    restart: unless-stopped

  backend-service:
    image: registry.example.com/backend:1.4.2
    container_name: backend-service
    depends_on:
      postgres-db:
        condition: service_healthy
      redis-cache:
        condition: service_healthy
      rabbit-mq:
        condition: service_healthy
    environment:
      <<: *common-env
      SPRING_DATASOURCE_URL: jdbc:postgresql://db.host:5432/app_main
      SPRING_DATA_REDIS_HOST: cache.host
      SPRING_RABBITMQ_HOST: mq.host
    volumes:
      - ./app/logs:/app/logs
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 40s
    restart: unless-stopped

  scheduler-job:
    image: registry.example.com/scheduler:2.0.1
    container_name: scheduler-job
    depends_on:
      backend-service:
        condition: service_healthy
    environment:
      <<: *common-env
      BACKEND_URL: http://backend-service:8080
    networks:
      - app-network
    restart: unless-stopped

  nginx-gateway:
    image: nginx:1.27-alpine
    container_name: nginx-gateway
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./nginx/logs:/var/log/nginx
    depends_on:
      backend-service:
        condition: service_healthy
    networks:
      - app-network
    restart: unless-stopped

注意几个细节:

  1. x-common-env是YAML锚点,避免重复写JVM参数
  2. network.aliases让服务拥有多个DNS名——比如db.hostpostgres-db更语义化,后续换数据库中间件时不用改代码
  3. depends_on全部使用service_healthy条件,形成链式依赖:postgres → backend → scheduler/nginx
  4. 数据库初始化脚本通过init-scripts目录挂载,首次启动自动执行建表SQL

五、踩坑与优化:三个真实问题及解决

5.1 坑一:healthcheck的start_period设置不当

最初PostgreSQL的healthcheck没有设置start_period,导致首次启动时data目录初始化耗时6秒,而检查间隔5秒、重试3次,刚好在第3次检查时服务还没就绪,被标记为unhealthy。虽然restart: unless-stopped会触发重启,但重启又需要重新初始化,形成死循环。

解决:设置start_period: 10s,并增加retries到10。实际含义是:首次启动后10秒内不计数失败,之后每5秒检查一次,最多重试10次。

5.2 坑二:RabbitMQ健康检查命令选错

最开始用rabbitmqctl status做检查,但这个命令需要Erlang VM完全启动,且会输出大量日志,把Compose的日志都刷屏了。后来换成rabbitmq-diagnostics -q ping,这个命令只返回PING_OK,安静且快速。

5.3 坑三:卷权限问题导致PostgreSQL启动失败

postgres:16.2-alpine镜像中,数据目录属主是postgres用户(UID 999)。我们最初用./data/pg:/var/lib/postgresql/data绑定挂载宿主机目录,但宿主机目录属主是root,导致PostgreSQL无法写入。

解决:改用命名卷pg_data,由Docker自动管理属主。如果必须用绑定挂载,需要先执行chown -R 999:999 ./data/pg

六、效果数据:上线后的真实对比

指标 部署前(裸机) 部署后(Compose)
全量启动耗时 3分12秒 58秒
环境搭建耗时 45分钟(手动装依赖) 5分钟(pull镜像+启动)
端口冲突事件 月均2-3次 0次
服务不可用恢复 人工介入,平均15分钟 自动重启,平均40秒
日志采集 手动ssh查看 挂载卷直接对接ELK

另外一个意外收获:因为使用了自定义网络别名,我们后来把Redis升级到7.2时,代码里连接地址从redis.host改成cache.host,只需要改一行配置,其他服务无感知。

七、总结与建议

Docker Compose做多服务编排,核心就是把"启动顺序"和"资源隔离"这两件事用声明式配置固化下来。healthcheck + depends_on: service_healthy是解决顺序问题的金钥匙,custom network是解决网络隔离和端口冲突的银弹。

给正在迁移的团队三个建议:

  1. 不要一上来就上K8s,如果服务数在10个以内,Compose完全够用,运维成本低一个量级
  2. healthcheck一定要写,而且要针对服务特性定制命令,别用默认的wget localhost糊弄
  3. 环境变量全部走.env文件,不要把密码写在compose文件里,我们是通过${DB_PASSWORD}引用,.env文件加入.gitignore

最后,我们的完整配置已经放在GitLab内部仓库,如果你们有类似场景,可以评论区留言交流。下一步我们计划把docker-compose.yml升级到docker compose插件版,并接入GitLab CI实现自动构建和滚动更新。