Prometheus 源码专题【左扬精讲】—— Prometheus Exporter 定制化开发:编码器篇 —— 深入学习编码器工作机制

Prometheus 源码专题【左扬精讲】—— Prometheus Exporter 定制化开发:编码器篇 —— 深入学习编码器工作机制

        在 Prometheus 监控体系中,Exporter 作为指标采集的核心组件,其最终输出的指标数据必须经过编码转换,才能通过 HTTP 协议返回给 Prometheus Server。而这一转换过程的核心引擎,正是编码器(Encoder)。本文将以 Prometheus 3.4.2 源码为基础,结合实际案例,深入解析 Exporter 编码器的工作机制、源码实现及定制化实践路径。

        Prometheus Exporter 编码器是衔接指标数据与 HTTP 响应的关键枢纽。借助 expfmt(github.com/prometheus/common/expfmt) 包的抽象化设计,它实现了对多种数据格式的灵活支持。深入掌握编码器的工作原理,不仅能帮助开发者透彻理解 Exporter 的数据输出全流程,更为定制化需求(如扩展新型格式、优化编码性能等)提供了坚实的技术基础。

        在实际开发场景中,需依据业务需求选择适配的编码格式,在可读性与传输性能间找到最佳平衡点,确保监控数据的高效采集与稳定传输。通过深耕源码实现逻辑,开发者能够更精准地利用并拓展 Prometheus 的编码能力,为构建高可靠的监控体系赋能。

一、核心功能:连接Metric与 HTTP 响应

        在 Prometheus Exporter 的工作流程中,指标数据首先通过采集逻辑生成 MetricFamily 数组(包含多个指标组),随后需要通过编码器将其转换为客户端可识别的格式。Prometheus 3.4.2 支持 5 种主流编码格式,包括:​

    1. 文本格式(Text Format):默认格式,人类可读,适用于调试和简单场景。​
    2. Protocol Buffers(Protobuf):二进制格式,传输效率高,适用于大规模监控场景。​
    3. OpenMetrics 文本格式:扩展的文本格式,支持更多元数据(如指标类型、单位)。​
    4. Protobuf 压缩格式:基于 Protobuf 的压缩版本,进一步减少传输体积。​
    5. OpenMetrics Protobuf 格式:结合 OpenMetrics 元数据与 Protobuf 效率的混合格式。

        编码器的核心作用是实现 MetricFamily 到目标格式的序列化,并通过 HTTP 响应头的 Content-Type 标识输出格式。客户端(如 Prometheus Server)通过请求头的 Accept 字段指定期望的格式,Exporter 则根据该字段选择对应的编码器。​

二、Prometheus 3.4.2 编码器源码架构学习

2.1、核心处理流程

编码器的核心实现在  https://github.com/prometheus/client_golang/blob/main/prometheus/promhttp/http.go#L193 中,主要处理流程如下:

