Prometheus Exporter开发

1,127 阅读14分钟

前导知识:监控的两种模式

  • 拉取(PULL)模型(prometheus): 被监控的对象 自己暴露出自己状态
    • 优点:
      • 批量拉取(压缩)
      • 监控耦合度比较低(按需更换 Exporter)
      • 易于排错
      • 独立于监控系统之外, 部署采集器,不需要依赖和知道 Server
    • 缺点:
      • 实时性不高
      • 历史数据无法补充(拉取只能拉到最新的状态)
      • 无法跨域防火墙(Ios App 性能上报, 获取浏览器 异常上报)
  • 推送(PUSH)模型(zabbix,tig,openflcon)
    • 优点:
      • 数据及时性,能及时到达 Server
      • 跨域防火墙
    • 缺点:
      • agent 过多,会造成DDOS
      • 监控耦合度太高, 会给监控系统的迭代带来很大阻碍

Exporter 开发

我们将要开发的本机的: 8050:/metrics

数据格式

  • 通讯协议
    • HTTP 协议
    • 服务端实现了 gzip
  • 数据格式
    • text/plain:文本协议 prometheus 是拉取数据的监控模型, 它对客户端暴露的数据格式要求如下: image.png

简单粗暴

我们直接开发一个满足 prometheus 格式的 API 接口

package main

import (
    "fmt"
    "net/http"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "lexporter_request_count{user="admin"} 1000" ) // prometheus类型的data
}

func main () {
    http.HandleFunc("/metrics", HelloHandler)
    http.ListenAndServe(":8050", nil)
}

使用SDK

大多数场景可以利用 Prometheus 提供的 SDK 快速完成 Metric 数据的暴露

默认指标

Prometheus 准备了一个客户端, 可以基于客户端快速添加监控

package main

import (
 "net/http"

 "github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
    // Serve the default Prometheus metrics registry over HTTP on /metrics.
     http.Handle("/metrics", promhttp.Handler())
     http.ListenAndServe(":8050", nil)
}

可以在浏览器中访问 http://127.0.0.1:8050/metrics 来获得默认的监控指标数据: image.png

代码中并什么业务逻辑,但依然有一些指标数据输出,Go 客户端库默认在我们暴露的全局默认指标注册表 中注册了一些关于 promhttp 处理器和runtime 相关的默认指标,根据不同指标名称的前缀可以看出:

  • go_*:以 go_ 为前缀的指标是关于 Go 运行时相关的指标,比如垃圾回收时间、goroutine 数量等,这些都是 Go 客户端库特有的,其他语言的客户端库可能会暴露各自语言的其他运行时指标。
  • promhttp_*:来自 promhttp 工具包的相关指标,用于跟踪对指标请求的处理。

这些默认的指标是非常有用,但是更多的时候我们需要自己控制,来暴露一些自定义指标。这就需要我们去实现自定义的指标了。

自定义指标

PrometheusServer 端, 只认如下数据格式:

# HELP go_goroutines Number of goroutines that currently exist.
# TYPE go_goroutines gauge
go_goroutines 19

但是 Prometheus 客户端本身也提供一些简单数据二次加工的能力, 他把这种能力描述为4种指标类型:

  • Gauges(仪表盘)Gauge 类型代表一种样本数据可以任意变化的指标,即可增可减。
  • Counters(计数器)counter 类型代表一种样本数据单调递增的指标,即只增不减,除非监控系统发生了重置。
  • Histograms(直方图):需要配置把观测值归入的 bucket 的数量,以及每个 bucket 的上边界。Prometheus 中的直方图是累积的,所以每一个后续的 bucket 都包含前一个 bucket 的观察计数,所有 bucket 的下限都从 0 开始的,所以我们不需要明确配置每个 bucket 的下限,只需要配置上限即可。
  • Summaries(摘要):与 Histogram 类似类型,用于表示一段时间内的数据采样结果(通常是请求持续时间或响应大小等),但它直接存储了分位数(通过客户端计算,然后展示出来),而不是通过区间计算

指标采集

下面以SDK的方式演示4种指标的采集方式

Gauges

最常见的 Metric 类型,即实时指标, 值是什么就返回什么, 并不会加工处理

SDK提供了该指标的构造函数: NewGauge

ChengDuHot := prometheus.NewGauge(prometheus.GaugeOpts{
    // Namespace, Subsystem, Name 会拼接成指标的名称: China_SiChuan_ChengDu
    // 其中Name是必填参数
    Namespace: "China",
    Subsystem: "SiChuan",
    Name:      "ChengDu",
    // 指标的描信息
    Help:      "成都的火热指数",
    // 指标的标签
    ConstLabels: map[string]string{
        "module": "http-server",
    },
})

