一、问题背景:单体脚本部署的失控时刻

之前我们的项目部署靠一套Shell脚本,按固定顺序启动PostgreSQL→Redis→后端Jar→Nginx。听起来逻辑清晰,但上线三个月后问题频出:

  1. 启动时序不可控:PostgreSQL容器起来了,但内部初始化脚本还没跑完,后端已经连上去,直接报Connection refused。脚本里加sleep 15,结果数据库升级后初始化时间变成20秒,又得改脚本。
  2. 网络配置靠运气:四个服务用--link硬关联,IP是动态分配的,后端配置里写死Redis IP,一重启容器IP变了就失联。
  3. 故障恢复无感知:Redis OOM被内核杀掉,脚本不会自动拉起,前端接口全部超时,直到监控报警才发现。

项目用户量不大,但每个问题都够折腾半小时以上。后来决定全面切换Docker Compose,核心诉求只有一个:让容器编排像写声明式配置一样,描述最终状态,而不是编写启动步骤

二、环境与版本说明

  • 宿主机:Ubuntu 22.04 LTS,内核5.15.0
  • Docker Engine:24.0.6(支持healthcheckstart_period参数)
  • Docker Compose Plugin:v2.21.0(注意不是docker-compose独立二进制,而是插件版)
  • 镜像版本:postgres:15.3-alpineredis:7.0.12-alpineopenjdk:17-jdk-slimnginx:1.25.2-alpine

选alpine变体是刻意的,镜像体积平均减少60%,尤其PostgreSQL从400MB降到200MB左右,内网拉取时间从25秒降到9秒。

三、方案设计:网络隔离 + 卷挂载 + 健康检查三位一体

核心思路分三层:

第一层:自定义网络,强制服务间通过服务名通信。 创建两个网络——frontendbackend。Nginx只挂frontend,后端挂两个网络,数据库和Redis只挂backend。这样即使容器被攻破,横向移动的面也被限制在对应的网络段内。

第二层:命名卷挂载,数据与容器生命周期解耦。 PostgreSQL和Redis数据用命名卷,docker compose down不会删除数据,但docker compose down -v会。Nginx日志用绑定挂载到宿主机/var/log/nginx,方便直接看日志文件。

第三层:healthcheck探针 + 条件依赖,替代sleep硬编码。 这是本次改造的核心。每个服务定义healthcheck命令,depends_on里用condition: service_healthy,Compose会等健康检查通过后才启动依赖服务。

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

直接上配置,这是线上跑了两周的版本,注释标明了关键参数的作用:

version: "3.8"

networks:
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/24
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.1.0/24

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

services:
  postgres:
    image: postgres:15.3-alpine
    container_name: app-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}  # 从.env文件读取,不写入仓库
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d:ro  # 首次启动时执行初始化SQL
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s  # 给容器启动留缓冲,避免误判

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

  backend:
    build: ./backend
    container_name: app-backend
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      SPRING_REDIS_PASSWORD: ${REDIS_PASSWORD}
      JVM_OPTS: "-Xms512m -Xmx1g -XX:+UseG1GC"
    volumes:
      - ./logs:/app/logs
    networks:
      - backend
      - frontend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s  # Spring Boot启动较慢,给足时间

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

配套的.env文件(不提交到Git):

DB_PASSWORD=MyStr0ng!Pass
REDIS_PASSWORD=Redis@2024

启动命令:docker compose --env-file .env up -d --build

注意--env-file参数在Compose v2里是显式指定的,如果不加,默认读取同目录.env,但容易和系统环境变量混淆,建议显式指定。

五、踩坑与优化:三个让我熬夜的细节

坑1:健康检查里的start_period不是万能的

第一次配置PostgreSQL健康检查,start_period设为0,结果容器启动花了8秒,pg_isready在容器内还没就绪,直接重试5次(每次间隔5秒),第25秒才标记healthy。而Redis的start_period设为5秒,实际没事。后来把start_period调到10秒,PostgreSQL在10秒内完成了初始化,健康检查一次性通过。经验:start_period要大于镜像首次启动的最慢时间,否则会误判为unhealthy并导致依赖服务延迟启动。

坑2:Nginx反代后端时,proxy_pass的host必须用服务名

Nginx配置里写proxy_pass http://backend:8080;,但backend的容器名是app-backend。在Compose网络里,服务名backend会被解析到容器IP,但如果你在Nginx配置里写了proxy_pass http://app-backend:8080;,虽然容器名也能解析,但一旦容器重建(docker compose up -d --force-recreate),容器名对应的IP会变,而Nginx的DNS缓存不会立即失效,导致502。解法:统一使用服务名(service name),不要用container_name。 我在Nginx配置里全部改用backend,问题消失。

坑3:卷挂载的权限问题

后端镜像用openjdk:17-jdk-slim,容器内用户是root,而宿主机挂载的./logs目录是当前用户(uid=1000)所有。容器启动后写日志报Permission denied。两个解法:一是在Dockerfile里加RUN useradd -m appuser并以USER appuser启动;二是宿主机目录chown 1000:1000。我选了后者,因为开发机的用户uid恰好是1000,生产环境需要统一。建议:生产环境用非root用户运行容器,并在Dockerfile里明确指定uid/gid。

六、效果数据:改造前后的量化对比

改造上线后,我记录了一周的数据(日均请求量约2万,峰时QPS 300):

指标 改造前(Shell脚本) 改造后(Compose)
全量部署耗时 6分钟(含等待) 40秒(并行构建+启动)
服务依赖就绪时间 不可控,偶尔失败 确定性:PostgreSQL 12s,Redis 3s
故障恢复 手动干预,平均15分钟 restart: unless-stopped自动拉起,平均30秒
配置变更部署 改脚本,易出错 改YAML,docker compose up -d,零停机(Nginx先起)

最直观的感受:以前凌晨被叫起来处理Redis挂掉,现在docker compose ps看一眼,容器自动重启了,日志显示恢复时间在秒级。

七、总结与建议

Docker Compose不是万能的,但对于中小型项目(服务数量少于10个),它是性价比最高的编排工具。核心价值在于声明式描述依赖关系,让启动顺序从“脚本逻辑”变成“配置事实”。

几点总结:

  1. 健康检查是编排的基石,没有它,depends_on只是摆设。建议每个服务都定义,探针命令要简单快速,不要在里面做复杂逻辑。
  2. 网络划分要趁早,两个网络隔离前后端,比全放一个网络更安全,而且几乎零成本。
  3. 卷挂载分清楚——数据卷(命名卷)用于持久化,绑定挂载用于日志和配置。不要混用。
  4. 版本锁定:镜像版本、Compose版本、Docker引擎版本,全部固定,避免“昨天还能跑,今天全挂”的玄学。

如果你还在用Shell脚本编排多个容器,建议花半天时间迁移到Compose。前期配置成本约2小时,但换来的是部署时间的数量级下降和故障恢复的自动化,这笔账怎么算都不亏。


(全文完,代码已脱敏,可直接套用,注意替换密码和路径。)