課題
GitHub ActionsでDocker イメージをビルドしてる際にどうもうまくCacheされてなくて、Library変更やDockerfileをいじってないのに毎回 pip installをしてビルド時間が長いケースがたまにあると思います。
Checkpoints
- Dockerfileの書き方
- Docker layer cacheを使えるように書けばいい (building best practices)
- docker/build-push-action
- Docker layer cache: docker/build-push-actionを使えばOK✅️
- GitHub Actions Cache: docker/build-push-actionのcache typeのうちの一つ
gha
がGitHub Actions Cache. 別のCache type registryなどもあるので必ずしも使わなくてもいい - Pip Cache:
- Docker imageをbuildするときのDockerfile内でのpip installはDocker layer cacheでcacheする
- 一方、GitHub Actions上でテストを実行する場合は、GitHub Actions Cacheを使ってCacheする
- Docker layer cacheを使う場合はrequirements.txtが変更されると全てのpackageのインストールをやり直すので時間がかかる
- Self-hosted RunnerとGitHub Runner
- GitHub Actions Cacheを使うとSelf-hosted RunnerのRegionによっては遠くなるので、逆に遅くなるケースもあるので注意
- GitHub Actions Cacheの制約
- PRで使われるCache: PR上では同一PRで実行された際に保存されたCacheまたはmain上で保存されたCacheを使うことができる
- mainで使われるCache: mainで実行されるWorkflowはmainで保存されたCacheのみを使うことができる
- PRで保存されるCache: PRに対して保存されるCacheはそのPRの後続の実行のときにのみ使うことができる。mainや他のPRからは使えない
- mainで保存されるCache: mainで保存されるCacheは、他のPRやmainブランチからも使える
Dockerfile
シンプルなケースでは、multi-stageをせずに以下のように書けば良い
FROM python:3.12.3-slim
WORKDIR /app
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
COPY . .
ENV PORT=8080
EXPOSE 8080
apt getが必要であれば
以下のような RUN
を COPY requirments.txt requirements.txt
前に追加する
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
この場合、このLayerでCacheできないとそのあとにある pip installもすべてやり直しになるので、こういったケースではmulti-stageにしたほうが良いはず。
GitHub Actions
GitHub Actions の設定では、cacheはGitHub Runnerを使ってる場合は、gha
を選択すれば良い。
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
GitHub Actions 全体の例:
name: build-and-push
on:
release:
types:
- published
push:
tags:
- 'v*'
branches:
- main # this is necessary for pr to utilize the cache
pull_request:
paths:
- .github/workflows/build-and-push.yml
- docker-layer-cache/*
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Cacheのケース
PRでのCacheは他のPRからは使われないという点は注意が必要 (Restrictions for accessing a cache)
- PR
- PR上の1回目のWorkflow
- mainにCacheがある&使える -> mainのCacheを使う
- それ以外 -> No Cache
- 同じPRの2回目以降のWorkflow -> PR上で前回実行されたときに保存されたPRのCacheを使う
- PR上の1回目のWorkflow
- mainにmerge
- 初めてCacheを入れたときのPush -> No cache (PRで保存したCacheはmainや他のPRからは使われない)
- 前のmainでCacheされたCacheが使える -> mainのCacheを使う
- Dockerfileが更新されている -> No cache or lower cache rate (layer cacheなので部分的に使えるケースもある)
Cacheされたケースの例: Cacheされて pip installが丸々Skipされるので5sで終わった
Tips
キャッシュ容量の上限 10 GiBにいってしまうケース
mainだけでCacheを保存するように設定する
Dockerfileやrequirements.txtを変更してないのにCacheがされてない
apt-get update
などが前にあって変更されると後続のLayerがすべてBuildされ直す。
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
pip install requirements.txt
の前に入っているとLayer Cacheが効かないことがある。apt-get installがある場合はmult-stageにしたほうがよいかも。
poetryなど requirements.txtで直接管理してない場合
- name: Export requirements.txt
run: |
poetry self add poetry-plugin-export
poetry export -f requirements.txt --output requirements.txt --without-hashes
というStepをBuild前にいれてたのですが、どうもこれだとchecksumが変わってしまうようだった modification time は変更されても大丈夫と書いてあったが、creation timeも変わってしまうとCacheが効かなくなってしまうようです。
この場合は、 poetry.lockが変更されたら requirements.txtを更新してrepo内へPushするようにしました。
GitHub Actions Cacheをマウントする (更に高速化)
上で紹介したDockerfileとGitHub ActionsではシンプルにDocker Layer Cacheを使って高速化できるのですが、requirements.txtが一つでも更新されると、すべてDownloadし直すためとても遅くなってしまいます。
この部分を改善するためにGitHub Actions CacheとDockerのmountを活用する方法を紹介します。
Dockerfileは以下のように書きかえます。みそは、RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ pip install -r requirements.txt
になります。cacheから/root/.cache/pip
にマウントして pip install
を実行します。
FROM python:3.12.7-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
pip install -r requirements.txt
COPY . .
ENV PORT=8080
EXPOSE 8080
CMD ["python", "app.py"]
これに対応してGitHub Actions側も以下のように変更します。
... # metadata の後を以下のようにする
- name: Restore pip cache
uses: actions/cache/restore@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
id: pip-cache
with:
path: root-dot-cache-pip
key: pip-cache-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-cache-
# buildkit-cache-dance を使ってキャッシュを Docker ビルドに注入
- name: Inject cache into Docker build
uses: reproducible-containers/buildkit-cache-dance@5de31fc1534ed8789e63d41ea933c5df9944a261 # v3.1.0
with:
cache-map: |
{
"root-dot-cache-pip": "/root/.cache/pip"
}
skip-extraction: ${{ steps.pip-cache.outputs.cache-hit }}
# Docker イメージをビルドして必要に応じてプッシュ
- name: Build and push Docker image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
with:
context: docker-layer-cache
file: docker-layer-cache/Dockerfile.cache
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
- name: Save pip cache
uses: actions/cache/restore@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
if: github.ref_name == 'main'
with:
path: root-dot-cache-pip
key: ${{ steps.pip-cache.outputs.cache-primary-key }}
このようにすることで、一度InstallしたPackageをGitHub Actions Cacheに保存することで毎回Build時にDownloadしなくてよくなるので高速化することができます。
一方で、requirements.txt の変更がないときには、Docker layer cacheを直接使ったほうが断然速くなるので一長一短かなと思います。 cacheからmountする場合は、毎回cacheから取得する部分が実行されてしまうのでDocker layer cacheを使うよりも遅くなってしまいます。
References
- Restrictions for accessing a cache (by GitHub)
- Do not use cache when installing packages (by Datadog)
- Build cache invalidation (by Docker)
- Docker Best Practices for Python Developers (Blog)
- Enhancing Developer Experience: Accelerating Docker Image Builds by 90% Using GitHub Actions Cache (Medium)
- Cache management with GitHub Actions (by Docker)
- self-hosted runnerと actions/cache の噛み合わせが悪かった件: self-hostedとactions/cacheは遅い
- やんないほうがいいかも、GitHub Actions の setup-xxx での依存キャッシュ保存: PR間でCacheが共有できないからmainのpush時だけcacheを保存するようにする
-
GitHub Actions 上での Go の Docker ビルドを高速化する
--mount=type=cache
の活用 -
2024年版のDockerfileの考え方&書き方
--mount=type=cache
のかつよう