🍙

GitHubActionsを並列で実行する

2023/06/12に公開

以前、GitHubActionsを利用して、Firebaseにデプロイする設定を実施しました。
その後CIを実装したのですが、直列で実行していました。
個人開発レベルであれば、直列でも問題ないと思いつつ、やはり並列実行できる部分は並列にしたいと思うのが人の性…。
並列実行にチャレンジしました。

https://zenn.dev/sg4k0/articles/e71d87efa33050

はじめに

大前提として、現時点ではフロントエンドのみ実装しており、かつ環境はDockerを利用しています。
現在、CIで実施しているのはESLintVitestの実行です。
さらに、Dockerはあくまでも開発環境とBuildにのみ利用しており、DockerをレジストリへPushするようなことはしていません。
そのため、GitHubActionsのCacheを利用した並列化を実施しています。

今回説明のために利用するシステムのディレクトリ構成は以下の通りです。
firebase_emulatorはFirebaseのエミュレータを起動させるための環境、frontendはReactを利用したフロントエンドのシステムになります。

app
├─ .github
│ └── workflows
|    └── test.yml
├─ firebase_emulator
│ └── Dockerfile
├─ frontend
│ ├── Dockerfile
│ └── package.jsonやyarn.lockなど
└─ docker-compose.yml

1. 環境準備用のJob

ESLintおよびVitestを並列で実行させるために、前段で各Jobで必要となるDockerImageのCacheとnode_modulesのCacheを作成します。
以下が環境準備用のJobになります。

setup:
  name: Setup
  runs-on: ubuntu-latest
  outputs:
    commit-hash: ${{ steps.yarn-lock-file-commit-hash.outputs.commit-hash }}
  timeout-minutes: 10
  steps:
    - name: checkout pushed commit
      uses: actions/checkout@v3
      with:
        fetch-depth: 0
    - name: Yarn Lock File Commit Hash
      id: yarn-lock-file-commit-hash
      run: |
        COMMIT_HASH="$(git log -n 1 --pretty=%H --date-order -- frontend/yarn.lock)"
        echo "COMMIT_HASH=${COMMIT_HASH}" >> $GITHUB_ENV
        echo "commit-hash=$COMMIT_HASH" >> GITHUB_OUTPUT
    - uses: docker/setup-buildx-action@v2
    - uses: docker/build-push-action@v4
      with:
        context: ./frontend
        build-args: |
          NODE_VER=${{ env.NODE_VER }}
          FIREBASE_VER=${{ env.FIREBASE_VER }}
        tags: test-react:latest
        load: true
        cache-from: type=gha,scope=react
        cache-to: type=gha,mode=max,scope=react
    - uses: docker/build-push-action@v4
      with:
        context: ./firebase_emulator
        build-args: |
          NODE_VER=${{ env.NODE_VER }}
          OPEN_JDK_VER=${{ env.OPEN_JDK_VER }}
          FIREBASE_VER=${{ env.FIREBASE_VER }}
        tags: test-emulator:latest
        load: true
        cache-from: type=gha,scope=emulator
        cache-to: type=gha,mode=max,scope=emulator
    - name: Cache node_modules
      uses: actions/cache@v3
      id: test_node_modules
      with:
        path: /tmp/node_modules/
        key: test_node_modules-${{env.COMMIT_HASH}}
    - name: Cache Directory
      if: steps.test_node_modules.outputs.cache-hit != 'true'
      run: |
        sudo mkdir -p /tmp/node_modules
    - name: docker volume create
      run: |
        docker volume create test_node_modules
        sudo cp -r /tmp/node_modules/. /var/lib/docker/volumes/test_node_modules/_data
    - name: run test on docker-compose
      run: |
        docker compose run --rm react yarn install
        sudo cp -rf /var/lib/docker/volumes/test_node_modules/_data/. /tmp/node_modules
      working-directory: ./

それぞれ要所要所を抜き出して説明していきます。

node_modulesのCache

Outputsの設定

  outputs:
    commit-hash: ${{ steps.yarn-lock-file-commit-hash.outputs.commit-hash }}
[省略]
    - name: checkout pushed commit
      uses: actions/checkout@v3
      with:
        fetch-depth: 0
    - name: Yarn Lock File Commit Hash
      id: yarn-lock-file-commit-hash
      run: |
        COMMIT_HASH="$(git log -n 1 --pretty=%H --date-order -- frontend/yarn.lock)"
        echo "COMMIT_HASH=${COMMIT_HASH}" >> $GITHUB_ENV
        echo "commit-hash=$COMMIT_HASH" >> GITHUB_OUTPUT

