CircleCIでLayer Cachingを使わずにdocker buildを高速化する
【東京】CircleCI ユーザーコミュニティ ミートアップ #9
2022/04/21
加瀬健太 @Kesin11
自己紹介
所属:DeNAのSWETグループ(SoftWare Engineer in Test)
Twitter: @Kesin11, GitHub: Kesin11
CI/CDサービスのマニアなので有名なサービスは大体チョットワカル
主な業務
モバイルゲーム開発のJenkins構築、運用、パイプライン構築
社内CircleCI Serverの運用チーム
docker build 毎日しますよね?
ビルドは早いほうが嬉しい
CircleCIではどうしますか?
完
完全に正しいが・・・
実際どうしてますか?
DeNAではCircleCI Server(オンプレ)なので実質無料
docker buildする場合にはバンバン使ってもらっている
Layer Cachingのお値段
ドキュメントによると1ジョブごとに300クレジット
Freeプランの無料枠は30,000クレジットなので100ビルド相当
Docker/Linux Medium(2CPU, 4GB)が10クレジット/分
Layer Cachingを有効にするだけで30分相当のクレジット
キャッシュによってビルド時間自体を減らせる
高いと見るか安いと見るか・・・
Layer Cachingに頼らずにビルドを�早くする方法はないだろうか?🤔
発表内容
docker buildの各種キャッシュ方式の解説
CircleCI上でのビルド実験
実験の結果の考察
おまけCircleCIでdocker buildする際の重要ポイント
まずbuildkitの有効化
DOCKER_BUILDKIT: 1
マルチステージビルドが並列ビルドされるので早くなる
もう基本中の基本なので語ること無し
とはいえ最新のcimg/base:stableを使っていても明示的に環境変数でONにする必要があるので忘れないように
docker build --build-arg BUILDKIT_INLINE_CACHE=1
pushするイメージにキャッシュ用のメタデータを書き込む
次回ビルドでキャッシュとしてpush済みのイメージを参照する
マルチステージビルドにおいては普通に使ってもキャッシュが有効になる場面はほぼ無い
忘れてOK
補足:buildx
この後の各種キャッシュ機能はbuildkitの機能を使います
dockerからbuildkitのフル機能を使うにはdocker buildxが必要
とりあえずdocker buildx buildを使うのが最新のビルド方法という認識で大丈夫です
(正式な名称はよくわからない。便宜上Mount cacheと呼びます)
DockerfileのRUNに追加する
RUN --mount=type=cache,target=/root/.cache/go-build go build …
RUN中にディレクトリ指定でマウントしたボリュームを次のビルドで使い回す
パッケージやビルドキャッシュを使い回せると次のビルドが早くなる
docker buildx build -f Dockerfile \
--cache-to=type=registry,mode=max,ref=$CACHE_IMAGE:cache \
--cache-from=type=registry,ref=$CACHE_IMAGE:cache \
-t $IMAGE:latest .
ビルドするイメージとは別にキャッシュ用レイヤーを別イメージとしてpushする
mode=maxにするとマルチステージの中間も含めて全レイヤーがpushされる
マルチステージビルドでもキャッシュを有効に使える
docker buildx build -f Dockerfile \
--cache-to=type=registry,mode=max,ref=$CACHE_IMAGE:cache \
--cache-from=type=registry,ref=$CACHE_IMAGE:cache \
-t $IMAGE:latest .
キャッシュとしてのイメージはdocker run可能なものではないので混ざらないようにイメージ名を別にした方が安全
CircleCIはコンテナレジストリの機能を提供していないのでGitHub/AWS/GCPあたりのレジストリを自分で用意する
docker buildx build -f Dockerfile \
--cache-to=type=local,mode=max,dest=/tmp/docker_cache \
--cache-from=type=local,src=/tmp/docker_cache \
-t $IMAGE:latest .
キャッシュ用レイヤーをローカルに書き出す
キャッシュとしての効果はRegistryと同じ
mode=maxの効果も同じ
docker buildx build -f Dockerfile \
--cache-to=type=local,mode=max,dest=/tmp/docker_cache \
--cache-from=type=local,src=/tmp/docker_cache \
-t $IMAGE:latest .
CircleCIのキャッシュ機能と併用して次回のビルドで使い回せる
注意:書き出されるディレクトリの中身は上書きではなく太り続ける
キャッシュ戦略をミスると古いファイルも残り続けてしまい、無駄なネットワーク転送が増え続けて本末転倒になってしまう
番外:dockerでビルドしない方法
何を言っているのか??
番外:dockerでビルドしない方法
CircleCIのremote dockerはスペックが固定されている(2core, 8GB)
docker build時にコンパイルなどCPUをぶん回す場合は頭打ちになる
(別解としてハイスペックmachine executorを使う方法もある)
最大で20CPU, 40GBまでスペックを上げられるCircleCIなのにもったいない
番外:dockerでビルドしない方法
ハイスペックなコンテナ内でビルドしてCOPYだけでイメージを焼く
例:go buildでバイナリを生成、DockerfileはCOPYだけ
Dockerのキャッシュの代わりに普段どおりCircleCIのキャッシュを使う
Dockerが担保していた再現性のあるビルドは担保されなくなるので注意
とはいえCircleCIはコンテナでビルドしているので元々再現性は高い
実験
CircleCI-Public/circleci-cliをforkしてdocker build中でバイナリをビルドするようなDockerfileを作成
中身はほぼ go mod, go build
これをCircleCI上でビルドさせる
https://github.com/Kesin11/my-circleci-docker-build-sandbox
詳細が気になる人はDockerfileや.circleci/config.ymlを参照
実験
キャッシュの有無で差が生じやすいようにsmall(1core, 2GB)
以下を比較
キャッシュを使わない通常のdocker build(DOCKER_BUILDKIT=1)
Mount cache
Registry cache
Local cache
dockerでビルドしない
実験
キャッシュが有効なケースと無効なケースを意図的に作り出す
有効なケース
CircleCIで再ビルド。何も変更しないのでキャッシュがフルに効く
無効なケース
go.modに適当なコメント行を追加し、不正なキャッシュを生成
わざと不正なキャッシュを参照させることで意図的に無効化させる
結果
それぞれのケースで5回計測
横軸はジョブ全体の時間(秒)
remote dockerの立ち上げに0-90秒程度の振れ幅があったため、最小値、中央値、最大値を表示
評価は中央値で行う(ソートも)
プロジェクト構成によって結果はかなり変わるのであくまで参考程度
考察 Mount cache
同一マシンにしかキャッシュが保持されない仕組み
CircleCIは毎回マシンが変わるのでキャッシュの意味がないことは予想通り
デメリットもなさそうなのでDockerfileに書いておくとローカルマシンでのビルドが早くなるメリット
考察 Registry cache
キャッシュが有効な場合はLocalとほぼ同等の早さ
ネットワーク的にLocalの方が有利と予想していたので意外だった
Localはrestore_cache, save_cacheのキーとパス設計が複雑になりがちなのでRegistryの方が簡単
考察 Local cache
キャッシュ無効の場合に特に遅い
ログを見るとキャッシュの書き出しに時間がかかっていた
CircleCIはremote dockerなのでネットワーク越しのIOであり実際にはローカルではないので時間がかかる?
書き出されたサイズは約1.5GBでこれをCircleCIのキャッシュに送るのも時間がかかっていた
考察 dockerでビルドしない
キャッシュが無効なら早い、有効ならRegistryやLocalに負ける
レイヤーキャッシュがフルに効く場合はdockerはビルドをほぼスキップしているため
良く言えばキャッシュの有無に依らずに安定している
自分なりの結論
buildxを有効にしてRegistry cache + Mount cacheが無難でシンプル
ただしキャッシュ用イメージの分だけコンテナレジストリに費用が発生するのをお忘れなく
キャッシュ用イメージのタグ戦略
featureブランチでは--cache-fromをmain、--cache-toは無し
キャッシュ書き出しの時間を節約
mainブランチでは--cache-fromをmain、--cache-toもmain
CIでよく使うキャッシュ戦略と同一
おまけ:CircleCI上のbuildxのセットアップ
クライアントとリモートのdockerのバージョンがある程度新しい必要がある
クライアント
cimg系のコンテナであればよほど古くない限り多分大丈夫
リモート(デフォルトが17.09.0-ceなのはそろそろ更新をお願いしたい・・・)
setup_remote_docker:
version: 20.10.11
CircleCI Serverの場合
remote docker用のVMのdockerでバージョンが固定されているため、古い場合は管理者にAMIのアップデートをしてもらう必要がある
おまけ:CircleCI上のbuildxのセットアップ
実はRegistry cacheを使うには以下のコマンドでbuildxの設定が必要
docker buildx create --use
ローカルでは問題ないがCircleCIだと以下のようなエラーになった
error: could not create a builder instance with TLS data loaded from environment. Please use `docker context create <context-name>` to create a context for current environment and then create a builder instance with `docker buildx create <context-name>`
ワークアラウンドとして以下のようにセットアップすれば動く
docker context create circleci�docker buildx create --use circleci
参考�https://support.circleci.com/hc/en-us/articles/360058095471-How-To-Use-Docker-Buildx-in-Remote-Docker-�http://www.er.crichardson.com/post/microservices/2022/01/18/build-multi-arch-docker-images-circleci.html
おまけ:.dockerignoreが超重要
何気ない `COPY . .` がキャッシュを無効化する
docker build時にディレクトリの中身がcontextとしてビルダーに送られる
contextも完全に一致しないとレイヤーキャッシュが効かない
docker buildするのに本来は不要なファイルが含まれていないか?
.git/の中身はコミットされるごとに変わる
.circleci/config.ymlは当然CircleCIの設定を変更すると変わる
.gitは気をつけていたが、.circleciを忘れていて実験を何度かやり直すハメになりました・・・キャッシュの振る舞いが変だと思ったら.dockerignoreをチェックしよう
参考
RUN —mount=type=cacheの公式ドキュメント(buildkit)
How To Use Docker Buildx in Remote Docker?(CircleCI Support Center)
BuildKitでイメージをビルドする(buildkitとbuildxの解説)
Dockerイメージのビルドで使うキャッシュの種類 - レイヤーキャッシュ、BuildKitの--mount=type=cache