// HandlerForTransactional 类似于 HandlerFor,但使用事务性采集器(TransactionalGatherer)。
// 事务性采集允许在调用 `Gather` 后且在 `done` 回调前安全地原地修改返回的 *dto.MetricFamily。
func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerOpts) http.Handler {
	var (
		// 用于限制并发请求数的信号量通道
		inFlightSem chan struct{}
		// 记录处理过程中各类错误的计数器
		errCnt      = prometheus.NewCounterVec(
			prometheus.CounterOpts{
				Name: "promhttp_metric_handler_errors_total",
				Help: "Total number of internal errors encountered by the promhttp metric handler.",
			},
			[]string{"cause"}, // 错误原因标签:gathering 或 encoding
		)
	)

	// 初始化并发请求限制
	if opts.MaxRequestsInFlight > 0 {
		inFlightSem = make(chan struct{}, opts.MaxRequestsInFlight)
	}
	
	// 注册错误计数器到指定注册表
	if opts.Registry != nil {
		// 预初始化所有可能的错误标签值
		errCnt.WithLabelValues("gathering")
		errCnt.WithLabelValues("encoding")
		
		// 注册错误计数器,处理已注册的情况
		if err := opts.Registry.Register(errCnt); err != nil {
			are := &prometheus.AlreadyRegisteredError{}
			if errors.As(err, are) {
				errCnt = are.ExistingCollector.(*prometheus.CounterVec)
			} else {
				panic(err) // 非预期错误,直接 panic
			}
		}
	}

	// 确定支持的压缩格式(基于用户配置或默认值)
	var compressions []string
	if !opts.DisableCompression {
		offers := defaultCompressionFormats()
		if len(opts.OfferedCompressions) > 0 {
			offers = opts.OfferedCompressions
		}
		for _, comp := range offers {
			compressions = append(compressions, string(comp))
		}
	}

	// 构建 HTTP 请求处理函数
	h := http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) {
		// 设置进程启动时间头(如果已配置)
		if !opts.ProcessStartTime.IsZero() {
			rsp.Header().Set(processStartTimeHeader, strconv.FormatInt(opts.ProcessStartTime.Unix(), 10))
		}
		
		// 并发请求限制处理
		if inFlightSem != nil {
			select {
			case inFlightSem <- struct{}{}: // 成功获取信号量,继续处理
				defer func() { <-inFlightSem }() // 处理完成后释放信号量
			default:
				// 达到并发限制,返回 503 错误
				http.Error(rsp, fmt.Sprintf(
					"Limit of concurrent requests reached (%d), try again later.", opts.MaxRequestsInFlight,
				), http.StatusServiceUnavailable)
				return
			}
		}
		
		// 采集指标(事务性)
		mfs, done, err := reg.Gather()
		defer done() // 确保资源释放
		
		// 处理采集错误
		if err != nil {
			if opts.ErrorLog != nil {
				opts.ErrorLog.Println("error gathering metrics:", err)
			}
			errCnt.WithLabelValues("gathering").Inc()
			
			switch opts.ErrorHandling {
			case PanicOnError:
				panic(err) // 遇到错误直接 panic
			case ContinueOnError:
				if len(mfs) == 0 {
					// 没有采集到任何指标,返回错误
					httpError(rsp, err)
					return
				}
				// 否则继续处理已采集的指标
			case HTTPErrorOnError:
				httpError(rsp, err) // 返回 HTTP 错误
				return
			}
		}

        // 注意这里:========> 在 HandlerForTransactional 函数中的核心编码逻辑 <=======
		// 根据请求头协商内容类型(支持 OpenMetrics 或传统格式)
		var contentType expfmt.Format
		if opts.EnableOpenMetrics {
			contentType = expfmt.NegotiateIncludingOpenMetrics(req.Header)
		} else {
			contentType = expfmt.Negotiate(req.Header)
		}
		rsp.Header().Set(contentTypeHeader, string(contentType))

		// 协商并设置响应编码(压缩)
		w, encodingHeader, closeWriter, err := negotiateEncodingWriter(req, rsp, compressions)
		if err != nil {
			if opts.ErrorLog != nil {
				opts.ErrorLog.Println("error getting writer", err)
			}
			w = io.Writer(rsp) // 回退到未压缩写入
			encodingHeader = string(Identity)
		}

		defer closeWriter() // 确保资源释放

		// 设置 Content-Encoding 头(仅在启用压缩时)
		if encodingHeader != string(Identity) {
			rsp.Header().Set(contentEncodingHeader, encodingHeader)
		}

        // 注意这里:========> 创建编码器 <=======
		// 创建指标编码器(支持 OpenMetrics 的 created 时间戳)
		var enc expfmt.Encoder
		if opts.EnableOpenMetricsTextCreatedSamples {
			enc = expfmt.NewEncoder(w, contentType, expfmt.WithCreatedLines())
		} else {
			enc = expfmt.NewEncoder(w, contentType)
		}

		// 错误处理辅助函数(根据配置决定是否终止处理)
		handleError := func(err error) bool {
			if err == nil {
				return false
			}
			if opts.ErrorLog != nil {
				opts.ErrorLog.Println("error encoding and sending metric family:", err)
			}
			errCnt.WithLabelValues("encoding").Inc()
			
			switch opts.ErrorHandling {
			case PanicOnError:
				panic(err) // 遇到错误直接 panic
			case HTTPErrorOnError:
				// 已开始响应,无法修改状态码,只能终止发送
				return true
			}
			// ContinueOnError 模式下忽略错误,继续处理后续指标
			return false
		}

        // 注意这里:========> 编码每个 MetricFamily <=======
		for _, mf := range mfs {
			if handleError(enc.Encode(mf)) {
				return
			}
		}
		
		// 关闭编码器(确保 OpenMetrics 格式的完整性)
		if closer, ok := enc.(expfmt.Closer); ok {
			// 特别是为 OpenMetrics 添加最后的 "# EOF\n" 行
			if handleError(closer.Close()) {
				return
			}
		}
	})

	// 应用超时处理(如果配置了超时时间)
	if opts.Timeout <= 0 {
		return h
	}
	return http.TimeoutHandler(h, opts.Timeout, fmt.Sprintf(
		"Exceeded configured timeout of %v.\n",
		opts.Timeout,
	))
}

