Learn
Prometheus/11-recording-and-alerting-rules

记录规则与告警规则

到目前为止,Prometheus 在我们手里还是个「被动」的系统:数据存进去,你去查它才给结果。本章加上主动性——让它按固定节奏自己跑查询,把结果要么存回库里(记录规则),要么变成告警推给 Alertmanager(告警规则)。

这两件事共用同一套机制:规则文件。它们的语法几乎一样,只差一个关键字。理解了这一点,本章的一半内容就通了。

读完本章你会掌握:

  • rule_files 怎么写,规则文件的 groups / interval / rules 三层结构
  • 记录规则(recording rules)如何把昂贵查询预计算成廉价指标,以及官方命名约定 level:metric:operations
  • 同一个 group 内规则顺序执行这个特性带来的能力与约束
  • 告警规则(alerting rules)的五个字段:alert / expr / for / labels / annotations
  • for 的真正作用:消除抖动,以及告警的三种状态 inactive / pending / firing
  • 注解模板变量 {{ $labels.xxx }}、{{ $value }} 和 humanize 系列函数
  • 用 promtool check rules 和 promtool test rules 在上线前验证规则

一、rule_files 与规则文件结构

主配置里用 rule_files 指定从哪加载规则,支持 glob 通配:

# prometheus.yml
global:
  evaluation_interval: 15s      # 规则求值的全局默认间隔
 
rule_files:
  - '/etc/prometheus/rules/*.yml'
  - '/etc/prometheus/rules/team-*/*.yml'
⚠️glob 不递归,后缀也要对上

rules/*.yml 不会匹配 rules/node/cpu.yml——单个星号不跨目录。要覆盖子目录必须显式再写一行,或者用 rules/*/*.yml。

另外 *.yml 匹配不到 .yaml 后缀的文件。团队里混用两种后缀是很常见的踩坑点,建议统一成一种,并在 CI 里加一条检查。

规则文件本身是三层结构:

groups:
  - name: node-recording           # 组名,同一个文件内必须唯一
    interval: 30s                  # 本组的求值间隔,不写则用 global.evaluation_interval
    limit: 0                       # 本组每次求值最多产出多少条序列/告警,0 表示不限
    rules:
      - record: instance:node_cpu_utilization:ratio_rate5m
        expr: |
          1 - avg without (cpu, mode) (
            rate(node_cpu_seconds_total{mode="idle"}[5m])
          )
 
  - name: node-alerts
    rules:
      - alert: HostHighCpu
        expr: instance:node_cpu_utilization:ratio_rate5m > 0.9
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: '主机 CPU 使用率过高'

group 的三条执行语义

这三条决定了你该怎么组织规则,务必记牢:

  1. 同一个 group 内,规则严格按书写顺序、串行执行,且所有规则使用同一个求值时间戳。
  2. 不同 group 之间并行执行,互不等待。
  3. 每个 group 按自己的 interval 独立调度。

第 1 条带来一个非常有用的能力:后面的规则可以直接引用前面规则刚算出来的结果。

groups:
  - name: api-slo
    interval: 30s
    rules:
      # 第一步:分别算出错误数和总数的速率
      - record: path:http_errors:rate5m
        expr: sum by (path) (rate(http_requests_total{code=~"5.."}[5m]))
 
      - record: path:http_requests:rate5m
        expr: sum by (path) (rate(http_requests_total[5m]))
 
      # 第二步:直接用上面两条的结果算比率(同一个 group 才能这么写)
      - record: path:http_errors_per_requests:ratio_rate5m
        expr: path:http_errors:rate5m / path:http_requests:rate5m
⚠️跨 group 引用会拿到「上一轮」的旧数据

如果把上面第三条规则单独放到另一个 group 里,它依然能查到 path:http_errors:rate5m——但那是上一次求值时写入的数据,可能已经过时几十秒。更糟的是 Prometheus 刚启动时那条序列还不存在,第三条规则会返回空结果。

原则:有依赖关系的规则必须放在同一个 group 里,并按依赖顺序书写。没有依赖关系的规则应该拆到不同 group,让它们并行跑得更快。

一个 group 太慢会怎样

