Docker Compose Healthcheck 正确写法与依赖检查
了解 Docker Compose 如何根据退出代码评估 healthcheck,为什么 depends_on 不会等待服务就绪,并掌握 Postgres 与应用的可靠就绪检查写法。
Docker Compose healthcheck 的实际工作方式
Docker Compose healthcheck 是 Docker 按定时器在容器内运行的一条命令。Docker 不会读取日志、监控端口或检查进程列表。它会运行这条命令,读取退出代码,并在容器上保存一个状态:starting、healthy 或 unhealthy。退出代码 0 表示健康。任何其他退出代码都表示不健康。退出代码 2 由 Docker 保留,因此不要故意返回该代码。
这就是全部机制。几乎所有 healthcheck 问题本质上都是同一个问题:您编写的命令回答了另一个问题,而不是您原本想检查的问题。本指南假设您已经了解如何在 VPS 上编写 compose 文件,并从堆栈以错误顺序启动的位置开始。
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30stest 值有两种常用形式。以 CMD 开头的列表会直接运行命令,不使用 shell,因此管道、&& 和变量展开都不起作用。以 CMD-SHELL 开头的列表会将其余内容作为一个字符串传递给容器内的 /bin/sh -c。只要检查需要 shell 语法,就应使用这种形式。普通字符串会被视为 CMD-SHELL。恰好包含 ["NONE"] 的列表会移除镜像通过 Dockerfile 内置的 healthcheck。
检查会在容器内运行,因此命令中指定的每个二进制文件都必须存在于该镜像中。请先确认这一点。没有 curl 的精简镜像会导致容器永久处于不健康状态,而应用程序日志中不会显示原因。手动测试:
docker compose exec api curl --version缺少二进制文件时会返回 OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown。基于 Alpine 的镜像通常会提供 BusyBox wget,因此检查命令应改为 ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"]。
interval、重试次数和 start_period 如何组合
有 5 个设置控制时间安排。它们的默认值来自 Docker Engine,而不是 Compose。
interval:容器结束 start period 后,两次检查之间的间隔。默认值为 30s。timeout:一次检查最多可以运行多长时间。超过此时间后,Docker 会终止该检查,并将此次检查计为失败。默认值为 30s。retries:状态变为unhealthy前所需的连续失败次数。默认值为 3。start_period:容器启动后的宽限期。默认值为 0s。start_interval:start period 期间检查的运行间隔。默认值为 5s,且需要 Docker Engine 25.0 或更高版本。
需要注意的规则是:在 start period 期间,失败的检查不会计入 retries,容器会保持 starting 状态。检查首次成功时,容器变为 healthy,并立即结束 start period,即使该阶段的大部分时间尚未使用。如果 start period 结束时检查仍然失败,正常倒计时就会开始。容器需要连续失败 retries 次,才会被标记为 unhealthy。
因此,从容器启动到 unhealthy 的最坏情况时间为 start_period 加上 retries 乘以 interval,再加上 timeout。按照上面文件中的值计算,即 30 加上 5 乘以 13,结果为 95 秒。在设置部署超时前,先记下这个数字。因为如果发布在 60 秒后放弃,就永远无法看到该容器进入最终状态。
这里常见的错误是提高 retries 来覆盖缓慢的启动过程。这只能解决一次问题,之后会持续带来影响:某个服务启动时需要 8 次重试,生产环境中就会容忍连续 8 次失败,之后才会有任何提示。应改用 start_period,因为它只在首次成功前生效。
为什么单独使用 depends_on 不能保证任何事情
depends_on 的简写形式是大多数混淆的来源。
api:
depends_on:
- db这只表示一件事:先启动 db 容器,再启动 api 容器。Compose 会等待容器创建并启动。它不会等待 PostgreSQL 完成首次初始化,也不会等待端口 5432 接受连接。您的应用大约在1秒后启动,此时还没有进程监听该端口,因此应用退出。您会在日志中看到 Connection refused;如果服务器已启动但仍在恢复,则会看到 FATAL: the database system is starting up。
长格式才是实际需要的配置:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition 有3个值。service_started 与简写形式相同。service_healthy 会让依赖服务在依赖项报告健康状态之前保持等待。只有当依赖项定义了 healthcheck 时,这个设置才有意义;该定义可以位于 compose 文件中,也可以位于其镜像中。service_completed_successfully 会等待一次性容器(例如数据库迁移容器)以状态码0退出。
condition 旁边还有两个字段。restart: true 会在依赖服务更新后通知 Compose 重启此服务。required: false 会将缺少依赖项从错误降级为警告。
现在说明一个容易被忽略的限制。这些条件只会在启动整个堆栈时评估。它们用于控制启动顺序,而不是监督规则。如果数据库在凌晨3点重启,系统不会重新评估 service_healthy,也不会为了再次满足该条件而重启您的应用。应用程序代码仍然必须自行重新建立连接。docker compose up --no-deps api 会按设计跳过整个机制,直接使用 docker start 启动容器时也是如此。
编写测试就绪状态的检查,而不是检查进程是否存在
类似 pgrep nginx 的检查只能证明进程表中存在一个条目。它无法证明服务能够响应请求。Web 应用的数据库连接池失效后,仍可能长时间保持监听套接字打开,而进程检查会在整个故障期间保持正常。
让容器执行它本应执行的工作:
- 对于 HTTP 服务,请求一个真实的端点。由于
-f,curl -fsS在任何 400 或更高的状态码下都会以非零状态退出,因此故障应用返回 500 时,检查也会失败。 - 对于 PostgreSQL,使用
pg_isready。服务器接受连接时退出状态为 0,拒绝连接时为 1,完全没有响应时为 2,传入的参数错误时为 3。 - 对于 Redis,使用
redis-cli ping。它会输出PONG,并以 0 状态退出。 - 对于 MariaDB,官方镜像提供了
healthcheck.sh脚本,维护者文档说明应使用healthcheck.sh --connect --innodb_initialized。
pg_isready 有一个需要注意的问题。在空数据目录上首次启动时,官方 postgres 镜像会针对临时服务器执行初始化。该服务器只监听 Unix 套接字。未指定主机参数的 pg_isready 会使用该套接字,因此在 TCP 端口 5432 仍对应用关闭时,它可能已经回答“正在接受连接”。显式将检查指向 TCP 即可解决问题,因为临时服务器不会在 TCP 上响应。
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s两个美元符号不是笔误。Compose 读取文件时会自行展开 $VAR,这会把主机环境中的值写入检查命令。$$ 会将其转义为单个 $,这样容器内的 shell 就会根据容器自身的环境变量进行展开。
按正确顺序启动的 postgres 和应用堆栈
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:启动堆栈并观察状态变化:
docker compose up -d
docker compose psSTATUS 列在方括号中显示健康状态。健康的两个服务在两行中都显示 Up 41 seconds (healthy)。数据库仍在初始化时,db 显示为 Up 4 seconds (health: starting),并且列表中没有 api,因为 Compose 尚未创建它。
要了解检查通过或失败的原因,请查看健康检查日志:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker 会保留最近几次结果。每条结果包含开始时间、结束时间、ExitCode 以及命令的 Output。存储的输出会被截断,因此输出大段页面正文的检查只会生成无用的日志条目。请让检查保持安静。
容器变为不健康时 Docker 的处理方式
什么也不做。这是最让人意外的答案。
单主机上的 Docker Engine 不会重启不健康的容器。restart: unless-stopped 策略会对主进程退出作出响应,而不健康的容器并未退出。它可以在 unhealthy 状态下持续一周,而 Compose 不会处理它。Swarm 模式会替换不健康的任务,但普通的 Compose 堆栈在单台服务器上不会这样做。
因此,只有两个可行选项。让进程在确认自身已损坏时退出,使重启策略能够执行操作。或者从外部监控状态并对此发出告警。将 Uptime Kuma 监控指向与健康检查调用的同一端点,可以让依赖项故障同时显示在两个位置,并由监控通知您,而不是由用户反馈。如果流量通过 Traefik 反向代理到达应用,请记住,代理对后端的自身视图与 Docker 健康状态彼此独立,因此一个不能替代另一个。
调试始终无法变为 healthy 的检查
在同一个容器中亲自运行完全相同的命令,并查看退出代码:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"如果容器仍报告为 unhealthy,而此处为 exit=0,说明您的 compose test 与刚才输入的内容不同。通常是因为在需要 shell 语法的位置使用了 CMD。
其余问题中,最常见的是以下两类。第一类是端口错误。健康检查在容器内部运行,因此必须使用容器端口,不能使用发布到主机的端口。使用 ports: - "8080:3000" 时,应用监听 3000 端口。如果检查 http://localhost:8080,检查会一直失败,但网站在浏览器中运行正常。第二类是主机错误。在检查中,localhost 表示当前容器本身。检查当前容器时这样写是正确的;检查相邻容器时则不正确。此时应使用服务名称,例如 db。
还有一种情况需要单独说明:健康检查通过,但用户仍看到错误。这通常是因为端点直接返回静态的 200 响应,没有执行任何实际检查。不会查询数据库的就绪端点无法发现数据库已不可用。应让它执行一次开销较低的真实查询。
FAQ
为什么 depends_on 声明数据库已处于健康状态,但我的应用仍然无法连接?
因为 condition: service_healthy 只会在堆栈启动时评估一次。之后它不会执行任何监控。如果数据库容器稍后重启,Compose 不会为了再次满足该条件而重启应用,因此应用代码需要自行实现重新连接和重试逻辑。使用 docker start 或 docker compose up --no-deps 启动单个容器时,该条件也不起作用。
如果镜像已经定义了 healthcheck,我还需要定义吗?
通常不需要。覆盖它通常会适得其反,因为镜像维护者更清楚该软件何时已准备就绪。只有当镜像中的检查不适用于您的环境时,才应自行添加,例如它检查的是您已更改的端口。要禁用镜像的 healthcheck,请在服务上设置 test: ["NONE"] 或 disable: true。
healthcheck 应使用 curl 还是 wget?
使用镜像中已有的工具,并在依赖它之前通过 docker compose exec <service> curl --version 确认该工具存在。许多基于 Debian 的镜像两者都没有。基于 Alpine 的镜像包含 BusyBox wget。如果软件自带客户端,例如 pg_isready 或 redis-cli,不要仅为了运行 healthcheck 而向镜像添加软件包。
不健康的容器会自动重启吗?
在单主机环境中,Docker Engine 不会这样做。重启策略响应的是进程退出,而不是健康状态。因此,不健康的容器会继续运行并保持故障状态,直到其他组件采取措施。您可以让进程在检测到故障时退出,也可以运行外部监控程序,在状态异常时发出告警。
start_period 应设置多长?
应至少覆盖您测得的最慢正常首次启动时间,并留出一定余量。请针对空卷使用 docker compose up 测量,因为数据库首次启动的速度远慢于之后的每次启动。start period 过长只会延迟首次 unhealthy 判定。重试次数过高会削弱检查在容器整个生命周期内的有效性,这种后果更严重。