吕亚霖,2019年加入作业帮,作业帮架构研发负责人,在作业帮期间主导了云原生架构演进、推动实施容器化改造、服务治理、GO微服务框架、DevOps的落地实践。
莫仁鹏,2020年加入作业帮,作业帮高级架构师,在作业帮期间,推动了作业帮云原生架构演进,负责作业帮服务治理体系的设计和落地、服务感知体系建设以及自研mesh、MQproxy研发工作。
日志是服务观察的主要方式,我们依赖日志去感知服务的运行状态、历史状况;当发生错误时,我们又依赖日志去了解现场,定位问题。日志对研发工程师来说异常关键,同时随着微服务的流行,服务部署越来越分散化,所以我们需要一套日志服务来采集、传输、检索日志
基于这个情况,诞生了以 ELK 为代表的开源的日志服务。
在我们的场景下,高峰日志写入压力大(每秒千万级日志条数);实时要求高:日志处理从采集到可以被检索的时间正常 1s 以内(高峰时期 3s);成本压力巨大,要求保存半年的日志且可以回溯查询(百 PB 规模)。
ELK 方案里最为核心的就是 ElasticSearch, 它负责存储和索引日志, 对外提供查询能力。Elasticsearch 是一个搜索引擎, 底层依赖了 Lucene 的倒排索引技术来实现检索, 并且通过 **shard **的设计拆分数据分片, 从而突破单机在存储空间和处理性能上的限制
ElasticSearch 写入数据需要对日志索引字段的倒排索引做更新,从而能够检索到最新的日志。为了提升写入性能,可以做聚合提交、延迟索引、减少 refersh 等等,但是始终要建立索引, 在日志流量巨大的情况下(每秒 20GB 数据、千万级日志条数), 瓶颈明显。离理想差距过大,我们期望写入近乎准实时。
ElasticSearch 需要定期维护索引、数据分片以及检索缓存, 这会占用大量的 CPU 和内存,日志数据是存储在机器磁盘上,在需要存储大量日志且保存很长时间时, 机器磁盘使用量巨大,同时索引后会带来数据膨胀,进一步带来成本提升。
ELK需要解析日志以便为日志项建立索引, 非格式化的日志需要增加额外的处理逻辑来适配。存在很多业务日志并不规范,且有收敛难度。
总结:日志检索场景是一个写多读少的场景, 在这样的场景下去维护一个庞大且复杂的索引, 在我们看来其实是一个性价比很低的事情。如果采用 ElasticSearch 方案,经测算我们需要几万核规模集群,仍然保证不了写入数据和检索效率,且资源浪费严重。
面对这种情况, 我们不妨从一个不同的角度去看待日志检索的场景, 用一个更适合的设计来解决日志检索的需求, 新的设计具体有以下三个点:
同样的我们需要对日志进行采集,但在处理日志时我们不对日志原文进行解析和索引,而是通过日志时间、日志所属实例、日志类型、日志级别等日志元数据对日志进行分块。这样检索系统可以不对日志格式做任何要求,并且因为没有解析和建立索引(这块开销很大)的步骤, 写入速度也能够达到极致(只取决于磁盘的 IO 速度)。
简单来说, 我们可以将一个实例产生的同一类日志按时间顺序写入到一个文件中, 并按时间维度对文件拆分. 不同的日志块会分散在多台机器上(我们一般会按照实例和类型等维度对日志块的存储机器进行分片), 这样我们就可以在多台机器上对这些日志块并发地进行处理, 这种方式是支持横向扩展的. 如果一台机器的处理性能不够, 横向再扩展就行。
那如何对入日志块内的数据进行检索呢?这个很简单, 因为保存的是日志原文,可以直接使用 grep 相关的命令直接对日志块进行检索处理。对开发人员来说, grep 是最为熟悉的命令, 并且使用上也很灵活, 可以满足开发对日志检索的各种需求。因为我们是直接对日志块做追加写入,不需要等待索引建立生效,在日志刷入到日志块上时就可以被立刻检索到, 保证了检索结果的实时性。
接下来我们看看要如何对这么一大批的日志块进行检索。
首先我们当日志块建立时, 我们会基于日志块的元数据信息搭建索引, 像服务名称、日志时间, 日志所属实例, 日志类型等信息, 并将日志块的存储位置做为 value 一起存储。通过索引日志块的元数据,当我们需要对某个服务在某段时间内的某类日志发起检索时,就可以快速地找到需要检索的日志块位置,并发处理。
索引的结构可以按需构建, 你可以将你关心的元数据信息放入到索引中, 从而方便快速圈定需要的日志块。因为我们只对日志块的元数据做了索引, 相比于对全部日志建立索引, 这个成本可以说降到了极低, 锁定日志块的速度也足够理想。
日志数据以时间维度的方向可以理解为一种时序数据, 离当前时间越近的日志会越有价值, 被查询的可能性也会越高, 呈现一种冷热分离的情况。而且冷数据也并非是毫无价值,开发人员要求回溯几个月前的日志数据也是存在的场景, 即我们的日志需要在其生命周期里都能够对外提供查询能力。
对于这种情况,如果将生命周期内的所有日志块都保存在本地磁盘上, 无疑是对我们的机器容量提了很大的需求。对于这种日志存储上的需求,我们可以采用压缩和沉降的手段来解决。
简单来说,我们将日志块存储分为本地存储(磁盘)、远程存储(对象存储)、归档存储三个级别; 本地存储负责提供实时和短期的日志查询(一天或几个小时), 远程存储负责一定时期内的日志查询需求(一周或者几周), 归档存储负责日志整个生命周期里的查询需求。
现在我们看看日志块在其生命周期里是如何在多级存储间流转的, 首先日志块会在本地磁盘创建并写入对应的日志数据, 完成后会在本地磁盘保留一定时间(保留的时间取决于磁盘存储压力), 在保存一定时间后, 它首先会被压缩然后被上传至远程存储(一般是对象存储中的标准存储类型), 再经过一段时间后日志块会被迁移到归档存储中保存(一般是对象存储中的归档存储类型).
这样的存储设计有什么好处呢? 如下面的多级存储示意图所示, 越往下存储的数据量越大, 存储介质的成本也越低, 每层大概为上一层的 1/3 左右, 并且数据是在压缩后存储的, 日志的数据压缩率一般可以达到10:1, 由此看归档存储日志的成本能在本地存储的**1%**的左右, 如果使用了 SSD 硬盘作为本地存储, 这个差距还会更大。
价格参考:
存储介质 | 参考链接 |
---|---|
本地盘 | https://buy.cloud.tencent.com/price/cvm?regionId=8&zoneId=800002 |
对象存储 | https://buy.cloud.tencent.com/price/cos |
归档存储 | https://buy.cloud.tencent.com/price/cos |
那在多级存储间又是如何检索的呢? 这个很简单, 对于本地存储上的检索, 直接在本地磁盘上进行即可。
如果检索涉及到远程存储上的日志块, 检索服务会将涉及到的日志块下载到本地存储, 然后在本地完成解压和检索。因为日志分块的设计,日志块的下载同检索一样,我们可以在多台机器上并行操作; 下载回本地的数据复制支持在本地缓存后一定的时间后再删除, 这样有效期内对同一日志块的检索需求就可以在本地完成而不需要再重复拉取一遍(日志检索场景里多次检索同样的日志数据还是很常见).
对于归档存储, 在发起检索请求前, 需要对归档存储中的日志块发起取回操作, 取回操作一般耗时在几分钟左右, 完成取回操作后日志块被取回到远程存储上,再之后的数据流转就跟之前一致了。即开发人员如果想要检索冷数据, 需要提前对日志块做归档取回的申请,等待取回完成后就可以按照热数据速度来进行日志检索了。
在了解上面的设计思路后, 我们看看基于这套设计的日志检索服务是怎么落地的.
日志检索服务分为以下几个模块:
查询调度器, 负责接受查询请求, 对查询命令做解析和优化, 并从 Chunk Index 中获取查询范围内日志块的地址, 最终生成分布式的查询计划
GD-Search 本身是无状态的, 可以部署多个实例,通过负载均衡对外提供统一的接入地址。
本地存储查询器, 负责处理 GD-Search 分配过来的本地日志块的查询请求。
远程存储查询器, 负责处理 GD-Search 分配过来的远程日志块的查询请求。
Remote-Search 会将需要的日志块从远程存储拉取到本地并解压, 之后同 Local-Search 一样在本地存储上进行查询。同时 Remote-Search 会将日志块的本地存储地址更新到 Chunk Index 中,以便将后续同样日志块的查询请求路由到本地存储上。
本地存储管理器,负责维护本地存储上日志块的生命周期。
Log-Manager 会定期扫描本地存储上的日志块, 如果日志块超过本地保存期限或者磁盘使用率到达瓶颈,则会按照策略将部分日志块淘汰(压缩后上传到远程存储, 压缩算法采用了 ZSTD), 并更新日志块在 Chunk Index 中的存储信息。
日志摄取器模块, 负责从日志 kafka 订阅日志数据, 然后将日志数据按时间维度和元数据维度拆分, 写入到对应的日志块中。在生成新的日志块同时, Log-Ingester 会将日志块的元数据写入 Chunk Index 中, 从而保证最新的日志块能够被实时检索到。
日志块元数据存储, 负责保存日志块的元数据和存储信息。当前我们选择了 Redis 作为存储介质, 在元数据索引并不复杂的情况下, redis 已经能够满足我们索引日志块的需求, 并且基于内存的查询速度也能够满足我们快速锁定日志块的需求。
在检索策略设计上, 我们认为检索的返回速度是追求更快, 同时避免巨大的查询请求进入系统。
我们认为日志检索一般有以下三种场景:
查看最新的服务日志。
查看某个请求的日志, 依据 logid 来查询。
查看某类日志, 像访问 mysql 的错误日志, 请求下游服务的日志等等。
在大部分场景下, 用户是不需要所有匹配到的日志, 拿一部分日志足以处理问题。所以在查询时使用者可以设置 limit 数量, 整个检索服务在查询结果满足 limit设置的日志数量时, 终止当前的查询请求并将结果返回给前端。
另外 GD-Search 组件在发起日志块检索时, 也会提前判断检索的日志块大小总和, 对于超限的大范围检索请求会做拒绝。(用户可以调整检索的时间范围多试几次或者调整检索语句使其更有选择性)
使用 1KB 每条的日志进行测试, 总的日志块数量在10000左右, 本地存储使用 NVME SSD 硬盘, 远程存储使用 S3 协议标准存储.
• 写入
单核可支持 2W条/S的写入速度, 1W 条/S的写入速度约占用 1~2G 左右的内存,可分布式扩展,无上限
• 查询(全文检索)
基于本地存储的 1TB 日志数据查询速度可在 3S 以内完成
基于远程存储的 1TB 日志数据查询耗时在 10S 间。
在每秒千万级写入,百 PB 存储上,我们使用十几台物理服务器就可以保证日志写入和查询。热点数据在本地 nvme 磁盘上,次热数据在对象存里,大量日志数据存储在归档存储服务上。
因为不需要建立索引,我们只需要千核级别就可以保证写入,同时日志索引是个写多读少的服务,千核可以保证百级别 QPS 查询。
ES 在这个量级上需要投入几万核规模。来应对写入性能和查询瓶颈,但是仍不能保证写入和查询效率。
核心是在保证业务需求下,使用更便宜的存储介质(归档存储 VS 本地磁盘)和更少的存储数据(压缩率 1/10vs 日志数据索引膨胀)。能有两个量级的差距。