告警标签标识正在跟踪哪一个条件,并提供路由与分组字段;注解则解释这个条件,例如当前值、简短摘要或操作手册链接。把变化中的说明文字放进标签,可能把同一个持续问题拆成多个身份;反过来,如果责任归属字段只存在于注解中,依赖标签的路由又可能无法识别应该由谁处理。两类字段都重要,但承担不同职责。

区分身份与当前说明

队列告警可以保持 alertname、service 与 instance 标签稳定,同时把摘要从等待六项更新为等待八项。Alertmanager 可以在相同告警指纹下更新注解,而不是新增一条告警。指纹来自身份标签,不是通知渲染文本的哈希,所以文字改变不应该被直接理解为发生了另一个独立事件。这个边界也方便操作人员跟踪问题的持续变化。

该区别在进入 Alertmanager 之前也有影响。Prometheus 按告警标签集跟踪等待时长,如果标签中包含不断变化的当前值,就可能持续创建新身份,导致预期等待过程无法完成。除非数值确实定义另一种告警条件,否则应把它放在注解中。遇到反复等待却不触发的问题,应同时审阅规则标签和模板内容,而不是只调整等待参数。

一组告警未必拥有共同摘要

通知模板能访问多个层级的数据。GroupLabels 包含分组选择的标签,CommonLabels 包含通知中所有告警都具有的相同标签对,CommonAnnotations 同样只包含组内共有的注解。Alerts 列表则保留各成员自己的标签和注解,因此可以逐条解释不同实例的现场情况。共同字段与全部字段不是同一概念,字段存在于某条告警中也不保证它存在于组级对象中。

两个副本的摘要描述不同队列长度时,CommonAnnotations 就没有一个共同摘要。只打印该字段的模板,可能恰好省掉响应人员最需要的信息。更合理的方式是用共同上下文组织标题,再遍历每条告警输出变化中的说明。即使当前值不同,两条告警仍可能共享同一份操作手册链接,这个链接就可以继续作为共同注解使用。

也不能假定每一组都具有实例这样的分组标签。按服务与告警名分组,本来就是为了合并不同实例。模板应对应真实分组契约,并主动处理缺少共同字段的情况。代表性输入需要覆盖多条告警、不同注解,以及相关触发或恢复状态,不能只用一条理想告警验证,因为单条输入很容易让所有字段都碰巧成为共同字段。

注解更新也不等于立即发送新通知。Alertmanager 0.28.1 的去重判断关注触发与恢复的告警身份以及重复间隔,单独修改摘要不会产生新的指纹。可以在告警视图中保留持续变化的现场信息,却不能因此承诺每次数值更新都会发送一条消息。需要即时更新的业务应另行确认实际通知链路,而不是把文字变化当成发送触发器。

坏掉的样子

动态标签会让每次数值变化产生新指纹,增加告警变化量,并可能重新开始等待。匹配 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 模板渲染器提供文档规定的数据结构:共同注解故意只保留共享手册链接,不放入彼此不同的摘要。这里明确提供的输入验证渲染行为,不代表已验证服务器如何组装实际通知,更不能替代发送链路测试。

渲染标题显示 common-summary=none,之后分别出现 api-a:queue=8 与 api-b:queue=9。断言核对更新后的数值和逐条说明。全部 HTTP 请求只经过容器回环地址,不挂载宿主机目录,也不发布端口,退出时删除容器。生产通知仍应使用真实路由、模板数据和接收器集成进行验证,才能把展示正确与实际送达连起来。

本轮没有修改此运行实验,也没有重新执行。文中的预期输出和此前记录的观察,与本轮执行证据分别保留,不能据此声称真实集成已经再次通过。

来源