Gauge对象提供了如下方法用来设置他的值:

// 使用 Set() 设置指定的值
ChengDuHot.Set(0)

// 增加或减少
ChengDuHot.Inc()   // +1:gauge增加1.
ChengDuHot.Dec()   // -1:gauge减少1.
ChengDuHot.Add(23) // 增加23
ChengDuHot.Sub(42) // 减少42

测试用例:

package metric_test

import (
	"fmt"
	"os"
	"testing"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/common/expfmt"
)

func TestGauge(t *testing.T) {
    ChengDuHot := prometheus.NewGauge(prometheus.GaugeOpts{
    // Namespace, Subsystem, Name 会拼接成指标的名称: China_SiChuan_ChengDu
    // 其中Name是必填参数
    Namespace: "China",
    Subsystem: "SiChuan",
    Name:      "ChengDu",
    // 指标的描信息
    Help:      "成都的火热指数",
    // 指标的标签
    ConstLabels: map[string]string{
        "module": "http-server",
    },
})

	ChengDuHot.Set(100)

	// 创建一个自定义的注册表
	registry := prometheus.NewRegistry()
	registry.MustRegister(ChengDuHot)

	// 获取注册所有数据
	data, err := registry.Gather()
	if err != nil {
		panic(err)
	}

	// 编码输出
	enc := expfmt.NewEncoder(os.Stdout, expfmt.FmtText)
	fmt.Println(enc.Encode(data[0]))
}

执行后他的输出

# HELP China_SiChuan_ChengDu 成都的火热指数
# TYPE China_SiChuan_ChengDu gauge
China_SiChuan_ChengDu{module="http-server"} 100
Counters

Counters 是计算器指标, 用于统计次数使用, 通过 prometheus.NewCounter() 函数来初始化指标对象

totalRequests := prometheus.NewCounter(prometheus.CounterOpts{
 Name: "http_requests_total",
 Help: "The total number of handled HTTP requests.",
})
  • Inc(): +1:计数器增加1
  • Add(float64): +n:计数器增加n
func TestCounter(t *testing.T) {
	totalRequests := prometheus.NewCounter(prometheus.CounterOpts{
		Name: "http_requests_total",
		Help: "The total number of handled HTTP requests.",
	})

	for i := 0; i < 10; i++ {
		totalRequests.Inc()
	}

	registry := prometheus.NewRegistry()
	registry.MustRegister(totalRequests)

	data, err := registry.Gather()
	if err != nil {
		panic(err)
	}

	enc := expfmt.NewEncoder(os.Stdout, expfmt.FmtText)
	fmt.Println(enc.Encode(data[0]))
}

输出结果

# HELP http_requests_total The total number of handled HTTP requests.
# TYPE http_requests_total counter
http_requests_total 10
Histograms

Histograms 直方图/柱状图, 主要用于统计指标值的一个分布情况, 也就是常见的概率统计问题

比如, 我们要统计一个班级的 成绩分布情况:

image.png

  • 横轴表示 分数的区间(0-59, 60-70, 70-80, ...)
  • 纵轴表示 落在该区间的人数

prometheusHistograms 用于解决这类问题, 用于设置横轴区间的概念叫 Bucket, 不同于传统的区间设置之处, Bucket 只能设置上限, 下限就是最小值,换用 prometheus Histograms, 上面的区间会变成这样:

0 ~ 59
0 ~ 70
0 ~ 80
...

设置好 Bucket 后, prometheus 的客户端需要统计落入每个 Bucket 中的值的数量(即:一个Counter), 也就是 Histograms 这种指标类型的计算逻辑

在监控里面, Histograms 典型的应用场景 就是统计请求耗时分布, 比如

0 ~ 100ms 请求个数
0 ~ 500ms 请求个数
0 ~ 5000ms 请求个数

那为啥不用平均值来进行统计? 提示: 平均值里面的噪点, 比如一个值 远远大于其他所有值的和

我们使用 NewHistogram 初始化一个直方图类型的指标:

requestDurations := prometheus.NewHistogram(prometheus.HistogramOpts{
  Name:    "http_request_duration_seconds",
  Help:    "A histogram of the HTTP request durations in seconds.",
  // Bucket 配置:第一个 bucket 包括所有在 0.05s 内完成的请求,最后一个包括所有在10s内完成的请求。
  Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
})

Histogram 类型指标提供一个 Observe() 方法, 用于加入一个值到直方图中, 当然加入后 体现在直方图中的不是具体的值,而是值落入区间的统计,实际上每个 bucket 就是一个 Counter 指标