group 内串行执行,意味着组里所有规则的耗时是累加的。如果总耗时超过了 interval,Prometheus 会记录一条告警日志,并跳过本轮的部分执行。监控这件事的指标是:

# 每个规则组上次求值花了多久
prometheus_rule_group_last_duration_seconds
 
# 求值耗时占间隔的比例,接近 1 就危险了
prometheus_rule_group_last_duration_seconds
  / prometheus_rule_group_interval_seconds
 
# 有多少次求值因为上一轮没跑完而被跳过
rate(prometheus_rule_group_iterations_missed_total[5m])
💡给规则组本身配一条告警

规则求值跟不上是个隐性故障:告警会延迟甚至漏发,而你毫无感知。建议加上这条:

- alert: PrometheusRuleGroupSlow
  expr: |
    prometheus_rule_group_last_duration_seconds
      / prometheus_rule_group_interval_seconds > 0.8
  for: 15m
  labels:
    severity: warning

出现后的处理方向:拆分 group 增加并行度、减小查询区间、给昂贵的子表达式再加一层记录规则。

二、记录规则:把昂贵查询变便宜

为什么需要它

看这条查询——某个 SLO 看板上的错误率:

sum by (service, path) (
  rate(http_requests_total{code=~"5..", env="prod"}[5m])
)
/
sum by (service, path) (
  rate(http_requests_total{env="prod"}[5m])
)

假设 http_requests_total 有 20 万条序列。这条查询每执行一次,就要从 TSDB 里捞出 20 万条序列在 5 分钟内的全部样本,做两遍 rate、两遍 sum。看板上有 10 个面板、5 个人同时打开、Grafana 每 15 秒自动刷新一次——瞬间就是几十次重复的重量级计算。

记录规则的思路很朴素:这个结果反正每次都一样,不如让 Prometheus 每 30 秒自己算一次存起来,大家直接查那条现成的序列。

- record: service_path:http_errors_per_requests:ratio_rate5m
  expr: |
    sum by (service, path) (rate(http_requests_total{code=~"5..", env="prod"}[5m]))
    /
    sum by (service, path) (rate(http_requests_total{env="prod"}[5m]))

看板改成查这一条:

service_path:http_errors_per_requests:ratio_rate5m

原本要扫 20 万条序列的查询,现在只扫几十条。响应时间从几秒降到几毫秒。

官方命名约定:level:metric:operations

这是 Prometheus 官方文档明确推荐的格式,用冒号分成三段:

level : metric : operations
  ↑       ↑         ↑
聚合维度  指标名   施加的操作(最新的写在最前)
  • level:结果保留了哪些标签维度,用下划线连接。比如按 instance 和 path 聚合就写 instance_path;聚合到 job 级别就写 job。
  • metric:原始指标名,去掉 Counter 的 _total 后缀(因为 rate 之后它已经不是计数器了)。
  • operations:施加了哪些操作,最近的一次写在最前面,用下划线连接。

官方文档里的标准示例:

记录规则名含义
instance_path:requests:rate5m按 instance 和 path,请求数的 5 分钟速率
path:requests:rate5m在上面的基础上跨 instance 求和
instance_path:request_failures:rate5m按 instance 和 path,失败请求的 5 分钟速率
path:request_failures_per_requests:ratio_rate5m失败率(先算 rate 再算 ratio,所以 ratio 在前)
job:http_inprogress_requests:sum按 job 聚合的进行中请求数
ℹ️为什么冒号很重要

Prometheus 自身产生的指标名从不使用冒号,Exporter 也约定不用。所以冒号成了一个天然的命名空间隔离符:看到指标名里有冒号,你立刻知道「这是人为定义的记录规则,不是原始采集数据」。

这不只是美观问题。当有人在 Grafana 里看到 path:requests:rate5m 时,他知道去规则文件里能找到它的定义;而如果你把它命名成 http_requests_rate,别人会误以为这是某个 Exporter 直接暴露的原始指标,排查时会去翻 Exporter 文档,白费半天力气。

记录规则的 labels 字段

可以给记录规则的输出追加或覆盖标签:

- record: job:http_requests:rate5m
  expr: sum by (job) (rate(http_requests_total[5m]))
  labels:
    aggregation: 'global'
    computed_by: 'recording-rule'

