一、背景
在日常的零碎可视化监控过程中,当监控探知到指标异样时,咱们往往须要对问题的根因做出定位。但监控数据所裸露的信息是提前预设、高度提炼的,在信息量上存在着很大的有余,它须要联合可能承载丰盛信息的日志零碎一起应用。
当监控零碎探知到异样告警,咱们通常在 Dashboard 上依据异样指标所属的集群、主机、实例、利用、工夫等信息圈定问题的大抵方向,而后跳转到日志零碎做更精密的查问,获取更丰盛的信息来最终判断问题根因。
在如上流程中,监控零碎和日志零碎往往是独立的,应用形式具备很大差别。比方监控零碎 Prometheus 比拟受欢迎,日志零碎多采纳 ES+Kibana。他们具备齐全不同的概念、不同的搜寻语法和界面,这不仅给使用者减少了学习老本,也使得在应用时需在两套零碎中频繁做上下文切换,对问题的定位通畅。
此外,日志零碎多采纳全文索引来撑持搜寻服务,它须要为日志的原文建设反向索引,这会导致最终存储数据相较原始内容成倍增长,产生不可小觑的存储老本。并且,不论数据未来是否会被搜寻,都会在写入时因为索引操作而占用大量的计算资源,这对于日志这种写多读少的服务无疑也是一种计算资源的节约。
Loki 则是为了应答上述问题而产生的解决方案,它的指标是 打造可能与监控深度集成、老本极度低廉的日志零碎。
二、Loki 日志计划
1,低应用老本
数据模型
在数据模型上 Loki 参考了 Prometheus。数据由 标签 、 工夫戳 、 内容 组成,所有标签雷同的数据属于同 一日志流,具备如下构造:
在数据模型上 Loki 参考了 Prometheus。数据由 标签 、 工夫戳 、 内容 组成,所有标签雷同的数据属于同一 日志流,具备如下构造:
{
"stream": {
"label1": "value1",
"label1": "value2"
}, # 标签
"values": [["<timestamp nanoseconds>","log content"], # 工夫戳,内容
["<timestamp nanoseconds>","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。
2,架构
整体上 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": [["<timestamp nanoseconds>","log content"],
["<timestamp nanoseconds>","log content"]
]
}
......
]
Distributor 以 grpc 形式向 ingester 发送数据,数据封装构造:
{
"streams": [
{"labels": "{label1=value1, label2=value2}",
"entries": [{"ts": <unix epoch in nanoseconds>, "line:":"<log line>"},
{"ts": <unix epoch in nanoseconds>, "line:":"<log 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 等形式。
HeadBlock
正在接管写入的非凡 block,它在满足肯定大小后会被压缩归档为 Block,而后新 headBlock 会被创立。
存储适配
因为底层存储要反对 S3、Cassandra、BigTable、DnyamoDB 等零碎,适配层将各种零碎的读写操作形象成对立接口,负责与他们进行数据交互。
输入
Chunk
Loki 以 Chunk 为单位在存储系统中读写数据。在长久存储态下的 Chunk 具备如下构造
- meta:封装 chunk 所属 stream 的指纹、租户 ID,开始截止工夫等元信息;
- data:封装日志内容,其中一些重要字段;
- encode 保留数据的压缩形式;
- block-N bytes 保留一个 block 的日志数据;
- blocks section byte offset 单元记录 #block 单元的偏移量;
- block 单元记录一共有多少个 block;
- entries 和 block-N bytes 一一对应,记录每个 block 里有日式行数、工夫起始点,blokc-N bytes 的开始地位和长度等元信息。
Chunk 数据的解析程序:
- 依据尾部的 #blocks section byte offset 单元失去 #block 单元的地位;
- 依据 #block 单元记录得出 chunk 里 block 数量;
- 从 #block 单元所在位置开始读取所有 block 的 entries、mint、maxt、offset、len 等元信息;
- 程序的依据每个 block 元信息解析出 block 的数据
索引
Loki 只索引了标签数据,用于实现 标签→日志流→Chunk 的索引映射,以分表模式在存储层存储。
1. 表构造
CREATE TABLE IF NOT EXISTS Table_N (
hash text,
range blob,value blob,
PRIMARY KEY (hash, range)
)
- Table_N,依据工夫周期分表名;
- hash,不同查问类型时应用的索引;
- range,范畴查问字段;
- value,日志标签的值
2. 数据类型
Loki 保留了不同类型的索引数据用以实现不同映射场景,对于每种类型的映射数据,Hash/Range/Value 三个字段的数据组成如下图所示:
seriesID 为 日志流 ID,shard 为 分片 ,userID 为 租户 ID,labelName 为 标签名 ,labelValueHash 为 标签值 hash,chunkID 为 chunk 的 ID,chunkThrough 为 chunk 里 最初一条数据的工夫 这些数据元素在映射过程中的作用在 Querier 环节的查问流程) 做具体介绍。
上图中三种色彩标识的索引类型从上到下别离为:
- 数据类型 1:用于依据用户 ID 搜寻查问所有日志流的 ID;
- 数据类型 2:用于依据用户 ID 和标签查问日志流的 ID;
- 数据类型 3:用于依据日志流 ID 查问底层存储 Chunk 的 ID;
除了采纳分表外,Loki 还采纳分桶、分片的形式优化索引查问速度。
- 分桶
以天宰割:
bucketID = timestamp / secondsInDay。
以小时宰割:
bucketID = timestamp / secondsInHour。
- 分片
将不同日志流的索引扩散到不同分片,shard = seriesID% 分片数。
Chunk 状态
Chunk 作为在 Ingester 中重要的数据单元,其在内存中的生命周期内分如下四种状态:
- Writing:正在写入新数据;
- Waiting flush:进行写入新数据,期待写入到存储;
- Retain:曾经写入存储,期待销毁;
- Destroy:曾经销毁。
四种状态之间的转换以 writing -> waiting flush -> retain -> destroy 程序进行。
1. 状态转换机会
- 合作触发:有新的数据写入申请;
- 定时触发:刷写周期 触发将 chunk 写入存储, 回收周期 触发将 chunk 销毁。
2. writing 转为 waiting flush
chunk 初始状态为 writing,标识正在承受数据的写入,满足如下条件则进入到期待刷写状态:
- chunk 空间满(合作触发);
- chunk 的 存活工夫(首末两条数据时间差)超过阈值(定时触发);
- chunk 的 闲暇工夫 (间断未写入数据时长) 超过设置(定时触发)。
3. waiting flush 转为 etain
Ingester 会定时的将期待刷写的 chunk 写到底层存储,之后这些 chunk 会处于”retain“状态,这是因为 ingester 提供了对最新数据的搜寻服务,须要在内存里保留一段时间,retain 状态则解耦了数据的 刷写工夫 以及在内存中的 保留工夫,不便视不同选项优化内存配置。
4. destroy,被回收期待 GC 销毁
总体上,Loki 因为针对日志的应用场景,采纳了程序追加形式写入,只索引元信息,极大水平上简化了它的数据结构和解决逻辑,这也为 Ingester 可能应答高速写入提供了根底。
Querier
查问服务的执行组件,其负责从底层存储拉取数据并依照 LogQL 语言所形容的筛选条件过滤。它能够间接通过 API 提供查问服务,也能够与 queryFrontend 联合应用实现分布式并发查问。
查问类型
- 范畴日志查问
- 单日志查问
- 统计查问
- 元信息查问
在这些查问类型中,范畴日志查问利用最为宽泛,所以下文只对范畴日志查问做具体介绍。
并发查问
对于单个查问申请,尽管能够间接调用 Querier 的 API 进行查问,但很容易会因为大查问导致 OOM,为应答此种问题 querier 与 queryFrontend 联合一起实现查问合成与多 querier 并发执行。
每个 querier 都与所有 queryFrontend 建设 grpc 双向流式连贯,实时从 queryFrontend 中获取曾经宰割的子查问求,执行后将后果发送回 queryFrontend。具体如何宰割查问及在 querier 间调度子查问将在 queryFrontend 环节介绍。
查问流程
1. 解析 logQL 指令
2. 查问日志流 ID 列表
Loki 依据不同的标签选择器语法应用了不同的索引查问逻辑,大体分为两种:
- =,或多值的正则匹配 =~,工作过程如下:
- 以相似下 SQL 所形容的语义查问出 标签选择器 里援用的每个 标签键值对 所对应的 日志流 ID(seriesID) 的汇合。
SELECT * FROM Table_N WHERE hash=?AND range>=?AND value=labelValue
◆ hash 为租户 ID(userID)、分桶 (bucketID)、标签名(labelName) 组合计算的哈希值;◆ range 为标签值 (labelValue) 计算的哈希值。
- 将依据 标签键值对 所查问的多个 seriesID 汇合取并集或交加求最终汇合。
比方,标签选择器 {file=”app.log”, level=~”debug|error”} 的工作过程如下:
- 查问出 file=”app.log”,level=”debug”, level=”error” 三个标签键值所对应的 seriesID 汇合,S1、S2、S3;2. 依据三个汇合计算最终 seriesID 汇合 S = S1∩cap (S2∪S3)。
- !=,=~,!~,工作过程如下:
- 以如下 SQL 所形容的语义查问出 标签选择器 里援用的每个 标签 所对应 seriesID 汇合。
SELECT * FROM Table_N WHERE hash = ?
◆ hash 为租户 ID(userID)、分桶(bucketID)、标签名(labelName)。
- 依据标签抉择语法对每个 seriesID 汇合进行过滤。
- 将过滤后的汇合进行并集、交加等操作求最终汇合。
比方,{file~=”mysql*”, level!=”error”}的工作过程如下:
- 查问出标签“file”和标签 ”level” 对应的 seriesID 的汇合,S1、S2;2. 求出 S1 中 file 的值匹配 mysql* 的子集 SS1,S2 中 level 的值!=”error” 的子集 SS2;3. 计算最终 seriesID 汇合 S = SS1∩SS2。
3. 以如下 SQL 所形容的语义查问出所有日志流所蕴含的 chunk 的 ID
SELECT * FROM Table_N Where hash = ?
- hash 为分桶 (bucketID) 和日志流 (seriesID) 计算的哈希值。
4. 依据 chunkID 列表生成遍历器来程序读取日志行
遍历器作为数据读取的组件,其次要性能为从存储系统中拉取 chunk 并从中读取日志行。其采纳多层树形构造,自顶向下逐层递归触发形式弹出数据。具体构造如下图所示:
- batch Iterator:以批量的形式从存储中下载 chunk 原始数据,并生成 iterator 树;
- stream Iterator:多个 stream 数据的遍历器,其采纳堆排序确保多个 stream 之间数据的保序;
- chunks Iterator:多个 chunk 数据的遍历器,同样采纳堆排序确保多个 chunk 之间保序及多正本之间的去重;
- blocks Iterator:多个 block 数据的遍历器;
- block bytes Iterator:block 里日志行的遍历器。
5. 从 Ingester 查问在内存中尚未写入到存储中的数据
因为 Ingester 是定时的将缓存数据写入到存储中,所以 Querier 在查问工夫范畴较新的数据时,还会通过 grpc 协定从每个 ingester 中查问出内存数据。须要在 ingester 中查问的工夫范畴是可配置的,视 ingester 缓存数据时长而定。
下面是日志内容查问的次要流程。至于指标查问的流程与其大同小异,只是减少了指标计算的遍历器层用于从查问出的日志计算指标数据。其余两种则更为简略,这里不再具体开展。
QueryFrontend
Loki 对查问采纳了计算后置的形式,相似于在大量原始数据上做 grep,所以查问势必会耗费比拟多的计算和内存资源。如果以单节点执行一个查问申请的话很容易因为大查问造成 OOM、速度慢等性能瓶颈。为解决此问题,Loki 采纳了将单个查问合成在多个 querier 上并发执行形式,其中查问申请的合成和调度则由 queryFrontend 实现。
queryFrontend 在 Loki 的整体架构上处于 querier 的前端,它作为数据读取操作的入口服务,其次要的组件及工作流程如下图所示:
- 宰割 Request:将单个查问宰割成子查问 subReq 的列表;
- Feeder: 将子查问程序注入到缓存队列 Buf Queue;
- Runner: 多个并发的运行器将 Buf Queue 中的查问并注入到子查问队列,并期待返回查问后果;
- Querier 通过 grpc 协定实时从子查问队列弹出子查问,执行后将后果返回给相应的 Runner;
- 所有子申请在 Runner 执行结束后汇总后果返回 API 响应。
查问宰割
queryFrontend 依照固定时间跨度将查问申请宰割成多个子查问。比方,一个查问的工夫范畴是 6 小时,宰割跨度为 15 分钟,则查问会被分为 6 *60/15=24 个子查问
查问调度
Feeder
Feeder 负责将宰割好的子查问逐个的写入到缓存队列 Buf Queue,以生产者 / 消费者模式与上游的 Runner 实现可控的子查问并发。
Runner
从 Buf Queue 中竞争形式读取子查问并写入到上游的申请队列中,并解决来自 Querier 的返回后果。Runner 的并发个数通过全局配置管制,防止因为一次合成过多子查问而对 Querier 造成微小的徒流量,影响其稳定性。
子查问队列
队列是一个二维构造,第一维存储的是不同租户的队列,第二维存储同一租户子查问列表,它们都是以 FIFO 的程序组织外面的元素的入队出队
调配申请
queryFrontend 是以被动形式调配查问申请,后端 Querier 与 queryFrontend 实时的通过 grpc 监听子查问队列,当有新申请时以如下程序在队列中弹出下一个申请:
- 以循环的形式遍历队列中的租户列表,寻找下一个有数据的租户队列;
- 弹出该租户队列中的最老的申请。
三、总结
Loki 作为一个正在疾速倒退的我的项目,最新版本已到 2.0,相较 1.6 加强了诸如日志解析、Ruler、Boltdb-shipper 等新性能,不过根本的模块、架构、数据模型、工作原理上已处于稳固状态,心愿本文的这些尝试性的分析可能可能为大家提供一些帮忙,如文中有了解谬误之处,欢送批评指正。
举荐浏览:
- 11.11Tech Talk | 揭秘 11.11 监控排障利器 京东高稳固日志服务深度解析
- 面对 DevOps 怎么做?这里有一份京东 11.11DevOps 备战指南
- 轻松撑持百万级数据点写入 京东智联云时序数据库 HoraeDB 架构解密
欢送点击【京东智联云】,理解开发者社区
更多精彩技术实际与独家干货解析
欢送关注【京东智联云开发者】公众号