一、问题背景
前段时间接手了一个中小型项目,技术栈是典型的前后端分离:Nginx做静态资源与反向代理,Spring Boot提供API,MySQL存业务数据,Redis做缓存和会话。开发阶段大家都是各自在本机装MySQL、Redis,然后IDE里跑Java,本地起个Nginx。看似没问题,但一到部署和联调就出状况。
最典型的三类问题:
- 启动顺序靠运气。API容器启动比MySQL快,连不上数据库直接退出;重启一次有时候能好,有时候要重启三四次。
- 环境不一致。同事本地MySQL是5.7,测试服务器是8.0,SQL语法和认证插件差异导致一堆莫名其妙的报错。
- 数据卷管理混乱。有人把数据挂在宿主机相对路径,换台机器路径就找不到;有人干脆不挂,容器一删数据全没。
后来我决定用Docker Compose把整套环境固化下来,一条命令拉起全部服务。这篇博客就把最终落地的配置和踩过的坑完整写出来。
二、环境与版本
先明确版本,因为Compose文件格式和Docker Engine版本是强相关的:
- 宿主机:Ubuntu 22.04 LTS
- Docker Engine:24.0.7
- Docker Compose:v2.23.0(注意是v2,命令是
docker compose而不是docker-compose) - Nginx:1.25.3-alpine
- OpenJDK:eclipse-temurin:17-jre-jammy
- MySQL:8.0.35
- Redis:7.2.3-alpine
Compose文件使用version字段其实在v2里已经废弃,直接省略即可,Compose v2会自动识别。我下面给的配置就是不带version的写法。
三、方案设计
整体设计思路:
- 网络:创建一个自定义bridge网络
app-net,四个服务都接入。自定义网络的好处是内置DNS,服务之间可以直接用服务名互相访问,比如API连数据库直接用mysql:3306。 - 卷:MySQL数据、Redis数据、Nginx日志都用命名卷(named volume),不用bind mount。命名卷由Docker管理,跨机器迁移更省心。
- 健康检查:MySQL用
mysqladmin ping,Redis用redis-cli ping,API暴露Spring Boot Actuator的/actuator/health,Nginx用wget请求本地。 - 启动顺序:用
depends_on的长语法配合condition: service_healthy,让API等MySQL和Redis都健康后再启动,Nginx等API健康后再启动。
这样整个依赖链就是:MySQL/Redis → API → Nginx。
四、核心实现
先看目录结构:
project/
├── docker-compose.yml
├── .env
├── api/
│ └── Dockerfile
├── nginx/
│ ├── Dockerfile
│ └── nginx.conf
└── mysql/
└── init.sql
.env文件放敏感信息和版本号,方便统一管理:
MYSQL_ROOT_PASSWORD=Root@2024
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=App@2024
API_PORT=8080
WEB_PORT=80
下面是完整的docker-compose.yml,这是本文的核心:
services:
mysql:
image: mysql:8.0.35
container_name: app-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
- --max_connections=500
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", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7.2.3-alpine
container_name: app-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "Redis@2024"]
volumes:
- redis-data:/data
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "Redis@2024", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
api:
build:
context: ./api
dockerfile: Dockerfile
image: app-api:1.0.0
container_name: app-api
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_PASSWORD: Redis@2024
JAVA_OPTS: "-Xms256m -Xmx512m"
expose:
- "8080"
networks:
- app-net
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
image: app-nginx:1.0.0
container_name: app-nginx
restart: unless-stopped
ports:
- "${WEB_PORT}:80"
volumes:
- nginx-logs:/var/log/nginx
networks:
- app-net
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 15s
timeout: 3s
retries: 3
start_period: 10s
volumes:
mysql-data:
redis-data:
nginx-logs:
networks:
app-net:
driver: bridge
几个关键点解释一下:
healthcheck的start_period很关键。MySQL首次初始化要建库建表,我给了30秒;Spring Boot应用启动慢,给了60秒。这段时间内的失败不计入retries,避免刚启动就被判定为不健康。
depends_on的condition写法是Compose v2的长语法,短语法(数组形式)只能保证容器启动顺序,不能保证服务就绪。必须用长语法加service_healthy。
API的Dockerfile用了多阶段构建,最终镜像只含JRE:
FROM maven:3.9.5-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
RUN useradd -r -u 1001 appuser
COPY --from=builder /build/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
注意用非root用户运行,安全上更稳妥。
Nginx的healthcheck我在nginx.conf里配了一个/healthz直接返回200:
server {
listen 80;
server_name _;
location /healthz {
access_log off;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://api:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
启动命令就一条:
docker compose up -d
看启动状态:
docker compose ps
五、踩坑与优化
坑1:MySQL 8.0认证插件。默认是caching_sha2_password,老版本JDBC驱动连不上。我在command里加了--default-authentication-plugin=mysql_native_password强制用旧插件。不过要注意这个参数在MySQL 8.4里已经被移除,如果你用8.4要换成--mysql-native-password=ON。
坑2:healthcheck里的密码变量。${MYSQL_ROOT_PASSWORD}在test数组里会被Compose解析,但-p和密码之间不能有空格,写成-p${VAR}才行,否则mysqladmin会把密码当成独立参数。
坑3:Redis healthcheck报警告。redis-cli -a会打印"Using a password with -a option may not be safe"的警告到stderr,虽然不影响退出码,但日志很吵。可以在healthcheck的test前加sh -c重定向,或者直接忽略,我选择忽略,因为退出码是对的。
坑4:API健康检查依赖Actuator。一定要在pom.xml里引入spring-boot-starter-actuator,并且application-prod.yml里暴露health端点:
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: never
优化1:资源限制。生产上我给API加了内存和CPU限制:
deploy:
resources:
limits:
cpus: "1.5"
memory: 768M
reservations:
memory: 512M
优化2:日志轮转。默认json-file驱动会无限增长,加上:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
优化3:时区统一。所有容器都设TZ=Asia/Shanghai,避免日志时间对不上。
六、效果数据
改造前后对比,数据来自我们测试环境(4核8G云主机):
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 冷启动命令数 | 5步手动 | 1条docker compose up -d |
| 平均启动耗时 | 90秒 | 35秒 |
| 首次启动可用率 | 70% | 100% |
| 环境重建耗时 | 约30分钟 | 约2分钟 |
| 镜像总大小 | - | 约1.1GB |
其中API镜像从最初的780MB(单阶段构建带Maven)压到210MB(多阶段+JRE)。启动顺序问题彻底消失,连续重启20次没有出现一次API连不上数据库的情况。
总结
Docker Compose做多服务编排,核心其实就三件事:网络让服务能找到彼此,卷让数据能持久化,健康检查加条件依赖让启动顺序可控。这套配置不复杂,但每一个参数背后都对应一个真实踩过的坑。如果你也在维护类似的中小项目,可以直接把这套模板拿去改,把镜像版本、环境变量和init.sql换掉就能跑。
有两点建议:一是别用latest标签,所有镜像版本钉死;二是healthcheck的start_period宁可给大点,尤其是数据库和Java应用,给少了会误判。