样本过滤与标签删除主题封面,展示观察、判断与行动三个步骤。

样本重新标记在入库前过滤指标

Prometheus 存在多个重新标记阶段,相似语法容易遮住不同作用。目标重新标记在采集之前处理发现目标,样本重新标记则在抓取之后、入库之前处理采集样本。前一个阶段的 drop 可以让某个端点不被抓取,后一个阶段的相同动作则从已经获取的响应中丢弃匹配样本。首先确定阶段,才能判断配置到底改变了哪一部分观测。 先决定删除数据,还是删除标签 在 metric_relabel_configs 中,action: drop 匹配组合后的源标签值,并丢弃匹配样本。使用 __name__ 作为输入时,可以拒绝指定指标族。它不是删除这个源标签却保留样本的意思。如果对一个广泛存在的值使用过宽条件,可能丢掉远超预期的数据,所以不能仅凭动作名称中有“删除”就认为它只影响元数据。 action: labeldrop 则把正则表达式用于标签名称,从保留样本中移除匹配标签;labelkeep 只保留名称匹配的标签。两者都不会求和或生成汇总。前一类操作删除观测,后一类操作改变身份,应该按照预期数据契约选择,而不是把名称相近的动作当成可互换参数。留下数值但删掉维度,也可能比直接过滤一部分样本带来更隐蔽的问题。 重新标记表达式默认在两端锚定。替换或过滤规则需要在对应阶段使用正确的源标签与模式。发现元数据通常属于目标阶段,到样本处理阶段时,已不能继续期待使用 __meta_ 输入。需要保留的上下文应提前复制到普通目标标签,而不是假定每个导出指标都会携带内部发现字段。配置语法相同,不代表输入集合也相同。 删除维度之后仍须保持唯一身份 时间序列由指标名和完整标签集合识别。移除区分两条观测的标签,可能让它们身份重叠,产生重复样本冲突或丢失区别。Prometheus 不会自动推断这些数值该求和、取平均还是任选其一。如果真正需要服务总量,应优先采用受控维度的仪表化以及明确查询聚合,而不是把标签删除当成自动汇总机制。 本例只在剩余路由身份本来就唯一的情况下删除构建标签,这是受控前提,不表示可以从任意导出器移除任何昂贵标签。上线前应使用有代表性的输出检查碰撞,也应核对引用被删标签的面板和告警。抓取成功只能说明采集过程完成,不能保证这些消费者仍会选择正确数据,更不能保证历史与新数据可以直接比较。 坏掉的样子 把样本过滤规则放进目标重新标记中,可能移除整个端点,而不是一类昂贵指标。反过来,样本过滤也不会阻止导出器生成响应,或阻止响应字节经过网络。它减少的是入库保留的样本。如果昂贵部分在采集本身,还需要处理仪表化或导出器行为,不能把存储侧过滤描述为同时消除了全部上游成本。 up 等自动生成的抓取序列不经过样本重新标记,因此在这个阶段尝试删除 up,不会隐藏系统生成的指标。健康的 up 可以与被主动丢弃的业务指标同时存在,解释数据缺失时应结合过滤配置。修改规则影响未来入库,不会重写已经保存的历史标签,所以查询跨越变更时刻时也可能看到两种身份。 应把 scrape_samples_post_metric_relabeling 与目标错误、up 一起观察。配置的样本数量上限在样本重新标记之后生效,超出时会让抓取失败,并不是任意保留前面一部分额度内样本。因此,主动过滤与入库上限失败需要不同诊断,即便二者都表现为预期业务序列缺失。 快速验证 保存为 metric-relabel.py 后执行 python3 metric-relabel.py。需要 Docker 与 Prometheus 3.5.1,必要时由 Docker 获取镜像。同一个静态文本端点分别被原始任务与过滤任务抓取。路径使用 .txt 以提供文本内容类型,启动过程等待真正出现样本,不假定服务刚启动时目标列表已经填充完成。 ...

八月 4, 2027 · 2 分钟 · XB Field Notes
告警分组与静默主题封面,展示观察、判断与行动三个步骤。

Alertmanager 分组组织告警,静默抑制通知

