阶段二 · Docker 工程实践

多容器编排 / 网络 / Volume / 环境分离

前置回顾

第 05 篇讲过:容器之间默认可以通过 docker0 网桥互通,但 docker run 只能管一个容器.
第 01 篇讲过:veth pair + bridge 组网是需要手动配置的.
Compose 的设计目标就是把这些手动操作自动化--你只需要声明"有哪些服务,它们怎么关联",Compose 替你创建网络,启动容器,配置 DNS.

为什么需要 Compose

一个典型的 Web 应用通常不止一个容器:

一个博客系统需要:
├─ web 容器(处理 HTTP 请求)
├─ api 容器(业务逻辑)
├─ db 容器(PostgreSQL)
└─ redis 容器(缓存)

如果用纯 docker run:
# 1. 创建网络
docker network create blog-net

# 2. 启动 db(记住各种参数)
docker run -d --name db --network blog-net \
-e POSTGRES_PASSWORD=xxx \
-v blog-pgdata:/var/lib/postgresql/data \
postgres:16

# 3. 启动 redis
docker run -d --name redis --network blog-net \
-v blog-redis:/data \
redis:7-alpine

# 4. 启动 api
docker run -d --name api --network blog-net \
-e DB_HOST=db -e REDIS_HOST=redis \
myapp-api:latest

# 5. 启动 web
docker run -d --name web --network blog-net \
-p 80:80 \
myapp-web:latest

# 停止时还要按顺序一个个关
# 每次启动都要记住所有参数
# 换一台机器要重敲所有命令

用 Compose,上面所有操作变成一个文件 + 一条命令:

# compose.yml -- 全部描述在这个文件里
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: xxx
volumes:
- pgdata:/var/lib/postgresql/data

redis:
image: redis:7-alpine
volumes:
- redisdata:/data

api:
build: ./api # 也可以用 Dockerfile 构建
environment:
DB_HOST: db # 服务名直接当域名用
REDIS_HOST: redis

web:
build: ./web
ports:
- "80:80"

volumes: # 声明命名卷
pgdata:
redisdata:

# 启动:docker compose up -d
# 停止:docker compose down
# 重建:docker compose up -d --build

docker run 是逐个拧螺丝--每启动一个容器你都要亲手指定网络,端口,环境变量,卷.
Compose 是流水线--你把设计图纸(compose.yml)画好,一条命令整个生产线跑起来.图纸可以放进 git 版本控制,任何人拉下来一键启动.

核心概念与 YAML 结构

最小的 compose.yml 长什么样

# compose.yml 的顶层三大块
services: # ← 容器 = 服务.一个 service = 一组相同的容器(可以扩缩容)
web: # 这个服务叫 "web"
image: nginx # 用什么镜像
ports: # 端口映射
- "8080:80"

api: # 这个服务叫 "api"
build: . # 从当前目录的 Dockerfile 构建
environment: # 环境变量
- PORT=3000

networks: # ← 网络.不声明的话 Compose 自动创建一个 default 网络
backend:

volumes: # ← 数据卷.不声明的话 Compose 自动创建命名卷
db-data:

docker compose vs docker-compose(V2 vs V1)

你可能会在教程里同时看到两种写法--它们不是两个等效命令,而是同一产品的两个大版本:

对比项 V1 docker-compose(横杠) V2 docker compose(空格)
本质 独立 Python 脚本,需 pip install 单独安装 Docker CLI 内置插件(Go 重写),装 Docker Desktop 就自带
命令格式 docker-compose up -d docker compose up -d
维护状态 2023 年 7 月停止维护 活跃维护,当前标准
version 字段 需要 version: "3.8" 不再需要,可以删掉
depends_on condition 部分支持 完整支持 service_healthy
--profile 不支持 支持

本教程全部使用 V2(空格). 如果你的 docker-compose(横杠)报 command not found,说明机器上只有 V2,把横杠换成空格就行.目前最新版 Docker Desktop 会把两个命令都指向同一个插件,两者都能用,但习惯上用空格版.

docker compose up 背后做了什么

回顾第 05 篇的 docker run 全流程(dockerd → containerd → runc → kernel).Compose 不是取代这个链--它是这个链的"批量调用管理器":

