深入 Operator 日誌框架,實現一個高性能的 Operator

讓我們用Operator日誌記錄來展開我們的主題。不同於最常見的打印方法fmt.Printf,它不會在消息中打印具體的參數信息,而是單獨在日誌中打印,比如JSON字符串。

{"severity":"INFO","eventTime":"2022-07-09T09:04:55.260Z","logger":"controller.opresource","message":"Deleting OpResource, because it is no longer in scope!","reconciler group":"op.spotify.com","reconciler kind":"OpResource","name":"afs-proxy-0618bce","namespace":"","name":"afs-proxy"}

這是Operator日誌打印的規範方式嗎? 讓我們通過深入研究Operator日誌框架的實現和使用細節來揭示答案。

Log in operator

controller-runtime alias.go提供了一個默認的Log對象,默認情況下由go-logr執行該對象的實現

// Log is the base logger used by controller-runtime.  It delegates
// to another logr.Logger.  You *must* call SetLogger to
// get any actual logging.
Log = log.Log

Go-logr是一個簡單的日誌框架,它本身沒有日誌輸出功能,而是對Dave Cheney提出的Go日誌分類的進一步優化。

一般情況下,一個Go程序只需要兩個級別的日誌:INFOERROR; 而其他層次並不重要。對於調試,它提供v級輸出,放棄跟蹤或調試等其他級別。

它的實現包括LogrLogSink接口,任何外部日誌框架都可以通過實現LogSink來集成Logr

在默認的controller-runtime中,DelegatingLogSink被實現爲默認的Logr日誌輸出,其中logger使用簡單的NullLogSink。這個默認實現對於大多數場景已經足夠了,並且可以與fmt.Sprintf結合使用。

log.V(10).Info(fmt.Sprintf("Create Resources for User:%s, Project:%s", user, project))

然而, DelegatingLogSink有一個明顯的缺陷:當它被部署爲集羣範圍的控制器並管理數千個資源時,它的低效率是一個性能瓶頸。以下兩個是導致其效率低下的主要原因。

它使用了很多鎖 。DelegatingLogSink使用一個 promisesLock互斥鎖,並且兩者 WithNameWithValues的方法 loggerPromise需要鎖,甚至 Enable, InfoError函數依賴於 RLock,讀鎖。毫無疑問,在具有大量寫入操作的日誌輸出中頻繁鎖定,即使使用讀鎖定,也會導致性能下降。

func (l *DelegatingLogSink) Info(level int, msg string,keysAndValues ...interface{}) {
  l.lock.RLock()
  defer l.lock.RUnlock()
  l.logger.Info(level, msg, keysAndValues...)
}

日誌內容使用的是fmt.Sprintf。我們使用字符串。JoinBuffer操作來替換fmt.Sprintf+連接字符串,以提高許多Go字符串操作的效率 (閱讀golang中的連接字符串快速基準:+fmt.Sprintf瞭解更多)。但是在編寫日誌時,我們確實希望從日誌框架中免費獲得這一點,而不是重新創建輪子,這是logrcontroller-runtime的默認實現無法提供的。

其他選項

如果我們放棄默認實現,還有其他選項嗎? 如何切換?

Go社區提供了廣泛的日誌包,甚至本地的也可以滿足基本的日誌輸出需求。

《A Sip of Go Log》中,我們深入挖掘了日誌的基本邏輯,並比較了一些流行的日誌開源包,其中Uberzap以其性能吸引了我們的眼球。讓我們回顧一下這些驚人的數字,看看zap是否是最好的選擇。

controller-runtime已經有了默認的zapr實現,它已經在內部的很多地方被使用了。但爲什麼不將它用作默認實現呢?

用戶可以通過定製一個Logger來集成zap,以實現某些附加功能,如時間轉換、日誌輸出到外部存儲等,controller-runtime將其封裝爲一個LoggerLogger最終在zap控制器運行時實現。

按照以下四個步驟定義zap Logger

zap.go實現了consolejson兩種編碼器,並提供了zapr標準Logger所需的功能,如WriteToLevel

默認情況下,zapr採用consoleEncoder和debugLevel/warnLevelin開發模式,而使用jsonencoderinfollevel /errorLevelin生產。

至於替換這個Operator中的默認Logr,在初始化main方法中的Reconciler 時,我們只需要使用Log: zap.New(),然後我們可以按以下模式在控制器中打印日誌。

