看啥推荐读物
专栏名称: 绝师
DBA
目录
相关文章推荐
卤猫  ·  恋人·  昨天  
卤猫  ·  日常之物·  昨天  
今天看啥  ›  专栏  ›  绝师

Kubernetes 监控神器 Prometheus 源码分析

绝师  · 掘金  ·  · 2018-11-05 05:41

文章是杨谕黔写在今年2月份,虽然9个月过去,但还是非常值得大家一阅,同时Prometheus也于今年8月份从CNCF毕业;

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 和它们周边生态等。

推荐阅读




原文地址:访问原文地址
快照地址: 访问文章快照