用得不多,主要场景是给多个来源计算出的同名指标打上区分标记。

什么该做成记录规则,什么不该

适合的场景:

  • 被多个看板/告警反复使用的表达式
  • 计算成本高(涉及大量序列、多层聚合、histogram_quantile)
  • 需要长期保留的聚合结果(原始高基数数据可以短保留,聚合结果长保留)
  • 跨很长时间窗口的查询,比如按天/周聚合

不适合的场景:

  • 只用一次的临时查询
  • 结果序列数几乎和输入一样多(没有降基数,白白多存一份)
  • 依赖查询时才确定的参数(比如 Grafana 变量选择的时间窗口)
⚠️记录规则不会追溯计算历史数据

新加一条记录规则,它只从现在开始产生数据。昨天的、上周的数据不会自动补上——你打开看板选「最近 7 天」,会看到一条只有最近几分钟有数据的曲线。

所以:先加记录规则,等它积累够时间跨度,再把看板切过去。如果确实需要回填历史,可以用 promtool tsdb create-blocks-from rules 从已有数据生成历史 block,但这属于高级操作,要求原始数据还没过期。

🎯练习 1:设计一组记录规则

你的服务有一个 Histogram 指标 api_request_duration_seconds_bucket,带 service、path、le 标签,还有一个 Counter api_requests_total,带 service、path、code 标签。总序列数约 30 万。

Grafana 看板需要展示三样东西,而且每 10 秒刷新一次,卡得不行:

  1. 各接口的 P99 延迟
  2. 各接口的 QPS
  3. 各服务的整体错误率(5xx 占比)

请设计一组记录规则来优化,命名要符合官方约定,并说明 group 该怎么划分。

三、告警规则:把异常变成事件

五个字段

- alert: HighErrorRate                        # 告警名,会变成 alertname 标签
  expr: |                                     # 表达式,非空结果即为「异常」
    service:api_errors_per_requests:ratio_rate5m > 0.05
  for: 5m                                     # 持续多久才真正触发
  keep_firing_for: 5m                         # 恢复后再多保持一会儿(2.42+,可选)
  labels:                                     # 附加标签,用于 Alertmanager 路由
    severity: critical
    team: backend
  annotations:                                # 给人看的描述,支持模板
    summary: '{{ $labels.service }} 错误率过高'
    description: '错误率已达 {{ $value | humanizePercentage }},持续 5 分钟以上。'
    runbook_url: 'https://wiki.internal/runbooks/high-error-rate'

核心机制:Prometheus 每隔 evaluation_interval 执行一次 expr。表达式返回的每一条序列,就是一条告警。返回空结果表示一切正常。

这意味着告警表达式必须是一个过滤型表达式:用 > 0.05、== 0、< 10 这类比较运算符把「正常的数据」过滤掉,只留下异常的。写 rate(errors[5m]) 是错的——它永远有结果,告警会一直触发。

三种状态与 for 的作用

一条告警在 Prometheus 内部有三种状态:

inactive  表达式没有结果 → 一切正常
 
pending   表达式刚开始有结果,但还没满足 for 指定的时长
          此时告警「已发现但不上报」,不会发给 Alertmanager
 
firing    表达式持续有结果,且已经超过 for 指定的时长
          此时才真正推送给 Alertmanager

for 的价值就在 pending 这个缓冲区:

- alert: HostHighCpu
  expr: instance:node_cpu_utilization:ratio_rate5m > 0.9
  for: 10m

CPU 偶尔飙到 95% 持续几十秒,是完全正常的(一次编译、一次备份、一次 GC)。没有 for 的话,这种毛刺会立刻发出告警,几十秒后又发一条恢复通知——这就是告警抖动(flapping),是消磨值班人耐心的头号杀手。加上 for: 10m 之后,只有真正持续 10 分钟的高负载才会被上报。

⚠️for 期间表达式一旦返回空,计时器会归零

假设 for: 10m,某条告警已经 pending 了 9 分 30 秒。这时表达式恰好有一次求值返回了空(比如 CPU 短暂降到 89%),状态立刻回到 inactive,计时清零。下次再超阈值,要重新从 0 开始数 10 分钟。

