当前位置: 首页 > 科技观察

还在用ELK?是时候了解一下轻量化日志服务Loki了

时间:2023-03-20 23:59:22 科技观察

还在用ELK?又到了学习Loki这个轻量级日志服务的时候了,希望能给关注Loki的朋友提供一些帮助。在日常的系统可视化监控过程中,当监控检测到异常指标时,我们往往需要定位问题的根源。但是,监控数据暴露的信息是预先预设的、高度精炼的,信息量上存在很大不足。它需要与能够承载丰富信息的日志系统一起使用。当监控系统检测到异常告警时,我们通常会根据异常指标所属的集群、主机、实例、应用、时间等信息在Dashboard上勾画出问题的大致方向,然后跳转到日志系统进行更详细的查询,获取更丰富的信息,确定问题的根源。在上述过程中,监控系统和日志系统往往是独立的,使用方式也大不相同。比如监控系统Prometheus比较流行,日志系统多采用ES+Kibana。它们有着完全不同的概念、不同的搜索语法和接口,这不仅增加了用户的学习成本,而且在使用时需要频繁地在两个系统之间进行上下文切换,难以定位问题。另外,日志系统大多采用全文索引来支持搜索服务。需要对日志原文建立反向索引,这会导致最终存储的数据与原始内容相比翻倍,造成不可低估的存储成本。另外,无论以后是否要查找数据,写入时都会因为索引操作而占用大量的计算资源,对于日志等写多写少的业务来说,无疑是一种计算资源的浪费读。Loki是解决上述问题的方法。它的目标是打造一个可以与监控深度结合,并且成本极低的日志系统。使用成本低的Loki日志解决方案①数据模型在数据模型方面,Loki参考了Prometheus。数据由标签、时间戳和内容组成。具有相同标签的所有数据都属于同一个日志流:标签描述了集群、服务、主机、应用程序和类型等元信息,用于后面的搜索服务。Timestamp,日志生成时间。Content,日志的原始内容。具有以下结构:{"stream":{"label1":"value1","label1":"value2"},#label"values":[["","logcontent"],#timestamp,Content["","logcontent"]]}Loki也支持多租户,同一租户下具有完全相同标签的日志集合称为日志流。在日志采集端使用与监控时序数据一致的标签,以便后面结合监控系统时可以使用相同的标签,也为UI界面结合监控时快速上下文切换提供数据基础.LogQL:Loki使用了类似于Prometheus的PromQL的查询语句logQL,语法简单,贴近社区的使用习惯,降低了用户的学习和使用成本。语法示例如下:{file="debug.log""}|="err"streamselector:{label1="value1",label2="value2"},通过label选择日志流,支持equal,unequal,match,mismatch等选择方式filter:|="err",过滤日志内容,支持include,notinclude,match,mismatch等过滤方式,这种工作方式类似于find+grep,find查找文件,grep从文件中逐行匹配:find.-name"debug.log"|greperrlogQL除了支持日志内容查询,还支持日志总量和频率的聚合计算Grafana:原生支持Loki插件-在Grafana中,将监控和日志查询整合在一起,可以在同一个UI界面对监控数据和日志进行并排下钻查询探索,比反复切换不同的更直观方便系统。另外,在Dashboard中可以将监控和日志查询一起配置,可以同时查看监控数据趋势和日志内容,更直观地发现可能存在的问题。存储成本低仅对与日志相关的元数据标签进行索引,而日志内容以压缩的方式存储在对象存储中,不进行任何索引。与ES这样的全文索引系统相比,数据可以减少十倍。使用对象存储,最终的存储成本可以降低几十倍甚至更低。该方案不解决复杂的存储系统问题,而是直接应用现有成熟的分布式存储系统,如S3、GCS、Cassandra、BigTable等。Loki架构Loki整体采用读写分离架构,由多个模块组成:Promtail、Fluent-bit、Fluentd、Rsyslog等开源客户端,负责收集和上报日志。Distributor:日志写入entry,数据转发给Ingester。Ingester:日志写入服务,将日志内容和索引缓存并写入底层存储。Querier:日志读取服务,执行查询请求。QueryFrontend:日志读取入口,向Querier发送读取请求并返回结果。Cassandra/BigTable/DnyamoDB/S3/GCS:索引和日志内容的底层存储。Cache:缓存,支持Redis/Memcache/localCache。其主要结构如下图所示:Distributor:作为日志写入的入口服务,负责上报数据的解析、校验和转发。解析接收到的上报数据后,会检查size、entry、frequency、label、tenant等参数,然后将合法的数据转发给Ingester服务。转发前最重要的工作就是保证同一个日志流的数据必须转发到同一个Ingester,保证数据的顺序。哈希环:Distributor使用一致性哈希和复制因子的组合来确定将数据转发到哪些Ingester。Ingester启动后,会生成一串32位的随机数作为自己的Token,然后将自己连同这组Token一起注册到Hash环中。在选择数据转发目的地时,Distributor根据日志标签和租户ID生成Hash,然后在Hash环中按Token升序查找第一个大于Hash的Token。这个Token对应的Ingester就是日志需要转发的目的地。如果设置了复制因子,则依次在后续的Token中寻找不同的Ingesters作为复制的目的地。哈希环可以存储在etcd和consul中。另外,Loki使用Memberlist实现集群内部的KV存储。如果你不想依赖etcd或consul,你可以使用这个解决方案。输入输出:Distributor的输入主要是通过HTTP协议批量接受和上报日志。日志封装格式支持JSON和PB,数据封装结构:[{"stream":{"label1":"value1","label1":"value2"},"values":[["""logcontent"],["","logcontent"]]},...]Distributor以grpc方式向ingester发送数据,数据封装结构:{"streams":[{"labels":"{label1=value1,label2=value2}","entries":[{"ts":,"line:":""},{"ts":,"line:":""},]}....]}①Ingester是Loki的写入模块,Ingester的主要任务是对数据进行缓存和写入底层存储。根据写入数据在模块中的生命周期,ingester大致分为三层:校验、缓存、存储适配。②验证Loki的一个重要特点是不乱序组织数据,要求同一个日志流的数据必须严格按照时间戳单调递增的顺序写入。因此,除了验证数据的长度和频率外,检查日志顺序也很重要。Ingester将每个日志流中每个日志的时间戳和内容与前一个进行比较。策略如下:与上一条日志相比,这条日志的时间戳更新,接收这条日志。与之前的日志相比,时间戳相同,内容不同。收到此日志。与之前的日志相比,时间戳和内容都是一样的,忽略这条日志。与上一条日志相比,这条日志的时间戳更旧,返回乱序错误。③缓存日志在内存中的缓存采用多层树结构,隔离不同租户和日志流。同样的日志流以顺序追加的方式写入块中:Instances:以租户的userID为键,Instance为值的Map结构。实例:一个租户下所有日志流的容器。Streams:以日志流指纹(streamFP)为键,Stream为值的Map结构。流:日志流的所有块的容器。块:块列表。Chunk:内存状态下用于读写的最小持久存储单元的结构。Block:Chunk的块,即压缩后的归档数据。HeadBlock:仍处于写入状态的块。Entry:单个日志单元,包括时间戳(timestamp)和日志内容(line),整体结构如下:租户ID(userID)和标签定位日志流(stream)和Chunks。块由按时间升序排列的块组成。最后一个chunk接收最新写入的数据,其他的flush到底层存储。当最后一个chunk的生存时间或数据大小超过指定的阈值时,Chunks在末尾追加一个新的chunk。Chunk:Chunk是Loki在底层存储上读写的最小单元的内存状态中的结构。它由几个块组成,其中headBlock是一个正在打开写入的块,而其他块则有归档的压缩数据。块:块是数据的压缩单位。这样做的目的是为了避免在读操作时每次都解压整个Chunk造成计算资源的浪费,因为在很多情况下读取一个chunk的部分数据就可以满足需要的数据量并将结果返回上去。Block存放的是日志的压缩数据。它的结构是按时间顺序排列的日志时间戳和原始内容。可以通过gzip、snappy、lz4等进行压缩。HeadBlock:接收写入的特殊块。达到一定大小后会压缩归档为一个Block,然后创建一个新的headBlock。存储适配:由于底层存储需要支持S3、Cassandra、BigTable、DnyamoDB等系统,因此适配层将各个系统的读写操作抽象为统一的接口,负责与它们进行数据交互。④OutputLoki以Chunk为单位在存储系统中读写数据。持久化存储状态的Chunk有如下结构:meta:封装了chunk所属流的指纹、租户ID、起止时间等元信息。data:封装日志内容,包括一些重要的字段。encode保存数据的压缩方法。block-N字节保存一块日志数据。#blocks段字节偏移量单元记录了#block单元的偏移量。#block单元记录了总共有多少块。#entries一一对应block-N字节,记录每个block的日文行数、起始时间点、blokc-N字节的起始位置和长度等元信息。Chunk数据的解析顺序:根据末尾的#blocks段字节偏移单元得到#block单元的位置。根据#blockunit记录获取chunk中的块数。从#block单元所在位置读取所有块的entries、mint、maxt、offset、len等元信息。根据每个块的元数据顺序解析块数据。⑤IndexLoki只对tag数据进行索引,用于实现tag→logstream→Chunk的索引映射,以分表的形式存储在存储层。表结构如下:CREATETABLEIFNOTEXISTTable_N(hashtext,rangeblob,valueblob,PRIMARYKEY(hash,range))Table_N,根据时间段取表名;hash,用于不同查询类型的索引;range,范围查询字段;value,日志标签的值。数据类型:Loki存储了不同类型的索引数据,以实现不同的映射场景。对于每一类映射数据,Hash/Range/Value这三个字段的数据构成如下图所示:seriesID为日志流ID,shard为Fragmentation,userID为租户ID,labelName为标签名称,labelValueHash是标签值的hash,chunkID是chunk的ID,chunkThrough是chunk最后一条数据的时间。这些数据元素在映射过程中的作用在Querier链接]((null))的查询过程中有详细介绍。上图中三种颜色标识的索引类型,从上到下依次为:数据类型1:ID,用于根据用户ID搜索查询所有日志流。数据类型2:ID,用于根据用户ID和标签查询日志流。数据类型3:用于根据日志流ID查询底层存储Chunk的ID。除了使用表分片,Loki还使用桶和分片来优化索引查询速度。Bucket:按天划分:bucketID=timestamp/secondsInDay。按小时拆分:bucketID=timestamp/secondsInHour。Sharding:将不同日志流的索引分布到不同的分片上,shard=seriesID%分片数。Chunk状态:Chunk作为Ingester中重要的数据单元,在内存中的生命周期中分为以下四种状态:Writing:正在写入新数据。Waitingflush:停止写入新数据,等待写入存储。Retain:已经写入存储,等待销毁。灭:毁坏。四种状态之间的转换按照写入→等待flush→retain→destroy的顺序进行。状态转换时机:协作触发:有新的数据写入请求。定时触发:写周期触发chunk写入存储,恢复周期触发chunk销毁。Writing变为waitingflush:chunk初始状态为writing,表示正在接受数据写入。如果满足以下条件,则进入等待刷新状态:chunk空间已满(协同触发)。chunk的存活时间(第一个和最后两个数据的时间差)超过阈值(定时触发)。chunk的空闲时间(连续未写入数据持续时间)超过设置(定时触发)。Waitingflushtoetain:Ingester会周期性地将等待flush的chunk写入底层存储,然后这些chunk会处于“retain”状态。这是因为ingester提供最新数据的搜索服务,需要在内存中保留一段。保留状态将数据刷新时间和内存中的保留时间解耦,从而根据不同的选项优化内存配置。destroy、被回收和等待GC销毁:一般来说,Loki对于日志的使用场景采用顺序追加的方式,只对元信息进行索引,大大简化了它的数据结构和处理逻辑,这也让Ingester能够处理高-speedwrites提供了基础。Querier:查询服务的执行组件,负责从底层存储中拉取数据,并根据LogQL语言描述的过滤条件进行过滤。可以直接通过API提供查询服务,也可以与queryFrontend结合使用,实现分布式并发查询。⑥查询类型查询类型有如下几种:范围日志查询单条日志查询统计查询元信息查询在这些查询类型中,范围日志查询是应用最广泛的,所以下面只详细介绍范围日志查询。并发查询:对于单个查询请求,虽然可以直接调用Querier的API进行查询,但是由于查询量大,容易造成OOM。针对这个问题,结合queryer和queryFrontend实现查询分解和多查询器并发执行。每个queryer与所有queryFrontends建立一个grpc双向流式连接,实时从queryFrontend中获取分割后的子查询,执行后将结果返回给queryFrontend。如何在查询器之间拆分查询和调度子查询将在queryFrontend链接中介绍。⑧查询过程首先解析logQL命令,然后查询日志流ID列表。Loki根据不同的标签选择器语法使用不同的索引查询逻辑,大致可以分为两种:=,或者多值正则匹配=~,工作过程如下:查询语义类似的标签选择器下面描述的SQL中引用的每个标签键值对对应的日志流ID(seriesID)的集合。选择*FROMTable_NWHEREhash=?AND范围>=?ANDvalue=labelValuehash为租户ID(userID)、bucketID、标签名(labelName)组合计算出的hash值;range是为标签值(labelValue)计算的哈希值。根据tagkey值,查询到的多个seriesID集合的并集或交集,将得到最终的集合。例如,标签选择器{file="app.log",level=~"debug|error"}的工作方式如下:Queryfile="app.log",level="debug",level="error"三A每个标签键值S1、S2、S3对应的seriesID集合。从三个集合中计算出最终的seriesID集合S=S1∩cap(S2∪S3)。!=,=~,!~,工作过程如下:使用下面SQL描述的语义查询标签选择器中引用的每个标签对应的seriesID集合。SELECT*FROMTable_NWHEREhash=?hash是租户ID(userID)、桶ID(bucketID)、标签名(labelName)。每个seriesID集合都根据标签选择语法进行过滤。对过滤后的集合进行并集、交集等操作,得到最终的集合。例如{file~="mysql*",level!="error"}的工作过程如下:查询标签“file”和标签“level”对应的seriesID集合,S1和S2。发现S1中file的值匹配mysql*的子集SS1,S2中level的值匹配!="error"的子集SS2。计算最终的seriesID集合S=SS1∩SS2。使用以下SQL描述的语义查询所有日志流中包含的chunk的ID:SELECT*FROMTable_NWherehash=?hash是为bucket(bucketID)和logstream(seriesID)计算的hash值。根据chunkID列表生成遍历器,顺序读取日志行:作为数据读取组件,遍历器的主要功能是从存储系统中拉取chunk,并从中读取日志行。它采用多层树结构,从上到下逐层递归触发弹出数据。具体结构如上图所示:batchIterator:从存储中批量下载chunk原始数据,生成迭代器树。streamIterator:多流数据的遍历器,使用堆排序保证数据在多流间的保序;chunksIterator:多chunk数据的遍历器,同样使用堆排序来保证多副本之间的保序和去重。blocksIterator:多块数据的遍历器。块字节迭代器:块中日志行的遍历器。从Ingester查询内存中还没有写入storage的数据:由于Ingester会定时将缓存的数据写入storage,所以当Querier查询较新时间范围的数据时,也会通过grpc协议从每个ingesterIn-memory数据被查询。ingester中需要查询的时间范围是可配置的,取决于ingester缓存数据的时间。以上就是日志内容查询的主要流程。索引查询的过程和它类似,只是增加了索引计算的遍历层,从查询到的日志中计算索引数据。另外两个比较简单,这里就不细说了。QueryFrontend:Loki采用后计算的方式进行查询,类似于对大量原始数据做grep,因此查询必然会消耗更多的计算和内存资源。如果在单个节点上执行查询请求,很容易因查询量大而造成OOM、速度慢等性能瓶颈。为了解决这个问题,Loki采用了将单个查询分解并在多个查询器上并发执行的方法,其中查询请求的分解和调度由queryFrontend完成。queryFrontend是Loki整体架构中querier的前端。它作为数据读取操作的入口服务。其主要组成部分和工作流程如上图所示:SplitRequest:将单个查询拆分为子查询列表subReq。Feeder:将子查询顺序注入到缓存队列BufQueue中。Runner:多个并发runner将BufQueue中的查询注入到子查询队列中,等待查询结果返回。Querier通过grpc协议实时从子查询队列中弹出子查询,执行后将结果返回给对应的Runner。Runner执行完所有子请求后,将汇总结果返回给API响应。⑨查询切分queryFrontend将查询请求按照固定的时间跨度划分为多个子查询。比如一个查询的时间范围是6小时,分割跨度是15分钟,那么这个查询就会被分成6*60/15=24个子查询。⑩QuerySchedulingFeeder:Feeder负责将划分后的子查询一一写入缓冲队列BufQueue,以生产者/消费者模式与下游Runner实现可控的子查询并发。Runner:竞争从BufQueue中读取子查询写入下游请求队列,处理Querier返回的结果。Runners的并发数由全局配置控制,避免一次分解过多的子查询给Querier造成巨大的浪费流量,影响其稳定性。子查询队列:队列是一个二维结构。第一维存储不同租户的队列,第二维存储同一租户的子查询列表。它们都是按照先进先出的顺序组织元素的进出。分配请求:queryFrontend以被动方式分配查询请求。后端Querier和queryFrontend通过grpc实时监控子查询队列。当有新的请求时,下一个请求会按照以下顺序在队列中弹出:循环遍历队列中的租户列表,找到下一个有数据的租户队列。弹出此租户队列中最早的请求。总结Loki是一个快速发展的项目。最新版本已经到了2.0。Loki相比1.6增强了日志解析、Ruler、Boltdb-shipper等新功能。但基本模块、架构、数据模型、工作原理都已经稳定下来。状态。希望本文的这些初步分析能为您提供一些帮助。文中如有错误,欢迎批评指正。作者:张海军编辑:陶佳龙来源:转载自公众号京东云开发者(ID:JDC_Developers)