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 ... 打包即可。数据库这类场景首选。

绑定挂载把宿主机路径直接映射进去,改文件即时生效,适合放配置和代码。但它有两个坑:

  1. 只能关掉覆盖,不能关掉新建。容器里对挂载目录的写入会落到宿主机文件,权限、属主全按宿主机算。MySQL 用 bind mount 存数据目录时经常因为 UID 不匹配起不来(容器内 mysql 用户是 999)。
  2. 空目录挂进容器会覆盖镜像内原有内容。比如 ./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 的 ${} 占位符。三种来源优先级从低到高:

  1. .env 文件
  2. docker compose --env-var / shell 环境变量
  3. 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 有没有被读到、哪个变量拼错了。

八、上生产前的几条硬建议

  1. 给所有服务加 restart: unless-stopped。服务器重启后容器自动回来,比 always 更友好(手动 stop 的不会被拉起来)。
  2. 日志要限流,否则一个跑飞的应用能把磁盘写满:

yaml logging: driver: json-file options: max-size: "50m" max-file: "3"

  1. 时区统一。容器默认 UTC,log 时间戳和数据库存储时间会差 8 小时。所有服务都加 TZ=Asia/Shanghai,MySQL 再加 --default-time-zone=+08:00。
  2. 镜像打 tag,别用 latest。latest 每次 pull 都可能拿到不同版本,回滚无从谈起。
  3. 敏感信息不要写进 yml。.env 进 .gitignore,生产环境用密钥管理或者直接注入环境变量。
  4. 定期验证备份真的能恢复。mysqldump 定时任务谁都会加,但没人验证过 dump 文件能不能 import —— 这是备份体系最常见的假象。

小结

Compose 的价值不在"少敲几条命令",而在于把环境认知固化成文件:谁依赖谁、数据存在哪、端口怎么暴露,全都写在版本库里,换机器一行命令就能复现。

真正决定它能不能在生产站稳的,是那几个不显眼的细节:容器内的 localhost、depends_on 的语义边界、卷的三种写法差异、以及 down -v 这把双刃剑。这四件事想清楚了,Compose 才算真的会用。

查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。