docker compose up -d 背后:

  1. 解析 compose.yml → 整理出 services / networks / volumes
  2. 创建 network(等于 docker network create xxx)
  3. 创建 volume(等于 docker volume create xxx)
  4. 按依赖顺序启动容器(depends_on 决定先后)
  5. 每个容器走完整的 docker run 流程(dockerd → containerd → runc → kernel)

Compose 不是"容器运行时",它是"容器运行的编排器". 它调用 Docker API 来管理多个容器,但它自己不创建任何 Namespace/Cgroup.

depends_on 的陷阱:启动顺序 ≠ 服务就绪

Compose 用 depends_on 控制容器启动的先后顺序:

services:
api:
depends_on:
- db # db 先启,api 后启
- redis # redis 先启,api 后启

但这里有一个很容易踩的坑:depends_on 只保证容器进程先启动,不保证容器内的服务已经 ready.来看一个典型故障场景:

docker compose up -d

时间线:
T+0s: db 容器启动 → postgres 进程开始运行
T+1s: api 容器启动(depends_on 已满足--db 进程在跑)
T+1s: api 连 db:5432 → 连接成功!执行 SQL:CREATE TABLE ...
T+2s: ❌ SQL 失败!

原因:
postgres 进程虽然起来了,但数据库内部还在:
• 从 WAL(预写日志)恢复数据
• 回放上次未完成的 checkpoint
• 初始化共享内存缓冲区
→ 这个过程可能持续 5-30 秒
→ 在此期间数据库不接受查询连接
→ api 以为 db 已经 ready,实际上还没有

解决方案一:healthcheck + depends_on condition(Compose v3.9+)

healthcheck 是 Docker 容器级别的健康检查机制.它定期在容器内执行一条命令,根据命令的返回值判断容器是否"真的就绪":

services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
healthcheck: # ← 定义健康检查
test: ["CMD-SHELL", "pg_isready -U postgres"]
# │ │
# │ └── 执行这条命令,返回 0 = 健康,非 0 = 不健康
# └── 执行方式:CMD(直接执行)或 CMD-SHELL(通过 shell 执行)
interval: 5s # 每 5 秒检查一次
timeout: 3s # 单次检查超过 3 秒视为失败
retries: 5 # 连续失败 5 次后标记为 unhealthy
start_period: 10s # 容器启动后 10 秒内不检查(给初始化留时间)

api:
depends_on:
db:
condition: service_healthy # ← 等 db 的 healthcheck 通过后才启动 api
# │
# └── 可选值:
# service_started (默认)容器启动就算
# service_healthy 容器的 healthcheck 通过才算
# service_completed_successfully (一次性任务成功退出才算)

healthcheck 的状态变化过程:

容器启动 → starting(start_period 内不检查)
→ 第一次检查 pg_isready 返回 1(还没好)→ 状态仍是 starting
→ 第二次检查 pg_isready 返回 1 → 仍是 starting
→ 第三次检查 pg_isready 返回 0 → healthy!← depends_on 条件满足,api 启动
# 查看容器的健康状态
docker compose ps
# NAME STATUS
# db Up 12s (healthy) ← 通过了 healthcheck
# api Up 3s

# 如果一直 unhealthy,查看原因
docker inspect <container> --format '{{json .State.Health}}' | python3 -m json.tool
# {
# "Status": "unhealthy",
# "FailingStreak": 5,
# "Log": [
# {"Output": "pg_isready: connection refused", "ExitCode": 2},
# ...
# ]
# }

常见服务的 healthcheck 写法

服务 healthcheck 命令 说明
PostgreSQL pg_isready -U postgres PostgreSQL 自带的就绪检测工具
MySQL mysqladmin ping -h localhost MySQL 自带的管理工具
Redis redis-cli ping 返回 PONG 说明就绪
HTTP 服务 curl -f http://localhost:8080/health 应用暴露 /health 端点,返回 200 即健康
通用 TCP nc -z localhost 8080 仅检查端口是否监听,不保证业务逻辑正常

解决方案二:应用层 retry(更健壮)

即使有 healthcheck,数据库也可能在运行中途挂了.所以 healthcheck + retry 是双保险,不是二选一:

// 应用启动时,连接失败不要立刻崩溃
// 等几秒重试--无论 healthcheck 过没过,这层保护都值得加

func connectDB(dsn string) *sql.DB {
for i := 0; i < 10; i++ {
db, err := sql.Open("postgres", dsn)
if err == nil && db.Ping() == nil {
return db
}
time.Sleep(2 * time.Second)
}
panic("database not reachable after 10 retries")
}

