Prometheus 以强大的功能和灵活的查询语言使其成为了云原生监控领域的佼佼者,本文将分五章节介绍如何快速搭建 Prometheus 监控体系:

  1. 指标生产
  2. 抓取配置
  3. PromQL 查询
  4. 指标可视化
  5. 告警

本文只会对关键环节进行介绍不会花费太多篇幅在细节上,虽然大多数情况下 Prometheus 会部署在 Kubernetes 集群中,但为了降低学习门槛本文将使用 Docker Compose 进行部署,完整的部署文件放在 GitHub,准备好 Docker Compose,我们开始吧!

0. 指标生产

指标(Metric) 是 Prometheus 体系中最核心的概念,整个体系都围绕着指标展开,来看一条指标示例:

1
node_memory_MemFree_bytes{instance="172.30.105.137:9100"} 6270644224

指标由指标名称和一组键值对标签组成,每个指标可以附加多个键值对形式的标签,这些标签用于提供额外的上下文信息,例如这条指标表示的是实例 172.30.105.137 上的可用内存量,数字 6270644224 是指标的 样本值,表示的是在 某个时间点 上该指标的确切值。

节点的可用内存量会随时间变化,因此指标的值并不是一个固定的值,而是由时间戳和值组成的 时间序列,如图所示:

20241017202214
20241017202214

Prometheus 会定期拉取(Pull)数据存储在时间序列数据库(TSDB)中,数据来源有两种:一种是业务代码中埋点生成的指标,另一种是通过 Exporter 从外部系统获取的指标。下面来看如何在业务代码中埋点生成指标,Prometheus 官方以及开源社区为大多数编程语言都提供了 client library 以便我们集成在业务中。

下面以 Go 语言为例演示如何使用 github.com/prometheus/client_golang/prometheus 包在一个 HTTP 应用中埋点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const MetricNamespace = "app"

var (
requestCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: MetricNamespace, // 指标名前缀
Name: "requests", // 指标名
}, []string{"status", "method"}) // 指标标签
inflightRequests = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: MetricNamespace,
Name: "inflight_requests",
}, []string{"method"})
requestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: MetricNamespace,
Name: "request_duration_seconds",
Buckets: []float64{.01, .025, .05, .1, .15, .2},
}, []string{"method"})
)

