作者:Manolis Karpathiotakis,Dino Wernli,Milos Stojanovic
我们的硬件基础设施由数百万台机器组成,所有这些机器都会生成日志;我们需要处理和存储这些日志,并提供与这些日志相关的服务。这些日志的总大小以每小时几个 PB 的速度增长。处理日志的机器通常与生成日志的机器不是同一台:日志会与各种下游处理流程产生关联,并且它们可能会在不同时间被访问。完成收集、合并和交付这些日志(具有低延迟和高吞吐量)的任务需要一种系统化的方法。我们的解决方案是 Scribe,这是一个分布式队列系统,它对服务日志从 A 点移动到 B 点背后的所有复杂性进行了封装。
Scribe 处理日志时的输入速率可以超过 2.5TB/s,输出速率可以超过 7TB/s。作为参考,我们可以看下欧洲核子研究中心的大型强子对撞机,它在最近这次运行期间的输出速率估计只有每秒 25GB(https://home.cern/science/computing/processing-what-record)。Scribe 最近进行了一次重大的架构改造和简化,新架构现在已经投入生产。本文将首次分享 Scribe 当前的设计、设计出当前架构的一些考虑因素,并介绍我们的规模和演进在过去十年中是如何影响它的。
Scribe:一个通用的缓冲队列系统
我们的生态系统涉及各种各样的日志生成场景。一个典型的例子是 web 服务器生成的半结构化日志,用来指示它们的健康状况。在大多数情况下,开发人员希望将静态文件的非结构化内容传输到下游系统。下游系统通常是我们的众多分析工具之一。通常的期望是,开发人员应该能够对一组日志执行分析和挖掘任务。根据使用用例,他们可能希望观察实时趋势或历史模式。因此,需要将日志发送到数据仓库进行历史分析,或发送到实时流处理系统(如 Puma、Stylus 和 Scuba)进行实时挖掘。
传送一个日志
Scribe 允许它的用户从我们的任何机器上向命名的逻辑流(称为类别)写一个有效负载。有效负载可以在大小、结构和格式上有很大的不同,但是 Scribe 以同样的方式对待它们。每个日志都可以被保存一段时期,这个时期的长短是可配置的——通常是几天。在此期间,使用者可以按顺序读取日志流,这种操作称为跟踪。
Scribe 当前架构的高级视图。
任何进程都可以使用生产者(Producer)库来编写日志。生产者可以属于容器中运行的应用程序,比如执行 Hack(https://hacklang.org/)代码的 web 服务器(Hack 是 PHP 的一种变体)。使用 Scribe 的工程师可以指引生产者采取下列路径之一:
写入一个本地守护进程(Scribed),该守护进程负责最终将日志发送到存储后端
直接写入后端服务器的远程层(写入服务)
最后,消费者可以连接一个读取服务,通过流式方式读取日志。
Scribe 的第一个角色(https://www.usenix.org/conference/lisa18/presentation/braunschweig)实际上是 Scribed 的一个早期版本——本质上是 NetApp 文件归档器——它负责将日志持久化到连接了网络的存储驱动器上。虽然 Scribe 是一个没有任何宕机时间的长可用系统,但是随着时间的推移,它的架构已经演变成了一个更复杂的系统,现在包含 40 多个不同的组件。越来越多的复杂组件使得我们很难维护一个剥离了内部规范的开源版本。这种复杂性是我们归档 Scribe 项目的开放源码(https://github.com/facebookarchive/scribe)的主要原因。下面详细介绍 Scribe 架构的当前版本,重点关注组成数据层的组件。
Scribed
我们的大多数机器都运行一个名为 Scribed 的守护进程。如果客户最关心的是尽快释放日志的所有权,那么他们可以直接将日志写入 Scribed。Scribed 使用本地磁盘(来扩展内存)作为缓冲区,用于机器失去连接或后端不可用的情况。对于大多数 Scribe 用户来说,直接将日志写到 Scribed 就很好用了。但与 Scribed 通信也有一些缺点:
把日志存储到磁盘上可能会有延迟和一致性的问题。
如果 Scribed 在单台机器上无法获取,写操作可能会丢失,或被阻塞相当长的一段时间。
Scribed 本是机器上的共享资源,但是试图写入大量日志的用户会独占 Scribed 资源,从而影响同一机器上其他用户的体验。
虽然这些情况比较罕见,但用户也会遇到;为了解决这些问题,我们为用户构建了一种能力,让他们能够绕过 Scribed,并且指示他们的生产者直接向 Scribe 写服务写入日志。这样做可以减少端到端的延迟,并增加写可用性,因为生产者可以将写操作指向多个后端主机,而不是指向单个本地进程。另一方面,写弹性可能会降低:例如,如果生产者不能足够快地将日志释放到写服务,那么生产者的内存队列可能会被填满,从而导致拒绝多余的写请求,消息也会丢失。
写服务
最终,每个日志都会被发送到 Scribe 的某一个后端服务器。Scribe 根据地点和可用性将服务器的选择权委托给内部的负载平衡服务。这种选择是动态的,后端服务器机群充当单一的、有弹性的写服务,没有任何一处故障点。由于 Scribe 从数百万台机器收集日志,来自不同机器的日志可以以任何顺序到达写服务。此外,来自单个机器的日志可以到达几个不同的写服务机器,并最终存储在几个不同的存储后端实例中。这些后端不共享全局时间或先后关系的概念,因此 Scribe 不会试图保存它接收到的日志的相对顺序。
相反,写服务侧重于按类别对输入日志进行批处理,并将这些日志按批转发到存储后端。存储是按集群来组织的:每个存储集群是一组托管了 Scribe 存储组件的机器。写服务会选择把每批日志放在哪个存储集群中。写服务的目的是将相关的日志(例如,相同的类别)放在集群的一个子集中,子集既要足够大以确保写可用性,又要足够小以确保读可用性,并在这两者之间取得平衡。此外,集群放置决策还考虑了其他因素,比如我们希望日志从哪个地理区域读取等。
一旦选择了存储集群,写服务就会转发日志。在日志到达集群中的持久存储点之前,日志通常一直驻留在进程内存中,因此很容易出现一些故障,这些故障会导致日志丢失;对日志丢失的最小容忍度是我们的设计考虑之一,以避免产生开销,影响其高吞吐量和低延迟的指标。有些用户需要更严格的交付保证,他们可以选择 Scribe 的一种配置,以性能为代价换取更少的损失。
LogDevice 的缓冲存储
Scribe 的存储后端是 LogDevice(https://engineering.fb.com/core-data/logdevice-a-distributed-data-store-for-logs/),它是一个分布式日志存储系统,在各种工作负载和故障场景下提供了高持久性和可用性。Scribe 将每个日志存储为一个 LogDevice 记录。通过 LogDevice 进行持久存储后,Scribe 可以把每个记录当做像是只有一个(逻辑)副本那样操作:LogDevice 层隐藏了操作的复杂性,比如记录复制和恢复等。
LogDevice 将记录组织成序列,称为分区。Scribe 类别由跨多个 LogDevice 集群的多个分区支持。一旦 LogDevice 在一个分区中持久地存储了一条记录,该记录就可以被读取了。同样,请注意,Scribe 在 LogDevice 中留存记录的时间是有限的——通常是几天。Scribe 的目标是在一段时间内缓冲记录,以能够让客户使用这些记录,或者在发生故障时重放记录。需要保留超过几天时间的客户通常会将日志放入长期存储区。
在迁移到 LogDevice 之前,Scribe 的存储后端一直依赖于 HDFS(https://hadoop.apache.org/)。Scribe 最终达到了 HDFS 的可伸缩性极限——大约在同一时间,Facebook 也弃用了 HDFS。转换到基于 LogDevice 的后端后,Scribe 演变为当前的形式,并可以有效地服务于更多的用例。
读取日志
客户可以通过读服务来读取写入到 Scribe 的日志,该读服务提供对日志流的读访问。读服务的实现基于流式 Thrift(https://code.fb.com/open-source/under-the-hood-building-and-open-sourcing-fbthrift/),并遵循响应式流规范(https://www.reactive-streams.org/)。除了对日志流的一般访问之外,读服务还充当一个负载平衡层,允许 Scribe 在共享资源(例如,到存储后端的连接)的同时为多用户提供读用例服务。
通过读服务从 Scribe 读取日志涉及到集群识别(这些集群包含了请求类别的日志),并需要读取相应的 LogDevice 分区。每个分区的内容合并在一起,并根据存储时间戳进行部分排序。考虑到使用者正在从多个集群读取日志,并且在单个客户端主机中生成的日志可能会存在于多个 Scribe 集群中,消费者会避免强制执行严格的输出顺序。相反,消费者应用“粗略”排序,确保输出日志相互间不超过 N 分钟(N 通常是 30),这个时间是相对于到达持久存储区的时间而言的:需要按创建日志的精确顺序刷新日志的客户,通常使用流处理系统(如 Stylus)从 Scribe 读取日志并进一步排序。
设计决策
Scribe 将自己公开为一个运行在主机上的 Thrift 服务,并从客户端收集实时流日志。为了获得更好的普适性,Scribe 是根据一些一流的需求设计的。具体来说,Scribe 服务必须满足:
简洁性,为客户提供一个简单的 API 来读写日志。
(写)可用性,容忍传输过程中任何部分的失败,同时允许在过程中可能丢失少量日志。
可伸缩性,可以处理数百万生产者(其聚合后的输入速率可以超过 2.5TB/s)和数十万消费者(其聚合后的输出速率可以超过 7TB/s)。
多用户,确保客户可以通过共享的 Scribe 介质进行多路复用,而不会影响其他客户的服务质量。
简洁性
Scribe 为用户提供了一个高级 API 来读写日志,分别是生产者 API 和消费者 API。生产者 API 可以绑定到多种编程语言,并且仅由一个写方法组成。消费者 API 提供一个消费者对象,该对象可用于从 Scribe 读取日志流,也可用于执行其他操作,比如获取检查点。
除了高级 API 之外,Scribe 还提供了在我们的主机中可用的方便的二进制文件。想要在 Scribe 中写日志的客户可以使用如下命令:
而如果需要从 Scribe 中读取日志,客户可以使用下面的命令动态创建一个读取流来跟踪给定时间段内的某类别日志:
可用性
Scribe 的主要关注点是确保写入到它的日志能够到达具有持久性的 LogDevice 存储区,同时不受多种故障类型的影响。从 Scribe 的“边缘”开始,生产者实例在内存中缓存日志,以处理短时间内 Scribed 不可用的问题(例如,为了更新的目的 Scribed 被重新启动)。通过类似的方式,Scribed 在本地磁盘上缓冲日志,用于应对网络中断的情况,因为网络中断就不能顺利地把日志发送到写服务。LogDevice 则通过持久化它接收到的每个日志记录的多个副本,来进一步增强写可用性。这些功能的组合使 Scribe 能够成功地服务于绝大多数的写调用。
Scribe 的读取路径被设计为在分散的存储主机、机架和集群上下文中有效地提供日志。具体来说,考虑到 Scribe 日志最终所在的存储集群的高扇入,读数据流通常从分布在世界各地的多个集群获取日志。由于日志分散在多个集群中,所以 Scribe 需要能够处理集群无法足够快或根本无法为读取者提供服务的情况。
Scribe 可以允许用户对输出日志的交付和顺序保证降低要求,从而处理短暂的集群不可用的情况。实际上,这意味着读取者可以选择是等待当前不可用的日志还是在没有日志的情况下继续。
可伸缩性
Scribe 必须能够容纳不断写入其中的日志。而且即使是在一天之内,写模式也会有很大的不同。Scribe 类别的物理存储分布在多个 LogDevice 分区上,每个分区可以维持一个最大的写吞吐量。Scribe 根据分区接收的卷动态地扩展某个类别的分区数量。这个分区供应过程的最终结果是自动处理写操作时的流量变化,这使得 Scribe 的吞吐量水平能够弹性伸缩。
关于日志读取,它可能不那么容易处理,它需要为单一的消费者进程进行处理,并且为给定类别生成的大量日志进行刷新:消费者主机的网卡可能会变得饱和,或接收消费者输出的进程可能由于 CPU 的限制,无法以日志生成的速度来处理日志。Scribe 因此引入了“桶”——它是允许多个独立的消费者进程共同使用来自单个 Scribe 类别日志的功能。桶是一种分片/索引机制:它们允许用户指定他们希望 Scribe 对输入进行分片的流的数量,并允许用户通过给定消费者来检索特定的分片。生产者可以显式地提供桶号,也可以不指定它,让日志随机分布在可用的桶中。
多用户
Scribe 用户把它当作一个规模与 Facebook 同级的服务:在任何给定的时间点,数百万的生产者向数十万个 Scribe 类别写入日志;然后数十万的消费者读取这些日志。尽管如此,客户仍然希望他们的体验不受 Scribe 所服务的其他客户的影响。因此,Scribe 不断地监视使用模式,以检测可能危及系统整体健康的非预期用户行为。
Scribe 对写操作施加速率限制,以保护它的后端。具体地说,Scribe 给每个类别划定一个写配额:超额会导致 Scribe 将一个类别列入黑名单(例如,丢弃新进来的数据流),或者仅接受它的一部分日志,亦或对生产者施加背压。此外,Scribe 会检测以下情况:客户多次读取某个类别的内容,因为这会增加 Scribe 和其存储后端的压力,并增加跨区域的带宽消耗——由于 Scribe 的“write-anywhere”特性,带宽就成为了一种宝贵的经常被使用到的资源。
除了控制需求外,Scribe 还试图最小化与 Scribe 服务的交互,因为这可能需要人工的干预。例如,故障转移和停机对终端客户是透明的,客户的写配额是自动扩展的,用于适应有机增长,写流量的增加会自动导致存储资源的供应增加,客户可以使用消费者提供的“检查点”从停止的地方重启读取过程。
Scribe 的未来演变
Scribe 已经有机地成长了十多年,它的设计也在发展中。Scribe 继续改进的方向之一是进一步简化整个系统,这将涉及到合并众多不同的组件,以及以可选插件的形式封装特定于 Facebook 的基础设施。除了这样的简化工作,我们还在努力扩展 Scribe 以支持客户不断变化的需求:例如,我们目前正在评估不同交付担保的显式公开,比如 at-least-once 和 exactly-once。此外,我们还通过缓存和过滤功能丰富了写服务,以便为某些客户优化用例,这些客户需要多次读取相同日志并/或对根据内容过滤类别的结构化日志感兴趣。最后,我们一直在努力确保 Scribe 的各个层面——从分片机制到放置策略和灾备特性——与我们每天处理的日志体量保持同步。
在 Scribe 多次重写期间出现的一个有趣模式是,它的 API 和架构决策开始接近其他大型消息传递系统。我们打算继续演进 Scribe,并没有打算将我们的使用场景迁移到其他系统,这主要是因为 Scribe 具备自我管理、自我扩展和自我修复的能力,即使在面临大规模灾难性故障时也是如此。考虑到 Scribe 是提供给多租户的,这些要求都是很严格的。因此,这些需求被构建到其整体设计、每个组件以及组件的协作中。其他系统通常依赖第三方解决方案来实现自动伸缩和灾难恢复等功能。
总之,Scribe 是一个高度可用的系统和服务,在过去的 12 年里,它随着我们不断扩展的工具和服务而发展,变得越来越有弹性。我们希望看到 Scribe 未来的进一步发展,我们非常渴望进一步提高它的可伸缩性和容错能力,同时继续简化它的实现。我们希望这些简化的努力能够在将来的某个时候为再次开源 Scribe 铺平道路。
原文链接:
Scribe: Transporting petabytes per hour via a distributed, buffered queueing system
评论