首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

Cloudflare 如何大规模运行 Prometheus

我们使用Prometheus来监控构成我们全球网络的所有不同的硬件和软件。Prometheus 让我们可以随时度量其健康状况和性能,如果任何服务有任何问题,那么我们的团队在其成为问题之前就可以知道。

在写这篇文章的时候,我们运行着 916 个 Prometheus 实例,总共大约 49 亿个时间序列。下面的截图展示了确切的数值:

平均每个实例大约有 500 万个时间序列,但实际上,我们的实例有的非常小,有的非常大,最大的实例每个存储了大约 3000 万个时间序列。

运营如此大规模的 Prometheus 部署非常具有挑战性。这篇博文将介绍我们在试图从每个 Prometheus 实例收集数百万个时间序列时,可能遇到的一些问题。

指标基数

在开始运行自己的 Prometheus 实例时,你可能听到的第一个问题就、是基数问题。关于这个问题,有一个最突出的例子是“基数爆炸”。

所以让我们看一下,Prometheus 中基数的含义,看看那什么时候会成为问题,以及一些处理方法。

假设我们想要检测一个应用程序,也就是说要以指标的形式添加一些可观察属性,让 Prometheus 从我们的应用程序中读取这些属性。指标可以是任何能用数值表示的东西,例如:

  • 车辆行驶的速度
  • 当前温度
  • 特定事件发生的次数

要在应用程序中创建指标,我们可以从众多 Prometheus 客户端库中随便选择一个。简单起见,我们选择了client_python,但是无论你使用哪种语言,概念都是适用的。

from prometheus_client import Counter


# 声明第一个指标。
# 第一个参数是指标名称。
# 第二个参数是指标描述。
c = Counter(mugs_of_beverage_total, 'The total number of mugs drank.')


# 每喝完一杯就调用inc()增加指标值。
c.inc()
c.inc()

通过这段简单的代码,Prometheus 客户端库将创建一个指标。为了让 Prometheus 收集这个指标,我们需要让应用程序运行一个 HTTP 服务器并暴露出我们定义的指标。最简单的方法是使用 client_python 本身提供的功能——请参阅文档

当 Prometheus 向应用程序发送 HTTP 请求时,它将收到以下响应:

# HELP mugs_of_beverage_total The total number of mugs drank.
# TYPE mugs_of_beverage_total counter
mugs_of_beverage_total 2

关于上述这种响应格式和底层数据模型,Prometheus 的文档中多有介绍。要了解更多信息,请查阅数据模型暴露格式页。

如果需要,则可以进一步添加指标,它们都将出现在指标端点的 HTTP 响应中。

Prometheus 的指标还可以有标签。我们可以利用它为指标添加更多信息,这样我们就可以更好地理解发生了什么。

在上面的指标示例中,我们知道总共喝掉了多少杯饮料,但如果我们还想知道喝的是哪种饮料呢?或者我们想知道是冷饮还是热饮呢?非常简单,我们所需要做的就是添加标签并指定它们的名称。之后,我们还需要在增加计数时额外传递标签值信息(要与指定标签名称的顺序一致)。

为此,让我们调整下示例代码。

from prometheus_client import Counter


c = Counter(mugs_of_beverage_total, 'The total number of mugs drank.', ['content', 'temperature'])


c.labels('coffee', 'hot').inc()
c.labels('coffee', 'hot').inc()
c.labels('coffee', 'cold').inc()
c.labels('tea', 'hot').inc()

HTTP 响应将显示更多的条目:

# HELP mugs_of_beverage_total The total number of mugs drank.
# TYPE mugs_of_beverage_total counter
mugs_of_beverage_total{content="coffee", temperature="hot"} 2
mugs_of_beverage_total{content="coffee", temperature="cold"} 1
mugs_of_beverage_total{content="tea", temperature="hot"} 1

如你所见,每个唯一的标签组合都有一个条目。

到这里,我们就需要说一下指标基数的定义了。基数是所有标签的唯一组合的数量。标签越多,每个标签可以接受的值越多,则可以创建的唯一组合就越多,基数也就越高。

指标 vs 样本 vs 时间序列

现在,让我们暂停一下,对指标和时间序列做一下区分,这很重要。

指标是具有一些已定义维度(标签)的可观察属性。在我们的例子中,它是一个 Counter 类对象。

时间序列是该指标的一个实例,是所有维度(标签)的唯一组合加上一系列时间戳值对——“时间序列”的名字即由此而来。指标名称和标签告诉我们正在观察什么,而时间戳值对告诉我们可观察属性随着时间如何变化,让我们可以使用这些数据绘制图表。

也就是说,一个指标可以创建一个或多个时间序列。时间序列的数量完全取决于标签的数量以及这些标签所有可能取值的数量。

