..

[译]我们如何将 Grafana Cloud Logs 的 Memcached 集群扩展到 50TB 并提高可靠性

作者:Danny Kopping

译者:aimuz

原文地址:https://grafana.com/blog/2023/08/23/how-we-scaled-grafana-cloud-logs-memcached-cluster-to-50tb-and-improved-reliability

Grafana Loki 是一个建立在云端对象存储服务上的开源日志数据库。这些服务是 Loki 能够实现巨大规模扩展的关键组成部分。然而,与所有 SaaS 产品一样,对象存储服务也有其限制——我们在Grafana Cloud Logs中也开始遇到这些限制,这是我们基于Grafana Loki提供的 SaaS 产品。

在这篇详细的博文中,我们将深入探讨最近在 Grafana Cloud Logs 中所遇到的一些性能和可靠性方面的挑战。当流量超过了云供应商对象存储服务的处理能力时,我们开始遇到这些问题。我们将介绍问题的检测、故障排除和分析过程,并详细说明我们如何设计和实施了一个解决方案,将缓存容量从 1.2TB 扩展到 50TB。

Grafana Loki 简介

Grafana Loki 是专门用于存储和搜索大量日志的时间序列数据库。日志行会连同时间戳、日志内容以及重要的标签被存入 Loki。

标签可以标识日志所属的应用程序、日志所来自的位置或用于识别一组特定日志所需的任何其他元数据。唯一的标签组合称为 ”流(stream)“,每个日志流会被压缩成 “块(chunks)”。 块是 Loki 内部的基本工作单元。当运行一个查询时,Loki 会从存储(通常是像 Amazon S3 这样的对象存储服务)中获取这些块,解压缩它们,并根据查询处理日志数据。

虽然这篇文章与 Grafana Cloud Logs 相关,但其中的经验教训适用于任何自我托管的 Loki 安装或 Grafana 企业日志

日志数据的访问模式

在可观测性的使用场景中,日志访问模式往往倾向于最近的数据。下图展示了在不同定义的时间 “桶(buckets)” 中消耗的块数据量。在 30 天的时间范围内,我们可以明显看到访问倾向于非常近期的数据(3 小时以内),近 24 小时内的块数据量几乎占了 70%。这表示存在非常高的时效性偏差。

显示随时间消耗的块数据量的图表

图 1:随时间消耗的块数据量

有几个原因导致这种倾向:

  1. 日志通常用于了解目前或最近发生的事情。
  2. Grafana 仪表板可以自动刷新,每次刷新时只检索最新数据。
  3. 警报和记录规则通常对最近的数据进行评估。

因此,起初看来足以只缓存最近几个小时的数据。在后续章节中,我们将看到我们的直觉甚至成功标准被证明是错误的。

缓存

为了理解日志数据(更广泛地说,大多数可观察性数据)的缓存模式,让我们比较两种类型的网站及其可能的缓存数据方式:

  • 新闻网站: 从本质上讲,新闻倾向于关注最近发生的事件。旧闻相对于当前新闻来说价值较低,因此最近发布的新闻文章会有更多的流量。一篇报道发布后,我们可能会看到类似于上图中的曲线:刚发布后请求率很高,然后随着时间推移逐渐减少。在某个时点,很少有人访问旧闻。
  • 流媒体服务: 另一方面,流媒体服务可能具有流行且经典的电视节目和电影。这些数据会在很长的时间跨度内被定期访问。

可观察性数据更像是一个新闻网站,而不是流媒体服务。任何一个块在缓存中作为“热”数据的时间都不会很长,不久后就会被另一个更新的块所替代。

为何以及如何缓存块

我们缓存块的主要目的是为了提高查询执行速度。当一个块不在缓存中时,我们必须从对象存储中获取它——这个过程可能缓慢、低效且昂贵。单个查询可以指向数十万甚至数百万个块,而对象存储 API 只允许每个 API 调用访问单个对象。

关于 Loki 如何缓存块有两点需要注意:

  1. Loki 缓存它获取用来执行查询的每一个块。 查询引擎首先会检查块是否在缓存中,若不在,则从对象存储中获取。块会被缓存在内存中,并异步写回到缓存中。
  2. Loki 会缓存所有由 ingesters 生成的块。 这样做有两个原因:首先,正如我们所见,日志访问主要集中在最近的数据上,并且 ingesters 创建的数据块包含了最新的数据。其次,这些块需要被写入对象存储,但由于 Loki 通过其分发器实现了复制功能,ingesters 有可能多次写入同一个块。一旦块被写入对象存储,它就会被存储在缓存中;在每次写入对象存储之前都会检查缓存,因此如果缓存存在,则 ingester 将不会尝试再次存储该块。