一个事故可能产生很多告警,但一组告警并不能证明它们具有同一个原因。Alertmanager 根据配置的标签与路由上下文组织告警,便于一起处理通知,组内仍然保留各条告警的独立身份。静默则是另一种操作:在指定时间窗口内抑制匹配通知,并不会替应用解决故障。理解这些差异,才能准确解释告警页面与通知数量的变化。 分组键决定哪些细节放在一起 配置 group_by: [alertname, service] 时,两条属于结算服务的 QueueHigh 告警可以进入同组,即使实例标签不同。搜索服务的 QueueHigh 属于另一组,结算服务的 DiskFull 也因为告警名不同而单独成组。分组依据被选择的标签值,不要求所有成员具有完全相同的指纹。通知被合并后,实例级细节仍然可以从成员列表查看。 把实例加入分组键会增加组数,也可能抵消跨副本合并的目的;省略服务又可能把不同负责人或工作负载混在一起。应围绕排查方式和责任归属选择分组键,同时保留查看成员的入口。特殊值 ... 表示使用所有标签分组,实际效果接近关闭更大范围的聚合,不能把它当作自动寻找合理分组方式的通配符。 路由上下文同样重要。发往不同接收器的告警,即使所选分组标签相同,也不一定组成同一份通知。应验证路由与分组的组合行为。负责整个服务的接收器可能适合合并多个实例,另一个集成则可能需要不同分组契约。任何一种设置都不会自动证明这些告警具有相同根因,因果关系仍然需要从事件与运行证据判断。 初次等待、组内变化和重复提醒使用不同计时 group_wait 延迟新组的第一份通知,让相近时间到来的告警可以加入。某条告警若在初次等待结束前已经恢复,可能完全不产生通知。group_interval 决定之后检查组内更新的节奏,包括新增触发成员或恢复成员;它不是承诺每隔这么久无条件重发所有尚未结束的组。 没有新增触发或恢复的告警身份需要更新时,repeat_interval 控制重复提醒;单独改变注解文字并不一定触发新通知。是否到达重复时机是在组周期上检查的,因此应选择兼容的整数倍,而不是把它理解为完全独立且精确的闹钟。发送处理与失败还可能增加延迟。应依据响应目标设置这些参数,并将规则等待时长与通知调度分别考虑,避免只看其中一个参数就承诺发现到送达的时间。 通知历史同样存在保留边界。如果 repeat_interval 超过 Alertmanager 的数据保留时间,文档说明可能按保留边界重复发送。因此,把提醒间隔设置得很长,不保证这段时间里绝无重复消息。通知日志的保留期限,与具有明确结束时间的静默,是不同生命周期,应分别纳入运行检查。 坏掉的样子 分组太细,会让一组副本故障产生很多通知;分组太宽,又可能看不出具体服务需要处理。页面只显示一个组,不意味着只剩一条告警。判断事故是否改善前,应展开成员并核对分组键,不能把通知变少直接当成故障减少的证据。通知数量也可能只是由策略变更或抑制行为造成。 静默条件过宽,可能抑制计划维护范围之外的告警。应限制时间窗口,并检查哪些活动告警实际匹配。静默到期只是恢复通知资格,不会修复应用,也不保证立即发出一条消息。抑制规则则是独立的配置关系,由活动的源告警抑制匹配目标通知,与人工设置有时限静默不同,不能把两者的原因和结束条件混为一谈。 快速验证 把程序保存为 grouping.py,在 Docker 可用时执行 python3 grouping.py。使用 Alertmanager 0.28.1,必要时获取镜像。真实接口接收四条合成告警。接收器没有发送集成,初次通知等待也故意设置得很长,因此实验只观察分组和抑制状态,不会向外部发送消息,也不接触已有告警实例。 import http.client import io import json import subprocess import time import uuid name = "xbcatm-monitoring-" + uuid.uuid4().hex[:12] class ResponseSocket: def __init__(self, data): self.data = data def makefile(self, mode): return io.BytesIO(self.data) def request(method, path, body="", content_type="application/json"): body = body.encode() headers = (f"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n" f"Connection: close\r\nContent-Type: {content_type}\r\n" f"Content-Length: {len(body)}\r\n\r\n").encode() result = subprocess.run( ["docker", "exec", "-i", name, "/bin/nc", "-w", "3", "127.0.0.1", str(port)], input=headers + body, capture_output=True, timeout=5, ) if result.returncode: raise RuntimeError(result.stderr.decode()) response = http.client.HTTPResponse(ResponseSocket(result.stdout)) response.begin() return response.status, response.read().decode() def get_json(path): status, body = request("GET", path) assert status == 200, (status, body) return json.loads(body) def eventually(check): for attempt in range(150): try: value = check() if value: return value except (RuntimeError, AssertionError, http.client.HTTPException): pass time.sleep(0.1) raise AssertionError("Expected local server state did not appear") from datetime import datetime, timedelta, timezone port = 9093 script = r"""set -eu cat > /tmp/alertmanager.yml <<'ALERT_CONFIG' route: receiver: preview group_by: [alertname, service] group_wait: 1h group_interval: 5m repeat_interval: 4h receivers: - name: preview ALERT_CONFIG exec /bin/alertmanager --config.file=/tmp/alertmanager.yml --storage.path=/tmp/data \ --web.listen-address=127.0.0.1:9093 --cluster.listen-address= """ try: subprocess.run( ["docker", "run", "-d", "--rm", "--name", name, "--network", "none", "--entrypoint", "/bin/sh", "prom/alertmanager:v0.28.1", "-c", script], check=True, capture_output=True, text=True, timeout=20, ) eventually(lambda: request("GET", "/-/ready")[0] == 200) labels = [ {"alertname": "QueueHigh", "service": "checkout", "instance": "api-a"}, {"alertname": "QueueHigh", "service": "checkout", "instance": "api-b"}, {"alertname": "QueueHigh", "service": "search", "instance": "api-c"}, {"alertname": "DiskFull", "service": "checkout", "instance": "api-a"}, ] assert request("POST", "/api/v2/alerts", json.dumps([{"labels": x} for x in labels]))[0] == 200 groups = eventually(lambda: (groups if len(groups := get_json("/api/v2/alerts/groups")) == 3 else False)) sizes = {g["labels"]["service"] + "/" + g["labels"]["alertname"]: len(g["alerts"]) for g in groups} assert sizes == {"checkout/QueueHigh": 2, "checkout/DiskFull": 1, "search/QueueHigh": 1} now = datetime.now(timezone.utc) silence = {"matchers": [{"name": "service", "value": "checkout", "isRegex": False, "isEqual": True}], "startsAt": (now - timedelta(seconds=1)).isoformat(), "endsAt": (now + timedelta(minutes=10)).isoformat(), "createdBy": "local-fixture", "comment": "Disposable grouping test"} status, body = request("POST", "/api/v2/silences", json.dumps(silence)) assert status == 200 silence_id = json.loads(body)["silenceID"] def silenced_state(): alerts = get_json("/api/v2/alerts") count = sum(silence_id in x["status"]["silencedBy"] for x in alerts) return alerts if count == 3 else None alerts = eventually(silenced_state) assert len(alerts) == 4 assert all((silence_id in x["status"]["silencedBy"]) == (x["labels"]["service"] == "checkout") for x in alerts) assert request("DELETE", "/api/v2/silence/" + silence_id)[0] == 200 def unsilenced_state(): alerts = get_json("/api/v2/alerts") return len(alerts) == 4 and all(silence_id not in x["status"]["silencedBy"] for x in alerts) eventually(unsilenced_state) print(json.dumps({"group_sizes": sizes, "silenced_alerts": 3, "alerts_remain_visible": 4, "silence_expired": True}, indent=2)) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 输出应显示三个组:结算服务的 QueueHigh 有两个成员,结算服务的 DiskFull 有一个,搜索服务的 QueueHigh 也有一个。随后,临时静默匹配结算服务,抑制三条告警,但四条告警仍然都能通过接口看到;搜索服务告警不在静默范围内。程序再主动结束静默,并确认各告警的静默标识列表中不再包含该标识。 ...

