魔改prometheus打通云原生单节点的查询告警通道

559 阅读16分钟

最近在公司独立一人做了一个项目,还蛮有意思,一个多月设计和实现,一个月优化内存,记录分享下。

背景

为了完善QOS, 需要worker节点上的管控资源的agent(后面称为guard)获取当前节点的一些资源数据或者可以被抓取的业务指标来做出相应的行为。而且这样的决策行为需要尽量和集群中心节点的控制器保持一致,也就是说配置的一条规则可以让guard和调度器两者都能理解。所以我们需要一个可以采集数据、提供查询告警接口、有着丰富语义的告警规则引擎的一个组件。

组件实现的功能

在这里对我们的这个组件提出几点要求:

  • 自身可以collect数据
  • 可以scrape数据
  • 提供一定的存储能力
  • 支持查询metrics
  • 支持告警推送

这个组件我们就先称为exporter。

这乍一看像是prometheus + node-exporter ,不过既然prometheus这些功能基本都有,我们可以基于它来修改,补强我们所需的功能。

为何不直接查询prometheus

主要考虑以下几点:

  • 时延。不光是网络时延,还得算上collector (比如node-exporter)的采集时延、prometheus scrape时延、prometheus recording rule 和 alert 的计算时延。
  • 给prometheus带来压力。一个集群如果上千个节点,单点部署的promethues必然支撑不住,得做水平扩展。这又会带来一系列新的问题。

为何不水平扩展prometheus

主要考虑以下几点:

  •  需要维护新的组件,比如thanos。(主要是不会,像prometheus官方支持federal模式,同样的也得花时间学习)
  •  组网问题。如果一切都是无状态的,直接LB,这个问题可能没那么重要。比如告警通知这块,如果时效性要求没那么高,事先配好recording rule,然后让guard去轮询,雀食也能用。但是让prometheus来异步通知是不是会更优雅一点,包括guard的编码和维护上也会更简单一点?那么这就成了有状态的了,要么某个逻辑集群的promethues需要知道每个节点的存在,要么guard需要知道自己该连到 哪个prometheus上。

有状态的告警通知难以维护

  • 按上述,如果让prometheus去管理自己需要通知多少个guard,简单一点直接用alert rule去配置即可,每个guard都得注册进alert rule。但是每个guard只关心本节点的告警(每一个guard都收到其他所有节点的告警,惊群效应副作用略大),而prometheus的notifier是所有告警不加区分的推送,所以得做一个中间件alert manager,让它去处理分发告警(又得多维护一个组件,问题变得更复杂起来)。
  • 同样地,如果是让guard知道自己该连接哪个prometheus,无非就是这些重复低效的配置和维护工作转移到guard这边。 

如果试着让节点的事情在节点内部去解决,问题或许可以简化很多,不存在时延压力性能问题,也不用考虑复杂的分布式设计。

Prometheus

在这里对prometheus的设计实现做一些简单的介绍。感兴趣可以继续参阅大神们的分享以及prometheus的源码。

Ganesh Vernekar 的 tsdb 系列文章

ganeshvernekar.com/blog/promet…

Fabian Reinartz在github上的文章,原文已失效,这里是快照

web.archive.org/web/2021080…

架构

图片

API

  • Web api -主要是提供查询和管理功能接口,主要服务于页面。

  • Remote Write api- prometheus 以agent模式启动,把数据读写放到实现了remote write api的远端组件,相当于一个SPI。

  • Federal api,用于官方提供的水平扩展方案-联邦模式。 

Scrape Manager

简单来说就是按照配置文件对目标地址端口的metrics数据进行爬取。目标必须是http/https的接口,配置的规则很丰富,可以玩些骚的,比如relabel之类的。可以基于默认的或者discovery来编写规则,也可以自己实现discovery。

Discovery

对于Scrape target的服务发现,常用的一个就是 kubernetes, 对于在annotations中打了指定tag的pod, prometheus 会按照规则去爬取并处理数据,存入TSDB中。 

Notifier Mgr

如果一个服务实现了alert SPI接口,可以将其写入配置文件注册成为Alert Manager。Notifier Mgr会在告警产生时,将告警发送给这些Alert Manager。

