告警注解与通知模板主题封面,展示观察、判断与行动三个步骤。

注解解释告警现场,同时保留稳定身份

告警标签标识正在跟踪哪一个条件,并提供路由与分组字段;注解则解释这个条件,例如当前值、简短摘要或操作手册链接。把变化中的说明文字放进标签,可能把同一个持续问题拆成多个身份;反过来,如果责任归属字段只存在于注解中,依赖标签的路由又可能无法识别应该由谁处理。两类字段都重要,但承担不同职责。 区分身份与当前说明 队列告警可以保持 alertname、service 与 instance 标签稳定,同时把摘要从等待六项更新为等待八项。Alertmanager 可以在相同告警指纹下更新注解,而不是新增一条告警。指纹来自身份标签,不是通知渲染文本的哈希,所以文字改变不应该被直接理解为发生了另一个独立事件。这个边界也方便操作人员跟踪问题的持续变化。 该区别在进入 Alertmanager 之前也有影响。Prometheus 按告警标签集跟踪等待时长,如果标签中包含不断变化的当前值,就可能持续创建新身份,导致预期等待过程无法完成。除非数值确实定义另一种告警条件,否则应把它放在注解中。遇到反复等待却不触发的问题,应同时审阅规则标签和模板内容,而不是只调整等待参数。 一组告警未必拥有共同摘要 通知模板能访问多个层级的数据。GroupLabels 包含分组选择的标签,CommonLabels 包含通知中所有告警都具有的相同标签对,CommonAnnotations 同样只包含组内共有的注解。Alerts 列表则保留各成员自己的标签和注解,因此可以逐条解释不同实例的现场情况。共同字段与全部字段不是同一概念,字段存在于某条告警中也不保证它存在于组级对象中。 两个副本的摘要描述不同队列长度时,CommonAnnotations 就没有一个共同摘要。只打印该字段的模板,可能恰好省掉响应人员最需要的信息。更合理的方式是用共同上下文组织标题,再遍历每条告警输出变化中的说明。即使当前值不同,两条告警仍可能共享同一份操作手册链接,这个链接就可以继续作为共同注解使用。 也不能假定每一组都具有实例这样的分组标签。按服务与告警名分组,本来就是为了合并不同实例。模板应对应真实分组契约,并主动处理缺少共同字段的情况。代表性输入需要覆盖多条告警、不同注解,以及相关触发或恢复状态,不能只用一条理想告警验证,因为单条输入很容易让所有字段都碰巧成为共同字段。 坏掉的样子 动态标签会让每次数值变化产生新指纹,增加告警变化量,并可能重新开始等待。匹配 team 的路由不能用同名注解代替标签;分组策略与模板假设不一致时,通知标题也可能只描述一台实例,正文却混入不同服务的告警。应把身份、分组与展示作为一条真实调用链检查,避免各部分单独看起来合理,组合后却误导读者。 模板语法通过,并不保证真实分组数据能渲染出完整且准确的消息。应核对渲染内容,而不是只检查解析成功。反过来,渲染成功也不能证明通知集成接受该消息,或消息已经送达。凭据与敏感请求详情不应放在注解中,因为这些文字可能被复制到路由选择的所有接收器,也可能进入后续通知记录。 快速验证 把程序保存为 annotations.py,在 Docker 可用时运行 python3 annotations.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") 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) alerts = [{"labels": {"alertname": "QueueHigh", "service": "checkout", "instance": instance}, "annotations": {"summary": "queue=" + str(depth), "runbook": "https://example.invalid/queue"}} for instance, depth in [("api-a", 6), ("api-b", 9)]] assert request("POST", "/api/v2/alerts", json.dumps(alerts))[0] == 200 before = eventually(lambda: (a if len(a := get_json("/api/v2/alerts")) == 2 else False)) fingerprints = {a["labels"]["instance"]: a["fingerprint"] for a in before} alerts[0]["annotations"]["summary"] = "queue=8" assert request("POST", "/api/v2/alerts", json.dumps([alerts[0]]))[0] == 200 def updated_annotations(): items = get_json("/api/v2/alerts") changed = any(x["labels"]["instance"] == "api-a" and x["annotations"]["summary"] == "queue=8" for x in items) return items if changed else None updated = eventually(updated_annotations) assert len(updated) == 2 assert {a["labels"]["instance"]: a["fingerprint"] for a in updated} == fingerprints # Supply the documented notification Data shape to the real template renderer. data = {"receiver": "preview", "status": "firing", "groupLabels": {"alertname": "QueueHigh", "service": "checkout"}, "commonLabels": {"alertname": "QueueHigh", "service": "checkout"}, "commonAnnotations": {"runbook": "https://example.invalid/queue"}, "alerts": [{"status": "firing", "labels": a["labels"], "annotations": a["annotations"]} for a in sorted(updated, key=lambda a: a["labels"]["instance"])]} template = '{{define "preview"}}{{.GroupLabels.service}}|common-summary={{with .CommonAnnotations.summary}}{{.}}{{else}}none{{end}}{{range .Alerts}}\n{{.Labels.instance}}:{{.Annotations.summary}}{{end}}{{end}}' for path, content in [("/tmp/preview.tmpl", template), ("/tmp/data.json", json.dumps(data))]: subprocess.run(["docker", "exec", "-i", name, "/bin/sh", "-c", "cat > " + path], input=content, text=True, capture_output=True, check=True, timeout=5) rendered = subprocess.run(["docker", "exec", name, "/bin/amtool", "template", "render", "--template.glob=/tmp/preview.tmpl", "--template.data=/tmp/data.json", '--template.text={{ template "preview" . }}'], text=True, capture_output=True, check=True, timeout=5).stdout.strip() assert rendered == "checkout|common-summary=none\napi-a:queue=8\napi-b:queue=9", rendered print(json.dumps({"alert_count": 2, "fingerprints_unchanged": True, "rendered": rendered}, indent=2)) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 结果确认更新后仍只有两条告警,指纹也保持不变。程序随后向真实 amtool 模板渲染器提供文档规定的数据结构:共同注解故意只保留共享手册链接,不放入彼此不同的摘要。这里明确提供的输入验证渲染行为,不代表已验证服务器如何组装实际通知,更不能替代发送链路测试。 ...

