薄いブログ

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

ちゃんと理解するdocker build cache

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

TL;DR

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

前提

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

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

docker buildにおけるcache

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

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

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

2019/4/25 追記: ドキュメントに大体同じようなことが書いてありました.

docs.docker.com

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のコマンドを作って実行しています. その先でrunDaemonを呼び出しています.

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を書きましょう