薄いブログ

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

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していただけると励みになります.