服务间网络与 DNS

服务名就是域名--这是 Compose 最有价值的功能

回顾第 05 篇:Docker 用 docker0 网桥 + iptables 实现容器间通信.但容器之间怎么知道彼此的 IP?手动查 IP 再写到配置文件里?容器重启 IP 就变了.

Compose 的解决方案:内置 DNS 服务器.同一个 compose 文件里的所有容器自动加入一个默认网络,且 服务名自动成为 DNS 域名:

services:
api:
build: ./api
environment:
DB_HOST: db # ← 直接写服务名!
DB_PORT: "5432" # ← 不需要查 IP
REDIS_HOST: redis # ← redis 的 IP 也不需要

db:
image: postgres:16

redis:
image: redis:7

# api 容器内的进程访问 db:直接连接 db:5432
# DNS 解析过程:
# api → 发起连接 "db:5432"
# → 内置 DNS 查 "db" → 解析为 db 容器的内网 IP(如 172.18.0.3)
# → 连接成功
#
# db 容器重启后 IP 变了(变成 172.18.0.4)?
# → DNS 自动更新 → api 仍然连 "db:5432",无感知!

# 对比不用 Compose:
# docker run --name db ... → IP 可能每次不同
# docker run --name api -e DB_HOST=172.17.0.3 ... → IP 写死了,重启就挂

自定义网络:隔离不同业务组

services:
api:
networks:
- backend # ← api 只连接 backend 网
- frontend # ← 和 frontend 网

db:
networks:
- backend # ← db 只连 backend 网
# 前端流量永远到不了 db

web:
networks:
- frontend # ← web 只连 frontend 网

# 用 docker compose exec api ping db → 通(同 backend 网)
# 用 docker compose exec api ping web → 通(api 也接了 frontend)
# 用 docker compose exec db ping web → 不通(db 不在 frontend)

networks:
frontend:
backend:

Volume 与数据持久化

回顾第 04/05 篇:容器的可写层(upperdir)在容器删除后就被销毁.数据库的数据不能存在可写层--容器一删数据就没了.Volume 是独立于容器生命周期的存储,数据存在宿主机上,挂载到容器内的指定路径.

命名卷的完整生命周期

先看一个最简单的命名卷配置,然后跟踪它从创建到销毁的全过程:

services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data # ← 命名卷
# │ │
# └── 卷名 └── 容器内路径

这个配置背后实际发生了什么?完整的生命周期:

1. docker compose up -d

Compose 看到你引用了一个叫 pgdata 的卷
→ 检查宿主机上是否已存在此卷
→ 没有 → 自动 docker volume create
→ 在宿主机上创建目录:/var/lib/docker/volumes/项目名_pgdata/_data/
→ 注意:实际卷名会加上项目名前缀,避免不同项目冲突

2. db 容器启动时,Docker 做了一次 bind mount:

mount --bind /var/lib/docker/volumes/项目名_pgdata/_data \
/var/lib/postgresql/data

→ 容器内 /var/lib/postgresql/data 指向宿主机的 _data 目录
→ postgres 写入的所有数据,实际落到了宿主机上
→ 这些数据不在容器的可写层(upperdir)里!

3. docker compose down

容器删除 → 可写层销毁
️但 /var/lib/docker/volumes/项目名_pgdata/_data 完好
→ 数据没丢!

4. docker compose up -d(重新启动)

卷 pgdata 已存在 → 直接复用,不再创建
→ postgres 看到上次的数据全在
→ 这就是"持久化"的本质

5. docker compose down -v

容器删除 + 卷也删除
→ /var/lib/docker/volumes/项目名_pgdata/ 整个目录被销毁
→ 数据永久丢失

用一张图理解关键:卷的数据在宿主机文件系统上,不在容器的 OverlayFS 里.

宿主机                               容器内(merged 视图)

/var/lib/docker/volumes/ /
myapp_pgdata/ ├─ var/
_data/ ← 数据真实物理位置 │ └─ lib/
├─ PG_VERSION │ └─ postgresql/
├─ base/ │ └─ data/ ← 指向宿主机 _data
└─ pg_wal/ │ 不是 upperdir!

