一、问题背景:手工部署的混乱与痛点
上个月我们团队接手了一个遗留电商系统,包含4个服务:前端Nginx、两个Spring Boot应用(订单服务和用户服务)、PostgreSQL数据库、Redis缓存。原先的部署方式是运维手工执行20多条shell命令,每次发版都要对着文档敲命令,经常出现以下问题:
- 数据库还没初始化完,后端服务就开始连接,导致启动失败
- 服务间通过localhost通信,换台机器就要改配置
- 日志散落在各个容器里,排查问题时需要挨个docker logs -f
- 没有统一的网络隔离,开发环境、测试环境互相干扰
最离谱的是上个月一次发布,因为Redis先启动了而PostgreSQL还在初始化,订单服务重试了8次全部失败,最后人工介入花了40分钟才恢复。这让我下定决心用Docker Compose彻底重构部署流程。
二、环境与版本
先交代一下我们的基础环境,方便大家对照:
- 操作系统:Ubuntu 22.04 LTS(内核5.15.0)
- Docker Engine:24.0.7
- Docker Compose:v2.24.2(注意,我们用的是v2语法,不是老式的docker-compose)
- 镜像版本:nginx:1.25.3-alpine、openjdk:17-jdk-slim、postgres:15.4-alpine、redis:7.2-alpine
之所以选alpine版本,是因为我们压测过,同样的业务逻辑下,alpine镜像比ubuntu镜像体积小约67%,启动速度快28%(实测数据:订单服务从冷启动到接受请求,ubuntu版需要8.3秒,alpine版只需要5.9秒)。
三、方案设计:网络拓扑与数据持久化
我们采用以下设计思路:
-
网络规划:创建一个自定义bridge网络
app_net,子网172.28.0.0/24。让Nginx暴露80端口到宿主机,其余服务全部走内网IP,不暴露宿主机端口。这样外部只能访问80端口,数据库和Redis不直接暴露,减少攻击面。 -
卷挂载策略:
- PostgreSQL数据目录挂载到宿主机
/data/postgres,防止容器删除后数据丢失 - 后端服务日志挂载到
/data/logs/{service_name},宿主机直接查看 -
Nginx配置文件和静态资源用bind mount方式,方便热更新
-
健康检查:每个服务配置
healthcheck指令,PostgreSQL和Redis用内置命令检查,后端服务用Spring Boot Actuator暴露的/actuator/health端点检查HTTP状态码。 -
启动顺序:通过
depends_on配合condition: service_healthy,确保严格按依赖顺序启动,而不是简单按声明顺序。
四、核心实现:完整的docker-compose.yml
下面是我们的生产环境配置(已脱敏),直接可运行:
version: "3.8"
networks:
app_net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
gateway: 172.28.0.1
volumes:
postgres_data:
driver: local
driver_opts:
type: none
o: bind
device: /data/postgres
order_logs:
driver: local
driver_opts:
type: none
o: bind
device: /data/logs/order-service
user_logs:
driver: local
driver_opts:
type: none
o: bind
device: /data/logs/user-service
services:
# 1. PostgreSQL - 数据层
postgres:
image: postgres:15.4-alpine
container_name: shop_postgres
restart: always
environment:
POSTGRES_USER: shop_admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: shop_db
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
app_net:
ipv4_address: 172.28.0.10
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop_admin -d shop_db"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
# 2. Redis - 缓存层
redis:
image: redis:7.2-alpine
container_name: shop_redis
restart: always
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis_data:/data
networks:
app_net:
ipv4_address: 172.28.0.11
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
# 3. 订单服务 - 业务层
order-service:
image: registry.internal/shop/order-service:2.3.1
container_name: shop_order
restart: always
environment:
SPRING_PROFILES_ACTIVE: prod
DB_URL: jdbc:postgresql://172.28.0.10:5432/shop_db
REDIS_HOST: 172.28.0.11
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- order_logs:/app/logs
networks:
app_net:
ipv4_address: 172.28.0.20
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: 20s
# 4. 用户服务 - 业务层
user-service:
image: registry.internal/shop/user-service:1.8.0
container_name: shop_user
restart: always
environment:
SPRING_PROFILES_ACTIVE: prod
DB_URL: jdbc:postgresql://172.28.0.10:5432/shop_db
REDIS_HOST: 172.28.0.11
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- user_logs:/app/logs
networks:
app_net:
ipv4_address: 172.28.0.21
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: 20s
# 5. Nginx - 网关层
nginx:
image: nginx:1.25.3-alpine
container_name: shop_nginx
restart: always
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/static:/usr/share/nginx/html:ro
networks:
app_net:
ipv4_address: 172.28.0.30
depends_on:
order-service:
condition: service_healthy
user-service:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
对应的.env文件(敏感信息走环境变量,不写死在yml里):
DB_PASSWORD=StrongPass#2024
REDIS_PASSWORD=Redis@Secure#888
启动命令就一条:docker compose up -d --build。停止命令:docker compose down。升级时改镜像tag后执行docker compose pull && docker compose up -d。
五、踩坑记录:三个值得注意的细节
坑1:健康检查的start_period必须合理设置
最初配置PostgreSQL健康检查时没设置start_period,结果容器启动后5秒内还没准备好,pg_isready直接返回错误,Compose判定为不健康,导致依赖它的服务一直等待。后来加上start_period: 10s,给数据库留出初始化时间。注意这个参数是v2.24版本才稳定支持的,之前版本可能被忽略。
坑2:固定IP地址在重启后可能冲突
我们给每个服务指定了静态IP,比如PostgreSQL的172.28.0.10。但有一次我手动删除了网络docker network rm后重建,发现IP分配冲突,两个容器拿到了同一个IP。解决办法是:不要手动删除网络,让Compose管理网络生命周期。如果必须重建,先docker compose down再docker compose up。
坑3:depends_on的condition在旧版本Compose中不生效
我们刚开始用的是Docker Compose v1.x,condition: service_healthy会被静默忽略,导致启动顺序无效。升级到v2.24.2后问题解决。如果你还在用docker-compose(带连字符的老版本),赶紧迁移到docker compose(v2插件版)。
另一个性能优化:两个后端服务的JVM参数统一在Dockerfile里设置-Xms512m -Xmx1g,避免容器内默认堆内存过大。我们生产机器是4核8G,这套配置跑下来内存占用峰值约4.2G,CPU空闲时约15%。
六、效果数据与对比
改造完成后,我们做了一组对比测试,结果如下:
| 指标 | 手工脚本部署 | Docker Compose编排 |
|---|---|---|
| 全量启动时间 | 4分32秒 | 58秒(缩短78.6%) |
| 故障恢复时间 | 平均22分钟 | 平均3分钟(缩短86.4%) |
| 配置错误率 | 每月约3次 | 0次 |
| 新环境部署 | 40分钟+手动调参 | 5分钟 |
具体到一次PostgreSQL宕机恢复场景:手工部署时,运维需要先重启数据库,再手动重启两个后端服务,期间Nginx会返回502。现在只需docker compose restart postgres,后端服务通过健康检查自动等待数据库就绪后重连,全程业务中断时间从15分钟降到2分钟以内。
日志排查效率也提升明显:现在直接tail -f /data/logs/order-service/order.log就能看宿主机日志,不用再进容器。配合docker compose logs -f --tail=200可以同时跟踪所有服务输出,开发调试时开两个终端窗口非常方便。
七、总结与建议
这套方案我们已经稳定运行了3个月,经历了3次版本发布和2次故障演练。核心收益是:可重复性——任何环境执行同样的命令都能得到一致的结果;可观测性——健康检查状态一目了然;可控性——启动顺序严格按依赖关系执行。
给读者几点建议:
- 如果你的服务少于3个,可能不需要上Compose,直接docker run就行。但一旦超过3个且存在依赖关系,Compose的收益是指数级增长。
- 健康检查的
start_period一定要根据服务实际启动时间调整,宁可多给几秒,不要吝啬。 - 生产环境强烈建议给每个服务分配固定IP,配合防火墙规则可以精确控制网络访问。但注意不要手动删除Compose管理的网络。
- 敏感信息一律走
.env文件,并且把.env加入.gitignore。我们吃过亏,有一次把数据库密码提交到了代码仓库,最后不得不轮换所有凭据。
最后,别把docker-compose.yml写得过于复杂。我们团队的原则是:能用一个service解决的不用两个,能用环境变量配置的不用单独文件。配置文件越简单,维护成本越低,出问题的概率越小。