我们之前如何确定缓存大小

我们的 Grafana Cloud Logs 服务每天摄入数百 TB 的日志。该服务在三大云提供商的 20 多个区域中可用,自然,一些区域比其他区域繁忙。我们称 Loki 在这些区域中的每个安装为一个“单元”。

每个单元都包含一个 memcached 服务器集群。我们按比例设置这些集群的大小,大约是 1-3 小时内的块量。正如我们在图 1 中所看到的,使用这种容量,我们应该能够从缓存中提供大约 60% 的数据块。

当我们最初设置缓存时,我们的假设是所有单元都会遵循相同的数据访问模式。随着时间的推移,一些单元变得比其他单元大得多,我们的访问模式也开始改变。

访问模式的差异

我们最大的单元之一loki-prod3每天产生超过 6TB 的 块。它具有 200 个 memcached 实例,每个实例有 1 个 CPU 和 6GB 内存。拥有 1.2TB 缓存,这大约是 4-5 小时的容量。

我们的单元是多租户的,这意味着计算资源在客户之间共享。

随着时间的推移,一些单元开始会出现与整体模式截然不同的访问模式,这是由于客户对 Loki 的不同使用方式造成的。例如,在loki-prod3中,块数据量的分布开始显著不同:

loki-prod3中的块数据量分布差异图

图 2: loki-prod3中的块数据量分布差异

与通常模式相比,2⁄3 的块数据量请求都在最近 24 小时内,而这个单元的请求跨度是 5 天。这意味着这个单元中的客户更经常查看更老的数据(一般在 0-5 天之间),而整体模式主要查看最近 24 小时的数据。

然而令人困惑的是,当我们查看缓存的关键成功指标(命中率)时,一切似乎都在良好地运行。 让我们深入研究一下。

缓存的“黄金信号”:命中率

命中率被定义为从缓存中检索项目的命中次数与未命中次数的比率。较高的命中率通常表示缓存服务器性能良好(即,我们需要定期获取的项目在需要时可以从缓存中获得)。如果我们的缓存为空,命中率将为 0%,但随着缓存的填充,我们可能期望该比率接近 100%。

下图 3 显示了loki-prod3在 30 天内的缓存命中率:

loki-prod3 30天缓存命中率图

图 3: loki-prod3中的缓存命中率

我们可以看到一个相当令人满意的命中率:当我们需要时,从缓存中请求的项目中有 70%以上是存在的。这怎么可能呢?如果我们只能在缓存中存储大约 3 小时的数据,并且只有 35%的块数据量是针对过去 3 小时请求的,为什么会有如此高的命中率呢?

一个可能的(但不完整的)解释是 Loki 默认通过“间隔(intervals)”将范围查询拆分为并行执行。split_queries_by_interval 配置控制着这一点,默认为 30 分钟。然而,max_chunk_age默认为 2 小时 —— 这意味着单个块在短时间内可以由多个子查询引用。

命中率只是评估缓存行为的一种角度。正如我们将看到的,如果不考虑“翻新(Churn)”的背景,命中率可能会非常具有欺骗性。

翻新(Churn)

Memcached 使用最近最少使用(LRU)的缓存替换策略,以保留经常使用的项目并丢弃(删除)最不经常使用的项目,为新项目腾出空间。如果我们不断向缓存中添加项目,但缓存空间不足,则该机制会失效。

这里所定义的缓存“抖动(Churn)”是指一个被储存在缓存中却从未被取出就被驱逐的项目(Memcached 称之为未获取的驱逐)。对于缓存来说,翻新是一种特别糟糕的状态,因为它意味着缓存在最好的情况下无效,在最坏的情况下只是在浪费资源。

下图 4 显示了loki-prod3每小时的异常翻新率。

loki-prod3异常翻新率图

图 4: loki-prod3中的异常翻新率

这显示我们每小时驱逐出多达 1.5 亿个块以让出空间给新块!那些块在可以访问一次之前就被逐出了!