func log(ctx context.Context) {
  log.FromContext(ctx).Info("message""param1", param1, "param2", param2 ...)
  log.FromContext(ctx).Error(err, "message""param1", param1, "param2", param2 ...)
}

爲什麼 zap 這麼快

zap採取的5個步驟 (每個步驟都反映了優化) 主要使用Go的兩個特性,這兩個特性決定了zap的效率。

使用同步。池,以避免輸出日誌時的內存開銷。

日誌。Check是打印的第一步,在Check方法中進行了兩項性能優化。

避免不必要的操作,如跳過不必要的日誌級別,直接返回不需要打印的日誌。例如,如果我們的日誌級別是Info,那麼一旦出現調試級別日誌,它就直接返回。重用Entry對象。zap構造一個真正打印的Entry對象,它將被Checked,以便在同步保存的對象中重用它。池,降低高頻日誌對象的創建和消除頻率,最終減少GC

func getCheckedEntry() *CheckedEntry {
  ce := _cePool.Get().(*CheckedEntry)
  ce.reset()
  return ce
}

每次寫後更新池

func (ce *CheckedEntry) Write(fields ...Field) {
  //…
  putCheckedEntry(ce)
}

在輸出日誌時,我們還使用同步。內存優化池。默認的consoleEncoderjsonEncoder都將打印的信息存儲在buffer中,buffer使用sync構建的bufferPool。池中獲取一個對象,並通過下面的拼接獲得最終的輸出日誌。

// console_encoder.go
func (c consoleEncoder) EncodeEntry(ent Entry, fields []Field) (*buffer.Buffer, error) {5 years ago • Akshay Shah [Fix allocation when returning []byte to pool …]
 line := bufferpool.Get()
  // ... 
}

// json_encoder.go
func (enc *jsonEncoder) clone() *jsonEncoder {
 clone := getJSONEncoder()
 clone.EncoderConfig = enc.EncoderConfig
 clone.spaced = enc.spaced
 clone.openNamespaces = enc.openNamespaces
 clone.buf = bufferpool.Get()
 return clone
}

func (enc *jsonEncoder) EncodeEntry(ent Entry, fields []Field) (*buffer.Buffer, error) {5 years ago • Akshay Shah [Fix allocation when returning []byte to pool …]
 final := enc.clone()
  //...
}

// buffer.Pool
type Pool struct {
 p *sync.Pool
}

// NewPool constructs a new Pool.
func NewPool() Pool {
 return Pool{p: &sync.Pool{
  New: func() interface{} {
   return &Buffer{bs: make([]byte, 0, _size)}
  },
 }}
}

