prometheus通过Exporter收集一切指标

2,934 阅读11分钟

微信公众号:运维开发故事,作者:夏老师

Exporter介绍

Exporter 是一个采集监控数据并通过 Prometheus 监控规范对外提供数据的组件,它负责从目标系统(Your 服务)搜集数据,并将其转化为 Prometheus 支持的格式。Prometheus 会周期性地调用 Exporter 提供的 metrics 数据接口来获取数据。那么使用 Exporter 的好处是什么? 举例来说,如果要监控 Mysql/Redis 等数据库,我们必须要调用它们的接口来获取信息(前提要有),这样每家都有一套接口,这样非常不通用。所以 Prometheus 做法是每个软件做一个 Exporter,Prometheus 的 Http 读取 Exporter 的信息(将监控指标进行统一的格式化并暴露出来)。简单类比,Exporter 就是个翻译,把各种语言翻译成一种统一的语言。 ![export收集.png](img-blog.csdnimg.cn/img_convert… Object]&name=export收集.png&originHeight=340&originWidth=840&originalType=binary&ratio=1&size=45007&status=done&style=none&taskId=ua5fca842-ebe8-4b9c-9234-03d71b1cd32) 对于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,读者可以直接阅读文档进行开发,本文只是帮助理解。以下是简化流程图:

![export.png](img-blog.csdnimg.cn/img_convert… Object]&name=export.png&originHeight=1040&originWidth=1140&originalType=binary&ratio=1&size=105377&status=done&style=none&taskId=u4d643ee5-d35a-4c40-b908-e627dfa91cc)四种数据类型

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的编写原则与方法

主要方法

参考链接:prometheus.io/docs/instru…。 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) parseAndRegisterConstMetric(ch chan<- prometheus.Metric, fieldKey, fieldValue string) {
	orgMetricName := sanitizeMetricName(fieldKey)
	metricName := orgMetricName
	if newName, ok := e.metricMapGauges[metricName]; ok {
		metricName = newName
	} else {
		if newName, ok := e.metricMapCounters[metricName]; ok {
			metricName = newName
		}
	}

	var err error
	var val float64

	switch fieldValue {

	case "ok", "true":
		val = 1

	case "err", "fail", "false":
		val = 0

	default:
		val, err = strconv.ParseFloat(fieldValue, 64)

	}
	if err != nil {
		log.Debugf("couldn't parse %s, err: %s", fieldValue, err)
	}
    //根据key判断prometheus.ValueType
	t := prometheus.GaugeValue
	if e.metricMapCounters[orgMetricName] != "" {
		t = prometheus.CounterValue
	}

	switch metricName {
	case "latest_fork_usec":
		metricName = "latest_fork_seconds"
		val = val / 1e6
	}
    //根据prometheus.ValueType生成生成prometheus.Metric,将其放到一个chanal中
	e.registerConstMetric(ch, metricName, val, t)
}e.parseAndRegisterConstMetric(ch, fieldKey, fieldValue)

然后通过func (e *Exporter) registerConstMetric(ch chan<- prometheus.Metric, metric string, val float64, valType prometheus.ValueType, labelValues ...string)生成prometheus.Metric,将prometheus.Metric放到一个chanal中。代码如下:

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端口列表:github.com/prometheus/…。 [2] 标准命名最佳实践:prometheus.io/docs/practi…。 [3] Redis Exporter地址:github.com/oliver006/r…。 [3] Redis INFO命令地址:redis.io/commands/in…