二月 22, 2028 · 3 分钟 · XB Field Notes
告警路由主题封面,展示观察、判断与行动三个步骤。

Alertmanager 通过标签匹配树选择告警接收器

Alertmanager 根据告警标签遍历路由树,选择通知接收器。路由树表达责任归属与升级策略,接收器则定义实际通知集成。配置能够解析,并不代表每一类告警都被分配给正确目的地。因此,不能把重载成功当作路由有效的完成证据;应该先核对进入系统的标签契约,再验证整棵树针对典型告警的选择结果。 既要看匹配,也要看何时停止 每条告警都会进入必须匹配所有告警的根路由。子路由继承没有主动覆盖的配置;没有子节点匹配时,由当前节点处理。否则,遍历通常在第一个匹配的子节点后停止。匹配节点配置 continue: true,才允许继续检查后续同级节点。这不表示配置中的所有接收器都会获得同一条告警,未匹配的节点仍然不会参与。 一个路由节点只能指定一个接收器。接收器内部可以包含多个通知集成,多条匹配路由也可以选择不同接收器,两者是向多个目的地通知的不同方式。不能把路由的 receiver 字段直接换成列表,就期待具有相同行为。选择方案时应核对是否会重复通知,以及不同目的地是否需要独立的分组方式、升级策略和发送节奏。 责任归属依赖稳定的标签契约 匹配 team="payments" 的路由,需要进入 Alertmanager 的告警真正带有这个标签值。名为 team 的注解不能满足该条件。Prometheus 表达式中的聚合可能提前移除一个维度,所以应该检查最终告警标签,包括告警重新标记的影响,不能假定原始指标上的标签会原样保留到通知阶段。原始指标存在团队字段,不足以证明路由可以看到它。 根接收器应当对没有匹配具体子节点的告警仍然有用。这类告警也需要可观察的去向,以及能够修正分类的负责人。测试时除了已知团队,还应覆盖标签缺失:缺少团队的警告可能留在根节点,但缺少同一标签的严重告警仍可能命中独立的严重程度路由。是否缺失与是否完全没有匹配路径,是不同问题。 路由排列顺序本身就是行为。较宽泛的前置路由,可能让后面的具体路由根本没有机会被检查,除非启用了继续匹配。因此,即使匹配器文字没有变化,移动一段配置也可能改变收件方。顺序调整应按策略变化审阅,并把代表性标签输入与配置放在一起,以便持续核对预期选择。 坏掉的样子 拼写错误或标签改名可能使告警落到根接收器,新添加的宽泛子路由可能截走后面团队的告警,而不必要的继续匹配则可能让一个事件产生多份通知。诊断这些问题,需要实际标签和完整匹配路径,单独阅读某个节点通常不够。还应核对新增规则与既有顺序共同作用后的结果,而不只是新增部分自身是否正确。 接收器选择正确仍不等于送达。分组等待、静默、抑制、模板错误、传输故障和目的地行为,都可能在之后介入。先验证路由,再沿所选接收器追踪通知链路。增加规则等待时间不会修复团队匹配条件,缩短分组等待也不能修复接收器凭据缺失。每个阶段都需要对应证据,不能用前一阶段通过代替后一阶段结果。 快速验证 把程序保存为 routing.py,执行 python3 routing.py。需要 Docker 与公开的 Alertmanager 0.28.1 镜像,镜像缺失时可能先下载。真实 amtool 检查配置并验证七组标签输入。实验接收器只有名称,没有实际发送集成,因此可以检查路由选择而不会联系任何人员或外部服务,也不会影响现有告警系统。 import subprocess import uuid # Requires Docker and Alertmanager 0.28.1. No receiver has a delivery integration. script = r"""set -eu cat > /tmp/routes.yml <<'ROUTES' route: receiver: default group_by: [alertname, service] routes: - receiver: payments matchers: ['team="payments"'] continue: true - receiver: pager matchers: ['severity="critical"'] receivers: - name: default - name: payments - name: pager ROUTES /bin/amtool check-config /tmp/routes.yml /bin/amtool config routes test --config.file=/tmp/routes.yml --verify.receivers=payments,pager team=payments severity=critical /bin/amtool config routes test --config.file=/tmp/routes.yml --verify.receivers=payments team=payments severity=warning /bin/amtool config routes test --config.file=/tmp/routes.yml --verify.receivers=pager team=data severity=critical /bin/amtool config routes test --config.file=/tmp/routes.yml --verify.receivers=default team=data severity=warning /bin/amtool config routes test --config.file=/tmp/routes.yml --verify.receivers=default severity=warning /bin/amtool config routes test --config.file=/tmp/routes.yml --verify.receivers=default team=payments-preview severity=warning sed 's/continue: true/continue: false/' /tmp/routes.yml > /tmp/stop.yml /bin/amtool config routes test --config.file=/tmp/stop.yml --verify.receivers=payments team=payments severity=critical """ name = "xbcatm-route-test-" + uuid.uuid4().hex[:12] try: result = subprocess.run( ["docker", "run", "--rm", "--name", name, "--network", "none", "-i", "--entrypoint", "/bin/sh", "prom/alertmanager:v0.28.1", "-s"], input=script, text=True, capture_output=True, timeout=30, ) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(result.stdout, end="") if result.returncode: raise RuntimeError(result.stderr or result.stdout) print("Verified seven route cases, including continuation and unmatched labels.") 第一组结果为 payments,pager:支付团队路由先匹配并继续,严重程度路由随后也匹配。支付团队警告只选择支付接收器,数据团队严重告警选择呼叫接收器;没有匹配或缺少团队值的相应输入进入根接收器。payments-preview 不会匹配精确的支付团队值,避免把名称相近误认为条件成立。 ...

