1. 背景:一个看似简单却反复翻车的编排需求

上个月接手一个老项目,需要把原先用Shell脚本硬编码启动顺序的4个服务迁移到Docker Compose。本来以为是个轻松的活,结果第一个版本就翻车了——Spring Boot应用启动时连不上PostgreSQL,直接抛异常退出。原因很蠢:Compose默认的depends_on只保证容器启动顺序,不保证服务内部就绪。数据库容器起来了,但PostgreSQL还在初始化,应用一连接就炸。

更隐蔽的问题是网络配置。原项目所有容器都挂在默认bridge网络上,服务间通过容器名通信,表面没问题,但一旦涉及外部访问或跨主机部署,端口映射和网络隔离就乱成一锅粥。卷挂载也是,日志文件散落在宿主机各处,清理都找不到地方。

这次重构的目标很简单:用一份docker-compose.yml搞定所有服务编排,做到启动顺序可控、网络隔离清晰、数据持久化可靠、故障可感知。下面直接上干货。

2. 环境版本与基础配置

先交代实验环境,避免版本差异导致配置失效:

  • Docker Engine: 24.0.7
  • Docker Compose: v2.24.2
  • 宿主机: Ubuntu 22.04.3 LTS,内核 6.2.0
  • 服务栈:Nginx 1.25.3(网关)、Spring Boot 3.1.5(业务)、PostgreSQL 15.4、Redis 7.2.1

基础目录结构如下:

project/
├── docker-compose.yml
├── nginx/
│   └── conf.d/
│       └── default.conf
├── backend/
│   ├── app.jar
│   └── Dockerfile
└── init-db/
    └── init.sql

3. 方案设计:网络、卷、健康检查的协同规划

设计阶段定下三个核心原则:

网络隔离:创建两个自定义bridge网络——frontend_net用于Nginx与后端通信,backend_net用于后端与数据库、Redis通信。Nginx不直接访问数据库,后端不直接暴露到外部,这样即使某个容器被攻破,攻击面也被限制在单条链路上。

卷挂载策略:PostgreSQL数据目录和Redis持久化文件必须用命名卷,避免容器删除后数据丢失。Nginx日志和Spring Boot日志用绑定挂载,方便宿主机直接查看。init-db目录用只读挂载,防止容器内篡改。

健康检查与启动顺序:这是本次重构的重头戏。每个服务定义healthcheck,然后depends_on里用condition: service_healthy替代默认的条件。同时给Spring Boot应用设置restart: on-failure,即使首次启动连接失败也能自动重试。

整体依赖拓扑如下:

nginx → backend(健康检查通过后)→ postgres(健康检查通过后)
                                    → redis(健康检查通过后)

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

直接看代码,这是最终版配置,逐段解释关键部分:

version: "3.9"

services:
  postgres:
    image: postgres:15.4
    container_name: app-postgres
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD:-apppass123}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init-db:/docker-entrypoint-initdb.d:ro
    networks:
      - backend_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 1G

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

  backend:
    build: ./backend
    container_name: app-backend
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-apppass123}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD:-redispass456}
    volumes:
      - ./backend/logs:/app/logs
    networks:
      - frontend_net
      - backend_net
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 30s
    restart: on-failure
    deploy:
      resources:
        limits:
          memory: 1.5G

  nginx:
    image: nginx:1.25.3
    container_name: app-nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/logs:/var/log/nginx
    networks:
      - frontend_net
    depends_on:
      backend:
        condition: service_healthy
    restart: unless-stopped

networks:
  frontend_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/24
  backend_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.29.0.0/24

volumes:
  pgdata:
  redisdata:

这段配置里几个容易被忽视的细节:

  • 密码通过环境变量注入:使用${DB_PASSWORD:-apppass123}语法,开发环境用默认值,生产环境必须显式设置,避免硬编码。
  • init-db挂载为只读:PostgreSQL镜像会自动执行/docker-entrypoint-initdb.d/下的.sql文件,但只读挂载防止运行时被篡改。
  • backend同时挂两个网络:它既要被Nginx访问(frontend_net),又要访问数据库(backend_net),这是跨网络通信的标准做法。

后端镜像的Dockerfile也不复杂,重点是多阶段构建减少体积:

# 构建阶段
FROM maven:3.9.5-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# 运行阶段
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

5. 踩坑记录与优化过程

坑1:healthcheck里的curl不可用
Spring Boot的healthcheck用了curl命令,但我们的基础镜像是eclipse-temurin:17-jre-alpine,Alpine默认不带curl。第一次启动直接报sh: curl: not found。解决方式有两种:改用wget(Alpine自带busybox的wget),或者在Dockerfile里安装curl。我选了wget,避免多装一个包:

healthcheck:
  test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health || exit 1"]

坑2:PostgreSQL的start_period设置过短
首次启动时PostgreSQL需要初始化数据目录,耗时约15-20秒。如果start_period设置为5秒,healthcheck会在初始化完成前就标记失败。调成10秒后,配合retries: 5,基本能覆盖首次启动的慢速窗口。后续重启因为数据目录已存在,启动时间会降到3秒以内。

坑3:网络子网冲突
最初没指定ipam.subnet,Compose自动分配。结果宿主机上另一个VPN占用了172.28.0.0/24网段,导致容器网络启动失败。显式指定子网后问题消失,建议生产环境都手动规划好网段。

优化1:Spring Boot的优雅停机
后端容器停止时,默认SIGTERM后立即退出,可能导致正在处理的请求被中断。在application.yml里加上:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s

配合Compose的stop_grace_period: 30s,确保滚动更新时不丢请求。

优化2:日志轮转
Nginx和Spring Boot的日志都挂到宿主机,如果不处理会无限增长。在宿主机上配置logrotate,按天切割并保留7天:

# /etc/logrotate.d/docker-app
/project/nginx/logs/*.log /project/backend/logs/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
}

6. 效果数据与最终验证

优化后的启动流程实测数据(冷启动,无缓存镜像):

服务 启动耗时 健康检查通过时间
PostgreSQL 18s 22s
Redis 2s 3s
Backend 35s 38s
Nginx 1s 40s(等待后端)

总启动时间从原来的90秒降到45秒,关键改进点在于:

  • 数据库和Redis并行启动,节省约15秒
  • 后端在数据库健康后才启动,避免了重试造成的额外等待
  • 健康检查间隔从默认的30秒缩短到5-15秒,故障发现时间缩短70%

连续10次docker compose up -d --force-recreate测试,全部成功,无一次因依赖未就绪导致崩溃。

最终验证命令:

# 检查所有服务健康状态
docker compose ps
# 输出显示所有服务均为 "healthy" 状态

# 模拟数据库宕机
docker compose stop postgres
# 观察backend在30秒内自动退出,nginx返回502
# 恢复数据库后,backend自动重启并恢复服务

7. 总结与思考

这次重构最大的收获不是配置本身,而是理解了容器编排的状态机思维——容器启动不等于服务就绪,服务就绪不等于可以对外提供服务。healthcheck + depends_on的组合正是把这三个状态串起来的核心手段。

需要特别提醒的是,如果你的项目使用Docker Compose v2,请务必使用version: "3.9"(或省略version字段),因为v2已经不支持旧的version: "2"语法。另外,depends_oncondition字段目前只支持service_startedservice_healthyservice_completed_successfully三种值,不要写其他自定义条件。

最后,这套配置并非银弹。如果你的服务数量超过10个,或者需要跨主机部署,建议升级到Kubernetes或Docker Swarm。但对于中小型项目,Docker Compose加上合理的健康检查,已经能解决90%的编排问题。希望这篇分享能帮你少走一些弯路。