一、问题背景:单体脚本部署的失控时刻
之前我们的项目部署靠一套Shell脚本,按固定顺序启动PostgreSQL→Redis→后端Jar→Nginx。听起来逻辑清晰,但上线三个月后问题频出:
- 启动时序不可控:PostgreSQL容器起来了,但内部初始化脚本还没跑完,后端已经连上去,直接报
Connection refused。脚本里加sleep 15,结果数据库升级后初始化时间变成20秒,又得改脚本。 - 网络配置靠运气:四个服务用
--link硬关联,IP是动态分配的,后端配置里写死Redis IP,一重启容器IP变了就失联。 - 故障恢复无感知:Redis OOM被内核杀掉,脚本不会自动拉起,前端接口全部超时,直到监控报警才发现。
项目用户量不大,但每个问题都够折腾半小时以上。后来决定全面切换Docker Compose,核心诉求只有一个:让容器编排像写声明式配置一样,描述最终状态,而不是编写启动步骤。
二、环境与版本说明
- 宿主机:Ubuntu 22.04 LTS,内核5.15.0
- Docker Engine:24.0.6(支持
healthcheck的start_period参数) - Docker Compose Plugin:v2.21.0(注意不是
docker-compose独立二进制,而是插件版) - 镜像版本:
postgres:15.3-alpine、redis:7.0.12-alpine、openjdk:17-jdk-slim、nginx:1.25.2-alpine
选alpine变体是刻意的,镜像体积平均减少60%,尤其PostgreSQL从400MB降到200MB左右,内网拉取时间从25秒降到9秒。
三、方案设计:网络隔离 + 卷挂载 + 健康检查三位一体
核心思路分三层:
第一层:自定义网络,强制服务间通过服务名通信。 创建两个网络——frontend和backend。Nginx只挂frontend,后端挂两个网络,数据库和Redis只挂backend。这样即使容器被攻破,横向移动的面也被限制在对应的网络段内。
第二层:命名卷挂载,数据与容器生命周期解耦。 PostgreSQL和Redis数据用命名卷,docker compose down不会删除数据,但docker compose down -v会。Nginx日志用绑定挂载到宿主机/var/log/nginx,方便直接看日志文件。
第三层:healthcheck探针 + 条件依赖,替代sleep硬编码。 这是本次改造的核心。每个服务定义healthcheck命令,depends_on里用condition: service_healthy,Compose会等健康检查通过后才启动依赖服务。
四、核心实现:docker-compose.yml完整配置
直接上配置,这是线上跑了两周的版本,注释标明了关键参数的作用:
version: "3.8"
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
backend:
driver: bridge
ipam:
config:
- subnet: 172.28.1.0/24
volumes:
pg_data:
driver: local
redis_data:
driver: local
services:
postgres:
image: postgres:15.3-alpine
container_name: app-postgres
restart: unless-stopped
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD} # 从.env文件读取,不写入仓库
volumes:
- pg_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro # 首次启动时执行初始化SQL
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: app-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis_data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
backend:
build: ./backend
container_name: app-backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb
SPRING_REDIS_HOST: redis
SPRING_REDIS_PORT: 6379
SPRING_REDIS_PASSWORD: ${REDIS_PASSWORD}
JVM_OPTS: "-Xms512m -Xmx1g -XX:+UseG1GC"
volumes:
- ./logs:/app/logs
networks:
- backend
- frontend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s # Spring Boot启动较慢,给足时间
nginx:
image: nginx:1.25.2-alpine
container_name: app-nginx
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- /var/log/nginx:/var/log/nginx
networks:
- frontend
配套的.env文件(不提交到Git):
DB_PASSWORD=MyStr0ng!Pass
REDIS_PASSWORD=Redis@2024
启动命令:docker compose --env-file .env up -d --build
注意--env-file参数在Compose v2里是显式指定的,如果不加,默认读取同目录.env,但容易和系统环境变量混淆,建议显式指定。
五、踩坑与优化:三个让我熬夜的细节
坑1:健康检查里的start_period不是万能的
第一次配置PostgreSQL健康检查,start_period设为0,结果容器启动花了8秒,pg_isready在容器内还没就绪,直接重试5次(每次间隔5秒),第25秒才标记healthy。而Redis的start_period设为5秒,实际没事。后来把start_period调到10秒,PostgreSQL在10秒内完成了初始化,健康检查一次性通过。经验:start_period要大于镜像首次启动的最慢时间,否则会误判为unhealthy并导致依赖服务延迟启动。
坑2:Nginx反代后端时,proxy_pass的host必须用服务名
Nginx配置里写proxy_pass http://backend:8080;,但backend的容器名是app-backend。在Compose网络里,服务名backend会被解析到容器IP,但如果你在Nginx配置里写了proxy_pass http://app-backend:8080;,虽然容器名也能解析,但一旦容器重建(docker compose up -d --force-recreate),容器名对应的IP会变,而Nginx的DNS缓存不会立即失效,导致502。解法:统一使用服务名(service name),不要用container_name。 我在Nginx配置里全部改用backend,问题消失。
坑3:卷挂载的权限问题
后端镜像用openjdk:17-jdk-slim,容器内用户是root,而宿主机挂载的./logs目录是当前用户(uid=1000)所有。容器启动后写日志报Permission denied。两个解法:一是在Dockerfile里加RUN useradd -m appuser并以USER appuser启动;二是宿主机目录chown 1000:1000。我选了后者,因为开发机的用户uid恰好是1000,生产环境需要统一。建议:生产环境用非root用户运行容器,并在Dockerfile里明确指定uid/gid。
六、效果数据:改造前后的量化对比
改造上线后,我记录了一周的数据(日均请求量约2万,峰时QPS 300):
| 指标 | 改造前(Shell脚本) | 改造后(Compose) |
|---|---|---|
| 全量部署耗时 | 6分钟(含等待) | 40秒(并行构建+启动) |
| 服务依赖就绪时间 | 不可控,偶尔失败 | 确定性:PostgreSQL 12s,Redis 3s |
| 故障恢复 | 手动干预,平均15分钟 | restart: unless-stopped自动拉起,平均30秒 |
| 配置变更部署 | 改脚本,易出错 | 改YAML,docker compose up -d,零停机(Nginx先起) |
最直观的感受:以前凌晨被叫起来处理Redis挂掉,现在docker compose ps看一眼,容器自动重启了,日志显示恢复时间在秒级。
七、总结与建议
Docker Compose不是万能的,但对于中小型项目(服务数量少于10个),它是性价比最高的编排工具。核心价值在于声明式描述依赖关系,让启动顺序从“脚本逻辑”变成“配置事实”。
几点总结:
- 健康检查是编排的基石,没有它,
depends_on只是摆设。建议每个服务都定义,探针命令要简单快速,不要在里面做复杂逻辑。 - 网络划分要趁早,两个网络隔离前后端,比全放一个网络更安全,而且几乎零成本。
- 卷挂载分清楚——数据卷(命名卷)用于持久化,绑定挂载用于日志和配置。不要混用。
- 版本锁定:镜像版本、Compose版本、Docker引擎版本,全部固定,避免“昨天还能跑,今天全挂”的玄学。
如果你还在用Shell脚本编排多个容器,建议花半天时间迁移到Compose。前期配置成本约2小时,但换来的是部署时间的数量级下降和故障恢复的自动化,这笔账怎么算都不亏。
(全文完,代码已脱敏,可直接套用,注意替换密码和路径。)