- 引言 — 没人打开的仪表盘
- 先写下仪表盘要回答的问题
- 数据源与变量 — 让一个仪表盘服务于多个对象
- 面板设计 — 会被读的排布和不会被读的排布
- 告警要挂在症状上,而不是原因上
- SLO 与错误预算 — 给阈值一个依据
- 减少告警疲劳的规则
- 把仪表盘和告警当作代码来管理
- 结语 — 仪表盘和告警,设计的其实是人的行为
引言 — 没人打开的仪表盘
经常能看到有 60 个仪表盘的组织。其中值班真正会打开的只有两三个。剩下的在做出来的时候被打开过一次,之后就再也没打开过。
告警也差不多。规则有 200 条,值班却把通知频道静音了,真正重要的那一条就埋在里面。
这两个问题的根源是同一个:仪表盘和告警都是从「要展示什么」出发做出来的。出发点本该是「谁需要在什么时候做出什么决定」。
本文就从这个出发点重新开始,内容以 Grafana 12 系列和 Prometheus 3.13.0 LTS 为准验证。由于 Grafana 的界面路径经常变动,本文一律用 provisioning 文件和查询来说明,而不是靠截图操作。
先写下仪表盘要回答的问题
在做面板之前先把问题写下来。没有问题,就不做面板。
不同类型的仪表盘要回答的问题也不同,想把所有东西都塞进一个仪表盘,正是失败的开始。
| 类型 | 使用者 | 要回答的问题 | 面板数 |
|---|---|---|---|
| 服务概览 | 值班,最初 5 分钟 | 用户现在是否受影响,是哪条路由 | 6~8 |
| 服务详情 | 该服务的负责人 | 原因是代码、依赖还是资源 | 15~25 |
| 依赖关系 | 值班 | 我们调用的对象里有没有出问题的 | 6~10 |
| 容量 | 周度评审 | 下个季度谁会最先触顶 | 10~15 |
| SLO | 团队负责人,月度 | 错误预算还剩多少 | 4~6 |
服务概览仪表盘只需要五个问题就够了。
- 请求是否在失败,失败了百分之多少
- 是否变慢了,哪条路由变慢了
- 从什么时候开始的,是否和某次发布重合
- 流量本身有没有变化
- 我们依赖的东西里有没有变差的
只保留能回答这五个问题的面板,仪表盘就不会超过 8 个面板。顺序也很重要。从上往下读时应该是「是否有影响 → 在哪里 → 为什么」这样的顺序。如果 CPU 图表放在最上面,说明这个仪表盘的顺序已经错了。
数据源与变量 — 让一个仪表盘服务于多个对象
一旦开始为每个环境复制同一个仪表盘,管理就会崩溃。在 staging 上改的东西不会同步到 production,三个月后就会出现六份互不相同的副本。
解法是把数据源本身变成一个变量。
# provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
- name: Prometheus-prod
uid: prom-prod
type: prometheus
access: proxy
url: http://prometheus.observability.svc:9090
jsonData:
httpMethod: POST
timeInterval: 15s # 抓取间隔,是计算 rate 窗口的基准
prometheusType: Prometheus
prometheusVersion: 3.13.0
exemplarTraceIdDestinations:
- name: trace_id
datasourceUid: tempo-prod
isDefault: true
- name: Prometheus-staging
uid: prom-staging
type: prometheus
access: proxy
url: http://prometheus.staging.svc:9090
jsonData:
httpMethod: POST
timeInterval: 15s
把 timeInterval 和实际的抓取间隔对齐很重要。Grafana 的 rate 窗口变量会根据这个值和面板宽度来计算一个安全的窗口。如果这个值空着,就会按默认值计算,一旦你把面板看窄或者把时间范围取短,rate 的结果就会变空。
变量按级联方式组织,前一个变量的选择会缩小后一个变量的候选范围。
{
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"query": "prometheus",
"current": { "text": "Prometheus-prod", "value": "prom-prod" }
},
{
"name": "namespace",
"type": "query",
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"query": "label_values(kube_namespace_status_phase, namespace)",
"refresh": 1,
"sort": 1
},
{
"name": "service",
"type": "query",
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"query": "label_values(http_requests_total{namespace=\"$namespace\"}, service)",
"refresh": 2,
"includeAll": true,
"multi": true
},
{
"name": "route",
"type": "query",
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"query": "label_values(http_requests_total{namespace=\"$namespace\", service=~\"$service\"}, route)",
"refresh": 2,
"includeAll": true,
"multi": true
}
]
}
}
refresh 的值经常被搞混。1 是打开仪表盘时刷新,2 是时间范围变化时刷新。在滚动发布导致 Pod 名字一直在变的环境里,需要用 2。如果留成 1,昨天打开的标签页会一直查询早就消失了的 Pod。
把多选变量放进查询时,要用正则匹配。
# 多选变量要以正则形式接收。用等号接收的话只有单个值时才生效
sum by (route) (
rate(http_requests_total{namespace="$namespace", service=~"$service", route=~"$route"}[$__rate_interval])
)
# 仪表盘面板里要用 rate 窗口变量。
# 用固定的 [5m],时间范围拉宽时会浪费分辨率,
# 时间范围收窄时又会因为样本不够在图上留下空洞
histogram_quantile(0.99,
sum by (le, route) (
rate(http_request_duration_seconds_bucket{namespace="$namespace", service=~"$service"}[$__rate_interval])
)
)
如果允许全选,也要检查一下 includeAll 的自定义值。如果默认值不能作为正则表达式生效,全选时结果就会变空。
面板设计 — 会被读的排布和不会被读的排布
同样的数据,也有会被读的排布和不会被读的排布之分。
按行归拢问题。 第一行是「是否有影响」。错误率、p99、流量三个面板就够。第二行是「在哪里」。按路由拆解,以及排名靠前的错误类型。从第三行开始才是候选原因。
固定坐标轴。 如果错误率面板的最大值设成自动,哪怕错误率只有 0.01%,图表也会晃得看起来很严重;反过来,真出事故时又没法和之前对比。百分比轴应该从 0 开始,并且和阈值线一起画出来。
指定单位。 秒级的延迟不设 unit,读的人就分不清 1.4 是 1.4 秒还是 1.4 毫秒。不该把这种判断留到凌晨三点去做。
一个面板里不要超过 20 条时间序列。 一个有 120 条路由的服务如果全部画出来,什么都看不清。只画排名靠前的 N 条,其余的合并处理。
# 只画排名前 10 的路由。想看其余的就用变量收窄范围
topk(10,
sum by (route) (
rate(http_requests_total{service=~"$service", status_class="5xx"}[$__rate_interval])
)
)
# 把发布时间当作标注叠加上去,「从什么时候开始」这个问题立刻就解决了
changes(kube_deployment_status_observed_generation{namespace="$namespace"}[$__rate_interval]) > 0
发布标注是投入产出比最高的一项。如果图表拐点处正好有一条竖线,而那条线是一次发布,排查在那一刻就结束了。
告警要挂在症状上,而不是原因上
如果只能选一条告警设计的核心原则,那就是这一条:把告警挂在用户能体感到的东西上。
先看一个基于原因告警的问题案例。
# 基于原因 — 这类规则堆到 40 条左右,告警疲劳就开始了
- alert: HighCPU
expr: rate(process_cpu_seconds_total[5m]) > 0.8
for: 5m
- alert: HighMemory
expr: process_resident_memory_bytes / container_spec_memory_limit_bytes > 0.9
for: 5m
- alert: ManyGoroutines
expr: go_goroutines > 10000
for: 5m
- alert: DBConnectionsHigh
expr: pg_stat_activity_count > 80
for: 5m
这四条有三个问题。第一,哪怕对用户毫无影响也会响 —— CPU 到 85% 可能只是资源用得好。第二,真出故障时四条会同时响,反而让人搞不清到底是什么原因。第三,会漏掉用户正在经历失败、而这四个指标却全部正常的情况。
# 基于症状 — 挂在用户实际体验到的东西上
groups:
- name: symptom_alerts
rules:
- alert: HighErrorRate
expr: |
sum by (service) (rate(http_requests_total{status_class="5xx"}[5m]))
/
sum by (service) (rate(http_requests_total[5m]))
> 0.02
for: 5m
labels:
severity: page
annotations:
summary: '5xx 比率已超过 2%'
runbook_url: https://wiki.internal/runbook/high-error-rate
- alert: HighLatency
expr: |
histogram_quantile(0.99,
sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))
) > 1.5
for: 10m
labels:
severity: page
annotations:
summary: 'p99 延迟已超过 1.5 秒'
- alert: RequestsStopped
expr: |
sum by (service) (rate(http_requests_total[5m])) == 0
and
sum by (service) (rate(http_requests_total[5m] offset 1h)) > 1
for: 5m
labels:
severity: page
annotations:
summary: '平时有流量的服务现在没有收到请求'
第三条规则经常被漏掉。错误率在分母为 0 时无法计算,所以如果服务彻底挂掉、完全收不到请求,错误率告警反而不会响。流量彻底消失需要单独监控。
原因类指标该放在仪表盘上,而不是告警里。它们是值班被症状告警唤醒之后,用来收窄原因范围的材料。
揪出永远不会触发的告警
最危险的告警不是吵闹的告警,而是悄无声息地失效了的告警。
# 有问题的规则 —— 5 分钟平均值几乎不可能超过阈值
avg_over_time(http_request_duration_seconds_sum[5m])
/ avg_over_time(http_request_duration_seconds_count[5m]) > 5
# 为什么不会响: 平均值会掩盖长尾。p99 可能是 8 秒,平均值却只有 0.2 秒。
# 这条规则只有在整个服务都普遍耗时 8 秒时才会触发。
这种规则哪怕上线几个月,也不会有人发现。要定期把发火次数为 0 的规则挑出来看看。
# 找出过去 30 天里一次都没触发过的规则
# ALERTS_FOR_STATE 只在告警处于激活状态时存在,所以要和规则列表交叉核对
count by (alertname) (max_over_time(ALERTS[30d]))
# 同时确认规则评估本身有没有失败
increase(prometheus_rule_evaluation_failures_total[1h]) > 0
把规则级别的单元测试放进 CI,就能在上线前抓住这个问题。
# tests/alerts_test.yml
rule_files:
- ../rules/symptom_alerts.yml
evaluation_interval: 30s
tests:
- interval: 15s
input_series:
- series: 'http_requests_total{service="checkout", status_class="2xx"}'
values: '0+90x60'
- series: 'http_requests_total{service="checkout", status_class="5xx"}'
values: '0+10x60'
alert_rule_test:
- eval_time: 10m
alertname: HighErrorRate
exp_alerts:
- exp_labels:
service: checkout
severity: page
exp_annotations:
summary: '5xx 比率已超过 2%'
runbook_url: https://wiki.internal/runbook/high-error-rate
SLO 与错误预算 — 给阈值一个依据
「错误率 2%」这个数字是从哪来的?大多数时候哪儿都不是——是某个人凭感觉定的,之后再没人重新问过。
SLO 能给这个阈值一个依据,过程分三步。
- 定义 SLI。 表达为「好事件的比例」——非 5xx 响应的比例,300ms 内完成的请求比例。
- 定 SLO。 30 天内 SLI 至少要达到百分之多少。
- 算出错误预算。 SLO 是 99.9%,预算就是 0.1%;按 30 天算,就是 43.2 分钟。
# rules/slo.yml
groups:
- name: slo_sli
interval: 30s
rules:
# 好请求的比例 —— 排除健康检查
- record: service:sli_availability:ratio_rate5m
expr: |
sum by (service) (
rate(http_requests_total{status_class!="5xx", route!~"/healthz|/readyz"}[5m])
)
/
sum by (service) (
rate(http_requests_total{route!~"/healthz|/readyz"}[5m])
)
# 各个窗口的错误比率 —— 供燃烧率告警引用
- record: service:http_error_ratio:rate5m
expr: |
1 - service:sli_availability:ratio_rate5m
- record: service:http_error_ratio:rate1h
expr: |
sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[1h]))
/
sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[1h]))
- record: service:http_error_ratio:rate6h
expr: |
sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[6h]))
/
sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[6h]))
- record: service:http_error_ratio:rate30m
expr: |
sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[30m]))
/
sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[30m]))
# 剩余错误预算比例 —— 仪表盘上的核心面板
- record: service:error_budget_remaining:ratio30d
expr: |
1 - (
(
sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[30d]))
/
sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[30d]))
)
/ 0.001
)
燃烧率是「按现在的速度继续下去,预算还要多久才会耗尽」的倍数。燃烧率为 1 就是正好 30 天用完的速度,14.4 就是大约 2 天用完的速度。
| 燃烧率 | 30 天预算耗尽所需时间 | 长窗口 | 短窗口 | 应对方式 |
|---|---|---|---|---|
| 14.4 | 约 2 天 | 1 小时 | 5 分钟 | 立即呼人 |
| 6 | 5 天 | 6 小时 | 30 分钟 | 工作时间内处理 |
| 3 | 10 天 | 1 天 | 2 小时 | 建工单 |
| 1 | 30 天 | 3 天 | 6 小时 | 周度评审 |
同时使用长窗口和短窗口,是为了分别挡住两种误报。只用长窗口的话,一场已经结束的事故还会持续响上好几个小时。只用短窗口的话,一次瞬时的尖峰就会触发告警。
groups:
- name: slo_burn_rate
rules:
- alert: ErrorBudgetBurnFast
expr: |
service:http_error_ratio:rate1h > (14.4 * 0.001)
and
service:http_error_ratio:rate5m > (14.4 * 0.001)
for: 2m
labels:
severity: page
slo: availability
annotations:
summary: '正以 14.4 倍速度消耗错误预算,约 2 天后将全部耗尽'
runbook_url: https://wiki.internal/runbook/slo-burn
- alert: ErrorBudgetBurnSlow
expr: |
service:http_error_ratio:rate6h > (6 * 0.001)
and
service:http_error_ratio:rate30m > (6 * 0.001)
for: 15m
labels:
severity: ticket
slo: availability
annotations:
summary: '正以 6 倍速度消耗错误预算'
SLO 仪表盘只要四个面板就够了:剩余预算比例、30 天 SLI 走势、当前燃烧率,以及消耗预算最多的路由排行榜。这四项一起回答「现在能不能承担风险」这个问题。
减少告警疲劳的规则
告警失效的方式就是变得吵闹。下面这些规则效果很明显。
给每条告警都挂上 runbook 链接。 没有链接就不建告警。光这一条规则就能自然减少告警数量,因为写 runbook 的过程会让你意识到「这条其实不需要人来看」。
区分呼人和建工单。 只有需要人现在立刻起床处理的才用呼人,其余的都走工单或频道通知。如果凌晨响起的告警其实等到早上也没关系,就把它的级别调低。
使用抑制规则。 当上游故障正在发生时,把由它引起的下游告警归并起来。
# alertmanager.yml
route:
group_by: ['alertname', 'service']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: slack-default
routes:
- matchers: [severity = page]
receiver: pagerduty
group_wait: 10s
repeat_interval: 1h
continue: true
- matchers: [severity = ticket]
receiver: jira
repeat_interval: 24h
inhibit_rules:
# 整个集群故障期间,抑制单个服务的告警
- source_matchers: [alertname = ClusterUnreachable]
target_matchers: [severity =~ 'page|ticket']
equal: [cluster]
# 同一个服务如果已经有 page 在响,就抑制它的 ticket
- source_matchers: [severity = page]
target_matchers: [severity = ticket]
equal: [service]
receivers:
- name: pagerduty
pagerduty_configs:
- routing_key_file: /etc/alertmanager/pd_key
- name: jira
webhook_configs:
- url: http://alert-to-jira.internal/hook
- name: slack-default
slack_configs:
- channel: '#alerts'
send_resolved: true
定期复盘告警。 每月一次,把上个月触发过的告警按两个维度分类。
| 是否触发过 | 是否引发了行动 | 处置方式 |
|---|---|---|
| 是 | 是 | 保留 |
| 是 | 否 | 调整阈值或降级,如果反复出现就删除 |
| 否 | 不适用 | 测试规则是否能正常工作,不行就修复或删除 |
| 完全没有触发历史 | 不适用 | 用单元测试验证是否具备触发能力 |
第二行最重要。响过但没人做任何处理的告警,下个月还会重复同样的事。不会引发行动的告警是信息,不是告警。
把仪表盘和告警当作代码来管理
在界面里做出来的仪表盘,既没法评审也没法回滚。Grafana 12 在 provisioning 和「可观测性即代码」方面已经打磨得比较完善,用文件来管理明显更靠谱。
# provisioning/dashboards/dashboards.yaml
apiVersion: 1
providers:
- name: gitops
orgId: 1
folder: Services
type: file
disableDeletion: true
updateIntervalSeconds: 60
allowUiUpdates: false # 禁止界面修改,变更必须走仓库
options:
path: /etc/grafana/dashboards
foldersFromFilesStructure: true
# provisioning/alerting/rules.yaml — 把 Grafana 管理的告警定义成文件
apiVersion: 1
groups:
- orgId: 1
name: checkout_slo
folder: Alerts
interval: 1m
rules:
- uid: checkout-burn-fast
title: Checkout error budget burn (fast)
condition: THRESHOLD
data:
- refId: BURN
relativeTimeRange:
from: 3600
to: 0
datasourceUid: prom-prod
model:
expr: service:http_error_ratio:rate1h{service="checkout"}
instant: true
refId: BURN
- refId: THRESHOLD
datasourceUid: __expr__
model:
type: threshold
expression: BURN
conditions:
- evaluator:
type: gt
params: [0.0144]
refId: THRESHOLD
for: 2m
noDataState: NoData
execErrState: Alerting
labels:
severity: page
slo: availability
annotations:
summary: Checkout 的错误预算正以 14.4 倍速度消耗
runbook_url: https://wiki.internal/runbook/slo-burn
noDataState 该设成什么,实务中会有分歧。设成 Alerting,抓取哪怕只是短暂中断也会响。设成 OK,就算数据采集整个停掉了也会保持沉默。大多数情况下把它设成 NoData,再单独建一条专门监控「采集本身是否中断」的告警会更好。
- alert: ScrapeTargetDown
expr: up{job=~"checkout.*|payment.*"} == 0
for: 5m
labels:
severity: page
annotations:
summary: '抓取目标已经 5 分钟没有响应'
把仪表盘的 JSON 放进仓库时,有两点要注意。第一,从界面保存会生成一份混入了面板坐标、版本号之类噪声的 diff。在保存前用一个正规化脚本剥掉不必要的字段并放进 CI,评审才有可能进行。第二,如果把数据源用 UID 硬编码进去,在别的环境里就会失效。应该使用数据源变量,让面板引用这个变量。
结语 — 仪表盘和告警,设计的其实是人的行为
面板和规则只是产出物,真正要设计的是凌晨三点被叫醒的人会看到什么、会做什么。从这个角度看,判断就变得容易了。打开之后决定不了下一步行动的面板,删掉。响了之后无事可做的告警,降级或者去掉。
留下两项现在就能做的检查。第一,把过去 30 天里一次都没被打开过的仪表盘列出来——没人打开,维护成本却一直在产生。第二,数一下过去 30 天里触发过的告警,算出其中真正引发了处置动作的比例。如果这个比例低于一半,告警系统其实已经失去了可信度。
值得继续深入的资料。
현재 단락 (1/382)
经常能看到有 60 个仪表盘的组织。其中值班真正会打开的只有两三个。剩下的在做出来的时候被打开过一次,之后就再也没打开过。