薄いブログ

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

CIにおけるMulti-stage Buildsのcache

CIの時間を短くする活動を行っており, 特にその一部のアプリケーションコンテナの継続的 docker buildの改善について書きたいと思います.

CIにおけるMulti-stage Builds

Multi-stage Buildsについては以下の記事を参照すると良いと思います.

docs.docker.com

Multi-stage Buildsは最終イメージサイズを簡単に小さくするために導入された機能です.

イメージサイズを小さくするだけではなく様々な活用方法が生み出されました.

medium.com

speakerdeck.com

今では効率的なdocker buildにはMulti-stage Buildsは欠かせないものになっています.

Multi-stage Builds以前のCI環境では1 Dockerfile 1 Cacheの考え方で問題なかったのですが, Multi-stage Builds環境では問題が起こります.

ローカルで実行する分には何も問題は起きませんがdockerdが毎度変わる状況, つまりCI環境やDocker In Docker(dind)などの状況においてです.

最終イメージの中にbuild processのすべてが含まれていれば1 Dockerfile 1 Cache, つまり単純な--cache-fromを指定するだけで良いです. しかし複数ステージにまたがる場合は最終イメージに含まれない部分はcacheできず, Multi-Stage Buildsの意義的には最終イメージは小さくなりcacheするべき情報は殆ど含まれていないはずです. つまり意味のないcacheになってしまいます.

cacheしたいステージを明示的に--targetで指定してbuildされたイメージを外部に記憶してもらう必要があります.

いろんなやり方がありますがdocker saveした結果をobject storageにuploadしたり, registryにtagをつけてpushしてdocker registryをobject storageの代わりに使うものがあります.

object storageを新しく用意しなくて良い分, stageごとにtagをつけてregistryにpushする手法が始めやすくて良いと思っています.

しかしstageが増えれば増えるほどbuild processが変わったり, cache-fromに指定するものが増えたりするので人間がやるのが厳しくなってしまいます.

そこを簡単にするためのツールをPoC的に作ってみました.

github.com/orisano/castage

github.com

これはDockerfileを入力として与えるとstageをtagをつけながらbuildしてpushまでするshell scriptを生成します.

これによりCI環境下においても適切にcacheできる様になります. build processにbuild用のスクリプトを生成する部分を作ってしまえばあとは好きにstageを追加することができます. Dockerfileの改善活動を行っているとstageを追加したりすることが頻繁にあり, そのたびにpipelineを書き換えるのは非常に億劫です. pipelineを書き換えるのが億劫なのでDockerfileの改善活動のハードルになるのはおかしいので今回作成しました.

ぜひ使ってみて良いと思ったらGitHubでstarしていただけると嬉しいです. また使ってみてのフィードバックやPRは大歓迎です.

今回書いた内容は以下のスライドにも書いてあるのでぜひ読んで見てください.

speakerdeck.com

GoでASTと戯れる

GoでAST(Abstract Syntax Tree)を使ってソースコードを解析, 生成を行うに際してライブラリを作ったりしたので紹介したいと思います.

AST

Goは標準で go/ast でASTを扱うことができます.

便利なものもありますし詳しく記述されているのでgo/astのgodocを見るのが一番良いとは思うのですが,

とりあえず静的解析がしたい場合には

akito0107.hatenablog.com

http://goast.yuroyoro.net/

のようなツールを使って実際のコードがどういったASTになるのかを知りながら進めるのが良いかなと思います.

はじめてのAST

はじめに僕が作ったASTを使うツールは WithContextという終わり方をしていて第一引数がcontext.Contextの関数に常時context.Background()を渡す関数を生成するものでした.

github.com

Goを始めて2ヶ月くらいでAPI Clientを作っているときでした. その当時の僕はcontext.Contextを何も理解しておらず, とりあえずcontext.Backgroud()を渡しておけば良いと思っていました. しかし, どこかで第一引数はcontext.Contextを渡すようにするといいという情報を目にして思考停止して第一引数はcontext.Contextにしていました. API Clientで使用するエンドポイントが増えるたびに, リファクタで変数が変わるたびに人間が修正するのは正気じゃないと思ったのがきっかけでした. その程度の理解度の人間でも扱えるので, 非常に良くできていると思います.

実際に動作させると以下の様になります.

$ go get github.com/orisano/nocontext
$ cat main.go
package main

import "context"

func main() {}

func GetFooWithContext(ctx context.Context, a, b int) {

}

上のようなスカスカの関数があるだけのファイルを入力として与えると

$ nocontext -f main.go
func GetFoo(a, b int) {
    GetFooWithContext(context.Background(), a, b)
}

という風な関数を出力してくれます. 当時はgo generateなどをほとんど意識していなかったので全然使えません.

このくらいならsedでできるみたいな声が聞こえて来そうですが, 意外と引数の型やreturn文のパターン, 引数の一覧を展開するなどを考えるとASTで処理するのが最も良い様に思えます.

StructからInterfaceを生成したい

外部ライブラリでstructで実装が提供される場合があり, リファクタやテストのためのmockingためにinterfaceにしなければいけないことがありました. インターフェースは小さくせよとプログラミング言語Goには書いてありますが, 人間は怠惰なもので大きなinterfaceを作ってしまいます. すべてのメソッドをinterfaceにするのはなかなかに骨の折れる作業です. 複数の外部ライブラリの実装を共通で扱えるようにするためのinterface化の作業は辛いです.

上で挙げた問題を解決するためにツールを作成しました.

https://github.com/orisano/impast#interfacer