十二月 8, 2027 · 2 分钟 · XB Field Notes
告警状态与通知送达主题封面,展示观察、判断与行动三个步骤。

Prometheus 规则先跟踪告警状态,再交给通知链路

告警规则评估 PromQL 表达式,并持续跟踪表达式返回的标签集。Prometheus 判断每个告警是等待还是触发,Alertmanager 接收告警信息后负责通知路由、分组、静默与抑制。排查时应分别追踪这些环节:规则显示正在触发,是规则状态的证据,不能直接证明某个人已经收到消息。把状态和送达混为一谈,会使漏通知问题很难定位。 表达式返回元素就会激活告警候选项 表达式返回的每个元素代表一个活动告警候选项。queue_depth > 5 这样的比较会过滤不满足条件的数值;但加上 bool 修饰符之后,不满足条件的比较仍会留下数值为零的元素。告警规则依据元素是否存在来激活候选项,因此误用该修饰符,可能让数值已经为零的结果也进入告警流程。这是查询看似合理、规则却持续激活的常见原因。 标签定义告警身份。服务、队列与严重程度等稳定路由维度适合放在标签中,变化中的说明值则适合放在注解中。如果把当前队列大小写进标签,每次数值变化都可能创建一个新身份,重新开始等待。把大小写进注解则可以解释现场状态,同时保留原有告警身份。需要审阅的不只是阈值,还有标签和注解承担的职责。 等待时长通过评估时刻观察 配置 for: 2m 后,一个新出现的标签集会先等待,直到它在评估中持续活动两分钟。如果规则每分钟评估一次,第一分钟首次看到异常,就可能在第三分钟进入触发状态。引擎观察的是评估结果,不是样本之间连续发生的全部物理事实。因此,实际发现时间还包括等待下一次评估的时间,不能只用配置的等待时长计算最坏延迟。 省略 for 时,活动表达式可以在第一次评估就触发。这可能适合含义明确且必须立即处理的故障,但也意味着一个异常样本便足够触发。应根据失败语义主动选择,而不是由于忘记配置而意外采用默认行为。 若表达式在触发前不再返回该标签集,等待状态会清除。本例采用默认恢复行为,已经触发的告警也会在条件消失后清除。显式配置 keep_firing_for 可以在条件消失后继续保持一段触发时间,但本实验没有使用这个选项。不能把所有告警都描述为必须人工确认才能消除的事故记录;规则状态和事故管理系统的关闭流程是不同概念。 坏掉的样子 对缺失序列做阈值比较通常返回空结果。这可能让告警清除,但应用其实已经停止输出指标。应明确缺失是否需要独立规则,不能一律当成恢复。在测试阈值上下变化的同时,也要测试标签变更与样本缺失。语法检查只能证明文件能够解析,并不能揭示这类条件含义错误,更不能证明规则在真实数据中一定符合预期。 告警已经触发却没有页面通知,原因可能是静默、抑制、路由选择、分组等待或发送失败。这些机制属于通知链路,不会改变 Prometheus 对阈值的评估。应先查看活动告警标签,再查看 Alertmanager 匹配了哪个接收器,以及是否真正产生送达证据。延长 for 不会修复接收器配置错误,也不会自动补上路由所需但缺少的标签。 快速验证 把下面程序保存为 alert-state.py,在 Docker 可用时运行 python3 alert-state.py。程序使用 Prometheus 3.5.1,镜像未缓存时可能先下载。真实 promtool 按每分钟一份样本评估队列值 0 6 6 6 0 6 0 0。实验没有启动 Alertmanager,也没有配置任何外部通知目标,所以不会向他人发送测试消息。 ...

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

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