每次向指标中添加一个新标签时,我们都冒着输出到 Prometheus 的时间序列数量成数倍增加的风险。

我们的例子中有两个标签,“content”和“temperature”,它们都有两个不同的值。所以,我们最多可以创建 4(2*2) 个时间序列。如果我们另外添加一个标签,它也有两个值,那么我们现在就要输出多达 8 个时间序列(2*2*2)。标签越多,或者标签的取值越多,时间序列就越多。

如果所有标签值都由应用程序来控制,则可以计算出所有可能的标签组合的数量。但是,当你创建的标签带有来自外部世界的标签值时,才是真正的风险所在。

如果我们跟踪发送到 Web 服务器的 HTTP 请求的数量而不是饮料消耗,并使用请求路径作为其中一个标签值,那么任何人发出的大量随机请求都可能迫使我们的应用程序创建大量的时间序列。通常来说,为了避免这种情况,最好不要接受来自不可信来源的标签值。

更复杂一点,在阅读 Prometheus 文档时,你可能还会看到“样本”这个词。样本是介于指标和时间序列之间的东西——它是特定时间戳的一个时间序列值。这个时间戳可以是显式的,也可以是隐式的。如果一个样本没有任何明确的时间戳,则意味着这个样本代表了最近的值——它是给定时间序列的当前值,时间戳只是你进行观察的时间。

如果你查看示例指标的 HTTP 响应,就会看到返回的所有条目都没有时间戳。实际上,哪儿都没有时间戳。这是因为时间戳由 Prometheus 服务器自己负责。当 Prometheus 收集指标时,它会记录每次开始收集的时间,然后使用它作为每个时间序列的时间戳值对。

这就是为什么应用程序输出的不是真正的指标或时间序列,而是样本。

是不是很困惑?让我们来简要的回顾一下:

  • 我们首先介绍了指标——这是对我们可以观察到的东西的简单定义,比如喝了多少杯饮料。
  • 我们的指标以 HTTP 响应的形式暴露。该响应有一个样本列表——它们是指标的单个实例(由名称和标签表示)加上当前值。
  • 对于从 HTTP 响应中收集的所有样本,Prometheus 会添加时间戳,将所有这些信息结合在一起,我们就得到了一个时间序列。

基数相关的问题

每个时间序列都会消耗我们的资源,因为我们需要将它保存在内存中,所以我们拥有的时间序列越多,指标消耗的资源就越多。不管是客户端库,还是 Prometheus 服务器,皆是如此。不过,对于 Prometheus 来说,这更成为问题,因为单个 Prometheus 服务器通常要从许多应用程序收集指标,而应用程序只需保留自己的指标。

因为我们知道,标签越多,最终的时间序列就越多,所以我们可以看出来这什么时候会变成一个问题。只要向所有指标添加一个有两种取值的标签就可能会使我们要处理的时间序列翻倍。反过来,这也会使 Prometheus 服务器的内存使用量增加一倍。如果 Prometheus 消耗的内存超过它实际能够使用的内存大小,它就会崩溃。

通常,我们将这种情况称之为“基数爆炸”——部分指标突然添加了大量不同的标签值,创建了大量的时间序列,导致 Prometheus 耗尽内存,结果失去了所有的可观察性。

Prometheus 是如何使用内存的?

为了更好地处理与基数有关的问题,最好先深入了解下 Prometheus 的工作原理,以及时间序列是如何消耗内存的。

为此,让我们看下 Prometheus 内部是如何处理时间序列的。

第 1 步:HTTP 抓取

从 Prometheus 向应用程序发送 HTTP 请求的过程称为“抓取(scraping)”。在 Prometheus 的配置文件中,我们定义了一个“抓取配置”,告诉 Prometheus 发送 HTTP 请求的位置、频率,以及需要对请求和响应做哪些额外的处理(可选)。

它将记录发送 HTTP 请求的时间,然后将其作为所有收集到的时间序列的时间戳。

在发送请求后,它将解析响应,找出其中暴露的所有样本。

第 2 步:判断是新建还是更新时间序列

在从应用程序收集了一系列样本后,Prometheus 就会将其保存到TSDB——时间序列数据库——Prometheus 保存所有时间序列的数据库。

但在此之前,它首先需要检查哪些样本属于 TSDB 中已经存在的时间序列,哪些样本属于全新的时间序列。

我们之前提到过,时间序列是从指标生成的。每个指标标签的唯一组合都有一个时间序列。

也就是说,Prometheus 必须检查是否已经存在一个具有相同名称和相同标签的时间序列。在内部,时间序列名称只是另一个名为__name__的标签,因此,名称和标签之间实际上并没有区别。下面两种表示是输出同一时间序列的不同方式:

