一、背景
在日常的系統可視化監控過程中,當監控探知到指標異常時,我們往往需要對問題的根因做出定位。但監控數據所暴露的信息是提前預設、高度提煉的,在信息量上存在着很大的不足,它需要結合能夠承載豐富信息的日志系統一起使用。
當監控系統探知到異常告警,我們通常在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等新功能,不過基本的模塊、架構、數據模型、工作原理上已處於穩定狀態,希望本文的這些嘗試性的剖析能夠能夠為大家提供一些幫助,如文中有理解錯誤之處,歡迎批評指正。
推薦閱讀:
歡迎點擊【京東智聯雲】,了解開發者社區
更多精彩技術實踐與獨家干貨解析
歡迎關注【京東智聯雲開發者】公眾號