Go × GitHub Actions CI構築 ~ MySQL・Redis docker-compose 別リポジトリをGit clone する~

今回はGitHub ActionsでGoのAPIサーバーのCI構築を行いたいと思います。

GitHub Actionsのチュートリアル的な部分には触れませんのでご留意ください。

TL;DR


以下の構成でCI構築を行います。

上記の構成に至った理由をざっくりと説明いたします。

開発環境ではdocker-composeを使用してコンテナを制御している場合が多いと思いますが、管理するコンテナが増えるほどCI上に環境を再現することが手間になっていきます。特に今回のようにdocker-compose.yamlやDBのDockerFileを別リポジトリに置いている場合は尚更です。 そこでそっくりそのまま同じ環境を構築してCIを走らせたいという方針から本実装に至りました。 GitHub Actionsに提供されている Container serviceでMySQLやRedisを個別に立てて再現することもできましたが、 リポジトリを丸ごとcloneする方が変更箇所が少ない為この方式を採用しました。

実装

.github/workflows/cicd.yaml

今回はCI中に別のリポジトリを git cloneします。
よって二つのリポジトリをそれぞれ次のように呼びます。

{CurrentRepo}: APIサーバーのレポジトリ名
{ParRepo}: 親ディレクトリとなるdocker-compose.yamlが配置してあるレポジトリ名

以下が実装です。

on: push

defaults:
  run:
    shell: bash

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  IMAGE_CACHE_DIR: tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image
  CACHE_VER: 1 # キャッシュをクリアしたい時は数値に+1する

