CI での Docker Build のベストプラクティスを考えてみた

CI での Docker Build のベストプラクティスを考えてみた

要約

Docker in Docker な CI では、以下の Docker Build をオススメします。

スクリプト

Dockerfile

  • ステージ間の依存を弱くする(依存インストールとビルドを分ける)
  • 中間イメージも軽量化する
  • 不要なキャッシュを削除

ファイル変更差分によりますが、これらにより最大 1/3 へビルド時間を短縮しました。

はじめに

『ホットペッパービューティー』美容クリニックのカウンセリング予約サービスのバックエンドを担当している安達です。

新卒として 4 月に入社して、5 月中旬に美容クリニックに配属され、すでに約 4 ヶ月が過ぎました。 まず、チーム内で自分が活躍できる庭を作りたかったため Docker について詳しくなろうと思いました。 そこで、CI 上での Docker Build の改善タスクがあったので積極的にやってみることにしました。 この記事では、新人が Docker Build の最適化を通じて学んだ知見や失敗をまとめます。 対象読者は、Docker Build を早くしたい方、Docker イメージを軽量化したい方です。 これにより、CI/CD の高速化、Pull, Push の高速化、セキュリティ向上が期待されます。

美容クリニックでは 4 つのフロントアプリケーションと 5 つの API、バッチアプリケーションの全てがコンテナ化されており、ECS で本番稼働しています。 アーキテクチャや技術スタックについてはこちらの記事を参照してください。

美容クリニックでは以下のような CI/CD のフローになっています。

ci-flow

Jenkins から CodeBuild プラグインを利用して CodeBuild 上で Docker Build を行い ECR へ Push しています。 チームで定期的に開発業務を改善するためのヒアリングが行われており、Docker Build に時間がかかりすぎるという声がエンジニアからありました。 そのため、CodeBuild 上での Docker Build を高速化していきました。 高速化に伴い、Docker イメージの軽量化も行いました。 CodeBuild 特有の話もありますが、Docker Build 共通の話が多いので他の CI を利用している方にも有益だと思います。

CI でのマルチステージビルドの問題点

美容クリニックでは、CodeBuild 上でマルチステージビルドを行っていました。 しかし、効率的なキャッシュの利用が実現できていませんでした。

ローカル環境での Docker Build では毎回 dockerd が同じため問題ありませんが、dockerd が毎回変わる CI 環境では外部にキャッシュを保存しないとレイヤーキャッシュが効かない問題があります。 最終イメージの中にビルドプロセスの全てが含まれていれば 1 Dockerfile 1 Cache で単純に–cache-fromを指定するだけでキャッシュが効くようになります。 しかし、2020 年現在では Docker イメージの軽量化のためにマルチステージビルドは一般的になっており、このマルチステージビルドでは最終イメージにキャッシュすべき情報はほとんど含まれておらずキャッシュしても無意味です。 そのため、最終イメージのみならず中間イメージを外部に保存する必要があります。

マルチステージビルドになるとキャッシュが難しくなります。

検討した打ち手

  • Dockerfile に関して

    • パッケージマネージャで依存インストールとビルドの分離
    • パッケージマネージャのキャッシュ削除
    • RUN –mount=type=cache
  • スクリプトに関して

    • BuildKit の利用
    • -—cache-from の利用
    • Buildx の利用
  • CodeBuild の設定

    • ローカルキャッシュの利用
    • S3 キャッシュの利用
  • .dockerignore の作成

詳しくは以降の章で説明します。 また、本記事では Docker 19.03 を前提にしています。

実際に採用した打ち手

  • Dockerfile に関して

    • パッケージマネージャで依存インストールとビルドの分離
    • パッケージマネージャのキャッシュ削除
  • スクリプトに関して

    • BuildKit の利用
    • -—cache-from の利用
  • .dockerignore の作成

フロントアプリケーションの Docker Build を具体例に紹介します。

Dockerfile に関して

元々はDroneを CI に利用していたため、それに合わせて Dockerfile が書かれていました。

変更前の Dockerfile

これが変更前の Dockerfile で Yarn, Maven でビルドを行う中間ステージ 2 つと Jar ファイルを実行する最終ステージに分かれています。 node ステージ -> mvn ステージ -> jar ステージの依存関係があります。 また、Dockerfile は実際に運用されているものを簡略したものです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FROM node:10-alpine AS node
WORKDIR /app
COPY . /app

