关注
HarmonyOS技术社区
,回复
【鸿蒙】
送
定制T恤
(礼品不多,先到先得)
,还可以
免费下载
鸿蒙
入门资料
!
👇
扫码
立刻关注
👇
专注开源技术,共建鸿蒙生态
Loki 作为一个新兴的日志解决方案,现在越来越受到关注。经过调研比较,我们正在将日志服务底层逐步从 ES 替换为 Loki 。
本文基于我们对 Loki 的使用和理解,从它产生的背景、解决的问题、采用的方案、系统架构、实现逻辑等做一些剖析,希望对关注 Loki 的小伙伴们提供一些帮助。
在日常的系统可视化监控过程中,当监控探知到指标异常时,我们往往需要对问题的根因做出定位。
但监控数据所暴露的信息是提前预设、高度提炼的,在信息量上存在着很大的不足,它需要结合能够承载丰富信息的日志系统一起使用。
当监控系统探知到异常告警,我们通常在 Dashboard 上根据异常指标所属的集群、主机、实例、应用、时间等信息圈定问题的大致方向,然后跳转到日志系统做更精细的查询,获取更丰富的信息来最终判断问题根因。
在如上流程中,监控系统和日志系统往往是独立的,使用方式具有很大差异。比如监控系统 Prometheus 比较受欢迎,日志系统多采用 ES+Kibana 。
他们具有完全不同的概念、不同的搜索语法和界面,这不仅给使用者增加了学习成本,也使得在使用时需在两套系统中频繁做上下文切换,对问题的定位迟滞。
此外,日志系统多采用全文索引来支撑搜索服务,它需要为日志的原文建立反向索引,这会导致最终存储数据相较原始内容成倍增长,产生不可小觑的存储成本。
并且,不管数据将来是否会被搜索,都会在写入时因为索引操作而占用大量的计算资源,这对于日志这种写多读少的服务无疑也是一种计算资源的浪费。
Loki 则是为了应对上述问题而产生的解决方案,它的目标是打造能够与监控深度集成、成本极度低廉的日志系统。
①数据模型
在数据模型上,Loki 参考了 Prometheus ,数据由标签、时间戳、内容组成,所有标签相同的数据属于同一日志流:
{
"stream": {
"label1": "value1",
"label1": "value2"
}, # 标签
"values": [
["","log content"], # 时间戳,内容
["","log content"]
]
}
Loki 还支持多租户,同一租户下具有完全相同标签的日志所组成的集合称为一个日志流。
在日志的采集端使用和监控时序数据一致的标签,这样在可以后续与监控系统结合时使用相同的标签,也为在 UI 界面中与监控结合使用做快速上下文切换提供数据基础。
LogQL:
Loki 使用类似 Prometheus 的 PromQL 的查询语句 logQL ,语法简单并贴近社区使用习惯,降低用户学习和使用成本。
语法例子如下:
{file="debug.log""} |= "err"
流选择器:{label1="value1", label2="value2"}, 通过标签选择日志流, 支持等、不等、匹配、不匹配等选择方式。过滤器:|= "err",过滤日志内容,支持包含、不包含、匹配、不匹配等过滤方式。
这种工作方式类似于 find+grep,find 找出文件,grep 从文件中逐行匹配:
find . -name "debug.log" | grep err
logQL 除支持日志内容查询外,还支持对日志总量、频率等聚合计算。
Grafana:
在 Grafana 中原生支持 Loki 插件,将监控和日志查询集成在一起,在同一 UI 界面中可以对监控数据和日志进行 side-by-side 的下钻查询探索,比使用不同系统反复进行切换更直观、更便捷。
此外,在 Dashboard 中可以将监控和日志查询配置在一起,这样可同时查看监控数据走势和日志内容,为捕捉可能存在的问题提供更直观的途径。
只索引与日志相关的元数据标签,而日志内容则以压缩方式存储于对象存储中, 不做任何索引。
相较于 ES 这种全文索引的系统,数据可在十倍量级上降低,加上使用对象存储,最终存储成本可降低数十倍甚至更低。
方案不解决复杂的存储系统问题,而是直接应用现有成熟的分布式存储系统,比如 S3、GCS、Cassandra、BigTable 。
整体上 Loki 采用了读写分离的架构,由多个模块组成:
-
Promtail、Fluent-bit、Fluentd、Rsyslog 等开源客户端负责采集并上报日志。
-
Distributor:
日志写入入口,将数据转发到 Ingester。
-
Ingester:
日志的写入服务,缓存并写入日志内容和索引到底层存储。
-
Querier:
日志读取服务,执行搜索请求。
-
QueryFrontend:
日志读取入口,分发读取请求到 Querier 并返回结果。
-
Cassandra/BigTable/DnyamoDB/S3/GCS:
索引、日志内容底层存储。
-
Cache:
缓存,支持 Redis/Memcache/本地 Cache。
Distributor:
作为日志写入的入口服务,其负责对上报数据进行解析、校验与转发。
它将接收到的上报数解析完成后会进行大小、条目、频率、标签、租户等参数校验,然后将合法数据转发到 Ingester 服务,其在转发之前最重要的任务是确保同一日志流的数据必须转发到相同 Ingester 上,以确保数据的顺序性。
Hash 环:
Distributor 采用一致性哈希与副本因子相结合的办法来决定数据转发到哪些 Ingester 上。
Ingester 在启动后,会生成一系列的 32 位随机数作为自己的 Token ,然后与这一组 Token 一起将自己注册到 Hash 环中。
在选择数据转发目的地时,Distributor 根据日志的标签和租户 ID 生成 Hash,然后在 Hash 环中按 Token 的升序查找第一个大于这个 Hash 的 Token ,这个 Token 所对应的 Ingester 即为这条日志需要转发的目的地。
如果设置了副本因子,顺序的在之后的 Token 中查找不同的 Ingester 做为副本的目的地。
Hash 环可存储于 etcd、consul 中。另外 Loki 使用 Memberlist 实现了集群内部的 KV 存储,如不想依赖 etcd 或 consul ,可采用此方案。
输入输出:
Distributor 的输入主要是以 HTTP 协议批量的方式接受上报日志,日志封装格式支持 JSON 和 PB ,数据封装结构:
[
{
"stream": {
"label1": "value1",
"label1": "value2"
},
"values": [
["","log content"],
["","log content"]
]
},
......
]
Distributor 以 grpc 方式向 ingester 发送数据,数据封装结构:
{
"streams": [
{
"labels": "{label1=value1, label2=value2}",
"entries": [
{"ts": , "line:":"" },
{"ts": , "line:":"" },
]
}
....
]
}
①Ingester
作为 Loki 的写入模块,Ingester 主要任务是缓存并写入数据到底层存储。根据写入数据在模块中的生命周期,ingester 大体上分为校验、缓存、存储适配三层结构。
②校验
Loki 有个重要的特性是它不整理数据乱序,要求同一日志流的数据必须严格遵守时间戳单调递增顺序写入。
所以除对数据的长度、频率等做校验外,至关重要的是日志顺序检查。
Ingester 对每个日志流里每一条日志都会和上一条进行时间戳和内容的对比,策略如下:
-
与上一条日志相比,本条日志时间戳更新,接收本条日志。
-
与上一条日志相比,时间戳相同内容不同,接收本条日志。
-
与上一条日志相比,时间戳和内容都相同,忽略本条日志。
-
与上一条日志相比,本条日志时间戳更老,返回乱序错误。
③缓存
日志在内存中的缓存采用多层树形结构对不同租户、日志流做出隔离。同一日志流采用顺序追加方式写入分块:
-
Instances:
以租户的 userID 为键 Instance 为值的 Map 结构。
-
Instance:
一个租户下所有日志流(stream)的容器。
-
Streams:
以日志流的指纹(streamFP)为键,Stream 为值的 Map 结构。
-
Stream:
一个日志流所有 Chunk 的容器。
-
Chunks:
Chunk 的列表。
-
Chunk:
持久存储读写最小单元在内存态的结构。
-
Block:
Chunk 的分块,为已压缩归档的数据。
-
HeadBlock:
尚在开放写入的分块。
-
Entry:
单条日志单元,包含时间戳(timestamp)和日志内容(line)
Chunks:
在向内存写入数据前,ingester 首先会根据租户ID(userID)和由标签计算的指纹(streamPF)定位到日志流(stream)及 Chunks。
Chunks 由按时间升序排列的 chunk 组成,最后一个 chunk 接收最新写入的数据,其他则等刷写到底层存储。
当最后一个 chunk 的存活时间或数据大小超过指定阈值时,Chunks 尾部追加新的 chunk 。
Chunk:
Chunk 为 Loki 在底层存储上读写的最小单元在内存态下的结构。其由若干 block 组成,其中 headBlock 为正在开放写入的 block ,而其他 Block 则已经归档压缩的数据。
Block:
Block 为数据的压缩单元,目的是为了在读取操作那里避免因为每次解压整个 Chunk 而浪费计算资源,因为很多情况下是读取一个 chunk 的部分数据就满足所需数据量而返回结果了。
Block 存储的是日志的压缩数据,其结构为按时间顺序的日志时间戳和原始内容,压缩可采用 gzip、snappy 、lz4 等方式。