以不同的方式可见,在图 5 中,我们可以看到缓存被完全替换(意味着每个项目都被逐出)每小时高达 40 次:

缓存被完全替换图

图 5: 在loki-prod3中每小时的完全缓存替换次数

正如我们所见,即使缓存不断被翻新,命中率也可以相当高:我们的缓存访问模式倾向于访问最近的数据,几乎所有请求都会击中,只有少数请求会失败。

如我们所见,即使缓存不断被翻新,也有可能获得相当高的命中率:我们的缓存访问模式倾向于最近的数据,其中近乎全部会命中,而剩余的请求将导致未命中。

问题

我们的缓存太小,无法容纳我们需要的块数量。

我们从缓存中逐出的块越多,我们需要从对象存储中重新获取的块就越多。我们从对象存储获取的数据越多,查询处理就越慢,服务的运行成本就越高。

随着时间的推移,我们将面临一个更大的问题:对象存储限速

限速

Loki 与 Amazon S3、Google 云存储(GCS)和 Azure Blob 存储等对象存储服务提供一流的集成。这些服务使我们自己的 Grafana Cloud Logs 服务以及我们的社区都能够应对日益增长的需求扩展。

这些服务是按区域提供,多租户是其核心。与任何分布式系统一样,必须设置某些限制,以防止一个租户对其他租户的性能和可靠性产生负面影响(所谓的吵闹邻居问题)。这些限制以“限速(rate-limits)”的形式出现,它限制租户可以检索数据的速度或租户每秒可以发出的请求数。

在某些的条件下,Grafana Cloud Logs 就是那个吵闹的邻居。我们的租户会发出需要检索太多块数据的请求,这会发出太多请求给我们的对象存储提供商。

当我们达到一个特定阈值时,我们的请求就会被拒绝,并带有 429 Too Many Requests503 Service Unavailable。Loki 有能力重试失败的块请求,所以这不会导致查询失败 —— 但是它确实引入了额外的翻新和延迟。

“事件”

2022 年,一个大客户运行了涉及数百万块的大规模测试查询。这些块大多数没有在缓存中找到,因此是从对象存储中检索的。我们多次触发了速率限制,直到一个阈值被突破并应用了更为严格的速率限制;突然间,我们已经实际上已经 失去了对存储桶的所有访问权限 !这真的是一场噩梦,我们用了 24 小时才与服务提供商解决此问题。

这进一步增加了我们新的解决方案背后的动力:我们需要尽可能避免被限速以保持我们服务的高性能和可靠性。

为解决方案而努力

正如我们所见,这个loki-prod3单元和其他单元具有与整体模式截然不同的访问模式。我们需要根据这种需求调整该单元的缓存大小。我们需要一个解决方案,可以短期内缓解我们的问题,以便我们有时间在中长期内永久解决这些问题。

确定大小

正如我们在图 2 中所见,为了缓存临界质量(假设 2/3)的块,我们需要缓存大约 5 天的块。

loki-prod3此时每天生成 6-8TB 的数据块。我们稍微高估了一下,目标是存储满一周的数据块,这将需要 50TB 的缓存。

每日添加的块数据量图

图 6:每天添加到loki-prod3中的块数据量

成本

下表计算了我们以前的 memcached 集群中每个 CPU 核心和 GB RAM 在我们使用的实例类型上的每月成本。我们通过平均分配实例的成本来得出估算值

Google Cloud (来源)AWS (来源)
实例类型:n2-standard-8实例类型:m5.2xlarge
CPU:8CPU:8
内存:32GB内存:32GB
每月按需成本:$283.58每月按需成本:$280.32
每 CPU 成本:$17.72每 CPU 成本:$17.52
每 GB RAM 成本:$4.43每 GB RAM 成本:$4.43

回想一下,我们之前的缓存大小为 1.2TB。考虑需要增长到 50TB(增长了 42 倍),并且根据上述每 GB RAM 的成本,这将使我们每月的成本增加超过 20 万美元! 这显然是不可持续的,所以我们需要找到一个更具成本效益的选择。

本地 SSD

我们发现GCPAWSAzure都提供了某种形式的本地 SSD。在这种情况下,本地意味着物理上连接到虚拟机实例,这对缓存至关重要;缓存工作负载对延迟非常敏感。近年来,SSD 的容量不断增长,价格不断下降,吞吐量不断提高。SSD 的吞吐量和延迟现在只比 DRAM 慢几个数量级。这使得它们在缓存中变得非常有吸引力。

