深入 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
程序只需要兩個級別的日誌:INFO
和ERROR
; 而其他層次並不重要。對於調試,它提供v
級輸出,放棄跟蹤或調試等其他級別。
它的實現包括Logr
和LogSink
接口,任何外部日誌框架都可以通過實現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
互斥鎖,並且兩者 WithName
和 WithValues
的方法 loggerPromise
需要鎖,甚至 Enable
, Info
和 Error
函數依賴於 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
。我們使用字符串。Join
或Buffer
操作來替換fmt.Sprintf
和+
連接字符串,以提高許多Go
字符串操作的效率 (閱讀golang
中的連接字符串快速基準:+
或fmt.Sprintf
瞭解更多)。但是在編寫日誌時,我們確實希望從日誌框架中免費獲得這一點,而不是重新創建輪子,這是logr
和controller-runtime
的默認實現無法提供的。
其他選項
如果我們放棄默認實現,還有其他選項嗎? 如何切換?
Go
社區提供了廣泛的日誌包,甚至本地的也可以滿足基本的日誌輸出需求。
在《A Sip of Go Log》
中,我們深入挖掘了日誌的基本邏輯,並比較了一些流行的日誌開源包,其中Uber
的zap
以其性能吸引了我們的眼球。讓我們回顧一下這些驚人的數字,看看zap
是否是最好的選擇。
controller-runtime
已經有了默認的zapr
實現,它已經在內部的很多地方被使用了。但爲什麼不將它用作默認實現呢?
用戶可以通過定製一個Logger
來集成zap
,以實現某些附加功能,如時間轉換、日誌輸出到外部存儲等,controller-runtime
將其封裝爲一個Logger
。Logger
最終在zap
控制器運行時實現。
按照以下四個步驟定義zap Logger
。
zap.go
實現了console
和json
兩種編碼器,並提供了zapr
標準Logger
所需的功能,如WriteTo
和Level
。
默認情況下,zapr
採用consoleEncoder和debugLevel/warnLevelin
開發模式,而使用jsonencoder
和infollevel /errorLevelin
生產。
至於替換這個Operator
中的默認Logr
,在初始化main
方法中的Reconcile
r 時,我們只需要使用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)
}
在輸出日誌時,我們還使用同步。內存優化池。默認的consoleEncoder
和jsonEncoder
都將打印的信息存儲在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
結構設計顯著提高了日誌打印的速度,並用於定義每個輸入參數的類型信息,以快速將類型轉換爲字符串。並且在Field
的AddTo
方法中定義了所有可能的類型和字符串之間的轉換,避免了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 Logr
比controller-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