这个特性在「反复横跳」的场景下会导致告警永远发不出去:指标在阈值附近来回震荡,每次 pending 到 8 分钟就被打断。

应对办法:

  • 把阈值判断改成基于更长窗口的平均值,比如把 rate(...[5m]) 改成 rate(...[15m]),天然更平滑。
  • 或者用 avg_over_time 显式平滑:avg_over_time(cpu_usage[15m]) > 0.9。
  • 或者适当降低 for,配合 Alertmanager 的 group_wait / repeat_interval 来抑制噪声。

keep_firing_for:恢复后多撑一会儿

2.42 引入的字段,作用和 for 相反:表达式已经不返回结果了,但告警还继续保持 firing 状态一段时间才转为 resolved。

- alert: FlappyService
  expr: up{job="flaky"} == 0
  for: 2m
  keep_firing_for: 10m

用于抑制「恢复了又挂、挂了又恢复」造成的大量 resolved/firing 通知刷屏。

ALERTS 指标:告警本身也是数据

Prometheus 会把每条处于 pending 或 firing 状态的告警,写成一条名为 ALERTS 的序列,值恒为 1:

# 当前所有正在触发的告警
ALERTS{alertstate="firing"}
 
# 按告警名统计当前有多少条在烧
count by (alertname) (ALERTS{alertstate="firing"})
 
# 过去 24 小时里,哪条告警触发的时间最长(找出最吵的告警)
topk(10,
  sum by (alertname) (
    count_over_time(ALERTS{alertstate="firing"}[24h])
  )
)
 
# 某条告警在过去一周触发了多少次(用于评估告警质量)
changes(ALERTS_FOR_STATE{alertname="HighErrorRate"}[7d])
💡用 ALERTS 做「告警的告警」和季度复盘

ALERTS 是评估告警体系健康度的金矿。定期跑一次上面那条 topk,你会发现通常有两三条告警贡献了 80% 的通知量——它们要么阈值定得太敏感,要么根本不该是告警(应该只是看板上的一个指标)。

另一个实用查询是找出「从来没触发过的告警」:这类规则往往是表达式写错了(比如指标名改了没同步),一直静默失效,等真出事时才发现它根本不工作。

四、注解模板

annotations 和 labels 的值都支持 Go 模板语法,在每次求值时渲染。

两个核心变量

  • {{ $labels.标签名 }}:当前这条告警序列的标签值。
  • {{ $value }}:当前这条告警序列的数值(就是表达式算出来的那个数)。
annotations:
  summary: '实例 {{ $labels.instance }} 磁盘将满'
  description: '{{ $labels.instance }} 上的挂载点 {{ $labels.mountpoint }} 剩余空间仅 {{ $value }}%'

标签名如果含有特殊字符,用 index 函数取:

description: '{{ index $labels "kubernetes.io/hostname" }} 出现异常'

humanize 系列:把数字变成人话

裸写 {{ $value }} 常常会得到 0.05263157894736842 或 1073741824 这种没法读的东西。模板函数负责把它变好看:

函数输入输出
humanize12345671.235M
humanize102410737418241Gi
humanizeDuration37251h 2m 5s
humanizePercentage0.05265.26%
humanizeTimestamp17356896002025-01-01 00:00:00 +0000 UTC
printf "%.2f"0.052630.05

用法是管道形式:

annotations:
  summary: '{{ $labels.service }} 错误率异常'
  description: >-
    错误率 {{ $value | humanizePercentage }},
    已持续 5 分钟。当前 QPS 约 {{ $value | humanize }}。
  disk: '剩余 {{ $value | humanize1024 }}B'
  duration: '已经宕机 {{ $value | humanizeDuration }}'
  precise: '负载 {{ printf "%.2f" $value }}'
ℹ️$value 是「这条序列的值」,不是「所有序列」

新手常见的误解:以为 {{ $value }} 是整个查询的结果。实际上告警表达式返回 N 条序列就产生 N 条独立告警,每条告警渲染自己那份模板,{{ $value }} 取的是它自己那条序列的数值。

所以如果你想在描述里写「共有 5 台机器出问题」,{{ $value }} 是做不到的——它只知道自己这一台。要么在表达式里用 count() 聚合成一条告警,要么交给 Alertmanager 的分组功能去汇总。