从成本上看,它们同样诱人。在撰写本文时,GCP 中 375GB 的 SSD 每个月只需 30 美元(或每个月每 GB 0.08 美元!)。AWS 和 Azure 的 SSD 内置于实例成本中,计算起来更复杂一些,但与每 GB RAM 的成本相比,它们仍代表了更具吸引力的每 GB 缓存成本。

使用这些本地 SSD,我们将能够将每 GB 缓存的成本降低约 98%,这使得该解决方案变得可行。现在我们知道了如何存储缓存,我们想知道如何管理它。

解决方案:memcached

事实证明,memcached 已经解决了基于 RAM 的缓存成本高昂的问题。2018 年年中,memcached添加了一个名为“extstore”的功能,似乎直接解决了这个问题。

从概念上说,extstore 非常简单:无法存入 LRU(在 RAM 中)的项目简单地过渡到磁盘。 实质上,extstore 将所有键保留在 RAM 中,值则在 RAM 和磁盘之间拆分。

它的简单性和易用性确实令人印象深刻。要使用它只需要指定磁盘上的路径和大小限制-o ext_path=/data/file:5G。我们不会在此细致介绍 extstore 的内部实现,因为这些已经在memcached 优秀的文档中有详尽的介绍。

使用 memcached extstore 对我们来说是一个极具吸引力的选择,原因有以下几点:

  1. 操作熟悉程度: 我们已经在生产中运行 memcached 集群已经很多年了,我们知道如何部署和升级它们;此外,这已经是 20 年历史的软件,非常稳定。
  2. 不需要任何代码更改: memcached 仍在公开相同的 API,客户端(我们的查询器组件)不知道一个项目是来自 RAM 还是来自磁盘;我们可以使用现有的 memcached 客户端代码而进行任何更改。
  3. 时间价值: 我们可以专注于解决问题,而不必与配置和调优作斗争。从测试到生产只用了两周!

此外,extstore 的编写方式充分考虑了 SSD 的运作机制,既不会加速磨损,又可以发挥 SSD 的最佳性能。这不仅提高了效率,还减缓了硬盘老化的速度。

此外,extstore 已以一种mechanically-sympathetic(指充分理解工具或系统的最佳操作方式的基础上使用它们)的方式编写,不会烧坏 SSD。这既提高了性能,又降低了硬盘老化的速度。

结果

我们在 2023 年 5 月 4 日在loki-prod3中配置了一个可存储超过 1 亿个块、容量达 50TB 的缓存。

对象存储

下图 7 显示我们将一个月的观测窗口内将对象存储的请求减少了 65%

对象存储请求减少图

图 7: 对象存储请求数量的减少

限速已经几乎完全消除。 尽管我们仍预计基于重型查询的本地热点情况可能会有限速问题,但总体而言,我们的建模是正确的:通过增加缓存大小以容纳大量对象,从那时起我们很少再遇到限速问题。

限速响应减少图

图 8: 5 月 4 日推出后,限速响应急剧下降

成功指标

缓存命中率在推出后显着提高到 90%以上,并且随着正在运行的查询被动填充缓存,命中率继续提高。

缓存命中率提高图

图 9: 缓存命中率的提高

翻新在推出也大幅下降

翻新下降图

图 10: 翻新减少

在图 11 中以不同的方式进行可视化,注意在推出之前我们从存储和缓存中获取的数据量大致相同。在推出之后,绝大部分的块体积都是从缓存中检索到的。这或许是最能代表我们成功的单一图表。

从缓存与存储获取图

图 11: 从缓存与存储获取

查询吞吐量的50 和 99 百分位轻微提高。我们实际上希望从这次变更中看到进一步的性能提升,但显然我们当前的瓶颈不在数据访问上。

A graph showing query throughputs

查询吞吐量图

图 12:查询吞吐量,第 50 和第 99 百分位数,以及每日均值

成本

下表比较了我们以前和新的 memcached 配置:

以前配置新的配置
共享n2-standard-32节点专用n2-highcpu-8节点
200 个实例33 个实例
1 个 CPU,6GB RAM6 个 CPU,5GB RAM
-4 x 375GB SSD(1.5TB)
总缓存:1.2TB总缓存:约 50TB
每月成本:$8860每月成本:$8708 (-$152)

这是 42 倍的容量,成本却几乎相同!

