docker build と docker-compose build でレイヤーキャッシュが共有できない問題を回避する

まとめ

BuildKit 使ってなくても COMPOSE_DOCKER_CLI_BUILD=1 を使う

キャッシュが効いてない

CI をいじっていてタイトルの問題に気づいたのが発端。

Cloud Build でキャッシュを使いつつ、ビルドとテストを実行するためにこうしていた。

  • 1 前回のビルドのアプリケーションイメージを docker pull する
  • 2 docker build --cache-from でキャッシュを利用しつつイメージを作る
  • 3 docker-compose run で他のミドルウェア等を起動しつつイメージのテストを実行
  • 4 テストが通れば 2 で作ったイメージを push

うまくいくと思っていたけど、2 と 3 で都度アプリケーションイメージがビルドされてしまう。なんでだろなあ、と調べていた。

試していると、COPY を機に docker builddocker-compose build 間でキャッシュが共有できなくなっていることに気づく。今まで違和感を覚えるタイミングは何回かあったけどスルーしていたなあ。 https://gist.github.com/pokutuna/e181f8e9e20a49e7cc6c46c75bd07a82

docker-compose build
# Building app
# Step 1/5 : FROM alpine:3.12.0
#  ---> a24bb4013296
# Step 2/5 : RUN mkdir /app
#  ---> Using cache
#  ---> 2fe579ed6f0f
# Step 3/5 : COPY print.sh /app
#  ---> a787e9b6de30

原因

Issue が 2015 年からある歴史の長い問題。

docker はファイルを tar で固めて転送し、ハッシュを比較してキャッシュを使えるか判定するんだけど、docker CLI(go) と docker-compose の利用する docker-py の間で生成される tar に差異があってキャッシュが利用できなくなる。tar ヘッダ部分の扱いに由来するようだ。

という流れ。2020-08-17 現在まだ解決はしていない。

Issue でのやりとりを見るに、世間が BuildKit に移行することでどうせキャッシュが切れるので、昔からあったこの問題を今修正して世の中のキャッシュを2回壊すより BuildKit 化による1回を選ぶ、という感じかな。BuildKit ではコンテンツハッシュに tar ヘッダを利用しないようになっている。

Workaround その1: COMPOSE_DOCKER_CLI_BUILD=1

じゃあどうするねんという話だけど、COMPOSE_DOCKER_CLI_BUILD=1 を指定するとよい。

これはよく "docker-compose で BuildKit を有効にする環境変数" と説明されたりするけど、少しずれていて docker-compose でのビルドを docker CLI に移譲するフラグである。移譲された docker CLIDOCKER_BUILDKIT=1 により BuildKit を利用する。

BuildKit を使っていなくても、このフラグを使うことで docker CLI と docker-py の差異によるキャッシュ問題を回避できる。

周辺のコードを読んでいたら、docker-compose run を契機にビルドが走る際に COMPOSE_DOCKER_CLI_BUILD が考慮されていなかったのを見つけたので PR を投げてマージされた(えっへん)。これがリリースされたら run 時にビルドが走った場合にも BuildKit が使えるしキャッシュも効くようになる。今は1度 docker-compose build する必要がある。
Use docker cli on docker-compose run when COMPOSE_DOCKER_CLI_BUILD=1 passed. by pokutuna · Pull Request #7653 · docker/compose

Workaround その2: stage 間で COPY する

別の回避策として、ステージ間の COPY にする技があるらしい。

例えば上記の再現コードだと

FROM scratch as scratch
COPY . /context

FROM alpine:3.12.0 as app
RUN mkdir /app
COPY --from=scratch /context/print.sh /app
ENV PATH /app:$PATH
ENTRYPOINT ["print.sh"]

のように、1度なにもしないステージを経由してコピーする。 無駄のように思えるけど、ステージ間のコピーにすることで docker-py による問題から逃れられるというテク。 はじめの COPY 部分のキャッシュは効かないけど、ファイルの転送だけなのでほぼ時間はかからず、キャッシュの恩恵に預かれる。

Workaround issue where docker-compose and docker won't share layers a… by anuraaga · Pull Request #2870 · openzipkin/zipkin

よかったですね。

え、みんなもう BuildKit 使ってるから関係ない? そうですか......