容器删了 → 这部分没了
但宿主机 _data 还在 → 数据完好
# 查看卷的详细信息(包括物理路径)
docker volume inspect myapp_pgdata
# {
# "Name": "myapp_pgdata",
# "Driver": "local",
# "Mountpoint": "/var/lib/docker/volumes/myapp_pgdata/_data"
# }

顶层 volumes: 为什么"可选"

教程前面的例子里有顶层的 volumes: 声明:

volumes:                     # ← 这是顶层声明
pgdata:
redisdata:

"可选"的意思不是"写了和没写不一样"--而是 Compose 会自动补.
当你在 service 里写了 - pgdata:/var/lib/...,Compose 发现顶层没声明这个卷,它不报错,自动帮你创建,效果等于隐式补了顶层的 volumes: pgdata:.
所以日常使用中写不写顶层声明效果完全相同.

顶层声明的真正用途有两个场景:

# 场景一:引用宿主机上已有的外部卷
volumes:
shared-data:
external: true # ← 告诉 Compose:"别新建!用已经存在的那个!"
# 不加这句,Compose 会新建一个名为 项目名_shared-data 的卷
# 那就不是你手动建的 shared-data 了

# 场景二:指定卷的物理存储位置
volumes:
pgdata:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/fast-ssd/pgdata # ← 让这个卷存到指定磁盘路径
# └── 比如你有一块 SSD 专门放数据库

如果你不需要这两个场景,顶层声明写了纯粹是为了让 compose.yml 读起来更完整--一眼看清这个项目依赖哪些持久化资源.效果上写不写都一样.

macOS 上怎么配最方便

macOS 的特殊性:Docker Desktop 跑在一个隐藏的 Linux 虚拟机里. 你本机没有 /var/lib/docker/--它只存在于 VM 内部.命名卷默认路径在 VM 里,从 macOS 终端和 Finder 都看不到.

┌────────────────────────────────────────────────┐
│ Linux VM (Docker Desktop) │
├────────────────────────────────────────────────┤
│ • dockerd + 所有容器 │
│ • /var/lib/docker/volumes/
(你看不到这个路径) │
└────────────────────────────────────────────────┘

如果你希望数据直接出现在 macOS 的 ~/docker_data/ 下,有 两种方式:

# 方式一:bind mount 用 ~(最简单,macOS 开发首选)
services:
db:
image: postgres:16
volumes:
- ~/docker_data/pgdata:/var/lib/postgresql/data
# └── macOS 上 ~ 自动展开为 /Users/yutong
# docker compose up 后数据直接出现在 ~/docker_data/pgdata/

redis:
image: redis:7
volumes:
- ~/docker_data/redisdata:/data

# 优点:零额外配置,Finder 能直接看到文件
# 缺点:不是"命名卷",docker volume ls 看不到;路径写死了,换机器要改
# 方式二:命名卷 + .env 指定路径(推荐给想保留命名卷心智模型的人)
# compose.yml
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data

volumes:
pgdata:
driver: local
driver_opts:
type: none
o: bind
device: ${DATA_DIR:-~/docker_data}/pgdata

# .env(和 compose.yml 同级,Compose 自动读取)
DATA_DIR=~/docker_data

# 优点:docker volume ls 可见;通过 .env 换机器只改一行
# 缺点:比纯 bind mount 多几行;底层还是 bind mount,性能没额外提升

macOS 上学习,用方式一就够. 数据就在 ~/docker_data/ 下,Finder 能看到,删了重建也方便.等部署到 Linux 服务器上再把路径去掉,让 Docker 自动管理.

macOS bind mount 的路径限制

bind mount 的宿主机路径必须是 Docker Desktop 已共享的目录(Settings → Resources → File Sharing).
默认共享:/Users,/tmp,/Volumes,/private.
所以 ~/docker_data/ 能用(在 /Users 下),但 /opt/data 会报错.
另外 macOS 上 bind mount 比 Linux 原生慢--开发热更新够用,数据库生产不推荐.

bind mount - 开发环境用

services:
api:
build: ./api
volumes:
- ./api/src:/app/src # ← bind mount
# │ │
# │ └── 容器内路径
# └── 宿主机相对路径(相对于 compose.yml 所在目录)
#
# 改了宿主机上的代码 → 容器里立刻生效(同一份文件)
# 适合开发环境:改代码即刷新,不需要重新 build

