一、问题背景:手工部署的痛与容器化的转机

上个月接手一个老旧项目,四个服务(Nginx前端、Spring Boot后端、PostgreSQL数据库、Redis缓存)散落在三台服务器上,每次发版都要SSH上去手动启停。最崩溃的一次是凌晨两点,因为忘了先启动数据库导致后端服务启动失败,排查了整整一小时。

痛定思痛,决定用Docker Compose把这套东西统一编排起来。要求只有三个:第一,一条命令能启动整个项目;第二,服务间启动顺序要有保障;第三,日志和配置不能因为容器重建而丢失。

这篇文章记录的就是最终的落地方案,包含了网络规划、健康检查、卷挂载这些关键配置,以及调试过程中踩过的几个坑。

二、环境与版本

先交代一下运行环境,避免版本差异导致的配置不兼容:

  • 宿主机:Ubuntu 22.04 LTS,内核版本 5.15.0-89-generic
  • Docker Engine:26.1.4(通过apt安装,非snap版)
  • Docker Compose:v2.24.2(docker compose 插件形式,不是docker-compose)
  • 项目技术栈:Spring Boot 3.2.5 + PostgreSQL 15.3 + Redis 7.2.4 + Nginx 1.25.4

特别提醒:如果你还在用 docker-compose(带横杠的Python版),建议尽早迁移到 docker compose(插件版)。新版Compose支持 depends_on 中嵌套 condition 条件判断,这直接决定了健康检查能否作为启动顺序的依据。

三、方案设计:网络隔离 + 依赖控制 + 数据持久化

整体设计思路分三层:

网络层:创建一个自定义bridge网络,分配 172.28.0.0/24 子网。四个服务固定IP,其中Nginx映射到宿主机80/443端口,其他服务仅内网访问,不暴露端口到宿主机。这样既保证了服务间通信的稳定性(固定IP),又减少了攻击面。

启动顺序层:通过 depends_oncondition: service_healthy 来控制。PostgreSQL和Redis先启动并完成健康检查,Spring Boot确认数据库和缓存就绪后才启动,Nginx最后启动并等待后端服务就绪。

数据持久化层:使用命名卷(named volume)保存PostgreSQL的数据文件,使用bind mount把Nginx日志和后端日志映射到宿主机指定目录。

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

直接上配置文件,这是整个方案的核心。我删掉了敏感的业务配置,保留了所有关键结构。

version: "3.8"

networks:
  app-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/24
          gateway: 172.28.0.1

volumes:
  postgres-data:
    driver: local

services:
  postgres:
    image: postgres:15.3-alpine
    container_name: app-postgres
    restart: unless-stopped
    networks:
      app-network:
        ipv4_address: 172.28.0.10
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./postgres/init:/docker-entrypoint-initdb.d:ro
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD:-app_pass_2024}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7.2.4-alpine
    container_name: app-redis
    restart: unless-stopped
    networks:
      app-network:
        ipv4_address: 172.28.0.11
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redis_pass_2024}"]
    volumes:
      - ./redis/data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_pass_2024}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: app-backend:1.0.0
    container_name: app-backend
    restart: unless-stopped
    networks:
      app-network:
        ipv4_address: 172.28.0.12
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://172.28.0.10:5432/appdb
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-app_pass_2024}
      SPRING_DATA_REDIS_HOST: 172.28.0.11
      SPRING_DATA_REDIS_PORT: 6379
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_pass_2024}
    volumes:
      - ./logs/backend:/app/logs
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

  nginx:
    image: nginx:1.25.4-alpine
    container_name: app-nginx
    restart: unless-stopped
    networks:
      app-network:
        ipv4_address: 172.28.0.13
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./logs/nginx:/var/log/nginx
    depends_on:
      backend:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3

五、踩坑与优化:三个真实案例

坑一:healthcheck中curl不存在

给Spring Boot容器写健康检查时,一开始用的是 curl -f http://localhost:8080/actuator/health。结果容器启动后一直显示unhealthy,排查发现基于 eclipse-temurin:17-jre-alpine 的基础镜像里根本没有curl命令。

解决方案有两种,一是换用基础镜像中的 wget,二是直接安装curl。我最终选择了在Dockerfile里加一行 RUN apk add --no-cache curl,这样可以继续使用curl,因为后续调试时curl比wget更顺手。

# backend/Dockerfile 关键片段
FROM eclipse-temurin:17-jre-alpine
RUN apk add --no-cache curl
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

坑二:depends_on只等待容器启动,不等待服务就绪

这是Compose最经典的坑。早期的 depends_on 只能保证容器创建顺序,Spring Boot启动只要3秒,但PostgreSQL初始化可能需要10秒以上。如果后端先启动,连接数据库必然失败。

新版Compose的 condition: service_healthy 解决了这个问题。但有个细节需要注意:healthcheck的 start_period 参数要合理设置。PostgreSQL首次初始化数据目录需要时间,我设了30秒的 start_period,如果在这期间健康检查失败不会计入重试次数,避免误判。

坑三:日志文件权限混乱

把宿主机目录挂载进容器后,Nginx和Spring Boot写的日志文件属主是容器内的用户(通常uid是1000或101),宿主机上用普通用户查看日志非常别扭。

优化方案是在Compose配置里指定用户映射,或者用 user 参数统一uid。我最终在Nginx服务里加了:

user: "101:101"  # 对应宿主机www-data用户的uid/gid

后端服务则因为Java进程需要写文件,保持了默认,但宿主机上创建了 /data/logs/backend 目录并 chown -R 1000:1000,保证两边一致。

六、效果数据:启动时间与稳定性对比

配置完成后,我做了两组对比测试。

启动时间对比(同一台机器,冷启动,清空所有容器和卷):

部署方式 全流程启动时间 操作步骤数
手工部署 8分15秒 12步
Docker Compose 45秒 1步

稳定性数据(运行两周,每天模拟一次容器杀掉重启):

  • 容器异常重启次数:从手工部署时的每周3-5次,降至0次(自动重启策略生效)
  • 因依赖启动顺序导致的服务启动失败:从每月2-3次,降为0次
  • 日志查询效率:因为日志统一落盘到宿主机 /data/logs,用grep检索无需进入容器,单次排查时间从约10分钟缩短至1分钟

还有一个意外收获:因为PostgreSQL数据放在了命名卷里,升级数据库镜像版本时直接 docker compose up -d 就能完成,数据不会丢失。之前手工升级数据库,备份、导出、导入、验证一整套流程至少要半天。

七、总结:该用什么、不推荐什么

这套方案用了三周,整体稳定。总结下来几个要点:

  1. 固定IP比服务名好用:虽然Compose内置DNS支持服务名互访,但固定IP在排查问题时更直观,netstat一眼就能看出谁在跟谁通信。
  2. 健康检查是刚需:没有健康的依赖控制,容器编排就是碰运气。start_period 参数一定要根据服务实际启动时间调整,别用默认值。
  3. 卷映射要提前规划:日志、配置、数据目录分开管理,别都堆在容器里。容器本身应该是无状态的,重建不丢数据是底线。

最后说一句不推荐的做法:不要用 depends_onservice_started 模式,除非你的服务对启动顺序完全不敏感。我见过有人靠 sleep 30 硬等,这是最脆弱的方案,没有之一。

这套配置已经提交到团队内部仓库,有需要可以直接抄作业。如果你也遇到过类似的编排问题,欢迎在评论区交流。