$ go get -u github.com/orisano/impast/cmd/interfacer
$ interfacer -out HTTPClient net/http.Client
type HTTPClient interface {
    Do(req *http.Request) (*http.Response, error)
    Get(url string) (resp *http.Response, err error)
    Head(url string) (resp *http.Response, err error)
    Post(url, contentType string, body io.Reader) (resp *http.Response, err error)
    PostForm(url string, data url.Values) (resp *http.Response, err error)
}
$ interfacer -out Conn database/sql.DB database/sql.Tx
type Conn interface {
    Exec(query string, args ...interface{}) (sql.Result, error)
    ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
    Prepare(query string) (*sql.Stmt, error)
    PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
    Query(query string, args ...interface{}) (*sql.Rows, error)
    QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
    QueryRow(query string, args ...interface{}) *sql.Row
    QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

一つでも複数でも共通のメソッドを求めることができます. 構造体の埋込に関しても対応しています. -pkgを指定するとコンパイル可能な形で出力されるのでgo generateからで使用することができます.

InterfaceからMockが作りたい

github.com

recruit-tech.co.jp

ではソースコード生成部は文字列連結のような形で実現しましたが, やはりいろんなケースに対応するのは難しいと感じたので完全にASTでやりたいという気持ちがありました.

これはこれで純粋に自分でMock生成部を実装したいと思ったので実装しました.

https://github.com/orisano/impast#mocker

$ go get -u github.com/orisano/impast/cmd/mocker
$ mocker -pkg io -type ReadWriter
type ReadWriterMock struct {
    ReadMock    func(p []byte) (n int, err error)
    WriteMock   func(p []byte) (n int, err error)
}

func (mo *ReadWriterMock) Read(p []byte) (n int, err error) {
    return mo.ReadMock(p)
}

func (mo *ReadWriterMock) Write(p []byte) (n int, err error) {
    return mo.WriteMock(p)
}

というようなフィールドをベースとしたMockの実装を生成してくれます. 引数の名前が指定されている場合はそちらを優先し,されていない場合は連番でつけます.

InterfaceからStubを作りたい

GoLandではcmd+Nでinterfaceのstubの実装が生成できます. IDEから独立した形でこの機能を提供したいと思ったので実装しました.

www.jetbrains.com

のCode generationにある機能です.

https://github.com/orisano/impast#stuber

$ go get -u github.com/orisano/impast/cmd/stuber
$ stuber -pkg net -implement Conn -export -name c -type "*MyConn"
func (c *MyConn) Read(b []byte) (n int, err error) {
    panic("implement me")
}

func (c *MyConn) Write(b []byte) (n int, err error) {
    panic("implement me")
}

func (c *MyConn) Close() error {
    panic("implement me")
}

func (c *MyConn) LocalAddr() net.Addr {
    panic("implement me")
}

func (c *MyConn) RemoteAddr() net.Addr {
    panic("implement me")
}

func (c *MyConn) SetDeadline(t time.Time) error {
    panic("implement me")
}

func (c *MyConn) SetReadDeadline(t time.Time) error {
    panic("implement me")
}

func (c *MyConn) SetWriteDeadline(t time.Time) error {
    panic("implement me")
}

必要なメソッドの中身をpanic("implement me")で生成します. すでにあるメソッドを考慮してくれたりはしません.

まとめ

  • structからinterfaceを生成するツール
  • interfaceからmockを生成するツール
  • interfaceからstubを生成するツール

この3つのツールがあればinterfaceから定義するスタイルでも, 実装からするスタイルでもストレスのない開発が実現できると思います.

あとお気づきの方もいるとは思いますが, 上のツール群は以下のライブラリのコマンドとして実装されています.

github.com

impastは外部ライブラリのASTを読みたいと思ったときに簡単には実現できなかったことから作成したライブラリです. go/buildImportを使ってディレクトリだけ求めてparser.ParseDirを使用して実現しています.

importする機能だけでなく, 上の3つのツールを作る上で必要な*ast.Packageに関するユーティリティ関数が提供されていたりします.

上記のツールでもいいですし, impastそのものでもいいのでぜひ使ってみてフィードバックをいただけると嬉しいです.

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

Docker Imageを小さくするために

CIの時間を短くする活動を行っており, 特にその一部のアプリケーションコンテナの継続的 docker buildの改善について書きたいと思います.

Docker Imageを小さくするために

CIにおけるdocker build改善活動の一環でなぜdocker imageを小さくするのか. それはシンプルでdocker pull / pushのコストを減らすためです.

CIにおけるdocker buildでは特にcache-fromのためにpullすることが結構あります. pushする機会は当たり前のようにあります.

では実際にどうやって小さくしていくか

よくインターネット上で見られる方法として

  • baseイメージを小さいものにする
    • Alpine Linux
      • Alpine Linuxベースのイメージは割とスタンダードになってきていますが, glibcではなくmuslなのでglibcでしか動かないソフトウェアやmuslが嫌いな人間は使わない感じになっています.
    • Distroless
      • こっちはglibcなどをgoogleがメンテナンスしてくれる軽量の実行環境のみが存在するイメージです. 基本的にタグは打たれずlatestのみのような運用がなされています. 思想に共感できたり, glibcを使いたい場合などに使う感じです.
  • apk --no-cacheをつけたり/tmpを最後に消すようにしたり, apk add --virtual fooを使ってコマンドの最後に削除したりする
  • Multi-stage Buildsを使う
  • レイヤーの数を少なくする

などがありました. でもなんか何故小さくなるか書かれたものは少なかったです.

baseイメージを小さくするというのは簡単にできる解決策ですがそれ以上工夫できる余地はありません. これは基本的にやることになります.

apk --no-cacheなどは環境ごとのパッケージマネージャーのキャッシュをオフにするオプションは毎回調べるなり覚えることになります. こういうオプションがないときってどうするんですかね?

Multi-stage Buildsを使う. Multi-stage Buildsは意外と知名度が低い問題があって悲しいですが, これを使うことで成果物だけ別のイメージにCOPYすることができます. これは最終イメージを小さくすることにかなり寄与するんですが, 上に乗るアプリケーションによって使いづらかったりします. シングルバイナリになるGoなどは相性が良いですが. しかしこれもdocker build cacheの観点で見ると最終ステージ以外も小さくしないといけないので本質な問題は解決しません.

レイヤーの数を少なくする. これは少し前まで結構見た言説ですがこれは本当なんでしょうか.

Docker Imageが大きくなる理由の調査のためにdocker historyを使って説明しているものがありました. RUNを2つ書いたものとRUNを1つにまとめたもののdocker historyの結果を見比べて小さくなっていますねみたいなものや,パッケージのinstallをしている部分が大きくなっているのでcacheが作られてそうというところからオプションをつけるみたいな事例がありました.

あまりDocker Imageのサイズに興味がない人が多いし, そんなにメリットもないので原因を突き詰めないのは仕方のないことかもしれません.

それはそうとdocker historyは非常に有用なツールですがどのレイヤーが重そうかのあたりをつけるくらいにしか使えません.

そもそもDocker Imageがどの様に保持されているかを知っているとなぜ重いかの理解が捗ります.

moby/v1.md at master · moby/moby · GitHub

上のリンクの中身を見ればわかりますが, Docker Imageは差分により構成されていることがわかります. イメージとして以下のような感じになります.

http://docs.docker.jp/engine/userguide/storagedriver/aufs-driver.html#deleting-files-with-the-aufs-storage-driver

つまりどこの差分がなぜ重くなっているか知ることでより本質的な改善が行なえます.

自分がそこで困っていたのでそのためのツールを作りました.

github.com

レイヤーごとに差分のサイズとファイル名が出力されるだけのツールですがDocker Imageを小さくする際に力を発揮します. 不明なcacheファイルが残っていることや, 不要なツール, 重複して作られているファイルの存在の認識ができます.

実際にこれでGoの公式イメージサイズを小さくすることに成功したりしました.

ちゃんと改善活動をするときは原因特定ができる状況を作ってからにしましょう.

ぜひdlayerを使ってDocker Imageを小さくしてみてください!

余談

このツールが出た少しあとに以下のツールが爆発的に盛り上がりました.

github.com

非常に見やすく上位互換って感じがしますね...

ちゃんと理解するdocker build cache

CIの時間を短くする活動を行っており, 特にその一部のアプリケーションコンテナの継続的 docker buildの改善について書きたいと思います.

前提

  • アプリケーションはすべてコンテナ化されている
  • CI上でコンテナを作っており, コミットされるたびにその状態のイメージを作成している
  • CIもコンテナベースのものを使っている
  • CI上でのテストは生成されたイメージで行っている

docker buildの速度は開発の速度に影響しているので改善活動を行っています.

docker buildにおけるcache

docker buildを速くしようと思ったときに取り組むのはcacheの有効活用だと思います.

docker buildにはcacheの機構があります. これの仕組みをちゃんと理解することがdocker build改善の第一歩です.

cacheの挙動についてドキュメントを探したのですが, 見つからなかったのでソースコードベースで調べます.

orisano.hatenablog.com

の冒頭でも説明したのですが, dockerはclientとdaemon側で別れておりbuildそのものの処理を行うのはdaemon側なのでそちらのコードを見ます.

clientはRESTでdaemonと通信するのでその点を理解しているとソースコードを読む際の助けになります.

moby/build.go at master · moby/moby · GitHub

func (r *buildRouter) initRoutes() {
    r.routes = []router.Route{
        router.NewPostRoute("/build", r.postBuild),
        router.NewPostRoute("/build/prune", r.postPrune),
        router.NewPostRoute("/build/cancel", r.postCancel),
    }
}

ここで/buildにPOSTが来たときにdaemon側でbuildの処理が走る様に登録しています.

https://github.com/moby/moby/blob/master/api/server/router/build/build_routes.go#L205-L285

リクエストのハンドリングするコードが書いてあります. 今回はbuildの挙動が追いたいだけなので以下の部分だけで良いです.

   imgID, err := br.backend.Build(ctx, backend.BuildConfig{
        Source:         body,
        Options:        buildOptions,
        ProgressWriter: buildProgressWriter(out, wantAux, createProgressReader),
    })

br.backendはInterfaceになっており内部を知るために, dockerdのentrypointから追います.

moby/docker.go at master · moby/moby · GitHub

ここにmain関数があります. ここからcobraのコマンドを作って実行しています. その先でrunDeamonを呼び出しています.

moby/docker_unix.go at master · moby/moby · GitHub

これも結局はdaemonCli.startを呼び出しているだけです. daemonCli.startが本体なのでそこを読んでいきましょう.

https://github.com/moby/moby/blob/master/cmd/dockerd/daemon.go#L74-L253

以下の部分でinitRouterが実行されており, build.NewRouterが呼ばれています.

https://github.com/moby/moby/blob/master/cmd/dockerd/daemon.go#L223

build.NewRouterの引数のbackendにわたす引数を作っている部分が以下です.

moby/daemon.go at master · moby/moby · GitHub

https://github.com/moby/moby/blob/master/cmd/dockerd/daemon.go#L266-L317

func newRouterOptions(config *config.Config, d *daemon.Daemon) (routerOptions, error) {
    opts := routerOptions{}
    sm, err := session.NewManager()
    if err != nil {
        return opts, errors.Wrap(err, "failed to create sessionmanager")
    }

    builderStateDir := filepath.Join(config.Root, "builder")

    buildCache, err := fscache.NewFSCache(fscache.Opt{
        Backend: fscache.NewNaiveCacheBackend(builderStateDir),
        Root:    builderStateDir,
        GCPolicy: fscache.GCPolicy{ // TODO: expose this in config
            MaxSize:         1024 * 1024 * 512,  // 512MB
            MaxKeepDuration: 7 * 24 * time.Hour, // 1 week
        },
    })
    if err != nil {
        return opts, errors.Wrap(err, "failed to create fscache")
    }

    manager, err := dockerfile.NewBuildManager(d.BuilderBackend(), sm, buildCache, d.IdentityMapping())
    if err != nil {
        return opts, err
    }
    cgroupParent := newCgroupParent(config)
    bk, err := buildkit.New(buildkit.Opt{
        SessionManager:      sm,
        Root:                filepath.Join(config.Root, "buildkit"),
        Dist:                d.DistributionServices(),
        NetworkController:   d.NetworkController(),
        DefaultCgroupParent: cgroupParent,
        ResolverOpt:         d.NewResolveOptionsFunc(),
        BuilderConfig:       config.Builder,
    })
    if err != nil {
        return opts, err
    }

    bb, err := buildbackend.NewBackend(d.ImageService(), manager, buildCache, bk)
    if err != nil {
        return opts, errors.Wrap(err, "failed to create buildmanager")
    }
    return routerOptions{
        sessionManager: sm,
        buildBackend:   bb,
        buildCache:     buildCache,
        buildkit:       bk,
        features:       d.Features(),
        daemon:         d,
    }, nil
}

buildbackend.NewBackendで返すものがbr.backendであることがわかります.

つまり以下のBackendが実装を持った構造体ということになります.

moby/backend.go at master · moby/moby · GitHub

ここではbuildkitとbuilderで抽象化されています. buildkitは新しいbuilderで, DOCKER_BUILDKIT=1などを明示的につけないと使用されないので今回は無視します.

builderに渡されているのは dockerfile.NewBuildManagerの返り値なのでそこを参照すれば良いです.

https://github.com/moby/moby/blob/master/builder/dockerfile/builder.go#L66-L79

// NewBuildManager creates a BuildManager
func NewBuildManager(b builder.Backend, sg SessionGetter, fsCache *fscache.FSCache, identityMapping *idtools.IdentityMapping) (*BuildManager, error) {
    bm := &BuildManager{
        backend:   b,
        pathCache: &syncmap.Map{},
        sg:        sg,
        idMapping: identityMapping,
        fsCache:   fsCache,
    }
    if err := fsCache.RegisterTransport(remotecontext.ClientSessionRemote, NewClientSessionTransport()); err != nil {
        return nil, err
    }
    return bm, nil
}

dockerfile.BuildManagerが実体で有ることがわかりました. Buildの実装は以下の様になっています.

https://github.com/moby/moby/blob/master/builder/dockerfile/builder.go#L81-L121

// Build starts a new build from a BuildConfig
func (bm *BuildManager) Build(ctx context.Context, config backend.BuildConfig) (*builder.Result, error) {
    buildsTriggered.Inc()
    if config.Options.Dockerfile == "" {
        config.Options.Dockerfile = builder.DefaultDockerfileName
    }

    source, dockerfile, err := remotecontext.Detect(config)
    if err != nil {
        return nil, err
    }
    defer func() {
        if source != nil {
            if err := source.Close(); err != nil {
                logrus.Debugf("[BUILDER] failed to remove temporary context: %v", err)
            }
        }
    }()

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    if src, err := bm.initializeClientSession(ctx, cancel, config.Options); err != nil {
        return nil, err
    } else if src != nil {
        source = src
    }

    builderOptions := builderOptions{
        Options:        config.Options,
        ProgressWriter: config.ProgressWriter,
        Backend:        bm.backend,
        PathCache:      bm.pathCache,
        IDMapping:      bm.idMapping,
    }
    b, err := newBuilder(ctx, builderOptions)
    if err != nil {
        return nil, err
    }
    return b.build(source, dockerfile)
}

またもや抽象化されています. newBuilderによってdockerfile.Builderが作られ, それのbuildが呼ばれています.

https://github.com/moby/moby/blob/master/builder/dockerfile/builder.go#L240-L274

// Build runs the Dockerfile builder by parsing the Dockerfile and executing
// the instructions from the file.
func (b *Builder) build(source builder.Source, dockerfile *parser.Result) (*builder.Result, error) {
    defer b.imageSources.Unmount()

    stages, metaArgs, err := instructions.Parse(dockerfile.AST)
    if err != nil {
        if instructions.IsUnknownInstruction(err) {
            buildsFailed.WithValues(metricsUnknownInstructionError).Inc()
        }
        return nil, errdefs.InvalidParameter(err)
    }
    if b.options.Target != "" {
        targetIx, found := instructions.HasStage(stages, b.options.Target)
        if !found {
            buildsFailed.WithValues(metricsBuildTargetNotReachableError).Inc()
            return nil, errdefs.InvalidParameter(errors.Errorf("failed to reach build target %s in Dockerfile", b.options.Target))
        }
        stages = stages[:targetIx+1]
    }

    // Add 'LABEL' command specified by '--label' option to the last stage
    buildLabelOptions(b.options.Labels, stages)

    dockerfile.PrintWarnings(b.Stderr)
    dispatchState, err := b.dispatchDockerfileWithCancellation(stages, metaArgs, dockerfile.EscapeToken, source)
    if err != nil {
        return nil, err
    }
    if dispatchState.imageID == "" {
        buildsFailed.WithValues(metricsDockerfileEmptyError).Inc()
        return nil, errors.New("No image was generated. Is your Dockerfile empty?")
    }
    return &builder.Result{ImageID: dispatchState.imageID, FromImage: dispatchState.baseImage}, nil
}

ようやく実装っぽいところが見えてきました.

まずDockerfileのASTから解釈可能な命令列にしています. そのあとにtarget optionの処理をします. ここの実装を見れば分かる通り, targetの前段のステージはすべてbuildされてしまいます.

解釈可能な命令列を扱うのはdispatchDockerfileWithCancellationです.

https://github.com/moby/moby/blob/master/builder/dockerfile/builder.go#L302-L364

func (b *Builder) dispatchDockerfileWithCancellation(parseResult []instructions.Stage, metaArgs []instructions.ArgCommand, escapeToken rune, source builder.Source) (*dispatchState, error) {
    dispatchRequest := dispatchRequest{}
    buildArgs := NewBuildArgs(b.options.BuildArgs)
    totalCommands := len(metaArgs) + len(parseResult)
    currentCommandIndex := 1
    for _, stage := range parseResult {
        totalCommands += len(stage.Commands)
    }
    shlex := shell.NewLex(escapeToken)
    for _, meta := range metaArgs {
        currentCommandIndex = printCommand(b.Stdout, currentCommandIndex, totalCommands, &meta)

        err := processMetaArg(meta, shlex, buildArgs)
        if err != nil {
            return nil, err
        }
    }

    stagesResults := newStagesBuildResults()

    for _, stage := range parseResult {
        if err := stagesResults.checkStageNameAvailable(stage.Name); err != nil {
            return nil, err
        }
        dispatchRequest = newDispatchRequest(b, escapeToken, source, buildArgs, stagesResults)

        currentCommandIndex = printCommand(b.Stdout, currentCommandIndex, totalCommands, stage.SourceCode)
        if err := initializeStage(dispatchRequest, &stage); err != nil {
            return nil, err
        }
        dispatchRequest.state.updateRunConfig()
        fmt.Fprintf(b.Stdout, " ---> %s\n", stringid.TruncateID(dispatchRequest.state.imageID))
        for _, cmd := range stage.Commands {
            select {
            case <-b.clientCtx.Done():
                logrus.Debug("Builder: build cancelled!")
                fmt.Fprint(b.Stdout, "Build cancelled\n")
                buildsFailed.WithValues(metricsBuildCanceled).Inc()
                return nil, errors.New("Build cancelled")
            default:
                // Not cancelled yet, keep going...
            }

            currentCommandIndex = printCommand(b.Stdout, currentCommandIndex, totalCommands, cmd)

            if err := dispatch(dispatchRequest, cmd); err != nil {
                return nil, err
            }
            dispatchRequest.state.updateRunConfig()
            fmt.Fprintf(b.Stdout, " ---> %s\n", stringid.TruncateID(dispatchRequest.state.imageID))

        }
        if err := emitImageID(b.Aux, dispatchRequest.state); err != nil {
            return nil, err
        }
        buildArgs.MergeReferencedArgs(dispatchRequest.state.buildArgs)
        if err := commitStage(dispatchRequest.state, stagesResults); err != nil {
            return nil, err
        }
    }
    buildArgs.WarnOnUnusedBuildArgs(b.Stdout)
    return dispatchRequest.state, nil
}

最初にprogressを出すために全体のコマンド数を計算したりしています.

stageを一つ一つ処理していきます.

stageの名前が不正なものではないかの確認をします. と言っても現状では同じ名前のステージがないことを確認しているだけです.

その次にinitializeStageでFROMの処理を行います. 毎回dispatchRequest.state.updateRunConfigで現在のImageIDを更新します.

そしてstageのコマンド一つ一つ処理します. dispatchがコマンドそのものの処理になります.

https://github.com/moby/moby/blob/master/builder/dockerfile/evaluator.go#L38-L104

func dispatch(d dispatchRequest, cmd instructions.Command) (err error) {
    if c, ok := cmd.(instructions.PlatformSpecific); ok {
        err := c.CheckPlatform(d.state.operatingSystem)
        if err != nil {
            return errdefs.InvalidParameter(err)
        }
    }
    runConfigEnv := d.state.runConfig.Env
    envs := append(runConfigEnv, d.state.buildArgs.FilterAllowed(runConfigEnv)...)

    if ex, ok := cmd.(instructions.SupportsSingleWordExpansion); ok {
        err := ex.Expand(func(word string) (string, error) {
            return d.shlex.ProcessWord(word, envs)
        })
        if err != nil {
            return errdefs.InvalidParameter(err)
        }
    }

    defer func() {
        if d.builder.options.ForceRemove {
            d.builder.containerManager.RemoveAll(d.builder.Stdout)
            return
        }
        if d.builder.options.Remove && err == nil {
            d.builder.containerManager.RemoveAll(d.builder.Stdout)
            return
        }
    }()
    switch c := cmd.(type) {
    case *instructions.EnvCommand:
        return dispatchEnv(d, c)
    case *instructions.MaintainerCommand:
        return dispatchMaintainer(d, c)
    case *instructions.LabelCommand:
        return dispatchLabel(d, c)
    case *instructions.AddCommand:
        return dispatchAdd(d, c)
    case *instructions.CopyCommand:
        return dispatchCopy(d, c)
    case *instructions.OnbuildCommand:
        return dispatchOnbuild(d, c)
    case *instructions.WorkdirCommand:
        return dispatchWorkdir(d, c)
    case *instructions.RunCommand:
        return dispatchRun(d, c)
    case *instructions.CmdCommand:
        return dispatchCmd(d, c)
    case *instructions.HealthCheckCommand:
        return dispatchHealthcheck(d, c)
    case *instructions.EntrypointCommand:
        return dispatchEntrypoint(d, c)
    case *instructions.ExposeCommand:
        return dispatchExpose(d, c, envs)
    case *instructions.UserCommand:
        return dispatchUser(d, c)
    case *instructions.VolumeCommand:
        return dispatchVolume(d, c)
    case *instructions.StopSignalCommand:
        return dispatchStopSignal(d, c)
    case *instructions.ArgCommand:
        return dispatchArg(d, c)
    case *instructions.ShellCommand:
        return dispatchShell(d, c)
    }
    return errors.Errorf("unsupported command type: %v", reflect.TypeOf(cmd))
}

内部でコマンドごとに呼ぶ関数を変えています. docker buildのcacheは主にRUNに対するものなのでdispatchRunの実装を見ましょう.

https://github.com/moby/moby/blob/master/builder/dockerfile/dispatchers.go#L341-L403

// RUN some command yo
//
// run a command and commit the image. Args are automatically prepended with
// the current SHELL which defaults to 'sh -c' under linux or 'cmd /S /C' under
// Windows, in the event there is only one argument The difference in processing:
//
// RUN echo hi          # sh -c echo hi       (Linux and LCOW)
// RUN echo hi          # cmd /S /C echo hi   (Windows)
// RUN [ "echo", "hi" ] # echo hi
//
func dispatchRun(d dispatchRequest, c *instructions.RunCommand) error {
    if !system.IsOSSupported(d.state.operatingSystem) {
        return system.ErrNotSupportedOperatingSystem
    }
    stateRunConfig := d.state.runConfig
    cmdFromArgs := resolveCmdLine(c.ShellDependantCmdLine, stateRunConfig, d.state.operatingSystem)
    buildArgs := d.state.buildArgs.FilterAllowed(stateRunConfig.Env)

    saveCmd := cmdFromArgs
    if len(buildArgs) > 0 {
        saveCmd = prependEnvOnCmd(d.state.buildArgs, buildArgs, cmdFromArgs)
    }

    runConfigForCacheProbe := copyRunConfig(stateRunConfig,
        withCmd(saveCmd),
        withEntrypointOverride(saveCmd, nil))
    if hit, err := d.builder.probeCache(d.state, runConfigForCacheProbe); err != nil || hit {
        return err
    }

    runConfig := copyRunConfig(stateRunConfig,
        withCmd(cmdFromArgs),
        withEnv(append(stateRunConfig.Env, buildArgs...)),
        withEntrypointOverride(saveCmd, strslice.StrSlice{""}),
        withoutHealthcheck())

    // set config as already being escaped, this prevents double escaping on windows
    runConfig.ArgsEscaped = true

    cID, err := d.builder.create(runConfig)
    if err != nil {
        return err
    }

    if err := d.builder.containerManager.Run(d.builder.clientCtx, cID, d.builder.Stdout, d.builder.Stderr); err != nil {
        if err, ok := err.(*statusCodeError); ok {
            // TODO: change error type, because jsonmessage.JSONError assumes HTTP
            msg := fmt.Sprintf(
                "The command '%s' returned a non-zero code: %d",
                strings.Join(runConfig.Cmd, " "), err.StatusCode())
            if err.Error() != "" {
                msg = fmt.Sprintf("%s: %s", msg, err.Error())
            }
            return &jsonmessage.JSONError{
                Message: msg,
                Code:    err.StatusCode(),
            }
        }
        return err
    }

    return d.builder.commitContainer(d.state, cID, runConfigForCacheProbe)
}

ようやくcacheに関係してそうなところが出てきました. builder.probeCacheがキャッシュが有効かどうかを判定する関数のようです.

builderはdispatchRequestを作ったBuilderのインスタンスです.

https://github.com/moby/moby/blob/master/builder/dockerfile/internals.go#L413-L422

func (b *Builder) probeCache(dispatchState *dispatchState, runConfig *container.Config) (bool, error) {
    cachedID, err := b.imageProber.Probe(dispatchState.imageID, runConfig)
    if cachedID == "" || err != nil {
        return false, err
    }
    fmt.Fprint(b.Stdout, " ---> Using cache\n")

    dispatchState.imageID = cachedID
    return true, nil
}

処理はimageProberに委譲されています. imageProberは例によって抽象化されています.

実体はnewImageProberによって作られたimageProberになります.

moby/imageprobe.go at master · moby/moby · GitHub

// ImageProber exposes an Image cache to the Builder. It supports resetting a
// cache.
type ImageProber interface {
    Reset()
    Probe(parentID string, runConfig *container.Config) (string, error)
}

type imageProber struct {
    cache       builder.ImageCache
    reset       func() builder.ImageCache
    cacheBusted bool
}

func newImageProber(cacheBuilder builder.ImageCacheBuilder, cacheFrom []string, noCache bool) ImageProber {
    if noCache {
        return &nopProber{}
    }

    reset := func() builder.ImageCache {
        return cacheBuilder.MakeImageCache(cacheFrom)
    }
    return &imageProber{cache: reset(), reset: reset}
}

func (c *imageProber) Reset() {
    c.cache = c.reset()
    c.cacheBusted = false
}

// Probe checks if cache match can be found for current build instruction.
// It returns the cachedID if there is a hit, and the empty string on miss
func (c *imageProber) Probe(parentID string, runConfig *container.Config) (string, error) {
    if c.cacheBusted {
        return "", nil
    }
    cacheID, err := c.cache.GetCache(parentID, runConfig)
    if err != nil {
        return "", err
    }
    if len(cacheID) == 0 {
        logrus.Debugf("[BUILDER] Cache miss: %s", runConfig.Cmd)
        c.cacheBusted = true
        return "", nil
    }
    logrus.Debugf("[BUILDER] Use cached version: %s", runConfig.Cmd)
    return cacheID, nil
}

cacheBustedな状態になったらcacheせず, cacheが一度でも見つからなかった場合にcacheBustedがtrueになることがわかります.

GetCacheが空文字列を返すようになる条件がcacheBustedになる条件になります.

imageProberが保持しているcacheはcacheBuilder.MakeImageCacheによって作られます. cacheBuilderも抽象化されています.

cacheBuilderはBuildManagerのBackendというフィールドが渡ってきます.

BuildManagerのBackendというフィールドはDaemonのBuilderBackendの返り値が格納されています.

BuilderBackendはinterfaceでdaemondaemon.imageServiceをembeddedした構造体が返っています.

MakeImageCacheが実装されているのがdaemon.imageServiceの方です. 実装が以下になります.

https://github.com/moby/moby/blob/master/daemon/images/cache.go#L9-L27

// MakeImageCache creates a stateful image cache.
func (i *ImageService) MakeImageCache(sourceRefs []string) builder.ImageCache {
    if len(sourceRefs) == 0 {
        return cache.NewLocal(i.imageStore)
    }

    cache := cache.New(i.imageStore)

    for _, ref := range sourceRefs {
        img, err := i.GetImage(ref)
        if err != nil {
            logrus.Warnf("Could not look up %s for cache resolution, skipping: %+v", ref, err)
            continue
        }
        cache.Populate(img)
    }

    return cache
}

cache-fromが設定されていれば, 一つずつGetImageしてなければ無視, あればcacheに追加します.

cache.NewLocalの実装を追いましょう.

https://github.com/moby/moby/blob/master/image/cache/cache.go#L16-L31

// NewLocal returns a local image cache, based on parent chain
func NewLocal(store image.Store) *LocalImageCache {
    return &LocalImageCache{
        store: store,
    }
}

// LocalImageCache is cache based on parent chain.
type LocalImageCache struct {
    store image.Store
}

// GetCache returns the image id found in the cache
func (lic *LocalImageCache) GetCache(imgID string, config *containertypes.Config) (string, error) {
    return getImageIDAndError(getLocalCachedImage(lic.store, image.ID(imgID), config))
}

実体はgetLocalCachedImageであることがわかります.

https://github.com/moby/moby/blob/master/image/cache/cache.go#L214-L253

// getLocalCachedImage returns the most recent created image that is a child
// of the image with imgID, that had the same config when it was
// created. nil is returned if a child cannot be found. An error is
// returned if the parent image cannot be found.
func getLocalCachedImage(imageStore image.Store, imgID image.ID, config *containertypes.Config) (*image.Image, error) {
    // Loop on the children of the given image and check the config
    getMatch := func(siblings []image.ID) (*image.Image, error) {
        var match *image.Image
        for _, id := range siblings {
            img, err := imageStore.Get(id)
            if err != nil {
                return nil, fmt.Errorf("unable to find image %q", id)
            }

            if compare(&img.ContainerConfig, config) {
                // check for the most up to date match
                if match == nil || match.Created.Before(img.Created) {
                    match = img
                }
            }
        }
        return match, nil
    }

    // In this case, this is `FROM scratch`, which isn't an actual image.
    if imgID == "" {
        images := imageStore.Map()
        var siblings []image.ID
        for id, img := range images {
            if img.Parent == imgID {
                siblings = append(siblings, id)
            }
        }
        return getMatch(siblings)
    }

    // find match from child images
    siblings := imageStore.Children(imgID)
    return getMatch(siblings)
}

imageStore自体はdaemonから渡ってきているものです. daemonで初期化される場所が以下です.

https://github.com/moby/moby/blob/master/daemon/daemon.go#L954-L968

   // TODO: imageStore, distributionMetadataStore, and ReferenceStore are only
    // used above to run migration. They could be initialized in ImageService
    // if migration is called from daemon/images. layerStore might move as well.
    d.imageService = images.NewImageService(images.ImageServiceConfig{
        ContainerStore:            d.containers,
        DistributionMetadataStore: distributionMetadataStore,
        EventsService:             d.EventsService,
        ImageStore:                imageStore,
        LayerStores:               layerStores,
        MaxConcurrentDownloads:    *config.MaxConcurrentDownloads,
        MaxConcurrentUploads:      *config.MaxConcurrentUploads,
        ReferenceStore:            rs,
        RegistryService:           registryService,
        TrustKey:                  trustKey,
    })

https://github.com/moby/moby/blob/master/daemon/daemon.go#L871-L884

   ifs, err := image.NewFSStoreBackend(filepath.Join(imageRoot, "imagedb"))
    if err != nil {
        return nil, err
    }

    lgrMap := make(map[string]image.LayerGetReleaser)
    for os, ls := range layerStores {
        lgrMap[os] = ls
    }
    imageStore, err := image.NewImageStore(ifs, lgrMap)
    if err != nil {
        return nil, err
    }

image.NewImageStoreで作ったものがimageStoreの実体です.

moby/store.go at master · moby/moby · GitHub

ImageStoreを作るとき, つまりdaemonの起動時にfilesystemを見てローカルにあるimageのメタ情報をすべてメモリ上にロードしています.

https://github.com/moby/moby/blob/master/image/spec/v1.md

docker imageのmeta情報にはparentの情報しかなく, 子の情報を直接取得することができないのでメモリ上でその関係をmapで持っています.

restoreするとき,作るとき,削除するときに適切に更新されています.

getLocalCachedImageの実装の部分に戻るのですが,ここではimageStore.GetとimageStore.Childrenが呼び出されています.

Getの実装はfs.Getでファイルシステムから読んできたmeta情報をJSONから元に戻しているだけです.

https://github.com/moby/moby/blob/master/image/store.go#L203-L223

https://github.com/moby/moby/blob/master/image/fs.go#L89-L109

Childrenは上でも説明したようにメモリ上に持っている子の情報をsliceとして返すだけの関数です.

ということでgetLocalCachedImageは親のIDから子のID一覧を取得してきて, 変更されていない最新の子を取得してくる関数のようです.

変更されていないというのがどういうことなのかはcompare関数の実装を見ればわかります.

https://github.com/moby/moby/blob/master/image/cache/compare.go#L7-#L63

// compare two Config struct. Do not compare the "Image" nor "Hostname" fields
// If OpenStdin is set, then it differs
func compare(a, b *container.Config) bool {
    if a == nil || b == nil ||
        a.OpenStdin || b.OpenStdin {
        return false
    }
    if a.AttachStdout != b.AttachStdout ||
        a.AttachStderr != b.AttachStderr ||
        a.User != b.User ||
        a.OpenStdin != b.OpenStdin ||
        a.Tty != b.Tty {
        return false
    }

    if len(a.Cmd) != len(b.Cmd) ||
        len(a.Env) != len(b.Env) ||
        len(a.Labels) != len(b.Labels) ||
        len(a.ExposedPorts) != len(b.ExposedPorts) ||
        len(a.Entrypoint) != len(b.Entrypoint) ||
        len(a.Volumes) != len(b.Volumes) {
        return false
    }

    for i := 0; i < len(a.Cmd); i++ {
        if a.Cmd[i] != b.Cmd[i] {
            return false
        }
    }
    for i := 0; i < len(a.Env); i++ {
        if a.Env[i] != b.Env[i] {
            return false
        }
    }
    for k, v := range a.Labels {
        if v != b.Labels[k] {
            return false
        }
    }
    for k := range a.ExposedPorts {
        if _, exists := b.ExposedPorts[k]; !exists {
            return false
        }
    }

    for i := 0; i < len(a.Entrypoint); i++ {
        if a.Entrypoint[i] != b.Entrypoint[i] {
            return false
        }
    }
    for key := range a.Volumes {
        if _, exists := b.Volumes[key]; !exists {
            return false
        }
    }
    return true
}

CMDやLABEL, ENV, ENTRYPOINT, EXPOSE, VOLUMEを変えてしまうとcache対象のイメージではないと認識されることがわかります.

ここでいうCMDとはCMDコマンドのことではなく, Dockerfileの1行に書いてある命令とほとんど等価だと思ってもらって構いません.

つまりRUNのcache対象のイメージは同一の親イメージの子で命令, LABEL, ENV, ENTRYPOINT, EXPOSE, VOLUMEが変更されていない最新のものということです.

どういう状況でそれらが変更されるのでしょう. そもそもレイヤーが増えるのはCOPY, ADD, RUNの場合のみです.

COPY, ADDに関しては内部の実装は大体共通化されているのでCOPYの方を見ていきましょう.

https://github.com/moby/moby/blob/master/builder/dockerfile/dispatchers.go#L110-L132

// COPY foo /path
//
// Same as 'ADD' but without the tar and remote url handling.
//
func dispatchCopy(d dispatchRequest, c *instructions.CopyCommand) error {
    var im *imageMount
    var err error
    if c.From != "" {
        im, err = d.getImageMount(c.From)
        if err != nil {
            return errors.Wrapf(err, "invalid from flag value %s", c.From)
        }
    }
    copier := copierFromDispatchRequest(d, errOnSourceDownload, im)
    defer copier.Cleanup()
    copyInstruction, err := copier.createCopyInstruction(c.SourcesAndDest, "COPY")
    if err != nil {
        return err
    }
    copyInstruction.chownStr = c.Chown

    return d.builder.performCopy(d, copyInstruction)
}

ここでやっていることはCopyInstructionを作って, performCopyを呼び出しています.

https://github.com/moby/moby/blob/master/builder/dockerfile/internals.go#L153-L171

func (b *Builder) performCopy(req dispatchRequest, inst copyInstruction) error {
    state := req.state
    srcHash := getSourceHashFromInfos(inst.infos)

    var chownComment string
    if inst.chownStr != "" {
        chownComment = fmt.Sprintf("--chown=%s", inst.chownStr)
    }
    commentStr := fmt.Sprintf("%s %s%s in %s ", inst.cmdName, chownComment, srcHash, inst.dest)

    // TODO: should this have been using origPaths instead of srcHash in the comment?
    runConfigWithCommentCmd := copyRunConfig(
        state.runConfig,
        withCmdCommentString(commentStr, state.operatingSystem))
    hit, err := b.probeCache(state, runConfigWithCommentCmd)
    if err != nil || hit {
        return err
    }

ここでgetSourceHashFromInfosでsrcHashという値を求めて, commentStrに含む様にしています.

commentStrを含むコマンドをprobeCacheに渡しています. getSourceHashInfosは転送するファイルのハッシュを計算する関数です.

転送する内容が変わるとコマンドの内容が変わります. コマンドの内容が変わるということはprobeCacheでcompareしたときに引っかからなくなるということです.

つまり転送する内容が変わるとcacheがbustedになることがわかります.

これがdocker buildのcacheの仕組みです.

まとめ

  • dockerd の内部でイメージの子についての情報を持っている
  • cache対象のイメージは, 親イメージの子の中にcompare で同一とみなされる最新のもの
    • 同一のものかどうかと言うのは命令, LABEL, ENV, ENTRYPOINT, EXPOSE, VOLUMEが変更されていないということ
  • COPY, ADDは内部的にコマンドにコメントを埋め込む. そのコメントの中にコピーするファイルのハッシュが含まれている.
    • コピーするファイルのハッシュが変わった場合に命令(に付随するコメント)が変わるのでcacheがbustedになる

ちゃんと仕組みを理解して素晴らしいDockerfileを書きましょう

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

脱線してしまいましたが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してくれると励みになります.