Rule Mgr

Rule 分为alert rule和recording rule。两种rule都是定时器定期执行。 

  • Alert rule 即满足某个expr 则产生告警。
  • Recording rule 可以将一些需要查询聚合的指标,定期查询,并以新的指标的形式写入tsdb,这样在使用方来查这个metrics的时候,数据已经准备好了,不用每次被动请求过来再的去算,减少相应时间并且把计算压力在平时平摊掉。 

Query Engine

语法分析和查询引擎,能将promql通过parser转换成实际的query。

DB

也就是TSDB,控制存储的读写逻辑,在tsdb成员变量中,head负责通过series来管理多个chunk(实际存储单元)。WAL (wrie ahead log,新版本中更名为WLog),类似于bin log,用来做内存中数据(Mem Chunk)的check point。后面会对这块进行详细一点的介绍。

Storage

Storage是实际的存储层,分为以下三类

  • Mem Chunk,内存存储,存储最新的数据。这部分数据因为易失性,所以用WAL来做check point,在重启后能restore回上次check point点。

  • Mmap file,相对较老的数据存储在硬盘上,减少内存压力。利用内核的能力映射出mmap file在用户态内存中,相比于普通文件读写减少了内核-用户态空间的拷贝过程,动态加载进内存开销不大。

  • Disk file,过老的数据,持久化在硬盘上。

存储过程

简要介绍

这里我就简要根据 Ganesh Vernekar的博客Prometheus TSDB来介绍数据存储过程。

tsdb概览

图片

Head block是db的内存存储部分;灰色block是硬盘上的持久化存储部分,而且这部分是不可变的;我们有一个周期性写入的WAL(Write-Ahead-Log),它用来做checkpoint。一个样本(粉色)首先进入Head中,并且在内存中保持一段时间,随后便会刷入mmap映射的文件中。当这些mmap文件或者内存存储的chunk到达一定时间后,他们会持久化到磁盘上。

Head中的样本的生命周期

图片

样本存储在名为chunk的压缩单元。一个新的样本会被写入活跃的chunk(红色部分),这也是唯一我们可以写入活跃数据的地方。这里写入基于facebook Gorilla论文的实现,使用了差值算法压缩,简单来说就是后面的数据存储是该数据和前一条数据的异或运算的结果。这样一个时间戳-value pair(t,v) (这里并不存储label),一个int64和float64总共16字节的数据,平均下来只需要1.3字节。

当commit样本到chunk中,我们也将它周期性记录进磁盘上Write-Ahead-Log(WAL),以便在宕机后,我们可以恢复内存中的数据。

图片

一旦这个chunk容量到达120或者最年轻和最老的样本年龄差大于2小时,会生成新的chunk来替代旧的chunk。

编号为1的黄色区块填满了红色区块,红色区块会被丢弃并重新创建。

图片

一旦生成了一个新的chunk,旧的chunk会被刷入硬盘,并且会被mmap映射进用户态内存。借助mmap我们可以动态地将我们需要的chunk读进内存。

图片

相似的,新的样本到来,生成新的chunk。

图片

它们都会刷入磁盘和进行mmap映射。

图片

假以时日,Head会变成上面这种状态。红色区块满了,且Head中有6h的数据。

图片

图片

当Head中的数据年龄达到6h,那么前2h的数据会被持久化入磁盘,并且WAL会被切到新的checkpoint(图中没有画出)

相关代码

数据存储过程由tsdb中的head控制,实际上真正串起整个流程的是head的成员变量series(stripeSeries)。

通过相关几个结构体的声明便可看出一二,省去了流程不太相关的成员变量声明。

