GitHub ActionsでPythonのDocker imageをBuildするときに考えるCacheのこと

## 課題

GitHub ActionsでDocker イメージをビルドしてる際にどうもうまくCacheされてなくて、Library変更やDockerfileをいじってないのに毎回 pip installをしてビルド時間が長いケースがたまにあると思います。

## Checkpoints

- Dockerfileの書き方
    - Docker layer cacheを使えるように書けばいい ([building best practices](https://docs.docker.com/build/building/best-practices/#leverage-build-cache))
- [docker/build-push-action](https://github.com/docker/build-push-action)
    - cache type:
        - [gha](https://docs.docker.com/build/cache/backends/gha/) <- GitHub Runnerの場合はghaを使う✅️ (ただし、self-hostedの場合は遅くなる可能性もあるので別途考慮⚠️)
        - [inline](https://docs.docker.com/build/cache/backends/inline/) <- simple but `min` のみサポート
        - [registry](https://docs.docker.com/build/cache/backends/registry/) <- inline cacheの拡張版 (imageとcacheを分離できる、 cache mode `max`が使用可)
- Docker layer cache: [docker/build-push-action](https://github.com/docker/build-push-action)を使えばOK✅️
- GitHub Actions Cache: [docker/build-push-action](https://github.com/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をせずに以下のように書けば良い

```Dockerfile
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
```

<details><summary>apt getが必要であれば</summary>

以下のような `RUN` を `COPY requirments.txt requirements.txt` 前に追加する

```Dockerfile
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にしたほうが良いはず。

</details>


## GitHub Actions

GitHub Actions の設定では、cacheはGitHub Runnerを使ってる場合は、`gha`を選択すれば良い。

```yaml
      - 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 全体の例: 

```yaml
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のケース

:::note info
PRでのCacheは他のPRからは使われないという点は注意が必要 ([Restrictions for accessing a cache](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache))
:::


- PR
    - PR上の1回目のWorkflow
        - mainにCacheがある&使える -> mainのCacheを使う
        - それ以外 -> No Cache
    - 同じPRの2回目以降のWorkflow -> PR上で前回実行されたときに保存されたPRのCacheを使う
- 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で終わった

![Screenshot 2025-03-09 at 10.21.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7059/3ac4b466-a8fc-4094-bfac-6a269fb53cbd.png)

## Tips

### キャッシュ容量の上限 10 GiBにいってしまうケース

mainだけでCacheを保存するように設定する

### Dockerfileやrequirements.txtを変更してないのにCacheがされてない

`apt-get update` などが前にあって変更されると後続のLayerがすべてBuildされ直す。

```Dockerfile
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で直接管理してない場合

```yaml
      - 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` を実行します。

```Dockerfile
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側も以下のように変更します。

```yaml
... # 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しなくてよくなるので高速化することができます。

:::note warn
一方で、requirements.txt の変更がないときには、Docker layer cacheを直接使ったほうが断然速くなるので一長一短かなと思います。 cacheからmountする場合は、毎回cacheから取得する部分が実行されてしまうのでDocker layer cacheを使うよりも遅くなってしまいます。
:::



## References

- [Restrictions for accessing a cache](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache) (by GitHub)
- [Do not use cache when installing packages](https://docs.datadoghq.com/security/code_security/static_analysis/static_analysis_rules/docker-best-practices/pip-no-cache/) (by Datadog)
- [Build cache invalidation](https://docs.docker.com/build/cache/invalidation/) (by Docker)
- [Docker Best Practices for Python Developers](https://testdriven.io/blog/docker-best-practices/) (Blog)
- [Enhancing Developer Experience: Accelerating Docker Image Builds by 90% Using GitHub Actions Cache](https://medium.com/@eelzinaty/enhancing-developer-experience-accelerating-docker-image-builds-by-90-using-github-actions-cache-839acf09196c) (Medium)
- [Cache management with GitHub Actions](https://docs.docker.com/build/ci/github-actions/cache/) (by Docker)
- [self-hosted runnerと actions/cache の噛み合わせが悪かった件](https://speakerdeck.com/whywaita/self-hosted-runner-meets-actions-cache-githubactionsmeetup-2): self-hostedとactions/cacheは遅い
- [やんないほうがいいかも、GitHub Actions の setup-xxx での依存キャッシュ保存](https://ymmt.hatenablog.com/entry/2024/10/02/222243): PR間でCacheが共有できないからmainのpush時だけcacheを保存するようにする
- [GitHub Actions 上での Go の Docker ビルドを高速化する](https://zenn.dev/takamin55/articles/8c68349a069b4c) `--mount=type=cache` の活用
- [2024年版のDockerfileの考え方＆書き方](https://future-architect.github.io/articles/20240726a/) `--mount=type=cache` のかつよう