1. 程式人生 > 實用技巧 >還在用ELK? 是時候瞭解一下輕量化日誌服務Loki了

還在用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等新功能,不過基本的模組、架構、資料模型、工作原理上已處於穩定狀態,希望本文的這些嘗試性的剖析能夠能夠為大家提供一些幫助,如文中有理解錯誤之處,歡迎批評指正。

推薦閱讀:

歡迎點選京東智聯雲,瞭解開發者社群

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

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