一、背景:当Shell脚本撑不住的时候
上季度我们团队接手了一个电商后台系统,包含网关、用户服务、订单服务、MySQL和Redis五个组件。最初用Shell脚本按顺序启动,每次发版都要人工盯着日志,Redis没起来MySQL就先连了,MySQL没准备好Spring Boot就启动失败——这种竞态问题在测试环境一周能碰到三次。更头疼的是,不同服务对网络配置、日志路径各有要求,五台开发机的环境差异导致“在我这能跑”成为口头禅。
后来我们决定迁移到Docker Compose。目标很明确:一套配置到处跑,启动顺序可靠,依赖关系清晰。但这套方案落地过程中,我们踩了不少坑——比如healthcheck不能乱写、depends_on的condition在旧版本Compose里不支持、named volume和bind mount的权限问题。这篇文章会把最终可用的配置拆开揉碎讲清楚。
二、环境与版本
我们最终锁定的版本组合(2024年3月验证通过):
- Docker Engine:24.0.7(含containerd 1.7.11)
- Docker Compose:v2.24.2(使用
docker compose子命令,非docker-compose) - 基础镜像:
nginx:1.25.3-alpine、eclipse-temurin:17-jdk-alpine、postgres:16.1-alpine、redis:7.2-alpine - 宿主机:Ubuntu 22.04 LTS,内核5.15.0
注意:Compose V2的depends_on.condition字段必须配合version: "3.8"以上使用,我们用的是3.8。
三、方案设计:四个服务,两张网络,三种卷
设计思路遵循“最小暴露,最佳隔离”原则:
- 网络:自定义bridge网络
backend(服务间通信)和frontend(仅Nginx对外暴露80端口)。应用服务不映射宿主机端口,只通过Nginx反向代理对外。 - 卷:三种类型混用:
named volume:PostgreSQL数据目录pgdata,Redis持久化redisdatabind mount:应用日志输出到宿主机./logs/app,方便ELK采集tmpfs:Nginx缓存目录,追求读写速度且不需要持久化- 健康检查:每个服务都配
healthcheck,PostgreSQL用pg_isready,Redis用redis-cli ping,Spring Boot用curl /actuator/health,Nginx用wget -qO- http://localhost/healthz。 - 启动顺序:
depends_on+condition: service_healthy,确保严格按“基础设施→应用→网关”顺序启动。
四、核心实现:docker-compose.yml逐段拆解
先看完整配置(这是最终跑通的版本):
version: "3.8"
services:
postgres:
image: postgres:16.1-alpine
container_name: shop-postgres
restart: unless-stopped
environment:
POSTGRES_USER: shop_user
POSTGRES_PASSWORD: ${DB_PASSWORD} # 从.env文件读取
POSTGRES_DB: shop_order
volumes:
- pgdata:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_order"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
ports:
- "5432:5432" # 仅测试环境开放,生产将注释掉
redis:
image: redis:7.2-alpine
container_name: shop-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: 3
app:
build: ./app
container_name: shop-app
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/shop_order
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- ./logs/app:/app/logs
networks:
- backend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 40s
nginx:
image: nginx:1.25.3-alpine
container_name: shop-gateway
restart: unless-stopped
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- type: tmpfs
target: /var/cache/nginx
tmpfs-size: 100M
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
interval: 10s
timeout: 3s
retries: 3
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # 关键:禁止外网访问backend网络
volumes:
pgdata:
redisdata:
4.1 网络配置的细节:internal网络防暴露
backend网络设置为internal: true,这是最容易被忽略的安全点。加了之后,容器无法通过backend网络访问外网,但容器之间通信不受影响。为什么需要?因为Spring Boot应用如果被攻破,攻击者无法用它做跳板访问公网。代价是应用无法直接访问外网API——如果业务里面有调用第三方接口的需求,需要把该服务放到非internal网络,或者配置代理。
4.2 健康检查的间隔设计
PostgreSQL的start_period: 30s是血的教训。Postgres 16在冷启动需要初始化数据目录,期间pg_isready会返回失败。没有start_period,Compose会在5次重试(每10秒一次)后判定容器不健康,但此时数据库其实还没初始化完。设置30秒宽限期后,Compose会忽略这段时间内健康检查的失败结果。
Redis的requirepass参数导致健康检查必须带密码。我们踩过坑:在test里直接用redis-cli ping,返回NOAUTH Authentication required,健康检查永远失败。最终改成redis-cli -a ${REDIS_PASSWORD} ping,但注意密码会出现在docker inspect的进程参数里,测试环境可以接受,生产建议用--no-auth-warning参数或Redis ACL。
4.3 depends_on的condition语义
Compose V2中,depends_on默认只控制启动顺序(不等待健康)。加了condition: service_healthy后,才会真正等待对端健康。这里有隐藏坑:如果app服务的健康检查配置有误(比如返回码不对),整个启动链会卡住直到超时。我们的做法是先单独启动基础服务,用docker compose ps观察健康状态,确认无误后再启动完整栈。
五、Nginx配置与日志卷挂载的权限坑
Nginx配置用的是bind mount,宿主机目录./nginx/conf.d需要包含default.conf:
upstream shop_app {
server app:8080;
}
server {
listen 80;
server_name shop.example.com;
location /healthz {
access_log off;
return 200 "ok\n";
}
location / {
proxy_pass http://shop_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
这段配置有个细节:/healthz路径必须在Nginx容器内可访问,且不能走代理到后端。因为nginx的健康检查是wget http://localhost/healthz,如果这个路径转发到app服务,而app还没就绪,Nginx会判定不健康——但实际上Nginx本身没问题。
日志卷挂载的坑:Spring Boot应用以非root用户运行(eclipse-temurin镜像默认用uid=1000),而宿主机./logs/app目录如果属主是root,应用会报Permission denied。解决办法:
mkdir -p logs/app
chown 1000:1000 logs/app
如果不做这步,应用启动时日志框架会直接抛异常,但应用本身不退出——排查起来非常诡异。
六、踩坑优化记录与最终效果
踩坑1:重建容器时数据卷权限错乱
某次docker compose down -v后,PostgreSQL的pgdata卷被删除重建,属主变成了root。再启动PostgreSQL时,容器内的postgres用户无法写入数据目录。解决方案:初始化时用--user参数指定UID,或者在启动命令里加chown -R postgres:postgres /var/lib/postgresql/data。我们最终选择在init-scripts目录放一个01-chown.sh脚本,结合docker-entrypoint-initdb.d机制自动修复。
踩坑2:tmpfs卷挂载后Nginx启动失败
最初把Nginx的/var/cache/nginx挂成bind mount(宿主机目录),结果启动直接报mkdir() "/var/cache/nginx/client_temp" failed。原因是宿主机目录权限是755,Nginx的worker进程无法在其中创建子目录。改用tmpfs后问题消失,而且性能更好——缓存读写走内存,响应时间平均降低12ms。
踩坑3:healthcheck的start_period不是万能的
Spring Boot应用在start_period: 40s内如果还没有监听8080端口,健康检查仍然算失败。但我们的应用启动需要60秒(数据源初始化+缓存预热)。最终优化方案:把interval从15秒调到20秒,retries从3次调到5次,同时start_period改为60秒。这样总等待时间是60+5×20=160秒,比之前的15×3=45秒宽容得多。
效果数据(对比迁移前):
| 指标 | Shell脚本部署 | Docker Compose |
|---|---|---|
| 平均部署耗时 | 15分钟(含人工等待) | 2分40秒 |
| 启动失败率 | 23%(10次中有2-3次) | 3.8%(52次中有2次) |
| 环境切换时间 | 30分钟(手动改配置) | 2分钟(改.env文件) |
| 新员工上手时间 | 2天 | 3小时 |
七、总结与建议
这套配置我们已经稳定运行三个月,线上环境(去掉调试端口)零故障。给后来者三个建议:
- 不要迷信
depends_on:它只解决“启动顺序”,不解决“可用性”。真正的可靠性来自healthcheck,务必给每个服务配好,且start_period要大于最慢服务的启动时间。 - 网络隔离越早做越省心:
internal: true虽然会让某些服务无法访问外网,但这是安全底线。如果确实需要外网访问,用network字段单独接入frontend网络。 - 日志卷挂载前先想权限:官方镜像的默认UID各不相同(Postgres是70,Temurin是1000),挂载宿主机目录前先
chown对应UID,否则排查权限问题会浪费大量时间。
最后,完整配置已脱敏上传至GitHub仓库(链接见评论区),需要的自取。有问题欢迎在评论区交流——特别是healthcheck的start_period参数调优,不同服务差异很大,欢迎分享你的经验值。