Alertmanager 告警工程实战:从路由树匹配、抑制与静默到 Gossip 高可用的生产级设计

执行摘要:大多数团队的告警系统做不好,不是因为阈值设得不准,而是因为告警管线的语义没被当成系统设计。Alertmanager 是 Prometheus 体系里唯一一个有状态的组件,它做的不是"转发",而是四件事:按标签指纹做去重、按路由树做归属判定、按分组窗口做批量聚合、按抑制与静默做噪声裁决。这四件事里任何一件配错,表现都是同一个——值班手机半夜炸了,或者事故四十分钟没人知道。本文拆开这套决策系统:路由树的最深匹配与 continue 陷阱、四个时间参数真实语义、inhibit_rules 的 equal 判定、Silence 与 MuteTimeInterval 的边界,以及 Gossip 集群里最容易踩的"多副本各发一遍"问题。

一、先建立直觉:告警是决策系统,不是转发器

一个常见误解是"Alertmanager 就是把 Prometheus 推来的告警转发到钉钉/企微"。实际上 Prometheus 只负责判定当前是否异常(瞬时布尔),Alertmanager 负责判定这件事值不值得打扰一个人、打扰谁、打扰几次。后者的复杂度高一个数量级。

维度配错的后果典型症状
路由匹配告警发给错误的人或没人数据库告警发给了前端组
group_by通知条数爆炸或过度聚合300 台机器挂 300 条消息
时间窗口抖动放大或延迟过高重启即告警、恢复太慢
抑制规则根因被表象淹没机房断电却只收到业务 5xx
Gossip 复制通知重复或丢失每个副本各发一遍

一个具体的账:一次交换机故障导致 200 个实例的 up=0、以及依赖它们的 40 条 SLO 告警同时触发。不做分组是 240 条通知;做好分组 + 抑制,是 1 条"机房 A 网络分区"加上被抑制的 239 条。这中间的差值就是值班工程师的睡眠质量。

二、数据模型:标签集就是告警的身份

Alertmanager 里的告警由标签集(label set)唯一标识,annotation 只作为展示内容参与渲染,不参与任何匹配决策。这个设计决定了后面所有行为。

// Alertmanager 内部告警结构(简化自 pkg/types/types.go)
type Alert struct {
    Labels      LabelSet  `json:"labels"`       // 身份:参与匹配、去重、分组
    Annotations LabelSet  `json:"annotations"`  // 展示:summary / description / runbook_url
    StartsAt    time.Time `json:"startsAt"`     // 变为 firing 的时间
    EndsAt      time.Time `json:"endsAt"`       // 预计 resolved 的时间
    GeneratorURL string   `json:"generatorURL"`
}

func (a *Alert) Fingerprint() Fingerprint {
    // 关键:fingerprint 只对 labels 做哈希,与 annotations 无关
    return a.Labels.Fingerprint()
}

由此推出两条工程铁律:

  1. 不要把高基数值放进标签。pod_name、request_id、用户 ID 进标签,等于每条告警都是独立身份,group_by 彻底失效,通知数量随实例数线性膨胀。这类信息应该放 annotation。
  2. 改 annotation 不会产生新告警,改 label 会。调整 summary 文案是安全的;给 severity 改个名,会让所有在飞的告警产生新指纹,表现为"全部 resolved 又重新 firing"——很多误以为的"告警抖动"其实是这个原因。

三、路由树:最深匹配,不是最长前缀

route 是一棵树,从根开始逐层匹配,一直下钻到最深的匹配节点,并使用该节点的 receiver。这不是前缀匹配,而是"特异性优先":更具体的规则赢。

route:
  receiver: 'default-oncall'      # 兜底:没匹配上任何子节点
  group_by: ['alertname']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - receiver: 'dba-oncall'
      matchers:
        - team="database"
      group_by: ['alertname', 'cluster']   # 子节点可覆写分组维度
      routes:
        - receiver: 'dba-p0-phone'         # 更深的叶子:赢
          matchers:
            - team="database"
            - severity="critical"
    - receiver: 'sre-infra'
      matchers:
        - alertname=~"NodeDown|DiskWillFillIn4Hours"
      continue: false