下面是一个完整测试用例

func TestHistogram(t *testing.T) {
	requestDurations := prometheus.NewHistogram(prometheus.HistogramOpts{
		Name: "http_request_duration_seconds",
		Help: "A histogram of the HTTP request durations in seconds.",
		// Bucket 配置:第一个 bucket 包括所有在 0.05s 内完成的请求,最后一个包括所有在10s内完成的请求。
		Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
	})

	// 添加值
	for _, v := range []float64{0.01, 0.02, 0.3, 0.4, 0.6, 0.7, 5.5, 11} {
		requestDurations.Observe(v)
	}

	registry := prometheus.NewRegistry()
	registry.MustRegister(requestDurations)

	data, err := registry.Gather()
	if err != nil {
		panic(err)
	}

	enc := expfmt.NewEncoder(os.Stdout, expfmt.FmtText)
	fmt.Println(enc.Encode(data[0]))
}

最后的结果

# HELP http_request_duration_seconds A histogram of the HTTP request durations in seconds.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.05"} 2
http_request_duration_seconds_bucket{le="0.1"} 2
http_request_duration_seconds_bucket{le="0.25"} 2
http_request_duration_seconds_bucket{le="0.5"} 4
http_request_duration_seconds_bucket{le="1"} 6
http_request_duration_seconds_bucket{le="2.5"} 6
http_request_duration_seconds_bucket{le="5"} 6
http_request_duration_seconds_bucket{le="10"} 7
http_request_duration_seconds_bucket{le="+Inf"} 8
http_request_duration_seconds_sum 18.53
http_request_duration_seconds_count 8

注意点:

  • le="+Inf": 表示小于正无穷, 也就是统计所有的含义
  • 后缀 _sum: 参加统计的值的求和
  • 后缀 _count: 参加统计的值得总数

很多时候直接依赖直方图还是很难定位问题, 我们需要的是请求的一个概统分布, 比如百分之99的请求 落在了那个区间(比如99%请求都在500ms内完成的), 从而判断我们的访问 从整体上看 是良好的。

而像上面的概念分布问题有一个专门的名称叫: quantile, 翻译过来就分位数, 及百分之多少的请求 在那个范围下

image.png 基于直方图提供的数据, 可以计算出分位数, 但分位数的精度会受到分区设置精度的影响(bucket设置), 比如只设置了2个bucket, 0.001, 5, 那么你统计出来的100%这个分位数 就是5s, 因为所有的请求都会落到这个bucket中

如果bucket设置是合理的, 又想使用直方图来统计分位数喃? prometheus的QL, 提供了专门的函数histogram_quantile, 可以用于 基于直方图的统计数据,计算分位数

如果服务端压力很大, bucket也不确定, 我能不能直接在客户端计算分位数(quantile)?

答案是有的,就是第四种指标类型: Summaries

Summaries

这种类型的指标 就是用于计算分位数(quantile)的, 因此他需要配置一个核心参数: 你需要统计哪个(百)分位

NewSummary 来构建该类指标

requestDurations := prometheus.NewSummary(prometheus.SummaryOpts{
    Name:       "http_request_duration_seconds",
    Help:       "A summary of the HTTP request durations in seconds.",
    Objectives: map[float64]float64{
      0.5: 0.05,   // 第50个百分位数,最大绝对误差为0.05。
      0.9: 0.01,   // 第90个百分位数,最大绝对误差为0.01。
      0.99: 0.001, // 第90个百分位数,最大绝对误差为0.001。
    },
  },
)

和直方图一样, 他也近提供一个方法: Observe, 用于统计数据

下面是具体的测试用例:

func TestSummary(t *testing.T) {
	requestDurations := prometheus.NewSummary(prometheus.SummaryOpts{
		Name: "http_request_duration_seconds",
		Help: "A summary of the HTTP request durations in seconds.",
		Objectives: map[float64]float64{
			0.5:  0.05,  // 第50个百分位数,最大绝对误差为0.05。
			0.9:  0.01,  // 第90个百分位数,最大绝对误差为0.01。
			0.99: 0.001, // 第99个百分位数,最大绝对误差为0.001。
		},
	})

	for _, v := range []float64{0.01, 0.02, 0.3, 0.4, 0.6, 0.7, 5.5, 11} {
		requestDurations.Observe(v)
	}

	registry := prometheus.NewRegistry()
	registry.MustRegister(requestDurations)

	data, err := registry.Gather()
	if err != nil {
		panic(err)
	}

	enc := expfmt.NewEncoder(os.Stdout, expfmt.FmtText)
	fmt.Println(enc.Encode(data[0]))
}

