DoubleCloud 即将停止服务。利用限时免费迁移服务迁移到 ClickHouse。立即联系我们 ->->

博客 / 工程

如何使用 ClickHouse 构建 19 PiB 的日志平台并节省数百万美元

author avatar
Rory Crispin
2024 年 4 月 2 日

简介

我们相信尽可能地试用我们自己的技术,尤其是在解决我们认为 ClickHouse 最适合解决的挑战时。去年,我们详细解释了如何使用 ClickHouse 构建内部数据仓库以及我们面临的挑战。在这篇文章中,我们将探讨一个不同的内部用例:可观测性,以及我们如何使用 ClickHouse 来满足我们的内部需求并存储 ClickHouse Cloud 生成的海量日志数据。正如您将在下面看到,这每年为我们节省了数百万美元,并使我们能够扩展 ClickHouse Cloud 服务,而无需担心可观测性成本,也不必对保留的日志数据进行妥协。

为了让其他人从我们的旅程中受益,我们提供了我们自己的 ClickHouse 驱动的日志解决方案的详细信息,该解决方案包含19 PiB 未压缩数据或 37 万亿行,仅针对我们的 AWS 区域。作为一般的设计理念,我们希望将移动部件的数量降至最低,并确保设计尽可能简单且可复制。

正如我们稍后在定价分析部分中展示的那样,对于我们的工作负载,ClickHouse 的成本至少是 Datadog 的 200 倍,假设 Datadog 和 AWS 的价格列表用于托管 ClickHouse 和将数据存储在 S3 中。

一个小团队,面临着巨大的挑战

当我们首次发布 ClickHouse Cloud 的初始产品时,我们不得不做出一些妥协。虽然我们当时认识到 ClickHouse 可以用来构建可观测性解决方案,但我们的首要任务是构建 Cloud 服务本身。为了尽快提供一流的服务,我们最初决定使用 Datadog 作为云可观测性的公认市场领导者,以加快上市时间。

虽然这使我们能够快速将 ClickHouse Cloud 推向 GA,但 Datadog 的账单很快变得无法承受。由于 ClickHouse 服务器和 Keeper 日志占我们收集的日志数据的 98%,因此我们的数据量实际上会随着我们部署的集群数量而线性增长。

我们对这一挑战的最初反应是做大多数 Datadog 用户可能被迫做的事情 - 考虑限制数据保留时间以降低成本。虽然将数据保留时间限制为 7 天可以有效地控制成本,但这与我们用户(我们的核心和支持工程师调查问题)的需求以及提供一流服务的首要目标相冲突。如果在 ClickHouse 中发现问题(是的,我们确实有 bug),我们的核心工程师需要能够在长达 6 个月的时间内搜索所有集群中的所有日志。

对于 30 天的保留期限(距离我们 6 个月的需求还很远),在任何折扣之前,Datadog 的价格列表为每百万事件 2.5 美元,每 GB 摄取 0.1 美元(假设为年度合同),这意味着我们目前的 5.4 PiB/10.17 万亿行每月预计成本为 2600 万美元。对于我们需要的 6 个月的保留时间,好吧,我们只能说没有考虑它。

选择 ClickHouse

有了大规模运营 ClickHouse 的经验,知道对于相同的工作负载它会便宜得多,并且看到其他公司已经使用 ClickHouse 构建了他们的日志解决方案(例如,highlight.ioSignoz),我们优先考虑将我们的日志数据存储迁移到 ClickHouse,并组建了一个内部可观测性团队。我们只有 1.5 名全职工程师组成的一个小团队,在三个月内就构建了我们的 ClickHouse 驱动的日志平台“LogHouse”!

我们的内部可观测性团队负责的不仅仅是 LogHouse。我们承担着更广泛的任务,即帮助 ClickHouse 的工程师了解我们正在运行的所有 ClickHouse Cloud 集群的状况,因此我们提供了一些服务,包括一个警报服务,用于检测应主动解决的集群行为模式。

问题的规模

截至撰写本文时,ClickHouse Cloud 在 9 个 AWS 区域和 4 个 GCP 区域可用,Azure 支持即将推出。我们最大的区域每秒产生超过 110 万行日志。在所有 AWS 区域中,这产生了惊人的数字:logging_01.png

您可能立即注意到,我们在这里达成的压缩级别 - 19 PiB 压缩到大约 1.13 PiB,或 17 倍压缩。ClickHouse 达到这种压缩级别对于项目的成功至关重要,它使我们能够以经济高效的方式扩展规模,同时仍然提供良好的查询性能。

我们的整个 LogHouse 环境目前超过 19 PiB。这个数字仅包含我们的 AWS 环境。

ClickHouse Cloud 基础设施

我们在之前的文章中讨论过 ClickHouse Cloud 背后的架构。与我们的日志解决方案相关的该架构的主要特点是,ClickHouse 实例作为 Kubernetes 中的 pod 部署,并由自定义操作符进行编排。

pod 将日志记录到stdoutstderr,并由Kubernetes 作为文件捕获,符合标准配置。然后,OpenTelemetry 代理可以读取这些日志文件并将它们转发到 ClickHouse。

虽然我们的大部分数据来自 ClickHouse 服务器(在高负载下,某些实例每秒可以记录 4000 行日志)和 Keeper 日志,但我们也从云数据平面收集数据。 这包括运行在节点上的操作员和支持服务的日志。 对于 ClickHouse 集群编排,我们依赖于 ClickHouse Keeper(由我们核心团队开发的 C++ ZooKeeper 替代方案),它会生成涉及集群操作的详细日志。 虽然 Keeper 日志在任何时刻都占流量的约 50%(相比之下,ClickHouse 服务器日志占 49%,数据平面占 1%),但该数据集的保留时间更短,为 1 周(相比之下,服务器日志为 6 个月),这意味着它只占整体数据的很小一部分。