mugs_of_beverage_total{content="tea", temperature="hot"} 1
{__name__="mugs_of_beverage_total", content="tea", temperature="hot"} 1

由于所有东西都是标签,所以 Prometheus 可以简单地使用 sha256 或其他任何算法来哈希所有标签,得出每个时间序列的唯一 ID。

现在我们知道,Prometheus 可以快速检查在 TSDB 中是否存在哈希值相同的时间序列。总的来说,在 TSDB 中,我们使用标签哈希作为主键。

第 3 步:追加到 TSDB

在 TSDB 知道它是应该插入新的时间序列还是更新现有的时间序列后,它就可以开始真正的工作了。

在内部,所有时间序列都存储在Head结构的一个映射中。该映射以标签哈希为键,以名为memSeries的结构为值。这些 memSeries 对象存储了所有的时间序列信息。memSeries 结构的定义相当大,但我们真正需要知道的是,它有所有时间序列标签的副本以及包含所有样本(时间戳值对)的样本块(chunk)。

标签在每个 memSeries 实例中都会存储一次。

存储在样本块中的样本使用“varbit”编码,这是一种专门针对时间序列数据做过优化的无损压缩方案。每个样本块代表特定时间范围内的一系列样本。这有助于提升 Prometheus 的数据查询速度,因为它所需要做的只是首先找到标签与查询匹配的 memSeries 实例,然后找到对应查询时间范围的样本块。

在默认情况下,Prometheus每两个小时创建一个样本块,所以会有这样一些样本块:00:00 - 01:59、02:00 - 03:59、04:00 - 05:59、…、22:00 - 23:59。

我们只能向一个样本块追加,即“Head Chunk”。它是负责最近时间范围的样本块,其中包括抓取时间。其他样本块保存的是历史样本,因此是只读的。

每个样本块最多可容纳120个样本。这是因为,一旦一个样本块上的样本数超过 120 个,“varbit”编码的效率就会下降。TSDB 将尝试评估给定的样本块何时将达到 120 个样本,并相应设置当前 Head Chunk 的最大允许时间。

如果我们尝试追加一个时间戳晚于当前 Head Chunk 最大允许时间的样本,那么 TSDB 将创建一个新的 Head Chunk,并根据追加速率为其计算一个新的最大时间。

所有的样本块都必须对齐两小时的时间槽,所以如果 TSDB 为时间段 10:00-11:59 创建了一个样本块,而这个样本块在 11:30 已经“满”了,那么它将为 11:30-11:59 时间段另外创建一个样本块。

 由于 Prometheus 的默认抓取间隔是一分钟,所以需要两个小时才能达到 120 个样本。

也就是说,使用 Prometheus 的默认设置,每个 memSeries 应该有一个单独的样本块,其中包含两小时内产生的 120 个样本。

回到时间序列——至此,Prometheus 要么新建一个 memSeries 实例,要么使用已有的实例。一旦找到了可用的 memSeries 实例,它就会将样本追加到 Head Chunk 中。这可能需要 Prometheus 新建一个样本块。

第 4 步:内存映射旧样本块

在经过几个小时的运行和指标收集后,我们的时间序列中可能会出现多个样本块:

  • 一个“Head Chunk”—— 最多只能包含最后两个小时的时间槽。
  • 一个或多个历史范围——这些样本块仅供读取,Prometheus 不会试图追加任何样本。

由于所有这些样本块都存储在内存中,Prometheus 会设法将它们写入磁盘和内存映射,以减少内存占用。这样做的好处是内存映射样本块不占用内存,除非 TSDB 要读取它们。

Head Chunk 从不进行内存映射,它总是存储在内存中。

第 5 步:将样本块写到磁盘

到目前为止,所有的时间序列都完全存储在内存中,时间序列越多,Prometheus 的内存使用率就越高。唯一的例外是内存映射样本块,它们会被卸载到磁盘上,但如果需要查询,它们还将被读入内存。

这使得 Prometheus 每秒可以抓取和存储数千个样本,我们最大的实例每秒添加 550k 个样本,而且我们还可以同时查询所有指标。

但你不能永远将所有内容都保存在内存中,内存映射的数据也不能永远那样。

每隔两小时,Prometheus 就会将内存中的样本块持久化到磁盘上。这个过程也会与时钟对齐,但偏移了1个小时

当使用 Prometheus 的默认设置时,假设每两个小时产生一个单独的样本块,那么我们会看到:

  • 02:00 - create a new chunk for 02:00 - 03:59 time range
  • 03:00 - write a block for 00:00 - 01:59
  • 04:00 - create a new chunk for 04:00 - 05:59 time range
  • 05:00 - write a block for 02:00 - 03:59
  • 22:00 - create a new chunk for 22:00 - 23:59 time range
  • 23:00 - write a block for 20:00 - 21:59

 在样本块写入磁盘块后,Prometheus 就会把它从 memSeries 中删除,从而从内存中删除。在设定的保留期内,Prometheus 会将每个样本块保存在磁盘上。

