還在用ELK? 是時候了解一下輕量化日志服務Loki了


一、背景

在日常的系統可視化監控過程中,當監控探知到指標異常時,我們往往需要對問題的根因做出定位。但監控數據所暴露的信息是提前預設、高度提煉的,在信息量上存在着很大的不足,它需要結合能夠承載豐富信息的日志系統一起使用。

當監控系統探知到異常告警,我們通常在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數據的解析順序:

  1. 根據尾部的#blocks section byte offset單元得到#block單元的位置;
  2. 根據#block單元記錄得出chunk里block數量;
  3. 從#block單元所在位置開始讀取所有block的entries、mint、maxt、offset、len等元信息;
  4. 順序的根據每個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根據不同的標簽選擇器語法使用了不同的索引查詢邏輯,大體分為兩種:

  • =,或多值的正則匹配=~ , 工作過程如下:
  1. 以類似下SQL所描述的語義查詢出 標簽選擇器 里引用的每個 標簽鍵值對 所對應的 日志流ID(seriesID) 的集合。
SELECT * FROM Table_N WHERE hash=? AND range>=?    AND value=labelValue

◆ hash為租戶ID(userID)、分桶(bucketID)、標簽名(labelName)組合計算的哈希值;◆ range為標簽值(labelValue)計算的哈希值。

  1. 將根據 標簽鍵值對 所查詢的多個seriesID集合取並集或交集求最終集合。

比如,標簽選擇器{file="app.log", level=~"debug|error"}的工作過程如下:

  1. 查詢出file="app.log",level="debug", level="error" 三個標簽鍵值所對應的seriesID集合,S1 、S2、S3;2. 根據三個集合計算最終seriesID集合S = S1∩cap (S2∪S3)。
  • !=,=,!,工作過程如下:
  1. 以如下SQL所描述的語義查詢出 標簽選擇器 里引用的每個 標簽 所對應seriesID集合。
SELECT * FROM Table_N WHERE hash = ?

◆ hash為租戶ID(userID)、分桶(bucketID)、標簽名(labelName)。

  1. 根據標簽選擇語法對每個seriesID集合進行過濾。

  2. 將過濾后的集合進行並集、交集等操作求最終集合。

比如,{file~="mysql*", level!="error"}的工作過程如下:

  1. 查詢出標簽“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的前端,它作為數據讀取操作的入口服務,其主要的組件及工作流程如下圖所示:

  1. 分割Request:將單個查詢分割成子查詢subReq的列表;
  2. Feeder: 將子查詢順序注入到緩存隊列 Buf Queue;
  3. Runner: 多個並發的運行器將Buf Queue中的查詢並注入到子查詢隊列,並等待返回查詢結果;
  4. Querier通過grpc協議實時從子查詢隊列彈出子查詢,執行后將結果返回給相應的Runner;
  5. 所有子請求在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監聽子查詢隊列,當有新請求時以如下順序在隊列中彈出下一個請求:

  1. 以循環的方式遍歷隊列中的租戶列表,尋找下一個有數據的租戶隊列;
  2. 彈出該租戶隊列中的最老的請求。

三、總結

Loki作為一個正在快速發展的項目,最新版本已到2.0,相較1.6增強了諸如日志解析、Ruler、Boltdb-shipper等新功能,不過基本的模塊、架構、數據模型、工作原理上已處於穩定狀態,希望本文的這些嘗試性的剖析能夠能夠為大家提供一些幫助,如文中有理解錯誤之處,歡迎批評指正。

推薦閱讀:

歡迎點擊京東智聯雲,了解開發者社區

更多精彩技術實踐與獨家干貨解析

歡迎關注【京東智聯雲開發者】公眾號


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM