一、问题背景:手工启动服务,我被依赖关系折磨了一下午
接手公司一个电商后台项目,架构不复杂:Spring Boot写的API服务、React构建的Nginx前端、Redis做缓存、PostgreSQL存订单、RabbitMQ处理异步任务。五个服务,听起来不多,但每次环境搭建都要命。
第一次部署,我按文档顺序手工启动:先PostgreSQL,再Redis,再RabbitMQ,然后启动API,最后Nginx。结果API启动时报错——数据库连接池初始化失败,因为PostgreSQL虽然进程起来了,但还没准备好接受连接。重启API,又发现RabbitMQ的队列没声明。反复折腾,一次完整启动花了15分钟,中间各种超时、重试、手动kill进程。
更要命的是,某个同事改了Redis密码配置,我本地没同步,API连不上缓存,整个登录功能挂掉。这时候我意识到,没有统一的编排工具,多服务环境就是一场灾难。
二、环境与版本:我的技术栈基线
先说清楚环境,避免读者踩版本坑:
- Docker Engine:24.0.7(Linux内核5.15)
- Docker Compose:v2.24.2(重点:v2和v1的语法有差异,本文全部基于v2)
- 宿主机:Ubuntu 22.04 LTS,4核8G
- 项目服务清单:
| 服务 | 镜像版本 | 端口 | 说明 |
|---|---|---|---|
| api | openjdk:17-jdk-alpine | 8080 | Spring Boot 3.1.5 |
| web | nginx:1.25.3 | 80 | 前端静态资源+反向代理 |
| redis | redis:7.2.3 | 6379 | 缓存+Session共享 |
| db | postgres:16.1 | 5432 | 主数据库 |
| mq | rabbitmq:3.12-management | 5672/15672 | 消息队列+管理界面 |
代码仓库里原本只有Dockerfile,没有编排文件。我需要在项目根目录新增docker-compose.yml。
三、方案设计:网络隔离、卷持久化、健康检查、启动顺序四件套
设计思路,我按生产环境标准来,不搞本地开发那种裸奔配置:
1. 自定义网络划分
所有服务放在同一个bridge网络app_network内,但为了安全,把数据库和消息队列隔离在db_network内层网络,只有API能访问。前端Nginx只需要和API通信,Redis只被API使用。网络隔离的好处是:即使某个容器被攻破,攻击面也受限。
2. 命名卷挂载
PostgreSQL的数据目录、RabbitMQ的消息存储、Redis的持久化文件,全部用命名卷(named volume),不搞bind mount。原因:bind mount依赖宿主机目录结构,换机器就挂;命名卷由Docker管理,docker compose down不会删除数据,备份迁移也方便。
3. 健康检查(healthcheck)
每个依赖服务都配healthcheck,不信任“进程起来了就是健康”。PostgreSQL用pg_isready,Redis用redis-cli ping,RabbitMQ用rabbitmq-diagnostics -q ping。API服务本身也配健康检查,这样Nginx才能知道后端是否可用。
4. 启动顺序控制
Compose v2的depends_on不再只是等待容器启动,配合condition: service_healthy可以做到“等待健康检查通过后才启动下游”。这是解决我最初手工启动崩溃问题的关键。
四、核心实现:docker-compose.yml逐段拆解
直接上完整配置,文件放在项目根目录,所有服务共享同一个.env文件管理环境变量。先看网络和卷的定义:
# docker-compose.yml (第一部分:网络与卷)
version: "3.8"
networks:
app_network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
db_network:
driver: bridge
internal: true # 关键:禁止外部访问,仅容器间通信
volumes:
postgres_data:
driver: local
rabbitmq_data:
driver: local
redis_data:
driver: local
services:
# ...服务定义见下文
注意db_network的internal: true,这意味着外部宿主机无法直接访问数据库端口,只能通过API容器中转。这是安全加固的重要一步。
接下来是完整服务定义:
# docker-compose.yml (第二部分:服务编排)
services:
db:
image: postgres:16.1
container_name: ecommerce-db
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- db_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
redis:
image: redis:7.2.3
container_name: ecommerce-redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- app_network
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 10
mq:
image: rabbitmq:3.12-management
container_name: ecommerce-mq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
networks:
- db_network
- app_network # API需要访问,但管理端口不暴露
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
api:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ecommerce-api
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
mq:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME}
SPRING_DATASOURCE_USERNAME: ${DB_USER}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
SPRING_REDIS_HOST: redis
SPRING_REDIS_PASSWORD: ${REDIS_PASSWORD}
SPRING_RABBITMQ_HOST: mq
SPRING_RABBITMQ_USERNAME: ${RABBITMQ_USER}
SPRING_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
ports:
- "8080:8080"
networks:
- app_network
- db_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
web:
image: nginx:1.25.3
container_name: ecommerce-web
restart: unless-stopped
depends_on:
api:
condition: service_healthy
volumes:
- ./frontend/dist:/usr/share/nginx/html:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
ports:
- "80:80"
networks:
- app_network
有几个细节必须强调:
第一,API镜像的构建上下文。 build.context指向./backend,Dockerfile里多阶段构建,从maven镜像编译jar再拷贝到jre镜像。但这里有个坑:Compose构建时不会自动读取.dockerignore,如果backend目录里有target/或.git,构建上下文会巨大,构建时间从30秒飙到3分钟。我在backend/.dockerignore里加了target、.git、*.log,构建时间降回35秒。
第二,depends_on的condition语法。 这是Compose v2的核心特性。如果你用的还是depends_on: - db这种列表写法,那只是等待容器启动(不健康也会启动),必须用上面的映射写法才能等到健康检查通过。
第三,.env文件。 所有密码、用户名通过项目根目录.env注入,不硬编码在yml里。.env文件内容示例:
DB_USER=ecommerce
DB_PASSWORD=Str0ngP@ssw0rd!
DB_NAME=order_db
REDIS_PASSWORD=r3d1sSecret
RABBITMQ_USER=admin
RABBITMQ_PASSWORD=rabbitPass
五、踩坑记录:三个教训让我改了三次配置
坑1:健康检查的start_period设太短。
一开始PostgreSQL的start_period设了5秒,interval设3秒。结果本地机器IO慢,PostgreSQL初始化数据目录花了8秒,10次重试全失败,API一直等不到service_healthy,整个堆栈卡在启动中。日志里全是“Container ecommerce-db is unhealthy”。
解决:start_period改成10秒,retries改成20,给足冷启动时间。实际上PostgreSQL首次初始化要跑initdb,耗时6-15秒不等,机械硬盘更慢。
坑2:RabbitMQ的healthcheck用ping命令,但容器里没有bash。
RabbitMQ镜像基于Alpine,默认shell是/bin/sh,不是bash。我最初写test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]就没问题,但有人会写成test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]——这其实是数组形式的exec调用,不经过shell,反而更安全。关键坑点是:rabbitmq-diagnostics命令在容器启动后需要几秒才能就绪,如果start_period设太短,前几次探测会误报失败。我最终设了30秒。
坑3:内部网络internal: true导致API容器无法访问外网。
db_network设了internal: true,这没错。但API容器同时挂了app_network和db_network两个网络,而app_network没有设internal,所以API可以正常访问外网(比如调用第三方支付接口)。但如果你误把app_network也设成internal,API会连Maven仓库都访问不了,导致启动时拉取依赖失败。
检查方法:docker network inspect ecommerce_app_network查看Internal字段是否为false。
六、效果数据:启动时间从10分钟到45秒
改造完成后,我做了三轮对比测试,同一台机器,冷启动(docker compose down后再up):
| 场景 | 手工启动 | Docker Compose |
|---|---|---|
| 首次冷启动(无镜像缓存) | 10分25秒 | 45秒(含镜像拉取) |
| 二次启动(有缓存) | 6分30秒 | 28秒 |
| 失败自动恢复 | 无此功能 | 健康检查失败后自动重启,平均恢复时间8秒 |
| 数据持久化 | 手动备份 | 命名卷自动管理,down不丢数据 |
实际运行中,某次PostgreSQL突然OOM被kill,Compose检测到容器退出,因为restart: unless-stopped自动重启,同时健康检查发现数据目录完好,30秒内服务恢复,API无需重启,用户无感知。
还有一个隐性的收益:新同事加入项目,只需要装Docker和Compose,然后cp .env.example .env && docker compose up -d,5分钟环境就绪。以前要读10页文档配环境,现在一个命令搞定。
七、总结:Compose编排不是写yaml,是设计系统
经过这次重构,我深刻体会到,docker-compose.yml不是简单的服务清单,而是对整个系统依赖关系、容错策略、安全边界的抽象。几个核心心得:
- 健康检查是编排的基石——没有它,
depends_on只是摆设,容器启动顺序依然会乱。 - 网络分区要按信任级别划分——数据库和消息队列放内部网络,API做唯一入口,前端只暴露80端口。
- 命名卷是生产环境的底线——bind mount在本地开发可以,但生产环境一定要用命名卷,不然容器重建就是数据灾难。
- 版本不要追新,要稳定——Docker Compose v2.24这个版本我用了三个月没出问题,别急着升到v2.26,新特性往往伴随新bug。
最后给个建议:如果你的服务超过3个,别再手工启动了,花半小时写个compose文件,省下的时间够你喝一年咖啡。踩坑的细节我都写在上面了,照着配,基本一次过。