薄いブログ

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

monorepoのdocker buildにおけるdockerignore

表題の通りなのですが, monorepoにおけるdocker buildの話です.

build contextの話

まずこの問題を認識する上で必要なのがbuild contextの理解です.

docs.docker.com

Best practiceでも触れられている通り, build contextの概念は非常に重要です.

When you issue a docker build command, the current working directory is called the build context. By default, the Dockerfile is assumed to be located here, but you can specify a different location with the file flag (-f). Regardless of where the Dockerfile actually lives, all recursive contents of files and directories in the current directory are sent to the Docker daemon as the build context.

build contextとは基本的にDockerfileを含むディレクトリのことです (-fで指定することでDockerfileを指定することもできます). それの何が重要なのでしょうか.

それにはDockerのアーキテクチャに関係しているので以下のドキュメントがわかりやすいです.

https://docs.docker.com/engine/docker-overview/#docker-engine

https://docs.docker.com/engine/docker-overview/#docker-architecture

docker clientとdocker daemonの間はRESTでやり取りをします.

docker clientはdocker buildを実行したタイミングでbuild contextをtarでアーカイブしてdocker daemonに転送します.

つまり何もしなければdocker buildで指定したディレクトリ以下の内容がすべて転送されてしまいます. vendorやnode_modules, .gitなどの実際にbuildには必要のないファイルまでも転送されます.

そしてうっかり, Dockerfileで COPY . .と書いていれば不必要なファイル群が成果物のイメージに残ってしまっているかも知れません.

大量のファイルをアーカイブするコスト, 大きなファイルを転送するコスト, どちらも馬鹿にはできません.

ちなみにbuildkitではbuild contextの差分転送を行っており, この手の問題が初回にしか発生しません.

buildkit以外で問題を回避するために.dockerignoreというものがあります.

https://docs.docker.com/engine/reference/builder/#dockerignore-file

.dockerignore に書かれたルールに引っかかるファイルはアーカイブされず転送されることもありません.

詳細な振る舞いを確認したい場合はソースコードを見るといいです.

docker buildのclient側 https://github.com/docker/cli/blob/master/cli/command/image/build.go#L190-L475

   // read from a directory into tar archive
    if buildCtx == nil && !options.stream {
        excludes, err := build.ReadDockerignore(contextDir)
        if err != nil {
            return err
        }

        if err := build.ValidateContextDirectory(contextDir, excludes); err != nil {
            return errors.Errorf("error checking context: '%s'.", err)
        }

        // And canonicalize dockerfile name to a platform-independent one
        relDockerfile = archive.CanonicalTarNameForPath(relDockerfile)

        excludes = build.TrimBuildFilesFromExcludes(excludes, relDockerfile, options.dockerfileFromStdin())
        buildCtx, err = archive.TarWithOptions(contextDir, &archive.TarOptions{
            ExcludePatterns: excludes,
            ChownOpts:       &idtools.Identity{UID: 0, GID: 0},
        })
        if err != nil {
            return err
        }
    }

buildCtxがnil, つまり標準入力経由やURL経由でbuild contextが指定されておらず, stream optionが指定されていない場合に.dockerignoreを読み込みます.

.dockerignore自体の読み込み cli/dockerignore.go at master · docker/cli · GitHub

空行や, #始まる行の無視を行います. またpathのCleanなども行って使える形にします.

次にbuild.ValidateContextDirectory で.dockerignoreのルールにマッチしないファイル群に読み込みの権限があるか, symlinkがないかを調べます.

https://github.com/docker/cli/blob/master/cli/command/image/build/context.go#L39-L82

