Loki 是一个水平可扩展、高可用、多租户的日志存储与查询系统。受 Prometheus 启发,Loki 不对日志内容进行索引,而是采用标签(Label)作为索引,从而降低存储成本。
Like Prometheus, but for logs.
架构设计
Loki 采用读写分离架构,由多个微服务构建而成,被设计成一个水平可扩展的分布式系统。
图片
其中写组件有:
1、Distributor 分发器:分发器通过 HTTP 接收到日志后,会进行验证(验证日志时间或日志行大小等是否满足规则)、预处理(对 labels 按照 key 以字典顺序排序,方便后续进行一致性 hash 算法来将日志发往 Ingester 接收器),同时还负责限速功能,流量过大时可以拒绝额外的请求。
2、Ingester 接收器:接收器是一个有状态的组件,在日志流进入时对其进行 gzip/snappy
压缩操作,并负责构建和刷新日志 chunk ,当内存中的 chunk 达到一定的数量或者时间后,就会刷新 chunk 和对应的 index 索引存储到本地文件系统或对象存储中。接收器默认会启用 WAL 功能,防止数据丢失。
读组件有:
1、Query Frontend 查询前端:查询前端是一个可选的组件,具有查询拆分、缓存的作用。一个查询可以拆解成多个小查询,并行在多个 Querier 组件上进行查询,最终合并返回给前端展示。查询前端内部有一个内存队列,还可以将其移出作为一个 Query Scheduler 查询调度器的单独进程运行。
2、Querier 查询器:接收一个时间范围和标签选择器,Querier 查询器根据 index 索引来确定哪些日志 chunk 匹配,然后将结果显示出来。在查询数据时,优先查询所有 Ingester 接收器中的内存数据,没查到再去查存储。如果开启了数据副本,数据可能重复,因此 Querier 还有数据去重的功能。
另外还有一些其它组件:
1、Ruler 规则器:负责日志告警功能,可以持续查询一个 rule 规则,并将超过阈值的事件推送给 AlertManager 或者其它告警 Webhook 服务。
2、Compactor 压缩器:负责定时对索引进行压缩合并,同时负责日志的删除功能。
3、Memcaches 缓存:这部分属于外部第三方组件,支持的缓存类型有 in-memory、redis 和 memcached ,可以在 Ingester 、Query Frontend 、Querier 和 Ruler 上配置 Results 查询结果缓存、Index 索引缓存或 Chunks 块缓存。
组件之间的交互流程大致如下:
图片
一致性哈希环设计
Loki 的分布式架构源自 https://github.com/cortexproject/cortex 项目,对于各组件服务状态和数据的通信均采用一致性哈希环设计,哈希环的配置支持 consul、etcd、inmemory 或 memberlist :
common:
ring:
kvstore:
# 支持切换为 consul、etcd、inmemory
store: memberlist
在 Loki 中定义了很多哈希环:IngesterRing、RulerRing 和 CompactorRing 等,以分发器(Distributor)和接收器(Ingester)组件为例,借助 IngesterRing 哈希环,Distributor 就可以确定日志流应该发往哪个 Ingester 实例,具体流程如下:
1、日志流的唯一性计算:每个日志流由租户 ID 及其所有标签的 key/value 组合唯一确定,并由此计算出日志流的 hash key
—— 一个无符号的 32 位整数。
2、Ingester 的注册:每个 Ingester 实例会在哈希环(IngesterRing)上注册自身,并分配一组 token(每个 token 为随机生成的无符号 32 位整数),用于确定其在哈希环中的位置。
图片
3、日志流的分配:当 Distributor 需要分发日志时,它会在哈希环上找到第一个大于日志流 hash key
的 token,并将该 token 所属的 Ingester 实例视为目标存储节点。
a. 副本机制:若数据副本数(replication_factor) 大于 1(默认为 3),则继续顺时针查找,找到下一个 token 对应的不同 Ingester 实例,确保日志的多副本存储,提高容错能力。
b. 状态约束:仅当目标 Ingester 处于 JOINING 或 ACTIVE 状态时,才能接收日志写入请求;仅当 Ingester 处于 ACTIVE 或 LEAVING 状态时,才能处理日志读取请求。
其动态演示效果如下:
图片
但在这种机制下,若单个日志流中的数据量过大,就容易导致 Ingester 实例负载不均衡,如下:
图片
此时,可以启用自动分片流功能,通过在现有日志流中自动添加 __stream_shard__
标签及其值,以控制日志流速保持在 desired_rate
以下,达到负载均衡的效果:
limits_config:
shard_streams:
enabled: true
desired_rate: 1536KB
图片
存储设计
在 Loki 中,标签(label)实际就是在提取日志时分配给日志的一组任意 key/value,既是 Loki 对传入数据进行分块的键,也是查询时用于查找日志的索引。
每个标签的 key 和 value 的组合会唯一定义成一个日志流(stream),哪怕仅有一个标签值发生了变化,都会重新创建一个新的日志流。
不同的日志流会在 Ingester 实例的内存中构建出不同的日志 chunk ,满足规则(达到 chunk_target_size
、max_chunk_age
或 chunk_idle_period
上限)就会刷新到对象存储或本地文件系统中:
图片
因此,Loki 需要存储两种不同类型的数据:块(chunk)和索引(index)。
- chunk:即日志本身,一个 chunk 包含很多 block ,进行压缩后存储
- index:即日志索引,key/value 结构,key 是日志 label 的哈希,value 则包含日志存在哪个 chunk 上、chunk 大小、日志的时间范围等信息
其中 chunk 的存储可以直接上传到配置的存储系统中(例如本地文件系统或 S3),而 index 的存储处理稍微麻烦些。
在 2.0 之前,chunk 和 index 的存储是分开的,意味着需要配置两个存储系统。而 2.0 开始,推行一种叫单一存储架构的设计,实现了 “boltdb-shipper” 索引存储,这种机制下只需要一个共享存储,例如 S3,就可以同时用于 chunk 和 index 的存储。到了 Loki 2.8 ,再次推出了更高效的 “tsdb-shipper” 索引存储,这也是目前 3.x 版本所推荐的索引存储方式。
这两种索引存储的原理大致上是相似的,工作可以分为 uploadsManager 和 downloadsManager 两部分:
- uploadsManager:负责上传
active_index_directory
内的索引分片到配置的共享存储中,同时负责定期清理工作 - downloadsManager:负责从共享存储下载索引到本地缓存目录
cache_location
,同时负责定期同步和清理工作
简单来理解就是,一个是把本地 boltdb 文件当作索引存储,另一个把本地 tsdb 文件当作索引存储,但这两种索引存储都有 “shipper” 的能力,可以把自身上传到配置的共享存储中,并保持同步。如此一来,我们就可以利用 S3 同时存储 chunk 和 index 了。
随着版本的迭代,不可避免会出现很多不同的存储模式,好在 Loki 允许通过日期起点来定义不同时间段使用不同的存储模式:
schema_config:
configs:
-from:2024-01-01
store:boltdb-shipper
object_store:s3
schema:v12
index:
prefix:index_
period:24h
-from:2025-01-01
store:tsdb
object_store:s3
schema:v13
index:
prefix:index_
period:24h
需要注意的是:
- 升级架构,始终将新模式中的 from 日期设置为未来的日期,要注意是从 UTC 00:00:00 开始。
- 全新部署,from 日期需要设置为以前的日期,才可以接收处理日志。
- 架构变更是无法撤销或回滚的,使用什么架构写入的数据只能由该架构读取。
查询设计
Loki 第一次查询时,Querier 查询器会从共享存储中下载查询时间范围内的索引并解压到本地缓存目录 cache_location
,并按 resync_interval
周期同步,该索引缓存有效期受 cache_ttl
配置控制。这部分工作由之前介绍的索引存储的 downloadsManager 完成,这也是为什么 Loki 的第一次查询会比较慢。
抛开拉取索引耗时这部分因素,在 Loki 中,查询从快到慢分别为:
图片
- Label matchers 标签匹配器(最快):直接基于索引匹配到块,查找出满足 limit 的日志条数
- Line filters 行过滤器(中等):把满足标签匹配器匹配到的块,再进行过滤,直到查找出满足 limit 的日志条数
- Label filters 标签过滤器(最慢):把满足标签匹配器匹配到的块,进行二次标签,然后再进行过滤,直到查找出满足 limit 的日志条数
以行过滤器的查询流程为例:
1、时间范围拆分:Query Frontend 首先根据 split_queries_by_interval
将查询拆分为多个较小的时间段。例如,一个跨度为 4 小时的查询可能被拆分为 4 个独立的 1 小时子查询,这种拆分可以并行处理不同时间段的数据。
图片
2、动态分片:Query Frontend 继续将每个时间段的子查询进行进一步的动态分片。分片数量取决于数据量,数据量大的子查询可能拆分为更多分片,而数据量小的可能仅少量分片。分片的目的是将 chunk 按日志流的标签进一步细分,从而提升并行处理效率。
图片
3、任务队列与并行处理:Query Frontend 将拆分后的分片任务提交至 Query Scheduler 任务队列中,根据公平调度策略将任务分配给空闲的 Querier 工作节点并行处理。Querier 会从 Ingester 中的内存数据或对象存储中拉取对应的数据块,解析并过滤日志内容,最终返回匹配的结果。
图片
4、结果合并:所有子查询和分片的结果会被汇总到 Query Frontend 组件,进行排序、去重和合并,最终返回完整的查询结果。
完整的查询流程如下:
图片
所以 Loki 的设计就是推荐使用并行化 (parallelization) 来实现最佳性能,将查询分解成小块,并将其并行调度,这样就可以在小时间内查询大量的日志数据。
本文中的部分动图和图片引用自以下官方博客,推荐阅读:
- https://grafana.com/blog/2023/12/11/open-source-log-monitoring-the-concise-guide-to-grafana-loki/
- https://grafana.com/blog/2023/12/20/the-concise-guide-to-grafana-loki-everything-you-need-to-know-about-labels/
- https://grafana.com/blog/2023/12/28/the-concise-guide-to-loki-how-to-get-the-most-out-of-your-query-performance/
- https://grafana.com/blog/2020/12/08/how-to-create-fast-queries-with-lokis-logql-to-filter-terabytes-of-logs-in-seconds/