样本块最终会被“压缩”,也就是说,Prometheus 会把多个样本块合并在一起,形成一个覆盖更大时间范围的样本块。这个过程有助于减少磁盘占用,因为每个样本块都有一个索引,占用大量的磁盘空间。通过将多个样本块合并在一起,索引的大部分就都可以重新使用了,使 Prometheus 可以用同样的存储空间存储更多的数据。

第 6 步:垃圾收集

在将样本块写入磁盘块并从 memSeries 中删除之后,我们可能会得到一个不包含任何样本块的 memSeries 实例。如果再没有任何应用程序暴露任何时间序列,就没有抓取会试图向其添加更多样本,这种情况就会发生。

一个常见的模式是将软件版本输出为 build_info 指标,Prometheus 本身也是这样做的:

prometheus_build_info{version="2.42.0"} 1

在 Prometheus 2.43.0 版本中,这个指标的输出如下:

prometheus_build_info{version="2.43.0"} 1

这意味着带有 version="2.42.0"标签的时间序列将不会再接收到任何新的样本。

一旦这个时间序列的最后一个样本块被写入磁盘块并从 memSeries 实例中删除,其中就没有样本块了。也就是说,memSeries 仍然占用一些内存(主要是标签),但实际上什么也不做。

为了处理掉这样的时间序列,Prometheus 将在写完一个磁盘块后立即运行“Head 垃圾收集”(你是否还记得,Head 是保存所有 memSeries 的结构)。其他的暂且不说,这个垃圾收集的过程将查找所有不包含任何样本块的时间序列,并将其从内存中删除。

由于这是发生在写入磁盘块之后,而写入磁盘块发生在样本块窗口的中间(与时钟一致的两小时切片)。因此,它唯一能找到的 memSeries 都是“孤立的”——它们以前收到过样本,但现在再也收不到了。

所有这些意味着什么?

Prometheus 中使用的 TSDB 是一种特殊的数据库,针对特定的工作负载进行了高度优化:

  • 从应用程序中抓取的时间序列保存在内存中。
  • 如果有持续更新,则使用最有效的编码压缩样本。
  • 数个小时前的样本块会被写入磁盘并从内存中删除。
  • 当应用程序的时间序列消失,不再抓取时,它们仍然驻留在内存中,直到所有的样本块都被写入磁盘,垃圾回收才会将它们删除。

这意味着 Prometheus 在不断地一遍又一遍地抓取相同的时间序列时效率最高。当它只抓取一次便不再抓取时效率最低——与使用该内存存储的信息量相比,这样做会带来大量的内存使用开销。

如果我们设法可视化 Prometheus 最适合的数据类型,那么我们最终会得到这样的结果:

几条连续的线,描述了一些观察到的属性。

另一方面,如果要可视化 Prometheus 处理效率最低下的数据类型,那么我们将得到下面这样的结果:

这里我们得到的是单个数据点,每个数据点代表了我们度量的不同属性。

尽管你可以给 Prometheus 传递一个隐藏标志来调整它的部分行为,让它更适应短期时间序列,但通常,我们不建议这样做。这些标志仅用于测试目的,可能会对 Prometheus 服务器的其他部分产生负面影响。

为了更好地理解短期时间序列对内存使用的影响,我们再看个示例。

我们来看下,应用程序在 00:25 启动,而 Prometheus 只抓取一次,会发生什么:

prometheus_build_info{version="2.42.0"} 1

然后,在第一次抓取之后,我们立即将应用程序升级到一个新版本:

prometheus_build_info{version="2.43.0"} 1

在 00:25,Prometheus 将创建 memSeries,但我们必须等到 Prometheus 将包含 00:00-01:59 时段数据的样本块写入磁盘块,垃圾收集才会运行并将这个 memSeries 从内存中删除,而这将在 03:00 发生。

这个样本(数据点)将创建一个时间序列实例,它将在内存中停留超过两个半小时,消耗着资源,就只是为了一个时间戳值对。

如果我们不断地抓取大量只存在很短时间的时间序列,那么内存中将慢慢积累起大量的 memSeries,一直持续到下一次垃圾收集。

这时,查看 Prometheus 服务器的内存使用情况,我们会看到下面这种随着时间推移而重复出现的模式:

从上图中我们可以得到一条很重要的信息,即短期时间序列成本很高。一个只抓取一次的时间序列能在 Prometheus 上存活 1 到 3 个小时,这取决于抓取的确切时间。

基数的成本

