GitHubActionsを並列で実行する
以前、GitHubActionsを利用して、Firebaseにデプロイする設定を実施しました。
その後CIを実装したのですが、直列で実行していました。
個人開発レベルであれば、直列でも問題ないと思いつつ、やはり並列実行できる部分は並列にしたいと思うのが人の性…。
並列実行にチャレンジしました。
はじめに
大前提として、現時点ではフロントエンドのみ実装しており、かつ環境はDockerを利用しています。
現在、CIで実施しているのはESLint
とVitest
の実行です。
さらに、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/checkout
にfetch-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-from
とcache-to
で指定できます。それぞれのオプションについてはGitHubの公式を確認してください。
今回、フロントエンドのDockerとFirebaseのエミュレータのDockerをBuildしており、1つ注意点があります。
それはscope
の指定です。scope
を指定しないと、同じ場所へCacheしてしまい上書きしてしまいます。
複数のDockerをBuildする際は指定が必要にありますのでご注意ください。
以下の記事を参考にさせていただきました。ありがとうございます。
また、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