在 ClickHouse Cloud 之上构建

由于我们正在监控 ClickHouse Cloud,为 LogHouse 提供动力的集群无法位于 ClickHouse Cloud 基础设施本身 - 否则,我们将在被监控的系统和监控工具之间创建依赖关系。

但是,我们仍然希望从 ClickHouse Cloud 背后的技术中获益 - 特别是通过 SharedMergeTree 表引擎分离存储和计算。 主要的是,这让我们能够从在 S3 中存储我们的数据以及在本地使用大型 NVMe 缓存中获益,并且允许我们几乎无限地扩展我们的集群。 鉴于我们的数据量(随着我们接入更多客户,数据量只会增加),我们不想承担由于磁盘空间限制而必须配置更多集群和/或节点的挑战。 通过将所有数据存储在 S3 中,节点仅使用本地 NVMe 用于缓存,我们可以轻松地扩展并专注于数据本身。

logging_02.png

因此,我们在每个区域运行我们自己的“迷你 ClickHouse Cloud”(见下文),甚至使用 ClickHouse Cloud 的 Kubernetes 操作员。 能够使用“云”对于我们能够在如此短的时间内构建解决方案以及使用小型团队管理基础设施至关重要。 我们真正站在巨人的肩膀上(即 ClickHouse 核心团队)。

虽然此解决方案特定于我们的需求,但它等同于 ClickHouse Cloud 的 Dedicated Tier 产品,其中客户的集群部署在专用基础设施上,允许他们控制部署的各个方面,例如更新和维护窗口。 最重要的是,其他想要复制我们解决方案的用户不会受到相同的循环依赖约束,并且可以直接使用 ClickHouse Cloud 集群。

我们目前正在将内部集群(如 LogHouse)迁移到我们的 ClickHouse Cloud 基础设施中。 在那里,它将被隔离在单独的 Kubernetes 集群中,但除此之外将是一个“标准”的 ClickHouse Cloud 集群,与我们的客户使用的相同。

了解您的用户

使用 ClickHouse 进行日志存储要求用户 拥抱基于 SQL 的可观察性。 主要的是,这意味着用户能够舒适地使用 SQL 和 ClickHouse 的 大量字符串匹配分析函数 来搜索日志。 这种采用通过 Grafana 等可视化工具变得更加简单,它提供了 用于常见可观察性操作的查询构建器

在我们的例子中,我们的用户对 SQL 非常熟悉,因为他们是 ClickHouse 的支持和核心工程师。 虽然这有利于查询通常被高度优化,但它也带来了自己的挑战。 最值得注意的是,这些用户是高级用户,他们只会在初始分析中使用基于可视化的工具,然后希望通过 ClickHouse 客户端直接连接到 LogHouse 实例。 这意味着我们需要一种简单的方法让用户能够轻松地在任何仪表板和客户端之间导航。

在大多数情况下,对云问题的调查是由 Prometheus 收集的集群指标触发的警报启动的。 它们会检测可能存在问题的行为(例如,大量分区),这些行为应该被调查。 或者,客户可以通过支持渠道提出一个问题,表明出现异常行为或无法解释的错误。

一旦需要更深入的分析,日志就会与调查相关。 到那时,我们的支持或核心工程师已经确定了客户集群、其区域以及相关的 Kubernetes 命名空间。 这意味着大多数搜索都按时间和一组 pod 名称进行筛选 - 后者可以很容易地从 Kubernetes 命名空间中识别出来。

我们稍后将展示的架构针对这些特定的工作流程进行了优化,并有助于在多 PB 规模下提供出色的查询性能。

一些早期的设计决策

以下早期的设计决策对我们最终的架构产生了重大影响。

不跨区域发送数据

首先,我们不跨区域发送日志数据。 鉴于即使是小型区域也会产生大量数据,从数据出站成本的角度来看,集中式日志记录是不可行的。 相反,我们在每个区域都托管一个 LogHouse 集群。 正如我们将在下面讨论的那样,用户仍然可以跨区域查询。

不使用 Kafka 队列作为消息缓冲区

使用 Kafka 队列作为消息缓冲区是一种流行的设计模式,在日志记录架构中很常见,并且由 ELK 堆栈普及。 它提供了一些优势; 主要的是,它有助于提供更强的消息传递保证并帮助处理背压。 消息从收集代理发送到 Kafka 并写入磁盘。 从理论上讲,一个集群化的 Kafka 实例应该提供一个高吞吐量的消息缓冲区,因为它比解析和处理消息所产生的计算开销更少,例如在 Elastic 中,标记化和索引会产生大量的开销。 通过将数据从代理中移开,你也会降低由于 源日志轮换而丢失消息的风险。 最后,它提供了一些消息回复和跨区域复制功能,这对于某些用例来说可能很有吸引力。

但是,ClickHouse 可以非常快地处理数据的插入 - 在中等硬件上每秒数百万行。 ClickHouse 很少出现背压。 因此,在我们的规模下,利用 Kafka 队列没有任何意义,并且意味着比必要的多出更多的架构复杂性和成本。 在确定这种架构时,我们也接受了一个重要的原则 - 并非所有日志都需要相同的交付保证。 在我们的例子中,我们对传输过程中数据丢失的容忍度更高,因为我们还有日志的第二份副本(在实例本身),如果我们需要它们 - 尽管如此,我们仍然希望将传输过程中数据丢失降至最低,因为丢失的消息有可能破坏调查。

改进传输过程中数据丢失

