远程读取与流式传输

新的 Prometheus 2.13.0 版本已发布,并且像往常一样,它包含许多修复和改进。您可以在这里阅读更改内容。但是,有一个功能是某些项目和用户一直在等待的:分块、流式版本的远程读取 API

在本文中,我想深入探讨我们在远程协议中所做的更改、更改的原因以及如何有效地使用它。

远程 API

自 1.x 版本以来,Prometheus 能够使用远程 API 直接与其存储进行交互。

此 API 允许第三方系统通过两种方法与指标数据进行交互

  • 写入 - 接收 Prometheus 推送的样本
  • 读取 - 从 Prometheus 拉取样本

Remote read and write architecture

这两种方法都使用 HTTP,并使用 protobufs 编码消息。两种方法的请求和响应都使用 snappy 进行压缩。

远程写入

这是将 Prometheus 数据复制到第三方系统最流行的方式。在这种模式下,Prometheus 会定期向给定的端点发送一批样本来流式传输样本。

远程写入最近在 3 月份通过 基于 WAL 的远程写入 进行了大规模改进,提高了可靠性和资源消耗。还值得注意的是,几乎所有此处提到的第三方集成都支持远程写入。

远程读取

读取方法不太常见。它在 2017 年 3 月(服务器端)添加,此后没有重大发展。

Prometheus 2.13.0 版本的发布包括修复了读取 API 中已知的资源瓶颈。本文将重点介绍这些改进。

远程读取的关键思想是允许直接查询 Prometheus 存储 (TSDB),而无需 PromQL 评估。它类似于 PromQL 引擎用于从存储检索数据的 Querier 接口。

这本质上允许读取 Prometheus 收集的 TSDB 中的时间序列。远程读取的主要用例是

  • 在 Prometheus 的不同数据格式之间无缝升级 Prometheus,因此让 Prometheus 从另一个 Prometheus 读取
  • Prometheus 能够从第三方长期存储系统(例如 InfluxDB)读取数据。
  • 第三方系统从 Prometheus 查询数据,例如 Thanos

远程读取 API 公开一个简单的 HTTP 端点,该端点期望以下 protobuf 有效负载

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 的某些序列以及具有 endstart 的时间范围。

响应同样简单

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;
}

远程读取返回匹配的时间序列,其中包含值和时间戳的原始样本。

问题陈述

对于如此简单的远程读取,存在两个关键问题。它易于使用和理解,但是在我们定义的 protobuf 格式的单个 HTTP 请求中没有流式传输功能。其次,响应包括原始样本(float64 值和 int64 时间戳),而不是存储在 TSDB 中用于存储指标的编码压缩样本批次,称为“块”。

不带流式传输的远程读取的服务器算法是

  1. 解析请求。
  2. 从 TSDB 中选择指标。
  3. 对于所有解码的序列
    • 对于所有样本
      • 添加到响应 protobuf
  4. 编组响应。
  5. Snappy 压缩。
  6. 发回 HTTP 响应。

远程读取的整个响应必须以原始、未压缩的格式进行缓冲,以便在将其发送到客户端之前将其编组到一个可能巨大的 protobuf 消息中。整个响应必须在客户端再次完全缓冲,以便能够从接收到的 protobuf 中解组。只有这样,客户端才能使用原始样本。

这意味着什么?这意味着,例如,仅匹配 10,000 个序列的 8 小时的请求可能占用客户端和服务器各自分配的 2.5GB 内存!

以下是在远程读取请求期间 Prometheus 和 Thanos Sidecar(远程读取客户端)的内存使用指标

Prometheus 2.12.0: RSS of single read 8h of 10k series

Prometheus 2.12.0: Heap-only allocations of single read 8h of 10k series

值得注意的是,查询 10,000 个序列并不是一个好主意,即使对于 Prometheus 原生的 HTTP query_range 端点也是如此,因为您的浏览器根本不会乐于获取、存储和渲染数百兆字节的数据。此外,对于仪表板和渲染目的,拥有如此多的数据是不切实际的,因为人类不可能读取它。这就是为什么我们通常编写不超过 20 个序列的查询。

这很棒,但是一种非常常见的技术是以这种方式组成查询,即查询返回聚合的 20 个序列,但是查询引擎必须访问潜在的数千个序列才能评估响应(例如,当使用 聚合器 时)。这就是为什么像 Thanos 这样的系统(除其他数据外,它使用来自远程读取的 TSDB 数据)通常会遇到请求繁重的情况。

解决方案

为了解释这个问题的解决方案,了解 Prometheus 在查询时如何迭代数据很有帮助。核心概念可以在 QuerierSelect 方法返回的名为 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,从而最大限度地减少分配。

这对远程读取 API 很重要,因为我们可以通过向客户端发送单个序列的几个块形式的响应来重用相同的流式传输模式(使用迭代器)。由于 protobuf 没有原生分隔逻辑,我们 扩展了 proto 定义,以允许发送一组小的协议缓冲消息,而不是单个巨大的消息。我们将此模式称为 STREAMED_XOR_CHUNKS 远程读取,而旧的模式称为 SAMPLES。扩展协议意味着 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];
}

如您所见,该帧不再包含原始样本。这是我们做的第二个改进:我们在消息中发送以块(请观看此视频以了解有关块的更多信息)分批的样本,这些块与我们存储在 TSDB 中的块完全相同。

我们最终得到了以下服务器算法

  1. 解析请求。
  2. 从 TSDB 中选择指标。
  3. 对于所有序列
    • 对于所有样本
      • 编码为块
        • 如果帧 >= 1MB;中断
    • 编组 ChunkedReadResponse 消息。
    • Snappy 压缩
    • 发送消息

