LoginSignup
20
6

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

Last updated at Posted at 2021-08-28

GitHub Actions の Workflow で Docker を使っています。しかし、デバッグなどでトライ & エラーをするたびにベース・イメージを pull したりイメージをビルドするので無料枠のコスト(時間あたりの API リクエスト数制限)が気になります。

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

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

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

actions/cache の最新バージョンは v4 です。
動きは変わらないので、actions/cache@v2actions/cache@v4 に置き換えてご覧ください。

TL; DR (今北産業)

  1. actions/cache@v2 は、指定したディレクトリのキャッシュとリストアをする GitHub アクション。
  2. docker save, docker load は、指定した Docker イメージを保存・読み込みするコマンド。
  3. 上記 2 つを応用して、キャッシュ・ディレクトリに Docker イメージを保存しておくことで、勝手にリカバリ(リストア)してくれるため、リカバリした Docker イメージを再利用できる。

TS; DR (actions/cache@v2 のキャッシュを理解するまでのコマケーこと)

マスター、瓶ごとくれ

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

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

この例では、月をまたぐか、Dockerfile が変更されるとキャッシュは再作成されます。これは「キャッシュ ID」に Dockerfile のハッシュ値と、実行時の日付を利用しているためです。また、5 GB のキャッシュ領域を超えた場合は古いキャッシュが削除されます。

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

  1. 「キャッシュがある」場合は、docker load で tar 出力されたイメージを読み込む箇所
  2. 「キャッシュがない」場合はビルドした Docker イメージを tar 出力してキャッシュする箇所
リポジトリを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 を作成したので利用ください。

この記事の内容をベースに Windows にも対応させたものですが、スッキリと記載できるようになります。

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

# 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
20
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
6