GitHub ActionsのイメージビルドをDockerレイヤキャッシュで高速化(翻訳)
はじめに: 適切なDockerレイヤキャッシングでGitHub Actionsでイメージを構築する方法を学び、ググる時間を節約しましょう。DockerのBuildKit機能がGitHubのCIランナーで簡単にセットアップできるようになったおかげで、GitHub Actionsネイティブのキャッシュ機構を使って、イメージレイヤをビルドの隙間に収容できます。シングルステージやマルチステージのプロダクションDockerfileを用いる普通のRailsアプリケーションの例と、多くのプラットフォームで使える単一イメージのマトリックスビルドの例をご紹介します。
警告:本記事ではYAML設定がたくさん登場します。
キャッシュやCIについてひととおり理解している方は、ここをクリックして楽しいYAML設定までスキップしてください。
Dockerをローカル開発で使うかどうかは各自の好みでよいと思います(私たちはDocker信者とDocker嫌いを分け隔てしません)が、クラウド時代のデプロイメントでは、スケーラブルなアプリケーションをproduction環境で実行するためにコンテナと付き合わないわけにはいきません。
現代は、ビルドとプッシュが何らかの形で絶え間なく行われている時代です(手作業であろうと、高度に自動化されたDevOpsパイプラインに乗っかっていようと変わりません)。AWS FargateもGoogle Cloud RunもHerokuもコンテナを実行できます(現在のHerokuはDockerデプロイもサポートしています)。クラウドホスティング業界で激烈な競争が繰り広げられているこの時代においては、アプリケーションを仮想マシンにデプロイするという「昔ながらの手法」にこだわってもしょうがありません。
🔗 2021年版「Dockerキャッシュ即席入門」
Dockerイメージがケーキの断面のようなレイヤ(layer: 層)で構成されていることは、今なら小学生も知っています。Dockerファイル内に欠かれるRUN
、COPY
、ADD
コマンドは、読み取り専用のレイヤ(本質的にはファイルのかたまりでしかありません)を作成します。
Dockerホストはイメージの容量を削減するためにさまざまなレイヤを積み重ね、イメージを実行するときは書き込み可能な薄い「コンテナ」レイヤをその上に追加します。複数のレイヤにあるファイルが組み合わされて、コンテナ用のファイルシステム(ユニオンファイルシステム)が形成されます。
ユニオンファイルシステムを用いる主な理由は、最終的なシステムの中で「最も変更の少ない部分を安全にキャッシュ」し、「以後のビルドで再利用」したり、複数の最終コンテナ間で共有」できるようにするためです。
通信帯域の削減も目的のひとつです。依存するイメージの新バージョンがリリースされた場合に、ネットワーク経由で全レイヤを取得しなくても、変更部分を取得するだけで済みます。
つまり、カスタムDockerfileを正しく書くには、キャッシュが最もよく効くようにレイヤを配置すべきです。Dockerfileの冒頭部分にはなるべく変更が発生しないものを置き、変更が最も多く発生する部分はDockerfileの末尾にまとめるべきです。
通常、デプロイ前の最終的なコンテナで最初にコピーされるのはシステムの依存関係の部分であり、最も変化しやすい部分であるアプリケーションコードは最後にコピーされます。
DevOpsの専門家であれば、おそらくBuildKitについて聞いたことがあるでしょう。BuildKitは2017年に発表され、Docker Engine 18.09以降のすべてのディストリビューションに同梱されるまでに成熟しています。
また、バージョン19.03以降のDocker CEにはbuildx
と呼ばれるDocker用のスタンドアロンCLIプラグインが含まれており(experimentalモードのみですが)、docker build
のdefault builderとして設定できます。
Dockerをカジュアルに使うときにも、docker build
コマンドを実行する前に環境変数DOCKER_BUILDKIT=1
を設定しておけば、クールで新しい青みがかったCLIが表示されます(ビルドの出力全体が1つのターミナル画面に収まるようTTY表示に工夫が凝らされています)。これがBuildKit/buildxの動作です。また、Dockerがキャッシュを利用した箇所や、Dockerがゼロからレイヤを構築しなければならなかった箇所がひと目でわかるように出力されます。
DOCKER_BUILDKIT=1 docker build -t myimage:mytag .
の出力結果
上は、AnyCable用のデモアプリのイメージをビルド中のスクリーンショットです。アプリケーションのソースコードが変更されるたびにRailsがアセットのコンパイルを再実行するようになっているため、rails assets:precompile
を実行するレイヤを除いて、すべてのレイヤがDockerによってCACHED
として解決されたことがわかります。しかもコンパイル時間はわずか30秒です。キャッシュが効かなければ、私のローカルコンピュータでビルドがすべて完了するまで3分ほど待たなくてはならないでしょう。なお、ビルド時間はビルダーシステムやコンテナ内のアプリケーションのサイズによって変わります。
Action Cableの拡張機能を作った理由やプロジェクトの今後の展開については以下をご覧ください。
キャッシュ機構がなければ、平均的なDockerユーザーはさらにつらい思いをすることになります(信じていただきたいのですが、MacでしょっちゅうDockerを実行している私たちは嫌というほど身に沁みています)。
BuildKitの最もクールな点はDockerレイヤのアーティファクト(artifact: 成果物)を任意のフォルダに保存できることです。ローカルストレージやリモートレジストリからのエクスポートやインポートも非常に簡単です。
言うまでもありませんが、コンテナ化されたアプリケーションを扱う開発者は、本番用のDockerビルドを自分のローカルマシンで実行しないのが普通です。CIプロバイダを活用すれば、自宅での煩雑なイメージビルド作業を効率の高いデータセンターに移して開発作業を快適にできます。
しかし、実行のたびに破棄されるVM(仮想マシン)上でイメージをビルドすると、キャッシュのメリットがなくなってしまいます。
ほとんどのCIプロバイダーは分単位課金なので、CIを動かすのに十分な稼働時間を確保できます。
しかしおそらく、「ビルドに時間がかかる」「積み上がるキュー」「追加コスト」といった問題で頭の痛い方や、緊急で修正プログラムをリリースしたいのにこれらの問題のせいでインシデントに素早く対応できない悩みを抱えている方もいらっしゃるでしょう。
もちろん、CIプロバイダは自分たちの顧客を困らせるつもりなどありません。少なくともほとんどのCIプロバイダは何らかのキャッシュを実装していますし、特にコンテナのビルドが主要サービスであればなおさらです(こんにちはQuayさん)。専用のコンテナビルドサービスを使っていればこうした問題に気づかずに済むこともありますが、健全性を保つにはサービスを増やすのではなく減らすことが大切です。そこでGitHub Actionsの出番です。
🔗 GitHub Actionsを残らず飲み干す
GitHub Actionsは比較的新しいCIサービスですが、登場後たちまち巨大サービスに成長しました(ゾウあるいは体重千ポンドのゴリラ並ですね)。GitHub Actionsの主な目的は、「コードの共同作業」から「自動コードチェック」「デプロイ」まで、運用パイプライン全体を一手に引き受けることです。あらゆる開発者にとって夢のような世界がやってきました。なお、GitLabには何年も前から「パイプライン」機能がありました(GitLabの実装の方が優れているのは間違いありません)。しかし「git remote」市場の最大手であるGitHubがCI市場に参入してきたことで、今度はCI/CDを単独で提供しているプロバイダーが慌て始めています(Travis CI、お名残り惜しゅうございます)。
GitHubのUbuntuランナーやWindowsランナーにはすべてDockerがプリインストールされています(なぜかmacOSランナーにはありませんが)。したがって、「PRをメインブランチにマージする」「新しいリリースをデプロイする」のと同じように、リポジトリで発生する有意義なイベントに応じてDockerビルドも設定するのは当然の流れです。
必要なのは、ワークフローファイルのどこかにある、Dockerですっかりお馴染みになった以下の一連のステップだけです。
# Your mileage may vary
docker login
docker build -t myimage:mytag -f Dockerfile.prod .
docker push myimage:mytag
# VMはいずれにしろ破棄されるのでログアウトは不要
残念ながらこのままでは、GitHub Actionsでこれを実行するたびに、すべてのビルドがゼロから実行されてしまいます。
少し前に、GitHubワークフローの依存関係にキャッシュ機構が導入されましたが、Dockerレイヤで活用するための公式な方法が提供されていませんでした。カスタムアクションをDockerコンテナとして作成できる点は驚きです。
多くのサードパーティは、docker save
コマンドを中心にソリューションを構築するか、(Dockerでレイヤを再利用するために)新しいイメージをビルドする前に古いイメージを取り出すソリューションを構築しています。すべてのアプローチについて解説しているチュートリアルも一応ありますが、特にデフォルトのDocker機能を実装するタスクでは、いずれのアプローチも不自然に思えます。
率直に申し上げれば、Digital Oceanのドロップレット上に独自ランナーを作成して、実行中はそこにキャッシュを保持する方が理にかなっていると思えました。これは間違いなく実行可能な方法ですが、GitHubのエコシステムの外で独自ランナーを管理しなければならないため、多少手間がかかります。
🔗 いよいよアクションを少し追加する
GitHub Actionsが登場した頃はなかなかわかりませんでしたが、GitHub Actionsの最大の強みは「アクションそのもの」です。Docker社が公式に管理しているアクションの1つにbuild-push-actionというその名のとおりのアクションがあります。
GitHubの公式ドキュメントにも以下があります。
name: Publish Docker image
on:
release:
types: [published]
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Push to Docker Hub
uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: my-docker-hub-namespace/my-docker-hub-repository
tag_with_ref: true
しかし残念なことに、buildx/BuildKitランナーをVMのコンテキストでセットアップする(つまりDockerのキャッシュがエクスポート可能になり、正しくキャッシュできる)驚くほどシンプルな方法が存在するにもかかわらず、このドキュメントにはそのことが記載されていないのです。
以下は、docker/build-push-actionリポジトリから引用した公式なサンプルです。
name: ci
on:
push:
branches:
- "master"
jobs:
docker:
runs-on: ubuntu-latest
steps:
# Check out code
- name: Checkout
uses: actions/checkout@v2
# This is the a separate action that sets up buildx runner
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
# So now you can use Actions' own caching!
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# And make it available for the builds
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
push: false
tags: user/app:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
# This ugly bit is necessary if you don't want your cache to grow forever
# till it hits GitHub's limit of 5GB.
# Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
何ということでしょう。こんな誰でも見えるところに答えがあるのに、これまで誰も気づいていなかったのです。この方法が「現実の」アプリケーションのデプロイでも使えるかどうか確認してみましょう。
🔗 キャッシュ対決
私たちのキャッシュアプローチを実際に(in action)テストするために、Evil Martiansの筆頭バックエンドエンジニアである同僚のVladimir Dementyevによるデモ用Railsアプリケーション (ソース)を使うことにします。
これはよくある「Hello Rails」的なサンプルアプリではありません。RailsネイティブのAction Cableを超高速かつメモリ効率の高いAnyCableでドロップイン拡張した、最新のリアルタイムアプリケーションの機能デモです。
このアプリは大きさも手頃で、Vladimirのいつもの仕事ぶりと同様に、驚くほど念入りにテストされています。以下はrails stats
の実行結果です。
このアプリをforkして、フェイクのproduction向けDockerfileをいくつか追加することにします。
- Dockerfile.prod
- 標準的な "production" Dockerfile。
- 一般的なビルドの依存関係をすべて追加し、PostgreSQLクライアントとNode.jsをセットアップし、YarnとBundlerをインストールし、RubyとJSの依存関係をすべてビルドし、ソースコードをイメージにコピーし、最後に静的アセット(JavaScriptとCSS)をコンパイルします。
- Dockerfile.multi
- マルチステージビルドを用いてさらに進化したDockerfile。
- 最終的なイメージのサイズは半分強まで削減され、最終ステージには「コード」「アセット」「gem」のみが含まれます。
追加したDockerfileは、それらを用いてビルドしたイメージが単独では動作しないという意味においてのみ「フェイク」です。現実のproduction環境(Kubernetesクラスタなど)でアプリのすべての部分がクリックできるようにするには何らかのオーケストレーションが必要ですが、オーケストレーションの設定については本チュートリアルでは扱いません。
ここで確認したいのは実際のビルド時間の近似値やベンチマークだけなので、フェイクのDockerfileで十分です。
🔗 シングルステージでDockerfileをビルドする
それでは、デプロイをシミュレートするfake_deploy_singlestage.ymlワークフローを作成してみましょう。アプリケーションコードをチェックアウトし、イメージをビルドします。
現実世界であれば、ビルドしたイメージをレジストリにプッシュして、Kubernetesのマニフェストを更新するなり、新しいHelmのデプロイをトリガーするところでしょう。
ワークフローの中に2つのジョブを配置します。ジョブの1つはキャッシュを使い、もう1つは使いません。
GitHub Actions のワークフローではデフォルトでジョブがパラレルに実行されるので、2つのジョブの間に健全な競争心が生まれます。先に踊り終わった方が勝者です。
name: Fake deploy with normal Dockerfile
on: [push]
jobs:
# This job uses Buildx layer caching
fake_deploy_cache_single:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Prepare
id: prep
run: |
TAG=$(echo $GITHUB_SHA | head -c7)
IMAGE="my.docker.registry/progapangist/anycable_demo"
echo ::set-output name=tagged_image::${IMAGE}:${TAG}
echo ::set-output name=tag::${TAG}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@master
# Registry login step intentionally missing
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-single-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-single-buildx
- name: Build production image
uses: docker/build-push-action@v2
with:
context: .
builder: ${{ steps.buildx.outputs.name }}
file: .dockerdev/Dockerfile.prod
push: false # This would be set to true in a real world deployment scenario.
tags: ${{ steps.prep.outputs.tagged_image }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
# Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
# This job builds an image from scratch every time without cache
fake_deploy_no_cache_single:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Prepare
id: prep
run: |
TAG=$(echo $GITHUB_SHA | head -c7)
IMAGE="my.docker.registry/progapangist/anycable_demo"
echo ::set-output name=tagged_image::${IMAGE}:${TAG}
echo ::set-output name=tag::${TAG}
- name: Build production image
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
file: .dockerdev/Dockerfile.prod
push: false
tags: ${{ steps.prep.outputs.tagged_image }}
結果をコミットしてプッシュします。コールドキャッシュの結果は以下でご覧いただけます。
最初はキャッシュアクションのステップ数が増えるので、その分時間がかかります。今度は空のコミットをプッシュし、キャッシュが効くかどうかを確認してみましょう。
git commit --allow-empty -m "testing cache speed"
git push
さぁ、ドラムロールよろしく。
見事にキャッシュが効きました!もちろん、実際のプロジェクトのリポジトリで空のコミットをプッシュすることはそれほどありません。あるとすれば同じコードで再度デプロイをかけるときぐらいですが、こうした操作は実際よく行われるので、Dockerキャッシュを効かせてビルド時間を2分半短縮できれば文句なしです。
今度はもう少し現実っぽい操作を行ってみましょう。コードを少し変更して(任意のクラスにコメントを追加するなど)、再度プッシュします。
git add . && git commit -m "changing some code..."
git push
アプリケーションのコードが何らかの形で変更されると、DockerfileのRUN bundle exec rails assets:precompile
ステップが新たに実行されます。これは、新しいアセットを必要とする形でコードが変更された「可能性」があるからです。
キャッシュが適切に効いていれば、キャッシュされたワークフローで再構築されるレイヤは1つだけになるはずです。最も時間のかかる bundle install
やyarn install
のステップにキャッシュが効くはずです。
さて結果はどうでしょうか?
Dockerビルド中に変更されたのは、期待どおりアセットレイヤだけとなりました。ワークフロー内にproductionイメージのビルドステップが1行あるだけで、キャッシュミスが発生します。
上のスクリーンショットで示したように、キャッシュなしのワークフローではDockerイメージのビルドに3分38秒(トータルでは3分43秒)かかったのに対し、キャッシュありのワークフローでは1分29秒でイメージがビルドされました(トータルでは2分03秒)。
これでもビルド時間を半分近く削減できたんですよ!
🔗 マルチステージでDockerfileをビルドする
イメージのサイズが気になる方は(気にするべきです!)、Dockerのマルチステージビルドについて頭を整理しておく必要があります。マルチステージビルドを用いると、最終的なイメージに「デプロイ可能な」コードのみが含まれるようになり、一般的なビルドの依存関係や、Node.jsランタイム(Railsの場合)といった不要なファイルをイメージからすべて排除できます。
私たちのデモアプリのイメージの場合は、マルチステージビルドを使うと391MB、使わない場合は967MBでした。DockerイメージはCIサーバーとproductionサーバーの間で送受信されるので、ディスク容量とネットワーク帯域幅を大幅に削減できます。
それでは、サンプルのDockerfile.multiでマルチステージビルドの方法を確認してみましょう。
このDockerfileでは、productionビルドのdeploy
ステージのみを対象としています。マルチステージのビルドをキャッシュするワークフローはシングルステージの場合とほぼ同じで、注目すべき違いはほとんどありませんが、ここを省略してしまうと失敗したときに脳の血管がブチ切れるかもしれませんので、ご注意ください。
以下の設定をじっくり見てみると、docker/build-push-action@v2
ステップのcache-to
キーにmode=max
というややこしそうなオプションがあるのがわかります。
name: Fake deploy with multi-stage Dockerfile
on: [push]
jobs:
fake_deploy_cache_multi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Prepare
id: prep
run: |
TAG=$(echo $GITHUB_SHA | head -c7)
IMAGE="my.docker.registry/progapangist/anycable_demo"
echo ::set-output name=tagged_image::${IMAGE}:${TAG}
echo ::set-output name=tag::${TAG}
- name: Set up Docker Buildx
id: buildx
# Use the action from the master, as we've seen some inconsistencies with @v1
# Issue: https://github.com/docker/build-push-action/issues/286
uses: docker/setup-buildx-action@master
# Only worked for us with this option on 🤷♂️
with:
install: true
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
# Key is named differently to avoid collision
key: ${{ runner.os }}-multi-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-multi-buildx
- name: Build production image
uses: docker/build-push-action@v2
with:
context: .
builder: ${{ steps.buildx.outputs.name }}
file: .dockerdev/Dockerfile.multi
# Set the desired build target here
target: deploy
push: false
tags: ${{ steps.prep.outputs.tagged_image }}
cache-from: type=local,src=/tmp/.buildx-cache
# Note the mode=max here
# More: https://github.com/moby/buildkit#--export-cache-options
# And: https://github.com/docker/buildx#--cache-tonametypetypekeyvalue
cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
今回のベンチマーク結果もだいたい同じなので、ご自分で比較してみてください。
- コールドキャッシュでデプロイ(4:22/3:11)
- ウォームキャッシュで空コミットをデプロイ(00:30/3:09)
- アセットコンパイルありのデプロイ(2:06/3:09)
Dockerキャッシュのささやかな実験は最終的に成功しました!
「コミット時にアプリケーションコードだけが変更される」「依存関係は一切変更されない」という最も一般的なシナリオでは、デプロイ時間を半分に短縮できます。
もちろん、デプロイ中にGemfile.lock
やyarn.lock
が変更されればbundle install
やyarn install
のキャッシュレイヤが無効になり、ビルド時間は増加しますが、成熟したアプリケーションではほとんど起きません(依存関係の更新が必要な場合を除く)。
🔗 ボーナス: vendorイメージやライブラリの「マトリックスビルド」
Evil MartiansではFullstaq Rubyを愛用しています。Fullstaq Rubyはjemalloc
やmalloc_trim
パッチで最適化され、アプリケーションのメモリ使用量を30%〜50%削減できます。
Kubernetes上のproductionアプリケーションをFullstaq Rubyに移行した成果については、以下の過去記事をどうぞ。
Fulllstaq Rubyのメンバーが公式なコンテナ版のプラットフォームに取り組んでいる一方で、Evil Martiansのバックエンドエンジニアや外部コントリビュータはDebian 9 (stretch および stretch-slim) および Debian 10 (buster および buster-slim) 上で動作する Ruby 3.0.0、2.7.2、2.6.6のDockerイメージ↓をメンテナンスしています。
最近私たちがセットアップしたGitHub Actionは、ビルドマトリックス戦略を用いて、24の(わずかに)異なるイメージを同時にビルドします。
このリポジトリに置いたbuild-pushワークフローをご覧いただければ、私たちのアプローチがマトリックスビルドでどのように機能するかを確認できます。
レイヤキャッシュを有効にした場合、ビルド時間は1イメージあたり16~60秒程度です。
キャッシュ機構の旅はこれにておしまいです。お読みいただいた皆さまに感謝いたします。
本記事に掲載されているワークフローの完全版は私たちの以下のリポジトリから自由に取得して利用できます↓。
原文注
本記事は、キャッシュの仕組みを超わかりやすく解説したブログ記事やdocker-action-examplesリポジトリを公開したDocker社の皆さまのお力添えなしには実現しませんでした。
🔗 詳しく知りたい方へ
- Evil MartiansでDockerを「ローカル」開発環境にセットアップする方法については、AnyCableやTestProfの作者であるVladimir Dementyev氏による以下の画期的な記事をご覧ください。同記事では、Docker Composeの作業を楽にしてくれる火星印Dipツール(Mikhail Merkushin作)も紹介されています。フレームワークや言語に依存しないDockerとDipのマジックについては、"Reusable development containers with Docker Compose and Dip"もご覧ください。
- Go言語がお好きな方へ: "Speeding up Go Modules for Docker and CI"
🔗 お知らせ
自社Railsアプリケーションの「テラフォーミング」、クラウド向けの堅牢なデプロイメントパイプラインの構築、コンテナ中心の社内エンジニアリング文化の構築で支援をお求めの方は、お気軽にEvil Martiansのフォームまでご相談をお寄せください。Evil MartiansのエンジニアとDevOpsスペシャリストが、お客様のデジタルプロダクトの効率を最大化するためにお力添えいたします。
概要
原著者の許諾を得て翻訳・公開いたします。