Skip to content

Docker Compose 多服务编排

本篇是 Docker + Go 系列教程的第三篇。前两篇我们专注于把单个 Go 应用容器化,但真实的业务系统很少是单服务架构——一个 Web 应用通常依赖 MySQL、Redis、消息队列等多个服务。手动用 docker run 启动这些服务并配置网络、依赖顺序非常繁琐。Docker Compose 正是解决这个问题的工具:用一个 YAML 文件定义整个多服务环境,一条命令启动/停止全部服务。本篇将系统讲解 Docker Compose 的用法,并最终搭建一个 Go + MySQL + Redis 的三服务编排示例。

一、Docker Compose 简介

1. 什么是 Docker Compose

Docker Compose 是 Docker 官方提供的多容器编排工具。它通过一个 docker-compose.yml 文件描述:

  • 有哪些服务(service)
  • 每个服务用什么镜像、怎么构建
  • 服务之间的依赖关系
  • 网络、数据卷、端口映射
  • 环境变量、健康检查

然后用一条命令 docker compose up 启动整个环境,docker compose down 销毁整个环境。

2. 适用场景

Docker Compose 主要用于:

  • 本地开发环境:一键拉起依赖的所有服务,开发者无需在本地装 MySQL、Redis。
  • 自动化测试:CI 中用 Compose 启动测试环境,跑完销毁。
  • 小规模生产部署:单机多服务场景(不适用于需要集群编排的场景,那是 Kubernetes 的领域)。
  • 演示与培训:让别人一键跑起你的项目。

3. 版本说明

  • Docker Compose v1:独立 Python 程序,命令是 docker-compose(带连字符)。
  • Docker Compose v2:Go 编写的插件,命令是 docker compose(空格),集成在 Docker CLI 中。

本篇使用 v2 语法(docker compose),Docker Desktop 和新版 Docker Engine 已默认集成。如果你的环境只有 v1,建议升级。

4. 与 Kubernetes 的关系

Docker Compose 和 Kubernetes 都是「容器编排」工具,但定位完全不同:

特性Docker ComposeKubernetes
定位单机多容器编排多机集群编排
复杂度
学习曲线几小时几周到几个月
适用规模几个到几十个容器上千个容器
高可用无(单点)内置
自愈能力无(可加 restart)内置
服务发现通过服务名内置(DNS)
滚动更新有限支持完整支持

简单说:本地开发和单机部署用 Compose,生产集群用 Kubernetes。

二、docker-compose.yml 结构详解

1. 顶层结构

一个 docker-compose.yml 文件顶层有以下几个关键字段:

yaml
# Compose 文件版本(v2 推荐省略,用默认最新)
version: "3.9"

# 服务定义(核心)
services:
  web:
    image: nginx:alpine
  db:
    image: mysql:8

# 数据卷定义
volumes:
  db_data:

# 网络定义
networks:
  frontend:
  backend:

主要字段:

  • services:定义各个服务,必填。
  • volumes:定义命名数据卷,供服务挂载。
  • networks:定义网络,供服务接入。
  • configs:定义配置(Swarm 模式用)。
  • secrets:定义密钥(Swarm 模式用)。

2. 服务配置项

一个服务可以配置的常见字段:

字段作用
image使用的镜像
build构建配置(替代 image)
container_name容器名
ports端口映射
environment环境变量
env_file从文件读取环境变量
volumes数据卷挂载
networks接入的网络
depends_on依赖的服务
healthcheck健康检查
restart重启策略
command覆盖默认启动命令
entrypoint覆盖入口程序
working_dir工作目录
user运行用户
tty分配伪终端
stdin_open保持 stdin 打开

下面逐个讲解最常用的配置。

三、定义多个服务

下面从零开始,搭建一个 Go 应用 + MySQL + Redis 的多服务环境。

1. 基础结构

yaml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - mysql
      - redis
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis

  mysql:
    image: mysql:8
    environment:
      - MYSQL_ROOT_PASSWORD=root123
      - MYSQL_DATABASE=myapp
    ports:
      - "3306:3306"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

这三个服务组成一个最小可用环境:app 依赖 mysql 和 redis,通过服务名(mysqlredis)作为主机名访问它们。

2. 服务间通信

Compose 会为同一 docker-compose.yml 中的服务自动创建一个默认网络,服务之间可以用服务名作为主机名互相访问。例如 Go 应用连接 MySQL:

go
dsn := fmt.Sprintf("root:root123@tcp(mysql:3306)/myapp?parseTime=true")
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

注意 DSN 里的 mysql:3306——mysql 就是服务名,Compose 内置 DNS 会把它解析为对应容器的 IP。

3. 端口映射

ports 把容器端口映射到宿主机。两种写法:

yaml
ports:
  - "8080:8080"       # 宿主机 8080 -> 容器 8080
  - "9090:80"         # 宿主机 9090 -> 容器 80
  - "127.0.0.1:3306:3306"  # 只绑定到 localhost

注意:MySQL、Redis 这种服务,如果只供 Compose 内部其他服务访问,不需要映射端口到宿主机。映射出去反而有安全风险(开发环境可以映射方便用 GUI 工具连)。

四、服务依赖与健康检查

1. depends_on 的问题

depends_on 只保证「依赖的服务容器已经启动」,但不保证服务已经就绪。例如:

yaml
services:
  app:
    depends_on:
      - mysql

MySQL 容器启动很快,但 MySQL 服务真正能接受连接需要几秒(初始化数据库、用户等)。如果 app 在 MySQL 还没就绪时就连接,会失败。

2. 用 healthcheck + depends_on 解决

正确做法是给依赖服务配置 healthcheck,然后 depends_oncondition: service_healthy

yaml
services:
  app:
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started

  mysql:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: myapp
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot123"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

depends_on 的三种 condition:

  • service_started:容器启动即认为满足(默认)。
  • service_healthy:容器健康检查通过才算满足。
  • service_completed:容器运行结束(退出码 0)才算满足,用于一次性任务。

3. Go 应用内的重试机制

即便有 healthcheck,生产代码里也应该有重试逻辑,因为网络抖动、服务重启等情况都可能导致瞬时连接失败:

go
package main

import (
	"fmt"
	"time"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func connectDB(dsn string, maxRetries int) (*gorm.DB, error) {
	var db *gorm.DB
	var err error

	for i := 0; i < maxRetries; i++ {
		db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
		if err == nil {
			sqlDB, _ := db.DB()
			if err = sqlDB.Ping(); err == nil {
				return db, nil
			}
		}
		fmt.Printf("Connect DB failed (attempt %d/%d): %v\n", i+1, maxRetries, err)
		time.Sleep(2 * time.Second)
	}
	return nil, fmt.Errorf("after %d retries: %w", maxRetries, err)
}

func main() {
	dsn := "root:root123@tcp(mysql:3306)/myapp?parseTime=true&charset=utf8mb4"
	db, err := connectDB(dsn, 10)
	if err != nil {
		panic(err)
	}
	fmt.Println("Database connected!")
	_ = db
}

五、环境变量管理

1. environment 直接写

yaml
services:
  app:
    environment:
      - DB_HOST=mysql
      - DB_PORT=3306
      - DB_USER=root
      - DB_PASSWORD=root123
      - DB_NAME=myapp
      - REDIS_HOST=redis
      - REDIS_PORT=6379

也可以用 map 写法(更清晰):

yaml
services:
  app:
    environment:
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_USER: root
      DB_PASSWORD: root123
      DB_NAME: myapp

2. .env 文件

把敏感信息放 .env 文件(不提交到 git),Compos 会自动加载同目录下的 .env

text
# .env
DB_ROOT_PASSWORD=root123
DB_NAME=myapp
REDIS_PASSWORD=
APP_PORT=8080

docker-compose.yml 中用 ${VAR} 引用:

yaml
services:
  app:
    ports:
      - "${APP_PORT}:8080"
    environment:
      DB_PASSWORD: ${DB_ROOT_PASSWORD}
      DB_NAME: ${DB_NAME}

  mysql:
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}

也可以用 env_file 加载多个文件:

yaml
services:
  app:
    env_file:
      - .env.common
      - .env.development

3. 多环境配置

通过 ${VAR:-default} 提供默认值:

yaml
services:
  app:
    image: myapp:${TAG:-latest}
    environment:
      DB_PASSWORD: ${DB_PASSWORD:-defaultpass}

调用时指定:

bash
TAG=v1.2.3 DB_PASSWORD=secret docker compose up

六、数据卷挂载

1. 为什么需要数据卷

容器是临时的,删除容器后容器内可写层的数据会丢失。对于数据库这类需要持久化的服务,必须把数据放在数据卷(volume)中。

2. 命名卷

在顶层 volumes 声明,服务中挂载:

yaml
services:
  mysql:
    image: mysql:8
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

命名卷由 Docker 管理,删除容器后卷依然存在,docker compose down -v 才会删除卷。

3. 绑定挂载(bind mount)

把宿主机目录直接挂载到容器,常用于开发环境源码热重载:

yaml
services:
  app:
    build: .
    volumes:
      - ./:/app           # 当前目录挂载到容器 /app
      - /app/.git         # 排除(用匿名卷覆盖)
    working_dir: /app
    command: air

./:/app 把宿主机当前目录映射到容器 /app,容器内对 /app 的修改会同步到宿主机,反之亦然。

4. 常见挂载场景

yaml
volumes:
  # 命名卷:持久化数据
  - mysql_data:/var/lib/mysql

  # 绑定挂载:开发时挂源码
  - ./src:/app/src

  # 只读挂载:配置文件只读
  - ./config/nginx.conf:/etc/nginx/nginx.conf:ro

  # 匿名卷:屏蔽容器内目录
  - /app/node_modules

七、网络配置

1. 默认网络

不显式配置时,Compose 会创建一个名为 <项目名>_default 的 bridge 网络,所有服务都在这个网络里,可以用服务名互相访问。

2. 自定义网络

yaml
services:
  app:
    networks:
      - frontend
      - backend

  mysql:
    networks:
      - backend

  redis:
    networks:
      - backend

  nginx:
    networks:
      - frontend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true   # 内部网络,不能访问外网

这种隔离设计让 nginx 只能访问 app(通过 frontend 网络),不能直接访问 mysql/redis(它们在 backend 网络,且 backend 是 internal)。

3. 网络配置项

yaml
networks:
  mynet:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16
          gateway: 172.20.0.1

一般用默认配置即可,特殊需求才需要自定义子网。

八、常用命令

1. 启动与停止

bash
# 前台启动(日志直接输出到终端)
docker compose up

# 后台启动
docker compose up -d

# 启动并强制重新构建镜像
docker compose up -d --build

# 只启动指定服务(及其依赖)
docker compose up -d app

# 停止并删除容器、网络(保留卷)
docker compose down

# 停止并删除容器、网络、卷(慎用,会删数据)
docker compose down -v

# 只停止,不删除
docker compose stop

# 重新启动
docker compose start
docker compose restart

2. 查看状态

bash
# 查看运行中的服务
docker compose ps

# 查看所有服务(包括已停止的)
docker compose ps -a

# 查看日志
docker compose logs
docker compose logs -f           # 实时跟踪
docker compose logs -f app       # 只看 app 服务
docker compose logs --tail=100 app

3. 在运行中的服务执行命令

bash
# 进入 app 服务的 shell
docker compose exec app sh

# 在 mysql 服务执行 SQL
docker compose exec mysql mysql -uroot -proot123 myapp -e "SHOW TABLES;"

# 在 app 服务执行 Go 命令
docker compose exec app go test ./...

4. 构建与镜像

bash
# 构建或重建服务镜像
docker compose build

# 构建时不使用缓存
docker compose build --no-cache

# 查看服务使用的镜像
docker compose images

5. 其他

bash
# 查看服务配置(解析后的最终 yml)
docker compose config

# 查看服务进程
docker compose top

# 暂停服务(不释放内存)
docker compose pause
docker compose unpause

九、开发环境热重载:air + volume 挂载

开发时每次改代码都要重新构建镜像非常低效。利用 volume 挂载 + air 可以实现热重载:在容器内监听文件变化,自动重新编译运行。

1. 安装 air

在项目中创建 .air.toml 配置文件:

toml
# .air.toml
root = "."
tmp_dir = "tmp"

[build]
cmd = "go build -o ./tmp/main ."
bin = "./tmp/main"
full_bin = "./tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "frontend"]
include_dir = []
delay = 500

[log]
time = true

2. Dockerfile.dev(开发用)

dockerfile
FROM golang:1.22-alpine

WORKDIR /app

# 安装 air
RUN go install github.com/cosmtrek/air@latest

# 先拷依赖
COPY go.mod go.sum ./
RUN go mod download

# 源码通过 volume 挂载,不 COPY
CMD ["air", "-c", ".air.toml"]

3. docker-compose.yml(开发版)

yaml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "8080:8080"
    volumes:
      - ./:/app           # 源码挂载
      - go_mod:/go/pkg/mod  # Go 模块缓存持久化
    environment:
      DB_HOST: mysql
      DB_PASSWORD: root123
      REDIS_HOST: redis
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

  mysql:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: myapp
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  mysql_data:
  go_mod:

启动:

bash
docker compose up -d
docker compose logs -f app
# 改一行代码,几秒后容器内的 air 自动重新编译并重启