最后的结果:

# HELP http_request_duration_seconds A summary of the HTTP request durations in seconds.
# TYPE http_request_duration_seconds summary
http_request_duration_seconds{quantile="0.5"} 0.4 # 中位数是0.4
http_request_duration_seconds{quantile="0.9"} 11  # 90%的位置是11
http_request_duration_seconds{quantile="0.99"} 11
http_request_duration_seconds_sum 18.53
http_request_duration_seconds_count 8

可以看出来 直接使用客户端计算分位数, 准确度不依赖我们设置bucket, 是比较推荐的做法

指标标签

Prometheus将指标的标签分为2类:

  • 静态标签: constLabels, 在指标创建时提前声明好, 采集过程中永不变动
  • 动态标签: variableLabels, 用于在指标的收集过程中动态补充标签, 比如 kafka集群的 exporter 需要动态补充 instance_id

静态标签我们在 NewGauge 之类时已经指明, 下面讨论下如何添加动态标签

要让你的指标支持动态标签 有专门的构造函数, 对应关系如下:

  • NewGauge() 变成 NewGaugeVec()
  • NewCounter() 变成 NewCounterVec()
  • NewSummary() 变成 NewSummaryVec()
  • NewHistogram() 变成 NewHistogramVec()

下面以NewGaugeVec为例进行讲解

NewGaugeVec相比于NewGauge只多出了一个labelNames的参数:

func NewGaugeVec(opts GaugeOpts, labelNames []string) *GaugeVec

一定声明了labelNames, 我们在为指标设置值得时候就必须带上对应个数的标签

queueLength.WithLabelValues("rm_001", "kafka01").Set(100)

完整测试用例:

func TestGaugeVec(t *testing.T) {
	ChengDuHot := prometheus.NewGauge(prometheus.GaugeOpts{ 
            // Namespace, Subsystem, Name 会拼接成指标的名称: China_SiChuan_ChengDu 
            // 其中Name是必填参数 
            Namespace: "China", 
            Subsystem: "SiChuan", 
            Name: "ChengDu", 
            // 指标的描信息 
            Help: "成都的火热指数",
            // 指标的标签
            ConstLabels: map[string]string{
                  "module": "http-server",
            },
	}, []string{"instance_id", "instance_name"})

	ChengDuHot.WithLabelValues("rm_001", "kafka01").Set(100)

	registry := prometheus.NewRegistry()
	registry.MustRegister(ChengDuHot)

	data, err := registry.Gather()
	if err != nil {
		panic(err)
	}

	enc := expfmt.NewEncoder(os.Stdout, expfmt.FmtText)
	fmt.Println(enc.Encode(data[0]))
}

最终我们看到的结果如下:

# HELP China_SiChuan_ChengDu 成都的火热指数
# TYPE China_SiChuan_ChengDu gauge
China_SiChuan_ChengDu{instance_id="rm_001",instance_name="kafka01",module="http-server"} 100

指标注册

指标采集完成后需要注册给 PrometheusHttp Handler 才能暴露出去, Prometheus客户端提供了对应的接口

// 指标注册接口
type Registerer interface {
	// 注册采集器, 有异常会报错
	Register(Collector) error
	// 与上面相同,但有异常会panic
	MustRegister(...Collector)
	// 注销该采集器
	Unregister(Collector) bool
}
默认注册表

Prometheus 实现了一个默认的 Registerer 对象, 也就是默认注册表

var (
	defaultRegistry              = NewRegistry()
	DefaultRegisterer Registerer = defaultRegistry
	DefaultGatherer   Gatherer   = defaultRegistry
)

我们通过prometheus提供的 MustRegister 可以将我们自定义指标注册进去

// 在默认的注册表中注册该指标
prometheus.MustRegister(temp)
prometheus.Register()
prometheus.Unregister()

下面时一个完整的例子

package main