我们目前的传输过程中数据丢失率比我们满意的要高。 我们将其归因于几个因素

  1. 我们的摄取层缺乏自动缩放功能,当在 ClickHouse Cloud 中启动特定事件(例如更新)时,该层可能容易受到流量峰值的攻击。 尽管(2),但这是我们打算开发的功能。
  2. 我们在 OTEL 收集器中发现了一个问题,即代理和网关之间的连接没有均匀分布,导致网关出现“热点”,其中单个收集器接收的负载百分比更高。 该收集器不堪重负,当与(1)结合时,我们经历了传输过程中日志丢失率的增加。 我们正在解决此问题,并打算将修复程序贡献回来,以便其他人可以从中受益。

我们目前没有利用 ClickHouse Cloud 的自动缩放功能,因为 ClickHouse 本身很少出现背压。 但是,随着我们解决和解决上述问题,我们的 ClickHouse 实例可能会遇到更大的流量峰值。 到目前为止,收集器中现有的挑战充当了缓冲区,保护 ClickHouse 免受这种波动。 展望未来,因此我们可能需要探索实施 ClickHouse Cloud 的自动缩放器的潜在优势,以便更好地管理这些预期的需求增长。

尽管存在这些挑战,但我们并不后悔我们不部署 Kafka 的决定。 通过接受这一原则,我们得到的架构更简单、更便宜、延迟更低,并且我们相信可以通过采取上述措施将传输过程中数据丢失率降低到可以接受的水平。

结构化日志记录

我们想说,我们的日志始终是结构化的,并且干净地插入到一个完美的模式中。 但是,情况并非如此。 我们最初的部署将日志作为纯文本字符串发送,用户依赖于 ClickHouse 字符串函数来提取元数据。 这是由于我们的用户希望以其原始形式使用日志而驱动的。 虽然这仍然提供了很好的压缩,但查询性能下降了,因为每个查询都产生了线性扫描。 这是一个最初的架构错误,我们建议你不要重复。

在与我们的用户讨论了优缺点之后,我们转向了结构化日志记录,ClickHouse 实例以 JSON 格式记录日志。 正如下面所述,这些 JSON 键默认情况下存储在 Map(String, String) 类型中。 但是,我们将选择我们期望频繁查询的字段在插入时作为完整列提取。 然后,这些字段可以在我们的排序键中使用,并配置为利用 二级索引专门编解码器。 这允许对 pod_name 等列进行优化,确保最常见的工作流程针对查询性能进行了优化。 用户仍然可以访问地图键以获取不太常用的元数据。 但是,根据我们的经验,很少在优化列上过滤的情况下进行此操作。

选择 OpenTelemetry (OTel)

使用 OpenTelemetry 的决定是我们最大的设计决定,因此值得单独讨论。 这也代表了我们最初最担心的决定,因为当我们开始项目时,ClickHouse OTel 导出器 在日志收集方面(与跟踪相比)是相对未经验证的 - 它处于 Alpha 阶段,我们不知道有任何在我们的规模上部署的案例。 我们投资的决定基于以下几个因素

  • 社区采用 - 整个 OTel 项目已经获得了显著的采用。 我们还看到其他公司成功地使用 ClickHouse 导出器,尽管它处于 Alpha 阶段。 此外,许多使用 ClickHouse 的可观察性公司,例如 highlight.ioSignoz,使用 OTel 作为他们的标准摄取方式。
  • 投资未来 - 项目本身已经达到了成熟度和采用程度,这表明它将成为收集日志、跟踪和指标的事实上的方式。 Dynatrace、Datadog 和 Splunk 等专有可观察性供应商的投资表明其作为标准的出现得到了更广泛的认可。
  • 超越日志 - 我们考虑了其他日志收集代理,特别是 Fluentd 和 Vector,但我们想要一个能够让我们轻松地将 LogHouse 扩展到以后收集指标和跟踪的堆栈。

自定义处理器

OTel 的常见烦恼之一是需要在 YAML 中声明复杂的管道,将接收器、处理器和发送器链接起来。 我们在 OTEL 收集器实例(在网关层 - 见下文)中执行了大量处理,需要根据源进行条件路由,例如 ClickHouse 实例、数据平面和 Keeper 日志都将发送到具有优化模式的单独表格。

根据我们的经验,管理这个 YAML 代码很容易出错,并且难以测试。 而不是依赖于它,我们开发了一个 用 Go 编写的自定义处理器,它执行我们所有需要的转换逻辑。 这意味着我们部署了 OTEL 收集器的自定义版本,但我们能够轻松地测试对处理的任何更改。 此内部管道使用自定义处理器也比使用声明性方法构建的等效管道更快,从而进一步节省了我们的资源并减少了端到端延迟。

架构概述

从高层次来看,我们的管道如下所示

logging_03.png

我们将 OTel 收集器部署为每个 Kubernetes 节点的代理以进行日志收集,并作为网关,在消息发送到 ClickHouse 之前对其进行处理。这是一种经典的代理-网关架构,由 Open Telemetry 文档化,它允许将日志处理的开销从节点本身移开。这有助于确保代理资源占用尽可能小,因为它只负责将日志转发到网关实例。由于网关执行所有消息处理,因此随着 Kubernetes 节点数量(以及日志数量)的增加,网关也必须进行扩展。正如我们将在下面展示的那样,这种架构使我们每个节点都有一个代理,其资源分配基于底层实例类型。这是基于以下推理:较大的实例类型具有更多的 ClickHouse 活动,因此产生更多日志。然后,这些代理将它们的日志转发到用于处理的“网关组”,该网关组根据总日志吞吐量进行扩展。

扩展到上面,单个区域(AWS 或 GCP,很快将是 Azure)可能看起来像下面这样

logging_04.png