七月 27, 2027 · 3 分钟 · XB Field Notes
对外采集器标签主题封面,展示观察、判断与行动三个步骤。

外部标签标识 Prometheus 对外发送的数据

多个 Prometheus 向同一系统提供数据时,下游需要知道信息来自哪个采集器或环境。全局 external_labels 可以在与外部系统通信时补充这些上下文。但它不会重写每条本地存储序列,也不会覆盖对外序列中已经存在的同名标签。理解这两条边界,才能避免用错误的查询位置判断配置是否生效,也能避免误以为全局值一定压过目标值。 本地存储与对外身份并不相同 本地查询 up 通常看到抓取和重新标记产生的标签,外部标签 prom_region 并不一定出现。它没有出现在本地查询中,不能证明配置失败。应将适当的对外接口与本地结果比较;联邦接口提供了一种直接观察差异的方法,无需为了验证这个行为先接入远端存储系统。检查位置必须与希望验证的机制对应。 配置契约也涉及远端存储和告警等外部通信,但各条下游路径还可能有自己的重新标记与处理步骤。验证某条路径时,需要观察它实际到达的消费者。本地评估的记录规则不会因为保存了一条新序列,就自动获得所有外部标签。外部标签在相关通信边界补充,不是统一改写所有本地查询结果的机制。 序列已有的标签优先 如果目标序列已经具有 cluster="target-cluster",全局外部标签配置为 cluster="collector-cluster",不会把目标值替换掉。外部标签补充的是缺少的标签名称。这是已定义的行为,不能编造成新版本遇到冲突必定拒绝或记录错误。因此,本地指标对外导出时保留的集群值,可能不同于操作人员以为全局配置会强制设置的值。 需要区分目标身份与采集器身份时,应约定独立的采集器标签名,并检查导出器和重新标记规则是否与之冲突。联邦场景中,目标端的 honor_labels 还决定如何处理源标签与本端抓取标签的冲突。这个接收端行为,与源端如何向联邦响应补充外部标签是两件事,不能因为一侧正确便跳过另一侧检查。 稳定标签不会自动实现去重 改变全局标签值,会改变之后导出序列的身份。已经保存在下游的历史仍在旧标签下,不会被重写。修改选择器和面板时,应考虑这条历史边界。添加很多标签也不必然有益:固定标签提供上下文,不同采集器的不同取值则增加消费者需要处理的身份区别,应根据实际查询需求决定,而不是把能写的字段都写进去。 两个采集器抓取相同目标时,可能在不同副本标签下导出重复观测。直接跨副本求和,会让流量看起来翻倍。外部标签让这些副本可以被区分,却不会替下游配置高可用去重。标签也不是租户授权边界;如果存在隔离要求,应核对存储系统真实的权限和查询行为,不能仅依赖名称看起来来自不同环境。 告警重新标记发生在补充外部标签之后。因此,高可用采集器可以在发送等价告警前仅去掉副本区分字段,同时为需要它的存储链路保留该指标标签。仍须核对最终告警标签确实等价,不能把真正区分责任或环境的字段也删掉。度量副本去重与告警身份归一化属于不同路径,需要分别验收。 坏掉的样子 团队可能在本地查询中寻找外部标签,找不到便反复修改本来有效的配置。相反,也可能看到联邦输出带有预期标签,就认为远端写入和告警路由都已验证。应检查具体路径和最终标签,包括下游重新标记的影响,而不是把一个接口的结果当成全部集成已经成功的证据。目标标签与全局值相同的测试,还会掩盖真正的冲突优先级。 快速验证 保存为 external-labels.py 后执行 python3 external-labels.py。实验需要 Docker 与 Prometheus 3.5.1 镜像,未缓存时可能由 Docker 下载。单个一次性 Prometheus 抓取自己,随后比较本地查询响应与文本格式的联邦输出。所有 HTTP 请求都停留在容器回环地址,不发布宿主机端口,也没有外部网络访问。 ...