type stripeSeries struct {  series                  []map[chunks.HeadSeriesRef]*memSeries   hashes                  []seriesHashmap                                            }
type memSeries struct {  mmappedChunks []*mmappedChunk  headChunk     *memChunk         }
type memChunk struct {  chunk            chunkenc.Chunk  minTime, maxTime int64}
  • 前面所讲的最小存储单元chunk在内存里实际就是memChunk,minTime和maxTime声明了该chunk所涵盖数据的时间窗口。实际的读写实现是memChunk的成员变量chunk去负责,chunk一般是XORChunk,这里不做展开,实际上是一个byte数组的封装,从第3位(index 2)开始写,写规则遵照前文所提的异或差值算法。

  • memSeries的成员mmappedChunks维护mmapChunk的数量,如果超过5个,则会丢弃数组首位元素;成员headChunk就是内存存储的chunk,在每次执行append去写入数据时会对该chunk做检查,如果超过时间窗口,则会丢弃原chunk并重新生成一个。

  • 在写入一个metrics时,通过stripeSeries找到该metrics该写/读入哪个memSeries,stripeSeries负责维护label和chunk的映射关系。其成员series和hashes的size相同,默认是2<<14,即16384。根据其label计算出的hash,并将hash和size取模,得到i,对成员hashes中第i位元素进行遍历,如果其label和metrics的label一致则用该memSeries写/读;否则会新建一个memSeries,并写进成员series的数组索引i处的map中,写进该map的key则是memSeries的refId(该refId由atomic自增生成,实际上也表示该map当前的size大小),写入series成功后,会同步写入hashes中。hashes和series可以看做是不同维度的索引,在能知道refId的情况下,通过series去查找会更快,在只知道labels的hash值情况下,则可以通过hashes去查找。在gc过程中,tsdb会遍历hashes中过期的memSeries,删除其在hashes和series中的引用,等待go运行时去真正执行回收。

Exporter

这里主要简述设计和实现,以及对原生prometheus修改的地方,因为代码属于公司,这里尽量将实现原理说清楚,并补充一些脱敏伪代码。

设计

回顾我们对exporter的能力的抽象,并结合prometheus的架构和实现细节。我们得做一些删改和取舍。

图片

整体架构图如上。

guard和exporter直接通过grpc来查询指标和实现告警的推送

exporter会去爬取kubelet的10250端口/pods来获得pod数据,也会爬取10250的/metrics/cadvisor来获得容器级别的metrics。

exporter本身采集的数据,部分可以过滤后通过本pod的 http服务暴露给中心集群的prometheus,以便控制器获取metrics并做出集群上的action。

Grpc

提供Grpc的查询和订阅告警接口,基于我们的场景,客户端是guard,都是在一个节点上的两个pod,挂载hostpath然后创建Unix Domain Socket即可,减少tcp协议栈的打包拆包过程。订阅告警推送考虑使用grpc bi-direction stream,客户端(比如guard)在使用stream时,发送一个消息,包括唯一标识符和订阅的规则(也就是原生的recording rule和alert rule);在服务端收到消息后就会new一个alert manager对象,在rule manager计算出告警的时候,会推送告警给这个alert manager(但是需要做下过滤处理,如果非本alert manager订阅的告警,不要推送),再通过stream发送给客户端;客户端发现stream断开或者收到错误的时候,会阶梯重试建立bi-direction stream;同样的,服务端在客户端断开连接的时候,将本次注册的alert manager和相关的rule注销掉。编码实现可能稍微复杂点,但总体来说还是简单的。

WebApi

这里基本不用改啥,甚至为了方便调试和可视化查询,我们可以将原生的prometheus中的web目录下的js代码编译后,连同静态文件一起丢到镜像里去,这样我们可以直接用这个可视化界面去定位问题。

Discovery

切合我们的使用场景,prometheus提供的诸多discovery plugin是不必要的,无需install (也就是不引用这些包)。

为了做的更开放一点,我们可以保留prometheus的kubernetes的discovery,以便后续扩展可以抓取必要的pod的mertcis(比如,如果后面根据业务的qps变化做响应)。

但是原有实现是借助kube-clientset的informer,这样会对集群的kube-apiserver造成压力,我们可以给本pod加上nodes/proxy的rbac去请求本节点kubelet的10250端口,这样可以通过定时器和inotify去watch 不同QOS目录和每个pod的cgroup 来触发对kubelet的请求以获得本节点的pod信息以更新scrape target。还是那句话,本节点的事情,本节点解决。

Scrape Mgr

这里没啥太多要说的,主要是内存上的坑。后面内存优化的部分会重点说道说道。