至此,关于 Prometheus,我们应该知道如下一些事情:

  • 我们应该知道什么是指标、样本和时间序列。
  • 我们应该知道,一个指标的标签越多,它产生的时间序列就越多。
  • 我们应该知道每个时间序列都会被保存在内存中。
  • 我们应该知道时间序列会在内存中驻留一段时间,即使它们只被抓取过一次。

综合考虑,就可以看出问题所在——高基数指标,特别是标签来自外部世界的指标,很容易在很短的时间内创建大量的时间序列,导致基数爆炸。这会增加 Prometheus 的内存占用,如果超出所有可用的物理内存,就可能会导致 Prometheus 服务器崩溃。

为了更好地理解这个问题,我们调整下示例指标以跟踪 HTTP 请求。

我们为指标增加一个保存请求路径的标签。

from prometheus_client import Counter


c = Counter(http_requests_total, 'The total number of HTTP requests.', ['path'])


# Web服务器将调用的HTTP请求处理器
def handle_request(path):
  c.labels(path).inc()
  ...

如果使用 curl 命令发起一次请求:

> curl https://app.example.com/index.html

那么我们应该可以看到下面这些来自应用程序的时间序列:

# HELP http_requests_total The total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{path="/index.html"} 1

但是,如果一个邪恶的黑客决定向我们的应用程序发送一堆随机请求,会发生什么呢?

> curl https://app.example.com/jdfhd5343
> curl https://app.example.com/3434jf833
> curl https://app.example.com/1333ds5
> curl https://app.example.com/aaaa43321

那会额外创建许多时间序列:

# HELP http_requests_total The total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{path="/index.html"} 1
http_requests_total{path="/jdfhd5343"} 1
http_requests_total{path="/3434jf833"} 1
http_requests_total{path="/1333ds5"} 1
http_requests_total{path="/aaaa43321"} 1

1000 个随机请求就会在 Prometheus 中创建 1000 个时间序列。如果指标的标签再多一些,并且所有标签都是基于请求有效载荷(HTTP 方法名、IP、报头等)设置的,那么我们很容易就会得到数百万个时间序列。

通常,基数相关的问题并不是由恶意参与者引起的。一种常见的错误是指标上有一个错误标签,并将原始错误对象作为值传递。

from prometheus_client import Counter


c = Counter(errors_total, 'The total number of errors.', [error])


def my_func:
  try:
    ...
  except Exception as err:
    c.labels(err).inc()

如果需要处理的是一般错误,那么这个方法很有效,例如“Permission Denied”:

errors_total{error="Permission Denied"} 1

但是,如果错误字符串中包含一些特定于任务的信息,例如应用程序无法访问的文件名,或者 TCP 连接错误,那么这样做可能就很容易导致高基数指标:

errors_total{error="file not found: /myfile.txt"} 1
errors_total{error="file not found: /other/file.txt"} 1
errors_total{error="read udp 127.0.0.1:12421->127.0.0.2:443: i/o timeout"} 1
errors_total{error="read udp 127.0.0.1:14743->127.0.0.2:443: i/o timeout"} 1

一旦抓取,所有这些时间序列都将在内存中至少驻留 1 个小时。这很容易导致 Prometheus 中的时间序列不断累积,直到内存耗尽。

即使是 Prometheus 自己的客户端库也有Bug,可能会让你面临类似的问题。

一个时间序列需要多少内存?

存储在 Prometheus 中的每个时间序列(作为 memSeries 实例)包括:

  • 所有标签的副本。
  • 包含样本的数据块。
  • Prometheus 内部需要的额外字段。

标签所需的内存量取决于标签的数量和长度。标签越多,或者名称和值越长,占用的内存就越多。

Prometheus 内部存储标签的方式也很重要,但这是用户无法控制的。有一个开放的 pull 请求,它通过将所有标签存储为单个字符串来改善标签的内存占用。

每次抓取之后,当样本块上的样本越来越多时,它们消耗的内存也会增加。因此,内存使用会形成一个循环——当我们添加第一个样本时,内存使用量较低,然后内存使用量缓慢上升,直到新建一个样本块,然后重新开始。

你可以在 Prometheus 服务器上运行下面这个查询来计算时间序列需要多少内存:

go_memstats_alloc_bytes / prometheus_tsdb_head_series

请注意,Prometheus 服务器必须配置为自我抓取才能正常工作。

其次,这个计算基于 Prometheus 所使用的所有内存,而不仅仅是时间序列数据,所以它只是一个近似值。使用它可以大致了解每个时间序列使用了多少内存,不要把它当成确切的数字。

第三,Prometheus 是用Golang语言编写的,这种语言提供了垃圾收集机制。因此,Prometheus 实际需要的物理内存数量通常会比较高,因为那将包括 Go 运行时需要释放的未使用的(垃圾)内存。