⚠️labels 里也能用模板,但要非常克制

labels 字段同样支持模板渲染,但请记住:标签是 Alertmanager 分组和去重的依据。如果你在 labels 里塞了 {{ $value }},那么值每变化一次,Alertmanager 就认为这是一条全新的告警,去重和分组彻底失效,通知会像雪崩一样刷屏。

铁律:变化的东西放 annotations,稳定的分类信息放 labels。

🎯练习 2:写出三条生产级告警规则

为下面三个需求写出完整的告警规则(含 for、labels、annotations):

  1. 任何实例宕机超过 3 分钟。
  2. 任何磁盘挂载点剩余空间低于 15%,且持续 10 分钟(要在描述里显示具体百分比和挂载点)。
  3. 某个服务的 P99 延迟超过 1 秒,持续 5 分钟(P99 已有记录规则 service_path:api_request_duration_seconds:p99_rate5m)。

五、告警规则的常见陷阱

陷阱 1:表达式不带比较运算符

# 错误:这条表达式永远有结果,告警会一直烧着
expr: rate(http_errors_total[5m])
 
# 正确
expr: rate(http_errors_total[5m]) > 1

陷阱 2:用「指标消失」判断故障

# 无效:服务挂了之后这条序列就不存在了,表达式返回空,告警不触发
expr: my_service_healthy == 0
 
# 正确:用 absent 检测「序列消失」
expr: absent(my_service_healthy)
 
# 或者两者结合
expr: my_service_healthy == 0 or absent(my_service_healthy)
⚠️absent 会丢失原有标签

absent(up{job="api"}) 在序列不存在时返回一条值为 1 的序列,但它的标签只包含你在选择器里显式写出的那些(这里是 job="api"),instance 之类的是没有的——毕竟序列都不存在了,从哪儿知道是哪台机器。

所以基于 absent 的告警只能定位到 job 级别。如果需要精确到实例,得靠 up == 0(前提是目标还在服务发现列表里)。

陷阱 3:for 小于 evaluation_interval

evaluation_interval: 1m 配 for: 30s 是没有意义的——表达式一分钟才求值一次,第二次求值时已经过去 60 秒,直接就跨过了 30 秒的门槛,等价于没写 for。for 至少要是 evaluation_interval 的 2 到 3 倍才有平滑效果。

陷阱 4:告警表达式产生高基数结果

# 危险:如果 http_requests_total 带 user_id 标签,这条会产生成千上万条告警
expr: rate(http_requests_total{code="500"}[5m]) > 0

一次故障刷出上万条告警,Alertmanager 和通知渠道都会被打垮。告警表达式几乎总应该先做聚合:

expr: sum by (service, path) (rate(http_requests_total{code="500"}[5m])) > 1

陷阱 5:阈值写死在表达式里,没考虑规模差异

node_load1 > 10 对 4 核机器是灾难,对 64 核机器毫无压力。要写成相对值:

node_load1 / count by (instance) (node_cpu_seconds_total{mode="idle"}) > 2
🎯练习 3:修复五条有问题的告警规则

下面每条规则都至少有一个问题,请指出并修正:

- alert: A
  expr: node_memory_MemFree_bytes / node_memory_MemTotal_bytes < 0.1
  for: 30s
 
- alert: B
  expr: mysql_up == 0
  labels:
    instance: '{{ $labels.instance }}'
    current_value: '{{ $value }}'
 
- alert: C
  expr: increase(http_requests_total{code="500"}[5m])
 
