Prometheus 原理和源码分析


原文链接: Prometheus 原理和源码分析

Prometheus(下称 Prom)是一个基于 Metrics 的监控系统,与 Kubernetes 同属 CNCF(Cloud Native Computing Foundation),它已经成为炙手可热的 Kubernetes 生态圈中的核心监控系统,越来越多的项目(如 Kubernetes 和 etcd 等 )都加入了丰富的 Prom 原生支持,从侧面体现了社区对它的认可。

Prom 提供了通用的数据模型和便捷的数据采集、存储和查询接口,同时基于 Go 实现也大大降低了服务端的运维成本,可以借助一些优秀的图形化工具(如 Grafana)可以实现友好的图形化和报警。

实际使用中笔者发现工程人员中普遍存在对 Prom 中客户端数据模型和 PromQL 计算逻辑的误解,不但很难将数据中的价值发挥出来,还可能出现误判。本文分析了客户端和服务端的部分源码实现,介绍了客户端数据模型和 PromQL 计算逻辑,希望能为基于 Prom 的监控平台提供一些启发。
Go 客户端

Go 客户端实现了 Prom 数据协议,定义了时序数据模型和采集监控数据的接口(源码分析基于 https://github.com/prometheus/client_golang/tree/661e31bf844dfca9aeba15f27ea8aa0d485ad212)。
整体结构分析

无论是 Prom 拉取 (pull) 数据,还是客户端主动推送 (push) 数据,都可以从 Collector 获取 Metric 的定义,图 1.1.1 中 UML 图描述了 Go 客户端中主要结构和接口之间的关系。

图 1.1.1 Go 客户端 UML 图

先看 Collector 接口的定义,如图 1.1.2 所示。

图 1.1.2 Collector

Collector 中 Describe 和 Collect 方法都是无状态的函数,其中 Describe 暴露全部可能的 Metric 描述列表,在注册(Register)或注销(Unregister)Collector 时会调用 Describe 来获取完整的 Metric 列表,用以检测 Metric 定义的冲突,另外在 github.com/prometheus/client_golang/prometheus/promhttp 下的 Instrument Handler 中,也会通过 Describe 获取 Metric 列表,并检查 label 列表(InstrumentHandler 中只支持 code 和 method 两种自定义 label);而通过 Collect 可以获取采样数据,然后通过 HTTP 接口暴露给 Prom Server。另外,一些临时性的进程,如批处理任务,可以把数据 push 到 Push Gateway,由 Push Gateway 暴露 pull 接口,此处不赘述。

客户端对数据的收集大多是针对标准数据结构来进行的:

Counter:收集事件次数等单调递增的数据
Gauge:收集当前的状态,比如数据库连接数
Histogram:收集随机正态分布数据,比如响应延迟
Summary:收集随机正态分布数据,和 Histogram 是类似的

每种标准数据结构还对应了 Vec 结构,通过 Vec 可以简洁的定义一组相同性质的 Metric,在采集数据的时候传入一组自定义的 Label/Value 获取具体的 Metric(Counter/Gauge/Histogram/Summary),最终都会落实到基本的数据结构上,这里不再赘述。
Counter 和 Gauge

Gauge 和 Counter 基本实现上看是一个进程内共享的浮点数,基于 value 结构实现,而 Counter 和 Gauge 仅仅封装了对这个共享浮点数的各种操作和合法性检查逻辑。

先看 Counter 中 Inc 函数的实现,图 1.2.1 为 value 结构中 Inc 函数的实现。

图 1.2.1 value.Inc

value.Add 中修改共享数据时采用了“无锁”实现,相比“有锁 (Mutex)”实现可以更充分利用多核处理器的并行计算能力,性能相比加 Mutex 的实现会有很大提升。图 1.2.2 中是 Go Benchmark 的测试结果,对比了“有锁”(用 defer 或不用 defer 来释放锁)和“无锁”实现在多核场景下对性能的影响。

图 1.2.2 Go Benchmark 测试结果

注意图 1.2.2 中针对“有锁”的实现,进行了两组实验,其中一组用 defer 来释放锁,可见在多核场景下“无锁”实现的性能最好也最稳定。

Counter 和 Gauge 中的其他操作都很简单,不赘述。
Histogram

Histogram 实现了 Observer 接口,用来获取客户端状态初始化(重启)到某个时间点的采样点分布,监控数据常需要服从正态分布。

图 1.3.1 Oberver 接口定义

先看通过 Histogram 采集一个 float64 数据的 Observe 方法实现(图 1.3.2)。

图 1.3.2 histogram.Observe

此处每个 bucket 对应的 count 是不互相包含的,bucket 的计数器之和应该等于全局计数器,即 h.count == sum(h.counts) 是成立的。然而为了便于服务端存储和计算,最终服务端收集到的数据是向下包含的,这是在 histogram.Write(图 1.3.3)中实现的。

图 1.3.3 histogram.Write 实现

图 1.3.4 中用表格形式给出了 Histogram 采集和整理数据的过程。

图 1.3.4 Histogram 采集整理数据过程实例

Histogram 在客户端也是无锁的,因为每个采样点只更新一个具体 bucket 内的 Counter(float64),因此客户端性能开销相比 Counter 和 Gauge 而言没有明显改变,适合高并发的数据收集。

图 1.3.5 为 Go 客户端的 Histogram 默认 bucket 设置,可以用来采集 Web 服务响应时间,实际应用中通常需要为监控对象选择合理的 buckets,buckets 应设置为正态分布中常用的分位点。

图 1.3.5 histogram 默认 buckets 设置

Summary

Summary 是标准数据结构中最复杂的一个,用来收集服从正态分布的采样数据。在 Go 客户端 Summary 结构和 Histogram 一样,都实现了 Observer 接口(图 1.3.1)。

Summary 中 quantile 实际上是正态分布中的分位点 ,如图 1.4.1 所示,图中的实心圆点分别代表 [0.025 0.25 0.50 0.75 0.975] 分位点,图 2.1.10 中 0.5 分位点的采样数据为 0,而 0.975 分位点的采样值为 2,这说明采样数据的绝大部分的峰值都在 2 附近。

图 1.4.1 随机正态分布数据的 Quantile 逼近仿真

由于 Summary 结构的客户端实现相比其他几个结构而言复杂一些,先看一下 summary 结构的定义(图 1.4.2)。

图 1.4.2 summary 定义

Summary 会将采集到的数据经过正态分布逼近得出对应分位点的采样数据,数据流如图 1.4.3 所示。

图 1.4.3 Summary 数据流

接下来看 summary.Observe 实现,图 1.4.4 和 1.4.5 中加入了代码逻辑的注解。

图 1.4.4 summary.Observe 实现

图 1.4.5 summary.asyncFlush 的实现

再看 summary.Write 实现,图 1.4.6 中加入了代码逻辑的注解。

图 1.4.6 summary.Write 实现

集成优化建议

客户端集成时,需要关注采集监控数据对程序性能和可靠性的影响,同时也需要关注数据完备性,即采集到的数据应完整、正确地反映监控对象的状态和变化,笔者提出以下两点思路:

为监控对象定义“恰当”的监控数据集,“恰当”要求在详细设计阶段梳理并细化整个监控对象,不引入多余的监控数据,也不应该出现监控盲点
根据每个监控数据的实际情况选择合理的数据结构

Go 客户端为 HTTP 层的集成提供了方便的 API,但使用中需要注意不要使用 github.com/prometheus/client_golang/prometheus 下定义的已经 deprecated 的 Instrument 函数(如图 1.5.1 中注释部分),除了会引入额外(通常不需要)的监控数据,不仅会对程序性能造成不利影响,而且可能存在危险的 race(如计算请求大小时存在 goroutine 并发地访问 Header 逻辑)。

图 1.5.1 InstrumentHandler(Deprecated)

Go 客户端在后续的版本中给出了优化的 API,即 github.com/prometheus/client_golang/prometheus/promhttp 下的实现,为 HTTP Handler 的不同监控数据定义了独立的 InstrumentHandlerXXX(图 1.5.2),让监控数据集保持灵活可控,完全规避了图 1.5.1 中提到的几个问题。

图 1.5.2 promhttp 下的 InstrumentHandlerXXX

另外一个难点是根据实际使用场景,从 Histogram 和 Summary 中作出选择以及给予合理的初始化配置。

Histogram 常使用 histogram_quantile 执行数据分析, histogram_quantile 函数通过分段线性近似模型逼近采样数据分布的 UpperBound(如图 1.5.3),误差是比较大的,其中红色曲线为实际的采样分布(正态分布),而实心圆点是 Histogram 的 bucket(0.01 0.25 0.50 0.75 0.95),当求解 0.9 quantile 的采样值时会用 (0.75, 0.95) 两个相邻的的 bucket 来线性近似。

图 1.5.3 histogram_quantile 逼近正态分布

而 Summary 的分位点是客户端预先定义好的,已知分位点可以求该分位点的采样值,相比 Histogram 而言能更准确地获取分位点的采样值。

当然,Summary 精度高的代价是在客户端增加了额外的计算开销,而且 Summary 结构有频繁的全局锁操作,对高并发程序性能存在一定影响,图 1.5.4 是对 Histogram 和 Summary 分析 Benchmark 的结果,Observe 和 Write 操作都有着指数级别的差异,需要结合实际应用场景作出选择。

图 1.5.4 Histogram 和 Summary Benchmarking

PromQL

PromQL 是 Prom 中的查询语言,提供了简洁的、贴近自然语言的语法实现时序数据的分析计算。

表达式(Expression)是其中承载数据计算逻辑的部分,对表达式的准确理解有助于充分利用 promql 提供的计算和分析能力,本节先结合一个相对复杂的表达式来介绍 PromQL 的计算过程,然后对部分有代表性的函数实现进行了源码分析。
计算过程

PromQL 表达式输入是一段文本,Prom 会解析这段文本,将它转化为一个结构化的语法树对象,进而实现相应的数据计算逻辑,这里选用一个相对比较复杂的表达式为例:

sum(avg_over_time(go_goroutines{job="prometheus"}[5m])) by (instance)

上述表达式可以从外往内分解为三层:

sum(…) by (instance):序列纵向分组合并序列(包含相同的 instance 会分配到一组)
avg_over_time(…)
go_goroutines{job="prometheus"}[5m]

调用 Prom Restful API 查询表达式计算工作流如图 2.1.1 所示,请求数据的时候给出的 step 参数就是这里的 interval,它设定结果中相邻两个点的间隔,对 promql 的每次 evaluator 都是针对某个确定的时间点和 statement 来计算的,得到一个 vector(时间戳相同的向量)。Prom 可以将异构(时间戳不一致)的多维时间序列经过计算转化为同构(时间戳一致)的多维时间序列。

图 2.1.1 Restful API 查询表达式计算工作流

先看 go_goroutines{job="prometheus"}[5m] 的计算,这是一个某个时间点的 MatrixSelector 对象(图 2.1.2)。

图 2.1.2 MatrixSelector 计算 go_goroutines{job="prometheus"}[5m]

此处 iterator 是序列筛选结果的顺序访问接口,图 2.1.2 中获取某个时间点往前的一段历史数据,这是一个二维矩阵 (matrix),进而由外层函数将这段历史数据汇总成一个 vector(图 2.1.3)。

值得一提的是,很多函数(如 rate)都需要传入 matrix,尽管如此,这些函数的输出依然是针对某个时间点的 vector,它仅仅是在计算某个时间点的 vector 时考察了一部分历史数据而已。

图 2.1.3 avg_over_time 实现

最后来看关键字(keyword)sum 的实现,这里注意 sum 不是函数(Function),图 2.1.4 给出了所有关键字列表。

图 2.1.4 关键字列表

sum 关键字的完整语法比较复杂,本文中只介绍例子中给出的 sum(…) by (instance)。

图 2.1.5 sum(…) by (instance) 实现

至此输出某个时间点的结果向量,整个表达式的计算过程在 Excel 中集中展示如图 2.1.6 所示。

图 2.1.6 sum(avg_over_time(go_goroutines{job="prometheus"}[5m])) by (instance) 计算过程

PromQL 有三个很简单的原则:

任意 PromQL 返回的结果都不是原始数据,即使查询一个具体的 Metric(如 go_goroutines),结果也不是原始数据
任意 Metrics 经过 Function 计算后会丢失 __name__ Label
子序列间具备完全相同的 Label/Value 键值对(可以有不同的 __name__)才能进行代数运算

特别强调一些,如 2.1.1 所述,PromQL 在计算时使用的等距 interval 时间点,每个 interval 时间点的结果都是利用附近的采样点经过某种形式的估算或近似得到的,所以在 Prom 中提诸如“1:28:07 AM 发生了 113 次某种事件”是不准确的,PromQL 所有计算结果都存在误差。

有意思的是,在 Prom 中对多维时间序列进行代数运算时,不需要严格检查两边的矩阵一致性,因为 PromQL 只会处理相同 Label/Value 的序列之间的代数运算,图 2.1.7 中对两个不相关的 Metric 进行了代数运算,来说明代数运算的基本原理,这在一些以“数据库”为核心的系统中,如 influxdb,涉及跨表运算,无论是表达式复杂度还是计算性能都会有影响。

图 2.1.7 序列的代数运算

最后需要特别提的一点是,PromQL 表达式计算的原始数据集是共享内存空间的,但计算的中间结果是不共享内存空间的,所以从优化内存占用的角度来看,应该将常用的表达式持久化成 Metric,减少动态计算过程,让内存使用做到可控,这可以借助 Recording Rules 完成 。
部分函数(Function)实现

Prom 提供了丰富的函数(Function)库来对数据做复杂分析,本节通过介绍几个有代表性的函数实现来介绍其用途,希望能帮助读者准确理解表达式计算结果背后的工程含义。

delta/rate/increase

delta/rate/increase 背后共享了相同的计算逻辑(图 2.2.1),仅仅是参数不同。

图 2.2.1 delta/rate/increase 函数入口

来看 extrapolatedRate 实现(图 2.2.2),基于线性外插算法估计了 interval 时间点的采样值增量,Prom 实现中大量使用了线性插值。基本原理很简单,计算 range 范围内采样点头尾斜率,然后线性延伸至实际 interval 时间点。

图 2.2.2 extrapolatedRate

特别提一下此处的两个参数 isCounter 和 isRate,其中 isCounter=true 说明数据需要保证单调递增,当 Counter 的客户端重启后,数据会归零,出现非单调递增的数据,那么 isCounter 可以控制是否对该数据进行修正;isRate=true 用来对数据做采样范围内的均值,其结果表征当前时间点一秒内的采样值增量(秒级别增量)。

现在回头看图 2.2.1 中 delta/rate/increase 的参数就很明朗了(表 2.2.1)。

Function

isCounter

isRate

delta

false

false

rate

true

true

increase

true

false

可见 delta 在处理数据时,不假设数据单调递增(isCounter=false),适合用来处理 Gauge 数据;而 increase 适合处理 Counter 数据,并获取 range 范围内增量;rate 适合处理 counter 并获取 range 范围内的秒级增量。

XXX_over_time

XXX_over_time 实现 range 范围内数据的横向汇总,即采用 range 范围内的一定量历史数据估算当前时间点的值,其中 XXX 可以是 avg/sum/max/min 等动词,图 2.2.3 中为 XXX_over_time 中的函数 。

图 2.2.3 XXX_over_time 涉及的函数

由于它们的区别仅仅是对 range 内数据进行横向汇总时的计算方式不同,此处不做一一介绍,只关注其中的 avg_over_time 实现(图 2.2.4)。

图 2.2.4 avg_over_time

avg_over_time 的核心逻辑在 aggrOverTime 中实现,见图 2.2.5。

图 2.2.5 aggrOverTime

XXX_over_time 常用来做数据平滑,过滤数据中的异常点,其中 avg_over_time 就是常见的“滑动窗口平均”,在信号处理中为一种低通滤波器实现。

histogram_quantile

histogram_quantile(图 2.2.6)是 Prom 中比较难以理解的函数之一,可以根据 Histogram 估计估算采样数据在某个正态分布分位点的值(实际上估计的是 Upper Bound,即上限)。

图 2.2.6 histogram_quantile

估算 quantile 采样值逻辑在 bucketQuantile(图 2.2.7)函数中实现。

图 2.2.7 bucketQuantile

总结

Prom 是一种典型的基于 Metric 的监控系统,Metric 是多维时序数据分析在工程中的一种表现形式。社区中常将 Kubernetes 和 Prometheus 放到一起讨论,它的设计理念和 Kubernetes 也如出一辙:二者都为特定问题提出了标准或协议,为终端用户提供了易用的接口,专注于提供领域价值。

Prom 数据采集主要是通过 pull 模型实现的,主动从客户端拉取数据,减少了监控对象对外部系统的依赖,这种模型下监控对象只需维护少量客户端数据,保持可控、简单的实现,降低了维护复杂客户端逻辑的风险。另外,Prom 为一些临时存在的进程,如批处理任务,提供了 Push Gateway,这些客户端可以将数据 push 到 Push Gateway 中,然后由 Push Gateway 提供 pull 接口将数据暴露给 Prom Server。

相比 Prom,常见的 Metric 监控方案(如 InfluxDB 的 metrics 客户端实现 https://github.com/rcrowley/go-metrics )都是 push 模型,在客户端需要维护采样数据生命周期(如长时间没有存储成功的数据需要丢弃等),还需要避免客户端在数据采集和存储过程中可能出现的资源泄漏。

此外,PromQL 是 Prom 中一个争议和亮点并存的点,它提供了友好的、贴近时序数据语义的语法,对时序数据分析有着丰富的支持,如 Prom 考虑了 Counter 这种单调递增数据由于客户端反复重启导致数据归零的问题,Prom 中很多函数在计算的时候就对这样的数据进行了容错,对数据分析完全透明,极大地提升了易用性;同时 PromQL 提供了 histogram_quantile 根据 Histogram 来估算 quantile 值的计算支持,让 quantile 在 Prom 端计算可以降低客户端带来的额外性能负担。

总之,Prom 数据模型、分析计算接口的设计上都有着良好的一致性和扩展性。基于 pull 的数据采集模型一方面降低了客户端复杂度和对外部系统的依赖,另一方面也让客户端实现自由扩展。反观很多基于 push 模型的监控系统实现,瞬间扩展可能使监控系统服务端出现性能瓶颈,波及整个系统;还有 PromQL 简洁的接口让复杂的时序数据分析变得直观,很多工程上需要处理的数据预处理 Prom 都已经内置了,减少了数据预处理成本。
关于作者

杨谕黔,FreeWheel 基础架构部 高级软件工程师。 目前主要从事服务化框架、容器化平台相关的研发与推广。关注和感兴趣的技术主要有 Golang, Docker, Kubernetes 和它们周边生态等。

`