应该立即清楚的是,这在架构上是多么简单。关于这一点,有几个重要说明

  • 我们目前的 ClickHouse 云环境(AWS)使用的是 m5d.24xlarge (96 个 vCPU,384GiB 内存)、m5d.16xlarge (64 个 vCPU,256GiB 内存)、m5d.8xlarge (32 个 vCPU,128GiB 内存)、m5d.2xlarge (8 个 vCPU,32GiB 内存) 和 r5d.2xlarge (8 个 vCPU,64GiB 内存) 的混合。由于 ClickHouse 云允许用户创建具有不同总资源大小的集群(使用固定的 1:4 vCPU:RAM 比例),因此,通过垂直和水平扩展,ClickHouse Pod 的实际大小可能会发生变化。如何将这些 Pod 打包到节点上本身就是一个主题,但我们建议参考 这篇博文。一般来说,这意味着较大的实例类型会托管更多的 ClickHouse Pod(这些 Pod 会生成我们的大部分日志数据)。这意味着实例大小与需要由代理转发日志数据量之间存在很强的关联性。
  • OTel 收集器代理部署为 每个 Kubernetes 节点的守护进程集。分配给这些代理的资源取决于底层实例的大小。一般来说,我们有三种不同资源的“T 恤尺寸”:小、中和大。对于 m5d.16xlarge 实例,我们使用大型代理,该代理分配 1 个 vCPU 和 1GiB 内存。这足以处理从该节点上的 ClickHouse 实例收集日志,即使节点已完全占用。我们在代理级别执行最少的处理以最大程度地减少资源开销,只从日志文件中提取时间戳以覆盖 Kubernetes 观察到的时间戳。为代理分配更高的资源会对托管成本之外的事物产生影响。重要的是,它们会影响我们的 操作员在节点上有效打包 ClickHouse 实例的能力,这可能会导致驱逐和资源利用率低下。以下显示了节点大小到代理资源的映射(仅限 AWS)
Kubernetes 节点大小收集器代理“T 恤尺寸”分配给收集器代理的资源日志速率
m5d.16xlarge大型1 个 CPU,1GiB10k/秒
m5d.8xlarge中型0.5 个 CPU,0.5GiB5k/秒
m5d.2xlarge小型0.2 个 CPU,0.2GiB1k/秒
  • OTel 收集器代理通过配置为 使用拓扑感知路由 的 Kubernetes 服务将数据发送到网关实例。由于每个区域在每个可用区中至少有一个网关(为了高可用性),因此这确保默认情况下数据尽可能路由到同一个可用区。在更大的区域中,由于日志吞吐量更高,我们会配置更多网关来处理负载。例如,在我们最大的区域中,我们目前有 16 个网关来处理 1.1M 行/秒的吞吐量。每个网关都有 11 GiB 内存和 3 个内核。
  • 由于我们的 LogHouse 实例驻留在不同的 Kubernetes 集群中,为了与云保持隔离,网关通过集群的 NLB 将流量转发到这些 ClickHouse 实例。目前,这并非区域感知,导致区域间通信比最佳情况更高。因此,我们正在努力确保此 NLB 利用最近发布的 可用区 DNS 亲缘性
  • 我们的网关和代理都由 Helm 图表部署,这些图表作为由 ArgoCD 编排的 CI/CD 管道的组成部分自动部署,配置存储在 Git 中。
  • 我们的收集器网关通过 Prometheus 指标进行监控,警报指示它们何时遇到资源压力。
  • 我们的 ClickHouse 实例使用与云相同的部署架构 - 3 个 Keeper 实例分布在不同的可用区中,ClickHouse 也相应地分布。
  • 我们最大的 LogHouse ClickHouse 集群包含 5 个节点,每个节点有 200GiB 内存和 57 个内核,部署在 m5d.16xlarge 上。它每秒处理超过 1M 行插入操作。
  • 目前,每个区域部署了一个 Grafana 实例,其流量通过 NLB 在其本地 LogHouse 集群中进行负载均衡。但是,正如我们在下面更详细地讨论的那样,每个 Grafana 都能够访问其他区域(通过 TailScale VPN 和 IP 白名单)。

调整网关和处理背压

鉴于我们的网关执行了我们大部分数据处理工作,因此从这些网关获得性能对于最大限度地降低成本和提高吞吐量至关重要。在测试过程中,我们发现每个网关(具有 3 个内核)可以处理大约 60K 个事件/秒。

我们的网关不执行数据的磁盘持久化,只在内存中缓冲事件。目前,我们的网关分配了 11 GiB 内存,并在每个区域中部署了足够的数量,以便在 ClickHouse 偶尔不可用时提供长达 2 小时的缓冲时间。因此,我们目前的内存与 CPU 比例以及网关数量是吞吐量和提供足够的内存来缓冲日志消息(以防 ClickHouse 出现故障)之间的折衷。我们更愿意水平扩展网关,因为它提供了更好的容错能力(如果某个 EC2 节点出现问题,我们面临的风险会更小)。

重要的是,如果缓冲区已满,队列前端的事件将被丢弃,即网关始终会从代理本身接受事件,我们不会在这些事件上应用背压。但是,我们的 2 小时窗口已被证明足够了,OTel 管道问题很少影响我们的数据保留质量。也就是说,我们目前也在探索 OTel 收集器将数据缓冲到我们的网关磁盘的能力 - 这可能允许我们提供更强的对下游问题的弹性,同时还可能减少网关的内存占用空间。

logging_05.png

logging_06.png

我们在初始部署期间进行了一些基本测试以确定最佳批处理大小。这是在希望高效地将数据插入 ClickHouse 和确保日志及时可用以进行搜索之间的折衷。更具体地说,虽然较大的批次通常更适合 ClickHouse 的插入操作,但这必须与数据可用性和网关上的内存压力(以及非常大的批处理大小时的 ClickHouse)相平衡。我们最终确定将 批处理处理器 的批处理大小设置为 15K 行,这提供了我们所需的吞吐量,并满足了我们 2 分钟的数据可用性 SLA。

