Grafana 系列文章(十一):Loki 中的标签如何使日志查询更快更方便

时间:2023-02-08 10:10:44

????️URL: https://grafana.com/blog/2020/04/21/how-labels-in-loki-can-make-log-queries-faster-and-easier/

????Description:

关于标签在 Loki 中如何真正发挥作用,你需要知道的一切。它可能与你想象的不同

在我们从事 Loki 项目的第一年的大部分时间里,问题和反馈似乎都来自熟悉 Prometheus 的人。毕竟,Loki 就像 Prometheus--不过是针对日志的!"。

但是最近,我们看到越来越多的人尝试使用 Loki,他们没有 Prometheus 的经验,而且许多人来自于具有不同策略的系统,以处理日志。这就带来了很多关于 Loki 一个非常重要的概念的问题,即使是 Prometheus 专家也想了解更多:标签 (Labels)!

这篇文章将涵盖很多内容,以帮助每一个刚接触 Loki 的人和想要复习的人。我们将探讨以下主题。

什么是标签 (Label)?

标签是键值对,可以被定义为任何东西!我们喜欢把它们称为元数据 (metadata),用来描述日志流。如果你熟悉 Prometheus,你会习惯性地看到一些标签,比如jobinstance,我将在接下来的例子中使用这些。

我们用 Loki 提供的刮削 (scrape) 配置也定义了这些标签。如果你正在使用 Prometheus,在 Loki 和 Prometheus 之间拥有一致的标签是 Loki 的超级优势之一,使你 非常容易将你的应用程序指标 (Metrics) 与你的日志 (Logs) 数据联系起来

Loki 如何使用标签

Loki 中的标签执行一个非常重要的任务。它们定义了一个流。更确切地说,每个标签的键和值的组合都定义了流。如果只有一个标签值发生变化,就会产生一个新的流。

如果你熟悉 Prometheus,那里使用的术语是系列 (series);但是,Prometheus 有一个额外的维度:度量名称 (metric name)。Loki 简化了这一点,没有度量名称,只有标签,我们决定使用流而不是系列。

让我们举个例子:

scrape_configs:
 - job_name: system
   pipeline_stages:
   static_configs:
   - targets:
      - localhost
     labels:
      job: syslog
      __path__: /var/log/syslog

这个配置将跟踪一个文件并分配一个标签:job=syslog。你可以这样查询:

{job=”syslog”}

这将在 Loki 创建一个流。

现在让我们把这个例子扩大一点:

scrape_configs:
 - job_name: system
   pipeline_stages:
   static_configs:
   - targets:
      - localhost
     labels:
      job: syslog
      __path__: /var/log/syslog
 - job_name: system
   pipeline_stages:
   static_configs:
   - targets:
      - localhost
     labels:
      job: apache
      __path__: /var/log/apache.log

现在我们正在跟踪两个文件。每个文件只得到一个标签和一个值,所以 Loki 现在将存储两个数据流。

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

{job=”apache”} <- 显示标签 job 是 apache 的日志
{job=”syslog”} <- 显示标签 job 是 syslog 的日志
{job=~”apache|syslog”} <- 显示标签 job 是 apache **或** syslog 的日志

在最后一个例子中,我们使用了一个 regex 标签匹配器来记录使用标签 job 的两个值的流。现在考虑一下如何也使用一个额外的标签:

scrape_configs:
 - job_name: system
   pipeline_stages:
   static_configs:
   - targets:
      - localhost
     labels:
      job: syslog
      env: dev
      __path__: /var/log/syslog
 - job_name: system
   pipeline_stages:
   static_configs:
   - targets:
      - localhost
     labels:
      job: apache
      env: dev
      __path__: /var/log/apache.log

现在我们可以这样做,而不是使用正则表达式:

{env=”dev”} <- 返回 env=dev 的所有日志,本例中包括两个日志流

希望你现在开始看到标签的力量。通过使用一个标签,你可以查询许多数据流。通过结合几个不同的标签,你可以创建非常灵活的日志查询。

标签是 Loki 的日志数据的索引。它们被用来寻找压缩的日志内容,这些内容以块形式单独存储。每个独特的标签和值的组合都定义了一个流,一个流的日志被分批压缩,并作为块存储。

为了使 Loki 的效率和成本效益,我们必须负责任地使用标签。下一节将更详细地探讨这个问题。

基数 (Cardinality)

