薄いブログ

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

なぜ cache-from を指定しなければいけないか

気になるツイートを見かけ, 僕も理由がわからなかったので調査してみました.

TL;DR

  • build 時に cache-from をつけないと dockerd がメモリ上に保持している情報からしか子イメージを取得しない
  • build では子イメージの情報をメモリ上に登録する
  • pull は子イメージの情報をメモリ上に登録しない
  • なので pull してきたイメージは cache-from をつけないとキャッシュ対象にならない

わかっていること

  • docker buildしたイメージは cache-from をつけなくても cache が効く
  • docker pullしたイメージは cache-from をつけないと cache が効かない
  • イメージが等しい条件

orisano.hatenablog.com

  • build の際にイメージが cache が使われる条件
    • 共通の親イメージを持ち, イメージが等しいなかで最も新しいもの

orisano.hatenablog.com

  • イメージが親イメージの情報のみを持つ連結リストの構造であること
    • すべてのイメージは親をたどるとscratchという特殊なイメージにたどり着くこと
    • dockerdが子イメージに伸びる辺をメモリ上に保持している

speakerdeck.com

未知のこと

  • build したイメージと pull したイメージの差分
  • cache-from した場合としなかった場合の差

ソースコードを読む

moby/moby 2019/12/3時点でのHEAD (3152f9436292115c97b4d8bb18c66cf97876ee75) を参照する. https://github.com/moby/moby/tree/3152f9436292115c97b4d8bb18c66cf97876ee75

docker/cli 2019/12/3時点でのHEAD (54d085b857e954a8fc431a9a400f11bc3308efc1) を参照する. https://github.com/docker/cli/tree/54d085b857e954a8fc431a9a400f11bc3308efc1

imageStoreについて

  • NewDaemon (docker/daemon/daemon.go) で imageService が作られる.
    • NewFSStoreBackend で FSStoreBackend を $root/image/$graphDriver/imagedb に作る
    • NewImageStore の中で restore を呼び fs にある情報から子イメージの情報を構築する
      • 親から子に辺を張る(メモリ上)

pullの実装について知る 実装

  • cliで認証, 検証, タグの解決を行う
  • 認証情報, 解決されたタグの情報を /images/create に POST する
  • postImagesCreate(import,pull共通)の処理が実行される (docker/api/server/router/image/image_routes.go)
  • ImageService.PullImage の処理が実行される (docker/daemon/images/image_pull.go)
  • ImageService.pullImageWithReference の処理が実行される (docker/daemon/images/image_pull.go)
    • config.ImageStore は distribution.NewImageConfigStoreFromStore(ImageService.imageStore)
    • config.ReferenceStore は ImageService.referenceStore
    • config.DownloadManager は ImageService.downloadManager
  • distribution.Pull が実行される (docker/distribution/pull.go)
  • newPuller によって作られた v2Puller の Pull が実行される (docker/distribution/pull_v2.go)
  • v2Puller の pullV2Repository が実行される (docker/distribution/pull_v2.go)
  • pullV2Tag が実行される (docker/distribution/pull_v2.go)
    • マニフェストを取得する
    • pullSchema2 が実行される (docker/distribution/pull_v2.go)
      • pullSchema2Layers が実行される (docker/distribution/pull_v2.go)
      • config.ImageStore に digest が存在するか確認してある場合はそちらを使う
      • pullSchema2Config で Config を取得する (docker/distribution/pull_v2.go)
      • config.DownloadManager がある場合は RootFS を Download メソッドによってダウンロードする
      • Config と Download した RootFS が等しいか検証する
      • config.ImageStore に Put する
        • ImageStore って名前だが ImageConfigStore
        • Put すると image.Store に対して Create を実行する
        • Create は子イメージの情報がない状態で追加する (docker/image/store.go)
    • config.ReferenceStore がある場合は, tag の内容が更新されたかを確認して反映する

buildの復習

  • 基本的な流れは here
  • build 時に imageStore を触るタイミングはイメージを commit するとき
    1. dispatch (docker/builder/dockerfile/evaluator.go)
    2. dispatchXXX (docker/builder/dockerfile/dispatcher.go)
    3. commit (docker/builder/internals.go)
    4. commitContainer (docker/builder/internals.go)
    5. CommitBuildStep (docker/daemon/images/image_commit.go)
    6. CommitImage (docker/daemon/images/image_commit.go)
  • container の id から取得した read write の layer の tar を読み込む
  • layerStore に登録する
  • 親イメージIDを取得する
  • 今の状態を image.Store で Create する
  • SetParent で parent という metadata ファイルに書く
  • 親がいる場合は更新する, 元の親の children から自分の id を削除する
  • 親の children に自分の id を登録する

