记录规则与告警规则
到目前为止,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'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 的三条执行语义
这三条决定了你该怎么组织规则,务必记牢:
- 同一个 group 内,规则严格按书写顺序、串行执行,且所有规则使用同一个求值时间戳。
- 不同 group 之间并行执行,互不等待。
- 每个 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 里,它依然能查到 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,但这属于高级操作,要求原始数据还没过期。
你的服务有一个 Histogram 指标 api_request_duration_seconds_bucket,带 service、path、le 标签,还有一个 Counter api_requests_total,带 service、path、code 标签。总序列数约 30 万。
Grafana 看板需要展示三样东西,而且每 10 秒刷新一次,卡得不行:
- 各接口的 P99 延迟
- 各接口的 QPS
- 各服务的整体错误率(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 指定的时长
此时才真正推送给 Alertmanagerfor 的价值就在 pending 这个缓冲区:
- alert: HostHighCpu
expr: instance:node_cpu_utilization:ratio_rate5m > 0.9
for: 10mCPU 偶尔飙到 95% 持续几十秒,是完全正常的(一次编译、一次备份、一次 GC)。没有 for 的话,这种毛刺会立刻发出告警,几十秒后又发一条恢复通知——这就是告警抖动(flapping),是消磨值班人耐心的头号杀手。加上 for: 10m 之后,只有真正持续 10 分钟的高负载才会被上报。
假设 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 是评估告警体系健康度的金矿。定期跑一次上面那条 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 这种没法读的东西。模板函数负责把它变好看:
| 函数 | 输入 | 输出 |
|---|---|---|
humanize | 1234567 | 1.235M |
humanize1024 | 1073741824 | 1Gi |
humanizeDuration | 3725 | 1h 2m 5s |
humanizePercentage | 0.0526 | 5.26% |
humanizeTimestamp | 1735689600 | 2025-01-01 00:00:00 +0000 UTC |
printf "%.2f" | 0.05263 | 0.05 |
用法是管道形式:
annotations:
summary: '{{ $labels.service }} 错误率异常'
description: >-
错误率 {{ $value | humanizePercentage }},
已持续 5 分钟。当前 QPS 约 {{ $value | humanize }}。
disk: '剩余 {{ $value | humanize1024 }}B'
duration: '已经宕机 {{ $value | humanizeDuration }}'
precise: '负载 {{ printf "%.2f" $value }}'新手常见的误解:以为 {{ $value }} 是整个查询的结果。实际上告警表达式返回 N 条序列就产生 N 条独立告警,每条告警渲染自己那份模板,{{ $value }} 取的是它自己那条序列的数值。
所以如果你想在描述里写「共有 5 台机器出问题」,{{ $value }} 是做不到的——它只知道自己这一台。要么在表达式里用 count() 聚合成一条告警,要么交给 Alertmanager 的分组功能去汇总。
labels 字段同样支持模板渲染,但请记住:标签是 Alertmanager 分组和去重的依据。如果你在 labels 里塞了 {{ $value }},那么值每变化一次,Alertmanager 就认为这是一条全新的告警,去重和分组彻底失效,通知会像雪崩一样刷屏。
铁律:变化的东西放 annotations,稳定的分类信息放 labels。
为下面三个需求写出完整的告警规则(含 for、labels、annotations):
- 任何实例宕机超过 3 分钟。
- 任何磁盘挂载点剩余空间低于 15%,且持续 10 分钟(要在描述里显示具体百分比和挂载点)。
- 某个服务的 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(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下面每条规则都至少有一个问题,请指出并修正:
- 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。一个最小可用的流水线:
promtool check config prometheus.yml
promtool check rules rules/*.yml
promtool test rules tests/*.yml三条命令都是纯静态的,几秒就跑完。它们能拦住:语法错、模板错、for 逻辑错、告警该响时不响。
尤其是第三条——「重构了一下表达式,结果告警再也不触发了」这种事故,只有单元测试能提前发现。
你接手了一个新服务的监控,现有原始指标:
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:告警发出去之后,怎么分组、去重、抑制、静默,最后送到正确的人手上。