保护 Prometheus 免于基数爆炸

确实,Prometheus 提供了一些处理高基数问题的选项。抓取配置块中提供了许多选项。以下是从 Prometheus 文档中摘录的相关选项:

# An uncompressed response body larger than this many bytes will cause the
# scrape to fail. 0 means no limit. Example: 100MB.
# This is an experimental feature, this behaviour could
# change or be removed in the future.
[ body_size_limit: <size> | default = 0 ]
# Per-scrape limit on number of scraped samples that will be accepted.
# If more than this number of samples are present after metric relabeling
# the entire scrape will be treated as failed. 0 means no limit.
[ sample_limit: <int> | default = 0 ]


# Per-scrape limit on number of labels that will be accepted for a sample. If
# more than this number of labels are present post metric-relabeling, the
# entire scrape will be treated as failed. 0 means no limit.
[ label_limit: <int> | default = 0 ]


# Per-scrape limit on length of labels name that will be accepted for a sample.
# If a label name is longer than this number post metric-relabeling, the entire
# scrape will be treated as failed. 0 means no limit.
[ label_name_length_limit: <int> | default = 0 ]


# Per-scrape limit on length of labels value that will be accepted for a sample.
# If a label value is longer than this number post metric-relabeling, the
# entire scrape will be treated as failed. 0 means no limit.
[ label_value_length_limit: <int> | default = 0 ]


# Per-scrape config limit on number of unique targets that will be
# accepted. If more than this number of targets are present after target
# relabeling, Prometheus will mark the targets as failed without scraping them.
# 0 means no limit. This is an experimental feature, this behaviour could
# change in the future.
[ target_limit: <int> | default = 0 ]

为所有标签设置长度相关的限制,避免出现名称或值超长的标签占用过多内存的情况。

再看下带有错误标签的指标,我们可以想象有这样一种场景:某个操作返回一个非常长的错误消息,甚至包含数百行的堆栈跟踪。如果这样的堆栈跟踪最终成了一个标签值,那么它占用的内存将比其他时间序列都要多,甚至可能占用兆字节。由于 Prometheus 在处理查询时会复制标签,所以这可能会导致内存使用量明显增加。

设置 label_limit 提供了一定程度的基数保护,但即使只有一个标签名,如果值很多的话,我们也会遭遇高基数。传递 sample_limit 是防止高基数的终极武器。它使我们能够对从每个应用程序实例中获取的时间序列的数量施加硬性限制。

所有这些限制的缺点是,违反其中任何一个限制都会导致整个抓取出现错误。

如果我们将 sample_limit 设置为 100,而指标响应包含 101 个样本,那么 Prometheus 将不会抓取任何东西。这是 Prometheus 开发人员经过深思熟虑后做出的设计决定。

这一决定的主要动机似乎是,部分抓取的指标很难处理,将失败的抓取视为意外事件更好。

Cloudflare 是如何处理高基数的?

我们在世界各地有数百个数据中心,每个数据中心都有专门的 Prometheus 服务器负责收集所有指标。

每个 Prometheus 都在抓取几百个不同的应用程序,每个应用程序都运行在几百台服务器上。

加起来有很多不同的指标。一不小心就会引起基数问题,之前我们已经处理了相当数量的与之相关的问题。

基本限制

在我们的部署中,最基本的保护层是抓取限制,所有的抓取过程都必须遵守。99%的应用程序输出的指标都不会超过这些正常的默认值。

默认情况下,每个时间序列上最多只能有 64 个标签,这远远超过了大多数指标所需的标签数量。

我们还将标签名称和值的长度限制为 128 和 512 个字符,对于绝大多数抓取来说,这已经足够了。

最后,默认情况下,我们将 sample_limit 设置为 200——这样,在不做任何操作的情况下,每个应用程序就可以输出多达 200 个时间序列。

当有人想要输出更多的时间序列或使用更长的标签时要怎么办呢?他们所要做的就是在抓取配置中显式地进行设置。

这些限制的存在是为了捕捉意外事件,也为在应用程序输出的时间序列太多时(超过 200 个),负责应用程序的团队可以知道相关情况。这可以帮助我们避免应用程序输出数以千计实际上并不需要的序列。一旦时间序列超过 200 个,你就该好好考虑下自己的指标了。

CI 验证

下一层保护是在 CI(持续集)时运行检查,即在有人发起 pull 请求,为其应用程序添加新的抓取配置或修改现有的抓取配置时。

如果更改将导致收集的时间序列增加,那么这些检查可以确保所有 Prometheus 服务器上都有足够的空间来容纳额外的时间序列。