並列化したJobに引き渡す引数を定義します。
今回、yarn.lockを最後に更新したCommitのHashを渡しています。(後述)
CommitのHashを取得するためには、actions/checkoutfetch-depth: 0を指定する必要があります。
デフォルトでは最後のCommitLogのみ取得されるため、fetch-depth: 0を指定してすべてのCommitLogを取得します。
git logコマンドを利用して、yarn.lockを最後に更新したCommitのHashを1行取得しています。
その結果を環境変数COMMIT_HASHとOutputsのcommit-hashに設定しています。
Outputsはsteps.[id].outputs.[key]で該当StepのOutputを指定します。

Cacheの読み込みおよびCache用ディレクトリ作成

    - name: Cache node_modules
      uses: actions/cache@v3
      id: test_node_modules
      with:
        path: /tmp/node_modules/
        key: test_node_modules-${{env.COMMIT_HASH}}
    - name: Cache Directory
      if: steps.test_node_modules.outputs.cache-hit != 'true'
      run: |
        sudo mkdir -p /tmp/node_modules

GitHubActionsに用意されているactions/cacheを利用して、node_modulesのCacheを作成します。
actions/cacheは該当するkeyが存在した場合に指定されたpathにCacheを展開するようになっています。
同じkeyでCacheを上書きすることができないため、yarn.lockが更新されたらkeyが更新されるようにCommitのHashを利用するようにしました。
そうすることで、yarn.lockが更新されていないときは前回のCacheを利用するようになり、yarn.lockが更新されたら新たにCacheが作成されるようになります。
if: steps.test_node_modules.outputs.cache-hit != 'true'はCacheがHitしなかったときの処理で、Cacheが読み込めなかったときは/tmp/node_modulesのディレクトリを作成するようにしています。
CacheがHitしないときは前回のCommitのHashを参照するようにしたら更にyarn installの時間を短縮できるような気がしますが、今回はそこまで実施していません。
また、コンテナの実行権限をroot以外にしている(nodeのイメージをそのまま利用しているなど)場合はchownなどでディレクトリの権限を変更する必要があります。

Cacheの展開

    - name: docker volume create
      run: |
        docker volume create test_node_modules
        sudo cp -r /tmp/node_modules/. /var/lib/docker/volumes/test_node_modules/_data
    - name: run test on docker-compose
      run: |
        docker compose run --rm react yarn install
        sudo cp -rf /var/lib/docker/volumes/test_node_modules/_data/. /tmp/node_modules
      working-directory: ./

DockerのVolumeを作成し、Cacheが展開された/tmp/node_modulesをDockerのVolumeへコピーしています。
なぜそんなことをしているかというと、docker-compose.ymlで名前付きボリュームを作成しており、開発環境で利用するYAMLをそのまま利用したかったため、割と力技で名前付きボリューム内へコピーしています。
なお、外部で作成されたVolumeを利用するためにはexternalを指定しないといけないのですが、開発環境ではdocker-compose.yml内に閉じておいてほしかったため、環境変数で制御するように修正しました。

volumes:
  node_modules:
    name: test_node_modules
    external: ${VOLUME_EXTERNAL:-false}

yarn install完了後、Volume内のデータを/tmp/node_modulesへコピーしています。これは、CacheがHitしなかったときの対応となります。
コピー部分は別のStepに分けて、CacheがHitしなかったときだけ動作するようにしておくほうが後々installするpackageの数が増えたときに影響を受けなくてすむと思います。

DockerのCache

BuildKitの有効化

    - uses: docker/setup-buildx-action@v2

docker/setup-buildx-actionを利用することで、Buildkitを有効化できます。
その結果、Docker Buildのレイヤーキャッシュを有効にすることができます。

DockerのCacheの読み込みおよびImageのLoad

    - uses: docker/build-push-action@v4
      with:
        context: ./frontend
        build-args: |
          NODE_VER=${{ env.NODE_VER }}
          FIREBASE_VER=${{ env.FIREBASE_VER }}
        tags: test-react:latest
        load: true
        cache-from: type=gha,scope=react
        cache-to: type=gha,mode=max,scope=react
    - uses: docker/build-push-action@v4
      with:
        context: ./firebase_emulator
        build-args: |
          NODE_VER=${{ env.NODE_VER }}
          OPEN_JDK_VER=${{ env.OPEN_JDK_VER }}
          FIREBASE_VER=${{ env.FIREBASE_VER }}
        tags: test-emulator:latest
        load: true
        cache-from: type=gha,scope=emulator
        cache-to: type=gha,mode=max,scope=emulator

