原文:https://prometheus.io/blog/2019/10/10/remote-read-meets-streaming/
Prometheus新版本 2.13.0 的发布,他包含了许多bug fix和改进。你可以在这儿查看变更。这里有一个许多用户或项目十分期待的功能:chunked, streamed version of remote read API.
在本文中,我将深入介绍我们在远程协议中更改了哪些内容,更改的原因以及如何有效地使用它。
远程API
自从1.x版本,Prometheus有了与远程存储通过remote API交互的能力。
这个API允许第三方系统通过两种方法与metrics 数据交互:
Remote Write
这是将Prometheus数据放于第三方系统存储的最常见做法。在这种模式中,Prometheus周期性地向给定端点发送一批样本数据。
在3月份基于WAL 的Remote write在可靠性和资源占用率上都得到了巨大的提升。值得一提的是这里所有的第三方存储都支持该种方式。
Remote Read
read方法不太常见。它是在 March 2017
添加的(服务端内部包含),自那以后没有看到显著的发展。
普罗米修斯2.13.0版本修复了Read API中已知的资源瓶颈。本文将重点讨论这些改进。
remote read的关键点是不经过PromQL计算的前提下直接查询Prometheus storage (TSDB)。 他和 Querier 接口类似,用于从存储层获取数据。
这个特性允许对Prometheus采集到的时序数据进行访问, remote read主要使用场景如下:
- 两个不同数据格式的Prometheus之间可以无缝升级。Prometheus reading from another Prometheus.
- 普罗米修斯能够读取第三方持久化存储系统,例如,fluxdb。
- 第三方系统从Prometheus获取数据。例如:Thanos.
远程读API暴露了一个简单的HTTP endpoint ,它期望数据格式如下:
message ReadRequest {
repeated Query queries = 1;
}
message Query {
int64 start_timestamp_ms = 1;
int64 end_timestamp_ms = 2;
repeated prometheus.LabelMatcher matchers = 3;
prometheus.ReadHints hints = 4;
}
通过这个协议,客户端可以通过matchers
和start
以及end
的时间区间来获取到特定的时序数据
返回数据格式如下:
message ReadResponse {
// In same order as the request's queries.
repeated QueryResult results = 1;
}
message Sample {
double value = 1;
int64 timestamp = 2;
}
message TimeSeries {
repeated Label labels = 1;
repeated Sample samples = 2;
}
message QueryResult {
repeated prometheus.TimeSeries timeseries = 1;
}
Remote read返回匹配的时序数据,包含raw数据(包含vaule和时间戳)
问题描述
对于remote read有如下2个关键问题。它虽然很容易使用和理解,但是我们定义的protobuf格式的单个HTTP请求中没有streaming功能。其次,响应的是raw格式数据(float64值和int64时间戳),而不是TSDB存储的基于‘Chunk’的经过编码和压缩后的数据。
没有streaming的远程读取的server端逻辑是:
- 解析请求
- 从TSDB中选取metric数据
- 将所有的序列解码
将所有的样本数据加入protobuf响应 - 序列化响应体
- 压缩响应数据
- 将响应发送至客户端
remote read的所有响应体将被缓冲到一个未压缩的raw格式以便将它在发送到客户端前序列化到一个潜在的巨大的protobuf消息体中。为了protobuf在接受到响应后反序列它,整个响应必须整体在客户端被缓存。
下面是Prometheus和Thanos Sidecar(remote read 客户端)在remote read请求期间内的内存使用情况:
值得注意的是,即便对于Prometheus原生Http接口的query_range
方法而言, 查询10,000个series 也并不是一个好主意,因为你的浏览器也不希望获取,存储然后渲染数百兆字节的数据。此外,对于仪表板和呈现目的来说,拥有这么多数据也是不现实的,因为人不可能读取这么大量的数据。这就是为什么我们通常我们不会发起大于20个时序的查询请求。
虽然这样做很好,但是另外一种常规技术做法是通过聚合查询去获得聚合好的20个时间序列,但底层查询引擎必须通过上千个序列去计算响应(例如使用aggregators。这就是为什么像Thanos这样的系统,在其他数据中,使用TSDB数据从远程读取,通常情况下,请求很重。
解决办法
理解Prometheus在查询时是如何迭代数据的对理解这个问题很有帮助。核心概念可以从 Querier
的 Select
方法返回的一个叫SeriesSet
的类型中看出来。接口如下:
// SeriesSet contains a set of series.
type SeriesSet interface {
Next() bool
At() Series
Err() error
}
// Series represents a single time series.
type Series interface {
// Labels returns the complete set of labels identifying the series.
Labels() labels.Labels
// Iterator returns a new iterator of the data of the series.
Iterator() SeriesIterator
}
// SeriesIterator iterates over the data of a time series.
type SeriesIterator interface {
// At returns the current timestamp/value pair.
At() (t int64, v float64)
// Next advances the iterator by one.
Next() bool
Err() error
}
上面这一系列接口为进程提供了一种基于“流”的能力。我们不再需要预先计算包含样本的序列列表。使用这个接口,每个SeriesSet.Next()
实现都可以根据需要获取序列。我们还可以通过SeriesIterator.Next
动态地分别获取每个样本。
通过这个协议,Prometheus可以尽可能小的分配内存,因为PromQL引擎可以更优的对样本进行迭代,以计算出查询结果。TSDB以同样的方式实现SeriesSet,以一种最佳的方式从存储在文件系统中的块中一个接一个地获取序列,从而最小化分配。
这对 remote read API来说十分重要,因为我们可以以相同的调用形式来迭代式的向客户端发送单个时序中的一部分chunk形式的数据。因为protobuf原生没有分割消息数据的机制,所以我们扩展了
proto定义来允许发送一组小的protocol buffer消息,而不是单个巨大的消息体。我们把这种remote read模式称作STREAMED_XOR_CHUNKS
而老的模式叫SAMPLES
。扩展了protocol意味着Prometheus再也不需要缓冲整个响应了,他可以在调用SeriesSet.Next
或者SeriesIterator.Next
迭代时发送一个有序的独立帧,以尽可能的与下一个时序数据复用同一个内存页。
现在,STREAMED_XOR_CHUNKS
模式的响应是以下一组Protobuf消息(帧)
// ChunkedReadResponse is a response when response_type equals STREAMED_XOR_CHUNKS.
// We strictly stream full series after series, optionally split by time. This means that a single frame can contain
// partition of the single series, but once a new series is started to be streamed it means that no more chunks will
// be sent for previous one.
message ChunkedReadResponse {
repeated prometheus.ChunkedSeries chunked_series = 1;
}
// ChunkedSeries represents single, encoded time series.
message ChunkedSeries {
// Labels should be sorted.
repeated Label labels = 1 [(gogoproto.nullable) = false];
// Chunks will be in start time order and may overlap.
repeated Chunk chunks = 2 [(gogoproto.nullable) = false];
}
你可以发现消息帧不包含raw格式数据了。这是我们做的第二点提升:我们以chunk的形式发送消息样本(观看 这个视频来了解更多关于chunk的知识)它与我们存储在TSDB中的chunk完全相同。
我们最终使用了以下服务端逻辑:
- 解析请求
- 从TSDB中选取metric
- 对于所有时序:
对于所有样本数据:
将他们编码成chunk
如果帧>=1MB;break - 序列化
ChunkedReadResponse
消息 - 压缩
- 发送消息
我们所有的设计都在这里
Benchmarks
与旧的解决方案相比,这种新方法的性能如何?
我们来将Prometheus2.12.0
和2.13.0
remote read特型进行对比。就如本文开头给出的初步结果,我用Prometheus做服务端,用Thanos的sidecar做远程读取的客户端。我使用grpcurl
对Thanos sidecar执行gRPC调用来测试remote read。整个测试在我的笔记本((Lenovo X1 16GB, i7 8th)上docker中的k8s环境里进行。(使用kind)
数据是人工生成的,表示高度动态的10,000个序列(最坏的情况)。
测试的详细结果请见thanosbench repo
内存使用情况
无streaming
有streaming
减少内存是我们解决方案的主要目标。在整个请求过程中,Prometheus的缓冲区大约为50MB,而Thanos只有少量的内存使用。多亏了流式Thanos gRPC StoreAPI, sidecar现在是一个非常简单的代理。
此外,我尝试了不同的时间范围和序列数量,但正如预期的那样,我一直看到Prometheus的分配最多为50MB,而Thanos的分配则没有。这证明无论你单个请求多少个样本,我们的remote read始终使用恒定的内存大小。分配内存的多少受请求数据数量的影响大大减少,无论你请求多少数据,他都始终分配相同的内存。
在并发性限制的帮助下,这使得针对用户流量进行容量规划变得更加容易。
CPU
无streaming
有streaming
在我的测试期间,CPU使用率也有所提高,使用的CPU时间减少了两倍。
延迟
通过streaming和更少的编码次数,我们同时还实现了减少remote read请求的响应延迟。
Remote read 8小时时间跨度包含10000个序列:
2.12.0: avg time | 2.13.0: avg time | |
---|---|---|
real | 0m34.701s | 0m8.164s |
user | 0m7.324s | 0m8.181s |
sys | 0m1.172s | 0m0.749s |
2h时间跨度:
2.12.0: avg time | 2.13.0: avg time | |
---|---|---|
real | 0m10.904s | 0m4.145s |
user | 0m6.236s | 0m4.322s |
sys | 0m0.973s | 0m0.536s |
在Prometheus和Thanos侧处理和序列化的时间上,除了低2.5倍的响应延迟外, stream版本的响应是及时的,而非 stream版本客户端延迟27s(real
minus user
time)。
兼容性
Remote read以向后和向前兼容的方式进行了扩展。这要归功于protobuf和accepted_response_types
被旧版本所忽略的字段,同时如果旧的客户端(假设采用SAMPLES
模式的remote read)不支持accepted_response_types
他也能正常工作。
remote read协议前后版本兼容方式:
- v2.13.0前的Prometheus(假设采用
SAMPLES
模式的remote read)会安全的忽略来自于新版客户端的accepted_response_types
字段 - 高于v2.13.0的Prometheus对于不提供
accepted_response_types
的客户端会默认设置成SAMPLES
模式
使用
为了使用新的基于streamed remote read的Prometheus v2.13.0,第三方系统必须将accepted_response_types = [STREAMED_XOR_CHUNKS]
添加到请求中。
Prometheus就会用ChunkedReadResponse
来替代老版本消息体。每个ChunkedReadResponse
消息都符合varint大小和固定大小bigendian uint32用于CRC32 Castagnoli校验和。
对于go语音来说我们推荐使用 ChunkedReader来直接读取流
注意,storage.remote.read-sample-limit
设置将在STREAMED_XOR_CHUNKS. storage.remote.read-concurrent-limit
条件下不再起作用。
也提供了一个新的配置项storage.remote.read-max-bytes-in-frame
来控制每个消息体的最大大小。建议默认为1MB,因为谷歌建议protobuf消息不大于1MB.。
正如前面提到的,Thanos因为这个特性收益颇丰。Streamed remote read 在v0.7.0
被增加,因此,配合Prometheus 2.13.0 或更高版本,都将自动采用 streamed remote read 的形式。
下一步
Release 2.13.0引入了扩展的 remote read和Prometheus服务器端实现,然而,为了充分利用扩展的远程读协议的优势,目前还需要做一些事情:
总结
综上所述,分块、流远程读取的主要好处是:
- 无论客户端还是服务端每个请求都消耗恒定内存。这因为Prometheus只发送一个接一个的小帧而不是remote read的整个响应体。这大大有助于容量规划,特别是对于不可压缩的资源,如内存。
- Prometheus服务端在remote read期间再也不需要将chunks解码成raw样本了,如果系统正在使用原生的TSDB XOR压缩方式,那么客户端也一样不需要这么做了(如Thanos)
如果您有任何问题或反馈,一如既往,请随时在GitHub上提交问题或在邮件列表上提问。