Docker HEALTHCHECK 在容器内运行配置的命令,并把结果记录为健康状态。它提供了主进程是否运行之外的另一项观察,但含义取决于命令检查什么:进程存在、HTTP 响应,还是应用特定的就绪条件。Docker 不会因为出现 HEALTHCHECK 指令,就自动推导出一份通用业务就绪契约,检查者必须明确这个信号能证明哪些行为。
坏掉的样子
总是返回零的命令,即使应用无法提供有用工作,也会产生看起来正常的健康报告。shell 命令序列还可能因为最后一条命令成功,而掩盖前面的失败。反过来,如果探测依赖一个非必需外部服务,就可能把仍可工作的应用标成不健康,并促使消费该状态的系统执行不必要恢复。探测含义必须符合实际消费者需求。
独立 Docker 的重启策略响应容器终止,不是只要健康状态变成 unhealthy 就触发。加入 restart=always,不会让 Docker 因探测失败而重启仍然存活的应用。同样,健康检查也不会自动从所有代理的路由表中移除容器。必须有控制器或代理明确读取该状态,并实现预期动作,不能把观察配置当成恢复配置。
探测时序也容易被误解。重试阈值统计连续失败,启动宽限则改变早期哪些失败会被计数。启动期的一次成功可以结束初始宽限行为。检测耗时取决于命令持续时间、间隔、超时和配置,不能简单承诺一次请求失败就立刻停止流量。解释延迟时,应沿实际探测时间线检查,而不是只看一个 retries 数字。
快速验证
先取得固定基础镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。它只创建一个临时镜像和容器,关闭容器外部网络,使用自身回环 HTTP 服务。被改变的状态仅是实验内部一个无害就绪文件,结束后清理自己的容器和标签:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,time,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def inspect(container):return json.loads(docker('inspect',container).stdout)[0]
def wait_for(container,wanted):
deadline=time.monotonic()+25
while time.monotonic()<deadline:
state=inspect(container)
if state['State']['Health']['Status']==wanted:return state
time.sleep(.1)
raise AssertionError('health did not reach '+wanted)
name='xbcatm-health-'+uuid.uuid4().hex[:10];tag=name+':probe'
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory)
(root/'server.py').write_text('from http.server import BaseHTTPRequestHandler,HTTPServer\nfrom pathlib import Path\nclass Handler(BaseHTTPRequestHandler):\n def do_GET(self):\n code=200 if Path("/ready").exists() else 503\n self.send_response(code);self.end_headers();self.wfile.write(str(code).encode())\n def log_message(self,*args):pass\nHTTPServer(("127.0.0.1",8080),Handler).serve_forever()\n')
probe='import urllib.request;urllib.request.urlopen("http://127.0.0.1:8080/health",timeout=.5).read()'
(root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY server.py /server.py\nHEALTHCHECK --interval=1s --timeout=1s --retries=2 --start-period=5s CMD ["python", "-c", {json.dumps(probe)}]\nCMD ["python", "/server.py"]\n')
docker('build','--network','none','-t',tag,str(root))
began=time.monotonic()
docker('run','-d','--name',name,'--network','none','--restart','always',tag)
initial=inspect(name);assert initial['State']['Health']['Status']=='starting'
first=wait_for(name,'unhealthy');assert first['State']['Running'] and first['RestartCount']==0
assert first['State']['StartedAt']==initial['State']['StartedAt']
assert first['State']['Health']['FailingStreak']>=2
assert any(log['ExitCode']!=0 and '503' in log['Output'] for log in first['State']['Health']['Log'])
time.sleep(max(0,12-(time.monotonic()-began)))
stable=inspect(name);assert stable['State']['Running'] and stable['RestartCount']==0
assert stable['State']['StartedAt']==initial['State']['StartedAt'] and stable['State']['Health']['Status']=='unhealthy'
docker('exec',name,'python','-c','from pathlib import Path;Path("/ready").touch()')
healthy=wait_for(name,'healthy');assert healthy['RestartCount']==0 and healthy['State']['Health']['FailingStreak']==0
docker('exec',name,'python','-c','from pathlib import Path;Path("/ready").unlink()')
unhealthy=wait_for(name,'unhealthy');assert unhealthy['RestartCount']==0 and unhealthy['State']['Running']
assert unhealthy['State']['StartedAt']==initial['State']['StartedAt']
print('starting -> unhealthy -> healthy -> unhealthy follows the real HTTP probe; failure logs contain HTTP 503')
print('restart=always does not restart a still-running process solely because its health is unhealthy')
finally:
docker('rm','-f',name,check=False);docker('image','rm',tag,check=False)
/ready 不存在时,服务返回 HTTP 503。Dockerfile 健康检查使用 Python HTTP 客户端,遇到该状态会抛出错误并以失败状态结束。配置为一秒间隔、一秒超时、两次重试、五秒启动期。容器采用 restart=always 启动,初始检查记录健康状态为 starting,随后观察实际探测结果。
计入连续失败后,Docker 报告 unhealthy,健康日志包含真实 HTTP 503 错误,失败计数至少为二。实验保存初始启动时间,并等待到启动至少十二秒后,再确认主进程仍运行且没有重启。这样可以排除把初始启动行为误当成已进入稳定运行阶段后的恢复策略,结论不依赖一个过早的瞬时观察。
创建 /ready 后,HTTP 端点返回 200,之后的探测让健康状态变成 healthy,连续失败计数清零。同一个容器进程继续运行。这说明检查命令实际观察到应用恢复,不需要控制器为了清除状态而人为重启。状态变化来自被检查行为改变,能够与进程寿命独立对照。
删除该文件,端点再次失败,健康状态最终回到 unhealthy。启动时间始终相同,RestartCount 始终为零。实验覆盖完整的失败、恢复、再次失败,而不是只观察一次启动故障,就假定后续情况全部一样。这也说明健康状态不是只能从 starting 单向走向某个永久结果,它会随后续探测变化。
Docker 消费的是探测进程退出状态:零表示成功,一表示失败,二在 Dockerfile 契约中保留。Python 客户端遇到 HTTP 错误自然返回失败进程状态。脚本同时读取诊断输出,有助于区分真实就绪失败与探测程序缺失、命令拼写错误等问题。只看 unhealthy,无法知道检查究竟在哪一步失败。
单独定义响应动作
选择成本低、耗时有界、含义符合消费者要求的检查。内部 HTTP 探测可以验证应用处理,但不能证明公共 DNS、TLS 终止、代理路由或终端用户可达性;这些路径重要时,需要外部观察。探测应避免破坏性操作,也不要把凭据写进保留的健康日志。检查失败应提供有用信息,而不是制造额外事故。
Compose 启动依赖需要等待就绪时,应显式配置基于健康状态的条件。对于运行期路由或修复,核对真实控制器的策略,并测试它采取的动作。观察与行动应明确连接,同时留出合理宽限,避免把短暂依赖问题变成重启风暴。配置里存在健康检查,不等于这些后续行为已经被验证。
事故期间,分别检查主进程状态、健康状态、探测日志和重启历史。存活但不健康、已经退出、不断重启,是不同故障。每种状态都有清晰证据,才便于选择正确恢复方式,而不是看到一个红色标签就反复重启,掩盖真正的应用或依赖问题。
延伸阅读
来源
- Dockerfile HEALTHCHECK reference,探测退出码、间隔、重试、启动宽限及健康状态,已核验 2026-09-12。
- Start containers automatically,针对容器终止的重启策略,已核验 2026-09-12。
- Control startup order in Compose,消费健康状态的显式依赖启动条件,已核验 2026-09-12。