注解解释告警现场,同时保留稳定身份
告警标签标识正在跟踪哪一个条件,并提供路由与分组字段;注解则解释这个条件,例如当前值、简短摘要或操作手册链接。把变化中的说明文字放进标签,可能把同一个持续问题拆成多个身份;反过来,如果责任归属字段只存在于注解中,依赖标签的路由又可能无法识别应该由谁处理。两类字段都重要,但承担不同职责。 区分身份与当前说明 队列告警可以保持 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 模板渲染器提供文档规定的数据结构:共同注解故意只保留共享手册链接,不放入彼此不同的摘要。这里明确提供的输入验证渲染行为,不代表已验证服务器如何组装实际通知,更不能替代发送链路测试。 ...