Grafana Loki是一组组件,可以组成一个功能齐全的日志聚合系统。

Loki与其他日志系统不同是,它只会索引日志的元数据即labels(类似Prometheus的labels),日志数据本身会被压缩并分块(chunck)存储在对象存储中,也可以存储在本地文件系统中。小的索引和高度压缩的分块简化了操作,大大降低了Loki的成本。

1.概述

1.1 基本概念

  • Loki是一个为有效保存日志数据而优化的数据存储。日志数据的高效索引使Loki区别于其他日志系统。与其他日志系统不同的是,Loki的索引是由标签labels建立的,没有对原始日志信息进行索引。
  • Agent负责获取日志,将日志变成数据流,并通过HTTP API将数据流推送给Loki。Promtail Agent是为Loki而设计的,但许多其他Agent也能与Loki无缝集成。
  • Loki对流进行索引。每个流标识了一组与一组独特标签相关的日志。一组高质量的标签是创建索引的关键,它既紧凑又允许有效的查询执行。
  • LogQL是Loki的查询语言。

1.2 基本特性

  • 高效地利用内存为日志建立索引: 通过在一组标签上建立索引,索引可以比其他日志聚合产品小得多。更少的内存使其运行成本更低。
  • 多租户: 允许多个租户使用一个Loki实例。不同租户的数据与其他租户是完全隔离的。多租户是通过在代理中分配一个租户ID来配置的。
  • LogQL,Loki的查询语言: Prometheus的查询语言PromQL的用户会发现LogQL在生成针对日志的查询方面非常熟悉和灵活。该语言还有助于从日志数据中生成指标,这是一个强大的功能,远远超出了日志聚集的范围。
  • 可扩展性: Loki可以作为一个单一的二进制文件运行;所有的组件都在一个进程中运行。Loki是为可扩展性设计的,因为Loki的每个组件都可以作为微服务运行。配置允许单独扩展微服务。
  • 灵活性: 许多Agent都有插件支持。这使得当前的可观察性技术栈可以添加Loki作为他们的日志聚合工具,而不需要切换观察技术栈的现有部分。
  • Grafana集成: Loki与Grafana无缝集成,提供了一个完整的可观察性栈。

2.架构

2.1 概念

2.1.1 多租户

当Grafana Loki在多租户模式下运行时,所有数据,包括内存和长期存储中的数据,都可以通过租户ID进行分区,该ID来自请求中的X-Scope-OrgIDHTTP请求头。当Loki不在多租户模式下时,该标头被忽略,租户ID被设置为 “fake”,这将出现在索引和存储块中。

2.1.2 Chunk格式(Chunk Format)

 1 -------------------------------------------------------------------
 2  |                               |                                 |
 3  |        MagicNumber(4b)        |           version(1b)           |
 4  |                               |                                 |
 5  -------------------------------------------------------------------
 6  |         block-1 bytes         |          checksum (4b)          |
 7  -------------------------------------------------------------------
 8  |         block-2 bytes         |          checksum (4b)          |
 9  -------------------------------------------------------------------