十、多环境配置:docker-compose.override.yml

Compose 默认会读取两个文件:

  • docker-compose.yml:基础配置。
  • docker-compose.override.yml:覆盖配置(如果存在)。

两者会自动合并,override 文件的配置覆盖基础配置。利用这个机制可以实现多环境配置。

1. 基础文件 docker-compose.yml

yaml
services:
  app:
    build: .
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis
    depends_on:
      - mysql
      - redis

  mysql:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:7-alpine

volumes:
  mysql_data:

2. 开发覆盖 docker-compose.override.yml

yaml
services:
  app:
    build:
      dockerfile: Dockerfile.dev
    ports:
      - "8080:8080"
    volumes:
      - ./:/app
    command: air

  mysql:
    ports:
      - "3306:3306"

  redis:
    ports:
      - "6379:6379"

执行 docker compose up,会自动合并这两个文件,开发环境带端口映射、热重载、源码挂载。

3. 生产覆盖 docker-compose.prod.yml

yaml
services:
  app:
    image: myregistry/myapp:v1.2.3
    restart: always
    ports:
      - "80:8080"
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "0.5"

  mysql:
    restart: always
    ports: !reset []   # 清空端口映射

通过 -f 指定文件:

bash
# 生产环境
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

4. 环境变量驱动

也可以用环境变量在一份文件中区分环境:

yaml
services:
  app:
    image: myapp:${TAG:-latest}
    restart: ${RESTART_POLICY:-no}
    ports:
      - "${APP_PORT:-8080}:8080"
bash
# 开发
docker compose up

# 生产
TAG=v1.2.3 RESTART_POLICY=always APP_PORT=80 docker compose up -d

十一、完整示例:Go + MySQL + Redis 三服务编排

下面给出一个完整的、可直接运行的三服务编排示例。

1. 项目结构

text
compose-demo/
├── main.go
├── go.mod
├── go.sum
├── Dockerfile
├── docker-compose.yml
├── .env
├── .dockerignore
└── sql/
    └── init.sql

2. main.go(完整可运行)

go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/redis/go-redis/v9"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

type User struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
}

type App struct {
	db    *gorm.DB
	rdb   *redis.Client
	ctx   context.Context
}

func (a *App) healthHandler(c *gin.Context) {
	// 检查 MySQL
	sqlDB, err := a.db.DB()
	dbOK := "ok"
	if err != nil || sqlDB.Ping() != nil {
		dbOK = "fail"
	}

	// 检查 Redis
	redisOK := "ok"
	if a.rdb.Ping(a.ctx).Err() != nil {
		redisOK = "fail"
	}

	status := http.StatusOK
	if dbOK != "ok" || redisOK != "ok" {
		status = http.StatusServiceUnavailable
	}

	c.JSON(status, gin.H{
		"db":    dbOK,
		"redis": redisOK,
	})
}

func (a *App) createUserHandler(c *gin.Context) {
	var u User
	if err := c.ShouldBindJSON(&u); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := a.db.Create(&u).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	// 清除缓存
	a.rdb.Del(a.ctx, "users:all")
	c.JSON(http.StatusCreated, u)
}

func (a *App) listUsersHandler(c *gin.Context) {
	// 先查缓存
	cached, err := a.rdb.Get(a.ctx, "users:all").Result()
	if err == nil {
		var users []User
		if json.Unmarshal([]byte(cached), &users) == nil {
			c.JSON(http.StatusOK, gin.H{"users": users, "source": "cache"})
			return
		}
	}

	// 缓存未命中,查数据库
	var users []User
	if err := a.db.Find(&users).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	// 写缓存
	data, _ := json.Marshal(users)
	a.rdb.Set(a.ctx, "users:all", data, 30*time.Second)

	c.JSON(http.StatusOK, gin.H{"users": users, "source": "db"})
}

func connectDB(dsn string) (*gorm.DB, error) {
	var db *gorm.DB
	var err error
	for i := 0; i < 15; i++ {
		db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
		if err == nil {
			sqlDB, _ := db.DB()
			if err = sqlDB.Ping(); err == nil {
				return db, nil
			}
		}
		fmt.Printf("DB connect retry %d: %v\n", i+1, err)
		time.Sleep(2 * time.Second)
	}
	return nil, err
}

func connectRedis(addr string) (*redis.Client, error) {
	client := redis.NewClient(&redis.Options{Addr: addr})
	for i := 0; i < 15; i++ {
		if err := client.Ping(context.Background()).Err(); err == nil {
			return client, nil
		} else {
			fmt.Printf("Redis connect retry %d: %v\n", i+1, err)
		}
		time.Sleep(2 * time.Second)
	}
	return nil, fmt.Errorf("redis connect failed")
}