一个事故可能产生很多告警,但一组告警并不能证明它们具有同一个原因。Alertmanager 根据配置的标签与路由上下文组织告警,便于一起处理通知,组内仍然保留各条告警的独立身份。静默则是另一种操作:在指定时间窗口内抑制匹配通知,并不会替应用解决故障。理解这些差异,才能准确解释告警页面与通知数量的变化。 分组键决定哪些细节放在一起 配置 group_by: [alertname, service] 时,两条属于结算服务的 QueueHigh 告警可以进入同组,即使实例标签不同。搜索服务的 QueueHigh 属于另一组,结算服务的 DiskFull 也因为告警名不同而单独成组。分组依据被选择的标签值,不要求所有成员具有完全相同的指纹。通知被合并后,实例级细节仍然可以从成员列表查看。 把实例加入分组键会增加组数,也可能抵消跨副本合并的目的;省略服务又可能把不同负责人或工作负载混在一起。应围绕排查方式和责任归属选择分组键,同时保留查看成员的入口。特殊值 ... 表示使用所有标签分组,实际效果接近关闭更大范围的聚合,不能把它当作自动寻找合理分组方式的通配符。 路由上下文同样重要。发往不同接收器的告警,即使所选分组标签相同,也不一定组成同一份通知。应验证路由与分组的组合行为。负责整个服务的接收器可能适合合并多个实例,另一个集成则可能需要不同分组契约。任何一种设置都不会自动证明这些告警具有相同根因,因果关系仍然需要从事件与运行证据判断。 初次等待、组内变化和重复提醒使用不同计时 group_wait 延迟新组的第一份通知,让相近时间到来的告警可以加入。某条告警若在初次等待结束前已经恢复,可能完全不产生通知。group_interval 决定之后检查组内更新的节奏,包括新增触发成员或恢复成员;它不是承诺每隔这么久无条件重发所有尚未结束的组。 在没有其他通知内容变化时,repeat_interval 控制重复提醒。是否到达重复时机是在组周期上检查的,因此应选择兼容的整数倍,而不是把它理解为完全独立且精确的闹钟。发送处理与失败还可能增加延迟。应依据响应目标设置这些参数,并将规则等待时长与通知调度分别考虑,避免只看其中一个参数就承诺发现到送达的时间。 坏掉的样子 分组太细,会让一组副本故障产生很多通知;分组太宽,又可能看不出具体服务需要处理。页面只显示一个组,不意味着只剩一条告警。判断事故是否改善前,应展开成员并核对分组键,不能把通知变少直接当成故障减少的证据。通知数量也可能只是由策略变更或抑制行为造成。 静默条件过宽,可能抑制计划维护范围之外的告警。应限制时间窗口,并检查哪些活动告警实际匹配。静默到期只是恢复通知资格,不会修复应用,也不保证立即发出一条消息。抑制规则则是独立的配置关系,由活动的源告警抑制匹配目标通知,与人工设置有时限静默不同,不能把两者的原因和结束条件混为一谈。 快速验证 把程序保存为 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 会区分具有近期值的序列、已经标记失效的序列,以及找不到可用样本的选择器;告警又在查询结果之上使用自己的评估和等待规则。如果把这些机制统称为一个宽限时间,就可能漏掉被移除的目标,或者没有弄清到底缺少什么数据便宣布服务故障。准确的缺失告警首先需要准确描述观测对象。 失效标记不是统一的五分钟计时器 即时选择器通常使用回看范围内最新的合格样本,默认回看范围是五分钟。失效标记会告诉引擎:在标记之后,不再让这条序列出现在即时选择结果中。它不要求旧值再保留五分钟。历史样本仍然存在于存储中,也可能继续参与范围查询;因此,即时查询已经看不到某条序列,与历史窗口里完全没有样本,并不是同一件事。 具体采集路径会影响行为。导出器不再输出某个指标、目标从服务发现消失,以及导出器自己提供样本时间戳,不一定遵循相同的时间过程。显式时间戳和失效跟踪配置也会改变结果。在依据抓取周期推测消失时刻之前,应先确认导出器使用哪条路径。可配置的回看范围本质上是查询选择规则,不是能够统一判定所有服务故障的计时器。 先确定要询问哪一种缺失 absent(selector) 在选择器没有任何元素时返回一;只要仍有匹配数据,即使数值为零,也返回空向量。absent_over_time(selector[3m]) 则询问所选三分钟范围内是否存在样本。失效标记之前的真实样本,可能继续留在这个范围内,使范围缺失表达式保持为空,直到该样本移出窗口。这不是查询互相矛盾,而是问题的时间范围不同。 告警规则的 for 又是一层机制:相关标签集必须在连续的评估中持续由表达式返回,经过等待时长后才进入触发状态。它不会配置序列失效,也不会延长查询回看范围。把三分钟缺失窗口再配上额外等待时间,会进一步延后通知,评估节奏和采集时机也影响最终延迟。选择这些参数时,应从允许的发现时间反推,而不是只把它们当作降噪旋钮。 坏掉的样子 absent(up{job="api"}) 不是抓取失败告警。若已配置目标仍存在,只是 up=0,选择器仍有元素,缺失条件便不成立。与零比较才能检查这个场景。但如果目标彻底从发现清单消失,只检查零又可能在其序列消失之后漏报。对目标是否应当存在有要求时,需要另行监测预期成员关系,不能让一个表达式混合承担所有职责。 宽泛的选择器也不会自动列举所有缺少的成员。一个副本仍有数据时,对整个任务求 absent 仍为空,即使其他几个预期副本已消失。可以匹配明确已知的身份,或者将观测序列与权威清单比较。标签被改名同样可能使旧匹配条件看起来像发生故障,而进程其实正在以新身份正常工作,所以规则与标签变更需要一起审阅。 快速验证 把程序保存为 stale.py,在 Docker 可用时执行 python3 stale.py。程序使用公开的 Prometheus 3.5.1 镜像和真实 promtool 查询;镜像未缓存时,Docker 可能先进行下载。输入由显式失效标记、缺失位置和正常零值组成。本实验验证查询语义,不测量真实服务发现系统在墙上时钟中需要多久才移除一个目标。 import subprocess import uuid # Requires Docker and the public Prometheus 3.5.1 image. tests = r"""evaluation_interval: 1m 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: [] """ rules = r"""""" script = "set -eu\ncat > /tmp/tests.yml <<'TEST_DATA'\n" + tests + "\nTEST_DATA\n" if rules: script += "cat > /tmp/rules.yml <<'RULE_DATA'\n" + rules + "\nRULE_DATA\n" script += "exec /bin/promtool test rules /tmp/tests.yml\n" name = "xbcatm-prom-unit-" + uuid.uuid4().hex[:12] try: result = subprocess.run( ["docker", "run", "--rm", "--name", name, "--network", "none", "-i", "--entrypoint", "/bin/sh", "prom/prometheus:v3.5.1", "-s"], input=script, text=True, capture_output=True, timeout=45, ) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 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
观察、决策、行动三个方框,旁边强调说清症状并链接下一步检查。

