一、问题背景:手工部署的痛与容器化的转机
上个月接手一个老旧项目,四个服务(Nginx前端、Spring Boot后端、PostgreSQL数据库、Redis缓存)散落在三台服务器上,每次发版都要SSH上去手动启停。最崩溃的一次是凌晨两点,因为忘了先启动数据库导致后端服务启动失败,排查了整整一小时。
痛定思痛,决定用Docker Compose把这套东西统一编排起来。要求只有三个:第一,一条命令能启动整个项目;第二,服务间启动顺序要有保障;第三,日志和配置不能因为容器重建而丢失。
这篇文章记录的就是最终的落地方案,包含了网络规划、健康检查、卷挂载这些关键配置,以及调试过程中踩过的几个坑。
二、环境与版本
先交代一下运行环境,避免版本差异导致的配置不兼容:
- 宿主机:Ubuntu 22.04 LTS,内核版本 5.15.0-89-generic
- Docker Engine:26.1.4(通过apt安装,非snap版)
- Docker Compose:v2.24.2(docker compose 插件形式,不是docker-compose)
- 项目技术栈:Spring Boot 3.2.5 + PostgreSQL 15.3 + Redis 7.2.4 + Nginx 1.25.4
特别提醒:如果你还在用 docker-compose(带横杠的Python版),建议尽早迁移到 docker compose(插件版)。新版Compose支持 depends_on 中嵌套 condition 条件判断,这直接决定了健康检查能否作为启动顺序的依据。
三、方案设计:网络隔离 + 依赖控制 + 数据持久化
整体设计思路分三层:
网络层:创建一个自定义bridge网络,分配 172.28.0.0/24 子网。四个服务固定IP,其中Nginx映射到宿主机80/443端口,其他服务仅内网访问,不暴露端口到宿主机。这样既保证了服务间通信的稳定性(固定IP),又减少了攻击面。
启动顺序层:通过 depends_on 的 condition: service_healthy 来控制。PostgreSQL和Redis先启动并完成健康检查,Spring Boot确认数据库和缓存就绪后才启动,Nginx最后启动并等待后端服务就绪。
数据持久化层:使用命名卷(named volume)保存PostgreSQL的数据文件,使用bind mount把Nginx日志和后端日志映射到宿主机指定目录。
四、核心实现:docker-compose.yml 完整配置
直接上配置文件,这是整个方案的核心。我删掉了敏感的业务配置,保留了所有关键结构。
version: "3.8"
networks:
app-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
gateway: 172.28.0.1
volumes:
postgres-data:
driver: local
services:
postgres:
image: postgres:15.3-alpine
container_name: app-postgres
restart: unless-stopped
networks:
app-network:
ipv4_address: 172.28.0.10
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:-app_pass_2024}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7.2.4-alpine
container_name: app-redis
restart: unless-stopped
networks:
app-network:
ipv4_address: 172.28.0.11
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redis_pass_2024}"]
volumes:
- ./redis/data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_pass_2024}", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: app-backend:1.0.0
container_name: app-backend
restart: unless-stopped
networks:
app-network:
ipv4_address: 172.28.0.12
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://172.28.0.10:5432/appdb
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-app_pass_2024}
SPRING_DATA_REDIS_HOST: 172.28.0.11
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_pass_2024}
volumes:
- ./logs/backend:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
nginx:
image: nginx:1.25.4-alpine
container_name: app-nginx
restart: unless-stopped
networks:
app-network:
ipv4_address: 172.28.0.13
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
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
interval: 30s
timeout: 5s
retries: 3
五、踩坑与优化:三个真实案例
坑一:healthcheck中curl不存在
给Spring Boot容器写健康检查时,一开始用的是 curl -f http://localhost:8080/actuator/health。结果容器启动后一直显示unhealthy,排查发现基于 eclipse-temurin:17-jre-alpine 的基础镜像里根本没有curl命令。
解决方案有两种,一是换用基础镜像中的 wget,二是直接安装curl。我最终选择了在Dockerfile里加一行 RUN apk add --no-cache curl,这样可以继续使用curl,因为后续调试时curl比wget更顺手。
# backend/Dockerfile 关键片段
FROM eclipse-temurin:17-jre-alpine
RUN apk add --no-cache curl
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
坑二:depends_on只等待容器启动,不等待服务就绪
这是Compose最经典的坑。早期的 depends_on 只能保证容器创建顺序,Spring Boot启动只要3秒,但PostgreSQL初始化可能需要10秒以上。如果后端先启动,连接数据库必然失败。
新版Compose的 condition: service_healthy 解决了这个问题。但有个细节需要注意:healthcheck的 start_period 参数要合理设置。PostgreSQL首次初始化数据目录需要时间,我设了30秒的 start_period,如果在这期间健康检查失败不会计入重试次数,避免误判。
坑三:日志文件权限混乱
把宿主机目录挂载进容器后,Nginx和Spring Boot写的日志文件属主是容器内的用户(通常uid是1000或101),宿主机上用普通用户查看日志非常别扭。
优化方案是在Compose配置里指定用户映射,或者用 user 参数统一uid。我最终在Nginx服务里加了:
user: "101:101" # 对应宿主机www-data用户的uid/gid
后端服务则因为Java进程需要写文件,保持了默认,但宿主机上创建了 /data/logs/backend 目录并 chown -R 1000:1000,保证两边一致。
六、效果数据:启动时间与稳定性对比
配置完成后,我做了两组对比测试。
启动时间对比(同一台机器,冷启动,清空所有容器和卷):
| 部署方式 | 全流程启动时间 | 操作步骤数 |
|---|---|---|
| 手工部署 | 8分15秒 | 12步 |
| Docker Compose | 45秒 | 1步 |
稳定性数据(运行两周,每天模拟一次容器杀掉重启):
- 容器异常重启次数:从手工部署时的每周3-5次,降至0次(自动重启策略生效)
- 因依赖启动顺序导致的服务启动失败:从每月2-3次,降为0次
- 日志查询效率:因为日志统一落盘到宿主机
/data/logs,用grep检索无需进入容器,单次排查时间从约10分钟缩短至1分钟
还有一个意外收获:因为PostgreSQL数据放在了命名卷里,升级数据库镜像版本时直接 docker compose up -d 就能完成,数据不会丢失。之前手工升级数据库,备份、导出、导入、验证一整套流程至少要半天。
七、总结:该用什么、不推荐什么
这套方案用了三周,整体稳定。总结下来几个要点:
- 固定IP比服务名好用:虽然Compose内置DNS支持服务名互访,但固定IP在排查问题时更直观,netstat一眼就能看出谁在跟谁通信。
- 健康检查是刚需:没有健康的依赖控制,容器编排就是碰运气。
start_period参数一定要根据服务实际启动时间调整,别用默认值。 - 卷映射要提前规划:日志、配置、数据目录分开管理,别都堆在容器里。容器本身应该是无状态的,重建不丢数据是底线。
最后说一句不推荐的做法:不要用 depends_on 的 service_started 模式,除非你的服务对启动顺序完全不敏感。我见过有人靠 sleep 30 硬等,这是最脆弱的方案,没有之一。
这套配置已经提交到团队内部仓库,有需要可以直接抄作业。如果你也遇到过类似的编排问题,欢迎在评论区交流。