最后,我们的吞吐量确实存在低谷期。因此,我们还会在 5 秒后刷新网关中的批处理处理器(请参阅 timeout),以确保数据在上述 SLA 内可用。由于这可能会导致更小的插入操作,因此我们遵守 ClickHouse 的最佳实践并依赖于 异步插入

摄取处理

物化视图

OTel 收集器将字段整理到两个主要映射中:ResourceAttributesLogAttributes。前者包含由代理实例添加的字段,在我们的例子中,主要包括 Kubernetes 元数据,例如 Pod 名称和命名空间。相反,LogAttributes 字段包含日志消息的实际内容。由于我们在结构化 JSON 中记录信息,因此它可以包含多个字段,例如线程名称和生成日志的源代码行。

{
  "Timestamp": "1710415479782166000",
  "TraceId": "",
  "SpanId": "",
  "TraceFlags": "0",
  "SeverityText": "DEBUG",
  "SeverityNumber": "5",
  "ServiceName": "c-cobalt-ui-85-server",
  "Body": "Peak memory usage (for query): 28.34 MiB.",
  "ResourceSchemaUrl": "https://opentelemetry.io/schemas/1.6.1",
  "ScopeSchemaUrl": "",
  "ScopeName": "",
  "ScopeVersion": "",
  "ScopeAttributes": "{}",
  "ResourceAttributes": "{
      \"cell\":\"cell-0\",
      \"cloud.platform\":\"aws_eks\",
      \"cloud.provider\":\"aws\",
      \"cluster_type\":\"data-plane\",
      \"env\":\"staging\",
      \"k8s.container.name\":\"c-cobalt-ui-85-server\",
      \"k8s.container.restart_count\":\"0\",
      \"k8s.namespace.name\":\"ns-cobalt-ui-85\",
      \"k8s.pod.name\":\"c-cobalt-ui-85-server-ajb978y-0\",
      \"k8s.pod.uid\":\"e8f060c5-0cd2-4653-8a2e-d7e19e4133f9\",
      \"region\":\"eu-west-1\",
      \"service.name\":\"c-cobalt-ui-85-server\"}",
  "LogAttributes": {   
      \"date_time\":\"1710415479.782166\",
      \"level\":\"Debug\",
      \"logger_name\":\"MemoryTracker\",
      \"query_id\":\"43ab3b35-82e5-4e77-97be-844b8656bad6\",
      \"source_file\":\"src/Common/MemoryTracker.cpp; void MemoryTracker::logPeakMemoryUsage()\",
      \"source_line\":\"159\",
      \"thread_id\":\"1326\",
      \"thread_name\":\"TCPServerConnection ([#264])\"}"
}

重要的是,这些映射中的键可能会发生变化,并且是半结构化的。例如,任何时候都可以引入新的 Kubernetes 标签。虽然 ClickHouse 中的 Map 类型非常适合收集任意键值对,但它并不提供最佳的查询性能。从 24.2 开始,对 Map 列的查询需要解压缩并读取整个映射值 - 即使只访问一个键。我们建议用户使用物化视图将最常查询的字段提取到专用列中,这在 ClickHouse 中很容易实现,并且可以显着提高查询性能。例如,上面的消息可能会变成

{
  "Timestamp": "1710415479782166000",
  "EventDate": "1710374400000",
  "EventTime": "1710415479000",
  "TraceId": "",
  "SpanId": "",
  "TraceFlags": "0",
  "SeverityText": "DEBUG",
  "SeverityNumber": "5",
  "ServiceName": "c-cobalt-ui-85-server",
  "Body": "Peak memory usage (for query): 28.34 MiB.",
  "Namespace": "ns-cobalt-ui-85",
  "Cell": "cell-0",
  "CloudProvider": "aws",
  "Region": "eu-west-1",
  "ContainerName": "c-cobalt-ui-85-server",
  "PodName": "c-cobalt-ui-85-server-ajb978y-0",
  "query_id": "43ab3b35-82e5-4e77-97be-844b8656bad6",
  "logger_name": "MemoryTracker",
  "source_file": "src/Common/MemoryTracker.cpp; void MemoryTracker::logPeakMemoryUsage()",
  "source_line": "159",
  "level": "Debug",
  "thread_name": "TCPServerConnection ([#264])",
  "thread_id": "1326",
  "ResourceSchemaUrl": "https://opentelemetry.io/schemas/1.6.1",
  "ScopeSchemaUrl": "",
  "ScopeName": "",
  "ScopeVersion": "",
  "ScopeAttributes": "{}",
  "ResourceAttributes": "{
      \"cell\":\"cell-0\",
      \"cloud.platform\":\"aws_eks\",
      \"cloud.provider\":\"aws\",
      \"cluster_type\":\"data-plane\",
      \"env\":\"staging\",
      \"k8s.container.name\":\"c-cobalt-ui-85-server\",
      \"k8s.container.restart_count\":\"0\",
      \"k8s.namespace.name\":\"ns-cobalt-ui-85\",
      \"k8s.pod.name\":\"c-cobalt-ui-85-server-ajb978y-0\",
      \"k8s.pod.uid\":\"e8f060c5-0cd2-4653-8a2e-d7e19e4133f9\",
      \"region\":\"eu-west-1\",
      \"service.name\":\"c-cobalt-ui-85-server\"}",
  "LogAttributes": "{
      \"date_time\":\"1710415479.782166\",
      \"level\":\"Debug\",
      \"logger_name\":\"MemoryTracker\",
      \"query_id\":\"43ab3b35-82e5-4e77-97be-844b8656bad6\",
      \"source_file\":\"src/Common/MemoryTracker.cpp; void MemoryTracker::logPeakMemoryUsage()\",
      \"source_line\":\"159\",
      \"thread_id\":\"1326\",
      \"thread_name\":\"TCPServerConnection ([#264])\"}"
}

为了执行此转换,我们利用 ClickHouse 中的物化视图。我们不是将数据插入到 MergeTree 表中,而是将数据插入到 Null 表 中。此表类似于 /dev/null,因为它不会持久化接收到的数据。但是,附加到它的物化视图会在插入数据时对数据块执行 SELECT 查询。这些查询的结果将发送到目标 SharedMergeTree 表,这些表存储最终转换后的行。我们在下面说明了这个过程

logging_07.png

使用这种机制,我们有了一种灵活且本地化的 ClickHouse 方式来转换我们的行。要更改从映射中提取的模式和列,我们 在更新视图以提取所需列之前修改目标表

模式

我们的数据平面和 Keeper 日志具有专用的模式。鉴于我们的大部分数据来自 ClickHouse 服务器日志,因此我们将在下面重点介绍此模式。它表示从物化视图接收数据的目标表

CREATE TABLE otel.server_text_log_0
(
	`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
	`EventDate` Date,
	`EventTime` DateTime,
	`TraceId` String CODEC(ZSTD(1)),
	`SpanId` String CODEC(ZSTD(1)),
	`TraceFlags` UInt32 CODEC(ZSTD(1)),
	`SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
	`SeverityNumber` Int32 CODEC(ZSTD(1)),
	`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
	`Body` String CODEC(ZSTD(1)),
	`Namespace` LowCardinality(String),
	`Cell` LowCardinality(String),
	`CloudProvider` LowCardinality(String),
	`Region` LowCardinality(String),
	`ContainerName` LowCardinality(String),
	`PodName` LowCardinality(String),
	`query_id` String CODEC(ZSTD(1)),
	`logger_name` LowCardinality(String),
	`source_file` LowCardinality(String),
	`source_line` LowCardinality(String),
	`level` LowCardinality(String),
	`thread_name` LowCardinality(String),
	`thread_id` LowCardinality(String),
	`ResourceSchemaUrl` String CODEC(ZSTD(1)),
	`ScopeSchemaUrl` String CODEC(ZSTD(1)),
	`ScopeName` String CODEC(ZSTD(1)),
	`ScopeVersion` String CODEC(ZSTD(1)),
	`ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
	`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
	`LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
	INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
	INDEX idx_thread_id thread_id TYPE bloom_filter(0.001) GRANULARITY 1,
	INDEX idx_thread_name thread_name TYPE bloom_filter(0.001) GRANULARITY 1,
	INDEX idx_Namespace Namespace TYPE bloom_filter(0.001) GRANULARITY 1,
	INDEX idx_source_file source_file TYPE bloom_filter(0.001) GRANULARITY 1,
	INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
	INDEX idx_scope_attr_value mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
	INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
	INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
	INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
	INDEX idx_log_attr_value mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
	INDEX idx_body Body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1
)
ENGINE = SharedMergeTree
PARTITION BY EventDate
ORDER BY (PodName, Timestamp)
TTL EventTime + toIntervalDay(180)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

关于我们的模式,有一些观察结果

  • 我们使用排序键 (PodName, Timestamp)。这针对我们的查询访问模式进行了优化,在这些模式中,用户通常会按这些列进行筛选。用户将根据其预期的工作流程修改它。
  • 我们对所有字符串列(除了那些具有非常高基数的列)使用 LowCardinality(String) 类型。这种字典对我们的字符串值进行编码,已被证明可以提高压缩率,从而提高读取性能。我们目前的经验法则是,对于任何基数低于 10,000 个唯一值的字符串列,都应应用这种编码。
  • 所有列的默认压缩编解码器是 ZSTD,级别为 1。这是因为我们的数据存储在 S3 上。虽然与 LZ4 等其他编解码器相比,ZSTD 的压缩速度可能较慢,但这被更高的压缩率和始终快速的解压缩(大约 20% 的差异)所抵消。当使用 S3 作为存储时,这些是比较好的特性。
  • 继承自 OTel 架构,我们对任何映射的键和值使用 bloom_filters。这提供了一个基于布隆过滤器数据结构的映射键和值的二级索引。布隆过滤器是一种数据结构,它允许以牺牲少量误报的风险为代价,以空间效率的方式测试集合成员资格。理论上,这使我们能够快速评估 磁盘上的数据块 是否包含特定的映射键或值。从逻辑上讲,这种过滤器可能是有意义的,因为一些映射键和值应该与 Pod 名称和时间戳的排序键相关联,例如,特定的 Pod 将具有特定的属性。但是,其他键将存在于每个 Pod 中 - 我们不希望这些查询在这些值上查询时得到加速,因为过滤条件在数据块中至少有一行被满足的可能性很高(在这种配置中,一个数据块是一个数据块,因为 GRANULARITY=1)。有关排序键和列/表达式之间需要相关性的更多详细信息,请参阅 此处。此一般规则已考虑其他列,例如 Namespace。通常,这些布隆过滤器被广泛应用,需要优化 - 这是一项待完成的任务。0.01 的误报率也尚未调整。

数据过期

所有架构都按 EventDate 对数据进行分区,以提供许多好处 - 请参阅上述架构中的 PARTITION BY EventDate。首先,由于我们的大多数查询都是针对过去 6 小时的数据,因此这提供了一种快速过滤到相关部分的方法。虽然这可能意味着在更宽的日期范围内要查询的部分数量更多,但我们没有发现这对这些查询有明显的影响。

最重要的是,它允许我们有效地使数据过期。我们可以简单地使用 ClickHouse 的 核心数据管理功能 删除超出保留时间的任何分区 - 请参阅 TTL EventTime + toIntervalDay(180) 声明。对于 ClickHouse 服务器日志,如上所示的架构中,这是 180 天,因为我们的核心团队使用它来调查任何发现问题的历史流行程度。其他类型的日志,例如来自 Keeper 的日志,具有更短的保留需求,因为它们除了最初的解决问题之外,几乎没有其他用途。这种分区意味着我们还可以使用 ttl_only_drop_parts 来有效地删除数据。

增强 Grafana

Grafana 是我们推荐用于 ClickHouse 可观测性数据的可视化工具。最近的 4.0 版本发布使得从 Explore 视图快速查询日志变得更加简单,并且在我们的工程师内部受到赞赏。虽然我们确实开箱即用地使用了插件,但我们还通过额外的 场景插件 扩展了 Grafana 以满足我们的需求。我们称之为 LogHouse UI 的这个应用程序具有很强的意见性,并针对我们的特定工作负载进行了优化,并且与 Grafana 插件紧密集成。一些需求促使我们构建它

  • 我们始终需要显示一些特定的可视化效果以用于诊断目的。虽然这些可以通过仪表板来支持,但我们希望它们与我们的日志探索体验紧密集成 - 想象一下 Explore 和仪表板的混合。仪表板需要使用变量,我们有时会发现它有点笨拙,并且我们希望确保我们的查询针对最常见的访问模式尽可能地优化。
  • 我们的用户通常在处理问题时会使用命名空间或集群 ID 进行查询。LogHouse UI 能够自动将此限制为按 Pod 名称(以及任何用户时间限制)查询,从而确保有效地使用我们的排序键。
  • 鉴于我们拥有针对不同数据类型的专用架构,LogHouse UI 插件会根据查询自动切换支持架构,为用户提供无缝体验。
  • 我们的实际表架构可能会发生变化。虽然在大多数情况下,我们能够进行这些修改而不会影响 UI,但界面能够感知架构会很有帮助。因此,我们的插件确保在查询特定时间范围时使用正确的架构。这使我们能够进行架构更改,而无需担心会干扰用户体验。
  • 我们的用户非常精通 SQL,并且经常喜欢在 UI 中进行初步调查后,从 ClickHouse 客户端切换到查询日志数据。我们的应用程序提供了一个 ClickHouse 客户端快捷方式,其中包含任何可视化的相关 SQL。这使我们的用户能够轻松地在各个工具之间切换,典型的流程是从 Grafana 开始,然后通过客户端连接进行更深入的分析,在那里可以制定高度集中的 SQL 查询。
  • 我们的应用程序还启用了跨区域查询,我们将在下面探讨。

通常,我们与日志数据相关的大部分工作负载都是探索性的,仪表板通常基于指标数据。因此,定制的日志探索体验是有意义的,并且已被证明值得投资。

logging_08.png

跨区域查询

我们始终致力于为用户提供最佳体验,因此我们希望提供一个单一端点,用户可以从该端点查询任何 ClickHouse 集群,而不是需要导航到特定区域的 Grafana 实例来查询本地数据。

正如我们之前提到的,我们的数据量太大,无法进行任何跨区域数据复制 - 仅数据出口费用就会超过我们运行 ClickHouse 基础设施的当前成本。因此,我们需要确保可以从 Grafana 托管的区域(及其相应的故障转移区域)查询任何 ClickHouse 集群。

我们最初的朴素实现只是在 ClickHouse 中使用了一个 分布式表,因此可以从任何节点查询所有区域。这要求我们在每个 LogHouse 区域中配置一个逻辑集群 包含一个分片,每个分片包含包含相应节点的每个区域。这实际上创建了一个连接到所有区域中所有 LogHouse 节点的单一整体集群。

虽然这可以工作,但当查询分布式表时,必须向每个节点发出请求。由于我们不跨区域复制数据,因此每个查询的相关数据只能由一个区域中的节点访问。这种缺乏地理感知意味着其他集群会浪费资源来评估永远无法匹配的查询。对于包含排序键(Pod 名称)过滤器的查询,这会产生非常小的开销。然而,更广泛的调查性查询,尤其是涉及线性扫描的查询,会浪费大量的资源。

我们能够通过巧妙地使用 cluster 函数来解决这个问题,但这使得我们的查询变得笨拙且不必要地复杂。相反,我们利用 Grafana 和我们的自定义插件来执行必要的路由,并使我们的应用程序能够感知区域。

logging_09.png

这要求将每个集群配置为数据源。当用户查询特定的命名空间或 Pod 时,我们构建的自定义插件会确保只查询与 Pod 所在区域相关联的数据源。

性能和成本

成本分析

以下分析不考虑我们用于 LogHouse 的 GCE 基础设施,而只关注 AWS。我们的 GCE 环境确实具有与 AWS 相似的线性价格特征,基础设施成本略有差异。

我们当前的 AWS LogHouse 基础设施每月成本为 125,000 美元(按清单价计算),每月处理 5.4 PiB 的吞吐量(未压缩)。

这 125,000 美元包括承载我们网关的硬件。我们注意到,这些网关还处理我们的指标管道,因此不是专门用于日志处理。因此,这个数字是对 LogHouse 摄取相关的硬件的过高估计。

此基础设施存储 6 个月的数据,总计 19 PiB 未压缩数据,存储在 S3 中压缩后的数据仅略多于 1 PiB。虽然我们很难提供一个精确的成本模型,但我们根据每月 TiB 吞吐量预测我们的成本。这需要一些假设

  • 我们的 EC2 基础设施将根据吞吐量线性扩展。我们相信我们的 OTel 网关将根据资源和每秒事件数量线性扩展。更大的吞吐量将需要更多 ClickHouse 资源来进行摄取。同样,我们假设这是线性的。相反,较小的吞吐量将需要更少的基础设施。
  • ClickHouse 为我们的数据保持大约 17 倍的压缩比。
  • 我们假设如果我们的数据量增长,我们的用户数量保持不变,并且忽略 S3 GET 请求费用 - 这些费用是查询和插入的函数,并且不被认为是重要的。
  • 为简单起见,我们假设保留期限为 30 天。

在我们最近的一个月里,我们摄取了 5532TB 的数据。使用这个数据和上面的成本,我们可以计算出我们每月每 TiB 的 EC2 成本

每月每 TiB 的 EC2 成本为(125,000 美元 / 5542)= 22.55 美元

使用 S3 中每 GiB 的成本为 0.021 美元 以及 17 倍的压缩比,这给了我们一个函数

T = 每月吞吐量(TiB,未压缩)

成本($)= EC2 成本(摄取/查询)+ 保留(S3)=(22.55 * T)+((T*1024)/17 * 0.021)= 23.76T

这给了我们每月每 TiB(未压缩)的成本为 23.76 美元。

logging_10.png

logging_11.png

这里的黄色线实际上是 ClickHouse!200 倍的价格比率导致 ClickHouse 在叠加在 Datadog 的刻度上时显示为一条水平线。仔细观察,它并不完全平坦 :)