例如,如果有人想修改 sample_limit,比如从 500 改为 2000,在有 10 个抓取目标的情况下,每个目标增加 1500,10 个目标就可能额外抓取(10* 1500 =)15000 个时间序列。在允许合并 pull 请求之前,我们的 CI 将检查所有的 Prometheus 服务器,确保它们至少有 15000 个时间序列的空闲容量。

这给了我们信心,让我们不必担心应用更改后会有任何 Prometheus 服务器过载。

我们的自定义补丁

最重要的保护层之一是我们在 Prometheus 上面维护的一套补丁。Prometheus 存储库上有一个开放的pull请求。这个补丁集主要由两部分组成。

首先是一个补丁,让我们可以限制 TSDB 在任何时候可以存储的时间序列的总数。Prometheus 的标准构建并没有提供等效的功能。在标准版本中,如果有抓取产生了一些样本,Prometheus 就会将它们追加到 TSDB 中的时间序列中,并在需要的时候创建新的时间序列。

下面是一个没有设置任何 sample_limit 的标准抓取流:

 我们的补丁告诉 TSDB,在任何时候,它保存的来自所有抓取的时间序列最多只能有 N 个。因此,当 TSDB 收到追加新样本的要求时,它将首先检查已经存在多少个时间序列。

如果已存储的时间序列的总数低于设定的限制,则像往常一样追加样本。

我们的补丁与 Prometheus 标准版的区别体现在追加新样本但 TSDB 存储的时间序列已经达到最大数量时。我们修补后的逻辑是,检查将要追加的样本是否是 TSDB 中已经存在的时间序列,还是需要新建时间序列。

如果时间序列在 TSDB 中已经存在,则可以继续追加。如果时间序列还不存在,而追加会导致新建一个 memSeries 实例,那么我们将跳过这个样本。我们还会向抓取逻辑发送信号,说明跳过了某些样本。

以下是补丁修改后的流程:

 通过运行“go_memstats_alloc_bytes / prometheus_tsdb_head_series”查询,我们可以知道(平均)每个时间序列需要多少内存,也可以知道每台服务器上有多少物理内存可用。也就是说,我们很容易计算出 Prometheus 能够存储的时间序列的大致数量,考虑到 Prometheus 是用 Go 编写的,所以会有垃圾收集开销:

Prometheus可用的内存 / 每个时间序列的字节数 = 我们的容量

这里介绍的内容并没有涵盖 Prometheus 所有复杂的细节,但可以让我们粗略地估计可以存储多少时间序列。

通过在所有 Prometheus 服务器上设置这个限制,就可以保证抓取的时间序列永远不会超过我们的内存容量。这是我们为避免 Prometheus 服务器因内存不足而崩溃所设置的最后一道防线。

第二个补丁修改了 Prometheus 处理 sample_limit 的方式——应用补丁后,Prometheus 不再是让整个抓取失败,而是简单地忽略了多出的时间序列。如果我们将 sample_limit 设置为 200,而应用程序输出了 201 个时间序列,那么除了最后一个时间序列外,其他时间序列都将被接收。

下面是一个设置了 sample_limit 选项的标准抓取流:

整个抓取要么成功,要么失败。Prometheus 只是简单地计算一次抓取中有多少个样本,如果超过 sample_limit 所允许的值,抓取就会失败。

我们的自定义补丁并不关心一次抓取了多少样本。取而代之,我们在将时间序列追加到 TSDB 时计算时间序列。一旦追加的样本数到了 sample_limit 个,我们就开始做选择了。

任何多出来的样本(数量达到 sample_limit 之后)只会在它们所属的时间序列在 TSDB 中已经存在时才会被追加。

我们之所以在超出 sample_limit 的情况下还允许追加一些样本,是因为向现有的时间序列追加样本成本很低,只是额外增加一个时间戳值对。

另一方面,新建时间序列的成本要高得多——我们需要分配新的 memSeries 实例,其中包含所有标签的副本,并且至少要在内存中保存一个小时。

以下是修改后的抓取流:

 这两个补丁都为我们提供了两个层面的保护。

TSDB 限制补丁保护整个 Prometheus 免于因为时间序列过多而过载。

这是因为阻止时间序列消耗内存的唯一方法是防止它们被追加到 TSDB。等它们进入 TSDB 就太晚了。

而 sample_limit 补丁可以防止单个抓取占用 Prometheus 太多的容量,那可能会导致创建的时间序列太多并耗尽 Prometheus 的全部容量(由第一个补丁强制执行),这反过来会影响所有其他的抓取,有些新的时间序列就不得不忽略。与此同时,我们的补丁会将每次抓取的时间序列限制在某个水平上,从而实现优雅地降级,而不是严重失败并从受影响的抓取中删除所有时间序列,那将意味着我们完全失去了受影响应用程序的可观察性。

