Docker 健康状态与 Prometheus 的 up 序列来自不同检查。Compose 健康检查在应用容器内运行命令;Prometheus 则独立抓取配置的指标端点,并记录抓取是否成功。两项观察可以合理地不一致,因为应用可能已经无法处理有用工作,却仍能提供指标,也可能业务正常而指标端点损坏。理解区别,才能避免用一个绿灯替代全部服务状态。

坏掉的样子

把 up=1 解释成应用已经就绪,可能掩盖故障。它说明目标指标抓取成功,不说明登录、数据库写入或其他业务操作成功。对于这些问题,需要合适的行为指标或外部探测。自定义就绪 gauge 可以有用,但具体含义必须由应用契约定义,不能仅凭名字叫 ready,就认为它覆盖了所有必需依赖。

反向误解是认为 Docker 健康状态会自动导出到 Prometheus。Compose 中定义 healthcheck,不会自动创建 Prometheus 指标,也不会配置抓取目标。需要 exporter 或应用埋点明确暴露所需观察。即使完成导出,采集路径和新鲜度也与引擎健康状态不同,需要知道更新延迟和失败方式,才能解释不一致。

两种观察都不会自动定义重启或路由动作。Docker 的 restart: always 处理容器终止;容器仍在运行时,不会仅因 unhealthy 就重启。监控可以根据证据告警,明确配置的控制器可以采取动作,但这些连接不会因为两个工具恰好位于同一个 Compose 项目里就凭空出现。观察、告警和修复需要分别验证。

快速验证

使用宿主 Python、Docker Compose 和两个固定镜像运行脚本。实测为 Compose 5.1.1、Docker 29.3.1、Python 3.11.16,以及 Linux arm64 上的 Prometheus 3.5.1。它构建临时 Prometheus 配置镜像,创建唯一项目,只把 Prometheus API 发布到宿主回环地址,结束时清理自身项目、数据卷和标签:

from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.parse import urlencode
from urllib.request import urlopen
import json,subprocess,time,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
PROM='prom/prometheus@sha256:38c3b05c3bc744ff1b0b7b4eb82196026442845e62a1e2073795565da506d7a2'
def run(args,check=True):
    result=subprocess.run(args,text=True,capture_output=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def docker(*args,check=True):return run(['docker',*args],check)
def inspect(identity):return json.loads(docker('inspect',identity).stdout)[0]

name='xbcatm-prom-health-'+uuid.uuid4().hex[:10];tag=name+':prometheus'
with TemporaryDirectory(prefix=name+'-') as directory:
    root=Path(directory);file=root/'compose.yaml'
    def compose(*args,check=True):return run(['docker','compose','-p',name,'-f',str(file),*args],check)
    def wait(predicate):
        deadline=time.monotonic()+25
        while time.monotonic()<deadline:
            try:
                if predicate():return
            except (OSError,ValueError):pass
            time.sleep(.2)
        raise AssertionError('observation did not become true')
    server='''from http.server import BaseHTTPRequestHandler,HTTPServer
from pathlib import Path
Path('/ready').touch()
class H(BaseHTTPRequestHandler):
 def do_GET(self):
  ready=Path('/ready').exists()
  code=(503 if Path('/broken-metrics').exists() else 200) if self.path=='/metrics' else (200 if ready else 503)
  body=('# TYPE app_ready gauge\\napp_ready '+str(int(ready))+'\\n').encode()
  self.send_response(code);self.send_header('Content-Type','text/plain; version=0.0.4');self.end_headers();self.wfile.write(body)
 def log_message(self,*args):pass
HTTPServer(('0.0.0.0',8080),H).serve_forever()
'''
    (root/'prometheus.yml').write_text('global:\n  scrape_interval: 1s\n  scrape_timeout: 500ms\nscrape_configs:\n  - job_name: app\n    static_configs:\n      - targets: [app:8080]\n')
    (root/'Dockerfile').write_text(f'FROM {PROM}\nCOPY prometheus.yml /etc/prometheus/prometheus.yml\n')
    health=['CMD','python','-c','import urllib.request;urllib.request.urlopen("http://127.0.0.1:8080/health",timeout=.5).read()']
    file.write_text(json.dumps({'services':{'app':{'image':BASE,'command':['python','-c',server],'restart':'always','healthcheck':{'test':health,'interval':'1s','timeout':'1s','retries':2,'start_period':'3s'}},'prometheus':{'image':tag,'ports':[{'target':9090,'published':'0','host_ip':'127.0.0.1'}],'volumes':['metrics:/prometheus']}},'volumes':{'metrics':{}}},indent=2))
    try:
        docker('build','--network','none','-t',tag,str(root));compose('up','-d')
        app=compose('ps','-q','app').stdout.strip();prometheus=compose('ps','-q','prometheus').stdout.strip()
        port=inspect(prometheus)['NetworkSettings']['Ports']['9090/tcp'][0];assert port['HostIp']=='127.0.0.1'
        endpoint='http://127.0.0.1:'+port['HostPort']
        def value(expression):
            with urlopen(endpoint+'/api/v1/query?'+urlencode({'query':expression}),timeout=2) as response:data=json.load(response)
            result=data['data']['result'];return float(result[0]['value'][1]) if result else None
        wait(lambda:value('up{job="app"}')==1 and value('app_ready')==1 and inspect(app)['State']['Health']['Status']=='healthy')
        time.sleep(11)
        original=inspect(app)
        docker('exec',app,'python','-c','from pathlib import Path;Path("/ready").unlink()')
        wait(lambda:value('up{job="app"}')==1 and value('app_ready')==0 and inspect(app)['State']['Health']['Status']=='unhealthy')
        current=inspect(app);assert current['RestartCount']==0 and current['State']['StartedAt']==original['State']['StartedAt']
        print('Docker unhealthy and app_ready=0 coexist with Prometheus up=1 because the metrics endpoint is still scrapeable')
        docker('exec',app,'python','-c','from pathlib import Path;Path("/broken-metrics").touch()')
        wait(lambda:value('up{job="app"}')==0)
        assert inspect(app)['State']['Running'] and inspect(app)['RestartCount']==0
        print('returning HTTP 503 from /metrics changes up to 0 without stopping or restarting the application')
    finally:
        compose('down','--volumes','--remove-orphans',check=False);docker('image','rm',tag,check=False)

应用通过同一个小型 HTTP 服务提供 /health 与 /metrics。就绪文件控制健康响应和显式定义的 app_ready gauge;另一个文件控制指标请求本身是否返回错误。两个独立开关让实验区分应用状态与抓取可用性,同时使用真正的 Prometheus,不用本地模拟计算替代抓取、存储和查询行为。

Prometheus 每秒抓取 app:8080,使用默认指标路径和半秒抓取超时。Compose 则单独执行应用内部健康命令。控制器先等待健康状态正常、up 为一、app_ready 为一,再让应用继续运行超过初始启动阶段,之后才改变条件。这样后续重启观察不会被过早的启动时机混淆。

删除 /ready 后,/health 返回 HTTP 503,gauge 变为零。两次健康探测失败后,Docker 报告 unhealthy。但 /metrics 仍返回成功响应和有效指标数据,所以 Prometheus 继续记录 up=1。实验通过真实 Docker 检查和 Prometheus 查询接口同时验证三个观察,不只是根据配置推测结果。

应用设置了 restart: always,但启动时间不变,重启次数仍为零,直接证明不健康状态没有触发引擎重启。这个结果对事故判断很有用:进程仍运行、指标处理器仍工作,与就绪契约失败可以同时成立。如果监控只看前两者,就可能在用户已经受影响时仍显示服务正常。

创建 /broken-metrics 后,指标端点也返回 HTTP 503,下一次相关抓取把 up 记录为零,应用进程却继续运行。实验因此把抓取失败与进程退出独立验证。指标路径错误、超时或格式无效,同样可能导致抓取失败,而不能单独证明整个进程已经结束,需要继续检查测量路径和服务状态。

控制器轮询实际状态,不假定两套探测时钟完全对齐。这里的短间隔只用于可丢弃实验,不是通用生产建议。清理只删除自身临时指标卷,因为断言完成后不需要保留其中样本。真实监控数据的保留和删除应按照另外的存储策略处理,不能直接照搬实验清理范围。

按契约与观察路径分别告警

为抓取可用性、应用就绪和用户可见成功保留不同信号。自定义指标需要准确含义和有界标签集合。如果导出 Docker 健康状态,应记录 exporter、目标身份和更新延迟,再验证指标确实对应引擎状态,而不是因为名字相似就认定已经连接。明确采集链路,也有助于定位指标本身失真的原因。

还要区分失败抓取、序列缺失和目标被移除。查询结果为空,不自动等于采样值为零;发现和陈旧标记规则会影响哪些序列仍然存在。告警规则应考虑预期目标清单和可接受观察间隙,否则目标消失时可能反而没有告警,或者把正常发现变化误判为服务故障。

最后,单独测试行动。在拥有相应职责的系统里验证告警路由、流量移除或恢复行为。准确监控应保留这些区别,让操作者能够判断是服务失败、测量路径失败,还是两者同时失败,而不是把所有状态压缩成一个含义不清的健康标签。

延伸阅读

来源