告警告诉你该读什么

告警应让读者知道第一步该查什么,而不只是展示一个红色标记。它需要说明观察到的条件、受影响范围、持续时间、负责团队,以及接下来要看的证据。这些内容属于规则的可观察行为,应当和触发条件一起测试,不能等事故发生以后才把通知文案当作装饰补上。 把观察与解释分开 一次 scrape 失败,说明 Prometheus 没有成功收集目标的指标,并不能独立证明公共 API 已经不可用。服务可能仍能处理用户请求,但指标处理器失败;也可能指标采集正常,业务操作却一直报错。应该组合面向用户症状的探测与内部证据,并在告警里准确说明本次到底是哪一种观察触发,避免把采集失败直接写成业务停机。 最近发布是有价值的排查线索,却不是因果证明。证书到期、依赖故障、资源短缺或网络异常,都可能发生在没有部署的时刻。把相关变更记录和状态、日志、用户侧指标放在一起比较;在没有建立关联之前,不要把最近一次提交自动标记为根因。好的上下文帮助缩小范围,而不是提前替调查下结论。 保持身份稳定,让上下文可用 Prometheus 根据表达式返回的每组标签识别告警实例。服务、团队等稳定路由属性可以放进 labels;当前数值、解释文字和 runbook 链接放进 annotations。如果把不断变化的时间戳或测量值塞进标签,告警身份也会不断改变,pending 状态可能始终等不到持续时间结束。注解可以随情况变化提供解释,而不重新定义这是否还是同一条告警。 runbook 开头应给出可访问、只读的检查:先看目标的具体采集错误,再比较指标端点和用户请求路径,最后关联相关日志与变更记录。同时写清正常时应看到什么、看不到时进入哪个分支、什么时候升级求助。通知链接不要带凭据或敏感查询参数,还要实际确认值班读者有权访问目标页面。链接存在但读者无法打开,同样不能算有效的排查入口。 坏掉的样子 规则可能正确触发,却没有路由到任何接收人;也可能链接的文档已被删除,或者聚合时丢失了本应定位实例的标签。噪声阈值则会为不需要决策的短暂现象反复打断人。只有确实需要及时人工判断时才使用 page,其他情况应进入合适的看板、工单或自动处理。Alertmanager 的分组和抑制,与 Prometheus 判断规则是否 firing,是两段需要分别验证的流程。 可选的 for 要求表达式在连续评估中持续保持 active,达到指定时长后才进入 firing。它不是滑动平均窗口,也没有包含通知发送的等待时间。如果表达式不再产生该标签集合,本例规则就会停止活跃。尤其要注意,stale 或完全缺失的序列不是显式的零,因此告警安静不等于业务恢复;采集链路和预期目标清单需要独立监控,不能把“看不见”解释为健康。 快速验证 使用 Prometheus 发行包中的 promtool;下面实验已用 3.14.0 执行。在一个空的本地目录里,把接下来的两个代码块分别保存成 alerts.yml 和 alerts.test.yml。规则故意只描述指标采集可用性,不代表 API 可用性 SLO。severity 和 team 是测试标签,执行测试不会发送通知或改动任何现有告警配置。 ...

九月 22, 2026 · 2 分钟 · XB Field Notes