值得一提的是,如果没有 TSDB 总限制补丁,我们就可以不断地向 Prometheus 添加新抓取到的时间序列,这可能会耗尽所有可用的容量,即使每个抓取都设置了 sample_limit,并且抓取的时间序列数少于这个限制所允许的时间序列数。

Prometheus 本身额外输出的指标可以告诉我们是否有任何抓取超出了限制,如果发生这种情况,我们会提醒负责的团队。

这样做的另一个好处是我们可以自助进行容量管理——不需要一个团队来审批,如果 CI 检查通过,就说明可以满足应用程序所需的容量。

我们喜欢优雅降级的主要原因是,我们希望工程师能够自信地部署应用程序及其指标,而不必成为 Prometheus 专家。这样,即使是最缺乏经验的工程师也可以开始输出指标,而不用总是担心“这会导致意外事件吗?”。

另一个原因是,试图掌握使用情况是一项具有挑战性的任务。从表面上看,这似乎很简单,毕竟你只需要阻止自己创建太多的指标,添加太多的标签或设置来自不可信来源的标签值。

实际上,这就像确保应用程序不要占用太多的 CPU 或内存资源一样简单——可以通过减少内存分配和计算来实现。没有什么比这更容易的了,直到你真正尝试去做。任何应用程序为你做的越多,它就越有用,需要的资源可能也就越多。你或客户的需求会随着时间的推移而变化,因此,你不能仅仅是画一条线界定它可以消耗多少字节或 CPU 周期。如果你这样做,那么你最终要多次重画这条线。

通常,指标的标签越多,你能获得的见解也越多。因此,你试图监视的应用程序越复杂,就需要额外添加越多的标签。

除此之外,在大多数情况下,我们不会同时看到所有可能的标签值,那通常是所有可能组合的一个小子集。例如,我们在前面的示例中使用的 errors_total 指标,可能在我们开始看到一些错误之前根本就不存在,即使看到了错误,也可能只记录一两个错误。工程师正在使用的许多标签都是如此。

也就是说,应用程序能输出多少个时间序列和实际输出多少个时间序列,是两个完全不同的数值,这增加了容量规划的难度。

特别是在处理由多个不同的团队共同维护的大型应用程序时,每个团队都会从各自的堆栈输出一些指标。

出于这个原因,我们确实容忍了一定比例的短期时间序列存在,即使它们并不是很适合 Prometheus,并且占用了我们比较多的内存。

文档

最后,我们维护了一组内部文档页面,指导工程师收集和使用指标,其中有许多特定于我们环境的信息。

在概念上,Prometheus 和 PromQL(Prometheus 查询语言)非常简单,但这意味着所有的复杂性都隐藏在整个指标管道的不同元素之间的交互中。

从工程的角度管理指标的整个生命周期是一个非常复杂的过程。

你必须定义应用程序的指标,所使用的名称和标签要便于你轻松地处理产生的时间序列。然后,你必须正确地配置 Prometheus 抓取,并将其部署到合适的 Prometheus 服务器。接下来,你可能需要创建记录和/或警报规则,以便可以利用生成的时间序列。最后,你会希望创建一个仪表板来可视化所有指标,并从中发现趋势。

在这个过程的各个阶段你都有可能会犯错。我们之前发表过一篇关于 Prometheus 的博文“监控我们的监控”,其中介绍了一些最基本的陷阱。在那篇博文中,我们还提到了其中一个用来帮助工程师编写有效的 Prometheus 报警规则的工具。

我们有良好的内部文档,提供了有关我们环境和最常见任务的所有基础知识,这非常重要。我们自己就能够回答“我怎么做 X?”,而不必等专家来指导,这让每个人都更有成效,都可以更快地采取行动,同时也避免了 Prometheus 专家一遍又一遍地回答同样的问题。

结语

Prometheus 是一个非常棒且非常可靠的工具,但是处理高基数问题,特别是在同一台 Prometheus 服务器从许多不同的应用程序抓取指标的情况下,可能会非常具有挑战性。

过去,我们遇到了很多 Prometheus 实例过载的问题,并开发了许多工具来帮助我们处理这些问题,包括自定义补丁。

但解决高基数问题的关键是更好地理解 Prometheus 的工作原理以及什么样的使用模式会导致问题。

深入了解 Prometheus 的内部结构,让我们得以维护一个快速可靠的可观察性平台,节省了许多审批环节。我们围绕它开发的工具(其中一些是开源的)帮助我们的工程师避免了许多最常见的陷阱,让他们可以放心部署。

原文链接:

https://blog.cloudflare.com/how-cloudflare-runs-prometheus-at-scale/

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/QFFPRKN7a6y3L1Q7geIh
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券