- alert: D
  expr: rate(http_requests_total{code=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
  for: 5m
 
- alert: E
  expr: kafka_consumer_lag > 1000
  for: 1m
  annotations:
    summary: 'Kafka 消费延迟'

假设 global.evaluation_interval: 15s。

六、用 promtool 验证规则

静态语法检查

# 检查单个规则文件
promtool check rules /etc/prometheus/rules/node.yml
 
# 检查所有
promtool check rules /etc/prometheus/rules/*.yml
 
# 检查主配置(会连带检查 rule_files 引用到的所有文件)
promtool check config /etc/prometheus/prometheus.yml

它能查出:YAML 缩进错误、PromQL 语法错误、必填字段缺失、模板语法错误、同一组内记录规则名重复。

查不出的是:向量匹配失败、指标名拼错(那也是合法的 PromQL)、阈值不合理。

规则单元测试

这是被严重低估的功能。你可以给规则写单元测试,用构造的时间序列验证告警在什么时候该响、什么时候不该响:

# test-node.yml
rule_files:
  - node-alerts.yml
 
evaluation_interval: 1m
 
tests:
  - interval: 1m
    # 构造输入序列:值用 "初始值+步长x次数" 的紧凑语法
    input_series:
      - series: 'up{job="node", instance="10.0.0.1:9100"}'
        values: '1 1 1 0 0 0 0 0 1 1'
 
    alert_rule_test:
      # 第 4 分钟:刚宕机 1 分钟,for: 3m 还没满足,不应该有告警
      - eval_time: 4m
        alertname: InstanceDown
        exp_alerts: []
 
      # 第 7 分钟:已宕机 4 分钟,超过 for: 3m,应该触发
      - eval_time: 7m
        alertname: InstanceDown
        exp_alerts:
          - exp_labels:
              severity: critical
              job: node
              instance: 10.0.0.1:9100
            exp_annotations:
              summary: '实例 10.0.0.1:9100 宕机'
 
      # 第 9 分钟:已恢复,不应该有告警
      - eval_time: 9m
        alertname: InstanceDown
        exp_alerts: []
promtool test rules test-node.yml

也可以测记录规则的输出值:

    promql_expr_test:
      - expr: 'instance:node_cpu_utilization:ratio_rate5m'
        eval_time: 10m
        exp_samples:
          - labels: 'instance:node_cpu_utilization:ratio_rate5m{instance="10.0.0.1:9100"}'
            value: 0.85
💡把规则测试放进 CI

监控配置也是代码,应该纳入版本管理和 CI。一个最小可用的流水线:

promtool check config prometheus.yml
promtool check rules rules/*.yml
promtool test rules tests/*.yml

三条命令都是纯静态的,几秒就跑完。它们能拦住:语法错、模板错、for 逻辑错、告警该响时不响。

尤其是第三条——「重构了一下表达式,结果告警再也不触发了」这种事故,只有单元测试能提前发现。

🎯练习 4:从零搭建一套完整的规则体系

你接手了一个新服务的监控,现有原始指标:

  • http_requests_total(Counter,标签 service、path、method、code)
  • http_request_duration_seconds_bucket(Histogram,标签 service、path、le)
  • process_resident_memory_bytes(Gauge,标签 service、instance)
  • up(标签 job、instance)

请设计一套完整的规则体系,包含:(1) 记录规则;(2) 告警规则(至少覆盖可用性、错误率、延迟三类);(3) 一个针对告警规则的单元测试片段;(4) 说明 group 划分与 interval 选择的理由。

小结

  • rule_files 支持 glob 但不递归;规则文件是 groups → interval / rules 的三层结构。
  • 同 group 内串行、共享时间戳、可引用前面规则的结果;不同 group 并行。 有依赖必须同组,无依赖尽量分组。
  • 记录规则用于把昂贵查询预计算成廉价序列,命名遵循 level:metric:operations,冒号是「人为定义」的标志。
  • 记录规则不回填历史,先上线积累一段时间再切看板。
  • 告警规则的表达式必须带比较运算符,返回的每条序列就是一条告警;返回空 = 正常。
  • 状态机是 inactive → pending → firing,for 是消除抖动的缓冲期,期间表达式返回空会导致计时归零。
  • {{ $labels.xxx }} 取标签,{{ $value }} 取本条序列的值,配合 humanize / humanizeDuration / humanizePercentage 让通知可读。
  • 变化的值放 annotations,稳定的分类放 labels——labels 里出现 $value 会摧毁 Alertmanager 的去重。
  • 五大陷阱:不带比较符、靠指标消失判故障、for 短于求值间隔、结果高基数、绝对阈值不适配规模。
  • 上线前跑 promtool check rules 和 promtool test rules,并在 Graph 页面手动执行一遍表达式——这一步能抓住 promtool 查不出的向量匹配失效。
  • 下一章讲 Alertmanager:告警发出去之后,怎么分组、去重、抑制、静默,最后送到正确的人手上。