import (
	"net/http"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

func main(){
	ChengDuHot := prometheus.NewGauge(prometheus.GaugeOpts{
		// Namespace, Subsystem, Name 会拼接成指标的名称: China_SiChuan_ChengDu
		// 其中Name是必填参数
		Namespace: "China",
		Subsystem: "SiChuan",
		Name:      "ChengDu",
		// 指标的描信息
		Help:      "成都的火热指数",
		// 指标的标签
		ConstLabels: map[string]string{
			"module": "http-server",
		},
	})
	prometheus.MustRegister(ChengDuHot)
	ChengDuHot.Set(100)

	http.Handle("/metrics", promhttp.Handler())
	http.ListenAndServe(":8050", nil)
}

启动后重新访问指标接口,会发现多了一个名为China_SiChuan_ChengDu 的指标:

# HELP China_SiChuan_ChengDu 成都的火热指数
# TYPE China_SiChuan_ChengDu gauge
China_SiChuan_ChengDu{module="http-server"} 100
...
自定义注册表

Prometheus 默认的 Registerer , 会添加一些默认指标的采集, 比如上面的看到的go运行时和当前process相关信息, 如果不想采集指标, 那么最好的方式是 使用自定义的注册表

package main

import (
	"net/http"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

func main(){
	registry := prometheus.NewRegistry()

	ChengDuHot := prometheus.NewGauge(prometheus.GaugeOpts{
		// Namespace, Subsystem, Name 会拼接成指标的名称: China_SiChuan_ChengDu
		// 其中Name是必填参数
		Namespace: "China",
		Subsystem: "SiChuan",
		Name:      "ChengDu",
		// 指标的描信息
		Help:      "成都的火热指数",
		// 指标的标签
		ConstLabels: map[string]string{
			"module": "http-server",
		},
	})

	
	registry.MustRegister(ChengDuHot)
	ChengDuHot.Set(100)

	http.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{Registry: registry}))
	http.ListenAndServe(":8050", nil)
}
  • 使用NewRegistry()创建一个全新的注册表
  • 通过注册表对象的MustRegister把指标注册到自定义的注册表中

暴露指标时必须调用 promhttp.HandleFor() 函数创建一个专门针对我们自定义注册表的 HTTP 处理器,还需在 promhttp.HandlerOpts 配置对象的 Registry 字段中传递我们的注册表对象

可以看到指标少了很多, 除了 promhttp_metric_handler 就只有我们自定义的指标了

image.png

那如果后面又想把go运行时和当前process相关加入到注册表中暴露出去怎么办?

其实Prometheus在客户端中默认有如下Collector供我们选择

image.png

只需把需要的添加到我们自定义的注册表中即可

 // 添加 process 和 Go 运行时指标到我们自定义的注册表中
 registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
 registry.MustRegister(collectors.NewGoCollector())

再次访问http://localhost:8050/metrics, 之前少的指标又回来了

通过查看prometheus提供的 Collectors 我们发现, 直接把指标注册到registry中的方式不太优雅, 为了能更好的模块化, 我们需要把指标采集封装为一个Collector对象, 这也是很多第三方Collecotor的标准写法

采集器

下面是Collector接口声明:

type Collector interface {
	// 指标的一些描述信息, 就是# 标识的那部分
	// 注意这里使用的是指针, 因为描述信息 全局存储一份就可以了
	Describe(chan<- *Desc)
	// 指标的数据, 比如 promhttp_metric_handler_errors_total{cause="gathering"} 0
	// 这里没有使用指针, 因为每次采集的值都是独立的
	Collect(chan<- Metric)
}

下面我们就把之前的单个指标的采集, 改造成使用采集器的方式编写

demo采集器

实现demo采集器

func NewDemoCollector() *DemoCollector {
	return &DemoCollector{
		queueLengthDesc: prometheus.NewDesc(
			"China_SiChuan_ChengDu",
			"成都的火热指数",
			// 动态标签的key列表
			[]string{"instnace_id", "instnace_name"},
			// 静态标签
			prometheus.Labels{"module": "http-server"},
		),
		// 动态标的value列表, 这里必须与声明的动态标签的key一一对应
		labelValues: []string{"mq_001", "kafka01"},
	}
}

type DemoCollector struct {
	queueLengthDesc *prometheus.Desc
	labelValues     []string
}

func (c *DemoCollector) Describe(ch chan<- *prometheus.Desc) {
	ch <- c.queueLengthDesc
}

func (c *DemoCollector) Collect(ch chan<- prometheus.Metric) {
	ch <- prometheus.MustNewConstMetric(c.queueLengthDesc, prometheus.GaugeValue, 100, c.labelValues...)
}

重构后我们的代码将变得简洁优雅:

package main

import (
	"net/http"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/collectors"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
	registry := prometheus.NewRegistry()
	registry.MustRegister(collectors.NewGoCollector())
	registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))

	registry.MustRegister(NewCollctor())
	http.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{Registry: registry}))
	http.ListenAndServe(":8050", nil)
}

最后我们看到的结果如下:

# HELP China_SiChuan_ChengDu 成都的火热指数
# TYPE China_SiChuan_ChengDu gauge
China_SiChuan_ChengDu{instnace_id="mq_001",instnace_name="kafka01",module="http-server"} 10