GitHub Actions で Docker イメージのキャッシュとリストア（with actions/cache@v2）

> CI/CD の一環として GitHub Actions の **Workflow で Docker を使っています**。この時、<font color=darkorange>**ビルドしたイメージをキャッシュしたい**</font>のです。

と言うのも、デバッグなどでトライ & エラーをするたびにベース・イメージを `pull` したりイメージをビルドするので無料枠のコスト（時間あたりの [API リクエスト数制限](https://docs.github.com/ja/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28)）が気になります。

しかし [actions/cache@v2](https://github.com/actions/cache) でイメージをキャッシュしようにも、**`/var/lib/docker/` のディレクトリが権限の問題でキャッシュできません**。できれば `root` 権限は使いたくないのです。

この記事は、以下の 1 つでもマッチした人向けの記事です：

- **GitHub 公式の `actions/cache@v2` アクションだけでキャッシュ処理を完結させたい**
- Docker Hub や GitHub などの**コンテナ・レジストリにキャッシュ目的で `push` `pull` したくない**
- ベース・イメージが重いのでキャッシュしておきたい（無駄な `docker pull` を避けたい）
- ビルドに時間がかかるのでキャッシュして再利用したい
- 高速化だけでなく、外部への無駄な通信を抑えて GitHub や API のリクエスト負荷を下げたい

:::note info
[actions/cache](https://github.com/actions/cache) の最新バージョンは `v4` です。
動きは変わらないので、`actions/cache@v2` を `actions/cache@v4` に置き換えてご覧ください。
:::

## TL; DR <sub><sup>（今北産業）</sup></sub>

1. `actions/cache@v2` は、指定したディレクトリのキャッシュとリストアをする GitHub アクション
1. `docker save`, `docker load` は、指定した Docker イメージを保存・読み込みするコマンド
1. <font color=darkorange>**上記 2 つを応用して、キャッシュ・ディレクトリに Docker イメージを保存しておく**</font>と、条件が合うと勝手にリカバリ（リストア）してくれるため、リカバリした Docker イメージを読み込むことで再利用できる

## TS; DR <sub><sup>（actions/cache@v2 のキャッシュを理解するまでのコマケーこと）</sup></sub>

### マスター、瓶ごとくれ

- https://github.com/KEINOS/go-totp/blob/main/.github/workflows/unit-tests.yml
    1. 上記は、[複数ファイルから 1 つのハッシュ値を取得するアクション](https://github.com/KEINOS/gh-action-hash-for-cache)で得られた値をキャッシュ用の `key`（キャッシュ ID）として利用しています。つまり、指定したファイルのいずれかに 1 ビットでも変更があると `key` が変わります。仕組みは後述。
    2. キャッシュがある場合は、キャッシュから Docker コンテナのイメージを読み込み、コンテナ上でテストを実行します。
    3. キャッシュがない場合は、Docker コンテナのイメージを作成し、テストが全てパスしたら、キャッシュ・ディレクトリにイメージを保存します。

### マスター、これ何が入ってるの？

以下は、テストの実行環境の整ったコンテナに、チェックアウトしたリポジトリをマウントしてテスト（`hogefuga.sh`）を実行する例です。

この例では、月をまたぐか、Dockerfile が変更されるとキャッシュは再作成されます。これは「キャッシュ ID」に Dockerfile の[ハッシュ値](https://qiita.com/KEINOS/items/c92268386d265042ea16#%E3%83%8F%E3%83%83%E3%82%B7%E3%83%A5%E9%96%A2%E6%95%B0%E3%82%92%E5%AE%8C%E5%85%A8%E3%81%AB%E7%90%86%E8%A7%A3%E3%81%97%E3%81%9F%E6%B0%97%E3%81%AB%E3%81%AA%E3%82%8B%E3%82%B3%E3%83%9E%E3%82%B1%E3%83%BC%E3%81%93%E3%81%A8)と、実行時の日付を利用しているためです。また、[5 GB のキャッシュ領域](https://github.com/actions/cache#cache-limits)を超えた場合は古いキャッシュが削除されます。

**注目すべき箇所は 2 つ**あります。

1. 「キャッシュがある場合」は、`docker load` で tar 出力されたイメージを読み込む箇所
1. 「キャッシュがない場合」は、ビルドした Docker イメージを tar 出力してキャッシュする箇所

```yaml:リポジトリをDockerのコンテナにマウントしてテストを実行する例
name: "Sample Workflow"
on: [push]

env:
  # Docker run する際のコンテナにリポジトリをマウントする先のパス
  PATH_MOUNT: /workspaces/myapp
  # Docker イメージの tar アーカイブ出力先のパス
  PATH_CACHE: /tmp/docker-img-arch

jobs:
  Tests:
    runs-on: ubuntu-latest
    steps:
      # リポジトリの読み込み（fetch-depth=1）
      - name: Check out repo under workspace
        uses: actions/checkout@v2

      # キャッシュ ID の作成（イメージのハッシュと日付から作成。Windows 非互換。次項を参照）
      - name: Create image tag
        id: imagetag
        run: |
          : # Dockerfile からハッシュ値を作成
          HASH_IMAGE=${{ hashFiles('./Dockerfile') }}
          : # 日付と 7 文字のハッシュ値で合計 13 文字の ID を作成
          VARIANT=$(TZ=UTC-9 date '+%Y%m')${HASH_IMAGE:0:7}
          : # イメージのタグを作成
          NAME_IMAGE=myTestImage
          TAG="${NAME_IMAGE}:${VARIANT}"
          : # キャッシュする tar アーカイブ名とパスの設定
          NAME_TAR="${NAME_IMAGE}.${VARIANT}.tar"
          PATH_TAR=${{ env.PATH_CACHE }}"/${NAME_TAR}"
          : # 変数を他の run でも使えるように output
          echo "::set-output name=TAG::${TAG}"
          echo "::set-output name=PATH_TAR::${PATH_TAR}"

      # この Workflow が正常に終了したら path をキャッシュ。
      # すでに key が存在する場合、path にキャッシュをリストアする。
      - name: Enable cache
        id: cache
        uses: actions/cache@v2
        with:
          path: ${{ env.PATH_CACHE }}
          key: ${{ steps.imagetag.outputs.TAG }}

      # キャッシュがある場合は tar をロードしてイメージ一覧に追加
      - name: Load Docker image if exists
        if: steps.cache.outputs.cache-hit == 'true'
        run: docker load --input ${{ steps.imagetag.outputs.PATH_TAR }}

      # キャッシュがない場合は Docker イメージをビルド後、tar アーカイブをキャッシュ先に保存
      - name: Build Docker image and save
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          : # キャッシュディレクトリを作成
          mkdir -p ${{ env.PATH_CACHE }}
          : # 安定したビルドのためにベース・イメージを pull しておく（オプション）
          docker pull mybaseimage:latest
          : # イメージのビルド
          docker build -f './.devcontainer/Dockerfile' -t ${{ steps.imagetag.outputs.TAG }} .
          : # イメージのキャッシュ（tar をキャッシュ・ディレクトリに出力）
          docker save --output ${{ steps.imagetag.outputs.PATH_TAR }} ${{ steps.imagetag.outputs.TAG }}

      # 本処理
      # イメージからコンテナを起動してテスト（entrypoint.sh）を実行
      - name: Run tests for both Go and Shell Script
        run: |
          : # コンテナ内のスクリプトのパスを作成（マウントポイントから見たパス）
          path_entrypoint=${{ env.PATH_MOUNT }}/path/to/hogefuga.sh
          : # 実行
          docker run -u root -v "$(pwd)":${{ env.PATH_MOUNT }} -w ${{ env.PATH_MOUNT }} ${{ steps.imagetag.outputs.TAG }} "$path_entrypoint"

      # その他の処理（例えばカバレッジのアップデート）
      - name: Upload coverage
        uses: codecov/codecov-action@v1
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

```

### キャッシュ ID （ファイルのハッシュ化）部分を汎用化させてみた

上記の例は、ランナー（GitHub Actions を走らせる OS/プラットホーム）が Linux および macOS 限定です。キャッシュ ID の作成部分が Windows プラットホーム互換ではないからです。

**Win・Mac・Linux の、すべてのプラットフォームでキャッシュをしたい**場合は、[汎用のキャッシュ ID 作成用の GitHub Actions](https://github.com/marketplace/actions/file-hash-for-cache) を作成したので利用ください。

- [File Hash for Cache](https://github.com/marketplace/actions/file-hash-for-cache) | Actions | Marketplace @ GitHub

この記事の内容をベースに [Windows にも対応](https://qiita.com/KEINOS/items/ff1bad8fd77bcf4f8428)させたものですが、スッキリと記載できるようになります。

例えば、以下は Go 言語のパッケージを複数の Go バージョンでユニット・テストを行うサンプルです。各々 `docker compose` を利用してコンテナ上でテストしていますが、いずれもビルドされたイメージはキャッシュされます。

```yaml
# Unit testing on vaious Go versions, such as Go 1.18 and later.
# It will test the generated password hash verifying with PHP and Python.
#
# This workflow caches images built with Docker and docker-compose to speed up its execution.
name: UnitTests

on:
  workflow_dispatch:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

env:
  PATH_CACHE: /tmp/docker-img-arch

jobs:
  go:
    name: Run tests on Go via container
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repo
        uses: actions/checkout@v3

      - name: Create image tag from file hash
        uses: KEINOS/gh-action-hash-for-cache@main
        id: imagetag
        # Udate the hash if any file in the path has changed or the month has changed.
        with:
          path: |
            ./go.mod
            ./go.sum
            ./docker-compose.yml
            ./.github/Dockerfile
            ./.github/workflows/unit-tests.yml
          variant: $(TZ=UTC-9 date '+%Y%m')

      - name: Enable Cache
        id: cache
        uses: actions/cache@v3
        with:
          path: ${{ env.PATH_CACHE }}
          key: ${{ steps.imagetag.outputs.hash }}

      - name: Load cached Docker images if any
        if: steps.cache.outputs.cache-hit == 'true'
        run: |
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/go-totp_v1_18_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/go-totp_v1_19_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/go-totp_latest_1.tar

      - name: Pull base images if no-cache
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          : # Pull images one-by-one for stability
          docker pull golang:1.18-alpine
          docker pull golang:1.19-alpine
          docker pull golang:alpine

      - name: Build Docker images if no-cache
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          docker-compose build

      - name: Save/export built images to cache dir if no-cache
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          mkdir -p ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/go-totp_v1_18_1.tar go-totp_v1_18:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/go-totp_v1_19_1.tar go-totp_v1_19:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/go-totp_latest_1.tar go-totp_latest:latest

      - name: Run tests on Go 1.18
        run: docker-compose run v1_18
      - name: Run tests on Go 1.19
        run: docker-compose run v1_19
      - name: Run tests on latest Go
        run: docker-compose run latest
```

- [github.com/KEINOS/go-totp](https://github.com/KEINOS/go-totp/blob/main/.github/workflows/unit-tests.yml) @ GitHub より