func main() {
http.HandleFunc("/{status}", func(rw http.ResponseWriter, r *http.Request) {
// 正在处理请求数 + 1
inflightRequests.With(prometheus.Labels{"method": r.Method}).Inc()

// 记录请求开始时间
start := time.Now()

// 处理请求 & 返回响应
status := handleRequest(r)
rw.WriteHeader(status)

// 计算 & 记录请求耗时
dur := time.Since(start).Seconds()
requestDuration.With(prometheus.Labels{"method": r.Method}).Observe(dur)

// 完成请求数 + 1
requestCounter.With(prometheus.Labels{
"status": strconv.Itoa(status),
"method": r.Method,
}).Inc()

// 正在处理请求数 - 1
inflightRequests.With(prometheus.Labels{"method": r.Method}).Desc()
})

registry := prometheus.NewRegistry()
// 注册指标
registry.MustRegister(requestCounter, inflightRequests, requestDuration)
// 暴露指标
http.Handle("GET /metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))

log.Fatal(http.ListenAndServe(":8000", nil))
}

业务埋点分总体为三个步骤:指标定义、指标更新、暴露指标。示例应用使用了三个不同的方法定义指标,这是因为指标有 类型 的概念:

  • Counter 类型用于计数器场景,它的特点是只能增加不能减少,适用于请求次数、访问量等计数场景。Counter 的图像是一条上升线:

  • Gauge 类型用于记录瞬时值,它的值可以随意变化,适用于节点内存可用量、当前在线用户数等会随时变化的场景。Gauge 的图像是一条波动的线:

  • Histogram 类型用于记录样本值的分布情况,适用于统计请求耗时、响应大小等场景。在定义 Histogram 指标时需要指定桶(Bucket),比如 [0.01, 0.025, 0.05, 0.1, 0.15, 0.2],这些值表示我们希望统计的样本值范围,Histogram 会将收集到的样本值计入对应的桶中,例如 0.021 会被计入 0.025 桶中,0.08 则会被计入到 0.1 桶中,当样本值超出最大桶值时会被计入 +Inf 桶中。多数情况下 Histogram 会被用于统计 P99、P95 等百分位数:

了解完指标分类后我们再来看示例应用的指标定义,不同类型的指标适用于不同场景,因此埋点的方式也不一样:

  • requestCounter 记录完成的请求数,在请求结束时递增,Counter 类型;

  • inflightRequests 记录当前正在处理的请求数,在请求开始是递增,在请求结束时递减,Gauge 类型;

  • requestDuration 记录请求耗时分布情况,在请求结束时记录本次请求耗时,Histogram 类型;

为了更好的区分指标还可以在指标上附加标签,在示例应用中我们给所有指标都加上了 method 标签以便区分不同 HTTP 请求方法的数据,但需要注意的是不要选择区分度过高的值作为标签,如:用户 ID、客户端 IP 地址等。对于 Prometheus 来说,只有指标名相同且标签(值)完全相同的指标序列才会被认为是同一个指标序列,过多的标签值会导致指标序列数量暴增,导致 Prometheus 占用大量内存甚至崩溃。接下来我们启动示例应用并发起请求:

1
2
$ docker-compose up app -d
$ docker-compose run ab

ab 启动后我们可以通过 curl 命令查看暴露的指标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ curl http://localhost:8000/metrics
# HELP app_inflight_requests
# TYPE app_inflight_requests gauge
app_inflight_requests{method="GET"} 23358
# HELP app_request_duration_seconds
# TYPE app_request_duration_seconds histogram
app_request_duration_seconds_bucket{method="GET",le="0.01"} 0
app_request_duration_seconds_bucket{method="GET",le="0.025"} 3656
app_request_duration_seconds_bucket{method="GET",le="0.05"} 10155
app_request_duration_seconds_bucket{method="GET",le="0.1"} 22935
app_request_duration_seconds_bucket{method="GET",le="0.15"} 23338
app_request_duration_seconds_bucket{method="GET",le="0.2"} 23338
app_request_duration_seconds_bucket{method="GET",le="+Inf"} 23338
app_request_duration_seconds_sum{method="GET"} 1303.49026934
app_request_duration_seconds_count{method="GET"} 23338
# HELP app_requests
# TYPE app_requests counter
app_requests{method="GET",status="200"} 18640
app_requests{method="GET",status="500"} 4698

这就是 client library 最终生成的指标数据,以 # 开头的是注释行,除此之外每一行都表示一个指标序列。指标类型似乎没有在输出结果中体现出来,这是因为指标类型只是 client library 用于区分使用场景而设计的,但实际上 Prometheus 并不会记录指标类型,或者说在 Prometheus 中是没有指标类型的概念。

对于 Go 这类常驻内存的语言,业务所产生的指标数据会缓存在内存中等待 Prometheus 抓取,但对于 PHP 这类非常驻语言就无法做到,因为请求结束后解释器实例就会销毁,Prometheus 没有机会抓取数据。对于这类语言一般有两种解决方案:

  1. 使用 Redis 等外部数据库作为临时指标存储仓库,大多数 client library 都支持这个功能。
  2. 请求结束后主动将指标数据推送到 PushGateway,PushGateway 会暂存接收到的指标数据等待 Prometheus 抓取。

除了业务系统我们还可能需要对外部系统如 MySQL、Redis 甚至 Linux 系统进行监控,对于这些系统在代码中进行埋点不太现实,幸运的是大部分系统自身都有监控功能,例如 MySQL 中的 Performance Schema,Linux 系统的 /proc 文件系统等。既然无法在代码中埋点那就加一层中间层,把系统自身监控的数据转换成 Prometheus 指标,这就是 Exporter 的概念。常用的系统都能在开源社区里找到对应的 Exporter,例如 node-exporter,用于收集 Linux 系统的状态并生成 Prometheus 指标数据,下面我们启动它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ docker-compose up -d node-exporter
$ curl http://127.0.0.1:9100/metrics
# HELP node_load1 1m load average.
# TYPE node_load1 gauge
node_load1 0
# HELP node_load15 15m load average.
# TYPE node_load15 gauge
node_load15 0.05
# HELP node_load5 5m load average.
# TYPE node_load5 gauge
node_load5 0.04
# HELP node_memory_MemAvailable_bytes Memory information field MemAvailable_bytes.
# TYPE node_memory_MemAvailable_bytes gauge
node_memory_MemAvailable_bytes 5.003964416e+09
...

指标生产环节就介绍到这里,我们已经了解了指标的基本概念以及如何在业务代码中埋点生成指标,接下来我们将介绍如何部署 Prometheus 并使用 PromQL 查询指标数据。

1. Prometheus

Prometheus 是整个体系中的核心组件,负责对指标进行抓取、存储、查询、告警。Prometheus 使用 Go 语言开发,使用 TSDB 存储指标数据,提供了强大的查询语言 PromQL,支持多种服务发现机制,能在各种环境下从目标中抓取指标数据。Prometheus 非常容易部署并且几乎可以「开箱即用」,但有几个关键启动参数需要关注:

  • --config.file 配置文件路径
  • --web.enable-lifecycle 允许使用 HTTP API 进行 reload 操作,修改配置后无需重启实例
  • --storage.tsdb.path TSDB 数据库存储路径
  • --storage.tsdb.retention.time TSDB 数据保留时间 (默认: 15d)
  • --storage.tsdb.retention.size TSDB 数据保留大小

合理设置 retention.timeretention.size 参数可以避免 Prometheus 占用过多磁盘空间。下面我们启动 Prometheus:

1
$ docker-compose up -d prometheus

打开浏览器访问 http://127.0.0.1:9090 就可以看到 Prometheus 控制台,在 Expression 输入框输入 app_requests,按下回车键我们就完成了一次 PromQL 查询:

20241019135527
20241019135527

接下来我们来看看 Prometheus 是如何从示例应用中抓取指标数据的,这一切的奥秘都在 prometheus.yml 配置文件中。其中 scrape_configs 字段定义了 Prometheus 的抓取配置,数组内每个元素都代表一个 抓取任务,抓取任务都可以从任意个 抓取目标 中抓取数据,抓取目标可以简单理解为是一个 HTTP Endpoint,因此抓取配置的关键就是告诉 Prometheus 抓取目标在哪里:

1
2
3
4
5
scrape_configs:
- job_name: app
static_configs:
- targets:
- app:8000

配置定义了一个 app 抓取任务,使用静态配置 static_configs 直接指定了抓取目标地址 app:8000,Prometheus 会定期从抓取目标抓取指标数据。

之所以可以使用 app 访问示例应用是因为在 docker-compose 配置中使用 links 字段将 app 容器链接到了 prometheus 容器,prometheus 容器的 /etc/hosts 文件会添加 app 域名和 app 容器 IP 地址的映射。在 Docker 中容器随时会被销毁和重建,重建后的容器 IP 地址必然会发生变化,如果直接使用容器 IP 地址作为 Endpoint,意味着每次容器重建后都需要更新抓取配置的 IP 地址。即便 links 字段能为我们提供一个固定的访问域名,但每次增加新的抓取目标都要修改 docker-compose 配置也是不现实的。因此我们需要一种更高效的方法来「寻找」抓取目标,这就是服务发现 Service Discovery

服务发现最大的亮点在于它是「发现」目标而不是「指定」目标,我们只需要指定「途径」Prometheus 就会自动发现抓取目标,而这个途径就是一系列以 sd_config 为后缀的配置字段,比如基于 Kubernetes 的 kubernetes_sd_config、基于 Docker 的 docker_sd_config。下面我们使用服务发现方案修改抓取配置:

1
2
3
4
5
6
7
8
scrape_configs:
# - job_name: app
# static_configs:
# - targets:
# - app:8000
- job_name: app
docker_sd_configs:
- host: unix:///var/run/docker.sock

docker_sd_configs 的配置非常简单,仅仅是指定了 Docker Daemon 的地址,Prometheus 会通过 Docker API 获取 所有容器 并且把它们都作为抓取目标,这就是「发现」的概念。看到这里你可能会产生两个疑问,首先在这个抓取任务中我们只需要抓取 app 容器,但 Docker SD 会把所有容器都当做抓取目标;其次找到 app 容器之后如何把容器 IP 地址以及服务的端口号传递给 Prometheus?

要想回答这两个问题我们需要了解 Relabeling 机制,可以在目标被抓取之前进行 过滤 或者 修改标签。和指标一样抓取目标也有键值对形式的标签,在 Prometheus 控制台 Status -> Service Discovery 页面可以看到所有目标以及目标的标签信息:

20241019153940
20241019153940

__meta_ 为前缀的标签来自于服务发现机制,使用不同的服务发现机制所产生的标签也不同,比如 Docker SD 会把容器名、容器 IP 地址、容器标签等信息附加到抓取目标的标签集合中,这些标签是我们解决问题的关键,因为 Relabeling 机制是基于标签进行操作的,下面我们来看如何解决第一个问题:

1
2
3
4
5
6
7
8
9
10
- job_name: app
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels:
- __meta_docker_container_label_com_docker_compose_project
- __meta_docker_container_label_com_docker_compose_service
separator: ;
regex: "prometheus-stack;node-exporter"
action: keep

下面来解释一下这一段 Relabeling 配置的含义,首先是各字段的作用:

  • source_labels 指定源标签,标签值会被用于与 regex 字段进行匹配以确定 Relabeling 操作是否应用到当前目标
  • separator 指定多个标签值的分隔符,默认值为 ;
  • regex 指定正则表达式
  • action 指定操作类型,常用的有:keepdropreplacelabelmaphashmodlabeldroplabelkeeplabelreplace

了解完各字段的作用后就能理解这段配置的含义:保留 source_labels 字段所指定的标签值匹配 regex 字段的抓取目标。keep 操作是白名单策略,因此不匹配的抓取目标将会被丢弃。

__meta_docker_container_label_com_docker_compose_project 标签等同于容器的 com.docker.compose.project 标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker inspect --format '{{ json .Config.Labels }}' app | jq
{
"com.docker.compose.config-hash": "f64bbc4bddeb213d4fd1c54962db03def352807ec666fd7b06626b7dc76b76cc",
"com.docker.compose.container-number": "1",
"com.docker.compose.depends_on": "",
"com.docker.compose.image": "sha256:bbc7ab5f18e984ce2147e98d7d36ff9e105a2636e694e4fb75dcfb1f1dc73394",
"com.docker.compose.oneoff": "False",
"com.docker.compose.project": "prometheus-stack",
"com.docker.compose.project.config_files": "/Users/2m/Desktop/prometheus-stack/docker-compose.yml",
"com.docker.compose.project.working_dir": "/Users/2m/Desktop/prometheus-stack",
"com.docker.compose.service": "app",
"com.docker.compose.version": "2.29.7"
}

到这里第一个问题就解决了,此时在 Prometheus 控制台 Status -> Targets 标签页可以看到抓取目标信息:

20241020134612
20241020134612

虽然找到了 app 容器但任务还是 DOWN 状态,从 ERROR 列的错误信息可以看出,Prometheus 尝试使用 HTTP 默认端口抓取数据,而 app 容器监听的是 8000 端口。解决这个问题的方法还是 Relabeling,抓取目标的众多标签中有几个特殊标签,它们的作用是给 Prometheus 提供抓取信息:

  • __address__ 抓取目标地址 (IP:Port)

  • __scheme__ 抓取目标协议,默认:http

  • __metrics_path__ 抓取目标路径,默认:/metrics

在这个例子中问题出在 __address__ 标签,其它两个标签默认值就是正确的。我们需要将 __address__ 修改为正确的端口:

1
2
3
4
5
6
7
8
9
10
- job_name: app
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels:
- __meta_docker_network_ip
regex: (.+)
target_label: __address__
action: replace
replacement: "$1:8000"

这段配置的含义是:将 __address__ 的值替换为 ${__meta_docker_network_ip}:8000__meta_docker_network_ip 是 Docker SD 提供的容器 IP 标签。

实际上 Docker SD 会尝试通过一些途径找到服务的端口号,例如 Dockerfile 中使用 EXPOSE 指令声明的端口、通过 docker run -p 暴露的端口。如果这些端口号与容器内服务的端口号对应,我们就无需手动修改 __address__ 标签的值。

介绍完对抓取目标的 Relabeling 配置,接下来我们来看看如何对指标序列进行 Relabeling:

1
2
3
4
5
6
7
8
9
10
11
12
- job_name: app
metric_relabel_configs:
- source_labels:
- status
target_label: status_text
regex: "200"
replacement: "OK"
- source_labels:
- status
target_label: status_text
regex: "500"
replacement: "Internal Server Error"

metric_relabel_configs 字段用于对指标序列进行 Relabeling 操作,注意不要和 relabel_configs 混淆。这段配置的含义是:将 status 标签值为 200 的指标序列的 status_text 标签值替换为 OK;将 status 标签值为 500 的指标序列的 status_text 标签值替换为 Internal Server Error。这样我们就可以在查询结果中看到更直观的状态信息。

下面再来看几种常用的 Relabeling 操作:

  1. 丢弃指标序列,__name__ 是一个特殊标签,它代表指标名称:
1
2
3
4
5
metric_relabel_configs:
- source_labels:
- __name__
regex: node_filesystem_avail_bytes # 丢弃 node_filesystem_avail_bytes 指标序列
action: drop
  1. 丢弃指定标签值的指标序列:
1
2
3
4
5
6
metric_relabel_configs:
- source_labels:
- __name__
- mountpoint
regex: ^node_filesystem_.+;/var/lib/docker(.+) # 丢弃挂载点为 /var/lib/docker 的所有文件系统类指标序列
action: drop
  1. 从指标序列中移除某个标签:
1
2
3
4
5
6
metric_relabel_configs:
- source_labels:
- __name__
regex: node_filesystem_avail_bytes
action: labeldrop
target_label: fstype # 移除 fstype 标签
  1. 将目标信息附加到指标序列,比如将容器名称附加到所有指标序列上,这样就能更好的区分来自不同容器的指标序列。需要注意的是 metric_relabel_configs 中无法使用以 __meta_ 开头的标签,因此需要在 relabel_configs 完成这个操作。
1
2
3
4
5
6
relabel_configs: 
- source_labels:
- __meta_docker_container_name
target_label: container_name
regex: /(.*)
action: replace

最后推荐一个练习 Relabeling 的网站 Relabeler,它提供了一个可视化的界面帮助你更直观的理解 Relabeling 操作:

20241020144153
20241020144153

2. PromQL

PromQL 是一种简单灵活但功能强大的查询语言,支持多种操作符和函数,能够对指标序列进行聚合、过滤、运算等操作。篇幅所限本文不会对 PromQL 进行深入讲解,仅围绕示例应用的指标进行介绍。

在上文中我们已经尝试了在 Prometheus 控制台执行 PromQL 查询,将查询语句稍作修改:

20241020145508
20241020145508

新的查询语句中加入了 标签选择器 过滤出 status 标签为 200 的指标序列。查询中可以使用零个或多个标签选择器,除了相等 =,PromQL 还支持多种标签匹配操作符:

  • = : 完全匹配
  • != : 完全不匹配
  • =~ : 正则匹配,例如 mountpoint=~"^/run/.+"
  • !~ : 正则不匹配

这个查询返回的是 当前时间点 的值,如果想要查看历史数据可以使用 offset 关键字,例如查看 5 分钟前的数据:

20241020150525
20241020150525

查看某个时间点的数据可以使用 @ 操作符:

20241020150603
20241020150603

以上都是针对某一个时间点的查询,查询结果称为 *瞬时向量(Instant Vector)*。

想要查询一段时间内的指标数据,可以在查询语句末尾加上时间范围:

20241020150327
20241020150327

查询结果包含了 5 分钟内抓取到的所有指标值,这种查询结果称为 *范围向量(Range Vector)*,即包含一段时间内的所有时间点的值。

PromQL 支持对 瞬时向量 进行运算,比如将 node_memory_MemFree_bytes 指标转换为更易读的 MB 单位:

20241020212232
20241020212232

除了和标量(Scalar)进行运算,PromQL 还支持指标与指标之间的运算,比如可以通过 app_request_duration_seconds_sumapp_request_duration_seconds_count 指标计算出请求平均响应时间,这两个指标都是由 Histogram 自动生成的,分别表示样本值总和、样本值数量:

20241020154240
20241020154240

Prometheus 会将参与运算的指标序列进行标签匹配,查询结果中的每一条记录都是由两条具有完全相同标签集合的指标序列计算得到的。

PromQL 还支持聚合运算,以 node_cpu_seconds_total 指标为例,它提供了每个 CPU 核的累计使用时间:

1
2
3
4
5
6
node_cpu_seconds_total{cpu="0", mode="idle"} 89193.3
node_cpu_seconds_total{cpu="0", mode="system"} 166.05
node_cpu_seconds_total{cpu="0", mode="user"} 171.27
node_cpu_seconds_total{cpu="1", mode="idle"} 82172.3
node_cpu_seconds_total{cpu="1", mode="system"} 182.06
node_cpu_seconds_total{cpu="1", mode="user"} 179.21

如果我们想查询系统(system)总的 CPU 使用时间,可以使用 sum 函数进行聚合运算:

1
sum(node_cpu_seconds_total{mode="system"})

你可能已经发现了输入框底下的 Graph 按钮,Prometheus 控制台内置了一个图表绘制界面,可以帮助我们更直观的查看指标数据:

20241016164755
20241016164755

从图表中不难猜到 node_cpu_seconds_total 是一个 Counter 类型指标,因为它只会线性增长。对于这个指标而言,我们想要知道值变化情况而不是它的累计值,比如某个时间点 CPU 使用率飙升时,我们希望能够在图表上看到这个变化,这时候就需要使用 rate 函数:

20241016223521
20241016223521

这个图表反映的是 CPU 使用率的 变化情况rate 函数的作用是计算 范围向量 的增长速率,举个例子:指标序列在 T1 时刻值为 100,在 T2 时值为 120,rate 函数返回的是增长量 20 除以 T1 到 T2 的 间隔秒数rate 函数另一个比较常见的场景是计算服务的每秒请求数(QPS)。

本文对 PromQL 的介绍就到这里,PromQL 是一门简洁而又功能强大的查询语言,想要掌握 Prometheus,PromQL 是必不可少的一部分。更多 PromQL 查询案例可以到 PromLabs 查看。