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()
}
由此推出两条工程铁律:
- 不要把高基数值放进标签。
pod_name、request_id、用户 ID 进标签,等于每条告警都是独立身份,group_by彻底失效,通知数量随实例数线性膨胀。这类信息应该放 annotation。 - 改 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:静默必须复制到全集群,否则会出现"一半副本静默、一半还在发"的诡异现象。
生产上两个硬约束:
- 必须是奇数个副本且网络延迟低。Gossip 是最终一致的,跨地域部署会出现短暂状态分歧;跨地域建议各自独立集群 + 联邦,而不是拉长 Gossip。
--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 拓扑标签 |
九、结论
- 标签设计是告警系统的 schema 设计。低基数拓扑标签(
team/cluster/env/severity)决定了路由、分组、抑制三者能写到什么精度,后期改动的代价远高于提前规划。 - 四个时间参数分管不同阶段。
for:管抖动,group_wait管攒批,group_interval管变化,repeat_interval管重发。混用是噪声的主要来源。 - 抑制表达因果,静默表达例外。不要靠静默去解决本该用抑制规则处理的根因掩蔽问题,静默会积累成技术债。
- 把路由测试放进 CI。
amtool config routes test能把"配错了没人知道"变成"配错就构建失败"。
告警系统的终局指标不是"告警多不多",而是每一条通知是否都对应一个可执行动作。如果值班的人收到告警后不需要做任何事,那这条告警本身就是 bug——而 Alertmanager 提供的路由、抑制、静默三件套,正是用来把这些 bug 从通知通道里系统性清除掉的工具。

发表评论 取消回复