前面的两个例子使用的是静态定义的标签,只有一个值;但是,有一些方法可以动态地定义标签。让我们用 Apache 的日志和你可以用来解析这样的日志行的大量的重合词来看看。

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"
- job_name: system
   pipeline_stages:
      - regex:
        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>[^\"]*)?\"?$"
    - labels:
        action:
        status_code:
   static_configs:
   - targets:
      - localhost
     labels:
      job: apache
      env: dev
      __path__: /var/log/apache.log

这个词组匹配日志行的每一个组件,并将每个组件的值提取到一个捕获组中。在管道代码中,这些数据被放置在一个临时数据结构中,允许在处理该日志行时将其用于多种用途(此时,这些临时数据被丢弃)。关于这一点的更多细节可以在 这里 找到。

从该重合码中,我们将使用两个捕获组,根据日志行本身的内容动态地设置两个标签。

action(例如,action="GET",action="POST") status_code(例如, status_code="200", status_code="400")。

现在让我们看几个例子行:

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"
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"
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"
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"

在 Loki 中,将创建以下数据流:

{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"
{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"
{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"
{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,就把这个数字乘以每个用户。你可以很快有几千或几万个流。

这会导致很高的 cardinality。会杀死 Loki。

当我们谈论 cardinality 时,我们指的是标签和值的组合以及它们创造的流的数量。高 cardinality 是指使用具有大范围可能值的标签,如 ip,或结合许多标签,即使它们有一个小而有限的值集,如使用 status_code 和 action。

高 cardinality 导致 Loki 建立一个巨大的索引(读作:????????????????),并将成千上万的小块冲到对象存储中(读作:慢)。目前,Loki 在这种配置下表现很差,运行和使用起来将是最不划算和最没有乐趣的。

使用并行化 (parallelization) 的最佳 Loki 性能

现在你可能会问:如果使用大量的标签或有大量数值的标签是不好的,那么我应该如何查询我的日志呢?如果没有一个数据是有索引的,那查询岂不是很慢?

当我们看到使用 Loki 的人习惯于使用其他索引重复的解决方案时,他们似乎觉得有义务定义大量的标签,以便有效地查询他们的日志。毕竟,许多其他的日志解决方案都是关于索引的,这也是常见的思维方式。

在使用 Loki 时,你可能需要忘记你所知道的东西,看看如何用并行化的方式来解决这个问题。Loki 的超能力是将查询分解成小块,并将其并行调度,这样你就可以在小时间内查询大量的日志数据。

这种粗暴的方法听起来可能并不理想,但让我解释一下为什么会这样。

大型索引是复杂而昂贵的。通常情况下,你的日志数据的全文索引与日志数据本身的大小相同或更大。为了查询你的日志数据,你需要加载这个索引,而且为了性能,它可能应该在内存中。这是很难扩展的,当你摄入更多的日志时,你的索引会很快变大。

现在让我们来谈谈 Loki,它的索引通常比你摄入的日志量小一个数量级。因此,如果你能很好地保持你的数据流和数据流的流失,那么与摄取的日志相比,索引的增长非常缓慢。

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

为了了解这一点,让我们回过头来看看我们查询特定 IP 地址的访问日志数据的例子。我们不想用一个标签来存储 IP。相反,我们使用一个过滤器表达式来查询它。

{job=”apache”} |= “11.11.11.11”

在幕后,Loki 会将该查询分解成更小的片段(分片),并为标签所匹配的流打开每个分片,开始寻找这个 IP 地址。

这些分片的大小和并行化的数量是可配置的,并基于你提供的资源。如果你愿意,你可以把分片的间隔配置到 5m,部署 20 个查询器,在几秒钟内处理几十亿字节的日志。或者你可以疯狂地配置 200 个查询器,处理 TB 级的日志。

这种较小的索引和平行的暴力查询与较大/较快的全文索引之间的权衡,使得 Loki 能够比其他系统节省成本。操作大型索引的成本和复杂性很高,而且通常是固定的--无论你是否查询它,你都要一天 24 小时为它付费。

这种设计的好处是,你可以决定你想拥有多少查询能力,而且你可以按需改变。查询性能成为你想在上面花多少钱的一个函数。同时,数据被大量压缩并存储在低成本的对象存储中,如 S3 和 GCS。这使固定的运营成本降到最低,同时还能实现令人难以置信的快速查询能力

最佳实践

这里有一些 Loki 目前最有效的标签做法,可以给你带来 Loki 的最佳体验。

1. 推荐静态标签

像主机、应用程序和环境这些东西是很好的标签。它们对于一个给定的系统/应用程序来说是固定的,并且有限定的值。使用静态标签可以使你更容易在逻辑上查询你的日志(例如,给我看一个给定的应用程序和特定环境的所有日志,或者给我看一个特定主机上的所有应用程序的所有日志)。

2. 谨慎使用动态标签

太多的标签值组合会导致太多的数据流。在 Loki 中,这样做的惩罚是一个大索引和存储中的小块,这反过来又会降低性能。

为了避免这些问题,在你知道你需要它之前,不要为某样东西添加标签。使用过滤表达式 ( |= "text", |~ "regex", ...) 并对这些日志进行暴力处理。这很有效--而且速度很快。

从早期开始,我们就使用 promtail 管道为level动态地设置了一个标签。这对我们来说似乎很直观,因为我们经常想只显示level="error"的日志;然而,我们现在正在重新评估这一点,因为写一个查询。{app="loki"} |= "level=error"对我们的许多应用来说,证明与{app="loki",level="error"}一样快。

这似乎令人惊讶,但如果应用程序有中等至低容量,该标签导致一个应用程序的日志被分成多达五个流,这意味着 5 倍的块被存储。而加载块有一个与之相关的开销。想象一下,如果这个查询是{app="loki",level!="debug"}。这将不得不比{app="loki"} != "level=debug"}加载多的多数据块。

上面,我们提到在你需要它们之前不要添加标签,那么你什么时候会需要标签呢?再往下一点是关于 chunk_target_size 的部分。如果你把这个设置为 1MB(这是合理的),这将试图以 1MB 的压缩大小来切割块,这大约是 5MB 左右的未压缩的日志(可能多达 10MB,取决于压缩)。如果你的日志有足够的容量在比max_chunk_age更短的时间内写入 5MB,或者在这个时间范围内有多的多的块,你可能要考虑用动态标签把它分成独立的流。

你想避免的是将一个日志文件分割成流,这将导致块被刷新,因为流是空闲的或在满之前达到最大年龄。从 Loki 1.4.0 开始,有一个指标可以帮助你了解为什么要刷新数据块sum by (reason) (rate(loki_ingester_chunks_flushed_total{cluster="dev"}[1m]))

每个块在刷新时都是满的,这并不关键,但它将改善许多方面的操作。因此,我们目前的指导思想是尽可能避免动态标签,而倾向于过滤器表达式。例如,不要添加 level 的动态标签,而用|= "level=debug"代替。

3. 标签值必须始终是有界的

如果你要动态地设置标签,千万不要使用可以有*值或无限值的标签。这总是会给 Loki 带来大问题。

尽量将值限制在尽可能小的范围内。我们对 Loki 能处理的数值没有完美的指导,但对于动态标签来说,要考虑个位数,或者10 个数值。这对静态标签来说就不那么重要了。例如,如果你的环境中有 1,000 台主机,那么有 1,000 个值的主机标签就会很好。

4. 注意客户端的动态标签

Loki 有几个客户端选项。Promtail(也支持 systemd 日志摄取和基于 TCP 的系统日志摄取),FluentDFluent Bit,一个 Docker 插件,以及更多!

每一个都有方法来配置用什么标签来创建日志流。但要注意可能会用哪些动态标签。使用 Loki 系列 API 来了解你的日志流是什么样子的,看看是否有办法减少流和 cardinality。系列 API 的细节可以在 这里 找到,或者你可以使用 logcli 来查询 Loki 的系列信息。

5. 配置缓存

Loki 可以对数据进行多层次的缓存,这可以极大地提高性能。这方面的细节将在今后的文章中介绍。

6. 每条流的日志必须按时间顺序递增(新版本默认接受无序日志)

????Notes:

新版本默认接受无序日志

许多人在使用 Loki 时遇到的一个问题是,他们的客户端收到了错误的日志条目。这是因为 Loki 内部有一条硬性规定。

  • 对于任何单一的日志流,日志必须总是以递增的时间顺序发送。如果收到的日志的时间戳比该流收到的最新日志的时间戳大,该日志将被放弃。

从这个声明中,有几件事需要剖析。首先,这个限制是针对每个流的。让我们看一个例子:

{job=”syslog”} 00:00:00 i’m a syslog!
{job=”syslog”} 00:00:01 i’m a syslog!

如果 Loki 收到这两行是针对同一流的,那么一切都会好起来。但这种情况呢?

{job=”syslog”} 00:00:00 i’m a syslog!
{job=”syslog”} 00:00:02 i’m a syslog!
{job=”syslog”} 00:00:01 i’m a syslog!  <- 拒绝不符合顺序的!

嗯,额。..... 但我们能做些什么呢?如果这是因为这些日志的来源是不同的系统呢?我们可以用一个额外的标签来解决这个问题,这个标签在每个系统中是唯一的。

{job=”syslog”, instance=”host1”} 00:00:00 i’m a syslog!
{job=”syslog”, instance=”host1”} 00:00:02 i’m a syslog!
{job=”syslog”, instance=”host2”} 00:00:01 i’m a syslog!  <- 被接受,这是一个新的流!
{job=”syslog”, instance=”host1”} 00:00:03 i’m a syslog!  <- 被接受,流 1 仍是有序的
{job=”syslog”, instance=”host2”} 00:00:02 i’m a syslog!  <- 被接受,流 2 仍是有序的

但是,如果应用程序本身产生的日志是不正常的呢?嗯,这恐怕是个问题。如果你用类似 promtail 管道阶段的东西从日志行中提取时间戳,你反而可以不这样做,让 Promtail 给日志行分配一个时间戳。或者你可以希望在应用程序本身中修复它。

但是我想让 Loki 来解决这个问题!为什么你不能为我缓冲数据流并重新排序?说实话,因为这将给 Loki 增加大量的内存开销和复杂性,而正如这篇文章中的一个共同点,我们希望 Loki 简单而经济。理想情况下,我们希望改进我们的客户端来做一些基本的缓冲和排序,因为这似乎是解决这个问题的一个更好的地方。

另外值得注意的是,Loki 推送 API 的批处理性质可能会导致收到一些顺序错误的情况,这其实是误报。(也许一个批处理部分成功了,并出现了;或者任何以前成功的东西都会返回一个失序的条目;或者任何新的东西都会被接受)。

7. 使用 chunk_target_size

这是在 2020 年早些时候我们 发布 Loki v1.3.0 时添加的,我们已经用它实验了几个月。现在我们在所有的环境中都有chunk_target_size: 1536000。这指示 Loki 尝试将所有的 chunks 填充到 1.5MB 的目标压缩大小。这些较大的块对 Loki 来说是更有效的处理。

其他几个配置变量会影响到一个块的大小。Loki 默认的 max_chunk_age 为 1 小时,chunk_idle_period 为 30 分钟,以限制所使用的内存量,以及在进程崩溃时丢失日志的风险。

根据使用的压缩方式(我们一直使用 snappy,它的可压缩性较低,但性能较快),你需要 5-10 倍或 7.5-10MB 的原始日志数据来填充 1.5MB 的块。记住,一个块是每一个流,你把你的日志文件分成的流越多,在内存中的块就越多,在它们被填满之前,它们被击中上述的超时的可能性就越大。

很多小的、未填充的块目前是 Loki 的顽石。我们一直在努力改善这一点,并可能考虑在某些情况下使用压缩器来改善这一点。但是,一般来说,指导原则应该保持不变:尽力填充块。

如果你有一个应用程序,它的记录速度足以迅速填满这些块(远远小于max_chunk_age),那么使用动态标签将其分解成独立的数据流就变得更加合理。

总结

我最后再强调一次这个死马当活马医的主意吧!

为了性能而使用并行化,而不是标签和索引

对标签要严格要求。静态标签通常是好的,但动态标签应该少用。(如果你的日志流以每分钟 5-10MB 的速度写入,那么考虑一个动态标签如何将其分成两到三个流,这可以提高查询性能。如果你的量比较少,坚持使用 过滤表达式

索引不一定是 Loki 的性能之路!首先要优先考虑并行化和 LogQL 查询过滤。

请记住:与其他日志存储解决方案相比,Loki 需要一种不同的思维方式。我们正在对 Loki 进行优化,以获得更少的数据流和更小的索引,这有助于填充更大的块,更容易通过并行化进行查询。

我们正在积极改进 Loki,并研究如何做到这一点。请务必继续关注 Loki 故事的展开,我们都在琢磨如何将这个真正有效的工具发挥到极致!

Grafana 系列文章

Grafana 系列文章

三人行, 必有我师; 知识共享, 天下为公. 本文由东风微鸣技术博客 EWhisper.cn 编写.