六月 9, 2027 · 2 分钟 · XB Field Notes
Pushgateway 指标生命周期主题封面,展示观察、判断与行动三个步骤。

Pushgateway 保留批任务指标,直到生命周期被主动处理

短生命周期任务可能在 Prometheus 下一次抓取之前就结束。Pushgateway 提供中间层,保留任务推送的指标以供后续抓取,但它不是每个短进程的默认方案。Prometheus 主要建议把它用于适当的服务级批任务结果。保留数据的生命周期与任务自身独立,因此抓取成功不能证明任务最近运行过,也不能证明业务工作已经成功完成。 为稳定的批任务结果建立身份 夜间对账这样的服务级任务,可以把最近结果发布到稳定分组键下。如果把新的运行编号、时间戳或机器身份放进键中,每次运行都可能创建额外分组,而不是更新同一份结果。Pushgateway 没有自动清理旧分组的过期时间机制,负责人需要明确替换或删除相关数据,不能假定退出任务后数据也会跟着消失。 与具体机器相关的批任务,往往可以使用节点导出器的文本文件采集器。共享网关本身也增加了可用性依赖。它的 up 表示 Prometheus 能否抓取网关,不表示每个原始推送任务都健康。应根据观测事件与清理责任选择采集方式,而不是把推送当成绕过服务发现或网络策略的通用办法。任务语义没有确定时,先增加网关并不会自动解决监控定义。 替换范围由 HTTP 方法决定 URL 路径定义分组键,包括任务名称与其他标签对。HTTP PUT 替换这个精确分组中的指标;POST 则替换本次请求包含的同名指标族,同时保留组内其他指标族。POST 不表示给原值递增,也不是向持久事件日志追加一条记录。如果一次报告把数值从七写成九,结果是九,而不是自动累加成十六。 DELETE 删除 URL 标识的精确分组。仅删除任务名构成的分组键,不会删除所有同时包含该任务名和额外标签的分组。网关文档规定的方法顺序允许后续成功推送排在先前已入队的删除之后,这可以用于验证清理结果。不能只看到删除请求被接受,就认定所有名称相似的序列都已经被处理,尤其不能把分组键误当成标签子集查询。 抓取时间不等于最近成功时间 网关通过 push_time_seconds 导出某组最近成功推送的时间。读取指标端点不会推进这个值。Prometheus 为采集样本赋予抓取时间戳,所以刚刚抓到的样本仍可能描述很久以前的任务结果。如果真正关心任务何时成功完成,应把最近成功时间作为独立的仪表值导出,并按任务计划检查其新鲜度。 不要给推送的指标样本附带显式样本时间戳,Pushgateway 会拒绝这种输入。把 Unix 时间戳作为仪表指标的数值是另一回事,可以正常使用。推送成功也不能证明业务成功或已经立即持久化:任务可以成功报告自己的失败,而没有配置持久化的网关在重启后可能丢失保留数据。各项完成语义应分别核对。 同一分组键还需要明确并发写入责任。两个运行可能互相覆盖,较早开始但延迟完成的任务,也可能在较新任务之后推送。HTTP 接口不会替业务建立运行先后关系。应协调重叠任务,或在合适的记录中保留完成身份;不能只凭最后抵达网关,就断定它代表最新一轮。稳定键控制基数,不自动解决写入顺序。 坏掉的样子 任务失败或被停用之后,旧的成功指标可能一直存在。只检查序列是否存在的告警,就会漏掉没有发生的新执行。应把最近成功值与预期调度比较,并让任务移除与分组清理同步。若分组标签不断改变,新旧运行还可能永久并列显示,给面板制造重复任务或持续健康的错觉,而实际已经没有新的成功结果。 本来想发布完整快照,却使用 POST,可能保留本次任务已不再输出的指标;只准备更新一个指标,却使用 PUT,又可能删除其他面板仍需要的结果。应明确哪个组件拥有整个分组,以及它使用哪种方法。不存在能够代替这些 HTTP 语义的 --replace 选项,不能把一个未经核实的命令参数写进操作流程。 ...

五月 13, 2027 · 2 分钟 · XB Field Notes
目标发现与重新标记主题封面,展示观察、判断与行动三个步骤。

目标重新标记在抓取前选择目标并整理标签

