在 Prometheus / VictoriaMetrics 监控体系中,告警规则成百上千,但「通路本身是否还活着」常常被忽略——Alertmanager 挂了、Webhook 失效、企业微信 token 过期、邮件服务器异常,都可能让真实告警悄悄丢失。

最朴素的做法是配一个 vector(1) 的常驻告警,但这会持续刷屏。更合理的方案是「定时心跳」:

  • 每 N 小时主动触发一条 info 级别告警
  • 如果收到,说明通路正常
  • 如果超过预期时间未收到,需要人工介入排查

本规则在此基础上加了一个智能优化:如果过去 6 小时已经有其他告警触发过,则跳过本次心跳——毕竟通路已经被实战验证过了,再发一条纯属噪音。

一、完整规则

重点参考 spec.groups.rules 下面的规则即可

下面是以VMRule这个 CRD 示例,也可以适配PrometheusRule CRD(修改下apiVersionkind即可),以什么方式应用不是重点

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: alert-alive
  namespace: monitor
  labels:
    project: base
spec:
  groups:
    - name: alert-alive
      rules:
      # 每6小时触发一次,用于测试告警通路是否正常(UTC+8)
      # 如果过去6小时内有其他告警触发过,则跳过本次测试
      - alert: AlertAlive
        expr: ((hour() + 8) % 6 == 0) and (minute() < 5) and vector(1) unless on() (max_over_time(ALERTS{alertname!="AlertAlive", alertstate="firing"}[6h]))
        for: 0s
        labels:
          severity: info
          owner: system
        annotations:
          summary: "告警通路测试(每6小时触发,近6小时无告警时发送)"
          description: "此告警为自动触发的告警通路测试,若过去6小时内已有其他告警触发则跳过,无需处理。"

二、表达式逐段拆解

整条 expr 由 4 个部分通过布尔运算符串联而成:

((hour() + 8) % 6 == 0) and (minute() < 5) and vector(1)
  unless on()
(max_over_time(ALERTS{alertname!="AlertAlive", alertstate="firing"}[6h]))

1. (hour() + 8) % 6 == 0 —— 时间窗口(每 6 小时)

  • hour() 返回 UTC 时间的小时数(0–23)。
  • + 8 将其偏移为北京时间(UTC+8)。
  • % 6 == 0 表示偏移后的小时能被 6 整除。

满足条件的 UTC 小时为 4, 10, 16, 22,对应北京时间 00:00、06:00、12:00、18:00

VictoriaMetrics / Prometheus 默认按 UTC 评估,所有 hour() minute() 都是 UTC。本规则通过 +8 模拟本地时区,避免依赖服务端时区配置。

2. minute() < 5 —— 触发窗口

只在每小时的前 5 分钟内「可能」触发,配合 for: 0s(无等待)让告警在窗口开始后立即 firing。

为什么留 5 分钟?因为告警从触发到送达要经过多个环节(vmalert 的评估周期、Alertmanager 的 group_wait 分组等待等),留个缓冲;同时让告警在窗口结束前有足够时间完成分组、抑制、发送。

3. vector(1) —— 永真占位

PromQL 中 and 是集合运算,要求两边都有样本才保留。(hour() + 8) % 6 == 0minute() < 5 在条件不满足时返回空向量,会让整个 and 链断掉。

vector(1) 确保「时间条件满足时一定有样本」,否则前两个条件虽然成立,但表达式整体仍可能为空。

4. unless on() (max_over_time(ALERTS{...}[6h])) —— 智能跳过

这是规则最核心的部分。

  • ALERTS 是 Prometheus / VMAlert 内置指标,记录所有告警的当前状态:
    • alertstate="firing" —— 已触发
    • alertstate="pending" —— 等待中
  • alertname!="AlertAlive" —— 排除自身,避免心跳告警影响下一次判断。
  • max_over_time(...[6h]) —— 过去 6 小时内是否存在过 firing 状态的告警。max_over_time 把范围向量降为瞬时向量,有值则返回 1。
  • unless on() —— 左边有但右边没有时保留。on() 空标签列表表示按「无标签」匹配,即整条规则只剩下一个样本。
  • 效果:过去 6 小时有真实告警 firing 过 → unless 成立 → 不输出心跳告警;反之 → 输出心跳告警。

三、关键技术点

3.1 ALERTS 内置指标

ALERTS 是 vmalert / Prometheus 自动生成的时序指标,每条告警规则对应若干时间序列:

ALERTS{alertname="<规则名>", alertstate="firing|pending", <rule labels>}
  • 仅在告警 firing / pending 期间存在样本。
  • firing 结束后会写入一个 alertstate="firing" 但值为 0 的样本(用于表达「刚恢复」)。
  • 因此 max_over_time(ALERTS{...}[6h]) 能正确反映过去 6 小时是否有过 firing 事件。

3.2 unlesson() 修饰符

  • A unless B:返回在 A 中但不在 B 中的样本。
  • on() 指定匹配用的标签列表。空的 on() 表示按零标签匹配,等价于「不关心标签,只要 B 有任意样本就抑制 A」。

如果不加 on()unless 会按所有标签匹配,导致「过去 6h 内某个具体告警的样本」无法抑制「当前 AlertAlive 这一条」,跳过逻辑失效。

3.3 vector(1) 的必要性

省略 vector(1) 时,(hour() + 8) % 6 == 0 and (minute() < 5) 在条件成立时返回空向量(因为 and 需要双方都有样本),整条 expr 永远为空,告警永远不会触发。

3.4 for: 0s 的作用

通常 for: 1m 表示条件持续 1 分钟才 firing。这里设为 0s 让告警在表达式成立的瞬间就进入 firing,避免窗口(5 分钟)不够长导致 pending 来不及转 firing。

3.5 时区处理的取巧之处

PromQL 没有原生时区函数,hour() 永远是 UTC。+ 8 是常见但脆弱的写法:

  • 优点:不依赖服务端时区配置,规则可移植。
  • 缺点:夏令时国家不适用(中国无夏令时,所以没问题)。
  • 替代方案:在 vmalert 容器设置 TZ=Asia/Shanghai 环境变量,让 hour() 直接返回本地时间,表达式可简化为 (hour() % 6 == 0) and (minute() < 5) and vector(1)

四、部署与验证

4.1 应用规则

kubectl apply -f alert-alive-vmrule.yaml

确认 VMRule 被 vmoperator 识别:

kubectl get vmrule -n monitor alert-alive

4.2 验证 vmalert 已加载

可以在 vmalert 前端页面中查看告警规则

4.3 手动验证 expr

直接在 vmselect 查询接口验证表达式返回是否正确:

  1. 可以通过页面访问 vmselect 服务的 UI 页面来手动验证 expr,<ingress-domain>/select/0/vmui
  2. 如果在 Grafana 中添加了 vmselect 地址作为数据源,也可以在 Grafana 的 Explore 页面查询
# 验证时间条件(当前不在窗口内应返回空)
((hour() + 8) % 6 == 0) and (minute() < 5) and vector(1)

# 验证过去 6h 是否有其他告警 firing
max_over_time(ALERTS{alertname!="AlertAlive", alertstate="firing"}[6h])

五、踩坑记录

  • alertname!="AlertAlive" 必须排除自身:否则心跳自身 firing 后,下一个窗口会以为「过去 6h 有过告警」而跳过。
  • for: 0sfor: 1m 更稳:窗口只有 5 分钟,for: 1m 可能让 firing 滞后到窗口外。