一、问题背景:手工docker run的痛
去年接手一个项目,技术栈是 Vue + Nginx 做前端,Spring Boot 做后端,MySQL 8 存数据,Redis 7 做缓存。最初部署方式是写一个 deploy.sh,里面一堆 docker run 命令,网络用默认 bridge,数据卷用匿名卷,服务之间靠 --link 互联。
问题很快暴露出来:
- 启动顺序不可控。后端容器比 MySQL 先起来,连接池初始化直接抛
Communications link failure,然后容器退出,得手动重启。 - 数据丢失。有一次误删容器,匿名卷没保留,MySQL 数据全没了,恢复花了三个小时。
- 网络混乱。默认 bridge 下容器 IP 会变,后端配置里写死的 IP 隔三差五失效。
- 健康状态不可见。
docker ps只显示 running,但容器里进程可能已经假死,外部根本不知道。
统计了一下,那段时间每次重新部署,四个服务全部正常启动的概率只有 62% 左右,冷启动平均耗时 48 秒,其中大部分时间浪费在反复重启后端容器上。
后来全面改用 Docker Compose 编排,把网络、卷、健康检查、依赖顺序全部声明式管理,问题基本解决。下面把配置和踩坑过程完整记录下来。
二、环境与版本
先说明本文所有配置的验证环境,版本不一致可能导致行为差异:
- 操作系统:Ubuntu 22.04 LTS
- Docker Engine:24.0.7
- Docker Compose:v2.23.0(注意是 Compose V2 插件版,命令是
docker compose而非docker-compose) - Nginx:1.25.3-alpine
- OpenJDK:17(Spring Boot 3.2.0 要求 JDK 17+)
- MySQL:8.0.35
- Redis:7.2.3-alpine
Compose 文件格式使用 version: "3.8",虽然 V2 已经不强制要求 version 字段,但保留它能兼容部分老工具链。
三、方案设计
整体设计思路如下:
网络层:创建一个自定义 bridge 网络 app-net,四个服务全部接入。自定义 bridge 自带 DNS 解析,服务之间可以直接用服务名互访,比如后端连数据库写 mysql:3306 即可,不用关心 IP。
存储层:MySQL 数据、Redis 持久化文件、Nginx 日志、后端上传文件,全部用命名卷(named volume)挂载。命名卷由 Docker 管理,删容器不删卷,数据安全。
健康检查层:每个有状态服务都配 healthcheck。MySQL 用 mysqladmin ping,Redis 用 redis-cli ping,后端用 Spring Boot Actuator 的 /actuator/health,Nginx 用 wget 请求本地首页。
启动顺序层:用 depends_on 的 condition: service_healthy 语法。注意这是 Compose V2 才支持的写法,V1 只支持 condition: service_started,那个只能保证容器启动,不能保证服务就绪。
四、核心实现
4.1 目录结构
project/
├── docker-compose.yml
├── backend/
│ ├── Dockerfile
│ └── target/app.jar
├── frontend/
│ ├── Dockerfile
│ ├── dist/
│ └── nginx.conf
└── mysql/
└── init.sql
4.2 完整的 docker-compose.yml
这是核心文件,每一段都加了注释说明:
version: "3.8"
networks:
app-net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
mysql-data:
redis-data:
upload-data:
nginx-logs:
services:
mysql:
image: mysql:8.0.35
container_name: app-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: Root@123456
MYSQL_DATABASE: appdb
MYSQL_USER: appuser
MYSQL_PASSWORD: App@123456
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
- --max_connections=500
- --innodb_buffer_pool_size=512M
volumes:
- mysql-data:/var/lib/mysql
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- app-net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pRoot@123456"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
redis:
image: redis:7.2.3-alpine
container_name: app-redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass Redis@123456 --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "Redis@123456", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: app-backend:1.0.0
container_name: app-backend
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/appdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: App@123456
SPRING_REDIS_HOST: redis
SPRING_REDIS_PORT: 6379
SPRING_REDIS_PASSWORD: Redis@123456
JAVA_OPTS: "-Xms512m -Xmx1024m -XX:+UseG1GC"
volumes:
- upload-data:/app/upload
networks:
- app-net
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 8
start_period: 60s
nginx:
image: nginx:1.25.3-alpine
container_name: app-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./frontend/dist:/usr/share/nginx/html:ro
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- upload-data:/usr/share/nginx/html/upload:ro
- nginx-logs:/var/log/nginx
networks:
- app-net
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
interval: 15s
timeout: 3s
retries: 3
start_period: 10s
4.3 后端 Dockerfile
后端用多阶段构建,减小镜像体积:
FROM eclipse-temurin:17-jre-jammy AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r appuser && useradd -r -g appuser appuser
COPY target/app.jar /app/app.jar
RUN mkdir -p /app/upload && chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]
注意这里必须装 wget,因为 healthcheck 要用它请求 actuator 接口。alpine 镜像默认有 wget,但 temurin 的 jammy 基础镜像没有,不装的话健康检查永远失败。
4.4 启动命令
# 首次构建并启动
docker compose up -d --build
# 查看健康状态
docker compose ps
# 实时查看某服务日志
docker compose logs -f backend
五、踩坑与优化
坑1:start_period 设太短导致后端被误判为不健康
最初后端 healthcheck 的 start_period 只给了 20s,结果 Spring Boot 启动要 35 秒左右,健康检查还没等应用就绪就开始计失败次数,连续 8 次失败后容器被标记为 unhealthy,nginx 因为 depends_on 条件一直不启动。
后来把 start_period 调到 60s,retries 调到 8,问题解决。start_period 内的失败不计入 retries,这个参数对有状态服务特别关键。
坑2:MySQL 的 start_period 不够长
MySQL 8 首次启动要初始化数据目录、执行 init.sql,在低配机器上可能超过 30 秒。原来 start_period: 20s 不够,backend 一直等不到 mysql healthy。改成 40s 后稳定。
坑3:redis-cli 带密码时的告警
redis-cli -a password ping 会输出一行警告 "Warning: Using a password with '-a'..." 到 stderr。如果 healthcheck 只判断 exit code 没问题,但如果用输出内容判断就会失败。建议就用 exit code,或者改用 REDISCLI_AUTH 环境变量。
坑4:卷挂载权限问题
backend 容器里用非 root 用户 appuser 运行,挂载 upload-data 卷时,卷目录默认属主是 root,导致上传文件写入失败。解决办法是在 Dockerfile 里提前 mkdir 并 chown,Docker 初始化命名卷时会继承镜像里的属主设置。
优化:健康检查间隔调优
默认 30s 间隔太慢,前端请求会打到还没就绪的后端。改成 15s 后,服务整体就绪时间明显缩短。但也不要低于 5s,否则频繁的健康检查会占用容器资源。
六、效果数据
改造前后对比(同一台 4C8G 机器,冷启动):
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 全部服务启动成功 | 62% | 99% |
| 冷启动耗时 | 48s | 22s |
| 数据丢失事件 | 3次/年 | 0 |
| 部署命令条数 | 12条 | 1条 |
启动成功率的提升主要来自健康检查加条件依赖,避免了后端在数据库没就绪时启动失败。冷启动时间反而缩短,是因为不再需要人工反复重启后端容器。
七、总结
Docker Compose 编排的核心价值在于把网络、存储、依赖顺序这些"隐式知识"变成声明式配置。几个关键点再强调一遍:
- 自定义 bridge 网络是服务发现的基础,别再用默认 bridge 和
--link。 - 命名卷保证数据安全,删容器不删卷。
- healthcheck + depends_on 的 service_healthy 条件是保证启动顺序的正确姿势,
service_started不够用。 - start_period 一定要根据服务实际启动时间设置,宁长勿短,它不影响就绪后的检查频率。
这套配置在我们项目跑了半年多,重新部署从未出过启动顺序问题。如果你也在手工 docker run 一堆服务,强烈建议换成 Compose。