服务发现产生候选目标及其元数据,目标重新标记在 Prometheus 抓取之前处理这些候选项。它可以保留或过滤目标、改变访问地址,并创建随采集序列一起保存的标签。这与改写单个抓取样本发生在不同阶段。先弄清操作阶段,才能避免本来只想去掉一个指标,却因为配置位置错误而把整个目标从采集范围中移除。 定义规则前先查看发现输入 发现结果包含 __address__ 等内部标签,以及由具体发现机制提供的 __meta_ 字段。文件发现中的 __meta_filepath 指明目标来自哪个文件。这些元数据在目标重新标记期间可以使用,但内部标签会在该阶段结束后从最终目标标签集中移除。如果后续查询需要某个值,应明确把它复制到普通标签中,不能假定所有发现信息都会自动入库。 准确的元数据名称取决于发现机制。从 Kubernetes 发现配置复制过来的规则,不会自动适用于文件发现。组合输入时,缺少的源标签贡献空值,因此依赖一个不存在字段的保留规则,可能过滤所有候选项。排查网络故障之前,应通过目标接口或发现视图检查实际输入标签,先确认目标是否真的进入了抓取阶段。 规则顺序决定最终身份 重新标记步骤按照配置顺序执行。keep 动作仅保留组合源标签值匹配正则表达式的目标,drop 动作则移除匹配目标。正则表达式默认在两端锚定。replace 动作可以把捕获的文本复制到目标标签,而新值又能成为同一序列中后续规则的输入。因此,互换两个步骤可能改变结果,即使每条规则本身都能够通过解析。 复制一个值并不会自动删除原标签。确实需要删除时,它是另一项操作,而且不能破坏抓取或消费者依赖的身份信息。修改 __address__ 会改变 Prometheus 实际访问的端点,修改普通标签则改变上下文。两者都不能简单描述成给面板字段换一个好看的名字:地址变化可能影响可达性,标签变化则可能影响路由、聚合和历史查询。 目标标签能够影响从该目标收集的所有序列。应选择服务或有意义的部署范围等稳定字段,而不是复制全部发现注解。频繁变化的构建标识,可能让大量指标同时产生新身份。因此,发现配置中的一个小改动,能够影响远大于配置文件自身规模的存储数据及其消费者,需要根据真实查询需求审阅。 坏掉的样子 被重新标记过滤的目标,不是一个 up=0 的已配置目标。它没有被抓取,失败抓取查询可能永远不会报告它。预期服务消失时,应对照发现候选项、被过滤目标与活动目标。若告警假定全部预期成员始终存在对应 up 序列,一条错误保留规则就可能让整组目标看起来像安静且健康的空集合。 复制错误元数据也可能得到外观合理、语义却错误的归属标签。查询结果很整齐,不代表数据真的属于被标注的服务。应一起审阅执行顺序、捕获组和最终标签。大范围删除标签还可能合并原本不同的身份,目标重新标记不会为了补偿信息损失而自动计算聚合值,不能把去标签当成汇总方法。 如果没有规则设置 instance,Prometheus 会使用最终的 __address__ 作为默认值,因此改写地址可能同时改变访问端点与默认身份。还应检查 honor_labels:目标导出标签与抓取侧标签冲突时,结果由这个配置决定,不能笼统认为目标标签一定覆盖导出器提交的所有同名值。 快速验证 把程序保存为 target-relabel.py,执行 python3 target-relabel.py。需要 Docker 和 Prometheus 3.5.1,镜像未缓存时可能下载。隔离容器内同时提供静态文本指标端点与文件发现清单,清单包含一个接口目标和一个应被过滤的目标。指标路径使用 /metrics.txt,使 HTTP 响应具有 Prometheus 接受的文本内容类型,避免端点格式问题遮住标签实验。 ...

四月 6, 2027 · 2 分钟 · XB Field Notes
指标失效与告警等待主题封面,展示观察、判断与行动三个步骤。

Prometheus 序列失效与告警等待是不同机制