有一点是我们可以在配置scrape rule的时候修改规则,比如原生的是爬取pod-annotations中包含prometheus.io/scrape:true的pod,我们可以自定义成我们需要的annotation,比如exporter/scrape:true,这样可以和中心集群的prometheus爬取规则隔离,也是利用原生prometheus规则灵活的一个点。

还有就是,我们需要一些容器或者pod级别的数据,但是kubelet默认就集成了cadvisor,我们没必要再去集成一遍,开销挺大的,这里在配置上多配置一个规则去爬取kublet的metrics/cadvisor。同样地,本pod的rbac得配置nodes/metrics的rbac。

Notifier Mgr

主要是配合grpc的注册订阅做一些相应调整,实现按订阅进行告警。

Rule Mgr

同样地,主要是配合grpc的注册订阅做一些相应调整,实现按订阅进行告警。

Collector

built-in的样本采集模块,对于节点维度的数据,基本复用node-exporter的代码即可。然后需要注意的是,这里我们并不需要把所有节点都上报给中心节点的prometheus,所以这里可以wrap一下,重新实现gather方法,创建一个规则,屏蔽指定的namespace(也就是前缀)的metrics,不进行上报。

func (r *wrapper) Gather() ([]*dto.MetricFamily, error) {  mfs, err := Reg.Gather()  if err != nil {    return nil, err  }  res := make([]*dto.MetricFamily, 0, len(mfs))  for i := range mfs {    mf := mfs[i]    if !matchFn(mf.GetName()) {      continue    }    res = append(res, mf)  }
  return res, nil}

再有就是,原生prometheus只会将scrape的数据写入tsdb,需要做一些修改将collector的数据写入tsdb以便查询。

func Run(ctx context.Context) {  var (    ctx,cancel = context.WithCancel(ctx)    tick = time.NewTicker(time.Second * 5)  )  defer cancel()  defer tick.Stop()  for {    select {    case <-tick.C:      mfs, err := Reg.Gather()      if err != nil {        continue      }
      for _, mf := range mfs {        app := r.app.Appender(ctx)        _ = app.Append(0, lbls, appendee.Ts, appendee.Value)      }    case <-ctx.Done():      return    }  }}

DB

回到最初的问题,exporter是对同一节点的guard提供metrics以便其作出相应的action,相对于中心集群的控制器的决策路径,这肯定是相对来说比较快的路径。如果一个metrics需要数十分钟的采样才能做出决策,那么这个觉得没有必要让端上agent去处理,中心节点的controller去做就行;再者exporter只负责本节点的数据,没有那么多的数据需要存储。所以exporter只需要存储分钟级别的数据即可,比如3分钟。

可以简单计算下,基于前文提到异或差值算法,在平均情况下,可以让16 Byte的(t,v)数据压缩到1.3Byte。一般scrape周期是15s,也就是说3分钟一个metrics会上报12条,如果一个node上有15个pod,这个水位下kubelet的cadvisor每次上报3K条左右的metrics  ,那么也就是3K1.3B12 也才36KB左右(这里的计算都不包含label)。

这么点数据量,没有存储磁盘的必要,那么mmap file和disk file相关代码可以直接删去;在我们的场景下,宕机重启是可以忍受的,同样不用考虑check point,WAL自然也可以干掉,这样数据可以完全存在内存。

如果按照我说的这么去删改源码,运行后会在web页面上发现所有的metrics每3分钟会消失一次。回顾下前文对数据存储的介绍就能找到原因。

因为chunks是会定时全部丢弃的,再加上我们去掉了mmap和disk file,所以丢完之后就会查询不到。比如一条数据,在0:40写入,在3:00过后,其chunk会被准时丢弃,按理说我们在3:30应该也是能查询0:40的数据,毕竟在三分钟内。这里我想到的解决方案是buddy,顾名思义,就是我们在memChunk和XORChunk之间,用buddy wrap一下。

type buddy struct {  prev, curr Chunk}

一个buddy存储两个XORChunk,每个XORchunk都是三分钟时效。每次需要重建chunk的时候,实际上就是 prev,curr=curr,new 。这样可以使我们在任一时刻都能完整的查到三分钟前数据。不过带来的代价就是(t,v)存储会翻一倍,不过基于前面计算,翻一倍也没啥问题。

