search
LoginSignup
5

posted at

updated at

Organization

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

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 のリクエスト負荷を下げたい。

TL; DR (今北産業)

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

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

マスター動くものをくれ

  1. actions/cache@v2 アクションを使って指定したディレクトリをキャッシュする。

    # すでに同じ key(キャッシュの ID)が存在する場合は、
    # キャッシュを path にリストア(復元)して cache-hit
    # を true にセットします。
    - name: Enable Cache/Restore for image archive
      id: cache
      uses: actions/cache@v2
      with:
        path: <キャッシュしたいディレクトリ>
        key: <キャッシュの ID>
    
  2. キャッシュがない場合の処理

    • Docker イメージをビルドしてキャッシュしたいディレクトリに tar 形式でイメージを出力する。(キャッシュされる)
      • 条件式: if: steps.cache.outputs.cache-hit != 'true'
      • 実行内容: docker save --output <キャッシュ先のファイルパス> <キャッシュしたいイメージ名>
  3. キャッシュがある場合の処理

    • docker load で事前に tar をインポートする。(docker rundocker build 前に読み込んでおく)
      • 条件式: if: steps.cache.outputs.cache-hit == 'true'
      • 実行内容: docker load --input <キャッシュ先のファイルパス>

マスター、瓶ごとくれ

以下は、テストの実行環境の整ったコンテナに、チェックアウトしたリポジトリをマウントしてテスト(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 バージョンでユニット・テストを行い、Go プログラムの出力結果を PHP および Python で検証するテストです。

各々 docker compose を利用してコンテナ上でテストしていますが、いずれもキャッシュされます。

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 cache ID from file hash
        uses: KEINOS/gh-action-hash-for-cache@main
        id: imagetag
        # Udate the hash if Dockerfile/go.mod is changed or the month has changed.
        with:
          path: |
            ./go.mod
            ./.github/Dockerfile
            ./.github/docker-compose.yml
            ./.github/workflows/unit-tests.yml
          variant: $(TZ=UTC-9 date '+%Y%m')

      - name: Enable Cache
        id: cache
        uses: actions/cache@v2
        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 }}/github_v1_14_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_15_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_16_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_17_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_18_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_19_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_latest_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_php_1.tar
          docker load --input ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_python_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.14-alpine
          docker pull golang:1.15-alpine
          docker pull golang:1.16-alpine
          docker pull golang:1.17-alpine
          docker pull golang:1.18-alpine
          docker pull golang:1.19-alpine
          docker pull golang:alpine
          docker pull php:alpine
          docker pull python:alpine

      - name: Build Docker images if no-cache
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          docker-compose --file ./.github/docker-compose.yml 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 }}/github_v1_14_1.tar github_v1_14:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_15_1.tar github_v1_15:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_16_1.tar github_v1_16:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_17_1.tar github_v1_17:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_18_1.tar github_v1_18:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_v1_19_1.tar github_v1_19:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_latest_1.tar github_latest:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_php_1.tar github_php:latest
          docker save --output ${{ env.PATH_CACHE }}/${{ steps.imagetag.outputs.hash }}/github_python_1.tar github_python:latest

      - name: Run tests on Go 1.14
        run: docker-compose --file ./.github/docker-compose.yml run v1_14
      - name: Run tests on Go 1.15
        run: docker-compose --file ./.github/docker-compose.yml run v1_15
      - name: Run tests on Go 1.16
        run: docker-compose --file ./.github/docker-compose.yml run v1_16
      - name: Run tests on Go 1.17
        run: docker-compose --file ./.github/docker-compose.yml run v1_17
      - name: Run tests on Go 1.18
        run: docker-compose --file ./.github/docker-compose.yml run v1_18
      - name: Run tests on Go 1.19
        run: docker-compose --file ./.github/docker-compose.yml run v1_19
      - name: Run tests on latest Go
        run: docker-compose --file ./.github/docker-compose.yml run latest
      - name: Compatibility test on PHP
        run: docker-compose --file ./.github/docker-compose.yml run php
      - name: Compatibility test on Python
        run: docker-compose --file ./.github/docker-compose.yml run python

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
What you can do with signing up
5