指标缺失不等于数值为零。Prometheus 会区分具有近期值的序列、已经标记失效的序列,以及找不到可用样本的选择器;告警又在查询结果之上使用自己的评估和等待规则。如果把这些机制统称为一个宽限时间,就可能漏掉被移除的目标,或者没有弄清到底缺少什么数据便宣布服务故障。准确的缺失告警首先需要准确描述观测对象。 失效标记不是统一的五分钟计时器 即时选择器通常使用回看范围内最新的合格样本,默认回看范围是五分钟。失效标记会告诉引擎:在标记之后,不再让这条序列出现在即时选择结果中。它不要求旧值再保留五分钟。历史样本仍然存在于存储中,也可能继续参与范围查询;因此,即时查询已经看不到某条序列,与历史窗口里完全没有样本,并不是同一件事。 具体采集路径会影响行为。导出器不再输出某个指标、目标从服务发现消失,以及导出器自己提供样本时间戳,不一定遵循相同的时间过程。显式时间戳和失效跟踪配置也会改变结果。在依据抓取周期推测消失时刻之前,应先确认导出器使用哪条路径。可配置的回看范围本质上是查询选择规则,不是能够统一判定所有服务故障的计时器。 先确定要询问哪一种缺失 absent(selector) 在选择器没有任何元素时返回一;只要仍有匹配数据,即使数值为零,也返回空向量。absent_over_time(selector[3m]) 则询问所选三分钟范围内是否存在样本。失效标记之前的真实样本,可能继续留在这个范围内,使范围缺失表达式保持为空,直到该样本移出窗口。这不是查询互相矛盾,而是问题的时间范围不同。 告警规则的 for 又是一层机制:相关标签集必须在连续的评估中持续由表达式返回,经过等待时长后才进入触发状态。它不会配置序列失效,也不会延长查询回看范围。把三分钟缺失窗口再配上额外等待时间,会进一步延后通知,评估节奏和采集时机也影响最终延迟。选择这些参数时,应从允许的发现时间反推,而不是只把它们当作降噪旋钮。 坏掉的样子 absent(up{job="api"}) 不是抓取失败告警。若已配置目标仍存在,只是 up=0,选择器仍有元素,缺失条件便不成立。与零比较才能检查这个场景。但如果目标彻底从发现清单消失,只检查零又可能在其序列消失之后漏报。对目标是否应当存在有要求时,需要另行监测预期成员关系,不能让一个表达式混合承担所有职责。 宽泛的选择器也不会自动列举所有缺少的成员。一个副本仍有数据时,对整个任务求 absent 仍为空,即使其他几个预期副本已消失。可以匹配明确已知的身份,或者将观测序列与权威清单比较。标签被改名同样可能使旧匹配条件看起来像发生故障,而进程其实正在以新身份正常工作,所以规则与标签变更需要一起审阅。 快速验证 保存为 stale.py,运行 python3 stale.py。环境需要已经安装的 promtool,可以从 PATH 找到,或用 PROMTOOL 指定其可执行文件路径。本轮使用 Python 3.12.14 与 promtool 3.14.0。真实查询引擎在临时目录中评估合成样本,不启动服务器、监听端口或容器,也不联系外部服务。 from pathlib import Path from tempfile import TemporaryDirectory import os import shutil import subprocess # Uses a real, installed promtool; no server or Docker runtime is started. promtool = shutil.which(os.environ.get("PROMTOOL", "promtool")) if not promtool: raise SystemExit("Set PROMTOOL to an installed promtool executable") tests = r"""evaluation_interval: 1m rule_files: [rules.yml] tests: - interval: 1m input_series: - series: "up{job=\"api\",instance=\"gone\"}" values: "1 0 0 0 0" - series: "service_events_total{job=\"api\",instance=\"gone\"}" values: "10 stale _ _ _" - series: "service_events_total{job=\"api\",instance=\"zero\"}" values: "0 0 0 0 0" promql_expr_test: - expr: "service_events_total{job=\"api\",instance=\"gone\"}" eval_time: 1m exp_samples: [] - expr: "up{job=\"api\",instance=\"gone\"}" eval_time: 1m exp_samples: - labels: "{__name__=\"up\",job=\"api\",instance=\"gone\"}" value: 0 - expr: "absent(service_events_total{job=\"api\",instance=\"gone\"})" eval_time: 1m exp_samples: - labels: "{job=\"api\",instance=\"gone\"}" value: 1 - expr: "absent_over_time(service_events_total{job=\"api\",instance=\"gone\"}[3m])" eval_time: 2m exp_samples: [] - expr: "absent_over_time(service_events_total{job=\"api\",instance=\"gone\"}[3m])" eval_time: 4m exp_samples: - labels: "{job=\"api\",instance=\"gone\"}" value: 1 - expr: "absent(service_events_total{job=\"api\",instance=\"zero\"})" eval_time: 4m exp_samples: [] - expr: "absent(up{job=\"api\"})" eval_time: 4m exp_samples: [] - expr: 'absent_over_time(service_events_total{job="api",instance="gone"}[3m])' eval_time: 3m exp_samples: - labels: '{job="api",instance="gone"}' value: 1 - expr: 'ALERTS{alertname="BusinessMetricMissing",alertstate="pending"}' eval_time: 3m exp_samples: - labels: '{__name__="ALERTS",alertname="BusinessMetricMissing",alertstate="pending",job="api",instance="gone"}' value: 1 alert_rule_test: - eval_time: 4m alertname: BusinessMetricMissing exp_alerts: [] - eval_time: 5m alertname: BusinessMetricMissing exp_alerts: - exp_labels: {job: api, instance: gone} exp_annotations: {} """ rules = r"""groups: - name: absence rules: - alert: BusinessMetricMissing expr: absent_over_time(service_events_total{job="api",instance="gone"}[3m]) for: 2m """ with TemporaryDirectory(prefix="prometheus-query-check-") as directory: root = Path(directory) (root / "tests.yml").write_text(tests, encoding="utf-8") if rules: (root / "rules.yml").write_text(rules, encoding="utf-8") result = subprocess.run([promtool, "test", "rules", "tests.yml"], cwd=root, text=True, capture_output=True, timeout=45) print(result.stdout, end="") if result.returncode: print(result.stderr, end="") raise SystemExit(result.returncode) 成功时输出 SUCCESS。第一分钟,已经标记失效的业务序列不再被即时选择,但单独提供的 up 序列仍存在,数值为零;即时 absent 返回一。第二分钟,三分钟范围仍包含最初的业务样本,因此 absent_over_time 不返回元素;到了第三分钟,左开边界就已经排除最初样本,因此返回一,第四分钟仍返回一。程序分别断言每种结果,而不是只检查命令退出。 ...

二月 3, 2027 · 2 分钟 · XB Field Notes
直方图聚合与分位数主题封面,展示观察、判断与行动三个步骤。

Prometheus 直方图保留分桶后的分布信息