jobs:
  image-cache-or-build: # Dockerfile.devがキャッシュキー 変更があれば Clone Build する
    runs-on: ubuntu-latest
    env:
      API_IMAGE_TAG: {docker-composeのservice名(api)}:latest
      MYSQL_IMAGE_TAG: {docker-composeのservice名(rdb)}:latest
      REDIS_IMAGE_TAG: {docker-composeのservice名(NoSQL)}:latest

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker
      id: cache-docker-image
      uses: actions/cache@v2
      with:
        path: ${{env.IMAGE_CACHE_DIR}}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-${{ hashFiles('Dockerfile.dev')}}
        restore-keys: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-

    - name: Clone And Setup Dir
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      env:
        TOKEN: ${{ secrets.ACCESS_TOKEN }}
      run: |
        mkdir -p tmp
        cd ..
        git config --global url."https://${TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
        git clone https://github.com/{OrganizationName}/{ParRepo}
        cp -rp {CurrentRepo} {ParRepo}/
        mv {ParRepo}/ {CurrentRepo}/tmp/

    - name: Docker build
      id: docker-build
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        cd tmp/{ParRepo}
        docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1

    - name: Docker Save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        mkdir -p ${IMAGE_CACHE_DIR}
        docker image save -o ${IMAGE_CACHE_DIR}/api.tar ${{ env.API_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/mysql.tar ${{ env.MYSQL_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/redis.tar ${{ env.REDIS_IMAGE_TAG}}

  test:
    needs: image-cache-or-build
    runs-on: ubuntu-latest
    # docker-composeのenvはここに設置する

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker
      id: cache-docker-image
      uses: actions/cache@v2
      with:
        path: ${{env.IMAGE_CACHE_DIR}}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-${{ hashFiles('Dockerfile.dev')}}
        restore-keys: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-

    - name: Clone And Setup Dir
      env:
        TOKEN: ${{ secrets.ACCESS_TOKEN }}
      run: |
        mkdir -p tmp
        cd ..
        git config --global url."https://${TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
        git clone https://github.com/{OrganizationName}/{ParRepo}
        cp -rp {CurrentRepo} {ParRepo}/
        mv {ParRepo}/ {CurrentRepo}/tmp/

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: ls  -1 ${IMAGE_CACHE_DIR}/*.tar | xargs -L 1 docker load -i

    - name: docker-compose up
      run: |
        cd tmp/ {ParRepo}
        docker-compose up -d

    - name: Generate mockgen And gqlgen
      run: |
        cd tmp/ {ParRepo}
        docker-compose exec -T api make mockgen
        docker-compose exec -T api make gqlgen

    - name: Unit Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v `go list ./src/.../. | grep -v ./src/infra/redis | grep -v ./src/infra/server`

    - name: Integration Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v ./test/...

おおきな流れとしては次のとおりです。

まず二つのjobsを定義します。

  • image-cache-or-build

  • test

jobには依存関係があり順番に処理されます。

image-cache-or-build ではdocker image をキャッシュします。 キャッシュがヒットすれば何もせずにtest jobに進み、 キャッシュがヒットしなければ新たにdocker image を build して tarファイルとしてキャッシュを更新します。

test ではcloneしてきたdocker-compose.yamlを起動して テストを行います。 image-cache-or-build でキャッシュがヒットしていれば既存のtarファイルから、 キャッシュがヒットしなければ、build したimageからdocker-composeで起動します。

それでは次に一つ一つ細かく見ていきましょう。

詳細

env

まずは環境変数です。 GitHub Actionsはenvを定義する位置によってスコープが変わります。 トップレベルのenvは次のように定義しています。

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  IMAGE_CACHE_DIR: tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image
  CACHE_VER: 3 # キャッシュをクリアしたい時は数値に+1する

DOCKER_BUILDKIT,COMPOSE_DOCKER_CLI_BUILDはBuildKitを有効にします。

BuildKit によるイメージ構築 | Docker ドキュメント

IMAGE_CACHE_DIR, IMAGE_CACHE_KEYはその名の通りキャッシュに使用します。
CACHE_VERはキャッシュを操作する為の環境変数です。後述しますが、GitHub Actionsのキャッシュには公式の提供している、actions/cache@v2 を使用します。
このキャッシュ機構には既存のキャッシュをリセットする為の機能が存在しません。 故にキャッシュ KEYにバージョンナンバーとした数値を含めることで、キャッシュをリセットしたい時に手動で変更することができます。この数値を変更してActionsを走らせるとキャッシュはリセットされます。

以上がグローバルに定義した環境変数です。

次にそれぞれのjobの中身を見ていきます。

image-cache-or-build

jobs:
  image-cache-or-build:
    env:
      API_IMAGE_TAG: {docker-composeのservice名(api)}:latest
      MYSQL_IMAGE_TAG: {docker-composeのservice名(rdb)}:latest
      REDIS_IMAGE_TAG: {docker-composeのservice名(NoSQL)}:latest

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

envにはdocker image をtarファイルに保存する時に呼び出す image名:タグ名を定義しています。 最初のステップでcheck out することでリポジトリの中に入っています。

Caceh docker

    - name: Cache docker
      id: cache-docker-image
      uses: actions/cache@v2
      with:
        path: ${{env.IMAGE_CACHE_DIR}}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-${{ hashFiles('Dockerfile.dev')}}
        restore-keys: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-

次にキャッシュ処理です
hashFiles('Dockerfile.dev') に注目して下さい。
このように記述することで引数に指定したファイル内の変更を検知してキャッシュの更新を行います。
開発環境のDockerfileはそうそう変更されることはありません。
つまり今回のCI環境ではほとんどがキャッシュされたimageからbuildすることになるので、常に高速なCIを期待できそうです。

もしもDockerfileが頻繁に変更されるのであれば今回の方式はおすすめできません。

次にGitとディレクトリの操作を行います。

Clone And Setup Dir

    - name: Clone And Setup Dir
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      env:
        TOKEN: ${{ secrets.ACCESS_TOKEN }}
      run: |
        mkdir -p tmp
        cd ..
        git config --global url."https://${TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
        git clone https://github.com/{OrganizationName}/{ParRepo}
        cp -rp {CurrentRepo} {ParRepo}/
        mv {ParRepo}/ {CurrentRepo}/tmp/

まずは if:** の記述で 前処理のキャッシュがヒットしたかどうかを判定しています。
キャッシュが残っている場合はcloneしてくる必要がない為この処理はスキップされます。

次にgit config でトークンをセットしています。 cloneしてくるリポジトリはプライベートなので認証が必要になります。 このTOKENは GitHubPersonal Access Token というものを使用します。

https://docs.github.com/ja/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token

このトークンを生成し、GitHubサービス上でリポジトリのsecretsにTOKENとしてセットしておきます。 *このトークンは権限を絞れますが、マシンユーザーとして専用のアカウントを作成する事が推奨されています。

必要なリポジトリをクローンして {CurrentRepo} の tmp以下に 作業用のディレクトリを構成しています。

CI中のディレクトリ構造はこの時点で以下のようになっています。

**/{CurrentRepo} 
└── **  配下のディレクトリ・ファイル
 └──tmp/
  └──cache/
  └──  {ParRepo} /{CurrentRepo}  ## 実際はこのディレクトリを開発環境に見立てて作業する。

冗長な構造ではありますが、上位の{CurrentRepo} を維持しないと後のstepでエラーになってしまいます。



次にdocker の image 操作です。

Docker build save

if からわかるように残り2つのstepも キャッシュヒットしなかった時だけ実行されます。

    - name: Docker build
      id: docker-build
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        cd tmp/{ParRepo}
        docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1

    - name: Docker Save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        mkdir -p ${IMAGE_CACHE_DIR}
        docker image save -o ${IMAGE_CACHE_DIR}/api.tar ${{ env.API_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/mysql.tar ${{ env.MYSQL_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/redis.tar ${{ env.REDIS_IMAGE_TAG}}

Docker buildではその名の通り image をbuildしていますがこれは直前のstepで構築したtmp/{ParRepo}の中で、同ディレクトリに存在する docker-compose.yamlから起動しています。 docker-composeには API(Go), MySQL, Redis 三つのserviceが定義されております。

Docker Saveではbuild した imageをそれぞれ tar として保存しています。

ここまでが一つ目のjobです。

次は実際にtestを行うjobです。 少し長くなってしまったのだけ要点に絞って解説します。

test

  test:
    needs: image-cache-or-build
    runs-on: ubuntu-latest

needs:** では依存関係を定義しており、このjobはimage-cache-or-buildの後に実行されいます。

Docker load

 - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: ls  -1 ${IMAGE_CACHE_DIR}/*.tar | xargs -L 1 docker load -i

このstepでは事前にキャッシュしたtarファイルのimageをloadしてimageを復元します。 if文にあるようにキャッシュがヒットした時のみ行われ、ヒットしなかった場合はすでにbuildしてあるimageを使用します。

残りの処理をまとめて説明します。

compose up , test , other

- name: docker-compose up
      run: |
        cd tmp/ {ParRepo}
        docker-compose up -d

    - name: Generate mockgen And gqlgen
      run: |
        cd tmp/ {ParRepo}
        docker-compose exec -T api make mockgen
        docker-compose exec -T api make gqlgen

    - name: Unit Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v `go list ./src/.../. | grep -v ./src/infra/redis | grep -v ./src/infra/server`

    - name: Integration Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v ./test/...

ここまで環境構築は終了しているので後は必要なコマンドを実行していくだけです。

まずは 作業ディレクトリに入りコンテナを起動します。
筆者のプロジェクトではmockgen, gqlgenというライブラリで自動生成が必要で、これらはgitignoreされている為このタイミングで生成します。

最後に ユニットテストシステムテストをコンテナの中で実行します。 docker-compose execコマンドに -T オプションを付けないとエラーになるのでご注意ください。

解説は以上となります。

最後に

いかがでしたでしょうか、このDocker save load 方式とキャッシュを利用して 2分の1 程度までCI速度を改善する事ができました。 その他にも色々とキャッシュできそうなとこはありましたが、キャッシュキーにあまり多くを含めるとヒットする確率が減ってしまうのでこのような実装となっています。

本設計の旨味としては開発環境をそのまま再現できることにありましたが、 他の案としては冒頭で述べたようにそれぞれ個別にコンテナを立てる、Goのサーバーだけローカルで動かしgo moduleをキャッシュしておく、などが考えられました。

開発環境が変わったりテストの量が増えてくると要件も変わってくるので今後もベストプラクティスを探っていこうと思います。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

参考記事:

Dockerで構築したRailsアプリをGitHub Actionsで高速にCIする為のプラクティス(Rails 6 API編) - Qiita