ちゃんと理解する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 追記: ドキュメントに大体同じようなことが書いてありました.
の冒頭でも説明したのですが, 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でdaemonとdaemon.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を書きましょう