本文转载自微信公众号「运维开发故事」,作者没有文案的夏老师。转载本文请联系运维开发故事公众号。
Exporter介绍
Exporter 是一个采集监控数据并通过 Prometheus 监控规范对外提供数据的组件,它负责从目标系统(Your 服务)搜集数据,并将其转化为 Prometheus 支持的格式。Prometheus 会周期性地调用 Exporter 提供的 metrics 数据接口来获取数据。那么使用 Exporter 的好处是什么?举例来说,如果要监控 Mysql/Redis 等数据库,我们必须要调用它们的接口来获取信息(前提要有),这样每家都有一套接口,这样非常不通用。所以 Prometheus 做法是每个软件做一个 Exporter,Prometheus 的 Http 读取 Exporter 的信息(将监控指标进行统一的格式化并暴露出来)。简单类比,Exporter 就是个翻译,把各种语言翻译成一种统一的语言。
对于Exporter而言,它的功能主要就是将数据周期性地从监控对象中取出来进行加工,然后将数据规范化后通过端点暴露给Prometheus,所以主要包含如下3个功能。
- 封装功能模块获取监控系统内部的统计信息。
- 将返回数据进行规范化映射,使其成为符合Prometheus要求的格式化数据。
- Collect模块负责存储规范化后的数据,最后当Prometheus定时从Exporter提取数据时,Exporter就将Collector收集的数据通过HTTP的形式在/metrics端点进行暴露。
介绍Primetheus client
本文将介绍Primetheus client的使用,基于golang语言,golang client 是当pro收集所监控的系统的数据时,用于响应pro的请求,按照一定的格式给pro返回数据,说白了就是一个http server, 源码参见github,相关的文档参见GoDoc,读者可以直接阅读文档进行开发,本文只是帮助理解。以下是简化流程图:
四种数据类型
prometheus将所有数据保存为timeseries data,用metric name和label区分,label是在metric name上的更细维度的划分,其中的每一个实例是由一个float64和timestamp组成,只不过timestamp是隐式加上去的,有时候不会显示出来。如下面所示(数据来源于prometheus暴露的监控数据,访问http://localhost:9090/metrics 可得),其中go_gc_duration_seconds是metrics name,quantile="0.5"是key-value pair的label,而后面的值是float64 value。
- # HELP go_gc_duration_seconds A summary of the GC invocation durations.
- # TYPE go_gc_duration_seconds summary
- go_gc_duration_seconds{quantile="0.5"} 0.000107458
- go_gc_duration_seconds{quantile="0.75"} 0.000200112
- go_gc_duration_seconds{quantile="1"} 0.000299278
- go_gc_duration_seconds_sum 0.002341738
- go_gc_duration_seconds_count 18
- # HELP go_goroutines Number of goroutines that currently exist.
- # TYPE go_goroutines gauge
- go_goroutines 107
这些信息有一个共同点,就是采用了不同于JSON或者Protocol Buffers的数据组织形式——文本形式。在文本形式中,每个指标都占用一行,#HELP代表指标的注释信息,#TYPE用于定义样本的类型注释信息,紧随其后的语句就是具体的监控指标(即样本)。#HELP的内容格式如下所示,需要填入指标名称及相应的说明信息。
- HELP <metrics_name> <doc_string>
#TYPE的内容格式如下所示,需要填入指标名称和指标类型(如果没有明确的指标类型,需要返回untyped)。
- TYPE <metrics_name> <metrics_type>
监控样本部分需要满足如下格式规范。
- metric_name [ "{" label_name "=" " label_value " { "," label_name "=" " label_value " } [ "," ] "}" ] value [ timestamp ]
其中,metric_name和label_name必须遵循PromQL的格式规范。value是一个f loat格式的数据,timestamp的类型为int64(从1970-01-01 00:00:00开始至今的总毫秒数),可设置其默认为当前时间。具有相同metric_name的样本必须按照一个组的形式排列,并且每一行必须是唯一的指标名称和标签键值对组合。Prometheus为了方便client library的使用提供了四种数据类型:
Counter:Counter是一个累加的数据类型。一个Counter类型的指标只会随着时间逐渐递增(当系统重启的时候,Counter指标会被重置为0)。记录系统完成的总任务数量、系统从最近一次启动到目前为止发生的总错误数等场景都适合使用Counter类型的指标。
- Gauge:Gauge指标主要用于记录一个瞬时值,这个指标可以增加也可以减少,比如CPU的使用情况、内存使用量以及硬盘当前的空间容量等。
- Histogram:Histogram表示柱状图,主要用于统计一些数据分布的情况,可以计算在一定范围内的数据分布情况,同时还提供了指标值的总和。在大多数情况下,用户会使用某些指标的平均值作为参考,例如,使用系统的平均响应时间来衡量系统的响应能力。这种方式有个明显的问题——如果大多数请求的响应时间都维持在100ms内,而个别请求的响应时间需要1s甚至更久,那么响应时间的平均值体现不出响应时间中的尖刺,这就是所谓的“长尾问题”。为了更加真实地反映系统响应能力,常用的方式是按照请求延迟的范围进行分组,例如在上述示例中,可以分别统计响应时间在[0,100ms]、[100,1s]和[1s,∞]这3个区间的请求数,通过查看这3个分区中请求量的分布,就可以比较客观地分析出系统的响应能力。
- Summary:Summary与Histogram类似,也会统计指标的总数(以_count作为后缀)以及sum值(以_sum作为后缀)。两者的主要区别在于,Histogram指标直接记录了在不同区间内样本的个数,而Summary类型则由客户端计算对应的分位数。例如下面展示了一个Summary类型的指标,其中quantile=”0.5”表示中位数,quantile=”0.9”表示九分位数。
广义上讲,所有可以向Prometheus提供监控样本数据的程序都可以被称为一个Exporter,Exporter的一个实例被称为target,Prometheus会通过轮询的形式定期从这些target中获取样本数据。
自己动手编写一个Exporter
一般来说,绝大多数Exporter都是基于Go语言编写的,一小部分是基于Python语言编写的,还有很小一部分是使用Java语言编写的。比如官方提供的Consul Metrics自定义采集器Exporter,如果是在Go语言的运行环境下,需要按照如下所示代码运行这个Exporter。
- package main
- import (
- "log"
- "net/http"
- "github.com/prometheus/client_golang/prometheus"
- "github.com/prometheus/client_golang/prometheus/promhttp"
- )
- var (
- cpuTemp = prometheus.NewGauge(prometheus.GaugeOpts{
- NameSpace: "our_idc",
- Subsystem: "k8s"
- Name: "cpu_temperature_celsius",
- Help: "Current temperature of the CPU.",
- })
- hdFailures = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- NameSpace: "our_idc",
- Subsystem: "k8s"
- Name: "hd_errors_total",
- Help: "Number of hard-disk errors.",
- },
- []string{"device"},
- )
- )
- func init() {
- // Metrics have to be registered to be exposed:
- prometheus.MustRegister(cpuTemp)
- prometheus.MustRegister(hdFailures)
- }
- func main() {
- cpuTemp.Set(65.3)
- hdFailures.With(prometheus.Labels{"device":"/dev/sda"}).Inc()
- // The Handler function provides a default handler to expose metrics
- // via an HTTP server. "/metrics" is the usual endpoint for that.
- http.Handle("/metrics", promhttp.Handler())
- log.Fatal(http.ListenAndServe(":8888", nil))
其中创建了一个gauge和CounterVec对象,并分别指定了metric name和help信息,其中CounterVec是用来管理相同metric下不同label的一组Counter,同理存在GaugeVec,可以看到上面代码中声明了一个lable的key为“device”,使用的时候也需要指定一个lable: hdFailures.With(prometheus.Labels{"device":"/dev/sda"}).Inc()。变量定义后进行注册,最后再开启一个http服务的8888端口就完成了整个程序,Prometheus采集数据是通过定期请求该服务http端口来实现的。启动程序之后可以在web浏览器里输入http://localhost:8888/metrics 就可以得到client暴露的数据,其中有片段显示为:
- # HELP our_idc_k8s_cpu_temperature_celsius Current temperature of the CPU.
- # TYPE our_idc_k8s_cpu_temperature_celsius gauge
- our_idc_k8s_cpu_temperature_celsius 65.3
- # HELP our_idc_k8s_hd_errors_total Number of hard-disk errors.
- # TYPE our_idc_k8s_hd_errors_total counter
- our_idc_k8s_hd_errors_total{device="/dev/sda"} 1
上图就是示例程序所暴露出来的数据,并且可以看到counterVec是有label的,而单纯的gauage对象却不用lable标识,这就是基本数据类型和对应Vec版本的差别。此时再查看http://localhost:9090/graph 就会发现服务状态已经变为UP了。上面的例子只是一个简单的demo,因为在prometheus.yml配置文件中我们指定采集服务器信息的时间间隔为60s,每隔60s Prometheus会通过http请求一次自己暴露的数据,而在代码中我们只设置了一次gauge变量cupTemp的值,如果在60s的采样间隔里将该值设置多次,前面的值就会被覆盖,只有Prometheus采集数据那一刻的值能被看到,并且如果不再改变这个值,Prometheus就始终能看到这个恒定的变量,除非用户显式通过Delete函数删除这个变量。使用Counter,Gauage等这些结构比较简单,但是如果不再使用这些变量需要我们手动删,我们可以调用resetfunction来清除之前的metrics。
自定义Collector
直接使用Collector,go client Colletor只会在每次响应Prometheus请求的时候才收集数据。需要每次显式传递变量的值,否则就不会再维持该变量,在Prometheus也将看不到这个变量。Collector是一个接口,所有收集metrics数据的对象都需要实现这个接口,Counter和Gauage等不例外。它内部提供了两个函数,Collector用于收集用户数据,将收集好的数据传递给传入参数Channel就可;Descirbe函数用于描述这个Collector。
当收集系统收集的数据太多时时,就可以自定义Collector收集的方式,优化流程,并且在某些情况下如果已经有了一个成熟的metrics,就不需要使用Counter,Gauage等这些数据结构,直接在Collector内部实现一个代理的功能即可。
基本上所有的export都是通过自定义Collector实现。一个简单的Collector的实现export的代码如下:
- package main
- import (
- "github.com/prometheus/client_golang/prometheus"
- "github.com/prometheus/client_golang/prometheus/promhttp"
- "net/http"
- "sync"
- )
- type ClusterManager struct {
- sync.Mutex
- Zone string
- metricMapCounters map[string]string
- metricMapGauges map[string]string
- }
- //Simulate prepare the data
- func (c *ClusterManager) ReallyExpensiveAssessmentOfTheSystemState() (
- metrics map[string]float64,
- ) {
- metrics = map[string]float64{
- "oom_crashes_total": 42.00,
- "ram_usage": 6.023e23,
- }
- return
- }
- //通过NewClusterManager方法创建结构体及对应的指标信息,代码如下所示。
- // NewClusterManager creates the two Descs OOMCountDesc and RAMUsageDesc. Note
- // that the zone is set as a ConstLabel. (It's different in each instance of the
- // ClusterManager, but constant over the lifetime of an instance.) Then there is
- // a variable label "host", since we want to partition the collected metrics by
- // host. Since all Descs created in this way are consistent across instances,
- // with a guaranteed distinction by the "zone" label, we can register different
- // ClusterManager instances with the same registry.
- func NewClusterManager(zone string) *ClusterManager {
- return &ClusterManager{
- Zone: zone,
- metricMapGauges: map[string]string{
- "ram_usage": "ram_usage_bytes",
- },
- metricMapCounters: map[string]string{
- "oom_crashes": "oom_crashes_total",
- },
- }
- }
- //首先,采集器必须实现prometheus.Collector接口,也必须实现Describe和Collect方法。实现接口的代码如下所示。
- // Describe simply sends the two Descs in the struct to the channel.
- // Prometheus的注册器调用Collect来抓取参数
- // 将收集的数据传递到Channel中并返回
- // 收集的指标信息来自Describe,可以并发地执行抓取工作,但是必须要保证线程的安全
- func (c *ClusterManager) Describe(ch chan<- *prometheus.Desc) {
- // prometheus.NewDesc(prometheus.BuildFQName(namespace, "", metricName), docString, labels, nil)
- for _, v := range c.metricMapGauges {
- ch <- prometheus.NewDesc(prometheus.BuildFQName(c.Zone, "", v), v, nil, nil)
- }
- for _, v := range c.metricMapCounters {
- ch <- prometheus.NewDesc(prometheus.BuildFQName(c.Zone, "", v), v, nil, nil)
- }
- }
- //Collect方法是核心,它会抓取你需要的所有数据,根据需求对其进行分析,然后将指标发送回客户端库。
- // 用于传递所有可能指标的定义描述符
- // 可以在程序运行期间添加新的描述,收集新的指标信息
- // 重复的描述符将被忽略。两个不同的Collector不要设置相同的描述符
- func (c *ClusterManager) Collect(ch chan<- prometheus.Metric) {
- c.Lock()
- defer c.Unlock()
- m := c.ReallyExpensiveAssessmentOfTheSystemState()
- for k, v := range m {
- t := prometheus.GaugeValue
- if c.metricMapCounters[k] != "" {
- t = prometheus.CounterValue
- }
- c.registerConstMetric(ch, k, v, t)
- }
- }
- // 用于传递所有可能指标的定义描述符给指标
- func (c *ClusterManager) registerConstMetric(ch chan<- prometheus.Metric, metric string, val float64, valType prometheus.ValueType, labelValues ...string) {
- descr := prometheus.NewDesc(prometheus.BuildFQName(c.Zone, "", metric), metric, nil, nil)
- if m, err := prometheus.NewConstMetric(descr, valType, val, labelValues...); err == nil {
- ch <- m
- }
- }
- func main() {
- workerCA := NewClusterManager("xiaodian")
- reg := prometheus.NewPedanticRegistry()
- reg.MustRegister(workerCA)
- //当promhttp.Handler()被执行时,所有metric被序列化输出。题外话,其实输出的格式既可以是plain text,也可以是protocol Buffers。
- http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
- http.ListenAndServe(":8888", nil)
- }
此时就可以去http://localhost:8888/metrics 看到传递过去的数据了。
高质量Exporter的编写原则与方法
主要方法
参考链接:https://prometheus.io/docs/instrumenting/writing_exporters/。1.在访问Exporter的主页(即http://yourExporter/这样的根路径)时,它会返回一个简单的页面,这就是Exporter的落地页(Landing Page)。落地页中可以放文档和帮助信息,包括监控指标项的说明。落地页上还包括最近执行的检查列表、列表的状态以及调试信息,这对故障排查非常有帮助。
2.一台服务器或者容器上可能会有许多Exporter和Prometheus组件,它们都有自己的端口号。因此,在写Exporter和发布Exporter之前,需要检查新添加的端口是否已经被使用[1],建议使用默认端口分配范围之外的端口。
3.我们应该根据业务类型设计好指标的#HELP#TYPE的格式。这些指标往往是可配置的,包括默认开启的指标和默认关闭的指标。这是因为大部分指标并不会真正被用到,设计过多的指标不仅会消耗不必要的资源,还会影响整体的性能。
其他方法
对于如何写高质量Exporter,除了合理分配端口号、设计落地页、梳理指标这3个方面外,还有一些其他的原则。
- 记录Exporter本身的运行状态指标。
- 可配置化进行功能的启用和关闭。
- 推荐使用YAML作为配置格式。
- 遵循度量标准命名的最佳实践[2],特别是_count、_sum、_total、_bucket和info等问题。
- 为度量提供正确的单位。
- 标签的唯一性、可读性及必要的冗余信息设计。
- 通过Docker等方式一键配置Exporter。
- 尽量使用Collectors方式收集指标,如Go语言中的MustNewConstMetric。
- 提供scrapes刮擦失败的错误设计,这有助于性能调试。
- 尽量不要重复提供已有的指标,如Node Exporter已经提供的CPU、磁盘等信息。
- 向Prometheus公开原始的度量数据,不建议自行计算,Exporter的核心是采集原始指标。
Redis Exporter源码解析
在本章中,读者可以发现开源领域有着不计其数的Exporter,阿里巴巴开源的Exporter就有RocketMQ Exporter、Sentinel Exporter、Sentry Exporter、Alibaba Cloud Exporter等多种。编写Exporter和编写Spring Boot Starter一样,可以多参考其他优秀的开源软件的代码。本节就来简单分析一下运维工作中用到的Redis Exporter源码。在使用Redis Exporter时,可以通过redis_exporter--help命令查看完整的参数列表。默认情况下,它在端口9192上运行,并在路径/metrics上暴露指标。可以通过--web.listen-addres和--web.telemetry-path命令来设置端口和路径,代码如下所示。
- redis_exporter -web.listen-address=":8888" -web.telemetry-path="/node_metrics"
上述代码将修改redis Exporter绑定到端口8888并在路径/node_metrics上暴露指标。这个逻辑是在源码redis_exporter.go中实现的.Redis Exporter[3]主要通过Redis原生的命令获取Redis所有的信息,它支持2.x、3.x、4.x、5.x和6.x版本。在源码中,可以看到多处使用了doRedisCmd方法发送命令以获取性能指标,代码如下所示。主要是通过原生的INFO命令获取所有性能信息。该命令的返回结果详情参考[4]。
- infoAll, err := redis.String(doRedisCmd(c, "INFO", "ALL"))
生成的infoAll信息通过func (e *Exporter) extractInfoMetrics(ch chan<- prometheus.Metric, info string, dbCount int)继续处理。它的主要目的是遍历查询到的结果,根据指标生成一个hash值。源代码如下所示:
- func (e *Exporter) extractInfoMetrics(ch chan<- prometheus.Metric, info string, dbCount int) {
- keyValues := map[string]string{}
- handledDBs := map[string]bool{}
- fieldClass := ""
- //以换行符进行分割
- lines := strings.Split(info, "\n")
- masterHost := ""
- masterPort := ""
- //遍历查询到的结果,根据指标生成一个hash值
- for _, line := range lines {
- line = strings.TrimSpace(line)
- log.Debugf("info: %s", line)
- //去除带#的注释文件
- if len(line) > 0 && strings.HasPrefix(line, "# ") {
- fieldClass = line[2:]
- log.Debugf("set fieldClass: %s", fieldClass)
- continue
- }
- //去除不带:的或者字符小于2的
- if (len(line) < 2) || (!strings.Contains(line, ":")) {
- continue
- }
- //以冒号进行分割
- split := strings.SplitN(line, ":", 2)
- fieldKey := split[0]
- fieldValue := split[1]
- //将指标名称与值存到hash中
- keyValues[fieldKey] = fieldValue
- if fieldKey == "master_host" {
- masterHost = fieldValue
- }
- if fieldKey == "master_port" {
- masterPort = fieldValue
- }
- //按照集群和副本和哨兵模式进行处理
- switch fieldClass {
- case "Replication":
- if ok := e.handleMetricsReplication(ch, masterHost, masterPort, fieldKey, fieldValue); ok {
- continue
- }
- case "Server":
- e.handleMetricsServer(ch, fieldKey, fieldValue)
- case "Commandstats":
- e.handleMetricsCommandStats(ch, fieldKey, fieldValue)
- continue
- case "Keyspace":
- if keysTotal, keysEx, avgTTL, ok := parseDBKeyspaceString(fieldKey, fieldValue); ok {
- dbName := fieldKey
- e.registerConstMetricGauge(ch, "db_keys", keysTotal, dbName)
- e.registerConstMetricGauge(ch, "db_keys_expiring", keysEx, dbName)
- if avgTTL > -1 {
- e.registerConstMetricGauge(ch, "db_avg_ttl_seconds", avgTTL, dbName)
- }
- handledDBs[dbName] = true
- continue
- }
- case "Sentinel":
- e.handleMetricsSentinel(ch, fieldKey, fieldValue)
- }
- if !e.includeMetric(fieldKey) {
- continue
- }
- //将收集到信息进行按照一定规则进行处理
- e.parseAndRegisterConstMetric(ch, fieldKey, fieldValue)
- }
- for dbIndex := 0; dbIndex < dbCount; dbIndex++ {
- dbName := "db" + strconv.Itoa(dbIndex)
- if _, exists := handledDBs[dbName]; !exists {
- e.registerConstMetricGauge(ch, "db_keys", 0, dbName)
- e.registerConstMetricGauge(ch, "db_keys_expiring", 0, dbName)
- }
- }
- e.registerConstMetricGauge(ch, "instance_info", 1,
- keyValues["role"],
- keyValues["redis_version"],
- keyValues["redis_build_id"],
- keyValues["redis_mode"],
- keyValues["os"],
- keyValues["maxmemory_policy"],
- keyValues["tcp_port"], keyValues["run_id"], keyValues["process_id"],
- )
- if keyValues["role"] == "slave" {
- e.registerConstMetricGauge(ch, "slave_info", 1,
- keyValues["master_host"],
- keyValues["master_port"],
- keyValues["slave_read_only"])
- }
- }
然后通过e.parseAndRegisterConstMetric(ch, fieldKey, fieldValue)方法,将收集到hash中的信息,按照一定的规则生成prometheus.Metric。核心代码如下:
- func (e *Exporter) registerConstMetric(ch chan<- prometheus.Metric, metric string, val float64, valType prometheus.ValueType, labelValues ...string) {
- descr := e.metricDescriptions[metric]
- if descr == nil {
- descr = newMetricDescr(e.options.Namespace, metric, metric+" metric", labelValues)
- }
- if m, err := prometheus.NewConstMetric(descr, valType, val, labelValues...); err == nil {
- ch <- m
- }
- }
最后*Exporter.Collect的方法调用registerConstMetric方法,就完成了redis的info指标的收集。其他指标的收集原来也是相同的,有兴趣的读者可以自行阅读。
总结
本文介绍了Exporter的概念。Exporter的来源主要有两个:一个是社区提供的,一个是用户自定义的。在实际生产中,官方提供的Exporter主要涵盖数据库、硬件、问题跟踪及持续集成、消息系统、存储、HTTP、API、日志、其他监控系统等,这些已有的Exporter可以满足绝大多数开发人员及运维人员的需求。对于系统、软件没有Exporter的情况,本章也从数据规范、数据采集方式、代码案例撰写等方面带领读者体验了Exporter的设计与实践,一步步指导读者打造定制化Exporter。为了帮助读者形成良好的代码风格并能够真正编写高质量Exporter,本章还给出了编写高质量Exporter的建议,并结合 Redis Exporter的原理进行了实战解析。通过对本章的学习,读者可以掌握使用和定制Exporter的能力。
[1] Exporter端口列表:https://github.com/prometheus/prometheus/wiki/Default-port-allocations。
[2] 标准命名最佳实践:https://prometheus.io/docs/practices/naming。
[3] Redis Exporter地址:https://github.com/oliver006/redis_Exporter。
[4] Redis INFO命令地址:https://redis.io/commands/info。