配置

最后,还有一点就是,原生prometheus在改动配置后,不会主动reload,需要对其进程发送一个interrupt的signal或者调用web-api /reload接口。所以常见的云原生部署方式都是其配上一个边车容器(side-car)去触发reload。这里我没有使用边车,增加了一个模块,会通过inotify去watch configmap挂载的路径,一旦有write或者create的事件,会主动reload,时效性依赖kubelet更新挂载configmap的速度。不过这里也有坑,就是容器内mount的文件,因为跨文件系统的问题,在修改后等于是先删除再创建,所以还得关注删除事件。

内存优化

介绍到这里,基本上我们的设计实现就已经完成了。润是能润了,功能实现了,就是运行起来挺费内存。

跑了一段时间后,发现内存飚的贼高,跑7天飙升至几百兆,借助pprof神器,发现了如下几个问题。

buddyChunk其实还可以进一步优化

前文提到的buddyChunk,等于冗余了一份样本,我只需要查3分钟的,可实际上我存了6分钟的内存。

如果把这三分钟拆成很多份呢?

比如3分钟拆成6个30s的XORChunk,然后链表形式相连,头读尾写,每次truncate将头部元素移到尾部。等等,这不就是一个ring么?用ring反而编码更简单,所以这里我就改造成了一个ring。

我记得大学学的数据结构课上说环如果辨别头尾最简单是空出一个位置。比如我要存3分钟的样本,那么我就实际存3分半,然后一个环上有7个XORChunk,这样每次truncate就往后挪一格。这样比buddy省了快一半的内存。

原生代码中可以优化的点

pool的使用浪费内存

先简短介绍一下go的pool,基于go的GMP模型,G (goroutine) M(machine thread OS调度线程) P(processor 调度GM关系)。使用pool后,会先从本G中的队列去获取对象,如果为空会去公共队列中获取对象,如果仍为空会去其他P中去偷一个过来,如果还是为空,那么会去生成一个对象(GMP调度过程和这个也比较类似);并且在runtime gc时,优先回收pool中的队列中的对象,而被取出占用的对象不会被回收。

这看上去可以节省alloc的开销(这并不能节省内存资源,反而会浪费内存,因为可能某个时间pool中还有闲置的资源),prometheus在scrape时也是这么做的。

图片

这个pool是prometheus封装的pool list。感兴趣的同学可以翻一下源码 ,这里的意思就是以1000为基准,到100000000为止,以3作为等比生成一个等比的pool数列,其newFunc就是make([]byte,0,n)。这个[]byte数组是用来当做buffer使用,用来存储scrape回来的http resp.body。

首先这个pool list就很大了,大概30000个;其次,因为每次scrape回来的不一定是同样长度,比如这次爬了1000byte(现实中远远不止这个数量级),下次爬取了1001byte,就等于新生成一个长度3000的[]byte数组,新生成的数组在put后会放在pool内,因为pool不会优先gc,这就导致有很大程度的内存浪费;再有就是go的数组append是如果cap不够用了,会以2倍数去alloc 新的数组,然后copy过去,也就是从pool取出1000的长度的数组后,如果实际需要存储长度为1001,那么会自动append数组长度到2000,旧的1000长度的数组不会立马删除,而是等到下次gc才会回收。

就我们的场景而言,不如不用pool,至少不会有浪费太多内存。

scrape过程需要优化

这个点有太多可以聊。其实借助pprof分析,到最后内存大头都是scrape。

http关闭gzip

首先scrape是基于http去爬取数据,然后默认是开了gzip减少传输报文大小。但是gzip压缩后的报文得在本地解压,最后在内存里有一份原长度的[]byte数组和压缩长度的[]byte数组。

图片

prometheus这里默认是开了gzip,我们可以在request中去掉,省了一份内存。

图片

文本解析流程需要优化

原生prometheus在爬取数据后,将http body一次性读出来,并且一次性交给parser去处理。

图片