三个必须讲清楚的语义点:

  • continue:默认 false,命中即停止下钻。设成 true 后,告警会继续匹配兄弟节点,可以发给多个 receiver。想做"P0 同时发电话和群"必须显式开这个,否则你只会收到一个。
  • matchers 的正则默认全锚定。severity=~"crit" 匹配不到 critical,必须写 severity=~"crit.*"。这是新手配完"规则不生效"的第一大原因。
  • 根节点必须有 receiver,否则未匹配的告警被静默丢弃,且日志里只有一行 warning。

四、四个时间参数:真正决定噪声的量

这四个参数几乎全被配错过:

参数语义生产建议
group_wait一组告警诞生后,首次通知前的等待窗口,用于攒批30s~2m,太长会拖慢 P0
group_interval同一组内有新告警加入时,距上次通知的最小间隔5m 左右
repeat_interval同一组无变化时的重发周期P0 用 1h,P3 可 12h+
resolve_timeout多久没续报就判定 resolved需大于 Prometheus 的 evaluation_interval

关键区别:group_interval 管"变化",repeat_interval 管"没变化"。很多人以为把 group_interval 调大就能减少重发,实际不影响重发节奏,只影响新增告警的合并速度。

# 反例:抖动放大
group_wait: 0s        # 第一个实例挂了立刻发,后面 199 个各自成组
group_by: [alertname] # 丢失 instance 维度尚可,但丢失 cluster 就全串一起

# 正例
group_wait: 45s       # 攒够 45 秒,把滚动重启的抖动吃掉
group_by: [alertname, cluster, severity]
repeat_interval: 4h

还有一个被忽略的点:group_wait 只对"组的第一次通知"生效。组已经进入通知循环后,后续新成员受 group_interval 约束。所以想靠 group_wait 抑制长期抖动是无效的,那属于 Prometheus 侧 for: 的职责。

五、抑制(Inhibition)与静默(Silence):两种完全不同的机制

抑制是运行时的、声明式的因果关系:如果 A 在 firing,那么满足条件的 B 不发。

inhibit_rules:
  - source_matchers:
      - alertname="NodeDown"
      - severity="critical"
    target_matchers:
      - severity="warning"
    # 关键:equal 里的标签必须在 source 和 target 中取值相同才抑制
    equal: ['cluster', 'env']

equal 的语义是逐标签取值相等,不是"都存在"。如果 source 有 cluster=sh-1 而 target 缺少 cluster 标签,判定不通过;反过来 target 有而 source 没有,同样不通过。生产上建议给所有告警注入一组统一的低基数拓扑标签(cluster/env/region),否则抑制规则永远写不对。

静默是人工的、带有效期的、按 matcher 匹配的屏蔽,用于计划内维护。它优先于抑制,且不会阻止告警进入 Alertmanager,只是不通知——UI 上依然可见,这点很重要:静默不是让告警消失,是让它不打扰人。

# 维护窗口静默:精确匹配 + 明确过期时间
amtool silence add \
  alertname="HighLatency" cluster="sh-1" env="prod" \
  --author="ops@corp" --comment="数据库主从切换窗口" \
  --duration=2h

# 上线前验证路由归属(不真正发送)
amtool config routes test --config.file=alertmanager.yml \
  team="database" severity="critical"

最后一条命令应该进 CI。路由配置是纯声明式的,用测试验证比靠人肉推理可靠得多。

六、最小实现:Dispatcher 的核心逻辑

理解 Alertmanager 最快的方式是自己写一个 30 行的 dispatcher。真实实现用 DispatchGroups 做两级聚合,但语义一致:

type Dispatcher struct {
    mu     sync.Mutex
    alerts map[model.Fingerprint]*types.Alert   // 去重:指纹唯一
    groups map[string]*Group                    // 分组:groupKey -> 组
}

func (d *Dispatcher) groupKey(a *types.Alert, by []string) string {
    parts := make([]string, 0, len(by))
    for _, l := range by {
        parts = append(parts, string(a.Labels[model.LabelName(l)]))
    }
    return strings.Join(parts, "\x00")   // 注意分隔符:避免标签值拼接歧义
}

