一、问题背景:手工docker run已经失控了
上个月接手一个老项目,架构不复杂:Nginx做反向代理,一个Spring Boot应用提供REST API,后端挂PostgreSQL和Redis。但部署方式极其原始——四个docker run命令,每个容器单独启动,靠--link参数互相连接。
我接手后的第一个需求是加一个定时任务模块,需要再起一个Spring Boot实例。这意味着我要手动管理5个容器的启动顺序、网络别名、数据卷路径。更头疼的是,每次宿主机重启后,PostgreSQL容器的IP会变,Spring Boot配置文件里的spring.datasource.url就得改。
这种部署方式的致命缺陷在某个周五下午彻底爆发:我重启了Redis容器,结果Spring Boot连接池里的旧连接没失效,整个服务挂了40分钟。老板站在我身后看我用docker inspect查IP,那感觉别提了。
这次事故之后我决定彻底重构部署方案,核心目标有三个:
1. 一条命令启动全部服务,且能保证PostgreSQL和Redis先于Spring Boot就绪
2. 容器间通过服务名互相访问,彻底抛弃IP地址
3. 数据持久化到宿主机,容器随便删,数据不能丢
二、环境与版本:别用太老的版本
先说下我这边的运行环境,版本差异会导致配置语法不同,这是很多教程没讲清楚的:
宿主机:Ubuntu 22.04.3 LTS
Docker Engine:26.1.3
Docker Compose:v2.24.2(重要!旧版v1不支持depends_on的condition语法)
服务镜像版本:
nginx:1.25.3-alpine
postgres:16.1-alpine
redis:7.2.3-alpine
spring-boot镜像:基于eclipse-temurin:17-jdk构建,tag为1.0.0
如果你还在用docker-compose v1(Python写的那个),请立刻升级。v2是Go写的,性能和语法支持都更好。检查版本用docker compose version,注意中间有空格,不是docker-compose。
三、方案设计:网络、卷、健康检查三位一体
我的设计思路分三层:
第一层:网络隔离。创建一个自定义bridge网络app-network,所有服务都挂在这个网络上。好处是:服务名即DNS,postgres这个容器名在整个网络内可以直接当hostname用;而且这个网络与宿主机隔离,外部只能通过Nginx的80端口访问。
第二层:数据持久化。PostgreSQL和Redis的数据必须落在宿主机上。我用了Bind Mount方式,直接映射到宿主机特定目录。为什么不建议用named volume?因为named volume在docker compose down -v时会被删除,而Bind Mount只要不手动删目录,数据永远在。
第三层:启动顺序控制。这才是最关键的。Spring Boot连接PostgreSQL和Redis,如果数据库没起来就启动应用,连接池初始化会直接失败(除非你配置了retry)。我用了完整的depends_on条件控制:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
这个配置的意思是:只有当postgres和redis的healthcheck返回healthy状态后,才会启动springboot容器。这就彻底解决了启动顺序问题。
四、核心实现:完整的docker-compose.yml
下面是我项目里实际使用的配置,为了脱敏改了一些密码和IP,但结构完全一致:
version: "3.8"
networks:
app-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
postgres_data:
driver: local
redis_data:
driver: local
services:
# ---------- Nginx 反向代理 ----------
nginx:
image: nginx:1.25.3-alpine
container_name: gateway-nginx
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
networks:
- app-network
depends_on:
springboot:
condition: service_started
restart: unless-stopped
# ---------- Spring Boot 应用 ----------
springboot:
image: myapp/server:1.0.0
container_name: app-server
expose:
- "8080"
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mydb
SPRING_DATASOURCE_USERNAME: app_user
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
TZ: Asia/Shanghai
volumes:
- ./logs/app:/logs
- /etc/localtime:/etc/localtime:ro
networks:
- app-network
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: 5
start_period: 30s
restart: unless-stopped
# ---------- PostgreSQL 数据库 ----------
postgres:
image: postgres:16.1-alpine
container_name: db-postgres
environment:
POSTGRES_DB: mydb
POSTGRES_USER: app_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-sql:/docker-entrypoint-initdb.d:ro
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d mydb"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# ---------- Redis 缓存 ----------
redis:
image: redis:7.2.3-alpine
container_name: cache-redis
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- redis_data:/data
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
注意几个细节:
- springboot服务我用的是expose而不是ports,因为不需要直接暴露给宿主机,只有Nginx需要访问它
- Nginx的depends_on只用了service_started,因为Nginx启动后如果后端没起来,它只会报502但不会退出,所以不需要等healthy
- 密码用了环境变量${DB_PASSWORD},通过.env文件注入,避免明文写在yml里
- PostgreSQL的pg_isready命令必须指定用户名和数据库名,否则即使服务起来了但库还没创建好,也会误报healthy
启动命令就一行:
# 在项目根目录,确保.env文件存在
docker compose up -d --build
查看启动状态:
docker compose ps
输出类似这样:
NAME IMAGE STATUS
gateway-nginx nginx:1.25.3-alpine Up 2 minutes (healthy)
app-server myapp/server:1.0.0 Up 2 minutes (healthy)
db-postgres postgres:16.1-alpine Up 2 minutes (healthy)
cache-redis redis:7.2.3-alpine Up 2 minutes (healthy)
五、踩坑与优化:这些坑你们别再踩了
坑1:healthcheck的start_period必须设置。Spring Boot应用启动需要大约20秒(JVM加载+Spring上下文初始化),如果start_period不设置,healthcheck会从容器启动那一刻就开始检测。前20秒内检测失败5次,容器直接标记为unhealthy,然后depends_on的Spring Boot服务判断postgres不健康,一直等待。最后整个compose卡死。
坑2:pg_isready的陷阱。PostgreSQL的官方healthcheck一般推荐:
test: ["CMD-SHELL", "pg_isready -U postgres"]
但我的场景里用户是app_user,数据库是mydb。如果执行pg_isready -U app_user,返回的是accepting connections,但实际上mydb这个库可能还没创建完成。解决方式是显式指定数据库名。
坑3:Redis的healthcheck带密码会泄露。redis-cli -a ${REDIS_PASSWORD} ping这个命令会在docker inspect里看到明文密码。我临时用着,生产环境建议用--no-auth-warning参数抑制警告,或者更优雅的方式是挂载redis.conf文件来配置密码,不通过命令行传参。
坑4:Nginx首次启动后连不上Spring Boot。这是因为Nginx配置里我写了proxy_pass http://springboot:8080,但Nginx的worker进程启动时解析了DNS,如果当时Spring Boot还没起来,解析失败后就缓存了失败结果。解决方案是在nginx.conf里加resolver 127.0.0.11 valid=10s,让Nginx使用Docker内置DNS服务器,并每10秒重新解析。
优化1:日志轮转。Spring Boot的日志我挂载到./logs/app,如果不处理会无限增长。我在logback.xml里配置了SizeAndTimeBasedRollingPolicy,每天一个文件,单个超过100MB自动切割。
优化2:.env文件管理环境差异。根目录的.env文件内容示例:
DB_PASSWORD=YourStrongPass123
REDIS_PASSWORD=RedisPass456
COMPOSE_PROJECT_NAME=myapp
不同环境(开发、测试、生产)维护不同的.env文件,启动时通过--env-file指定:
docker compose --env-file .env.prod up -d
六、效果数据:重构后的真实提升
这次重构的效果是立竿见影的。我做了个简单的时间对比:
| 操作 | 重构前(docker run) | 重构后(docker compose) |
|---|---|---|
| 首次全量部署 | 12分钟(手动敲4条命令+改配置) | 3分40秒(含构建镜像) |
| 日常重启 | 5分钟(手动启停+检查IP) | 35秒(docker compose restart) |
| 添加新节点 | 需要手动配置网络和link | 修改yml加一个service即可 |
| 数据迁移 | 需要手动备份和恢复 | docker compose down不删卷,数据保留 |
还有一个我没预料到的收益:因为healthcheck的存在,我现在能通过docker compose ps一眼看到所有服务的健康状态。之前Spring Boot假死(进程在但请求超时)的情况,现在healthcheck能检测到并自动重启容器。
对了,我还在CI/CD流水线里加了docker compose config --quiet这一行来校验yml语法,避免语法错误导致发布失败。
七、总结
Docker Compose解决的不只是“少打几条命令”的问题,它把服务编排变成了代码,可以版本化、可审查、可回滚。这次重构个人觉得最值的三个决策是:
- 自定义网络替代--link。服务名即主机名,彻底摆脱IP依赖
- healthcheck + depends_on condition。用声明式的方式解决了服务依赖顺序
- Bind Mount + named volume分离。配置文件用Bind Mount(因为要改),数据用volume(因为不能丢)
最后送大家一句话:如果你还在为一个项目手动管理3个以上容器,别犹豫了,今天就把docker-compose.yml写了吧。你不用把所有功能都学完,只需要把网络、卷、健康检查这三个点用熟,就能解决80%的实际问题。
有什么问题欢迎评论区交流,我知道的都会回复。