一、问题背景:微服务编排的“野路子”踩坑记
上个月接手一个老项目,后端是Spring Boot单体应用,前端用Nginx托管静态资源,数据库PostgreSQL,缓存Redis。以前部署全靠shell脚本,每次发布都要手动敲命令,服务启动顺序完全靠“运气”——先启动数据库,再启动后端,最后启动Nginx。但问题是Spring Boot启动时如果连不上数据库,直接抛异常退出,而数据库容器启动到真正可接受连接,往往需要等待5-10秒。
连续两次线上发布失败后,我决定用Docker Compose重构整个部署流程。目标很明确:一条命令完成所有服务的编排、依赖管理和健康检查。
二、环境与版本
先交代一下环境,方便大家对照:
- Docker:20.10.17(宿主机Ubuntu 20.04 LTS)
- Docker Compose:v2.10.2(注意,v2和v1命令有差异)
- 镜像版本:
- nginx:1.24.0-alpine(比官方版小30%,约23MB)
- spring-boot-app:基于openjdk:17-jdk-alpine自定义构建
- postgres:15.3-alpine
- redis:7.0.12-alpine
项目结构:
deploy/
├── docker-compose.yml
├── nginx/
│ └── nginx.conf
└── app/
└── application-prod.yml
三、方案设计:四条核心约束
在设计编排方案时,我给自己定了四条硬性约束:
- 网络隔离:所有服务必须在一个自定义bridge网络中,但Nginx需要对外暴露80端口,其余服务一律不映射宿主机端口。
- 数据持久化:PostgreSQL数据目录和Redis的AOF持久化文件必须用命名volume挂载,避免容器重建导致数据丢失。
- 健康检查:每个服务必须定义healthcheck,不能只看进程是否存活,要检查服务是否真正可用。
- 启动顺序:PostgreSQL和Redis先启动,等健康检查通过后,Spring Boot再启动,最后Nginx。
四、核心实现:docker-compose.yml全解析
直接上完整配置,然后逐段解释关键点。
version: "3.9"
services:
postgres:
image: postgres:15.3-alpine
container_name: prod-postgres
restart: unless-stopped
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
TZ: Asia/Shanghai
volumes:
- pgdata:/var/lib/postgresql/data
- ./backup:/backup
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
redis:
image: redis:7.0.12-alpine
container_name: prod-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redisdata:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
app:
build:
context: ./app
dockerfile: Dockerfile
image: spring-boot-app:latest
container_name: prod-app
restart: unless-stopped
env_file:
- .env
volumes:
- applogs:/app/logs
networks:
- backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
nginx:
image: nginx:1.24.0-alpine
container_name: prod-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./dist:/usr/share/nginx/html:ro
networks:
- backend
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
networks:
backend:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
volumes:
pgdata:
redisdata:
applogs:
1. 网络隔离的实现细节
自定义网络backend,指定了子网172.20.0.0/16。这里有个细节:如果不指定子网,Docker会自动分配,但有时候会和其他VPN网段冲突。指定子网后,容器间通过服务名直接通信,比如Spring Boot的数据库连接地址写jdbc:postgresql://postgres:5432/appdb,而不是IP。
2. 卷挂载的三个层次
- 命名卷:
pgdata、redisdata、applogs,由Docker管理,存放在/var/lib/docker/volumes/下,适合持久化数据。 - bind mount:
./nginx/nginx.conf和./dist,直接映射宿主机目录,适合配置文件和环境相关数据。 - 只读挂载:Nginx配置和静态资源都加了
:ro,防止容器内误操作。
3. 健康检查的姿势
PostgreSQL用pg_isready,Redis用redis-cli ping(注意要带密码,否则报NOAUTH错误),Spring Boot用curl打actuator健康检查端点,Nginx用wget请求一个自定义的/healthz路径。
这里有个坑:Spring Boot镜像基于openjdk:17-jdk-alpine,里面没有curl,需要自己在Dockerfile里装:
# app/Dockerfile
FROM openjdk:17-jdk-alpine
RUN apk add --no-cache curl
COPY target/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]
4. 启动顺序控制的关键
depends_on支持两种条件:service_started(默认,只保证容器启动)和service_healthy(保证健康检查通过)。我用的是后者,确保PostgreSQL完全就绪才启动Spring Boot。
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
五、踩坑与优化:两个真实问题
坑一:健康检查的start_period设置不当
第一次部署时,PostgreSQL的start_period设成了5秒,结果容器启动后还没初始化完,pg_isready就报错,连续重试5次后直接标记为unhealthy,导致Spring Boot一直等待。后来查文档发现,start_period是给容器“预热”的时间,这段时间内健康检查失败不计入重试次数。改成10秒后问题解决。
坑二:Redis密码导致健康检查失败
Redis容器设置了--requirepass,但healthcheck里一开始没带密码,执行redis-cli ping返回NOAUTH Authentication required,被判定为非健康状态。修正后:
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
需要注意的是,redis-cli -a会输出警告日志,不影响功能,如果介意可以在命令后面加2>/dev/null。
优化:启动时间从90秒降到35秒
优化前,Spring Boot启动时总会遇到数据库连接池初始化超时,因为PostgreSQL虽然容器起来了,但还在执行初始化脚本。通过健康检查联动后,Spring Boot启动时数据库已就绪,连接池第一次连接就成功。同时把Java的spring.datasource.hikari.initialization-fail-timeout设成1秒,失败快速重试,而不是默认的30秒超时。
六、效果数据
改造后,我做了三轮验证:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 全栈启动时间 | 90秒(含手动等待) | 35秒(docker compose up -d) |
| 启动成功率 | 70%(依赖数据库状态) | 100%(连续10次验证) |
| 部署命令数 | 8条shell命令 | 1条docker compose命令 |
另外,由于网络隔离做得彻底,后端服务和数据库不暴露宿主机端口,安全扫描通过率从原来的65%提升到92%。
七、总结
Docker Compose编排的核心不是把服务堆在一起,而是处理好三个关系:网络关系(谁和谁能通信)、数据关系(哪些数据要持久化)、时间关系(谁先启动,等待谁)。healthcheck配合depends_on的service_healthy条件,是解决启动顺序问题的标准答案。
最后送大家一句话:能写进docker-compose.yml的,就不要写在shell脚本里。配置即代码,可审查、可回滚、可复用,这才是容器化部署的正确打开方式。
如果你也在用Docker Compose编排多服务,欢迎在评论区分享你的踩坑经历。