docker/build-push-actionはDocker ImageをBuildおよびPushするためのアクションになります。
このアクションにはキャッシュ機能もついており、cache-fromcache-toで指定できます。それぞれのオプションについてはGitHubの公式を確認してください。
今回、フロントエンドのDockerとFirebaseのエミュレータのDockerをBuildしており、1つ注意点があります。
それはscopeの指定です。scopeを指定しないと、同じ場所へCacheしてしまい上書きしてしまいます。
複数のDockerをBuildする際は指定が必要にありますのでご注意ください。
以下の記事を参考にさせていただきました。ありがとうございます。
https://pc.atsuhiro-me.net/entry/2023/02/05/111750

また、loadを指定することで、CacheからDocker ImageをLoadすることができます。
Loadする際の注意点として、docker-compose.ymlに指定しているImage名と指定しているImage名をあわせる必要があります。
合わせることで事前にLoadしたイメージが利用されて、docker compose runを行う際にBuildされなくなります。

  react:
    image: test-react:latest
    build:
      args:
        - NODE_VER=18.16.0-slim
        - FIREBASE_VER=12.3.0
      context: ./frontend

2. ESLint、VitestのJob

ESLintを実行するためのJobが以下となります。

  lint:
    name: Lint
    needs:
      - setup
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      COMMIT_HASH: ${{ needs.setup.outputs.commit-hash }}
    steps:
      - name: checkout pushed commit
        uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: docker/setup-buildx-action@v2
      - uses: docker/build-push-action@v4
        with:
          context: ./frontend
          build-args: |
            NODE_VER=${{ env.NODE_VER }}
            FIREBASE_VER=${{ env.FIREBASE_VER }}
          tags: test-react:latest
          load: true
          cache-from: type=gha,scope=react
          cache-to: type=gha,mode=max,scope=react
      - name: Cache node_modules
        uses: actions/cache@v3
        id: test_node_modules
        with:
          path: /tmp/node_modules/
          key: test_node_modules-${{env.COMMIT_HASH}}
      - name: docker volume create
        run: |
          docker volume create test_node_modules
          sudo cp -r /tmp/node_modules/. /var/lib/docker/volumes/test_node_modules/_data
      - name: run lint
        run: docker compose run --rm react yarn lint
        working-directory: ./

Cache周りのActionは環境準備用のJobと変わりません。
違いはneedsの指定と、yarn.lockのCommitのHashを取得するところのみとなります。

[省略]
    needs:
      - setup
[省略]
    env:
      COMMIT_HASH: ${{ needs.setup.outputs.commit-hash }}

needsは事前に動いておく必要があるJobを指定します。今回は環境準備用のJobが事前に動いておく必要があるため指定しています。
また、yarn.lockのCommitのHashは環境準備用のJobでOutputsに指定した値を環境変数COMMIT_HASHに設定しています。
needs.[Job名].outputs.[key]で参照可能です。

Vitestについても、最後に実施するStepの内容が異なるだけです。

最後に

並列で実行してみて、CacheがきいたおかげでCIの実行時間を1分ほど短縮することができました。
個人開発なのでそこまで恩恵はないのですが、プロダクトでGitHubActionsを利用されている方などは並列化はデリバリー速度にも大きく影響してくるのではないでしょうか。
Docker Volumeのコピーのところなどは力技感があり、もっとうまくできないものか悩みますが、今の構成ではこうならざるを得ない気もします。
Build用のDocker Imageを作成するというのも手だとは思っているのですが、レジストリわざわざ使うのもな…と思いこの手段で実施してみました。

どなたかのお役に立てば幸いです。

※最終的に以下のWorkflowになりました。

name: FrontEnd Testing
on:
  pull_request:
env:
  NODE_VER: 18.16.0-slim
  OPEN_JDK_VER: 20-slim
  FIREBASE_VER: 12.3.0
  VOLUME_EXTERNAL: true