10  |         block-n bytes         |          checksum (4b)          |
11  -------------------------------------------------------------------
12  |                        #blocks (uvarint)                        |
13  -------------------------------------------------------------------
14  | #entries(uvarint) | mint, maxt (varint) | offset, len (uvarint) |
15  -------------------------------------------------------------------
16  | #entries(uvarint) | mint, maxt (varint) | offset, len (uvarint) |
17  -------------------------------------------------------------------
18  | #entries(uvarint) | mint, maxt (varint) | offset, len (uvarint) |
19  -------------------------------------------------------------------
20  | #entries(uvarint) | mint, maxt (varint) | offset, len (uvarint) |
21  -------------------------------------------------------------------
22  |                      checksum(from #blocks)                     |
23  -------------------------------------------------------------------
24  |                    #blocks section byte offset                  |
25  -------------------------------------------------------------------

mintmaxt分别描述了最小和最大的Unix纳秒时间戳。

Block Format

一个block由一系列entries组成,每个entry都是一个单独的日志行。

请注意,一个bock的bytes是用Gzip压缩存储的。以下是它们未压缩时的格式:

1  -------------------------------------------------------------------
2  |    ts (varint)    |     len (uvarint)    |     log-1 bytes      |
3  -------------------------------------------------------------------
4  |    ts (varint)    |     len (uvarint)    |     log-2 bytes      |
5  -------------------------------------------------------------------
6  |    ts (varint)    |     len (uvarint)    |     log-3 bytes      |
7  -------------------------------------------------------------------
8  |    ts (varint)    |     len (uvarint)    |     log-n bytes      |
9  -------------------------------------------------------------------

ts是日志的Unix纳秒时间戳,而len是日志条目的字节长度。

2.1.3 存储

Loki将所有数据存储在一个单一的对象存储后端。这种操作模式快速、经济、简单,使用一个叫做boltdb_shipper的适配器,将索引存储在对象存储中(与存储chuncks的方式相同)。

2.1.4 读路径(Read Path)

  1. querier收到一个对数据的HTTP/1请求。
  2. querier将查询传递给所有ingesters以获取内存数据。
  3. ingesters收到读取请求,并返回与查询相匹配的数据(如果有的话)。
  4. 如果没有ingesters返回数据,查询器会延迟的(lazy)地从后端存储加载数据,并对其运行查询。
  5. querier对所有收到的数据进行迭代和去重计算,通过HTTP/1返回最终数据。

2.1.5 写路径(Write Path)

  1. distributor收到一个HTTP/1请求,以存储流的数据。
  2. 每个流都使用哈希环进行散列。
  3. distributor将每个流发送到适当的ingesters和他们的副本(基于配置的复制因子)。
  4. 每个ingesters将为流的数据创建一个chunck或附加到一个现有的chunck上。每个租户和每个标签集的chunck是唯一的。
  5. distributor通过HTTP/1响应一个成功代码。

2.2 部署模式

Loki是由许多组件的微服务构建而成的,并被设计为一个水平可扩展的分布式系统来运行。

Loki的独特设计是将整个分布式系统的代码编译成一个单一的二进制文件或Docker镜像。该单一二进制文件的行为由-target命令行标志控制,并定义了三种操作模式之一。

每个二进制文件的部署实例的配置进一步指定了它所运行的组件。

Loki这样的设计是为了在你的需求发生变化时,在不同的模式下轻松地重新部署集群,而不需要改变配置或对配置进行最小的改动。

2.2.1 单体模式(Monolithic mode)

最简单的操作模式是设置-target=all。这是默认的target,不需要指定。这是单体模式;它将所有Loki的微服务组件作为一个单一的二进制或Docker镜像在一个进程中运行。

monolithic-mode

单体模式对于快速入门,尝试使用Loki,以及每天不超过100GB的小规模读/写量是非常有用的。

通过使用共享对象存储和配置ring section在所有实例之间共享状态,可以将单体模式的部署水平地扩展到更多的实例。

通过使用·memberlist_config`配置和共享对象存储运行两个Loki实例,可以配置高可用性。

以round robin形式将流量路由到所有Loki实例。

查询并行化的限制在于实例的数量和定义的查询并行度。

2.2.2 简单的可扩展部署模式

如果你的日志量每天超过几百GB,或者你想把日志读写分离,Loki提供简单的可扩展部署模式。这种部署模式可以扩展到每天几TB的日志量,甚至更多。对于更大的日志里,可以考虑采用微服务模式的部署。

simple-scalable.png

在这种模式下,Loki的组件微服务被分成两类目标:-target=read-target=write。BoltDB压缩器服务将作为读取目标的一部分运行。

将读和写的路径分开是有好处的:

  • 通过提供专用节点来提高写路径的可用性
  • 可单独扩展的读取路径,以按需提高或降低查询性能
  • 简单的可扩展部署模式需要在Loki前面有一个负载均衡器,它将/loki/api/v1/push流量引导到写节点。所有其他的请求都到读取节点。流量应该以round robin的方式发送。

2.2.3 微服务模式

微服务部署模式将Loki的组件实例化为不同的进程。每个进程都被调用,并指定其target:

  • ingester
  • distributor
  • query-frontend
  • query-scheduler
  • querier
  • index-gateway
  • ruler
  • compactor

microservices-mode.png

作为单独的微服务运行组件,可以通过增加微服务的数量来扩大规模。定制的集群对单个组件有更好的可观察性。微服务模式的部署是最有效的Loki部署模式。然而,它们的设置和维护也是最复杂的。

微服务模式建议用于非常大的Loki集群或需要对扩展和集群操作进行更多控制的集群。

微服务模式最适合与Kubernetes部署一起使用。有Jsonnet和分布式Helm chart安装。

2.3 组件

loki_architecture_components.png

2.3.1 Distributor(分发器)

distributor服务负责处理客户端传入的数据流。它是日志数据写入路径上的第一站。一旦distributor收到一组数据流,每个数据流都会被验证是否正确,以确保它在配置的租户(或全局)限制范围内。然后,有效的Chunck被分割成批次,并平行地发送到多个ingesters。

重要的是,一个负载均衡器位于distributor的前面,以便正确地平衡流量给它们。

distributor是一个无状态的组件。这使得它可以轻松扩展并尽可能地卸载负荷,以减轻最关键的写入路径上的ingesters的工作量。独立扩展这些验证操作的能力意味着Loki也可以保护自身免受可能会过载ingesters的拒绝服务攻击(无论是恶意的还是非恶意的)。它们就像门口的保安,确保每个人都适当着装并拥有邀请函。这还允许我们根据复制因子进行写入扇出。

Validation(校验)

distributor的第一步是确保所有传入的数据符合规范。这包括检查标签是否是有效的Prometheus标签,同时确保时间戳既不太旧也不太新,日志行也不太长。

Preprocessing(预处理)

目前,distributor对传入的数据进行变换的唯一方式是标签的规范化。这意味着将{foo="bar",bazz="buzz"}等同于{bazz="buzz",foo="bar"},换句话说,对标签进行排序。这使得Loki能够以确定性方式对其进行缓存和哈希处理。

Rate limiting(速率限制)

distributor还可以根据每个租户的最大比特率限制入站日志的速率。它通过检查每个租户的限制并将其除以当前分发器数量来实现。这样可以在集群级别上指定每个租户的速率限制,并使我们能够根据需要扩展或缩减distributor,并相应地调整每个distributor的限制。例如,假设我们有10个distributor,租户A的速率限制为10MB。每个分发器将允许高达1MB/秒的速率,然后进行限制。现在,假设另一个大型租户加入集群,我们需要再启动10个分发器。现在的20个分发器将将租户A的速率限制调整为(10MB / 20个distributor)= 500KB/s!这就是全局限制如何使Loki集群的操作更加简单和安全的方式。

注意:distributor在内部使用ring组件来在同级之间注册自己并获取活动distributor的总数。这是与ingesters在ring中使用的不同的“键”,并来自于distributor自己的ring配置。

Forwarding(转发)

一旦distributor完成了所有的验证任务,它将数据转发给ingester组件,ingester组件最终负责确认写入操作。

Replication factor(复制因子)

为了降低任何单个ingester丢失数据的可能性,分发器将将写操作转发给它们的复制因子。通常情况下,复制因子为3。复制允许在不中断写入的情况下进行ingester的重启和升级,并为某些场景提供了额外的数据丢失保护。

简单来说,对于每个被推送到distributor的标签集合(称为流),它将对标签进行哈希计算,并使用结果值在ring环中查找复制因子个ingesters(ring环是一个公开分布式哈希表的子组件)。然后,它会尝试将相同的数据写入所有ingesters。如果写入成功的数量少于法定写入数(quorum),则会返回错误。法定写入数定义为 floor(replication_factor / 2) + 1。因此,对于复制因子为3,我们要求至少两个写入成功。如果少于两个写入成功,distributor将返回错误,并可以重新尝试写入操作。

制因子并不是唯一可以防止数据丢失的因素,而且可以说在如今的环境下,它的主要目的是在升级和重启期间允许写操作继续进行。Ingestor 组件现在包含一个预写日志(Write Ahead Log,WAL),它将传入的写操作持久化到磁盘,以确保只要磁盘未损坏,数据就不会丢失。复制因子和 WAL 的互补性确保数据不会丢失,除非两种机制都发生重大故障(例如多个 Ingestor 崩溃并且丢失/损坏了它们的磁盘)

Hashing(哈希)

distributor使用一致性哈希算法和可配置的复制因子来确定应该接收给定流的Ingestor服务的哪些实例。

流是与租户和唯一标签集相关联的一组日志。流使用租户ID和标签集进行哈希,然后使用哈希来找到要发送流的Ingestor。

Consul中存储的哈希环用于实现一致性哈希;所有的Ingestor都使用一组自己拥有的tokens在哈希环中注册自己。每个token是一个随机的无符号32位数字。除了一组tokens,Ingestor还将自己的状态注册到哈希环中。状态JOININGACTIVE都可以接收写请求,而ACTIVELEAVING的Ingestor可以接收读请求。在进行哈希查找时,distributor只使用适合请求的状态的Ingestor的tokens。

为了进行哈希查找,distributor找到大于流哈希值的最小适用token。当复制因子大于1时,下一个连续的(顺时针在环上)属于不同 Ingestor 的token也将包含在结果中。

这种哈希设置的效果是,每个Ingestor拥有的令牌负责一段哈希范围。如果有三个值为0、25和50的token,则哈希值为3的数据将被分配给拥有token 25的Ingestor;拥有token 25的Ingestor负责哈希范围1-25。

Quorum consistency(仲裁一致性)

由于所有的distributor共享对同一个哈希环的访问权限,写请求可以发送到任何一个distributor。

为了确保查询结果的一致性,Loki在读取和写入时采用了Dynamo风格的仲裁一致性。这意味着分发器会等待至少一半加一的Ingestor发送正面响应后,才会将样本发送给发起发送请求的客户端,并向其响应。

2.3.2 Ingester(接收器)

Ingestor服务负责在写入路径上将日志数据写入长期存储后端(如DynamoDB、S3、Cassandra等),并在读取路径上返回内存查询的日志数据。

Ingestor包含一个生命周期管理器,用于管理哈希环中Ingestor的生命周期。每个Ingestor具有以下状态之一:PENDING(待定)、JOINING(加入中)、ACTIVE(活动中)、LEAVING(离开中)或 UNHEALTHY(不健康)

每个 Ingestor 接收到的日志流都会在内存中逐渐构建成一组许多块(chuncks),并在可配置的时间间隔内刷新到后端存储。

当满足以下条件之一时,块将被压缩并标记为只读:

  • 当前块达到容量上限(可配置值)。
  • 在当前块未更新的情况下经过了太长时间。
  • 发生了刷新操作。

每当一个块被压缩并标记为只读时,一个可写的新块将取而代之。

如果Ingestor进程崩溃或意外退出,所有尚未刷新的数据将丢失。通常,Loki的配置中会复制每个日志的多个副本(通常为3个),以减轻这种风险。

当刷新操作发生到持久性存储提供者时,块将根据其租户、标签和内容进行哈希处理。这意味着具有相同数据副本的多个Ingestor不会将相同的数据写入后端存储两次,但如果任何一个副本写入失败,将会在后端存储中创建多个不同的块对象。有关数据如何去重,请参见Querier。

Timestamp Ordering(时间戳排序)

Loki可以配置为接受无序写入。

当Loki未被配置为接受无序写入时,Ingestor会验证接收的日志行是否按顺序。当Ingestor接收到不符合预期顺序的日志行时,该行将被拒绝,并向用户返回错误。

ingestor验证日志行按时间戳升序接收。每个日志都有一个时间戳,它发生在前一个日志之后。当ingestor接收到不符合此顺序的日志时,该日志行将被拒绝,并返回错误。

每个唯一标签集的日志在内存中被积累成"块"(chuncks),然后刷新到后备存储后端。

如果Ingestor进程崩溃或意外退出,尚未刷新的所有数据可能会丢失。通常会为Loki配置一个预写日志(Write Ahead Log),可以在重新启动时回放,并且每个日志的复制因子(通常为3)可以减轻此风险。

当未配置为接受无序写入时,推送到Loki的给定流(标签的唯一组合)的所有行必须具有比之前接收到的行更新的时间戳。然而,对于具有相同纳秒级时间戳的同一流的日志处理有两种情况:

  • 如果传入的行与先前接收到的行完全匹配(包括先前的时间戳和日志文本),传入的行将被视为完全重复并被忽略。
  • 如果传入的行具有与上一行相同的时间戳但内容不同,则接受该日志行。这意味着可以有两个不同的日志行具有相同的时间戳。

文件系统支持

虽然ingestor通过BoltDB支持向文件系统写入,但这仅适用于单进程模式,因为查询器需要访问相同的后端存储,而BoltDB只允许一个进程在给定时间对数据库进行锁定。

2.3.3 Query frontend(查询前端)

查询前端是一个可选的服务,提供查询器的API端点,可用于加速读取路径。当查询前端准备就绪时,传入的查询请求应该被发送到查询前端而不是查询器Querier。查询器服务仍然需要在集群中存在,以执行实际的查询操作。

查询前端在内部执行一些查询调整,并在内部队列中保存查询。在这种设置中,查询器Querier充当从队列中提取jobs、执行jobs并将其返回给查询前端进行聚合的工作者。为了允许查询器连接到查询前端,需要通过-query.frontend-address命令行标志为查询器配置查询前端地址。

查询前端是无状态的。然而,由于内部队列的工作方式,建议运行几个查询前端副本以获得公平调度的好处。在大多数情况下,两个副本应该足够。

Queueing(队列)

查询前端的队列机制用于以下目的:

  • 确保在查询器Querier中可能导致内存溢出(OOM)错误的大型查询在失败时会进行重试。这允许管理员为查询分配较少的内存,或者乐观地并行运行更多的小型查询,有助于降低总拥有成本(TCO)。
  • 通过使用先进先出(FIFO)队列将它们分布到所有查询器中,防止多个大型请求在单个查询器上进行队列操作。
  • 通过在租户之间公平调度查询,防止单个租户对其他租户进行拒绝服务(DOS)攻击。

Splitting(拆分)

查询前端将较大的查询拆分为多个较小的查询,在下游查询器上并行执行这些查询,并将结果再次组合在一起。这样可以防止大型(多天等)查询在单个查询器中引起内存问题,并帮助更快地执行这些查询。

Caching(缓存)

  • 指标查询: 查询前端支持对指标查询结果进行缓存,并在后续查询中重用它们。如果缓存的结果不完整,查询前端会计算所需的子查询,并在下游查询器上并行执行它们。查询前端可以选择根据其步骤参数对查询进行对齐,以提高查询结果的可缓存性。结果缓存与任何Loki缓存后端兼容(目前支持memcached、redis和内存缓存)。
  • 日志查询: 即将推出!正在积极开发缓存日志(过滤器、正则表达式)查询功能。

2.3.4 Querier(查询器)

查询器服务使用LogQL查询语言处理查询,从Ingester和长期存储中获取日志。

查询器在向后端存储运行相同查询之前,会查询所有Ingesters以获取内存中的数据。由于复制因子的存在,查询器可能会接收到重复的数据。为了解决这个问题,查询器会在内部对具有相同纳秒级时间戳、标签集和日志消息的数据进行去重处理。

2.4 Consistent Hash Rings(一致性哈希环)

一致性哈希环被纳入Loki集群架构中,用于以下目的:

  1. 帮助对日志行进行分片,以实现更好的负载均衡。
  2. 实现高可用性,确保系统在组件故障时仍能正常运行。
  3. 便于集群的水平扩展和缩小。对于需要重新平衡数据的操作,性能影响较小。

当满足以下条件时,哈希环用于连接同类型的组件实例:

  • 在单体部署模式下,存在一组Loki实例。
  • 在简单可扩展的部署模式下,存在多个读取组件或多个写入组件。
  • 在微服务模式下,存在多个同类型的组件实例。

并非所有的Loki组件都通过哈希环连接。以下组件需要连接到哈希环中:

  • 分发器(distributors)
  • 接收器(ingesters)
  • 查询调度器(query schedulers)
  • 压缩器(compactors)
  • 规则器(rulers)

以下组件可以选择性地连接到哈希环中:

  • 索引网关(index gateway)

在一个具有三个分发器和三个接收器的架构中,这些组件的哈希环将连接相同类型的组件实例。

ring-overview.png

环中的每个节点代表一个组件实例。每个节点都有一个键值存储,用于保存该环中每个节点的通信信息。节点定期更新键值存储,以确保所有节点之间的内容保持一致。对于每个节点,键值存储包含以下内容:

  • 组件节点的ID
  • 组件地址,用作其他节点的通信渠道
  • 组件节点健康状态的指示

2.4.1 配置环

common.ring_config块中定义环的配置。

除非有充分的理由使用不同的键值存储类型,否则请使用默认的memberlist键值存储类型。memberlist使用gossip协议将信息传播到所有节点,以确保键值存储内容的最终一致性。

分发器环、接收器环和规则器环还有其他配置选项。这些选项仅供高级和专业用途。这些选项分别在distributorsdistributor.ring块、ingestersingester.lifecycler.ring块以及rulersruler.ring块中定义。

2.4.2 关于分发器环

分发器使用其键值存储中的信息来计算分发器环中的分发器数量。这个数量进一步影响集群的限制。

2.4.3 关于接收器环

分发器使用键值存储中的接收器环信息。该信息使得分发器能够对日志行进行分片,确定将日志行发送到哪个接收器或一组接收器。

2.4.4 关于查询调度器环

查询调度器使用其键值存储中的信息进行调度器的服务发现。这允许查询器连接到所有可用的调度器,并使调度器能够连接到所有可用的查询前端,从而创建一个有助于平衡查询负载的单一队列。

2.4.5 关于压缩器环

压缩器使用键值存储中的信息来识别负责压缩的单个压缩器实例。尽管压缩器的目标是多个实例,但压缩器仅在负责的实例上启用。

2.4.6 关于规则器环

规则器环用于确定哪些规则组由哪些规则器评估。

2.4.7 关于索引网关环

索引网关环用于确定在规则器或查询器查询时,哪个网关负责哪个租户的索引。

3.标签(Labels)

标签是键值对,可以定义为任何内容!我们喜欢将它们称为元数据,用于描述日志流。如果您熟悉Prometheus,您可能已经见过一些标签,比如jobinstance,在接下来的示例中我将使用这些标签。

我们在Grafana Loki中提供的抓取配置也定义了这些标签。如果您正在使用Prometheus,Loki和Prometheus之间具有一致的标签是Loki的一项超能力,使得将应用程序指标与日志数据进行关联变得非常容易。

3.1 Loki如何使用标签

Loki中的标签扮演着非常重要的角色:它们定义了一个日志流。更具体地说,每个标签键和值的组合定义了一个日志流。如果仅有一个标签值发生变化,就会创建一个新的日志流。

如果您熟悉Prometheus,那里使用的术语是系列(series);然而,Prometheus还有一个额外的维度:指标名称(metric name)。Loki 简化了这一点,它没有指标名称,只有标签,而我们决定使用日志流(stream)而不是系列(series)来表示。

3.2 格式

Loki 对标签名称的命名有与Prometheus相同的限制:

它可以包含ASCII字母和数字,以及下划线和冒号。它必须符合正则表达式[a-zA-Z_:][a-zA-Z0-9_:]*

注意:冒号保留给用户定义的记录规则。导出器exporters或直接仪表化的过程不应使用冒号。

3.3 Loki 标签示例

以下一系列示例将说明 Loki 中标签的基本用法和概念。

我们以一个例子为例:

1scrape_configs:
2 - job_name: system
3   pipeline_stages:
4   static_configs:
5   - targets:
6      - localhost
7     labels:
8      job: syslog
9      __path__: /var/log/syslog

这个配置将追踪一个文件并分配一个标签:job=syslog。您可以按照以下方式进行查询:

1{job="syslog"}

这将在 Loki 中创建一个日志流。

现在让我们稍微扩展一下这个例子:

 1scrape_configs:
 2 - job_name: system
 3   pipeline_stages:
 4   static_configs:
 5   - targets:
 6      - localhost
 7     labels:
 8      job: syslog
 9      __path__: /var/log/syslog
10 - job_name: apache
11   pipeline_stages:
12   static_configs:
13   - targets:
14      - localhost
15     labels:
16      job: apache
17      __path__: /var/log/apache.log

现在我们将追踪两个文件。每个文件只有一个标签和一个值,因此 Loki 现在将存储两个日志流。

我们可以以几种方式查询这些日志流:

1{job="apache"} - 显示标签 job 为 apache 的日志
2{job="syslog"} - 显示标签 job 为 syslog 的日志
3{job=~"apache|syslog"} - 显示标签 job 为 apache 或 syslog 的日志

在最后一个示例中,我们使用了正则表达式标签匹配器来记录使用具有两个值的job标签的日志流。现在考虑如何使用额外的标签:

 1scrape_configs:
 2 - job_name: system
 3   pipeline_stages:
 4   static_configs:
 5   - targets:
 6      - localhost
 7     labels:
 8      job: syslog
 9      env: dev
10      __path__: /var/log/syslog
11 - job_name: apache
12   pipeline_stages:
13   static_configs:
14   - targets:
15      - localhost
16     labels:
17      job: apache
18      env: dev
19      __path__: /var/log/apache.log

现在,我们可以这样做:

1{env="dev"} - 将返回所有具有 env=dev 的日志,在这种情况下,包括两个日志流。

希望现在您开始看到标签的威力了。通过使用单个标签,您可以查询多个日志流。通过组合多个不同的标签,您可以创建非常灵活的日志查询。

标签是 Loki 日志数据的索引。它们用于查找独立存储为块的压缩日志内容。每个标签和值的唯一组合定义了一个日志流,而该日志流的日志被批量处理、压缩并以块形式存储。

为了让 Loki 高效且具有成本效益,我们必须负责地使用标签。接下来的部分将更详细地探讨这个问题。

3.4 Cardinality(基数)

前面的两个示例使用静态定义的具有单个值的标签;然而,还有一些方法可以动态定义标签。让我们来看一个使用Apache日志和一个可以用来解析这样一个日志行的庞大正则表达式的示例:

111.11.11.11 - frank [25/Jan/2000:14:00:01 -0500] "GET /1986.js HTTP/1.1" 200 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
 1- job_name: system
 2   pipeline_stages:
 3      - regex:
 4        expression: "^(?P<ip>\\S+) (?P<identd>\\S+) (?P<user>\\S+) \\[(?P<timestamp>[\\w:/]+\\s[+\\-]\\d{4})\\] \"(?P<action>\\S+)\\s?(?P<path>\\S+)?\\s?(?P<protocol>\\S+)?\" (?P<status_code>\\d{3}|-) (?P<size>\\d+|-)\\s?\"?(?P<referer>[^\"]*)\"?\\s?\"?(?P<useragent>[^\"]*)?\"?$"
 5    - labels:
 6        action:
 7        status_code:
 8   static_configs:
 9   - targets:
10      - localhost
11     labels:
12      job: apache
13      env: dev
14      __path__: /var/log/apache.log

这个正则表达式匹配日志行的每个组件,并将每个组件的值提取到一个捕获组中。在管道代码中,这些数据被放置在一个临时数据结构中,在处理该日志行时可以多次使用。关于这方面的更多详细信息可以在Promtail管道文档中找到。

根据该正则表达式,我们将使用两个捕获组来根据日志行的内容动态设置两个标签:

action(例如,action=“GET”、action=“POST”)

status_code(例如,status_code=“200”、status_code=“400”)

现在让我们逐行解释一些示例行:

111.11.11.11 - frank [25/Jan/2000:14:00:01 -0500] "GET /1986.js HTTP/1.1" 200 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
211.11.11.12 - frank [25/Jan/2000:14:00:02 -0500] "POST /1986.js HTTP/1.1" 200 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
311.11.11.13 - frank [25/Jan/2000:14:00:03 -0500] "GET /1986.js HTTP/1.1" 400 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
411.11.11.14 - frank [25/Jan/2000:14:00:04 -0500] "POST /1986.js HTTP/1.1" 400 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"

在Loki中,将创建以下日志流:

1{job="apache",env="dev",action="GET",status_code="200"} 11.11.11.11 - frank [25/Jan/2000:14:00:01 -0500] "GET /1986.js HTTP/1.1" 200 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
2{job="apache",env="dev",action="POST",status_code="200"} 11.11.11.12 - frank [25/Jan/2000:14:00:02 -0500] "POST /1986.js HTTP/1.1" 200 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
3{job="apache",env="dev",action="GET",status_code="400"} 11.11.11.13 - frank [25/Jan/2000:14:00:03 -0500] "GET /1986.js HTTP/1.1" 400 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
4{job="apache",env="dev",action="POST",status_code="400"} 11.11.11.14 - frank [25/Jan/2000:14:00:04 -0500] "POST /1986.js HTTP/1.1" 400 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"

这四个日志行将变成四个单独的日志流,并开始填充四个单独的块。

与这些标签/值组合匹配的任何其他日志行将添加到现有的日志流中。如果出现另一个唯一的标签组合(例如,status_code=“500”),将创建另一个新的日志流。

现在想象一下,如果您为 IP 设置了一个标签。不仅每个用户的请求都会成为一个唯一的日志流,而且来自同一用户但具有不同操作或状态代码的每个请求都将拥有自己的日志流。

做一些快速的计算,如果有大约四个常见的操作(GET、PUT、POST、DELETE)和大约四个常见的状态代码(尽管可能有多于四个!),那么将会有 16 个日志流和 16 个单独的块。现在如果乘以每个用户(如果我们使用 IP 标签),您很快就会拥有成千上万个日志流。

这就是高基数。这可能会使 Loki 崩溃。

当我们谈论基数时,我们指的是标签和值的组合以及它们创建的日志流数量。高基数是指使用具有大范围可能值的标签,例如 IP,或者即使使用具有小且有限值集的许多标签,例如使用 status_code 和 action。

高基数会导致 Loki 构建一个巨大的索引(read:$$$$),并将成千上万个微小的块刷新到对象存储中(read:缓慢)。在当前配置下,Loki的性能非常差,也是成本效益最低且最不好玩的运行和使用方式。

3.5 使用并行化的最佳Loki性能

现在您可能会问:如果使用大量标签或具有大量值的标签是不好的,那么我该如何查询我的日志?如果没有任何数据被索引,查询不会变得非常慢吗?

在使用Loki的用户中,我们发现习惯于其他索引密集型解决方案的人似乎觉得他们有责任定义大量标签以有效地查询日志。毕竟,许多其他日志解决方案都是基于索引的,这是常见的思维方式。

在使用Loki时,您可能需要忘记自己所知道的,并寻求使用并行化来以不同的方式解决问题。Loki的超能力在于将查询分解成小片段并并行发送,以便您可以在很短的时间内查询大量的日志数据。

这种蛮力的方法可能听起来并不理想,但让我解释一下为什么它是这样的。

大型索引复杂且昂贵。通常,日志数据的全文索引与日志数据本身的大小相同或更大。要查询日志数据,您需要加载此索引,并且为了提高性能,它可能应该在内存中。这很难扩展,随着您摄取更多的日志,索引迅速变大。

现在让我们来谈谈Loki,在Loki中,索引的大小通常比接收的日志容量小一个数量级。因此,如果您能够很好地将流和流的变动保持在最小限度,与接收的日志相比,索引的增长非常缓慢。

Loki将有效地将您的静态成本保持在尽可能低的水平上(索引大小和内存要求以及静态日志存储),并使查询性能成为您可以通过水平扩展在运行时控制的内容。

参考