func (d *Dispatcher) Process(incoming []*types.Alert, now time.Time) []*Group {
    d.mu.Lock()
    defer d.mu.Unlock()
    for _, a := range incoming {
        // 1) 去重:同指纹视为同一条告警,只更新 EndsAt / Annotations
        d.alerts[a.Fingerprint()] = a
        // 2) 按路由找到 receiver 与 group_by,计算组键
        key := d.groupKey(a, d.routeFor(a).GroupBy)
        g, ok := d.groups[key]
        if !ok {
            // 新组:起一个 group_wait 定时器,窗口内继续攒
            g = &Group{Key: key, FirstSeen: now}
            d.groups[key] = g
        }
        g.Alerts[a.Fingerprint()] = a
        g.LastUpdated = now
    }
    // 3) 抑制裁决:被 source 抑制的组不进入通知队列
    return d.readyGroups(d.inhibit(now))
}

这段代码揭示了三个事实:去重靠指纹、分组靠标签笛卡尔积、抑制发生在组级别而不是告警级别。这也解释了为什么 group_by 里塞一个高基数标签会让内存和定时器数量一起爆炸——组数等于标签值组合数。

七、Gossip 高可用:为什么通知不会重复发

Alertmanager 多副本部署时,Prometheus 会把同一条告警发给所有副本。如果各自独立通知,一个 P0 会发 N 遍。解决方案是 memberlist Gossip 加上两个复制状态机:

alertmanager \
  --config.file=/etc/alertmanager/alertmanager.yml \
  --storage.path=/var/lib/alertmanager \
  --cluster.listen-addr=0.0.0.0:9094 \
  --cluster.peer=am-2.ybb.press:9094 \
  --cluster.peer=am-3.ybb.press:9094 \
  --cluster.gossip-interval=200ms \
  --cluster.pushpull-interval=60s
  • nflog(notification log):记录"某组在某时刻已通知过"。所有副本共享这份日志,谁先抢到谁发,其他副本看到日志条目就跳过。这是防重复的核心。
  • silences:静默必须复制到全集群,否则会出现"一半副本静默、一半还在发"的诡异现象。

生产上两个硬约束:

  1. 必须是奇数个副本且网络延迟低。Gossip 是最终一致的,跨地域部署会出现短暂状态分歧;跨地域建议各自独立集群 + 联邦,而不是拉长 Gossip。
  2. --cluster.listen-addr 需要显式绑定。容器里默认监听的地址经常导致节点互相发现失败,表现为"集群只有一个成员"——amtool cluster show 应该进健康检查。

八、生产坑位清单

现象根因解法
告警规则改文案后全体重发annotation 改动不影响指纹,但 label 改动会;常见于误改 severity标签命名纳入变更评审
维护窗口结束了还在静默静默按绝对时间过期,无自动续期也不会提前失效静默时必须带 --duration,禁止永久静默
恢复通知永远收不到resolve_timeout 小于 Prometheus 抓取+评估周期设为 evaluation_interval 的 2~3 倍
P0 只发到一个渠道continue 默认 false,深层叶子赢后停止显式 continue: true 或单 receiver 内部多通道
通知重复 N 遍Gossip 未组建成功,各副本独立发送amtool cluster show 入健康检查
抑制规则不生效equal 中的标签在 source/target 取值不一致统一注入 cluster/env 拓扑标签

九、结论

  1. 标签设计是告警系统的 schema 设计。低基数拓扑标签(team/cluster/env/severity)决定了路由、分组、抑制三者能写到什么精度,后期改动的代价远高于提前规划。
  2. 四个时间参数分管不同阶段。for: 管抖动,group_wait 管攒批,group_interval 管变化,repeat_interval 管重发。混用是噪声的主要来源。
  3. 抑制表达因果,静默表达例外。不要靠静默去解决本该用抑制规则处理的根因掩蔽问题,静默会积累成技术债。
  4. 把路由测试放进 CI。amtool config routes test 能把"配错了没人知道"变成"配错就构建失败"。

告警系统的终局指标不是"告警多不多",而是每一条通知是否都对应一个可执行动作。如果值班的人收到告警后不需要做任何事,那这条告警本身就是 bug——而 Alertmanager 提供的路由、抑制、静默三件套,正是用来把这些 bug 从通知通道里系统性清除掉的工具。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部