延迟平均值可能隐藏一部分特别慢的请求。直方图通过记录落入不同桶的观测数量,保留更多分布信息,但仍然丢弃桶内部的细节。Prometheus 可以从这些计数估计分位数,无需保存每次请求的原始耗时。估计结果取决于桶边界与聚合方式,不能称为完整恢复了原始延迟分布,也不能因为输出包含小数就认为测量具有同样精度。 经典直方图由多条相关序列组成 经典直方图导出累计的 _bucket 计数器,以及 _count 与 _sum。le 标签表示每个桶包含的上界。一点五秒的观测会增加两秒桶和所有更大的桶,所以这些桶不是互不重叠的区间,不能直接相加得到总请求数。正无穷桶包含全部观测,与总计数对应。一个经典直方图因此不是一条可以单独理解的普通序列。 对于非负延迟观测,分别对总和与总计数应用 rate,可以得到每秒累计的耗时和每秒观测数量;两者相除就是以秒为单位的平均延迟。桶速率则描述选定时间范围内的分布。应该先对各条计数器求速率,再合并实例,这样各序列已经观测到的重置能够独立处理,不会在求和时被另一实例的增长掩盖。 先合并桶,再估计总体分位数 需要整个服务的一个分位数时,应在保留 le 的前提下合并桶速率,再调用 histogram_quantile。查询需要区分服务等维度时,也应保留相应标签。参与合并的桶边界要兼容,事件定义也要一致。如果先把 le 消掉,就丢失了经典分位数函数所需的桶身份,留下的数值无法继续表达累计分布。 不要平均各实例的分位数。分位数是分布中的位置,不是可相加的计数,即使各实例请求量完全相同,平均分位数也未必等于总体分位数。合并桶曲线可以具有与任何单个实例都不同的形状。如果问题是两秒以内完成的请求比例,恰好以两秒为边界的桶除以总计数,就可以直接回答这个阈值问题,不必绕到分位数再反推。 摘要指标可以导出客户端预先计算的分位数,这些数值也不能通过求和或平均合成总体分位数。不过,只要事件定义一致,它的总计数与总和仍可用于计算合并平均值。选择指标类型时,应从所需聚合能力出发,而不是仅看面板希望直接显示什么字段。 坏掉的样子 关注的分位数落在很宽的桶内时,输出容易显得比实际测量更精确。经典分位数计算会在选中的桶内插值,却不知道每个观测在桶内的真实位置。应围绕服务目标设置有用的边界,并在升级前审阅桶定义,避免滚动发布期间不同实例混用不兼容方案。事后加密桶边界也不会恢复旧观测已经丢失的细节。 桶或导出器缺失,与没有观测不同。面板上看似改善的分位数,也可能只是最慢实例的指标丢了。解释改善之前,应检查抓取完整性、参与计算的序列和样本数量。观测太稀疏或完全缺失时,分位数可能没有可用含义;把这种结果替换成零延迟,会制造虚假的成功信号,而不是补全测量。 快速验证 保存为 histogram-query.py,运行 python3 histogram-query.py。环境需要已经安装的 promtool,可以从 PATH 找到,或用 PROMTOOL 指定其可执行文件路径。本轮使用 Python 3.12.14 与 promtool 3.14.0。真实查询引擎在临时目录中评估合成样本,不启动服务器、监听端口或容器,也不联系外部服务。 from pathlib import Path from tempfile import TemporaryDirectory import os import shutil import subprocess # Uses a real, installed promtool; no server or Docker runtime is started. promtool = shutil.which(os.environ.get("PROMTOOL", "promtool")) if not promtool: raise SystemExit("Set PROMTOOL to an installed promtool executable") tests = r"""evaluation_interval: 1m tests: - interval: 1m input_series: - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"1\"}" values: "0+95x5" - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"2\"}" values: "0+100x5" - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"5\"}" values: "0+100x5" - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"+Inf\"}" values: "0+100x5" - series: "api_duration_seconds_count{instance=\"fast\"}" values: "0+100x5" - series: "api_duration_seconds_sum{instance=\"fast\"}" values: "0+17x5" - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"1\"}" values: "0+0x5" - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"2\"}" values: "0+0x5" - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"5\"}" values: "0+100x5" - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"+Inf\"}" values: "0+100x5" - series: "api_duration_seconds_count{instance=\"slow\"}" values: "0+100x5" - series: "api_duration_seconds_sum{instance=\"slow\"}" values: "0+400x5" - series: 'tail_duration_seconds_bucket{le="1"}' values: "0+0x5" - series: 'tail_duration_seconds_bucket{le="5"}' values: "0+90x5" - series: 'tail_duration_seconds_bucket{le="+Inf"}' values: "0+100x5" promql_expr_test: - expr: "abs(histogram_quantile(0.99, sum by (le) (rate(api_duration_seconds_bucket[2m]))) - 4.94) < bool 1e-12" eval_time: 5m exp_samples: - labels: "{}" value: 1 - expr: "abs(avg(histogram_quantile(0.99, rate(api_duration_seconds_bucket[2m]))) - 3.385) < bool 1e-12" eval_time: 5m exp_samples: - labels: "{}" value: 1 - expr: "sum(rate(api_duration_seconds_bucket{le=\"2\"}[2m])) / sum(rate(api_duration_seconds_count[2m]))" eval_time: 5m exp_samples: - labels: "{}" value: 0.5 - expr: "abs(sum(rate(api_duration_seconds_sum[2m])) / sum(rate(api_duration_seconds_count[2m])) - 2.085) < bool 1e-12" eval_time: 5m exp_samples: - labels: "{}" value: 1 - expr: 'histogram_quantile(0.99, rate(tail_duration_seconds_bucket[2m]))' eval_time: 5m exp_samples: - labels: '{}' value: 5 """ rules = r"""""" with TemporaryDirectory(prefix="prometheus-query-check-") as directory: root = Path(directory) (root / "tests.yml").write_text(tests, encoding="utf-8") if rules: (root / "rules.yml").write_text(rules, encoding="utf-8") result = subprocess.run([promtool, "test", "rules", "tests.yml"], cwd=root, text=True, capture_output=True, timeout=45) print(result.stdout, end="") if result.returncode: print(result.stderr, end="") raise SystemExit(result.returncode) 成功时输出 SUCCESS。正确合并后的百分之九十九分位数估计是四点九四秒;分别计算两个实例后再平均,得到三点三八五秒,并不是总体结果。满足两秒阈值的观测占一半,合并平均延迟为二点零八五秒。断言对浮点结果采用很小的数值容差,避免要求十进制小数必须具有完全相同的二进制表示。 ...

