一、问题背景:一次凌晨2点的线上事故
先说说我为什么写这篇博客。上周五凌晨,公司一个内部运营系统突然502,排查发现是PostgreSQL容器因磁盘空间不足被OOM Kill,但Nginx和Spring Boot容器还在运行——它们继续请求一个不存在的数据库,导致大量连接池报错。更麻烦的是,重启数据库后,Spring Boot容器并没有自动恢复连接,整个服务集群需要手动重启。
这个事故暴露了三个问题:
- 容器启动顺序完全靠运气:docker-compose up时,Spring Boot经常先于PostgreSQL启动,导致数据源初始化失败。
- 容器间通信依赖默认网络:compose默认创建的bridge网络不支持容器名DNS解析(在旧版本中),服务间只能通过IP访问,而IP在容器重建后会变化。
- 数据卷权限丢失: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。而且注意,healthcheck的start_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"},但如果你用了liveness和readiness分组(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切换,都没有再出现服务不可用的问题。核心收获是:
- 健康检查必须做,而且
depends_on一定要配condition: service_healthy,否则等于没配。 - 静态IP比服务名DNS更靠谱,尤其在内网环境,排障时看IP一眼就知道是哪个容器。
- 数据卷权限问题在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(插件版)替代。有问题欢迎在评论区交流,我会尽量回复。