实际上,我们存储了 6 个月的数据,AWS LogHouse 托管了 19 PiB 的未压缩日志。重要的是,这只会影响我们的存储成本,而不会改变我们使用的基础设施数量。这可以归因于使用 S3 来分离存储和计算。这意味着如果需要线性扫描,例如在历史分析期间,我们的查询性能会更慢。我们接受这种做法,以换取每 TiB 更低的总体价格,如下所示

(1.13 PiB(压缩)* 1048576 * 0.021)= 24,883 美元 + 125,000 美元 ≈ 150,000 美元

因此,每月 150,000 美元用于 19.11 PiB 的未压缩日志,或每 TiB 7.66 美元。

成本比较

鉴于我们从未认真考虑过其他解决方案,例如 Datadog 或 Elastic,因为它们的性价比甚至在远程上也无法与之相比,所以很难说我们节省了特定金额。

如果我们考虑 Datadog 并将保留期限限制为 30 天,我们可以估计一个价格。假设我们不进行查询(可能有点乐观),我们将产生每 GiB 摄取 0.1 美元的费用。按照我们当前的事件大小(见前面的顶级指标),1 TiB 等于大约 1.885B 个事件。Datadog 每月对每百万个日志事件额外收取 2.50 美元。

因此,我们每月每 TiB 未压缩的成本为

