關於收集,標準化和集中化處理 Golang 日誌的一些建議

originator發表於2020-04-09

依賴分散式系統的公司組織和團隊經常使用 Go 語言編寫其應用程式,以利用 Go 語言諸如通道和 goroutine 之類的併發功能。如果你負責研發或運維 Go 應用程式,則考慮周全的日誌記錄策略可以幫助你瞭解使用者行為,定位錯誤並監控應用程式的效能。

這篇文章將展開聊一些用於管理 Go 日誌的工具和技術。我們將首先考慮要使用哪種日誌記錄包來滿足各種記錄要求。然後會介紹一些使日誌更易於搜尋和可靠,減少日誌資源佔用以及使日誌訊息標準化的技術。

日誌包的選擇

Go 標準庫的日誌庫非常簡單,僅僅提供了 print,panic 和 fatal 三個函式對於更精細的日誌級別、日誌檔案分割以及日誌分發等方面並沒有提供支援. 所以催生了很多第三方的日誌庫,流行的日誌框架包括 logrus、zap、glog 等。我們先來大致看下這些日誌庫的特點再來根據實際應用情況選擇合適的日誌庫。

log 標準庫

Go 的內建日誌記錄庫(log)帶有一個預設記錄器(logger),該記錄器可寫入標準錯誤並自動向記錄中新增時間戳,而無需進行配置。你可以使用它日誌用於本地開發,和試驗性的程式碼段。這時從程式碼中獲得快速反饋可能比生成豐富結構化的日誌更為重要。

logrus

logrus 是一個為結構化日誌記錄而設計的日誌記錄包,非常適合以 JSON 格式記錄日誌。 JSON 格式使機器可以輕鬆解析 Go 日誌。而且,由於 JSON 是定義明確的標準,因此通過包含新欄位可以輕鬆地新增上下文,解析器能夠自動提取它們。

使用 logrus,可以使用功能 WithFields 定義要新增到 JSON 日誌中的標準欄位,如下所示。然後,可以在不同日誌級別呼叫記錄器,例如 Info(),Warn() 和 Error()。 logrus 庫將自動以 JSON 格式寫入日誌,並插入標準欄位以及您即時定義的所有欄位。

package main
import (
  log "github.com/sirupsen/logrus"
)

func main() {
   log.SetFormatter(&log.JSONFormatter{})

   standardFields := log.Fields{
     "hostname": "staging-1",
     "appname":  "foo-app",
     "session":  "1ce3f6v",
   }
   requestLogger := log.withFields(standardFields)
   requestLogger.WithFields(log.Fields{"string": "foo", "int": 1, "float": 1.1}).Info("My first ssl event from Golang")

}

生成的日誌將在 JSON 物件中包括訊息,日誌級別,時間戳、標準欄位以及呼叫記錄器即時寫入的欄位:

{"appname":"foo-app","float":1.1,"hostname":"staging-1","int":1,"level":"info","msg":"My first ssl event from Golang","session":"1ce3f6v","string":"foo","time":"2019-03-06T13:37:12-05:00"}

glog

glog 允許啟用或禁用特定級別的日誌記錄,這對於在開發和生產環境之間切換時保持檢查日誌量很有用。它使您可以在命令列中使用標誌(例如,-v 表示詳細資訊)來設定執行程式碼時的日誌記錄級別。然後,可以在 if 語句中使用 V() 函式僅在特定日誌級別上寫入 Go 日誌。功能 Info(),Warning(),Error() 和 Fatal() 分別指定日誌級別 0 到 3

if err != nil && glog.V(2){
    glog.Error(err)
  }

日誌庫的選擇

上面分析了,標準庫的 log 只適合非專案級別的程式碼片段的快速驗證和除錯。logrus 在結構化日誌上做的最好,有利於日誌分析。glog 可以減少日誌佔用的磁碟空間。不過相比產生的日誌佔用空間大的問題,利於分析的日誌給應用產品帶來的價值更大,所以 logrus 使用的更多一些。很多開源專案,如 Docker,Prometheus 等都是用了 logrus 來記錄他們的日誌。

logrus 的使用介紹

logrus 是目前 Github 上 star 數量最多的日誌庫,目前 (2020.03) star 數量為 14000+,fork 數為 1600+。logrus 功能強大,效能高效,而且具有高度靈活性,提供了自定義外掛的功能。很多開源專案,如 Docker,Prometheus 等都是用了 logrus 來記錄他們的日誌。

  • logrus 完全相容 Go 標準庫日誌模組,擁有六種日誌級別:debug、info、warn、error、fatal 和 panic,這是 Go 標準庫日誌模組的 API 的超集.如果你的專案使用標準庫日誌模組,完全可以以最低的代價遷移到 logrus 上.
  • 可擴充套件的 Hook 機制:允許使用者通過 hook 的方式將日誌分發到任意地方,如本地檔案系統、標準輸出、logstash、elasticsearch 或者 mq 等。
  • logrus 內建了兩種日誌格式,JSONFormatter 和 TextFormatter 還可以自己動手實現介面 Formatter,來定義自己的日誌格式。
  • Field 機制:logrus 鼓勵通過 Field 機制進行精細化的、結構化的日誌記錄,而不是通過冗長的訊息來記錄日誌。
  • Entry: logrus.WithFields 會自動返回一個 *Entry,Entry 會自動向日誌記錄裡新增記錄建立的時間 time 欄位。