cache-fromの挙動

  • MakeImageCache (docker/daemon/images/cache.go) で builder.ImageCache が作られる
  • cache-from が指定されていない場合は getLocalCachedImage を使う (docker/image/cache/cache.go)
    • メモリ上の children だけを参照して,キャッシュの対象を見つける
  • cache-from が指定されている場合は ImageCache.GetCache が呼ばれる (docker/image/cache/cache.go)
    • メモリ上の children だけを参照して,キャッシュの対象を見つける
      • キャッシュの対象が cache-from で指定したイメージの親イメージだった場合のみ使用される
    • 親イメージを Store から取得する
    • 親イメージと cache-from で指定したイメージで history が前方一致, RootFS の DiffID が前方一致しているイメージがキャッシュ対象になる

結論

  • cache-from をつけない build はメモリ上で保持している children しか参照しない
  • pull したイメージは親イメージ群に対して SetParent しないのでメモリ上で保持している children からは参照できない
  • よって pull したイメージは cache-from をつけないと build 時のキャッシュ対象にならない

なんでこんな仕様になっているんでしょうか, 詳しい人教えて下さい

kaniko が何をしているか, 何ができるか

TL;DR

  • kaniko を理解してない限りコンテナから出してはいけない.
  • kaniko を使っていればmulti stage buildだろうとCIのcacheについて余計なことを考えなくてよい (Dockerfileの書き方はcacheを意識して)

kaniko とは

github.com

kanikoはGoogleが作っているコンテナの中やKubernetes上で動くコンテナのbuilderです.

Cloud Buildなどで使うことができます.

kaniko の内部実装

早速ですが kanikoのbuildの詳細を調査するために以下から実装を読み進めました.

8c732f6f52bb334727194d090826e8dbd1df3793 における実装の詳細です.

github.com

Directory

/kaniko 以下が内部状態

  • (1) /kaniko/<image name>/... 依存しているイメージのlayersが完全に展開されている
  • (2) /kaniko/stages/<image name> --fromで依存しているイメージのtar
  • (3) /kaniko/stages/<stage id> ステージごとのtar
  • (4) /kaniko/<stage id>/... 別のステージから依存されているファイルを置く

DoBuild

  1. Dockerfileをパースし, ステージごとに分割する (--target まで)
    1. ステージ名を内部でidに変換する
    2. ベースイメージとして別のステージから参照されているかどうか調べる
  2. .dockerignoreを解釈する
  3. 外部の依存イメージをフェッチする
    1. ステージが持っているCOPYコマンドの--fromから外部のイメージに依存しているかを確認する
    2. 依存している場合はダウンロードしてkaniko内部のStageを保持するディレクトリ(2) にtarとして保存される
    3. kaniko内部のディレクトリ (1) にイメージを展開する
  4. ステージ間の依存ファイルを調べる
    1. COPYの--fromで他のステージから参照されているファイルを抽出する
  5. ステージごとにbuildする
    1. ベースイメージの解決を行う
      1. scratchかどうか
      2. 他のステージがベースイメージかどうか
      3. CacheDirが設定されている場合そこから取得する
      4. リモートから取得するようにする
    2. キャッシュの設定が有効な場合, 最適化を行う
      1. 親イメージのハッシュまたは直前のコマンドのcache key, コマンド名, COPY/ADD で参照されるファイル(中身,UID,GID,権限)のMD5 からcache keyを計算する
      2. RUNの場合はcache keyで外部ストレージに保存されているか確認する
      3. 保存されていない, CacheTTLを超えている場合はその場で最適化終了
      4. Cacheが有効な場合はRUNの内部実装をCacheに置き換える
      5. RUN/ADD/COPY/WORKDIR 以外のコマンドは実行される (cache可能性に影響するので)
    3. そのステージがCacheに失敗したRUNを持っているか,別のステージからファイルを参照されている場合にベースイメージを / に展開する
      1. 展開の際に /proc/self/mountinfoに含まれているpath,/kaniko,/var/run,/etc/mtab 以下に書き込まないようにしている
    4. 最初のsnapshotを取る
      1. / 以下のすべてのファイルをスキャンする
      2. メモリ上にある前のsnapshotの状態と比較する
      3. メモリ上にあってファイルが存在しないものはwhiteout listに追加する
      4. メモリ上にない, またはメモリ上とhashが違う場合はメモリ上の状態を更新する
        1. snapshotの際のhashはsnapshot modeによって異なる. (timeとfullがある)
        2. timeはmtimeだけを使う
        3. fullはmodeとmtime,uid,gid,ファイルの中身を使う
    5. 各コマンドを実行する
      1. RUNはprocessを実際にexecする
      2. SingleSnapShotモードの場合は最後のコマンドだけ以下を実行する
      3. RUNコマンド以外はコマンドから差分のファイルがわかるので差分だけメモリ上の状態を更新しtarとして書き出す
      4. RUNコマンドは / 以下のfull scanから差分を求めて(5.4と同様の処理) tarとして書き出す
      5. キャッシュが有効で, キャッシュが効かなかったRUNコマンドの結果のlayerを非同期でregistryにpushする
      6. layerをイメージに追加する
    6. layer cacheのregistryへのpushが終わるのを待つ
    7. 後のステージのベースイメージになっている場合はディレクトリ(3)にtarとして保存
    8. 別のステージからCOPY --fromで参照されているファイルをディレクトリ(4)にコピー
    9. /proc/self/mountinfoに含まれているpath,/kaniko,/var/run,/etc/mtab以外のファイルをすべて削除する
  6. 最終ステージをpushする