成本($)= 摄取 + 保留 =(T*1000*0.1)+(T*1885*2.5)= 100T+4712T=4812T

或约每 TiB 未压缩吞吐量 4,230 美元。这比 ClickHouse 贵 200 倍以上。

性能

我们最大的 ClickHouse LogHouse 集群由 5 个节点组成,每个节点拥有 200 GiB 内存和 57 个核心,并保存超过 10 万亿行,将数据压缩超过 17 倍。在每个云区域都有一个集群的情况下,我们目前在 LogHouse 中托管了超过 37 万亿行和 19 PiB 的数据,这些数据分布在 13 个集群/区域和 48 个节点上。

Markdown Image

我们的查询延迟略超过一分钟。我们注意到这种性能一直在不断改进,并且受我们核心团队执行的分析查询(如第 50 个百分位数所示)的影响很大,这些查询会扫描所有 37 万亿行,以识别特定的日志模式(例如,查看客户遇到的问题是否在过去 6 个月内被其他用户遇到过)。可以通过查询时间的直方图来最好地说明这一点。

Markdown Image

展望未来

从 ClickHouse 核心数据库开发的角度来看,我们的可观测性团队对一些工作感到兴奋。其中最重要的是 半结构化数据

最近将 JSON 类型迁移到生产就绪状态的努力将非常适用于我们的日志记录用例。此功能目前正在重新架构,Variant 类型的开发为更强大的实现提供了基础。准备就绪后,我们预计这将取代我们的 map,使用更强类型(即非统一类型)的元数据结构,这些结构也可能是分层的。