RUN yarn && \
  yarn build

FROM maven:3-amazoncorretto-11 AS mvn
WORKDIR /app
COPY --from=node /app/pom.xml /app/pom.xml
COPY --from=node /app/src /app/src

RUN mvn clean package -DskipTests=true

FROM amazoncorretto:11
COPY --from=mvn /app/target/hpbc-front.jar /hpbc-front.jar
ENTRYPOINT java -jar /hpbc-front.jar
EXPOSE 7080

dockerdotにより、依存関係を調べると以下になります。 青と赤の矢印は依存関係がなく BuildKit により並列実行されるレイヤーです。 mvn ステージが早い段階から node ステージに依存しているので実行される部分が少ないです。

before_depend

変更後の Dockerfile

まず、Yarn と Maven に関して依存パッケージのインストールとビルドのコマンドを分けました。 yarnyarn buildmvn dependency:resolvemvn clean package -DskipTests=trueです。 こうすることで、ステージごとの依存を緩和できyarnmvn dependency:resolveを並列に実行できます。 さらに、依存パッケージのインストールにはpackage.jsonpom.xmlなど一部のファイルしか必要がないため、レイヤーキャッシュが効きやすくなります。

次に、Docker Build で--cache-fromオプションを使ってキャッシュを利用することを前提に考えます。 その場合、中間ステージでさえイメージサイズを削減しておいた方がよいです。 yarn cache cleanをして不要なキャッシュを削除します。 また、オフィシャルイメージ自体のイメージサイズを削減しました。 イメージサイズの削減には dive を利用しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM node:10-alpine AS node
WORKDIR /app

COPY package.json yarn.lock /app/
RUN yarn && \
  yarn cache clean

COPY . /app
RUN yarn build

FROM maven:3-amazoncorretto-11 AS mvn
WORKDIR /app

COPY pom.xml /app/pom.xml
RUN mvn dependency:resolve

COPY --from=node /app/src /app/src
RUN mvn clean package -DskipTests=true

FROM amazoncorretto:11
COPY --from=mvn /app/target/hpbc-front.jar /hpbc-front.jar
ENTRYPOINT java -jar /hpbc-front.jar
EXPOSE 7080

dockerdotにより、依存関係を調べると以下になります。 変更前より依存関係が緩くなり、並列実行される部分が多くなったと視覚的に分かります。

after_depend

イメージ軽量化 Tips

中間ステージも軽量化する必要があります。 Docker イメージサイズは小さければ小さいほど、Push と Pull の高速化につながり嬉しいです。 docker historyによってイメージレイヤーごとのサイズは分かりますが、どのレイヤーのどのファイルのサイズが大きいかは分かりません。

wagoodman/dive

dive というツールにより、Dockerfile 上のコマンドごとによって生成されるファイルやその容量を分析できます。 yarn cache cleanを加えるのもこのツールにより思いつきました。 また、美容クリニックで利用されているmaven:3-amazoncorretto-11イメージが重いので分析してみました。 すると、キャッシュの削除忘れを発見したのでそれを削除することにより 659MB -> 464MB へ軽量化しました。 dive の詳しい使い方は、個人ブログにまとめたのでこちらをご覧ください。

スクリプトに関して

多くのアプリケーションがコンテナ化されているのでそれぞれで BuildKit が有効になっていたりなっていなかったりしていました。CodeBuild 側、スクリプト側の両方で有効化できるので、こういう事態になります。 スクリプト側で有効化すれば間違いありません。

また、--cache-fromをオプションに利用します。 その際 BuildKit を有効にすることと、--build-arg BUILDKIT_INLINE_CACHE=1を指定してイメージをビルドして Push する必要があります。

従来の--cache-fromでは明示的にdocker pullする必要がありましたが、BuildKit の Inline Cache を利用するとその必要がなくなってキャッシュヒットの可能性があるレイヤーのみを自動で Pull します。 Inline Cache は、イメージに埋め込まれるキャッシュです。 実態としては、イメージのメタデータを管理する JSON にmoby.buildkit.cache.v0フィールドが埋め込まれるだけでありイメージサイズには影響はありません。