您可以在此处找到完整的设计。

基准测试

这种新方法的性能与旧解决方案相比如何?

让我们比较一下 Prometheus 2.12.02.13.0 之间的远程读取特性。对于本文开头介绍的初始结果,我使用 Prometheus 作为服务器,并使用 Thanos sidecar 作为远程读取的客户端。我通过使用 grpcurl 对 Thanos sidecar 运行 gRPC 调用来调用测试远程读取请求。测试是在我的笔记本电脑(Lenovo X1 16GB,i7 第 8 代)上使用 docker 中的 Kubernetes(使用 kind)进行的。

数据是人为生成的,代表高度动态的 10,000 个序列(最坏情况)。

完整的测试平台可在thanosbench repo中找到。

内存

不带流式传输

Prometheus 2.12.0: Heap-only allocations of single read 8h of 10k series

带流式传输

Prometheus 2.13.0: Heap-only allocations of single read 8h of 10k series

减少内存是我们解决方案的主要目标。Prometheus 在整个请求期间缓冲大约 50MB 的内存,而不是分配 GB 的内存,而对于 Thanos 而言,内存使用量只是很小的一部分。由于流式的 Thanos gRPC StoreAPI,sidecar 现在只是一个非常简单的代理。

此外,我尝试了不同的时间范围和序列数,但正如预期的那样,我看到 Prometheus 的最大分配量为 50MB,而 Thanos 几乎看不到。这证明我们的远程读取每个请求使用恒定的内存,无论您请求多少样本。每个请求分配的内存也比以前更少地受到数据基数(即获取的序列数)的影响。

这有助于利用并发限制来更轻松地针对用户流量进行容量规划。

CPU

不带流式传输

Prometheus 2.12.0: CPU time of single read 8h of 10k series

带流式传输

Prometheus 2.13.0: CPU time of single read 8h of 10k series

在我的测试期间,CPU 使用率也得到了提高,CPU 时间减少了 2 倍。

延迟

由于流式传输和更少的编码,我们也设法降低了远程读取请求延迟。

10,000 个序列的 8 小时范围的远程读取请求延迟

2.12.0:平均时间 2.13.0:平均时间
真实 0m34.701s 0m8.164s
用户 0m7.324s 0m8.181s
系统 0m1.172s 0m0.749s

以及 2 小时的时间范围

2.12.0:平均时间 2.13.0:平均时间
真实 0m10.904s 0m4.145s
用户 0m6.236s 0m4.322s
系统 0m0.973s 0m0.536s

除了 ~2.5 倍的较低延迟外,与非流式版本相比,响应会立即流式传输,在非流式版本中,客户端延迟为 27 秒(real 减去 user 时间),仅在 Prometheus 和 Thanos 端进行处理和编组。

兼容性

远程读取以向后和向前兼容的方式进行了扩展。这归功于 protobuf 和 accepted_response_types 字段,旧服务器会忽略该字段。同时,如果旧客户端不存在 accepted_response_types,假设旧的 SAMPLES 远程读取,服务器也能正常工作。

远程读取协议以向后和向前兼容的方式进行了扩展。

  • v2.13.0 之前的 Prometheus 将安全地忽略较新客户端提供的 accepted_response_types 字段,并假定为 SAMPLES 模式。
  • v2.13.0 之后的 Prometheus 对于不提供 accepted_response_types 参数的旧客户端,将默认使用 SAMPLES 模式。

用法

要在 Prometheus v2.13.0 中使用新的流式远程读取,第三方系统必须在请求中添加 accepted_response_types = [STREAMED_XOR_CHUNKS]

然后,Prometheus 将流式传输 ChunkedReadResponse 而不是旧消息。每个 ChunkedReadResponse 消息都遵循 varint 大小和固定大小的大端 uint32 CRC32 Castagnoli 校验和。

对于 Go,建议使用 ChunkedReader 直接从流中读取。

请注意,storage.remote.read-sample-limit 标志不再适用于 STREAMED_XOR_CHUNKSstorage.remote.read-concurrent-limit 的工作方式与之前相同。

还有一个新的选项 storage.remote.read-max-bytes-in-frame,它控制每个消息的最大大小。建议保持默认的 1MB,因为 Google 建议 protobuf 消息 不大于 1MB

如前所述,Thanos 从此改进中获益良多。流式远程读取已添加到 v0.7.0 中,因此,只要 Prometheus 2.13.0 或更新版本与 Thanos sidecar 一起使用,此版本或任何后续版本都将自动使用流式远程读取。

下一步

2.13.0 版本引入了扩展的远程读取和 Prometheus 服务器端实现,但是,在撰写本文时,为了充分利用扩展的远程读取协议,仍然有一些项目需要完成。

  • 支持 Prometheus 远程读取的客户端:进行中
  • 避免在远程读取期间重新编码块:进行中

总结

总而言之,分块、流式传输远程读取的主要好处是

  • 客户端和服务器都能够使用每个请求几乎恒定的内存大小。这是因为 Prometheus 在远程读取期间只发送一个个小的帧,而不是整个响应。这极大地帮助了容量规划,特别是对于像内存这样不可压缩的资源。
  • Prometheus 服务器在远程读取期间不再需要将块解码为原始样本。如果系统重用原生 TSDB XOR 压缩(例如 Thanos 所做的),客户端的编码也是如此。

一如既往,如果您有任何问题或反馈,请随时在 GitHub 上提交工单或在邮件列表中提问。