ベースイメージのcache

上の流れでは毎回, ベースイメージをfetchしてきます.

もしkanikoが永続化されたstorageを使えるならgcr.io/kaniko-project/warmer--cache-dirを使うことでベースイメージのcacheができます.

buildの高速化を目指すなら取り組んでみてもよいと思います.

終わりに

kanikoは--cache=trueにすることでシームレスに実行したすべてのレイヤーをpushします.

既存のbuildだと生成されたイメージ単位でしかpushすることができませんでした.

そのため一度に複数のイメージをまたぐmulti stage buildだと状態を保持できない環境でのcacheが困難でした. (cache-from地獄)

orisano/castage のようなcache-fromを自動生成するツールであったり, cacheの効果が最大化するようなステージのみを明示的にbuildしてcache-fromすることで対応していました.

kanikoのシームレスなpushによってこのような煩雑な作業から開放され,高速なCIにおけるdocker buildが実現されます.

イメージ単位でのpushではなくレイヤー単位でのpush, それをシームレスに行うことによるcacheに気がついたとき感動してしまいました. (この記事を書くきっかけ)

どこまでもlayerのみで考えられておりmulti stage buildにおけるcache問題が解決されており, 状態が保持できないCIのような環境下向けに作られていると思います.

注意

kaniko自体はprocessの隔離等は行いません, コンテナ上で動かすこと前提としています.

実行される環境のファイルをめちゃくちゃに書き換えます. 間違えてもローカルでは実行しないでください.

まとめ

kanikoは上位のコンテナ技術に依存した作りになっています.

  • 自分ではプロセスの隔離などは行わずos.Execします
  • kanikoは隔離されている前提なので / にtarを展開したり, / から殆どのファイルを削除したりします
  • cacheの状態を簡単にremoteに置くことができる仕組みになっています
  • cacheのpushはシームレスに行われ, 最小単位はlayerです. multi stage buildでの悩みが解決されます

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

コンテナイメージを小さくするために

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

TL;DR

  • コンテナイメージを小さくするのは pull / push のコストを減らすため
  • ベースイメージを小さいものにする (ex. alpine linux, distroless)
  • Multi-stage Buildsを活用する
  • イメージに不要なファイルが含まれていないかを確認する. dlayer

コンテナイメージを小さくするために

なぜ小さくするのか

pull / pushのコストを減らすためです. CIの時間を短くするという文脈においては

  1. 成果物の push
  2. 依存イメージの pull (docker-compose を用いたテストなど)
  3. docker build

が主な改善対象になります.

(1), (2) に関してはコンテナイメージを小さくすることで直接的な効果があります. とはいえマネージドなコンテナレジストリを使っていることが多いと思いますし, クラウド内の内部通信なのでそれなりに大きなイメージでない限りあまり効果はないかもしれません.