基本用法

logrus 與 Go 標準庫日誌模組完全相容, logrus 可以通過簡單的配置,來定義輸出、格式或者日誌級別等。

package main

import (
    "os"
    log "github.com/sirupsen/logrus"
)

func init() {
    // 設定日誌格式為json格式
    log.SetFormatter(&log.JSONFormatter{})

    // 設定將日誌輸出到指定檔案(預設的輸出為stderr,標準錯誤)
    // 日誌訊息輸出可以是任意的io.writer型別
    logFile := ...
    file, _ := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
    log.SetOutput(file)

    // 設定只記錄日誌級別為warn及其以上的日誌
    log.SetLevel(log.WarnLevel)
}

func main() {
    log.WithFields(log.Fields{
        "animal": "walrus",
        "size":   10,
    }).Info("A group of walrus emerges from the ocean")

    log.WithFields(log.Fields{
        "omg":    true,
        "number": 122,
    }).Warn("The group's number increased tremendously!")

    log.WithFields(log.Fields{
        "omg":    true,
        "number": 100,
    }).Fatal("The ice breaks!")
}

自定義 Logger

如果想在一個應用裡面向多個地方寫 log,可以建立多個記錄器 Logger 例項。

package main

import (
    "github.com/sirupsen/logrus"
    "os"
)

// logrus提供了New()函式來建立一個logrus的例項.
// 專案中,可以建立任意數量的logrus例項.
var log = logrus.New()

func main() {
    // 為當前logrus例項設定訊息的輸出,同樣地,
    // 可以設定logrus例項的輸出到任意io.writer
    log.Out = os.Stdout

    // 為當前logrus例項設定訊息輸出格式為json格式.
    // 同樣地,也可以單獨為某個logrus例項設定日誌級別和hook,這裡不詳細敘述.
    log.Formatter = &logrus.JSONFormatter{}

    log.WithFields(logrus.Fields{
        "animal": "walrus",
        "size":   10,
    }).Info("A group of walrus emerges from the ocean")
}

Fields

logrus 不推薦使用冗長的訊息來記錄執行資訊,它推薦使用 Fields 來進行精細化的、結構化的資訊記錄. 例如下面的記錄日誌的方式:

log.Fatalf("Failed to send event %s to topic %s with key %d", event, topic, key)

在 logrus 中不太提倡,logrus 鼓勵使用以下方式替代之:

log.WithFields(log.Fields{
  "event": event,
  "topic": topic,
  "key": key,
}).Fatal("Failed to send event")

WithFields 可以規範使用者按照其提倡的方式記錄日誌。但是 WithFields 依然是可選的,因為某些場景下,確實只需要記錄一條簡單的訊息。

Default Fields

通常,在一個應用中、或者應用的一部分中,始終附帶一些固定的記錄欄位會很有幫助。比如在處理使用者 HTTP 請求時,上下文中所有的日誌都會有 request_id 和 user_ip。為了避免每次記錄日誌都要使用:

log.WithFields(log.Fields{“request_id”: request_id, “user_ip”: user_ip})

我們可以建立一個 logrus.Entry 例項,為這個例項設定預設 Fields,把 logrus.Entry 例項設定到記錄器 Logger,再記錄日誌時每次都會附帶上這些預設的欄位。

requestLogger := log.WithFields(log.Fields{"request_id": request_id, "user_ip": user_ip})
requestLogger.Info("something happened on that request") # will log request_id and user_ip
requestLogger.Warn("something not great happened")

Hook 介面

logrus 最令人心動的功能就是其可擴充套件的 HOOK 機制。通過在初始化時為 logrus 新增 hook,logrus 可以實現各種擴充套件功能.

logrus 的 hook 介面定義如下,其原理是每次寫入日誌時攔截修改 logrus.Entry.

// logrus在記錄Levels()返回的日誌級別的訊息時會觸發HOOK,
// 按照Fire方法定義的內容修改logrus.Entry.
type Hook interface {
    Levels() []Level
    Fire(*Entry) error
}

一個簡單自定義 hook 如下,DefaultFieldHook 定義會在所有級別的日誌訊息中加入預設欄位 appName=” myAppName”。

type DefaultFieldHook struct {
}

func (hook *DefaultFieldHook) Fire(entry *log.Entry) error {
    entry.Data["appName"] = "MyAppName"
    return nil
}