2.2、内容协商机制

编码器通过 expfmt.Negotiate(https://github.com/prometheus/client_golang/blob/main/prometheus/promhttp/http.go#L197) 函数根据客户端的 Accept 头选择最合适的编码格式:

// 声明 contentType 变量,用于存储协商后的指标格式类型
var contentType expfmt.Format

// 根据配置决定是否启用 OpenMetrics 格式支持
if opts.EnableOpenMetrics {
    // 使用支持 OpenMetrics 的协商函数,处理 Accept 头中包含的 OpenMetrics 格式
    // 例如:Accept: application/openmetrics-text; version=1.0.0,text/plain;version=0.0.4;q=0.9
    contentType = expfmt.NegotiateIncludingOpenMetrics(req.Header)
} else {
    // 仅支持传统 Prometheus 文本格式
    // 例如:Accept: text/plain;version=0.0.4
    contentType = expfmt.Negotiate(req.Header)
}

关键说明(此逻辑确保了 Prometheus 生态系统中不同版本客户端与服务端的互操作性)

OpenMetrics 支持:通过 opts.EnableOpenMetrics 配置项控制是否支持 OpenMetrics 格式。启用后:

        • 支持 application/openmetrics-text; version=1.0.0 内容类型
        • 支持 # EOF 结束标记
        • 支持 _created 时间戳等扩展字段

内容协商逻辑:

        • 客户端通过 HTTP 请求头 Accept 指定支持的格式
        • 服务端根据自身能力选择最匹配的格式
        • 返回优先级:OpenMetrics > Prometheus Text 0.0.4

典型 Accept 头示例:

Accept: application/openmetrics-text; version=1.0.0,text/plain;version=0.0.4;q=0.9
  • 优先使用 OpenMetrics 格式(权重最高)
  • 其次使用 Prometheus 文本格式 0.0.4

兼容性:

        • 启用 OpenMetrics 支持后,仍能兼容传统 Prometheus 客户端
        • 传统客户端会忽略 OpenMetrics 特有的元数据(如 # EOF)
        • 此逻辑确保了 Prometheus 生态系统中不同版本客户端与服务端的互操作性。

2.3、压缩处理

Prometheus/client_golang 1.22.0 支持多种压缩格式,通过 negotiateEncodingWriter(https://github.com/prometheus/client_golang/blob/v1.22.0/prometheus/promhttp/http.go#L465) 函数处理:

// negotiateEncodingWriter reads the Accept-Encoding header from a request and
// selects the right compression based on an allow-list of supported
// compressions. It returns a writer implementing the compression and an the
// correct value that the caller can set in the response header.
// 从 HTTP 请求的 Accept-Encoding 头中读取客户端支持的压缩格式。
// 根据服务器支持的压缩格式列表(允许列表),选择最合适的压缩格式。
// 返回一个实现了所选压缩格式的写入器,以及一个可以设置在响应头中的编码值,供调用者使用。
func negotiateEncodingWriter(r *http.Request, rw io.Writer, compressions []string) (_ io.Writer, encodingHeaderValue string, closeWriter func(), _ error) {
    // 检查支持的压缩格式列表是否为空
    if len(compressions) == 0 {
        // 如果为空,说明不支持任何压缩,直接返回原始的写入器
        // 编码头值设置为 "identity",表示内容未压缩
        // 关闭写入器的函数为空函数
        // 错误为 nil
        return rw, string(Identity), func() {}, nil
    }

    // TODO(mrueg): Replace internal/github.com/gddo once https://github.com/golang/go/issues/19307 is implemented.
    // 调用 NegotiateContentEncoding 函数,根据请求的 Accept-Encoding 头和支持的压缩格式列表
    // 选择最合适的压缩格式
    selected := httputil.NegotiateContentEncoding(r, compressions)

    // 根据选择的压缩格式进行不同的处理
    switch selected {
    case "zstd":
        // 检查是否已经实现了 zstd 压缩
        if internal.NewZstdWriter == nil {
            // 如果未实现,返回错误信息,提示内容压缩格式未被识别
            // 并列出有效的压缩格式
            return nil, "", func() {}, fmt.Errorf("content compression format not recognized: %s. Valid formats are: %s", selected, defaultCompressionFormats())
        }
        // 调用 internal.NewZstdWriter 函数创建一个 zstd 写入器
        // 返回写入器、关闭写入器的函数和可能的错误
        writer, closeWriter, err := internal.NewZstdWriter(rw)
        return writer, selected, closeWriter, err
    case "gzip":
        // 从 gzip 池(gzipPool)中获取一个 gzip 写入器
        gz := gzipPool.Get().(*gzip.Writer)
        // 重置 gzip 写入器,使其可以用于新的写入操作
        gz.Reset(rw)
        // 返回 gzip 写入器、编码头值为 "gzip"
        // 关闭写入器的函数会先关闭 gzip 写入器,然后将其放回 gzip 池
        return gz, selected, func() { _ = gz.Close(); gzipPool.Put(gz) }, nil
    case "identity":
        // 表示内容未压缩,直接返回原始的写入器
        // 编码头值为 "identity"
        // 关闭写入器的函数为空函数
        return rw, selected, func() {}, nil
    default:
        // 如果选择的压缩格式不在支持的范围内,返回错误信息
        // 提示内容压缩格式未被识别,并列出有效的压缩格式
        return nil, "", func() {}, fmt.Errorf("content compression format not recognized: %s. Valid formats are: %s", selected, defaultCompressionFormats())
    }
}

三、编码器实现细节

3.1、支持的编码方式

从源码分析,Prometheus 1.22.0 支持以下编码格式:

    • 文本格式:text/plain; version=0.0.4; charset=utf-8
    • Protobuf 格式:application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited
    • OpenMetrics 格式:application/openmetrics-text; version=1.0.0; charset=utf-8

3.2、压缩支持

支持的压缩格式:

      • Identity:无压缩
      • Gzip:gzip 压缩
      • Zstd:zstd 压缩(需要导入 _ "github.com/prometheus/client_golang/prometheus/promhttp/zstd")

3.3、错误处理机制

编码器实现了完善的错误处理机制:

// 在 promhttp/http.go 中的错误处理
handleError := func(err error) bool {
    if err == nil {
        return false
    }
    if opts.ErrorLog != nil {
        opts.ErrorLog.Println("error encoding and sending metric family:", err)
    }
    errCnt.WithLabelValues("encoding").Inc()
    switch opts.ErrorHandling {
    case PanicOnError:
        panic(err)
    case HTTPErrorOnError:
        return true
    }
    return false
}

四、实战:自定义 Exporter 中的编码器应用

4.1、基本 Exporter 实现

package main

import (
    "net/http"
    "time"

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

func main() {
    // 定义指标。创建一个名为 demo_counter 的计数器指标,这是 Prometheus 中最基本的指标类型,用于记录单调递增的数值。
    demoCounter := prometheus.NewCounter(prometheus.CounterOpts{
        Name: "demo_counter",
        Help: "A simple demo counter",
    })
    
    // 注册指标。使用 MustRegister 函数将质保注册到 Prometheus 的默认注册表中。如果注册失败的哈,则触发 panic。
    prometheus.MustRegister(demoCounter)
    
    // 模拟指标增长。启动一个 goroutine 来每秒增加计数器的值。Inc()方法将计数器加1,Inc()方法是 Prometheus 客户端库中计数器(Counter)类型的一个方法,用于将计数器的值增加1。
    go func() {
        for {
            demoCounter.Inc()
            time.Sleep(time.Second)
        }
    }()
    
    // 注册 HTTP handler
    http.Handle("/metrics", promhttp.Handler()) // 使用 promhttp.Handler()创建一个处理 Prometheus 指标请求的 HTTP 处理器,并将其注册到 /metrics 路径。
    http.ListenAndServe(":8080", nil)
}

4.2、编码器工作流程分析

当客户端请求 /metrics(https://github.com/prometheus/client_golang/blob/v1.22.0/examples/middleware/main.go#L41)端点时,编码器的工作流程如下:

    1. 请求解析:解析客户端的 Accept 头
    2. 格式协商:通过 expfmt.Negotiate 选择最佳编码格式
    3. 压缩协商:通过 negotiateEncodingWriter 选择压缩格式
    4. 指标收集:调用 reg.Gather() 收集所有指标
    5. 编码输出:使用选定的编码器将指标序列化并输出

4.3、自定义编码器实践

如果需要支持自定义格式,可以通过实现 expfmt.Encoder(https://github.com/prometheus/client_golang/blob/v1.22.0/prometheus/promhttp/http.go#L217) 接口:

// 自定义 JSON 编码器示例
type jsonEncoder struct {
    w io.Writer
}

func (j *jsonEncoder) Encode(mf *dto.MetricFamily) error {
    // 将 MetricFamily 转换为 JSON 格式
    data, err := json.Marshal(mf)
    if err != nil {
        return err
    }
    _, err = j.w.Write(data)
    return err
}

func (j *jsonEncoder) Close() error {
    return nil
}  

五、编码器选型与最佳实践

5.1 格式选择建议

    • 调试场景:优先使用文本格式(text/plain),便于人工阅读
    • 生产环境:推荐 Protobuf 格式,提升传输效率
    • 兼容性需求:若需与支持 OpenMetrics 的系统集成,选择 OpenMetrics 格式

5.2 性能优化

    • 高并发场景:避免频繁创建编码器实例,可通过单例模式复用
    • 大规模指标:优先使用 Protobuf 压缩格式减少网络开销
    • 压缩选择:zstd 压缩比 gzip 更高,但 CPU 消耗也更高

5.3 版本兼容性

    • Prometheus 1.22.0 对文本格式的默认版本为 0.0.4
    • OpenMetrics 支持需要显式启用 EnableOpenMetrics 选项
    • 压缩格式需要相应的导入包支持

六、Prometheus Exporter 编码器源码架构

6.1 核心组件

    • promhttp.HandlerFor:主要的 HTTP 处理器工厂函数
    • expfmt.Negotiate:内容格式协商
    • expfmt.NewEncoder:编码器创建
    • negotiateEncodingWriter:压缩格式协商

6.2 设计模式

    • 工厂模式:通过 HandlerFor 创建处理器
    • 策略模式:根据请求头选择不同的编码策略
    • 装饰器模式:通过中间件添加功能(如压缩、监控)

6.3 扩展点

    • 自定义编码器:实现 expfmt.Encoder 接口
    • 自定义压缩:实现 internal.NewZstdWriter 函数
    • 自定义错误处理:通过 HandlerOpts 配置
posted @ 2025-07-26 22:00  左扬  阅读(45)  评论(0)    收藏  举报