func (p Pool) Get() *Buffer {5 years ago • Akshay Shah [Expose Buffer pools to third-party encoders (]
 buf := p.p.Get().(*Buffer)
 buf.Reset()
 buf.pool = p
 return buf
}

避免使用interface{}設計api和優化JSON序列化。通過強類型設計和零內存開銷實現JSON序列化。

zap的日誌輸出中,JSON格式是最終統一的輸出格式,甚至console_encoder也最終調用json_encoder來打印字段。Fields結構設計顯著提高了日誌打印的速度,並用於定義每個輸入參數的類型信息,以快速將類型轉換爲字符串。並且在FieldAddTo方法中定義了所有可能的類型和字符串之間的轉換,避免了zap的類型推斷和反射,大大提高了輸出效率。

// zap console_encoder.go
func (c consoleEncoder) writeContext(line *buffer.Buffer, extra []Field) {
 context := c.jsonEncoder.Clone().(*jsonEncoder)
 defer func() {
  // putJSONEncoder assumes the buffer is still used, but we write out the buffer so
  // we can free it.
  context.buf.Free()
  putJSONEncoder(context)
 }()

 addFields(context, extra)
  // ...
}

func addFields(enc ObjectEncoder, fields []Field) {
 for i := range fields {
  fields[i].AddTo(enc)
 }
}

// Field.go
type Field struct {
 Key       string
 Type      FieldType
 Integer   int64
 String    string
 Interface interface{}
}

// AddTo exports a field through the ObjectEncoder interface. It's primarily
// useful to library authors, and shouldn't be necessary in most applications.
func (f Field) AddTo(enc ObjectEncoder) {
 var err error

 switch f.Type {
 case ArrayMarshalerType:
  err = enc.AddArray(f.Key, f.Interface.(ArrayMarshaler))
 case ObjectMarshalerType:
  err = enc.AddObject(f.Key, f.Interface.(ObjectMarshaler))
 case InlineMarshalerType:
  err = f.Interface.(ObjectMarshaler).MarshalLogObject(enc)
 case BinaryType:
  enc.AddBinary(f.Key, f.Interface.([]byte))
 case BoolType:
  enc.AddBool(f.Key, f.Integer == 1)
 case ByteStringType:
  enc.AddByteString(f.Key, f.Interface.([]byte))
  // more type convert func
}

當然,不要忘記釋放在bufferPool中獲得的對象!

性能測試

讓一個基準測試來驗證zap的出色性能。

import (
 "fmt"
 "testing"

 "github.com/go-logr/logr"
 "github.com/go-logr/logr/funcr"
 "sigs.k8s.io/controller-runtime/pkg/log"
 "sigs.k8s.io/controller-runtime/pkg/log/zap"
)

//go:noinline
func doInfoOneArg(b *testing.B, log logr.Logger) {
 for i := 0; i < b.N; i++ {
  log.Info("this is""a""string")
 }
}

//go:noinline
func doInfoSeveralArgs(b *testing.B, log logr.Logger) {
 for i := 0; i < b.N; i++ {
  log.Info("multi",
   "bool", true, "string""str""int", 42,
   "float", 3.14, "struct", struct{ X, Y int }{93, 76})
 }
}

// Default Logr
func BenchmarkDiscardLogInfoOneArg(b *testing.B) {
 var log logr.Logger = logr.Discard()
 doInfoOneArg(b, log)
}

// DKL: Default Kubernetes controllerruntime Logr
func BenchmarkDiscardLogInfoOneArgDKL(b *testing.B) {
 var log logr.Logger = log.Log
 doInfoOneArg(b, log)
}

// KLZ: Kubernetes controllerruntime ZapLogr
func BenchmarkDiscardLogInfoOneArgKLZ(b *testing.B) {
 var log logr.Logger = zap.New()
 doInfoOneArg(b, log)
}

func BenchmarkDiscardLogInfoSeveralArgs(b *testing.B) {
 var log logr.Logger = logr.Discard()
 doInfoSeveralArgs(b, log)
}

func BenchmarkDiscardLogInfoSeveralArgsDKL(b *testing.B) {
 var log logr.Logger = log.Log
 doInfoSeveralArgs(b, log)
}

func BenchmarkDiscardLogInfoSeveralArgsKLZ(b *testing.B) {
 var log logr.Logger = zap.New()
 doInfoSeveralArgs(b, log)
}

// output:
BenchmarkDiscardLogInfoOneArg-16               34606057         34.10 ns/op       32 B/op        1 allocs/op
BenchmarkDiscardLogInfoOneArgDKL-16            23754621         47.12 ns/op       32 B/op        1 allocs/op
BenchmarkDiscardLogInfoOneArgKLZ-16             4568048           284.3 ns/op       32 B/op        1 allocs/op
BenchmarkDiscardLogInfoSeveralArgs-16          14577048         102.1 ns/op      176 B/op        2 allocs/op
BenchmarkDiscardLogInfoSeveralArgsDKL-16       12127125         90.31 ns/op      176 B/op        2 allocs/op
BenchmarkDiscardLogInfoSeveralArgsKLZ-16        3421066           386.2 ns/op      180 B/op        2 allocs/op

通過比較這三個日誌包在無參數和多參數場景中的性能,我可以 “自豪地” 宣佈zap是最好的:zap Logrcontroller-runtime中的默認Logr快大約 7 倍。

很容易得出這樣的結論: 如果我們想實現一個高性能的Operator,就必須替換默認的Log實現。但是需要注意的是,如果您仍然使用v1,則可能需要將kubebuilder升級到最新的v3版本,這涉及到從klog遷移到logr,請參考結構化和上下文日誌遷移說明以獲得指導。

結構化和上下文日誌遷移: https://github.com/kubernetes/community/blob/HEAD/contributors/devel/sig-instrumentation/migration-to-structured-logging.md#structured-and-contextual-logging-migration-instructions

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/40Gm8IDBo8qVcjWcaLaXMg