func main() {
	dbHost := getEnv("DB_HOST", "mysql")
	dbPort := getEnv("DB_PORT", "3306")
	dbUser := getEnv("DB_USER", "root")
	dbPass := getEnv("DB_PASSWORD", "root123")
	dbName := getEnv("DB_NAME", "myapp")
	redisHost := getEnv("REDIS_HOST", "redis")
	redisPort := getEnv("REDIS_PORT", "6379")
	port := getEnv("PORT", "8080")

	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4",
		dbUser, dbPass, dbHost, dbPort, dbName)

	db, err := connectDB(dsn)
	if err != nil {
		panic(err)
	}
	db.AutoMigrate(&User{})

	rdb, err := connectRedis(fmt.Sprintf("%s:%s", redisHost, redisPort))
	if err != nil {
		panic(err)
	}

	app := &App{db: db, rdb: rdb, ctx: context.Background()}

	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	r.Use(gin.Logger(), gin.Recovery())

	r.GET("/health", app.healthHandler)
	r.POST("/users", app.createUserHandler)
	r.GET("/users", app.listUsersHandler)

	fmt.Printf("Server starting on port %s\n", port)
	r.Run(":" + port)
}

func getEnv(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

3. go.mod

go
module compose-demo

go 1.22

require (
	github.com/gin-gonic/gin v1.9.1
	github.com/redis/go-redis/v9 v9.4.0
	gorm.io/driver/mysql v1.5.4
	gorm.io/gorm v1.25.5
)

4. Dockerfile

dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app .

FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /build/app /app/app
ENV TZ=Asia/Shanghai
EXPOSE 8080
ENTRYPOINT ["/app/app"]

5. sql/init.sql

sql
-- 数据库由 MYSQL_DATABASE 环境变量自动创建
-- 这里只放初始化数据
USE myapp;

CREATE TABLE IF NOT EXISTS users (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(200) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email) VALUES
('Alice', 'alice@example.com'),
('Bob', 'bob@example.com');

6. .env

text
DB_PASSWORD=root123
DB_NAME=myapp
APP_PORT=8080

7. docker-compose.yml

yaml
services:
  app:
    build: .
    ports:
      - "${APP_PORT:-8080}:8080"
    environment:
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_USER: root
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME}
      REDIS_HOST: redis
      REDIS_PORT: "6379"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  mysql:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${DB_PASSWORD}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

volumes:
  mysql_data:
  redis_data:

8. 启动与测试

bash
# 启动
docker compose up -d --build

# 查看状态
docker compose ps
# NAME                STATUS                   PORTS
# compose-demo-app-1  Up (healthy)             0.0.0.0:8080->8080/tcp
# compose-demo-mysql-1 Up (healthy)            0.0.0.0:3306->3306/tcp
# compose-demo-redis-1 Up (healthy)            0.0.0.0:6379->6379/tcp

# 健康检查
curl http://localhost:8080/health
# {"db":"ok","redis":"ok"}

# 查询用户(首次走 DB)
curl http://localhost:8080/users
# {"users":[...],"source":"db"}

# 再次查询(走缓存)
curl http://localhost:8080/users
# {"users":[...],"source":"cache"}

# 创建用户
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Charlie","email":"charlie@example.com"}'

# 查看日志
docker compose logs -f app

# 进入 MySQL
docker compose exec mysql mysql -uroot -proot123 myapp

# 停止
docker compose down
# 停止并删除数据
docker compose down -v

十二、小结

本篇系统讲解了 Docker Compose 的用法。

关键要点:

  1. Compose 解决多服务编排:一份 YAML 描述整个环境,一条命令启停。
  2. 服务依赖与健康检查:用 depends_on + condition: service_healthy 保证依赖就绪,但应用内仍要有重试逻辑。
  3. 环境变量管理:用 .env 文件分离敏感信息,${VAR:-default} 提供默认值。
  4. 数据卷:命名卷持久化数据,绑定挂载做开发热重载。
  5. 网络:默认网络即可满足大多数需求,复杂场景用自定义网络隔离。
  6. 热重载:air + volume 挂载让开发体验接近本地。
  7. 多环境:用 docker-compose.override.yml 区分开发/生产配置。

下一篇我们将讨论容器化 Go 应用的最佳实践,包括十二要素应用、配置管理、日志处理、优雅关停、安全加固等生产级话题。