Docker Compose 编排实战:一套前端 + 后端 + MySQL + Redis 的完整环境
一套前端、后端、MySQL、Redis 的最小可用编排。健康检查、启动依赖顺序、环境变量分离,以及上线后才暴露的几个坑,一次讲清。
用 docker run 起过一个完整的后端环境就知道有多难受:MySQL 要记住端口映射和数据卷、Redis 要记住密码参数、后端要记住网络别名、nginx 还要单独挂配置。四条命令散落在某个 txt 里,换台机器就得重新拼一遍,谁改了什么也没人知道。
Docker Compose 要解决的就是这件事:把"一套服务怎么协作"写成一个可进版本库的声明式文件,一条 docker compose up -d 拉起全部。这篇文章用一个真实的前后端分离项目做例子,把 Compose 里最容易踩的四个坑讲透。
一、先看最终形态
项目结构大概是这样:
myapp/
├── docker-compose.yml
├── .env
├── nginx/
│ └── conf.d/
│ └── default.conf
├── backend/
│ ├── Dockerfile
│ └── target/app.jar
└── frontend/
└── dist/
docker-compose.yml:
services:
mysql:
image: mysql:8.0
container_name: myapp-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: myapp
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-time-zone=+08:00
volumes:
- mysql-data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d:ro
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
networks:
- backend-net
redis:
image: redis:7-alpine
container_name: myapp-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- backend-net
app:
build:
context: ./backend
dockerfile: Dockerfile
container_name: myapp-app
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: myapp
DB_USER: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD}
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
TZ: Asia/Shanghai
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/actuator/health | grep -q UP"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s
networks:
- backend-net
nginx:
image: nginx:1.25-alpine
container_name: myapp-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./frontend/dist:/usr/share/nginx/html:ro
- ./nginx/certs:/etc/nginx/certs:ro
- nginx-logs:/var/log/nginx
depends_on:
app:
condition: service_healthy
networks:
- backend-net
volumes:
mysql-data:
redis-data:
nginx-logs:
networks:
backend-net:
driver: bridge
配套 .env(必须加入 .gitignore,只把 .env.example 提交到仓库):
MYSQL_ROOT_PASSWORD=change-me-root
MYSQL_USER=myapp
MYSQL_PASSWORD=change-me-app
REDIS_PASSWORD=change-me-redis
到这里,docker compose up -d 就能把四个服务按依赖顺序拉起来了。下面四个细节,是新手最容易栽跟头的地方。
二、容器里的 localhost 不等于宿主机
这是最高频的错误。你在后端 application.yml 里写:
spring:
datasource:
url: jdbc:mysql://localhost:3306/myapp
在本机 IDE 里跑得好好的,一进容器就报 Connection refused。原因很简单:每个容器有自己独立的网络栈,容器内的 localhost 指向容器自己,而 MySQL 在另一个容器里。
正确做法是直接用服务名当主机名——Compose 会自动把所有服务注册进同一个自定义网络,服务名就是 DNS 记录:
spring:
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:myapp}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
把主机名抽成环境变量,本地开发默认 localhost、容器内注入 mysql,同一份配置文件两边通用。
反过来,如果你想在宿主机上用客户端连容器里的 MySQL,就得走 ports 映射出来的端口:
ports:
- "127.0.0.1:3306:3306" # 只监听本机,安全
注意这里的 127.0.0.1: 前缀。不加前缀会绑定到 0.0.0.0,等于把数据库端口暴露到公网——这是 2024 年一大批被挖矿的服务器共同的故事。数据库和 Redis 这类内部服务永远不要映射到公网。
三、depends_on 不等于"等到就绪"
早期 Compose 只有短语法:
depends_on:
- mysql
它的语义是"容器启动了就继续",不是"MySQL 能接受连接了就继续"。MySQL 从容器 start 到真正 ready 通常要 20-40 秒,这期间后端去连必然失败,然后后端自己重启,运气不好就进入循环重启。
正确写法是配合 healthcheck 用长语法:
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
condition 有三个可选值:
| 取值 | 含义 | 适用场景 |
|---|---|---|
service_started |
容器已启动 | 短语法等价,基本没用 |
service_healthy |
healthcheck 通过 | 依赖数据库、中间件 |
service_completed_successfully |
任务容器正常退出 | 数据库迁移、一次性脚本 |
healthcheck 本身也有讲究。上面 MySQL 那条:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
$$是转义,Compose 会把它还原成$交给容器内的 shell,避免在宿主机被提前展开-h 127.0.0.1不能省。省略时mysqladmin可能走 socket 路径,健康检查会过早通过start_period: 40s表示这 40 秒内的失败不计入retries,是给慢启动服务的宽限
这里有个值得警惕的细节:service_healthy 只保证"那一刻"健康。如果 MySQL 在 40 秒后因为 OOM 被 kill,后端不会自动重连失败就退出。真正的韧性还是要在应用层做:连接池配置 validationQuery、testOnBorrow、合理重试。Compose 的依赖只是启动顺序,不是运行时保障。
四、数据卷三种写法的区别
新手最容易混的三种写法:
volumes:
- mysql-data:/var/lib/mysql # 命名卷(named volume)
- ./mysql/init:/docker-entrypoint-initdb.d:ro # 绑定挂载(bind mount)
- /var/lib/mysql # 匿名卷
命名卷由 Docker 管理,实际落在 /var/lib/docker/volumes/<项目名>_mysql-data/_data。跨平台一致、性能好、备份用 docker run --rm -v ... 打包即可。数据库这类场景首选。
绑定挂载把宿主机路径直接映射进去,改文件即时生效,适合放配置和代码。但它有两个坑:
- 只能关掉覆盖,不能关掉新建。容器里对挂载目录的写入会落到宿主机文件,权限、属主全按宿主机算。MySQL 用 bind mount 存数据目录时经常因为 UID 不匹配起不来(容器内 mysql 用户是 999)。
- 空目录挂进容器会覆盖镜像内原有内容。比如
./nginx/conf.d是空目录,挂到/etc/nginx/conf.d之后,镜像自带的default.conf就"消失"了,nginx 直接报no server configured。
匿名卷就是只写容器路径不写宿主机路径,Docker 随机分配一个 ID 目录。好处是镜像里声明了 VOLUME 的数据不会被写进容器层;坏处是很难找、很难备份,docker compose down -v 一删就没了。
顺带说下清理:
docker compose down # 停容器、删网络,保留卷
docker compose down -v # 连卷一起删 —— 数据真没了,慎用
down -v 是我见过的"数据库被清空"事故里排第一的操作。执行前先看一眼 docker volume ls。
五、网络、容器名与 DNS
上面每个服务都写了 networks: [backend-net]。自定义 bridge 网络里有内建的 DNS,服务名和 container_name 都能解析:
docker exec -it myapp-app ping -c 2 mysql
# PING mysql (172.20.0.2) 56(84) bytes of data.
几个容易忽略的点:
- 默认的
default网络不能自动解析服务名(老版本行为),显式声明一个自定义网络最稳 container_name是全局唯一的,且会覆盖服务名的 DNS。想在同一台机器上跑两套环境(测试/预发),不要写死container_name,否则第二套起不来,改成docker compose -p staging up用项目名隔离- 服务之间不需要
ports。端口映射只为宿主机和外部访问准备,容器间直接用容器端口通信
六、环境变量与多环境
.env 文件里的变量会被 Compose 自动读取并注入 docker-compose.yml 的 ${} 占位符。三种来源优先级从低到高:
.env文件docker compose --env-var/ shell 环境变量docker-compose.yml里environment写死的值
多环境推荐用 profiles 而不是维护三份 yml:
services:
app:
profiles: ["prod"]
app-debug:
image: myapp:debug
profiles: ["dev"]
ports:
- "8080:8080"
docker compose --profile dev up -d # 只起开发用服务
docker compose --profile prod up -d
再加一个 docker-compose.override.yml,Compose 会自动叠加,天然适合放"开发时才需要"的端口映射和调试配置。
七、排错时的几条命令
# 看所有服务的健康状态
docker compose ps
# 只看某个服务的日志,实时跟随
docker compose logs -f --tail=100 app
# 重建单个服务(改了 Dockerfile 或代码后)
docker compose up -d --build app
# 进容器看网络到底通不通
docker compose exec app sh -c "wget -qO- http://mysql:3306 || echo unreachable"
# 检查容器实际生效的配置(排查环境变量没注入的经典手法)
docker compose config
# 查看某个容器的挂载与网络
docker inspect myapp-app --format '{{json .Mounts}}'
docker compose config 特别值得记住。它会把变量展开后的最终 yml 打出来,能一眼看出 .env 有没有被读到、哪个变量拼错了。
八、上生产前的几条硬建议
- 给所有服务加
restart: unless-stopped。服务器重启后容器自动回来,比always更友好(手动 stop 的不会被拉起来)。 - 日志要限流,否则一个跑飞的应用能把磁盘写满:
yaml
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
- 时区统一。容器默认 UTC,
log时间戳和数据库存储时间会差 8 小时。所有服务都加TZ=Asia/Shanghai,MySQL 再加--default-time-zone=+08:00。 - 镜像打 tag,别用
latest。latest每次pull都可能拿到不同版本,回滚无从谈起。 - 敏感信息不要写进 yml。
.env进.gitignore,生产环境用密钥管理或者直接注入环境变量。 - 定期验证备份真的能恢复。
mysqldump定时任务谁都会加,但没人验证过 dump 文件能不能 import —— 这是备份体系最常见的假象。
小结
Compose 的价值不在"少敲几条命令",而在于把环境认知固化成文件:谁依赖谁、数据存在哪、端口怎么暴露,全都写在版本库里,换机器一行命令就能复现。
真正决定它能不能在生产站稳的,是那几个不显眼的细节:容器内的 localhost、depends_on 的语义边界、卷的三种写法差异、以及 down -v 这把双刃剑。这四件事想清楚了,Compose 才算真的会用。
正在加载评论…