多容器协同编排——用一个 YAML 描述整个应用栈:服务、网络、数据卷、环境配置
前置回顾
第 05 篇讲过:容器之间默认可以通过 docker0 网桥互通,但 docker run 只能管一个容器。第 01 篇讲过:veth pair + bridge 组网是需要手动配置的。Compose 的设计目标就是把这些手动操作自动化——你只需要声明"有哪些服务、它们怎么关联",Compose 替你创建网络、启动容器、配置 DNS。
一个典型的 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 版本控制,任何人拉下来一键启动。
# 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 会把两个命令都指向同一个插件,两者都能用,但习惯上用空格版。
回顾第 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。
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 是 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 命令 | 说明 |
|---|---|---|
| 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 |
仅检查端口是否监听,不保证业务逻辑正常 |
即使有 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")
}
回顾第 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:
回顾第 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: # ← 这是顶层声明
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 的特殊性:Docker Desktop 跑在一个隐藏的 Linux 虚拟机里。 你本机没有 /var/lib/docker/——它只存在于 VM 内部。命名卷默认路径在 VM 里,从 macOS 终端和 Finder 都看不到。
┌─ macOS ───────────────────────────────────────────────┐
│ │
│ /Users/yutong/ ← 这是你能看到的 │
│ │
│ ┌─ Linux VM ────────────────────────────────────────┐ │
│ │ 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 原生慢——开发热更新够用,数据库生产不推荐。
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 打包进镜像,不依赖外部目录
| 命名卷 | bind mount | |
|---|---|---|
| 写法 | pgdata:/var/lib/... | ~/data/pgdata:/var/lib/... |
| 数据存在哪 | Docker 管理(/var/lib/docker/volumes/) | 你指定的宿主机路径 |
| docker volume ls 可见 | ✅ | ❌ |
| 可迁移到其他机器 | ✅(docker volume 导出) | ❌(路径硬编码) |
| 权限管理 | Docker 自动处理 | 宿主机什么权限就什么权限 |
| 典型场景 | 生产数据库 | 开发热更新 |
这是 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
debug、tools、monitoring、heavy,叫什么都可以,只要 YAML 里写的和命令行传的一致就行profiles: 的服务永远启动——它们属于隐式的"默认组",docker compose up 就跑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
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 你希望每次都生效(比如开发环境的调试工具),可以在 .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 的领域 |
healthcheck,用 docker compose ps 观察健康状态的变化compose.dev.yml,用 bind mount 挂载源码目录,改代码后验证热更新;再创建 compose.prod.yml,加上 restart 策略和资源限制build 源,验证 docker compose up --build 会重新构建镜像docker compose exec <service> sh 进入运行中的容器,测试 ping 另一个服务名,验证 DNS 解析depends_on 能解决吗?如果不能,有哪些方案?(提示:回顾之前讨论过的"启动顺序 ≠ 服务就绪")