// ValidateContextDirectory checks if all the contents of the directory
// can be read and returns an error if some files can't be read
// symlinks which point to non-existing files don't trigger an error
func ValidateContextDirectory(srcPath string, excludes []string) error {
    contextRoot, err := getContextRoot(srcPath)
    if err != nil {
        return err
    }
    return filepath.Walk(contextRoot, func(filePath string, f os.FileInfo, err error) error {
        if err != nil {
            if os.IsPermission(err) {
                return errors.Errorf("can't stat '%s'", filePath)
            }
            if os.IsNotExist(err) {
                return errors.Errorf("file ('%s') not found or excluded by .dockerignore", filePath)
            }
            return err
        }

        // skip this directory/file if it's not in the path, it won't get added to the context
        if relFilePath, err := filepath.Rel(contextRoot, filePath); err != nil {
            return err
        } else if skip, err := fileutils.Matches(relFilePath, excludes); err != nil {
            return err
        } else if skip {
            if f.IsDir() {
                return filepath.SkipDir
            }
            return nil
        }

        // skip checking if symlinks point to non-existing files, such symlinks can be useful
        // also skip named pipes, because they hanging on open
        if f.Mode()&(os.ModeSymlink|os.ModeNamedPipe) != 0 {
            return nil
        }

        if !f.IsDir() {
            currentFile, err := os.Open(filePath)
            if err != nil && os.IsPermission(err) {
                return errors.Errorf("no permission to read from '%s'", filePath)
            }
            currentFile.Close()
        }
        return nil
    })
}

前にちょこっと実装を読んだときにも思ったのですが, Walk対象のファイルの数だけ

https://github.com/moby/moby/blob/master/pkg/fileutils/fileutils.go#L219-L232

// Matches returns true if file matches any of the patterns
// and isn't excluded by any of the subsequent patterns.
func Matches(file string, patterns []string) (bool, error) {
    pm, err := NewPatternMatcher(patterns)
    if err != nil {
        return false, err
    }
    file = filepath.Clean(file)

    if file == "." {
        // Don't let them exclude everything, kind of silly.
        return false, nil
    }

    return pm.Matches(file)
}

が呼ばれており, 毎回新規にPatternMatcherを作り, すぐにMatchesを呼び出す実装になっています.

https://github.com/moby/moby/blob/master/pkg/fileutils/fileutils.go#L62-L97

func (pm *PatternMatcher) Matches(file string) (bool, error) {
    matched := false
    file = filepath.FromSlash(file)
    parentPath := filepath.Dir(file)
    parentPathDirs := strings.Split(parentPath, string(os.PathSeparator))

    for _, pattern := range pm.patterns {
        negative := false

        if pattern.exclusion {
            negative = true
        }

        match, err := pattern.match(file)
        if err != nil {
            return false, err
        }

        if !match && parentPath != "." {
            // Check to see if the pattern matches one of our parent dirs.
            if len(pattern.dirs) <= len(parentPathDirs) {
                match, _ = pattern.match(strings.Join(parentPathDirs[:len(pattern.dirs)], string(os.PathSeparator)))
            }
        }

        if match {
            matched = !negative
        }
    }

    if matched {
        logrus.Debugf("Skipping excluded path: %s", file)
    }

    return matched, nil
}

Matchesは上の実装になっていて, pattern.matchを呼び出します.

https://github.com/moby/moby/blob/master/pkg/fileutils/fileutils.go#L126-L215

func (p *Pattern) match(path string) (bool, error) {

    if p.regexp == nil {
        if err := p.compile(); err != nil {
            return false, filepath.ErrBadPattern
        }
    }

    b := p.regexp.MatchString(path)

    return b, nil
}

