kubeletの負荷が問題になった話 (prometheus/common)
ある日, 同僚からkubeletのCPU負荷が高くて困っていると相談されたときの話です.
実際に監視しているそのkubeletのCPU使用率は高いことを確認し, 問題が起きているインスタンスの調査をはじめました.
kubernetes/server.go at master · kubernetes/kubernetes · GitHub
ソースコードを見ればkubeletではpprofが有効になっているのがわかります.
実際にソースコードのどの部分の処理が負荷になっているのかを特定するためpprofを実行しました.
上のような結果が得られました. この結果からメトリクス取得系の処理が負荷を掛けているのではないかという仮設が立ちました.
DataDogによるメトリクス取得処理を行っていたので, それの頻度を下げることで一旦の問題は解決できるだろうということで落ち着きました.
せっかくプロファイルを取ったし, メトリクス取得が速くなっても誰も損しないだろうということでパフォーマンス・チューニングをはじめました.
prometheus/commonの高速化
pprofの結果画像の左の方をみてもらえるとわかると思うのですが, prometheus系の処理で若干の負荷がかかっていることがわかります.
手元でチューニングするためにcloneしてみたのですが, 当該箇所が無くなっていました. それは直近で以下のPRが出ていたためでした.
もともと遅かった箇所を高速化して, かつメモリを毎回確保する形をやめたというPRでした.
すでに別の人によって2倍程度速くなっていました. このままだとなんだか悔しいなと思い, benchmarkの結果を眺めていると改善後でも以下の関数が遅いことがわかりました.
func writeEscapedString(w enhancedWriter, v string, includeDoubleQuote bool) (int, error) { var ( written, n int err error ) for _, r := range v { switch r { case '\\': n, err = w.WriteString(`\\`) case '\n': n, err = w.WriteString(`\n`) case '"': if includeDoubleQuote { n, err = w.WriteString(`\"`) } else { n, err = w.WriteRune(r) } default: n, err = w.WriteRune(r) } written += n if err != nil { return written, err } } return written, nil }
この関数はソースコード見ればわかりますが, 特定文字をエスケープしつつ文字列をWriteしたいという関数です.
一文字ずつ検査し, エスケープ対象であればエスケープしたものをWriteして, それ以外ならWriteRuneをするというものです.
これで一見問題ない様に見えるのですが, 大量にWriteRuneを呼んでおりその結果syscallが大量に発行されていることがわかりました.
この処理をみて, strings.ReplacerにWriteStringがあることを思い出しました.
https://golang.org/pkg/strings/#Replacer.WriteString
そもそもstrings.Replacerは内部的に4種類の実装があり, 引数を元に実装を切り替えます.
src/strings/replace.go - The Go Programming Language
NewReplacerの実装を読んだほうが早いし正確ですが,
- 置換対象が一つのときに使われるsingleStringReplacer
- 置換元がすべて1byteで置換先もすべて1byteのときに使われるbyteReplacer
- 置換元がすべて1byteで置換先は1byteではないときに使われるbyteStringReplacer
- それ以外のときに使われるgenericReplacer
singleStringReplacerは内部でstringFinderを用いており, stringFinderはBM法で実装されています.
byteReplacerはすごくシンプルな実装になっていて, [256]byteを持ってbyte->byteの対応をもって愚直に変換していくだけです.
byteStringReplacerも基本的な発想はbyteReplacerと一緒で[256][]byteをもって変換していく感じです. ある程度の枝刈りがなされています.
genericReplacerはTrieで実装されています. 上の特定のケースに最適化されたreplacerよりは遅いですが普通に使えるものだと思います.
今回の実装の場合だと1byteを複数byteに置き換えるのでbyteStringReplacerが内部的に使われます.
byteStringReplacerのWriteStringの内容は以下で確認できます.
src/strings/replace.go - The Go Programming Language
置換対象の文字列でない場合は書き込まず, まとめて書き込んでくれる実装になっておりWriteの回数を少なくしてくれそうです.
また余計なメモリの確保なども行わない実装になっているのでAllocation-Freeの文脈でも問題なく使用できます.
今回は内部に条件分岐が存在しますが, 引数で決定するものかつ, 2種類しか値を持たないものなのでStringReplacerを2種類作ることで対応できます.
以上の改善で, 更に2倍近く高速にすることができました.