全体のビルドを先にして、タグ付けのために中間ステージを後にビルドします。 BuildKit では並列でビルドされるので、この順番にしました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# buildspec.yml

# ECRへのログイン
# --cache-fromでPullするのでビルド前にログインする必要があります
$(aws ecr get-login --no-include-email)

# BuildKitを有効化
export DOCKER_BUILDKIT=1
# --cache-fromで中間ステージを指定します(最終ステージはCOPYするだけなのでキャッシュしませんでした)
docker build -t hpbc-front:latest --cache-from=hpbc-front:node,hpbc-front:mvn --build-arg BUILDKIT_INLINE_CACHE=1  .

# キャッシュに利用するために中間イメージをPushするのでタグを打ちます(BuildKitでのマルチステージビルドでは--targetオプションを指定しないと中間ステージは残りません)
# &とwaitで並列処理します
docker build --target node -t hpbc-front:node --build-arg BUILDKIT_INLINE_CACHE=1  . &
docker build --target mvn -t hpbc-front:mvn --build-arg BUILDKIT_INLINE_CACHE=1  . &
wait

# 中間ステージをPushします
# &とwaitで並列処理します
docker push hpbc-front:node &
docker push hpbc-front:mvn &
docker push hpbc-front:latest &
wait

また、hpbc-front:node-branch-nameのようにタグ付けすることも考えられます。 こうすると、複数のブランチからビルドした時でもブランチごとのイメージがあるのでキャッシュが効きやすくなるメリットがある一方で、それぞれのブランチでの初回の Docker Build の時に全くキャッシュが効かないデメリットがあります。 メリット、デメリットを考慮して選択すると良いです。 美容クリニックでは、ビルド頻度の観点からブランチ名をタグに含めることはしませんでした。

変更結果

元々のビルド時間は 7 分 30 秒程度でした。 以上で説明した変更を加えると

  • キャッシュが効かない時(1 回目など): 7 分 45 秒程度
  • pom.xmlの変更がない時(mvn dependency:resolveまでキャッシュが効く時): 5 分 20 秒程度
  • 全てのレイヤーでキャッシュが効く時: 2 分 30 秒程度

これらの時間は、Jenkins 上での計測時間です。 キャッシュが効かない場合は、Push でオーバーヘッドがあるため遅くなります。 最大でキャッシュが効く場合、1/3 まで時間が短縮されます。

省略しましたが、dockerdへ送信するファイルサイズの削減やレイヤーキャッシュを効きやすくするために、.dockerignoreを書いておくことも重要です。

うまくいかなかったこと

RUN –mount=type=cache

