一、问题背景
去年接手一个内部管理系统,技术栈是典型的前后端分离:Nginx做静态资源服务和反向代理,Spring Boot 2.7应用提供API,MySQL 8.0存业务数据,Redis 7.2做缓存和会话。上线初期我是用四个docker run命令手工起容器的,每次部署要执行十几条命令,还得盯着日志等MySQL初始化完成再启动应用——有一次运维同事顺序搞错,应用容器先起来连不上数据库,直接崩溃重启了7次。
后来改用Docker Compose编排,把网络、卷、依赖关系全部写进一个YAML文件。这篇文章就把这套配置完整拆解一遍,包括我调试健康检查参数的过程和踩过的坑。
二、环境与版本
先明确版本,Compose文件格式在不同版本间差异不小:
- 宿主机:Ubuntu 22.04 LTS,内核 5.15.0
- Docker Engine:24.0.7
- Docker Compose:v2.23.0(插件形式,命令是
docker compose而非老的docker-compose) - Compose文件格式:
version: "3.8"(虽然v2已不强制要求version字段,但保留兼容性更好) - MySQL镜像:
mysql:8.0.35 - Redis镜像:
redis:7.2.3-alpine - 应用镜像:基于
eclipse-temurin:17-jre-jammy构建 - Nginx镜像:
nginx:1.25.3-alpine
服务器配置是4核8G,跑这套服务资源绰绰有余。
三、方案设计
整体设计思路有四条:
1. 网络隔离:自定义一个bridge网络app-net,四个服务都接入。Docker内置DNS会让服务名直接成为主机名,应用里配置jdbc:mysql://mysql:3306/...即可,不用写IP。同时把Nginx的80端口映射到宿主机,其余服务一律不暴露端口到公网,MySQL和Redis只在容器网络内可达,安全性直接上一个台阶。
2. 卷挂载:分两类。数据库数据用命名卷mysql-data、redis-data,由Docker管理生命周期,docker compose down不会删除;配置文件(my.cnf、nginx.conf)用bind mount绑定到宿主机具体路径,方便修改后docker compose restart生效。
3. 健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,应用用curl打Spring Boot Actuator的/actuator/health端点,Nginx用wget --spider检查本地80端口。每个服务定义healthcheck,间隔、超时、重试次数按服务特性分别设置。
4. 启动顺序:用depends_on的长语法配合condition: service_healthy。注意——Compose v2里depends_on的短语法(列表形式)只保证容器启动顺序,不保证就绪顺序,这是很多人踩的坑。必须用长语法才能等到健康检查通过。
依赖链是这样的:mysql和redis先就绪 → app等这两个healthy后启动 → nginx等app healthy后启动。
四、核心实现
完整目录结构:
project/
├── docker-compose.yml
├── .env
├── app/
│ └── Dockerfile
├── config/
│ ├── mysql/my.cnf
│ └── nginx/nginx.conf
└── html/
.env文件存放敏感信息和版本号,避免硬编码:
MYSQL_ROOT_PASSWORD=Root@2024#Strong
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=App@2024#Pass
REDIS_PASSWORD=Redis@2024#Pass
下面是完整的docker-compose.yml:
version: "3.8"
networks:
app-net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
mysql-data:
redis-data:
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
- --max_connections=500
- --innodb-buffer-pool-size=512M
volumes:
- mysql-data:/var/lib/mysql
- ./config/mysql/my.cnf:/etc/mysql/conf.d/custom.cnf: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: 40s
redis:
image: redis:7.2.3-alpine
container_name: app-redis
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redis-data:/data
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
app:
build:
context: ./app
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/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
SPRING_REDIS_HOST: redis
SPRING_REDIS_PORT: 6379
SPRING_REDIS_PASSWORD: ${REDIS_PASSWORD}
JAVA_OPTS: "-Xms512m -Xmx1024m"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-net
expose:
- "8080"
healthcheck:
test: ["CMD", "curl", "-f", "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"
volumes:
- ./html:/usr/share/nginx/html:ro
- ./config/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
app:
condition: service_healthy
networks:
- app-net
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost/health"]
interval: 30s
timeout: 3s
retries: 3
应用镜像的Dockerfile(用多阶段构建,最终镜像约180MB):
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 apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
注意Dockerfile里专门装了curl,因为健康检查要用它。用eclipse-temurin:17-jre-jammy而不是alpine,是因为curl在alpine上的兼容性问题折腾过一轮,索性用ubuntu基础镜像,镜像大40MB但省心。
nginx.conf的关键片段(反向代理到app服务名):
upstream backend {
server app:8080;
keepalive 32;
}
server {
listen 80;
server_name _;
location /health {
access_log off;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
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_connect_timeout 5s;
proxy_read_timeout 60s;
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
部署命令就一条:
docker compose up -d --build
五、踩坑与优化
坑1:depends_on短语法不管用。 一开始我写的是depends_on: [mysql, redis],结果app容器还是在MySQL初始化完成前就启动了,健康检查显示unhealthy。查文档才发现短语法只等容器created,不等healthy。改成condition: service_healthy后解决。另外MySQL 8.0首次初始化要跑一堆SQL,start_period必须给足,我最初设20s不够,容器在初始化期间被判定失败重启了两次,改成40s后才稳定。
坑2:healthcheck里的变量展开。 test: ["CMD", "mysqladmin", "ping", "-p${MYSQL_ROOT_PASSWORD}"]这种写法,变量是在宿主机Compose解析阶段展开的,能正常工作。但如果写成CMD-SHELL配合单引号,变量就在容器内展开了,那时环境变量存在也能用,但两种写法混用容易出错。我统一用exec数组形式,变量在宿主机展开,更可控。
坑3:Redis的healthcheck密码告警。 redis-cli -a password会在日志里打warning说命令行传密码不安全。生产环境可以改用REDISCLI_AUTH环境变量,或者直接忽略——健康检查的日志本来就只在本机可见。我选择了后者,但把timeout从默认的30s改成3s,避免探测卡住。
优化点:给MySQL和应用容器都加了资源限制(生产环境建议加deploy.resources.limits),并给app-net指定了固定子网172.28.0.0/16,避免和公司内网网段冲突——之前用默认网段撞过一次172.17.x.x,导致容器间偶发不通,排查了半天。
六、效果数据
改造前后的对比(同一台4核8G服务器,部署同一版本):
| 指标 | 手工docker run | Compose编排 |
|---|---|---|
| 部署命令数 | 12条 | 1条 |
| 冷启动到全服务healthy | 约150s,需人工盯日志 | 约95s,全自动 |
| 因启动顺序导致的失败 | 平均每周1次 | 0次(3个月统计) |
| 配置文件修改生效 | 重新run容器 | restart单个服务,约5s |
| 全量重建(down + up) | 约4分钟 | 约2分10秒 |
冷启动快了是因为Compose并行拉取镜像、并行启动无依赖的服务(mysql和redis同时起),而手工命令是串行的。应用容器内存稳定在1.1GB左右,MySQL约600MB,Redis约80MB,整机负载长期在1.5以下。
七、总结
这套配置的核心就三件事:自定义网络解决服务发现和隔离,命名卷解决数据持久化,healthcheck + depends_on长语法解决启动顺序。前两个是常规操作,第三个才是真正容易翻车的地方——短语法和长语法的区别值得每个用Compose的人记牢。
如果项目再复杂一点(比如要上多副本、滚动更新),就该考虑Kubernetes了。但就这个4服务的规模,Compose的运维成本低得多,改配置、看日志、重启服务都是秒级操作,对中小项目来说性价比很高。完整配置可以直接拿去改改就用,把.env里的密码换掉即可。