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_on的condition字段目前只支持service_started、service_healthy和service_completed_successfully三种值,不要写其他自定义条件。
最后,这套配置并非银弹。如果你的服务数量超过10个,或者需要跨主机部署,建议升级到Kubernetes或Docker Swarm。但对于中小型项目,Docker Compose加上合理的健康检查,已经能解决90%的编排问题。希望这篇分享能帮你少走一些弯路。