其实在操作系统的socket通信中,socket中接收的数据会存放在OS的缓冲区,然后内核会唤醒该socket fd下面等待的进程,让其准备去读取数据。

在go中,如果翻看http源码,http resp.Body实际上就是transport (比如TLS,HTTP) wrap了net.Conn(比如tcp),而tcp conn实际上就是交给操作系统去处理的文件描述符。

图片

图片

图片

所以,我们其实可以不用一次性全部读出来;再加上其实在我们的场景,metrics的http文本都是规律的,'\n'字符都是拿来换行的,注释中是不会有'\n'的。所以我们可以借助bufio.Scanner每次读到换行符为止,以及利用io.Pipe愉快的异步编程。

值得注意的一点是,我们得先把# Type和# Help 开头的读出来,然后每碰到一个metrics就拼上上次读到的type和help,然后交给parser去解析,碰到了# Type和# Help 就去更新type和help。

resp, err := s.client.Do(s.req.WithContext(ctx))  cType := resp.Header.Get("Content-Type")  pr, pw := io.Pipe()  defer pr.Close()  go func() {    defer pw.Close()    defer resp.Body.Close()    if _, err := io.Copy(pw, resp.Body); err != nil {      return    }  }()  sc := bufio.NewScanner(r)  var lastHelp, lastType []byte  genParser := func() textparse.Parser {    var b []byte    ok := sc.Scan()    for ; ok; ok = sc.Scan() {        p, err := textparse.New(text, contentType)        if err != nil {        }        return p      }
    }

事实证明,这样做让节省了大量内存,scrape http的处理流程也不再是pprof的大头了。

这里我曾想着去改prometheus的lex/yacc代码,在这些代码里就不用[]byte而直接用io.Reader去处理,实在是功力不够,再加上它的文本解析上一行和下一行相关联,只能作罢。不过目前改成这种方式,效果也很显著了。

加块scrape loop cache的删除过程

scrape loop有一个cache用来存储爬取target的scrape loop中碰到的metrics,如果没有命中则从tsdb中读取然后存入cache,另外超过生命周期则删除。因为我们爬取了kubelet-cadvisor的数据,在大节点上,这cache实在太大,所以这块的优化是加个定时器定期删除,反正兜底是查tsdb,问题也不大。

内存泄露

基本都是因为改源码产生bug导致的内存泄露

**bug1
**

直接贴上prometheus的源码,在gc这里,在执行truncateChunksBefore后会去判断是否需要删除该chunk,如果不需要continue到下一个元素。这里因为我使用了buddy,用prev和curr去处理truncate,而非整个chunk直接丢弃,所以在下面的判断中命中了series.HeadChunk!=nil条件,导致进入continue,从而这个map无限制的resize。

图片

bug2

除了这个bug之外,还有就是前面提到的替换原生的prometheus的kubernetes discovery plugin,这里代替方案是用http client去请求kubelet,这里原本是计划这个client是单例的,疏忽之中写成每次reload config都会生成一个client,并且旧client也没有停止,仍然在调用kubelet接口。

另外可以优化的点是,拿到http resp.body不要一次读出来,而是用json.NewEncoder去读,因为go的原生库会默认用定长的buffer分开读。

其他细节

比如[]byte和string互转,强转等于拷贝一份,hack一点用指针转。

*(*string)(unsafe.Pointer(&b))

优化结果

在15个左右pod的节点上,exporter占用60M左右;在30个左右pod的节点上,exporter占用100M左右;在45个左右pod的节点上,exporter占用140M左右。

内存的大头还是在scrape这块,因为pod一多,kubelet-cadvisor上报的metrics会多到离谱,想起来为了节省开销不集成cadvisor库而去爬取kubelet-cadvisor,这块如果继续优化的话,说不定直接使用cadvisor库效果会好一点

其实本来还想做一个promqlBuilder一样的东西,但是捉摸了半天,yacc实在搞不定,再加上prometheus一些内部的工具类也能用,只好作罢。

还有一点值得吐槽的是,prometheus每个小版本之间差异也比较大,我是基于0.38去改的,在准备升级到0.39时,发现很多方法签名都不一致;又同最新的0.40相比,interface声明都变了,很难做到平滑升级。