Docker 基础体系 · 第 53/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Compose 服务发现与依赖:DNS、Healthcheck、启动顺序和重连
在 Compose 中,“应用能否连接数据库”至少包含四个彼此独立的问题:
- 应用是否能通过服务名解析出数据库地址;
- 解析出的地址是否对应当前仍在运行的容器;
- 数据库进程是否已经启动并能够接受连接;
- 应用在连接断开、容器重建或地址变化后,是否会重新解析并建立连接。
depends_on 主要处理第 2、3 个问题中的启动协调,DNS 处理第 1 个问题,应用自身的连接管理处理第 4 个问题。它们不能相互替代。
一、先建立模型:Compose 中的服务发现链路
假设 Compose 项目包含两个服务:
services:
api:
image: example/api
depends_on:
- db
db:
image: postgres:16
如果没有额外配置,Compose 通常会创建一个项目级默认网络,并把 api 和 db 连接到这个网络。此时 api 容器内部可以使用:
db:5432
访问数据库。
这里的 db 不是宿主机上的 DNS 记录,也不是数据库容器的固定 IP,而是 Compose 在同一用户定义网络中提供的服务名解析。
一次典型连接过程可以抽象为:
应用调用 getaddrinfo("db", 5432)
│
▼
容器内 /etc/resolv.conf 指向 Docker 嵌入式 DNS
│
▼
Docker 根据当前网络成员关系返回 db 对应的容器 IP
│
▼
应用向该 IP 的 5432 端口建立 TCP 连接
│
▼
数据库进程完成协议握手并接受认证
其中每一步都可能单独失败:
- DNS 失败:
db无法解析; - 网络失败:解析成功,但目标 IP 不可达;
- 监听失败:数据库容器存在,但 PostgreSQL 尚未监听 5432;
- 协议或认证失败:TCP 已连通,但数据库尚未完成初始化,或账号密码错误;
- 运行时失效:连接建立后数据库重启,原有连接断开。
因此,“容器已启动”不是“服务已可用”的同义词。
二、服务发现:服务名、网络别名和容器 IP
1. 服务名是 Compose 内部连接的稳定入口
在同一个 Compose 网络中,服务可以通过服务名互相访问:
services:
api:
image: example/api
db:
image: postgres:16
应用应配置:
DB_HOST=db
DB_PORT=5432
而不是:
DB_HOST=127.0.0.1
DB_PORT=5432
容器中的 127.0.0.1 只表示当前容器自身的 loopback 接口。api 容器中的 127.0.0.1:5432 指向 api 自己,而不是 db。
也不应把宿主机映射端口用于容器间通信:
services:
db:
image: postgres:16
ports:
- "15432:5432"
这里:
- 宿主机访问数据库使用
localhost:15432; - 同一 Compose 网络中的其他容器仍应使用
db:5432。
ports 是宿主机与容器之间的发布规则,不是服务发现配置。容器间通信直接走 Docker 网络和容器端口,通常不经过宿主机发布端口。
2. 服务名解析到网络地址,而不是永久 IP
Compose 不保证某个服务名永久对应某个 IP。容器被重建后,IP 可能发生变化:
第一次:
db -> 172.20.0.3
db 容器重建后:
db -> 172.20.0.5
服务名仍然是 db,但底层地址可能变化。因此应用代码应保存服务名或主机名配置,而不是在配置文件中写死容器 IP。
错误示例:
environment:
DB_HOST: 172.20.0.3
正确示例:
environment:
DB_HOST: db
服务名稳定的是“发现入口”,不是“解析结果”。
3. 网络范围决定服务名是否可用
服务名只能在连接到相同网络、且允许彼此发现的容器之间使用。例如:
services:
api:
image: example/api
networks:
- backend
db:
image: postgres:16
networks:
- backend
networks:
backend:
此时 api 可以解析 db。
如果改成:
services:
api:
image: example/api
networks:
- frontend
db:
image: postgres:16
networks:
- backend
networks:
frontend:
backend:
两个容器没有共同网络,api 中的 db 通常无法解析或无法连通。
一个服务可以加入多个网络:
services:
api:
image: example/api
networks:
- frontend
- backend
db:
image: postgres:16
networks:
- backend
networks:
frontend:
backend:
这样 api 可以通过 backend 访问 db,但不应因此假设 frontend 上也存在 db。
4. 网络别名是网络范围内的额外名称
可以为服务设置网络别名:
services:
db:
image: postgres:16
networks:
backend:
aliases:
- database
- postgres-primary
networks:
backend:
同一 backend 网络中的容器可以使用:
db
database
postgres-primary
这些名称指向同一个服务的网络端点。别名的可见范围由网络决定,不是全局 DNS 记录。
别名适合兼容既有配置,但不宜随意为多个服务使用同一个别名。多个网络端点或多个容器共享同一名称时,解析可能返回多个地址,应用必须能够处理多地址结果;不能把别名当作必然唯一的主机名。
三、Docker 容器内 DNS:解析请求实际经过什么路径
在 Linux 容器中,可以检查解析配置:
docker compose exec api cat /etc/resolv.conf
在 Docker 管理的用户定义网络中,常见结果包含:
nameserver 127.0.0.11
options ndots:0
127.0.0.11 是 Docker 为容器提供的嵌入式 DNS 地址。它负责:
- 解析 Docker 网络中的服务名、容器名和网络别名;
- 将无法由 Docker 网络回答的外部域名请求转发给上游 DNS;
- 根据当前网络连接关系返回地址。
具体 resolv.conf 内容可能受 Docker 配置、宿主机 DNS、网络模式和运行时版本影响,因此应以容器内实际检查结果为准。
可以在容器中验证:
docker compose exec api getent hosts db
可能得到:
172.20.0.3 db
还可以验证端口连通性:
docker compose exec api sh -c 'nc -vz db 5432'
这两个命令验证的是不同层次:
getent hosts db:名称解析是否成功;nc -vz db 5432:解析之后的 TCP 端口是否能建立连接。
如果镜像没有 getent 或 nc,不能据此判断网络一定有问题。调试工具是否包含在业务镜像中是独立问题,可以临时使用带工具的调试容器加入同一网络。
ping 不是完整的服务可用性测试
下面的测试只能说明 ICMP 或名称解析相关路径:
ping db
它不能证明:
- 数据库端口正在监听;
- 数据库协议握手成功;
- 数据库接受当前账号;
- 应用执行查询时不会失败。
对于数据库,至少应验证实际服务端口,最好由数据库官方客户端执行一个轻量查询。
四、启动顺序:depends_on 到底保证什么
1. 没有依赖时,服务可以并行启动
Compose 会根据服务依赖构造有向图。若 api 和 db 没有依赖关系,Compose 可以并行创建和启动它们。
即使 YAML 中 db 写在 api 前面,也不能把文件顺序当作应用层启动顺序。YAML 的书写顺序不是可靠的依赖声明。
2. 短语法只表达“先启动依赖服务”
services:
api:
image: example/api
depends_on:
- db
db:
image: postgres:16
短语法表达的是:
创建并启动 db
↓
创建并启动 api
它通常不表达:
等待 db 完成初始化
等待 db 接受连接
等待 db 健康检查通过
因此,下列情况都可能发生:
db容器已经处于 running;- PostgreSQL 进程仍在初始化数据目录;
api启动并立即连接;api收到 connection refused、认证暂不可用或数据库未完成初始化;api因一次失败直接退出。
depends_on 解决的是 Compose 的容器操作顺序,不会自动修改应用的连接重试策略。
3. 长语法可以声明健康条件
services:
api:
image: example/api
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app-password
POSTGRES_DB: app
这里的启动路径变为:
创建 db
↓
启动 db 容器
↓
执行 db 的 healthcheck
↓
状态从 starting 变为 healthy
↓
Compose 启动 api
condition: service_healthy 的前提是依赖服务确实配置了可工作的 healthcheck。没有健康检查时,Compose 没有可等待的健康状态。
Compose 规范还定义了其他条件:
depends_on:
migration:
condition: service_completed_successfully
它表示依赖服务应成功退出,适合一次性迁移任务。其退出码必须为 0;迁移失败时,依赖它的服务不应继续启动。
service_started 表示服务已启动,效果接近短语法;service_healthy 需要健康状态;service_completed_successfully 需要依赖容器成功结束。
4. 依赖图必须是可满足的有向图
如果定义:
services:
api:
depends_on:
- db
db:
depends_on:
- api
就形成了环:
api → db → api
Compose 无法对这样的依赖进行拓扑排序,启动过程会失败。实际系统应把依赖关系建模为有向无环图,或者把相互协作改成“双方均可重试”的运行时关系,而不是用循环启动依赖强行解决。
5. 停止顺序与启动顺序相反
对于:
api depends_on db
Compose 启动时通常先启动 db,停止时通常先停止 api,再停止 db。这样可以减少应用仍在使用数据库时数据库先被关闭的情况。
但这不等价于优雅关闭已经完成。应用仍需处理 SIGTERM,停止接收新请求、释放连接池,并在超时前退出。Compose 的停止协调不能替代进程自身的关闭逻辑。
五、Healthcheck:健康状态如何产生,又不能说明什么
1. 健康检查是容器内执行的命令
示例:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
健康检查命令在目标容器内部执行,而不是在宿主机上执行。因此:
pg_isready必须存在于 PostgreSQL 镜像中;- 命令访问的
localhost表示数据库容器自身; - 检查命令应使用容器内实际监听的端口和路径。
健康检查命令的退出码通常按以下方式解释:
0:本次检查成功;1:本次检查失败;- 其他退出码:保留给运行时解释,通常也应视为失败,不应依赖未定义的特殊含义。
容器状态会经历类似过程:
starting
│
├─ 检查成功
│
├─ 连续失败达到 retries
▼
unhealthy
start_period 给服务预留启动宽限期。宽限期内的失败通常不会立即计入失败次数;一旦进入正常检查阶段,连续失败达到 retries,状态才会变为 unhealthy。
2. 健康检查必须匹配真实就绪条件
对于 PostgreSQL:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
主要检查数据库是否能够接受连接请求,但它不一定验证业务初始化是否完成。
如果服务启动还依赖迁移、表结构或特定配置,可以把检查设计为更接近应用需要的条件,例如:
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U app -d app && psql -U app -d app -tAc 'SELECT 1' | grep -q 1"
]
interval: 5s
timeout: 5s
retries: 12
start_period: 15s
这仍然不是万能证明。一个 SELECT 1 成功,只说明当前数据库实例接受该连接和查询,不代表:
- 所有业务表已存在;
- 外部依赖可用;
- 查询性能满足要求;
- 应用执行的复杂事务不会失败。
健康检查的正确含义应是:“该服务满足某个明确、有限的就绪条件”,而不是“服务永远健康”。
3. unhealthy 不会自动重启容器
下面的配置:
healthcheck:
test: ["CMD-SHELL", "some-check"]
interval: 10s
timeout: 3s
retries: 3
只会更新容器的健康状态。它不会仅因为状态变为 unhealthy 就自动重启容器。
restart: unless-stopped 主要处理容器进程退出,不等价于“健康检查失败后重启”。如果确实需要根据健康状态执行自动修复,必须使用明确的外部控制器、监控系统或编排平台机制,并评估重启是否会掩盖配置和数据问题。
4. Compose 等待健康状态,不代表运行时持续编排
当 api 使用:
depends_on:
db:
condition: service_healthy
Compose 在本次启动流程中会等待 db 变为 healthy,再启动 api。
但如果之后 db 变为 unhealthy,Compose 不会因此自动按依赖关系重启 api。应用必须能够处理运行时数据库不可用;健康检查也不应被误当作持续故障转移机制。
六、一个可运行的启动等待示例
下面的示例包含 PostgreSQL 和一个 Python 客户端。客户端只建立 TCP 连接,用于清楚展示 DNS、启动等待和运行时重连;它不是 PostgreSQL 协议客户端。
目录结构:
.
├── compose.yaml
└── app
└── app.py
compose.yaml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app-password
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 3s
timeout: 2s
retries: 20
start_period: 5s
api:
image: python:3.12-alpine
working_dir: /app
volumes:
- ./app:/app:ro
command: ["python", "/app/app.py"]
depends_on:
db:
condition: service_healthy
app/app.py
import socket
import time
HOST = "db"
PORT = 5432
def connect_once():
# 每次尝试都重新解析 HOST,避免永久使用旧地址。
addresses = socket.getaddrinfo(
HOST,
PORT,
type=socket.SOCK_STREAM,
)
last_error = None
for family, socktype, proto, _, sockaddr in addresses:
sock = socket.socket(family, socktype, proto)
sock.settimeout(3)
try:
sock.connect(sockaddr)
print(f"connected to {HOST} at {sockaddr}", flush=True)
return sock
except OSError as exc:
last_error = exc
sock.close()
raise last_error or OSError("no address succeeded")
delay = 1
while True:
try:
conn = connect_once()
delay = 1
# 示例只保持连接一段时间。
# 真实应用应在这里执行协议握手、认证和业务请求。
while True:
time.sleep(5)
print("connection is still open", flush=True)
except OSError as exc:
print(f"connection failed: {exc}; retry in {delay}s", flush=True)
time.sleep(delay)
delay = min(delay * 2, 30)
启动:
docker compose up
预期过程是:
- Compose 创建网络;
- 创建并启动
db; - PostgreSQL 初始化数据目录;
- 健康检查暂时失败或处于
starting; - PostgreSQL 就绪后,
db变为healthy; - Compose 启动
api; api解析db,连接db:5432。
查看状态:
docker compose ps
可能看到:
NAME SERVICE STATUS
project-db db Up ... (healthy)
project-api api Up
查看健康检查的原始结果:
docker inspect \
"$(docker compose ps -q db)" \
--format '{{json .State.Health}}'
如果 api 没有启动,优先检查:
docker compose logs db
docker compose config
docker compose config 可以检查 Compose 文件展开后的结果,尤其适合发现环境变量替换、缩进和依赖条件错误。
七、为什么启动成功后仍然需要重连
1. 已建立的 TCP 连接不会随 DNS 自动迁移
假设应用第一次解析得到:
db -> 172.20.0.3
并建立 TCP 连接:
api:随机端口 → 172.20.0.3:5432
此时如果数据库容器被重建:
旧容器 172.20.0.3 被删除
新容器 172.20.0.5 创建
db -> 172.20.0.5
旧 TCP 连接仍然指向 172.20.0.3。DNS 记录变化不会修改已存在的 TCP 连接,也不会把连接自动转发到新容器。
应用必须:
- 检测连接已断开;
- 关闭失效连接;
- 重新解析
db; - 使用新地址建立连接;
- 重新认证、恢复连接池和必要的会话状态。
2. 只在进程启动时解析一次可能造成旧地址问题
下面这种实现存在风险:
DB_IP = socket.gethostbyname("db")
while True:
connect(DB_IP, 5432)
如果 db 容器后来重建,DB_IP 仍然是旧地址。更合理的方式是:
每次建立新连接时解析服务名
或者使用支持服务名重新解析的连接池配置。但具体行为取决于语言运行时、DNS 缓存、数据库驱动和连接池实现,不能仅因为配置写成主机名,就假设驱动一定会在每次重连时重新解析。
3. 连接池可能隐藏部分失效状态
数据库连接池通常会保存多个长连接。数据库重启后可能出现:
- 池中所有连接失效;
- 部分连接因网络路径不同而仍可用;
- 空闲连接直到下一次借用才暴露错误;
- 一个事务中的连接断开,事务状态无法恢复。
因此重连逻辑不只是:
connect() 失败 → 再 connect()
还需要定义:
- 哪些错误可以重试;
- 当前事务是否可以安全重放;
- 连接池如何丢弃坏连接;
- 重试期间是否继续接收请求;
- 重试次数和最大等待时间;
- 数据库恢复后如何重新初始化会话参数。
已经提交成功但客户端未收到响应的写操作,不能简单重试,否则可能造成重复写入。可重试写操作通常需要幂等键、唯一约束或业务层去重。
八、重试策略:处理启动竞态和运行时故障
1. 线性重试与指数退避
固定间隔重试:
第 1 次:1 秒后
第 2 次:1 秒后
第 3 次:1 秒后
在多个副本同时启动时,可能形成同步请求洪峰。
指数退避可以写成:
其中:
- 是初始等待时间;
- 是已经失败的次数;
- 是最大等待时间;
- 是第 次失败后的等待时间。
实际工程中通常还加入抖动:
其中 表示在 0 到 之间随机选择等待时间。这样多个客户端不会在同一时刻再次冲击依赖服务。
但重试不能无限延长请求生命周期。应同时设置:
- 单次连接超时;
- 整体启动或请求超时;
- 最大退避时间;
- 可观测的失败日志;
- 停止信号处理。
2. 启动重试与运行时重试不是同一件事
启动阶段:
DNS 尚未可用或数据库仍在初始化
→ 等待并重试
运行阶段:
数据库已服务数小时后重启
→ 连接池失效
→ 中断请求或事务
→ 丢弃连接
→ 重新解析和连接
运行时重连还要考虑数据一致性和请求语义,不能只复制启动脚本中的无限循环。
3. Compose 的 restart 不是应用重连
services:
api:
restart: unless-stopped
这通常用于容器主进程退出后的重启。它不能替代应用内的:
- DNS 重新解析;
- TCP 重连;
- 连接池恢复;
- 请求重试;
- 事务恢复。
如果应用进程一直存活,但数据库连接已经失效,容器重启策略不会介入。
Compose 长语法中还可以写:
depends_on:
db:
condition: service_healthy
restart: true
这里的 restart: true 是 Compose 在显式执行相关重启操作时的依赖重启联动语义,不等于数据库因自身进程退出或健康状态变化后,Compose 自动持续重启所有依赖服务。不能用它代替应用的运行时容错。
九、常见失败表现与分层诊断
情况一:could not resolve host: db
优先检查服务是否位于同一网络:
docker compose config
docker compose ps
docker network ls
docker network inspect <project>_default
也可以直接在客户端容器中检查:
docker compose exec api getent hosts db
可能原因包括:
api和db没有共同网络;- 服务名拼写错误;
- 使用了错误的 Compose 项目或网络;
- 容器尚未加入目标网络;
- 应用运行在
network_mode: host、none或其他特殊网络模式; - 应用自身没有使用容器的系统解析配置。
情况二:db 能解析,但 connection refused
执行:
docker compose exec api sh -c 'nc -vz db 5432'
如果名称解析成功但端口拒绝,常见原因是:
- 数据库进程尚未监听;
- 数据库只监听了错误的地址;
- 容器端口配置错误;
- 数据库进程已经退出;
- 健康检查使用了错误的端口或账号;
- 应用启动早于数据库就绪,且没有重试。
此时应查看:
docker compose logs db
docker compose ps
depends_on: condition: service_healthy 可以降低启动竞态,但健康检查命令本身错误时,依赖服务会一直等不到 healthy。
情况三:TCP 已连接,但应用仍然失败
这说明故障已经越过 DNS 和基础 TCP 层,可能发生在:
- 数据库认证;
- TLS;
- 协议版本;
- 数据库初始化脚本;
- 业务表或迁移;
- 连接池配置;
- 查询或事务。
此时继续执行 ping 或反复检查 DNS 没有帮助,应使用数据库客户端或应用日志检查更高层协议。
情况四:容器重建后应用仍连接旧地址
常见原因是:
- 应用缓存了 IP;
- DNS 缓存时间过长;
- 连接池继续借出旧连接;
- 驱动没有在重连时重新解析;
- 应用把解析结果写入了持久化配置。
验证新地址:
docker compose exec api getent hosts db
docker inspect "$(docker compose ps -q db)" \
--format '{{json .NetworkSettings.Networks}}'
如果 getent 已返回新 IP,但应用仍失败,问题通常在应用的 DNS 缓存或连接池,而不是 Compose 服务发现本身。
十、不要把几个相似概念混为一谈
depends_on 不是服务注册中心
depends_on 只描述 Compose 项目内的启动和停止依赖,不提供:
- 跨主机服务发现;
- 负载均衡;
- 故障转移;
- 运行时健康实例摘除;
- 全局服务注册。
Compose 的服务名解析适合单机 Docker 网络中的服务互联。跨主机、多节点调度、滚动发布和自动故障转移需要更强的编排或服务治理系统。
Healthcheck 不是监控系统
健康检查产生的是容器级状态。它可以帮助 Compose 判断是否满足启动条件,也可以被运维工具读取,但它不是完整的指标、日志、告警和追踪系统。
restart 不是重连机制
容器重启可能暂时掩盖应用错误,却不会保证:
- 依赖服务已恢复;
- 新 IP 已被重新解析;
- 未完成事务得到正确处理;
- 重复请求不会产生副作用。
服务名不是宿主机 DNS 名称
在宿主机上执行:
getent hosts db
不一定能解析 Compose 服务名。该名称的解析范围通常是连接到对应 Docker 网络的容器,而不是宿主机的普通 DNS 环境。
容器网络边界不是 Linux 主机网络的全部行为
本文讨论的是 Linux 容器使用 Docker 用户定义网络时的典型行为。Docker Desktop、Windows 容器、macOS 虚拟化层、host 网络、none 网络、手工配置 DNS 以及外部网络驱动可能改变具体结果。遇到版本或平台差异时,应结合:
docker version
docker compose version
docker inspect <container>
docker network inspect <network>
以实际运行环境为准。
十一、一个可靠连接模型的完整推导
对于依赖数据库的 api,较完整的正确性条件可以写成:
其中:
Resolved(db):服务名db能解析到当前网络中的地址;Reachable(db):该地址和端口可达;Accepting(db):数据库进程正在监听并接受连接;ProtocolReady(db):数据库已完成应用所需的协议、认证和初始化条件;Retryable(api):应用能够在失败后重新解析、重新连接,并正确处理失败请求。
depends_on 最多直接帮助表达:
db 先于 api 启动
db 健康后 api 再启动
healthcheck 可以帮助近似表达:
ProtocolReady(db)
但前提是检查命令确实验证了这个条件。
Docker 嵌入式 DNS 帮助表达:
Resolved(db)
但它不保证目标进程正在监听。
最终的:
Retryable(api)
只能由应用自身实现。正因为这些条件属于不同层次,所以任何单独一个配置项都不能解决服务发现、启动竞态和运行时断连的全部问题。
在 Compose 中,合理的结构通常是:
services:
api:
depends_on:
db:
condition: service_healthy
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
再配合应用侧:
使用服务名而非固定 IP
每次新建连接时允许重新解析
连接失败时使用有限的退避重试
失效连接从连接池中移除
谨慎重试可能产生副作用的请求
这样才能把 Compose 的静态启动协调、Docker 的网络服务发现和应用的动态故障恢复连接成一个完整闭环。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:数据库运行在 Docker:持久化、初始化、备份、资源和生产边界
- 下一篇:Compose 配置合并:Override、include、extends、变量插值和验证
- 延伸:Docker Compose 完整指南:服务、网络、卷、依赖、Profile 和生产边界
- 延伸:Docker 容器 DNS:嵌入式解析、Service Name、Search Domain 和故障
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论