Docker 18.09 から追加されたRUN --mount=type=cache命令を使おうと試みました。 (今のところ非標準命令であるため,Dockerfile の 1 行目に # syntax = docker/dockerfile:experimentalと書く必要があります。)

1
2
3
4
5
6
7
8
RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
  yarn

RUN --mount=type=cache,target=/app/node_modules/.cache \
  yarn build

RUN --mount=type=cache,target=/root/.m2/repository \
  mvn clean package -DskipTests=true

この命令はこれまで着目してきたレイヤーキャッシュとは異なります。 このような書き方をしコンパイラやパッケージ管理ツールのキャッシュをマウントできます。 これにより、ビルドを高速化できます。 ローカル環境で試したところ、依存パッケージのダウンロードやビルドは半分の時間で終了しました。

これを利用するために、CodeBuild の S3 キャッシュを利用しようと思いました。 ローカルキャッシュではキャッシュ生存時間が短いという問題がありましたが、S3 キャッシュではその問題はありません。

S3 キャッシュのイメージは以下のようになります。 buildspec.yamlでのコマンドは青い部分で実行されることになります。

s3_cache

CodeBuild では以下のように S3 キャッシュを指定して、コンパイラやパッケージ管理ツールのキャッシュを保存しておこうと考えました。

1
2
3
4
5
6
7
8
9
10
11
12
# buildspec.yaml
version: 0.2
phases:
  pre_build:
    commands:
    # 略

cache:
  paths:
    - "/usr/local/share/.cache/yarn/**/*"
    - "/app/node_modules/.cache/**/*"
    - "/root/m2/repository/**/*"

Linux 内の dockerd から、S3 キャッシュに保存できると考えましたができませんでした。 どうやら、RUN --mount=type=cacheは dockerd に依存するため、CI のような dockerd が毎回変わる環境では使用できませんでした。

Buildx によるビルド

Docker Buildx は Docker コマンドを拡張する CLI プラグインであり、Moby BuildKit ビルダーツールキットにより提供される機能に完全対応するものです。 Ref: https://matsuand.github.io/docs.docker.jp.onthefly/buildx/working-with-buildx/

Docker には、デフォルトで BuildKit の一部の機能は含まれています。 しかし、Buildx を利用することで、BuildKit の全ての機能を利用できます。 Buildx が buildkitd という BuildKit デーモンをコンテナで立ち上げてそこでビルドを行うことで実現しています。

docker/buildx

先ほど長いスクリプトを紹介しましたが、Buildx を使えば以下のコマンドだけで中間ステージのキャッシュを利用できたり、Push まで行ってくれます。 --cache-to type=registry,ref=hpbc-front:cache,mode=maxのオプションにより、中間ステージも含めてキャッシュを外部レジストリに保存できます。

1
2
3
export DOCKER_CLI_EXPERIMENTAL=enabled
docker buildx create --use
docker buildx build --tag hpbc-front:latest --cache-from type=registry,ref=hpbc-front:cache --cache-to type=registry,ref=hpbc-front:cache,mode=max --push .

Buildx は Docker 19.03 から利用でき、CodeBuild では Docker 19.03 の環境を用いているのですが Buildx が有効にならずコマンドエラーで終了してしまいました。

以下のようにすれば Buildx が使用可能になりましたが、これによるオーバーヘッドが大きいためやめました。

1
2
3
4
export DOCKER_BUILDKIT=1
docker build --platform=local -o . git://github.com/docker/buildx
mkdir -p ~/.docker/cli-plugins
mv buildx ~/.docker/cli-plugins/docker-buildx

そこで、CodeBuild のベースイメージに Buildx を組み込ませればいいのではと思いPRを送りましたが、

Unfortunately, we won’t be adding any experimental features.

とリジェクトされてしまいました。

CodeBuild では自前のカスタムベースイメージも利用可能でそちらを利用する手段もありましたが、カスタムベースイメージを管理するコストが大きいのでやめました。

ローカルキャッシュの利用

CodeBuild ではローカルキャッシュ機能があり、その中でもDocker Layer Cacheモードがあります。 これにより、ローカルでの Docker Build のようにレイヤーキャッシュを効かせられます。 しかし、このローカルキャッシュは生存時間が短いという問題があります。 どれくらい短いかは公表されていませんが、私たちの用途には不適切でした。 取り敢えずDocker Layer Cacheモードを有効にしておく分には損はないので問題ありません。

また、Jenkins から CodeBuild を呼び出しているのですが、その Jenkins プラグインがローカルキャッシュに対応していなかったため対応させるように PR を送り修正しました。

おわりに

Docker Build には、CI の知識やパッケージ管理ツール、Linux など幅広い知識が必要でした。 Yarn や Maven について、ドキュメントを読むなどし勉強になりました。 また、OSS へ PR を送る機会もたくさんありいい経験になりました。

検証中に CodeBuild のキャッシュがなぜか効いてしまうことがあり、今のは明示的なキャッシュによるものなのか判断が難しいということがありました。これは、CodeBuild の内部で EC2 や S3 が動いておりその兼ね合いでないかと思っています。

また、Docker Build に関しては、kanikoBuildpacksJib など様々な便利ツールが出てきていて、ビルドの高速化やイメージの軽量化には他にも様々な方法があります。

これはほんの一例ですが、美容クリニックでは新卒でもやりたいと言えば様々なチャレンジができる環境があります。 興味がある方がいらっしゃいましたら、ぜひ採用ページを御覧ください。

参照

https://docs.docker.com/develop/develop-images/dockerfile_best-practices/

https://docs.docker.com/engine/reference/commandline/build/

https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/experimental.md

安達 光太郎

(ビューティ事業ユニット プロダクト開発グループ)

スノーボードが好きです。

Tags

NEXT