权衡

软件工程需要仔细平衡权衡。我们的解决方案并不完美;一起来看看缺点。

延迟

如预期的那样,在体系结构中添加硬盘会导致延迟急剧增加:

延迟增加图

图 13: memcached 延迟增加

这是测量querier组件与 memcached 之间请求的中位数(50%)延迟。在推出前,延迟相当稳定——通常在个位数毫秒范围内;在推出后,我们看到延迟变得更加不稳定。SSD 确实有它们的局限性,而 memcached extstore 尽可能地解决了这些局限性。我们的工作负载基本上是 随机的(与 顺序 相反),而硬盘在随机读写方面的性能往往较差。

但是,当您考虑两个因素时,这种增加的延迟是完全可以容忍的::

  1. 缓存操作可以批处理,而对象存储操作对每个对象进行。
  2. 对象存储延迟通常要糟糕得多——终究还是有一个硬盘在某个地方(“云就是别人的计算机”),并且中间还有一个网络,因此根据定义,它会比仅访问本地磁盘慢。

缓存与对象存储延迟图

图 14:缓存与对象存储延迟

操作复杂性

通过在缓存体系结构中引入硬盘,我们现在有一些新的故障模式:

  • 磁盘可能会填满: 如果 SSD 被填满,memcached 将无法写入任何新对象。为了缓解这个问题,需要配置一个合适的ext_path大小,如上所述(注意文件系统保留空间!); extstore 永远不会超过此大小。

  • 硬盘会老化: 随着 SSD 收到的写入次数增加,它们的健康状况会下降,延迟会开始增加。我们通过监控每个磁盘的读/写延迟来缓解这个问题,如果它突破了某个阈值(30 分钟内平均延迟为 50 毫秒,则认为硬盘运行状况太差而无法使用。在这种情况下,我们会排空该节点并重新设置(希望是新的或已修复的)SSD。

  • 硬盘不由我们管理: 底层磁盘可能会遇到一些硬件故障,在更换或修复硬盘时,挂载该磁盘的整个实例可能会被关闭。 在这些情况下,我们不知道实例为何宕机,并且我们将丢失该部分缓存。

更大的损失

更大的缓存意味着实例故障会带来更大的损失。以前我们有 200 个运行 memcached 的实例,因此失去一个实例将是缓存的 0.5%(6GB)。现在有了 33 个实例,每个实例故障将丢失约 3%(1500GB)的缓存。单独的实例故障是经过设计和预期的(这就是为什么我们没有设置 17 个每个都有 3000GB SSD 的实例),但一次性丢失如此多的数据确实是一个问题。虽然我们可以重新填充缓存,但这需要时间和金钱,因为我们从对象存储中被动地填充缓存。

持久性

与损失相关,我们应讨论这些 SSD 上的数据持久性。无论是GCP还是AWS都不能保证 SSD 在所有情况下都能保持其状态。此外,memcached 当前无法“重建”缓存(但正在积极进行相关工作)。这些并非严格意义上的权衡,因为先前的缓存在 RAM 中时也不具备持久性。

现在,当需要升级 memcached 集群或底层计算实例时,我们会丢失整个缓存。一旦 extstore 可以从硬盘重建缓存,缓存将在 memcached 服务器重启之后保持持久。当底层计算基础设施发生升级时,需要销毁现有实例并创建新实例时,我们目前没有计划保留缓存的机制。

其他考虑因素

这篇文章已经很长了,所以我们省略了对网络吞吐量的讨论,但如果不提及它将是不完整的。监控和运维也值得简要提及一下。

网络吞吐量

在处理如此规模的数据时,网络吞吐量是云计算中的一个限制因素。我们的loki-prod3缓存最终需要 500Gbps 的网络吞吐量,而每个n2-highcpu-8实例类型的额定吞吐量为 16Gbps:总共 33 个实例的吞吐量达到 528Gbps。

监控与运维

在分析、部署和监控此解决方案时,我们广泛使用了 Prometheus、Alertmanager、Grafana(当然还有 Loki!)。memcached_exporternode_exporter在观察缓存及其底层硬盘和网络的行为方面至关重要。

致谢

Memcached 维护者@dormando 在这个项目中提供了极大的帮助,他非常慷慨地花时间。谢谢!另外,特别感谢我的同事Ed Welch,他帮助设计和实施了这些变更。