淺析 Grafana Loki 日志聚合系統(tǒng)
Loki 是一個(gè)水平可擴(kuò)展、高可用、多租戶的日志存儲(chǔ)與查詢系統(tǒng)。受 Prometheus 啟發(fā),Loki 不對(duì)日志內(nèi)容進(jìn)行索引,而是采用標(biāo)簽(Label)作為索引,從而降低存儲(chǔ)成本。
Like Prometheus, but for logs.
架構(gòu)設(shè)計(jì)
Loki 采用讀寫分離架構(gòu),由多個(gè)微服務(wù)構(gòu)建而成,被設(shè)計(jì)成一個(gè)水平可擴(kuò)展的分布式系統(tǒng)。
圖片
其中寫組件有:
1、Distributor 分發(fā)器:分發(fā)器通過 HTTP 接收到日志后,會(huì)進(jìn)行驗(yàn)證(驗(yàn)證日志時(shí)間或日志行大小等是否滿足規(guī)則)、預(yù)處理(對(duì) labels 按照 key 以字典順序排序,方便后續(xù)進(jìn)行一致性 hash 算法來將日志發(fā)往 Ingester 接收器),同時(shí)還負(fù)責(zé)限速功能,流量過大時(shí)可以拒絕額外的請(qǐng)求。
2、Ingester 接收器:接收器是一個(gè)有狀態(tài)的組件,在日志流進(jìn)入時(shí)對(duì)其進(jìn)行 gzip/snappy
壓縮操作,并負(fù)責(zé)構(gòu)建和刷新日志 chunk ,當(dāng)內(nèi)存中的 chunk 達(dá)到一定的數(shù)量或者時(shí)間后,就會(huì)刷新 chunk 和對(duì)應(yīng)的 index 索引存儲(chǔ)到本地文件系統(tǒng)或?qū)ο蟠鎯?chǔ)中。接收器默認(rèn)會(huì)啟用 WAL 功能,防止數(shù)據(jù)丟失。
讀組件有:
1、Query Frontend 查詢前端:查詢前端是一個(gè)可選的組件,具有查詢拆分、緩存的作用。一個(gè)查詢可以拆解成多個(gè)小查詢,并行在多個(gè) Querier 組件上進(jìn)行查詢,最終合并返回給前端展示。查詢前端內(nèi)部有一個(gè)內(nèi)存隊(duì)列,還可以將其移出作為一個(gè) Query Scheduler 查詢調(diào)度器的單獨(dú)進(jìn)程運(yùn)行。
2、Querier 查詢器:接收一個(gè)時(shí)間范圍和標(biāo)簽選擇器,Querier 查詢器根據(jù) index 索引來確定哪些日志 chunk 匹配,然后將結(jié)果顯示出來。在查詢數(shù)據(jù)時(shí),優(yōu)先查詢所有 Ingester 接收器中的內(nèi)存數(shù)據(jù),沒查到再去查存儲(chǔ)。如果開啟了數(shù)據(jù)副本,數(shù)據(jù)可能重復(fù),因此 Querier 還有數(shù)據(jù)去重的功能。
另外還有一些其它組件:
1、Ruler 規(guī)則器:負(fù)責(zé)日志告警功能,可以持續(xù)查詢一個(gè) rule 規(guī)則,并將超過閾值的事件推送給 AlertManager 或者其它告警 Webhook 服務(wù)。
2、Compactor 壓縮器:負(fù)責(zé)定時(shí)對(duì)索引進(jìn)行壓縮合并,同時(shí)負(fù)責(zé)日志的刪除功能。
3、Memcaches 緩存:這部分屬于外部第三方組件,支持的緩存類型有 in-memory、redis 和 memcached ,可以在 Ingester 、Query Frontend 、Querier 和 Ruler 上配置 Results 查詢結(jié)果緩存、Index 索引緩存或 Chunks 塊緩存。
組件之間的交互流程大致如下:
圖片
一致性哈希環(huán)設(shè)計(jì)
Loki 的分布式架構(gòu)源自 https://github.com/cortexproject/cortex 項(xiàng)目,對(duì)于各組件服務(wù)狀態(tài)和數(shù)據(jù)的通信均采用一致性哈希環(huán)設(shè)計(jì),哈希環(huán)的配置支持 consul、etcd、inmemory 或 memberlist :
common:
ring:
kvstore:
# 支持切換為 consul、etcd、inmemory
store: memberlist
在 Loki 中定義了很多哈希環(huán):IngesterRing、RulerRing 和 CompactorRing 等,以分發(fā)器(Distributor)和接收器(Ingester)組件為例,借助 IngesterRing 哈希環(huán),Distributor 就可以確定日志流應(yīng)該發(fā)往哪個(gè) Ingester 實(shí)例,具體流程如下:
1、日志流的唯一性計(jì)算:每個(gè)日志流由租戶 ID 及其所有標(biāo)簽的 key/value 組合唯一確定,并由此計(jì)算出日志流的 hash key
—— 一個(gè)無符號(hào)的 32 位整數(shù)。
2、Ingester 的注冊(cè):每個(gè) Ingester 實(shí)例會(huì)在哈希環(huán)(IngesterRing)上注冊(cè)自身,并分配一組 token(每個(gè) token 為隨機(jī)生成的無符號(hào) 32 位整數(shù)),用于確定其在哈希環(huán)中的位置。
圖片
3、日志流的分配:當(dāng) Distributor 需要分發(fā)日志時(shí),它會(huì)在哈希環(huán)上找到第一個(gè)大于日志流 hash key
的 token,并將該 token 所屬的 Ingester 實(shí)例視為目標(biāo)存儲(chǔ)節(jié)點(diǎn)。
a. 副本機(jī)制:若數(shù)據(jù)副本數(shù)(replication_factor) 大于 1(默認(rèn)為 3),則繼續(xù)順時(shí)針查找,找到下一個(gè) token 對(duì)應(yīng)的不同 Ingester 實(shí)例,確保日志的多副本存儲(chǔ),提高容錯(cuò)能力。
b. 狀態(tài)約束:僅當(dāng)目標(biāo) Ingester 處于 JOINING 或 ACTIVE 狀態(tài)時(shí),才能接收日志寫入請(qǐng)求;僅當(dāng) Ingester 處于 ACTIVE 或 LEAVING 狀態(tài)時(shí),才能處理日志讀取請(qǐng)求。
其動(dòng)態(tài)演示效果如下:
圖片
但在這種機(jī)制下,若單個(gè)日志流中的數(shù)據(jù)量過大,就容易導(dǎo)致 Ingester 實(shí)例負(fù)載不均衡,如下:
圖片
此時(shí),可以啟用自動(dòng)分片流功能,通過在現(xiàn)有日志流中自動(dòng)添加 __stream_shard__
標(biāo)簽及其值,以控制日志流速保持在 desired_rate
以下,達(dá)到負(fù)載均衡的效果:
limits_config:
shard_streams:
enabled: true
desired_rate: 1536KB
圖片
存儲(chǔ)設(shè)計(jì)
在 Loki 中,標(biāo)簽(label)實(shí)際就是在提取日志時(shí)分配給日志的一組任意 key/value,既是 Loki 對(duì)傳入數(shù)據(jù)進(jìn)行分塊的鍵,也是查詢時(shí)用于查找日志的索引。
每個(gè)標(biāo)簽的 key 和 value 的組合會(huì)唯一定義成一個(gè)日志流(stream),哪怕僅有一個(gè)標(biāo)簽值發(fā)生了變化,都會(huì)重新創(chuàng)建一個(gè)新的日志流。
不同的日志流會(huì)在 Ingester 實(shí)例的內(nèi)存中構(gòu)建出不同的日志 chunk ,滿足規(guī)則(達(dá)到 chunk_target_size
、max_chunk_age
或 chunk_idle_period
上限)就會(huì)刷新到對(duì)象存儲(chǔ)或本地文件系統(tǒng)中:
圖片
因此,Loki 需要存儲(chǔ)兩種不同類型的數(shù)據(jù):塊(chunk)和索引(index)。
- chunk:即日志本身,一個(gè) chunk 包含很多 block ,進(jìn)行壓縮后存儲(chǔ)
- index:即日志索引,key/value 結(jié)構(gòu),key 是日志 label 的哈希,value 則包含日志存在哪個(gè) chunk 上、chunk 大小、日志的時(shí)間范圍等信息
其中 chunk 的存儲(chǔ)可以直接上傳到配置的存儲(chǔ)系統(tǒng)中(例如本地文件系統(tǒng)或 S3),而 index 的存儲(chǔ)處理稍微麻煩些。
在 2.0 之前,chunk 和 index 的存儲(chǔ)是分開的,意味著需要配置兩個(gè)存儲(chǔ)系統(tǒng)。而 2.0 開始,推行一種叫單一存儲(chǔ)架構(gòu)的設(shè)計(jì),實(shí)現(xiàn)了 “boltdb-shipper” 索引存儲(chǔ),這種機(jī)制下只需要一個(gè)共享存儲(chǔ),例如 S3,就可以同時(shí)用于 chunk 和 index 的存儲(chǔ)。到了 Loki 2.8 ,再次推出了更高效的 “tsdb-shipper” 索引存儲(chǔ),這也是目前 3.x 版本所推薦的索引存儲(chǔ)方式。
這兩種索引存儲(chǔ)的原理大致上是相似的,工作可以分為 uploadsManager 和 downloadsManager 兩部分:
- uploadsManager:負(fù)責(zé)上傳
active_index_directory
內(nèi)的索引分片到配置的共享存儲(chǔ)中,同時(shí)負(fù)責(zé)定期清理工作 - downloadsManager:負(fù)責(zé)從共享存儲(chǔ)下載索引到本地緩存目錄
cache_location
,同時(shí)負(fù)責(zé)定期同步和清理工作
簡(jiǎn)單來理解就是,一個(gè)是把本地 boltdb 文件當(dāng)作索引存儲(chǔ),另一個(gè)把本地 tsdb 文件當(dāng)作索引存儲(chǔ),但這兩種索引存儲(chǔ)都有 “shipper” 的能力,可以把自身上傳到配置的共享存儲(chǔ)中,并保持同步。如此一來,我們就可以利用 S3 同時(shí)存儲(chǔ) chunk 和 index 了。
隨著版本的迭代,不可避免會(huì)出現(xiàn)很多不同的存儲(chǔ)模式,好在 Loki 允許通過日期起點(diǎn)來定義不同時(shí)間段使用不同的存儲(chǔ)模式:
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
需要注意的是:
- 升級(jí)架構(gòu),始終將新模式中的 from 日期設(shè)置為未來的日期,要注意是從 UTC 00:00:00 開始。
- 全新部署,from 日期需要設(shè)置為以前的日期,才可以接收處理日志。
- 架構(gòu)變更是無法撤銷或回滾的,使用什么架構(gòu)寫入的數(shù)據(jù)只能由該架構(gòu)讀取。
查詢?cè)O(shè)計(jì)
Loki 第一次查詢時(shí),Querier 查詢器會(huì)從共享存儲(chǔ)中下載查詢時(shí)間范圍內(nèi)的索引并解壓到本地緩存目錄 cache_location
,并按 resync_interval
周期同步,該索引緩存有效期受 cache_ttl
配置控制。這部分工作由之前介紹的索引存儲(chǔ)的 downloadsManager 完成,這也是為什么 Loki 的第一次查詢會(huì)比較慢。
拋開拉取索引耗時(shí)這部分因素,在 Loki 中,查詢從快到慢分別為:
圖片
- Label matchers 標(biāo)簽匹配器(最快):直接基于索引匹配到塊,查找出滿足 limit 的日志條數(shù)
- Line filters 行過濾器(中等):把滿足標(biāo)簽匹配器匹配到的塊,再進(jìn)行過濾,直到查找出滿足 limit 的日志條數(shù)
- Label filters 標(biāo)簽過濾器(最慢):把滿足標(biāo)簽匹配器匹配到的塊,進(jìn)行二次標(biāo)簽,然后再進(jìn)行過濾,直到查找出滿足 limit 的日志條數(shù)
以行過濾器的查詢流程為例:
1、時(shí)間范圍拆分:Query Frontend 首先根據(jù) split_queries_by_interval
將查詢拆分為多個(gè)較小的時(shí)間段。例如,一個(gè)跨度為 4 小時(shí)的查詢可能被拆分為 4 個(gè)獨(dú)立的 1 小時(shí)子查詢,這種拆分可以并行處理不同時(shí)間段的數(shù)據(jù)。
圖片
2、動(dòng)態(tài)分片:Query Frontend 繼續(xù)將每個(gè)時(shí)間段的子查詢進(jìn)行進(jìn)一步的動(dòng)態(tài)分片。分片數(shù)量取決于數(shù)據(jù)量,數(shù)據(jù)量大的子查詢可能拆分為更多分片,而數(shù)據(jù)量小的可能僅少量分片。分片的目的是將 chunk 按日志流的標(biāo)簽進(jìn)一步細(xì)分,從而提升并行處理效率。
圖片
3、任務(wù)隊(duì)列與并行處理:Query Frontend 將拆分后的分片任務(wù)提交至 Query Scheduler 任務(wù)隊(duì)列中,根據(jù)公平調(diào)度策略將任務(wù)分配給空閑的 Querier 工作節(jié)點(diǎn)并行處理。Querier 會(huì)從 Ingester 中的內(nèi)存數(shù)據(jù)或?qū)ο蟠鎯?chǔ)中拉取對(duì)應(yīng)的數(shù)據(jù)塊,解析并過濾日志內(nèi)容,最終返回匹配的結(jié)果。
圖片
4、結(jié)果合并:所有子查詢和分片的結(jié)果會(huì)被匯總到 Query Frontend 組件,進(jìn)行排序、去重和合并,最終返回完整的查詢結(jié)果。
完整的查詢流程如下:
圖片
所以 Loki 的設(shè)計(jì)就是推薦使用并行化 (parallelization) 來實(shí)現(xiàn)最佳性能,將查詢分解成小塊,并將其并行調(diào)度,這樣就可以在小時(shí)間內(nèi)查詢大量的日志數(shù)據(jù)。
本文中的部分動(dòng)圖和圖片引用自以下官方博客,推薦閱讀:
- 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/