jobs:
  setup:
    name: Setup
    runs-on: ubuntu-latest
    outputs:
      commit-hash: ${{ steps.yarn-lock-file-commit-hash.outputs.commit-hash }}
    timeout-minutes: 10
    steps:
      - name: checkout pushed commit
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - name: Yarn Lock File Commit Hash
        id: yarn-lock-file-commit-hash
        run: |
          COMMIT_HASH="$(git log -n 1 --pretty=%H --date-order -- frontend/yarn.lock)"
          echo "COMMIT_HASH=${COMMIT_HASH}" >> $GITHUB_ENV
          echo "commit-hash=$COMMIT_HASH" >> GITHUB_OUTPUT
      - uses: docker/setup-buildx-action@v2
      - uses: docker/build-push-action@v4
        with:
          context: ./frontend
          build-args: |
            NODE_VER=${{ env.NODE_VER }}
            FIREBASE_VER=${{ env.FIREBASE_VER }}
          tags: test-react:latest
          load: true
          cache-from: type=gha,scope=react
          cache-to: type=gha,mode=max,scope=react
      - uses: docker/build-push-action@v4
        with:
          context: ./firebase_emulator
          build-args: |
            NODE_VER=${{ env.NODE_VER }}
            OPEN_JDK_VER=${{ env.OPEN_JDK_VER }}
            FIREBASE_VER=${{ env.FIREBASE_VER }}
          tags: test-emulator:latest
          load: true
          cache-from: type=gha,scope=emulator
          cache-to: type=gha,mode=max,scope=emulator
      - name: Cache node_modules
        uses: actions/cache@v3
        id: test_node_modules
        with:
          path: /tmp/node_modules/
          key: test_node_modules-${{env.COMMIT_HASH}}
      - name: Cache Directory
        if: steps.test_node_modules.outputs.cache-hit != 'true'
        run: |
          sudo mkdir -p /tmp/node_modules
      - name: docker volume create
        run: |
          docker volume create test_node_modules
          sudo cp -r /tmp/node_modules/. /var/lib/docker/volumes/test_node_modules/_data
      - name: run test on docker-compose
        run: |
          docker compose run --rm react yarn install
          sudo cp -rf /var/lib/docker/volumes/test_node_modules/_data/. /tmp/node_modules
        working-directory: ./
  lint:
    name: Lint
    needs:
      - setup
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      COMMIT_HASH: ${{ needs.setup.outputs.commit-hash }}
    steps:
      - name: checkout pushed commit
        uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: docker/setup-buildx-action@v2
      - uses: docker/build-push-action@v4
        with:
          context: ./frontend
          build-args: |
            NODE_VER=${{ env.NODE_VER }}
            FIREBASE_VER=${{ env.FIREBASE_VER }}
          tags: test-react:latest
          load: true
          cache-from: type=gha,scope=react
          cache-to: type=gha,mode=max,scope=react
      - name: Cache node_modules
        uses: actions/cache@v3
        id: test_node_modules
        with:
          path: /tmp/node_modules/
          key: test_node_modules-${{env.COMMIT_HASH}}
      - name: docker volume create
        run: |
          docker volume create test_node_modules
          sudo cp -r /tmp/node_modules/. /var/lib/docker/volumes/test_node_modules/_data
      - name: run lint
        run: docker compose run --rm react yarn lint
        working-directory: ./
  test:
    name: Test
    needs:
      - setup
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      COMMIT_HASH: ${{ needs.setup.outputs.commit-hash }}
    steps:
      - name: checkout pushed commit
        uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: docker/setup-buildx-action@v2
      - uses: docker/build-push-action@v4
        with:
          context: ./frontend
          build-args: |
            NODE_VER=${{ env.NODE_VER }}
            FIREBASE_VER=${{ env.FIREBASE_VER }}
          tags: test-react:latest
          load: true
          cache-from: type=gha,scope=react
          cache-to: type=gha,mode=max,scope=react
      - uses: docker/build-push-action@v4
        with:
          context: ./firebase_emulator
          build-args: |
            NODE_VER=${{ env.NODE_VER }}
            OPEN_JDK_VER=${{ env.OPEN_JDK_VER }}
            FIREBASE_VER=${{ env.FIREBASE_VER }}
          tags: test-emulator:latest
          load: true
          cache-from: type=gha,scope=emulator
          cache-to: type=gha,mode=max,scope=emulator
      - name: Cache node_modules
        uses: actions/cache@v3
        id: test_node_modules
        with:
          path: /tmp/node_modules/
          key: test_node_modules-${{env.COMMIT_HASH}}
      - name: docker volume create
        run: |
          docker volume create test_node_modules
          sudo cp -r /tmp/node_modules/. /var/lib/docker/volumes/test_node_modules/_data
      - name: run test
        run: |
          docker compose up -d emulator
          sleep 10
          docker compose run --rm react yarn test
        working-directory: ./

Discussion