薄いブログ

技術の雑多なことを書く場所

kubeletの負荷が問題になった話 (google/cadvisor)

orisano.hatenablog.com

の続編です. 上の記事では以下の図の左側について書きましたが今回は右側についてです.

f:id:orisano:20181210232544p:plain

google/cadvisorの高速化

上の図は非常に見づらいですが, 右側にgoogle/cadvisorというのが見えると思います.

github.com

google/cadvisorというのはdocker containerのメトリクスを取るためのツールでWebUIなどが提供されています.

kubeletの内部でcadvisorが使われているのがpprofからわかります.

google/cadvisor/manager (*containerData).GetInfoを起点としてListContainers, ReadDir経由でSyscallを呼び出しており, Syscallに全体の20%程度の時間を使っていることがわかります.

まず調査のためにListContainersの実装を確認しに行きました.

cadvisor/handler.go at 8faf19092784b75fce10ce190d37e66f89de3612 · google/cadvisor · GitHub

cadvisor/helpers.go at 8faf19092784b75fce10ce190d37e66f89de3612 · google/cadvisor · GitHub

cadvisor/helpers.go at 8faf19092784b75fce10ce190d37e66f89de3612 · google/cadvisor · GitHub

ListDirectoriesがReadDirを呼び出していることがわかりました.

// Lists all directories under "path" and outputs the results as children of "parent".
func ListDirectories(dirpath string, parent string, recursive bool, output map[string]struct{}) error {
    entries, err := ioutil.ReadDir(dirpath)
    if err != nil {
        // Ignore if this hierarchy does not exist.
        if os.IsNotExist(err) {
            err = nil
        }
        return err
    }
    for _, entry := range entries {
        // We only grab directories.
        if entry.IsDir() {
            name := path.Join(parent, entry.Name())
            output[name] = struct{}{}

            // List subcontainers if asked to.
            if recursive {
                err := ListDirectories(path.Join(dirpath, entry.Name()), name, true, output)
                if err != nil {
                    return err
                }
            }
        }
    }
    return nil
}

非常にシンプルな実装になっています. ディレクトリ名を再帰的に取得し, 対象がディレクトリだったらmap[string]struct{}に存在するディレクトリを追加していく感じです.

どうにかSyscallの数を減らせないかと, io/ioutilのReadDirの実装を読むことにしました.

src/io/ioutil/ioutil.go - The Go Programming Language

// ReadDir reads the directory named by dirname and returns
// a list of directory entries sorted by filename.
func ReadDir(dirname string) ([]os.FileInfo, error) {
    f, err := os.Open(dirname)
    if err != nil {
        return nil, err
    }
    list, err := f.Readdir(-1)
    f.Close()
    if err != nil {
        return nil, err
    }
    sort.Slice(list, func(i, j int) bool { return list[i].Name() < list[j].Name() })
    return list, nil
}

os.(*File).Readdirを呼んでいるだけということがわかりました.

ちなみにcadvisorは基本的にlinuxでしか動かないので以下追っていくコードはlinuxの話になります.

src/os/dir.go - The Go Programming Language

// Readdir reads the contents of the directory associated with file and
// returns a slice of up to n FileInfo values, as would be returned
// by Lstat, in directory order. Subsequent calls on the same file will yield
// further FileInfos.
//
// If n > 0, Readdir returns at most n FileInfo structures. In this case, if
// Readdir returns an empty slice, it will return a non-nil error
// explaining why. At the end of a directory, the error is io.EOF.
//
// If n <= 0, Readdir returns all the FileInfo from the directory in
// a single slice. In this case, if Readdir succeeds (reads all
// the way to the end of the directory), it returns the slice and a
// nil error. If it encounters an error before the end of the
// directory, Readdir returns the FileInfo read until that point
// and a non-nil error.
func (f *File) Readdir(n int) ([]FileInfo, error) {
    if f == nil {
        return nil, ErrInvalid
    }
    return f.readdir(n)
}

これはprivateなreaddirをwrapしているだけです.

src/os/dir_unix.go - The Go Programming Language