从集成方面来看,我们继续通过确保在整个管道中升级我们的集成游戏来支持可观测性轨迹。这转化为升级 ClickHouse OpenTelemetry 导出器以进行收集和聚合,以及改进和有见地的 Grafana ClickHouse 插件。

结论

在这篇文章中,我们分享了构建 ClickHouse 支持的日志记录解决方案的旅程细节,该解决方案目前仅在我们 AWS 区域存储了超过 19 PiB 的数据(压缩后 1.13 PiB)。

我们回顾了此平台背后的架构和关键技术决策,希望这对于那些也对使用最优工具构建可观测性平台感兴趣的人有所帮助,而不是使用 Datadog 之类的现成服务。

最后,我们证明 ClickHouse 在像我们这样的可观测性工作负载方面至少比 Datadog 便宜 200 倍——Datadog 在 30 天保留期内的预计成本高达每月 2600 万美元!

立即开始使用 ClickHouse Cloud 并获得 300 美元的积分。在您 30 天的试用期结束时,可以继续使用按需付费计划,或者联系我们 了解有关我们基于容量的折扣的更多信息。访问我们的定价页面 获取详细信息。

分享这篇文章

订阅我们的时事通讯

随时了解功能发布、产品路线图、支持和云服务!
正在加载表单...
关注我们
Twitter imageSlack imageGitHub image
Telegram imageMeetup imageRss image