(3) が CI の中で結構な割合を占めることになると思います. docker には build のための cache 機構が存在しますが状態を保持しない CI においては使うことができません. 状態を保持しない環境においても cache を使えるように --cache-from というオプションが存在します. --cache-from は手元で build していないイメージを cache の対象とするためのオプションで pull と組み合わせると CI 上でも docker の build cache の機構を使うことができます.

詳しくは以下を参照ください.

どうやって小さくするか

(2018/12時点で)よくインターネット上で見られた方法として

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

などがありました. しかし何故小さくなるかに触れている文献を当時(2018/12)はあまり見ることができませんでした.

ベースイメージを小さくする

ベースイメージを小さくするというのは比較的簡単にできる解決策です.

ですがAlpine等の環境は慣れ親しんでいない人が多いので思わぬ落とし穴にハマりがちです, 運用しやすさなどを考慮して選択すると良いです.

不要なキャッシュやパッケージを削除する

apk --no-cache などの環境ごとのパッケージマネージャーのキャッシュをオフにするオプションは毎回調べる, もしくは覚えることになります.

外部に公開するイメージでもない限り無視しても問題ない程度の大きさであることが多いです.

不要なパッケージの削除に関しては docker のレイヤーの仕組み上 RUN を一つにまとめないとレイヤーに残ってしまうので一つにまとめなければいけなくなります, これによる Dockerfile のメンテナンス性の低下を考えると後述の Multi-stage Buildsの活用のほうが良いと思います.

orisano/minid の様に RUN を自動でまとめるアプローチなどもありますがCIのパイプラインが複雑になりがちです.

Multi-stage Buildsを使う

Multi-stage Buildsは意外と知名度が低い問題があって(2018/12~)悲しいです.

これを使うことでイメージ(ステージ)間のファイルのCOPYが可能になり, ビルドに必要な依存関係と実行に必要な依存関係の分離が実現されます.

ビルドに必要な依存関係と実行に必要な依存関係が大きく異なる場合, 最終イメージを小さくすることにかなり寄与します.

しかしCIの時間を短くするためのdocker build cacheの観点で見ると最終ステージ以外も小さくしないといけないので本質な問題は解決しません.

レイヤーの数を少なくする

(2018/12時点で)これは少し前まで結構見た言説ですがこれは本当なんでしょうか. (2020/7)正しくないわけではないが Multi-stage Buildsを使ったほうが良いです.

なぜ小さくなるのか

コンテナイメージが大きい原因調査のためにdocker historyを使って説明しているものがありました.

RUNを2つ書いたものとRUNを1つにまとめたものの docker history の結果を比較して小さくなっていることを確認したり, パッケージの install をしている部分が大きくなっているので cache が作られているのではないかという仮説を立てたりしているものでした.

(2018/12時点で)あまりコンテナイメージのサイズに興味がない人が多いので原因を突き詰めないのは仕方のないことかもしれません.

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

そもそもコンテナイメージがどの様に保持されているかを知っているとなぜ重いかの理解が捗ります.

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

イメージの仕様を見てみるとコンテナイメージは差分により構成されていることがわかります. 以下のページの図が理解しやすいです.

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

どこの差分がなぜ重くなっているか知ることでより本質的な改善が行えるのではないかと考え, ツールを作りました.

github.com

docker saveで生成されるファイルを分析して, レイヤーごとに差分のサイズとファイル名が出力するツールです.

不明なcacheファイルが残っていることや, 不要なツール, 重複して作られているファイルの存在の認識ができコンテナイメージを小さくする際に力を発揮します.

実際にGoの公式イメージサイズを小さくすることに成功しました. 改善活動をするときは原因特定ができる状況を作りましょう.

ぜひdlayerを使ってコンテナイメージを小さくしてみてください!

余談

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

github.com

ちゃんと理解する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を書きましょう

monorepoのdocker buildにおけるdockerignore

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

build contextの話

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

docs.docker.com

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    return pm.Matches(file)
}

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

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

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

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

        if pattern.exclusion {
            negative = true
        }

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

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

        if match {
            matched = !negative
        }
    }

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

    return matched, nil
}

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

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

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

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

    b := p.regexp.MatchString(path)

    return b, nil
}

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

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

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

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

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

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

    regStr += "$"

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

    p.regexp = re
    return nil
}

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

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

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

github.com

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

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

monorepoにおけるbuild context

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

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

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

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

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

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

github.com

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

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

github.com

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

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

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

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

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

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

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