一、问题背景:一次凌晨2点的线上事故

先说说我为什么写这篇博客。上周五凌晨,公司一个内部运营系统突然502,排查发现是PostgreSQL容器因磁盘空间不足被OOM Kill,但Nginx和Spring Boot容器还在运行——它们继续请求一个不存在的数据库,导致大量连接池报错。更麻烦的是,重启数据库后,Spring Boot容器并没有自动恢复连接,整个服务集群需要手动重启。

这个事故暴露了三个问题:

  1. 容器启动顺序完全靠运气:docker-compose up时,Spring Boot经常先于PostgreSQL启动,导致数据源初始化失败。
  2. 容器间通信依赖默认网络:compose默认创建的bridge网络不支持容器名DNS解析(在旧版本中),服务间只能通过IP访问,而IP在容器重建后会变化。
  3. 数据卷权限丢失:PostgreSQL容器重启后,挂载目录的属主变成了root,导致数据库无法写入。

本文就是针对这三个问题的完整解决方案。环境版本:Docker Engine 26.1.1,Docker Compose v2.24.2,宿主机为Ubuntu 22.04 LTS(内核5.15)。

二、方案设计:四层架构与依赖拓扑

先看整体架构,我设计的是一个典型的前后端分离 + 中间件集群:

客户端 → Nginx (端口80/443)
         ├── /api/* → spring-app:8080
         └── /static/* → 直接返回前端静态文件

spring-app (Spring Boot 3.2.5)
         ├── PostgreSQL 15.3 (数据持久化)
         └── Redis 7.2.4 (缓存/Session)

关键设计决策:

  • 使用自定义网络app_net,并设置driver_opts指定子网,这样容器IP固定,服务间通信不依赖DNS。
  • 健康检查不是可选,是必需:PostgreSQL用pg_isready,Redis用redis-cli ping,Spring Boot用wget探测/actuator/health
  • 启动顺序控制depends_on从v2.20开始支持condition: service_healthy,这是解决竞态的关键。

三、核心实现:完整docker-compose.yml

直接上配置,这是我在生产环境验证过的版本,经过了三次迭代优化:

version: "3.8"

networks:
  app_net:
    driver: bridge
    driver_opts:
      com.docker.network.bridge.name: br-app
    ipam:
      config:
        - subnet: 172.28.0.0/16
          gateway: 172.28.0.1

volumes:
  pg_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/postgres  # 宿主机目录,需提前创建并chown 999:999
  redis_data:
    driver: local

services:
  postgres:
    image: postgres:15.3-alpine
    container_name: pg-primary
    restart: unless-stopped
    networks:
      app_net:
        ipv4_address: 172.28.0.10
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./init-sql:/docker-entrypoint-initdb.d:ro
    environment:
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: ${PG_PASSWORD:-app_pass_2024}
      POSTGRES_DB: app_db
      PGDATA: /var/lib/postgresql/data/pgdata
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

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

  spring-app:
    build: ./backend
    image: app-backend:1.0.0
    container_name: spring-api
    restart: unless-stopped
    networks:
      app_net:
        ipv4_address: 172.28.0.12
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://172.28.0.10:5432/app_db
      SPRING_DATASOURCE_USERNAME: app_user
      SPRING_DATASOURCE_PASSWORD: ${PG_PASSWORD:-app_pass_2024}
      SPRING_DATA_REDIS_HOST: 172.28.0.11
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASS:-redis_pass_2024}
      JAVA_OPTS: "-Xms512m -Xmx1g"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  nginx:
    image: nginx:1.26.0-alpine
    container_name: nginx-edge
    restart: unless-stopped
    networks:
      app_net:
        ipv4_address: 172.28.0.13
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./frontend-dist:/usr/share/nginx/html:ro
    depends_on:
      spring-app:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/health"]
      interval: 10s
      timeout: 5s
      retries: 5

这份配置里几个值得注意的细节:

  • 静态IP:每个容器在app_net里绑定固定IP,Spring Boot配置里直接写IP而不是服务名。虽然牺牲了一点灵活性,但换来的是日志里能直接看到实际IP,排查问题快很多。
  • 宿主机目录bind mount:PostgreSQL数据卷特意用type: none + bind方式挂载到/data/postgres,而不是命名卷。原因是命名卷的权限管理在Docker 26里有个坑(下文会讲),bind mount反而更可控。
  • 密码用环境变量注入:所有密码都通过.env文件或shell环境变量传入,绝不写死在配置文件里。

启动命令:

# 先准备宿主机目录
sudo mkdir -p /data/postgres
sudo chown 999:999 /data/postgres  # postgres用户UID是999

# 启动(前台看日志)
docker-compose up -d

# 查看健康状态
docker-compose ps

# 验证服务间连通性
docker exec spring-api ping -c 2 172.28.0.10

四、踩坑与优化:三个真实问题及解法

踩坑1:数据卷权限的"幽灵"问题

第一次部署后,重启PostgreSQL容器,发现数据库无法启动。日志显示:

FATAL:  data directory "/var/lib/postgresql/data/pgdata" has invalid permissions
DETAIL:  Files must be owned by the database user (uid: 999) or root.

排查了半小时,发现是Docker 26.1的命名卷在容器重建时,会把卷的属主重置为root(这是一个已知的bug,与user:配置和COPY --chown的交互有关)。解决方案就是上面配置里的bind mount + 手动chown。如果你还是想用命名卷,可以在docker-compose.yml里加:

services:
  postgres:
    user: "999:999"

但我实测在Docker 26.1.1上,这个配置依然偶发失效,所以最终选择bind mount。

踩坑2:depends_on不是启动顺序的银弹

很多人以为写了depends_on就万事大吉,但直到Compose v2.19之前,depends_on只控制启动顺序,不等待服务"就绪"。也就是说,即使PostgreSQL容器起来了,但pg_isready还没返回成功,Spring Boot照样会连接失败。

正确的做法是用condition: service_healthy,这要求被依赖的服务必须定义healthcheck。而且注意,healthcheckstart_period一定要设置,否则在JVM冷启动期间,健康检查会一直失败,导致Compose认为服务不健康。

踩坑3:Spring Boot的优雅停机与健康检查超时

Spring Boot 3默认的优雅停机时间只有10秒,而我们的应用有定时任务,最长执行时间要15秒。如果健康检查的timeout设得太短,会导致K8s或Compose误判。这里我给出的配置是timeout: 5s,配合start_period: 30s,实测下来基本不会误报。

另外有个优化点:Spring Boot的/actuator/health默认只返回{"status":"UP"},但如果你用了livenessreadiness分组(Spring Boot 2.3+),健康检查应该分别探测这两个端点。我这里简化了,只用单端点。

五、效果数据与验证

部署完成后,我做了一次完整的重启测试,记录关键指标:

  • 启动耗时:从docker-compose up -d到所有容器healthy,实测23.5秒(之前无健康检查时,平均90秒,且经常有服务要手动重启)。
  • 故障恢复:手动docker kill pg-primary,PostgreSQL自动重启后,Spring Boot连接池自动重建,全程无人工干预,服务中断时间约8秒。
  • 资源占用:四个容器总内存占用约1.8GB(Spring Boot 1.2GB + PostgreSQL 450MB + Redis 120MB + Nginx 30MB),在2C4G的云服务器上运行稳定。

验证命令:

# 模拟数据库宕机
docker stop pg-primary
sleep 5
docker start pg-primary
# 观察spring-app日志,确认连接池重建
docker logs spring-api --since 1m | grep -i "reconnect\|pool"

输出示例:

2024-06-01T10:23:45.123Z INFO  - HikariPool-1 - Starting...
2024-06-01T10:23:45.456Z INFO  - HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@4a1b2c3

六、总结与你的下一步

这套配置我已经跑了两个月,期间经历了三次数据库重启、两次Redis切换,都没有再出现服务不可用的问题。核心收获是:

  1. 健康检查必须做,而且depends_on一定要配condition: service_healthy,否则等于没配。
  2. 静态IP比服务名DNS更靠谱,尤其在内网环境,排障时看IP一眼就知道是哪个容器。
  3. 数据卷权限问题在Docker 26.x上是个深坑,bind mount + 手动chown是最稳的方案。

如果你的场景更复杂(比如要加Kafka或Elasticsearch),可以在networks里多加一个子网,或者用external: true接入已有的overlay网络。另外,建议把这份配置纳入Git版本管理,配合.env.example文件,团队协作时减少沟通成本。

最后,如果你在部署中遇到"depends_on条件不生效"的问题,先检查Compose版本:

docker-compose version  # 需要v2.20+

低于这个版本,请升级或用docker compose(插件版)替代。有问题欢迎在评论区交流,我会尽量回复。