- /app/node_modules # ← 匿名卷:保持容器内的 node_modules
# 不映射到宿主机,防止被宿主机的空 node_modules 覆盖

# bind mount 风险:
# • 容器进程以 root 运行 → 可以修改宿主机文件
# • 路径硬编码 → 换机器可能路径不对
# • 生产环境应避免--应用应通过 docker build 打包进镜像,不依赖外部目录

命名卷 vs bind mount 速查

命名卷 bind mount
写法 pgdata:/var/lib/... ~/data/pgdata:/var/lib/...
数据存在哪 Docker 管理(/var/lib/docker/volumes/) 你指定的宿主机路径
docker volume ls 可见
可迁移到其他机器 ✅(docker volume 导出) ❌(路径硬编码)
权限管理 Docker 自动处理 宿主机什么权限就什么权限
典型场景 生产数据库 开发热更新

环境分离:开发 vs 生产

多 compose 文件叠加

这是 Compose 最优雅的设计之一.不同环境的差异用"覆盖文件"表达,基础配置放在主干文件:

# compose.yml(基础配置,所有环境共享)
services:
api:
build: .
environment:
NODE_ENV: production

db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data

volumes:
pgdata:
# compose.dev.yml(开发环境覆盖)
services:
api:
build:
target: dev # 用 Dockerfile 的 dev stage
environment:
NODE_ENV: development
volumes:
- ./src:/app/src # 代码热更新
ports:
- "3000:3000"

db:
ports:
- "5432:5432" # 开发时可以本地直连数据库

# 开发环境额外加一个调试工具
adminer:
image: adminer
ports:
- "8080:8080
# compose.prod.yml(生产环境覆盖)
services:
api:
restart: always # 崩溃自动重启
deploy:
resources:
limits:
cpus: "2"
memory: 512M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

db:
restart: always
deploy:
resources:
limits:
memory: 1G
# 使用方式:多个 -f 参数叠加
docker compose -f compose.yml -f compose.dev.yml up -d
# Compose 按顺序叠加:后面的文件覆盖前面同名配置

# 开发环境
docker compose -f compose.yml -f compose.dev.yml up -d --build

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

# 停止
docker compose -f compose.yml -f compose.dev.yml down

profiles:按需启停服务

核心规则:三条就够

  1. profile 名称完全自定义--debug,tools,monitoring,heavy,叫什么都可以,只要 YAML 里写的和命令行传的一致就行
  2. 没写 profiles: 的服务永远启动--它们属于隐式的"默认组",docker compose up 就跑
  3. 写了 profiles: 的服务只有显式开启才启动--不传对应的 --profile 就不会启动

基本用法

services:
api:
build: . # ← 没写 profiles → 永远启动

db:
image: postgres:16 # ← 没写 profiles → 永远启动

adminer: # 数据库 Web 管理界面(调试用)
image: adminer
ports: ["8080:8080"]
profiles:
- debug # ← 只在传了 --profile debug 时启动

prometheus: # 监控(吃资源,平时不跑)
image: prom/prometheus
profiles:
- monitoring # ← 只在传了 --profile monitoring 时启动
# 日常运行--只启动 api + db
docker compose up -d
# adminer 和 prometheus 不会启动,因为它们被 profile 标记了

# 调试时--多带一个 profile
docker compose --profile debug up -d
# 启动:api + db + adminer

# 性能排查时--带另一个 profile
docker compose --profile monitoring up -d
# 启动:api + db + prometheus

# 一次性带多个 profile
docker compose --profile debug --profile monitoring up -d
# 启动:api + db + adminer + prometheus

# 启动所有带 profile 的服务(* 表示全部)
docker compose --profile "*" up -d

一个服务可以属于多个 profile

services:
adminer:
image: adminer
profiles:
- debug # ← 传 --profile debug 也启动
- tools # ← 传 --profile tools 也启动
# 任意一个 match 就启动
# 这两种方式都能启动 adminer:
docker compose --profile debug up -d
docker compose --profile tools up -d

用环境变量预设 profile

有些 profile 你希望每次都生效(比如开发环境的调试工具),可以在 .env 里预设,省去每次敲 --profile:

# .env
COMPOSE_PROFILES=debug,tools

# 现在直接 up 就等于带了 --profile debug --profile tools
docker compose up -d

常见用法:按重量级拆分

services:
api:
build: .