一月 6, 2027 · 2 分钟 · XB Field Notes
标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

把单位写进指标名里

名为 request_duration 的指标会让读者猜测,二百五十究竟表示秒、毫秒还是别的量。面向 Prometheus 的名称,例如 http_request_duration_seconds,能够把单位约定带入查询、记录规则与告警配置。但这个约定必须由正确实现支撑:给毫秒数据的名字加上秒后缀,不会自动改变数值大小,反而可能让监控产生一千倍的误差。名称提供线索,转换逻辑才决定数据是否可信。 在明确的边界完成换算 每个指标族应只表示一种量和一种单位。Prometheus 建议使用秒、字节等基础单位,并在适用时把单位写为后缀。持续时间与绝对时间戳都可以用秒表示,却不是同一种量,因此不能因为名字都含秒,就把上次成功的时间戳与一次请求的耗时直接相加。帮助文本应写明测量对象、开始结束边界以及含义,让简短名称背后仍有明确的契约。 如果底层库输出毫秒,应在观测或导出边界转换一次,并保留足够精度,避免把很短的耗时截断为零。仪表盘可以按基础单位配置显示,让零点二五秒自动显示为二百五十毫秒,这只改变呈现方式,不改变存储样本。不能仅因为坐标轴显示毫秒,就在记录规则中再做一次换算,否则查询和图表会在不同环节重复缩放。 单位与类型回答不同问题 累计传输字节数通常使用字节加总量后缀,当前内存占用仪表则只使用字节后缀。两者单位相同,但只有前者表示不断累积的工作量,适合计算计数器速率。请求次数按惯例使用总量后缀,无须编造一个物理单位。直方图的桶、总和与计数,还会在耗时指标族名称后加入各自的组件后缀,因此阅读名称时也要识别指标族与导出组件之间的关系。 比例通常表示零到一之间的分数,而百分数使用另一种数值尺度,面板应明确自己期待哪种输入。计数器可以在进程重启时归零,Prometheus 的计数器速率函数会根据观测序列中的下降处理重置,并不要求额外存在创建时间序列。应先分别计算独立计数器的速率,再执行聚合,让各自的重置仍能被识别;不要先把多个实例的累计值混成一条看似连续的曲线。 坏掉的样子 名字正确的指标仍然可能承载错误数值。如果同一个标识下混入秒和毫秒,历史数据已经被污染,修改帮助文字或补一个说明标签不能修复旧样本。单位迁移应被视为契约变化,生产端、查询、告警和仪表盘需要一起核对,历史区间的比较也必须说明边界。旧序列含义不兼容时,可以采用新的指标标识,避免新旧数值被误当成同一种量连续使用。 名称后缀也不会强制执行计数器语义。一个普通运行过程中可以升降的仪表,即使名字带总量后缀,也不适合按累计计数器解释。反过来,已经得到每秒速率后再除以时间窗口,会制造另一种量。这些是维度和测量语义的问题,不能只靠显示格式解决。其他遥测体系可能把单位放在元数据中,应遵循已定义的导出映射,而不是机械重命名所有上游仪器。 快速验证 把代码保存为 units.py,使用 Python 3 执行。程序转换三条人为构造的延迟观测,并输出带帮助文字和类型声明的 Prometheus 文本格式。十进制定点运算使毫秒到秒的换算清楚可见;阈值比较则展示,仅仅改名、却不转换数据的生产端,为什么不能和正确的生产端共用同一条延迟规则。 from decimal import Decimal latency_ms = [Decimal('120'), Decimal('250'), Decimal('630')] latency_seconds = [value / Decimal('1000') for value in latency_ms] threshold_seconds = Decimal('0.300') correct_good = sum(value <= threshold_seconds for value in latency_seconds) rename_only_good = sum(value <= threshold_seconds for value in latency_ms) transferred_bytes = 1024 * 1024 used_ratio = Decimal('25') / Decimal('100') assert correct_good == 2 and rename_only_good == 0 assert sum(latency_seconds) == Decimal('1.000') assert used_ratio == Decimal('0.25') print(f"# within 0.300s: converted={correct_good}/3, rename_only={rename_only_good}/3") print('# HELP demo_last_request_duration_seconds Duration of the most recent completed request.') print('# TYPE demo_last_request_duration_seconds gauge') print(f'demo_last_request_duration_seconds {latency_seconds[-1]:.3f}') print('# HELP demo_transferred_bytes_total Bytes transferred since this process started.') print('# TYPE demo_transferred_bytes_total counter') print(f'demo_transferred_bytes_total {transferred_bytes}') print('# HELP demo_capacity_used_ratio Fraction of configured capacity currently used.') print('# TYPE demo_capacity_used_ratio gauge') print(f'demo_capacity_used_ratio {used_ratio}') 首行注释说明,转换后有三分之二的观测不超过零点三秒,而把毫秒数值直接当成秒时,三条都无法满足阈值。导出的最后请求耗时为零点六三秒,累计字节数为一百零四万八千五百七十六,容量使用比例为零点二五,在百分比界面上对应百分之二十五。三个数分别对应持续时间、累计大小与无量纲比例,不能因为都能画成曲线就混用。 ...

十月 30, 2026 · 1 分钟 · XB Field Notes