一、问题背景:微服务编排的“野路子”踩坑记

上个月接手一个老项目,后端是Spring Boot单体应用,前端用Nginx托管静态资源,数据库PostgreSQL,缓存Redis。以前部署全靠shell脚本,每次发布都要手动敲命令,服务启动顺序完全靠“运气”——先启动数据库,再启动后端,最后启动Nginx。但问题是Spring Boot启动时如果连不上数据库,直接抛异常退出,而数据库容器启动到真正可接受连接,往往需要等待5-10秒。

连续两次线上发布失败后,我决定用Docker Compose重构整个部署流程。目标很明确:一条命令完成所有服务的编排、依赖管理和健康检查。

二、环境与版本

先交代一下环境,方便大家对照:

  • Docker:20.10.17(宿主机Ubuntu 20.04 LTS)
  • Docker Compose:v2.10.2(注意,v2和v1命令有差异)
  • 镜像版本:
  • nginx:1.24.0-alpine(比官方版小30%,约23MB)
  • spring-boot-app:基于openjdk:17-jdk-alpine自定义构建
  • postgres:15.3-alpine
  • redis:7.0.12-alpine

项目结构:

deploy/
├── docker-compose.yml
├── nginx/
│   └── nginx.conf
└── app/
    └── application-prod.yml

三、方案设计:四条核心约束

在设计编排方案时,我给自己定了四条硬性约束:

  1. 网络隔离:所有服务必须在一个自定义bridge网络中,但Nginx需要对外暴露80端口,其余服务一律不映射宿主机端口。
  2. 数据持久化:PostgreSQL数据目录和Redis的AOF持久化文件必须用命名volume挂载,避免容器重建导致数据丢失。
  3. 健康检查:每个服务必须定义healthcheck,不能只看进程是否存活,要检查服务是否真正可用。
  4. 启动顺序:PostgreSQL和Redis先启动,等健康检查通过后,Spring Boot再启动,最后Nginx。

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

直接上完整配置,然后逐段解释关键点。

version: "3.9"

services:
  postgres:
    image: postgres:15.3-alpine
    container_name: prod-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      TZ: Asia/Shanghai
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./backup:/backup
    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: prod-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
    volumes:
      - redisdata:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 5s

  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    image: spring-boot-app:latest
    container_name: prod-app
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - applogs:/app/logs
    networks:
      - backend
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s

  nginx:
    image: nginx:1.24.0-alpine
    container_name: prod-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./dist:/usr/share/nginx/html:ro
    networks:
      - backend
    depends_on:
      app:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost/healthz || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 5s

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16

volumes:
  pgdata:
  redisdata:
  applogs:

1. 网络隔离的实现细节

自定义网络backend,指定了子网172.20.0.0/16。这里有个细节:如果不指定子网,Docker会自动分配,但有时候会和其他VPN网段冲突。指定子网后,容器间通过服务名直接通信,比如Spring Boot的数据库连接地址写jdbc:postgresql://postgres:5432/appdb,而不是IP。

2. 卷挂载的三个层次

  • 命名卷pgdataredisdataapplogs,由Docker管理,存放在/var/lib/docker/volumes/下,适合持久化数据。
  • bind mount./nginx/nginx.conf./dist,直接映射宿主机目录,适合配置文件和环境相关数据。
  • 只读挂载:Nginx配置和静态资源都加了:ro,防止容器内误操作。

3. 健康检查的姿势

PostgreSQL用pg_isready,Redis用redis-cli ping(注意要带密码,否则报NOAUTH错误),Spring Boot用curl打actuator健康检查端点,Nginx用wget请求一个自定义的/healthz路径。

这里有个坑:Spring Boot镜像基于openjdk:17-jdk-alpine,里面没有curl,需要自己在Dockerfile里装:

# app/Dockerfile
FROM openjdk:17-jdk-alpine
RUN apk add --no-cache curl
COPY target/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]

4. 启动顺序控制的关键

depends_on支持两种条件:service_started(默认,只保证容器启动)和service_healthy(保证健康检查通过)。我用的是后者,确保PostgreSQL完全就绪才启动Spring Boot。

depends_on:
  postgres:
    condition: service_healthy
  redis:
    condition: service_healthy

五、踩坑与优化:两个真实问题

坑一:健康检查的start_period设置不当

第一次部署时,PostgreSQL的start_period设成了5秒,结果容器启动后还没初始化完,pg_isready就报错,连续重试5次后直接标记为unhealthy,导致Spring Boot一直等待。后来查文档发现,start_period是给容器“预热”的时间,这段时间内健康检查失败不计入重试次数。改成10秒后问题解决。

坑二:Redis密码导致健康检查失败

Redis容器设置了--requirepass,但healthcheck里一开始没带密码,执行redis-cli ping返回NOAUTH Authentication required,被判定为非健康状态。修正后:

healthcheck:
  test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]

需要注意的是,redis-cli -a会输出警告日志,不影响功能,如果介意可以在命令后面加2>/dev/null

优化:启动时间从90秒降到35秒

优化前,Spring Boot启动时总会遇到数据库连接池初始化超时,因为PostgreSQL虽然容器起来了,但还在执行初始化脚本。通过健康检查联动后,Spring Boot启动时数据库已就绪,连接池第一次连接就成功。同时把Java的spring.datasource.hikari.initialization-fail-timeout设成1秒,失败快速重试,而不是默认的30秒超时。

六、效果数据

改造后,我做了三轮验证:

指标 优化前 优化后
全栈启动时间 90秒(含手动等待) 35秒(docker compose up -d)
启动成功率 70%(依赖数据库状态) 100%(连续10次验证)
部署命令数 8条shell命令 1条docker compose命令

另外,由于网络隔离做得彻底,后端服务和数据库不暴露宿主机端口,安全扫描通过率从原来的65%提升到92%。

七、总结

Docker Compose编排的核心不是把服务堆在一起,而是处理好三个关系:网络关系(谁和谁能通信)、数据关系(哪些数据要持久化)、时间关系(谁先启动,等待谁)。healthcheck配合depends_on的service_healthy条件,是解决启动顺序问题的标准答案。

最后送大家一句话:能写进docker-compose.yml的,就不要写在shell脚本里。配置即代码,可审查、可回滚、可复用,这才是容器化部署的正确打开方式。

如果你也在用Docker Compose编排多服务,欢迎在评论区分享你的踩坑经历。