db:
image: postgres:16

redis:
image: redis:7

# -- 以下按需启动 --

adminer:
image: adminer
profiles: [debug]

mailhog: # 邮件调试
image: mailhog/mailhog
profiles: [debug]

prometheus:
image: prom/prometheus
profiles: [monitoring]

grafana:
image: grafana/grafana
profiles: [monitoring]

elasticsearch: # 日志搜索(很吃内存)
image: elasticsearch:8
profiles: [heavy]

kafka: # 消息队列(不常用)
image: confluentinc/cp-kafka
profiles: [heavy]

然后三个场景三条命令:

# 日常开发
docker compose up -d # api + db + redis

# 调试数据库
docker compose --profile debug up -d # + adminer + mailhog

# 排查性能问题
docker compose --profile monitoring up -d # + prometheus + grafana

# 全套(包括吃资源的)
docker compose --profile "*" up -d # + 全部

profile 本质上是给服务打标签,然后按标签过滤.

  • 没打标签的服务属于"默认组",永远启动.
  • 打了标签的服务是"按需组",只有你显式选了对应标签才启动.

这和 docker compose -f compose.yml -f compose.dev.yml 的多文件叠加并不冲突--二者可以组合使用:文件叠加负责"哪些服务的配置不同",profile 负责"哪些服务要不要启动".

生产环境注意事项

Compose 能用于生产吗?可以,但要知道它的边界:

场景 Compose 够用? 说明
单机部署 Compose 的核心场景--一台机器上跑多容器应用
多机 / 集群 Compose 不会跨节点调度容器--这是 K8s 的领域
零停机更新 有限 docker compose up -d --no-deps api 可以逐个更新,但没有滚动更新策略
健康检查 healthcheck + depends_on: condition: service_healthy
日志聚合 ⚠️ 每个容器的日志仍独立,需要额外工具(如 Loki + Promtail)
Secrets 管理 Compose 支持 secrets: 顶层声明(但文件模式,不如 K8s Secrets 完善)

小结

本篇要点回顾

要点 一句话概括
Compose 的定位 批量管理 docker run 的编排器--用 YAML 描述多容器应用,一键启动/停止
服务名 = DNS 域名 同一 compose 的容器通过服务名互相发现,重启后 IP 变了自动更新
自定义网络 不同 service 可以接入不同 network,实现流量隔离(db 不暴露给前端网络)
命名卷 数据存于宿主机 /var/lib/docker/volumes/(macOS 在 Linux VM 内),独立于容器生命周期,down 不删,down -v 才删
顶层 volumes 声明 日常使用中可选(Compose 自动补),需要引用外部已有卷或指定物理存储路径时才必须写
macOS 卷配置 ~/docker_data/pgdata:/var/lib/... 最简单;命名卷 + driver_opts: device: 可保留命名卷心智模型;纯命名卷在 VM 内,日常不可见
bind mount 宿主机目录直通容器--开发环境神器(改了代码立刻生效),生产禁用
环境分离 多文件叠加(-f compose.yml -f compose.dev.yml)让基础配置和差异配置解耦
profiles 给服务打自定义标签,没标签的永远启动,有标签的只有 --profile 显式开启才启动;一个服务可属于多个 profile;可在 .env 用 COMPOSE_PROFILES 预设
生产边界 Compose 适合单机,不适合多机编排;没有滚动更新,自动扩缩容--那是 K8s 的领域

动手练习

  1. 把之前写的那个简单 HTTP 服务(06 篇的练习)改造成一个 Compose 项目:api 服务 + redis 服务(缓存),验证服务间能通过服务名互相访问
  2. 在 compose 中加入 healthcheck,用 docker compose ps 观察健康状态的变化
  3. 创建 compose.dev.yml,用 bind mount 挂载源码目录,改代码后验证热更新;再创建 compose.prod.yml,加上 restart 策略和资源限制
  4. 把 06 篇的多阶段构建 Dockerfile 作为 compose 中某个 service 的 build 源,验证 docker compose up --build 会重新构建镜像
  5. docker compose exec sh 进入运行中的容器,测试 ping 另一个服务名,验证 DNS 解析
  6. 思考题:如果 api 服务启动时需要数据库表已创建好,depends_on 能解决吗?如果不能,有哪些方案?(提示:回顾之前讨论过的"启动顺序 ≠ 服务就绪")