func (f *File) readdir(n int) (fi []FileInfo, err error) {
    dirname := f.name
    if dirname == "" {
        dirname = "."
    }
    names, err := f.Readdirnames(n)
    fi = make([]FileInfo, 0, len(names))
    for _, filename := range names {
        fip, lerr := lstat(dirname + "/" + filename)
        if IsNotExist(lerr) {
            // File disappeared between readdir + stat.
            // Just treat it as if it didn't exist.
            continue
        }
        if lerr != nil {
            return fi, lerr
        }
        fi = append(fi, fip)
    }
    if len(fi) == 0 && err == nil && n > 0 {
        // Per File.Readdir, the slice must be non-empty or err
        // must be non-nil if n > 0.
        err = io.EOF
    }
    return fi, err
}

どうやら os.(*File).Readdirnamesでディレクトリ名一覧を取得して, それぞれに対してlstatを呼び出しています.

lstatはsyscallを呼び出すだけですが, Readdirnamesは何をしているかわからないので更に調査します.

src/os/dir.go - The Go Programming Language

// Readdirnames reads and returns a slice of names from the directory f.
//
// If n > 0, Readdirnames returns at most n names. In this case, if
// Readdirnames returns an empty slice, it will return a non-nil error
// explaining why. At the end of a directory, the error is io.EOF.
//
// If n <= 0, Readdirnames returns all the names from the directory in
// a single slice. In this case, if Readdirnames succeeds (reads all
// the way to the end of the directory), it returns the slice and a
// nil error. If it encounters an error before the end of the
// directory, Readdirnames returns the names read until that point and
// a non-nil error.
func (f *File) Readdirnames(n int) (names []string, err error) {
    if f == nil {
        return nil, ErrInvalid
    }
    return f.readdirnames(n)
}

privateなos.(*File).readdirnamesをwrapしているだけです.

src/os/dir_unix.go - The Go Programming Language

func (f *File) readdirnames(n int) (names []string, err error) {
    // If this file has no dirinfo, create one.
    if f.dirinfo == nil {
        f.dirinfo = new(dirInfo)
        // The buffer must be at least a block long.
        f.dirinfo.buf = make([]byte, blockSize)
    }
    d := f.dirinfo

    size := n
    if size <= 0 {
        size = 100
        n = -1
    }

    names = make([]string, 0, size) // Empty with room to grow.
    for n != 0 {
        // Refill the buffer if necessary
        if d.bufp >= d.nbuf {
            d.bufp = 0
            var errno error
            d.nbuf, errno = f.pfd.ReadDirent(d.buf)
            runtime.KeepAlive(f)
            if errno != nil {
                return names, wrapSyscallError("readdirent", errno)
            }
            if d.nbuf <= 0 {
                break // EOF
            }
        }

        // Drain the buffer
        var nb, nc int
        nb, nc, names = syscall.ParseDirent(d.buf[d.bufp:d.nbuf], n, names)
        d.bufp += nb
        n -= nc
    }
    if n >= 0 && len(names) == 0 {
        return names, io.EOF
    }
    return names, nil
}

ReadDirentで結果をbufに格納して, そのbufferの中身を解釈するためにParseDirentを呼び出します. それによりそのfd以下のエントリー一覧が取得でき,名前がわかるという流れになります.

ここでなんで名前しか取得できないんだろうと思い, ReadDirentの調査を行いました.

ReadDirentは内部的にgetdents(2)を呼び出していることがわかったのでbufferの中に何が入るのか調べることにしました.

getdents(2) - Linux manual page

上の記事を見るとわかりますが, linux 2.6.4以降ではlinux_direntにd_typeというのがありDT_DIRかどうかでディレクトリかどうかが判定でき, d_nameで名前が取得できることがわかります.

大本のListDirectoriesの処理を見てみると, ディレクトリかどうかの判定と名前の取得だけできればいいのでgetdents(2)で十分ということがわかります.

上のことが実現できれば, os.(*File).readdirにおけるlstatが不要になるので深く多いエントリがあるケースにおいてsyscallがかなり減ることが期待できます.

しかし, GoのParseDirentではd_typeが取得できないのでそこを実装する必要があります.

ここを最初自分で実装したのですが, cadvisorのLICENSEとgoのLICENSE周りでいろいろ面倒くさいことになりうまく説明できなかったので同じことをやっているライブラリを使うことにしました.

github.com

初めて実装ありきでライブラリを探した気がします. このライブラリを使って当該箇所を高速化しました.

github.com

これで5倍程度速くなりました.