func (p *Pattern) compile() error {
    regStr := "^"
    pattern := p.cleanedPattern
    // Go through the pattern and convert it to a regexp.
    // We use a scanner so we can support utf-8 chars.
    var scan scanner.Scanner
    scan.Init(strings.NewReader(pattern))

    sl := string(os.PathSeparator)
    escSL := sl
    if sl == `\` {
        escSL += `\`
    }

    for scan.Peek() != scanner.EOF {
        ch := scan.Next()

        if ch == '*' {
            if scan.Peek() == '*' {
                // is some flavor of "**"
                scan.Next()

                // Treat **/ as ** so eat the "/"
                if string(scan.Peek()) == sl {
                    scan.Next()
                }

                if scan.Peek() == scanner.EOF {
                    // is "**EOF" - to align with .gitignore just accept all
                    regStr += ".*"
                } else {
                    // is "**"
                    // Note that this allows for any # of /'s (even 0) because
                    // the .* will eat everything, even /'s
                    regStr += "(.*" + escSL + ")?"
                }
            } else {
                // is "*" so map it to anything but "/"
                regStr += "[^" + escSL + "]*"
            }
        } else if ch == '?' {
            // "?" is any char except "/"
            regStr += "[^" + escSL + "]"
        } else if ch == '.' || ch == '$' {
            // Escape some regexp special chars that have no meaning
            // in golang's filepath.Match
            regStr += `\` + string(ch)
        } else if ch == '\\' {
            // escape next char. Note that a trailing \ in the pattern
            // will be left alone (but need to escape it)
            if sl == `\` {
                // On windows map "\" to "\\", meaning an escaped backslash,
                // and then just continue because filepath.Match on
                // Windows doesn't allow escaping at all
                regStr += escSL
                continue
            }
            if scan.Peek() != scanner.EOF {
                regStr += `\` + string(scan.Next())
            } else {
                regStr += `\`
            }
        } else {
            regStr += string(ch)
        }
    }

    regStr += "$"

    re, err := regexp.Compile(regStr)
    if err != nil {
        return err
    }

    p.regexp = re
    return nil
}

matchはcompileを呼び出します, patternを正規表現に変換してregexp.Compileします.

regexp.Compileはコストの高い操作です, そんな処理をファイル一つ一つに対してやっています.

記事を書いていて信じられなくなったのでPRを出しました.

github.com

(2019年4月7日 追記) 4月1日にmergeされ, Dockerの19.03から取り込まれるみたいです. まだベータですが

脱線してしまいましたがtarを作るときのOptionにexcludesに渡しており, それでtarに含まれないという挙動になっています.

monorepoにおけるbuild context

上の仕組みを理解しているとわかると思いますが, build時にbuild contextの親ディレクトリのファイルなどは参照できません.

monorepoで別のリポジトリに依存している場合にbuildできないという問題が起きてしまいます, 上で紹介した通りsymlinkなども使えません.

そういったとき, build contextを親のディレクトリに指定してbuildせざる負えないと思いますが,

その場合には不必要なrepositoryは転送したくないのでdockerignoreを書くことになります.

buildする対象リポジトリの数だけdockerignoreを書きますが, 一つ問題点があります.

dockerignoreはclientから指定できません. .dockerignoreというファイルでなければいけません.

github.com

(できるようになるっぽいですね)

この問題点を解決するためにツールを作りました.

github.com

すごくシンプルなツールで引数に必要なリポジトリを渡すとそれ以外のディレクトリを含んだ.dockerignoreを自動生成します.

必要なリポジトリ配下に.dockerignoreがある場合はリポジトリ名とパターンを結合して展開します. (!には対応していません)

CIなどのpipeline上でbuildの前で行うことでbuild contextの転送量を少なくすることができます.

monorepoにおけるdocker buildにおいて, dockerignoreの自動生成は必須だと思います. 少なくとも現時点では.

docker imageも公開しているのでコンテナベースのCIからでも簡単に使えると思います. ぜひ使ってみてフィードバックをください!

https://hub.docker.com/r/orisano/dignore

良いと感じたらGitHubでStarしていただけると励みになります.

orisano/targd の話

github.com

上のツールを作ったので紹介します.

背景

Docker buildを高速に, そして小さいイメージを作るためにMulti-stage buildsを日々活用しています.

コンテナベースのCIを使っていたり, アプリケーションをコンテナ前提で作っているとCIでDocker buildをすることになると思います.

そういう環境においてはbuildkitが使えなかったりします. Multi-stage builds使いにとってbuildkitがないということは死を意味します.

Multi-stage buildsでstageごとに別々の意味をもたせたりするパターンが存在します.

そういったケースではtargetステージを明示的に指定するのですが普通のdocker buildでは指定したステージ以前のステージをすべてbuildしてしまいます.

ステージごとに依存しているステージは別々なので必要のないステージのbuildは無駄でしかありません.

ちなみにMulti-stage buildsの活用については以前した発表があるのでぜひ見てみてください.

speakerdeck.com

orisano/targd

github.com

ということでbuildkitが使えない環境向けの必要ないステージをbuildしないためのツールを作りました.

これも非常にシンプルな作りになっていて

  • buildkitが内部的に持っているparserを使用してDockerfileのASTを取得
  • 深さ優先探索で必要なステージを求める
  • 必要なステージだけ記述されたDockerfileを出力

これも前の記事で紹介したDockerfile pipeline toolchainの一つになっていて, 使っていてそれなりに効果があるツールだと思っています.

orisano.hatenablog.com

Multi-stage builds沼に入ってbuildが遅いと感じてbuildkitが使えない状況なら, targdを使ってみてください.

このへんのツールは実際にCIで使うときにコンテナとして提供されていないのが体験として良くなかったので, 提供してあります.

https://hub.docker.com/r/orisano/targd/

コンテナベースのCIなら気軽に使えると思います. ぜひ使ってみてフィードバックをください.

良いと思ったらGitHubでStarしていただけると励みになります.

orisano/minid の話

github.com

上のツールを作ったので紹介の記事です.

背景

僕はアプリケーションエンジニアとしてDockerfileをそこそこ書くことがあるのですが,

巷にあふれるDockerfileがRUN一つにまとめられているのがずっと不思議に思っていました.

見栄えも悪いし, これってメンテナンス可能なんだろうか. そもそもなぜやっているんだろうと.

docs.docker.com

In older versions of Docker, it was important that you minimized the number of layers in your images to ensure they were performant. The following features were added to reduce this limitation:

In Docker 1.10 and higher, only the instructions RUN, COPY, ADD create layers. Other instructions create temporary intermediate images, and do not directly increase the size of the build.

In Docker 17.05 and higher, you can do multi-stage builds and only copy the artifacts you need into the final image. This allows you to include tools and debug information in your intermediate build stages without increasing the size of the final image.

上の記事を見ると, 古いバージョンのDockerにおいては, イメージのレイヤー数を最小化することが重要だったと書いてあります.

  • Docker 1.10 より前のバージョンのRUN, COPY, ADD以外の命令でもビルドのサイズが大きくなってしまう問題点
  • Docker 17.05 より前のバージョンではmulti-stage buildsが使用できない故に, イメージが大きくなりがちな問題, ツールを入れて使うのに気を使わないといけない問題

以上の問題点により, レイヤーの数がどうこうというよりイメージのサイズについて触れられていることが多いです.

17.05以降のバージョンを使える環境においてはRUN, COPY, ADDの数がレイヤーの数になりますし, multi-stage buildsを活用することでイメージのサイズは小さくできますのでレイヤー数がどうこうみたいなことは考えなくて良いと思います.

ちなみになぜイメージのサイズを意識するかというとpull, pushの速度に寄与するからだと思っています.

もしそのほかの観点でイメージのサイズを小さくする理由があるという方は教えていただけると幸いです.

orisano/minid

github.com

このツールは上の背景について理解してなかった僕が作った連続しているRUN, COPY, ADDを結合してくれるやつです.

機械的にRUN, COPY, ADDを結合することでイメージのサイズを小さくすることができます. しかし,レイヤー数が減るからイメージサイズが減るわけではないです.

厳密には違うのですが以下のページの図を参照していただけるとわかりやすいと思います.

AUFS ストレージ・ドライバの使用 — Docker-docs-ja 17.06.Beta ドキュメント

Dockerのイメージはファイルシステムの差分で構成されており, ファイルの削除はWhiteoutファイルで表現され, 移動などはOpaqueファイルで表現されています.

Dockerfileのすべてのレイヤーで独立なファイルを作成, 変更していればほとんど無駄がないので問題ありません.

しかし, RUNで実行されたコマンドがどういったファイルを作ったり変更したりするかをすべて把握しているかと聞かれれば怪しいと思います. (特にcache周りなど)

機械的にRUNをまとめることで, 自分が意識しないうちにレイヤーを跨いで触れているファイル分のサイズが小さくなります. これがサイズが小さくなる原理です.

また差分で保持しているという前提を知らなくても連続している場合のみ回避することができます.

一番重要なメリットしては, 人間が && を無限に書かなくても良くなるところです.

minidではbuildkitの内部にあるDockerfileのパーサを使用しASTを取得し, ランレングス圧縮の要領でまとめているだけです.

github.com

Dockerfileのparserは意外と簡単に使えるので使っておもちゃを作ってみると面白いかも知れません.

ぜひ面白いおもちゃができたときは教えてください.

終わりに

個人的にこのツールのあまり見ない点は, Dockerfileを受け取ってDockerfileを返す点だと思っています.

開発者が書きやすいDockerfileと小さく最適なDockerfileは別物だと思っていて,前者から機械的に後者を作れると信じているのでDockerfileを加工するシンプルなツールを作っています.

それをCI上でPipelineにすることで開発者の負荷を下げ, 高速なbuild, 高速なdeployの実現を夢みてやっています.

ツールが良いと思ったらGitHubでStarしてくれると励みになります.

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倍程度速くなりました.

kubeletの負荷が問題になった話 (prometheus/common)

ある日, 同僚からkubeletのCPU負荷が高くて困っていると相談されたときの話です.

実際に監視しているそのkubeletのCPU使用率は高いことを確認し, 問題が起きているインスタンスの調査をはじめました.

kubernetes/server.go at master · kubernetes/kubernetes · GitHub

ソースコードを見ればkubeletではpprofが有効になっているのがわかります.

実際にソースコードのどの部分の処理が負荷になっているのかを特定するためpprofを実行しました.

f:id:orisano:20181210232544p:plain

上のような結果が得られました. この結果からメトリクス取得系の処理が負荷を掛けているのではないかという仮設が立ちました.

DataDogによるメトリクス取得処理を行っていたので, それの頻度を下げることで一旦の問題は解決できるだろうということで落ち着きました.

せっかくプロファイルを取ったし, メトリクス取得が速くなっても誰も損しないだろうということでパフォーマンス・チューニングをはじめました.

prometheus/commonの高速化

pprofの結果画像の左の方をみてもらえるとわかると思うのですが, prometheus系の処理で若干の負荷がかかっていることがわかります.

github.com

手元でチューニングするためにcloneしてみたのですが, 当該箇所が無くなっていました. それは直近で以下のPRが出ていたためでした.

github.com

もともと遅かった箇所を高速化して, かつメモリを毎回確保する形をやめたという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種類作ることで対応できます.

github.com

以上の改善で, 更に2倍近く高速にすることができました.

Socket.IOとGoの話

speakerdeck.com

goでsocket.ioのclientを実装した話です.

背景

socket.io を使ったアプリケーションに負荷試験をしたいというのがありました.

go で書きたいという気持ちとよく考えたら socket.io のことあまり知らないなという気持ちから実装してみようと思い始めたものです.

はじめに

Socket.IOとは

FEATURING THE FASTEST AND MOST RELIABLE REAL-TIME ENGINE

を謳っているリアルタイム双方向event-basedなエンジンです.

Node.jsで実装されており, キラーアプリとしてNode.jsの普及に貢献したイメージがあります. 間違っていたらすいません.

Socket.IOは現在version 2.0まで出ており, 今回話すのもversion 2.0の話です.

よくある誤解というかあまり意識せずに Socket.IO == WebSocket という人もいるように感じます.

実際には Socket.IO !== WebSocket です.

むしろ Socket.IOはWebSocketの対応状況が悪かった時期に同様のことをするためのものだったという理解です.

Socket.IOの中ではTransportの部分は抽象化されており, その部分はEngine.IOと呼ばれています, 必ずしもWebSocketではありません.

Engine.IOはかなり簡素化されたRealtime Engineでeventなどの概念は存在しません. Engine.IOの仕様として

の3つのTransportをサポートしなければいけません. Engine.IOを通すことで上の層ではこれらを意識しなくて良くなります.

別に今の時代 WebSocketでいいだろと思った方は以下の記事にWebSocketを使う上で考えなければいけないことがまとまっているので読んでみてください.

blog.jxck.io

インターネットは難しく, リアルタイム通信ができないと価値を提供できないものの場合はWebSocketだけではなくfallbackを考えなければいけず, それが実現されているSocket.IOを使うという選択肢はあると思います.

Engine.IO

Engine.IOプロトコルに関しては以下のリポジトリにまとまっています.

github.com

最新はrevision 3でrevision 3の話をします. 見てみるとわかるのですがテキストベースのすごくシンプルなものになっています.

<packet type id>[<data>] という形式でpacket type idも7種類しかありません.

  • open
  • close
  • ping
  • pong
  • message
  • upgrade
  • noop

接続の維持, 識別, データの送信などの基本的な操作しかない.

非常に簡単で人間が手でも書くことができる. 上のスライドには載せてあるが実際に手でうって確認することができるリポジトリがあるので興味があればそちらを使ってみてください.

github.com

Socket.IO

Socket.IOのプロトコルについては以下のリポジトリにまとまっています.

github.com

今はrevision 4でrevision 4の話をします.

Engine.IOのmessageのpayload部がSocket.IOのPacketになります.

Packetは7種類しかなく基本的にテキストベースで非常にシンプルなプロトコルであることがわかります.

  • CONNECT
  • DISCONNECT
  • EVENT
  • ACK
  • ERROR
  • BINARY_EVENT
  • BINARY_ACK

です. 一応バイナリも対応しています.

上のリポジトリだけ見るとどういった形式で格納されているかは触れられていないので実際に使われているパーサーの実装を読みましょう.

github.com

実装を見てみると以下の形式であることがわかります.

<packet type id>[(attachments)-][(namespace:/),][id][data]

シンプルですがEngine.IOと比べるとnamespaceなどの概念が追加されています.

実際に流れるのが以下のようなテキスト列になります.

先頭の記号は通信の向きなので実際には送られないものです. // 以降はコメントで e:はEngine.IOのpacket type, s:はSocket.IOのpacket typeになっています.

> 0 // e:open
> 40 // e:message s:connect
< 42[“message”,”hello”] // e:message s:event data > 42[“reply”,”?hello”] // e:message s:event data
< 41 // e:message s:disconnect
< 42[“message”,”hello”] // e:message s:event data < 40 // e:message s:connect
< 42[“message”,”hello”] // e:message s:event data > 42[“reply”,”?hello”] // e:message s:event data

こういったテキストを送れる様になるだけでSocket.IOをしゃべることができます.

これをgoで実装したライブラリが以下です.

github.com

基本的にはこういう感じでパケットを組み立てるだけのライブラリなのですが, Engine.IOのレイヤーでping/pongをしてくれます.

このライブラリから普通にNode.jsのSocket.IOとも話すことができます.

実はこのライブラリ, 途中で力尽きており完全に仕様を網羅しているわけではないのでご了承いただければと思います.

終わりに

当初の負荷試験がしたいという目的は忘れてYak shavingに励んでいました.

でも楽しかったので問題なしです.

go-swaggerを速くした話

orisano.hatenablog.com

orisano.hatenablog.com

github.com

go-swaggerのgenerate serverを速くした話をします.

上の2つの記事であげたPRでgo-swaggerのコード生成を速くしました.

上の2つの記事ではどこが遅いか明らかになった状態からのことを書きましたが, この記事ではどうやって明らかにしたかを書きます.

と言っても非常に簡単でpprofを使っただけです.

runtime/pprof

net/http/pprof

今回はpprofを直接使ったわけではなく,

github.com

という非常に簡単に使えるライブラリ経由で使用しました. 本当にCLIなどの計測の際はこれで十分な気がします.

defer profile.Start().Stop()

上のようなコードをgo-swaggerのmain関数に追加してbuild, 実行しました. go tool pprof -svg [出力されたファイル] とすると以下のような画像が出力されます.

f:id:orisano:20181204032618p:plain

このグラフを見ると全体像から重い場所がわかると思います. こういった形で簡単に計測, 改善場所の発見ができるはずです.

この過程で, Analyzedが重いことと無駄にregexp.MustCompileを呼んでいることが発見できました.

また, これとは違う見方でFlameGraphというものがあります. これもpprofのファイルから作れる

github.com

というものがあるので分析するときの助けになると思います.

これはCLIを簡単に計測するときは github.com/pkg/profile でいいと思いますが,

サーバーアプリケーションやデーモンの場合はユーザがみたいタイミングで外部からpprofできるので net/http/pprof を使ったほうが良いです.

しかし問題は得てして観測していないときに起こったりするので定期的にpprofを取りたいというときは

github.com

という選択肢もありかも知れません.

いずれにせよ本番環境で有効にする場合はインターネットからつながらないように気をつけてください.

まとめ

  • CLIの高速化のときは github.com/pkg/profileを使うと便利
  • サーバーアプリケーションやデーモンの場合は net/http/pprof を使うと便利
  • go tool pprof -svg で全体感を把握できる.
  • go-torch で重い処理を深掘りしていける.
  • 今回の取り組みと工夫で swagger generate serverが11秒から3秒になった.