func (hook *DefaultFieldHook) Levels() []log.Level {
    return log.AllLevels
}

hook 的使用也很簡單,在初始化前呼叫 log.AddHook(hook) 新增相應的 hook 即可。Hook 比較常見的用法是把指定錯誤級別的日誌記錄訊息提醒傳送到郵件組或者錯誤監控系統(比如 sentry),起到主動錯誤通知的作用。

logrus 官方僅僅內建了 syslog 的 hook。但 Github 有很多第三方的 hook 可供使用。比方剛才說的 sentry 相關的 hook。

sentry-hook

Sentry 是一個錯誤監控系統,可以使用廠商的服務也可以在自己的伺服器上搭建 Sentry。模式跟 GitLab 很像,也是提供一鍵安裝包。為應用註冊 Sentry 後會分配一個 DSN 用於連線 Sentry 服務。

import (
  "github.com/sirupsen/logrus"
  "github.com/evalphobia/logrus_sentry"
)

func main() {
  log       := logrus.New()
  hook, err := logrus_sentry.NewSentryHook(YOUR_DSN, []logrus.Level{
    logrus.PanicLevel,
    logrus.FatalLevel,
    logrus.ErrorLevel,
  })

  if err == nil {
    log.Hooks.Add(hook)
  }
}

logrus 是執行緒安全的

預設情況下,Logger 受 mutex 保護,以進行併發寫入。當鉤子被呼叫並且日誌被寫入時,mutex 會被保持。如果確定不需要這種鎖,則可以呼叫 logger.SetNoLock() 禁用該鎖。

不需要鎖的情況包括:

  • 沒有註冊 hook,或者 hook 呼叫是執行緒安全的。
  • 寫入 logger.Out 是執行緒安全的,比如 logger.Out 已經被鎖保護或者 logger.Out 是一個以 Append 模式開啟的檔案控制程式碼。

日誌寫入和儲存的一些建議

選擇了專案使用的日誌庫後,您還需要計劃在程式碼中呼叫記錄器的位置,如何儲存日誌。在本部分中,將推薦一些整理 Go 日誌的最佳實踐,他們包括:

  • 從的主應用程式流程而不是 goroutine 中呼叫記錄器。
  • 將日誌從應用程式寫入本地檔案,即使以後再將其傳送到日誌集中化處理平臺也是如此。
  • 定義日誌的標準化預設欄位
  • 將日誌傳送到日誌處理平臺,以便進行分析和彙總。
  • 使用 HTTP 標頭攜帶分散式唯一 ID 記錄微服務中的使用者行為。

避免在 goroutine 中使用日誌記錄器

避免建立自己的 goroutine 來處理寫日誌有兩個原因。首先,它可能導致併發問題,因為記錄器的副本將嘗試訪問相同的 io.Writer。其次,日誌記錄庫通常會自己啟動 goroutine,在內部管理所有併發問題,而啟動自己的 goroutine 只會造成干擾。

總是將日誌寫入檔案

即使將日誌傳送到中央日誌平臺,我們也建議您先將日誌寫到本地計算機上的檔案中。這確保您的日誌始終在本地可用,並且不會在網路中丟失。此外,寫入檔案意味著您可以將寫入日誌的任務與將日誌傳送到中央日誌平臺的任務分開。您的應用程式本身無需建立連線或流式傳輸日誌給日誌平臺,您可以將這些任務交給專業的軟體處理,比如使用 Elasticsearch 索引日誌資料的話,那麼就可以用 Logstash 從日誌檔案裡抽取日誌資料。

使用日誌處理平臺集中處理日誌

如果您的應用程式部署在多個主機群集中,應用的日誌會分散到不同機器上。日誌從本地檔案傳遞到中央日誌平臺,以便進行日誌資料的分析和彙總。關於日誌處理服務的選擇,開源的日誌處理服務有 ELK,各個雲服務廠商也有自己的日誌處理服務,根據自身情況選擇即可,儘量選和雲伺服器同一廠商的日誌服務,這樣不用消耗公網的流量。

使用唯一 ID 跨微服務跟蹤 Go 日誌

對於構建在分散式系統之上的應用,一個請求可能會流經多個服務,每個服務都會自己記錄日誌。這種情況下為了查詢請求對應的日誌,通常的解決方案是在請求頭中攜帶唯一 ID,分散式系統中所有服務的日誌記錄器中增加唯一 ID 欄位,這樣每條寫入的日誌裡都會有 HTTP 請求的唯一 ID。在統一日誌平臺中分析日誌時,通過上游服務日誌記錄的請求唯一 ID 即可查詢到該請求在下游所有服務中產生的日誌。

參考連結:

https://www.datadoghq.com/blog/go-logging/

https://github.com/sirupsen/logrus/blob/master/README.md

更多原創文章乾貨分享,請關注公眾號
  • 關於收集,標準化和集中化處理 Golang 日誌的一些建議
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章