こんにちは、フロントエンドエンジニアの小張です。GitHub Actionsの実行時間を削減するために取り組んだことについて紹介します。
経緯
PR TIMESではReactに関するコードを、monorepoとしてprtimes-frontendという1つのリポジトリで管理しています。
GitHub Enterprise Cloudプランでは月50,000分のGitHub Actionsを無料で実行することができますが、prtimes-frontendだけで7割近い時間を消費してしまっていました。またCIに時間がかかることで、Pull Requestを作成した後、10分近く待たないとコードレビューに回すことができず、開発効率が落ちてしまっていました。
そこで現状の使い方を見直して、billable timeの削減に取り組むことになりました。
billable time削減の改善点を探す
billable timeが長いworkflowを特定する
まず直近1ヶ月のGitHub Actionsのbillable timeの情報を、GitHubの設定画面からダウンロードし、Google Sheets上でworkflow別のbillable timeを集計しました。
その結果、Pull Requestごとに実行されている3つのworkflow(clipping-ci、prtimes-ci、prtimes-public-ci)のbillable timeが、prtimes-frontend全体の7割を占めていることがわかりました。
workflowの時間を短縮できるか調査する
上記3つのworkflowは、monorepo内のworkspaceごとに分けられているだけで、基本的には実行しているjobの内容は同じです。実行しているjobとその内容は以下のとおりです。
job | 実行内容 |
---|---|
install-deps | API ClientをOpenAPIから生成し、キャッシュに追加。 |
typecheck | tsc —noEmitを実行する |
lint | XOを実行する |
unit-test | Vitestを実行する |
playwright-integration-test | Playwrightを実行する |
build | vite build もしくはnext buildを行う |
preview | build結果をbranch単位の検証環境にデプロイする |
build-storybook | Storybookをbuildする |
preview-storybook | Storybookのbuild結果をbranch単位の検証環境にデプロイする |
test-storybook | Storybook Test runnerを実行する |
実行内容の詳細について以下をご覧ください。
これらのjobの中で時間がかかっている箇所を探すため、まずKesin11/actions-timelineというActionsを導入し、実行時間の可視化を行いました。以下の記事を参考にしたのでご覧ください。
そして調査の結果、以下のような改善点が見つかりました。
- jobが過剰に分割されており、billable timeで分単位の切り上げや、パッケージインストールなどで余分な実行時間がかかってしまっていた。
- XO、VitestがPull Requestの差分に関わらず、全ファイルに対して回っていた。
- OpenAPIのclientやnode_modulesのキャッシュの保存・取り出しに時間がかかっていた。
不要なworkflow実行がないか調査する
上記の3つのworkflow自体の時間を短くするだけでなく、不要なworkflow呼び出しがないかも調査しました。workflow別のbillable timeを集計した際に利用したGitHubのデータを使い、ユーザー別の消費時間などを調べたところ、以下のような改善点が見つかりました。
4. Pull Requestの作成時にworkflowが実行されるようになっていたが、renovate botによって自動生成された大量のPull Request(1ヶ月に70件ほど)がその対象になっていた。さらにそれぞれが自動でrebaseされるごとにworkflowが実行されてしまっていた。
5. 同じPull Requestにpushするごとにworkflowが実行されてしまっていた。
見つかった改善点とそれに対する施策
見つかった計5つの改善点に対して、それぞれ以下のような対応を行いました。
1. jobが過剰に分割されており、billable timeで分単位の切り上げや、パッケージインストールなどで余分な実行時間がかかってしまっていた。
並列で実行していたGitHub ActionsのJobをまとめるようにしました。
以下の記事をご覧ください。
2. XO、VitestがPull Requestの差分に関わらず、全ファイルに対して回っていた。
Pull Requestの変更に合わせて、適切なファイルに対してXOやVitestを実行するようにしました。
XO
lintは変更があったファイルだけ確認すれば良いので、以下の要件を満たすようにworkflowを設定しました。
- XOの設定ファイルが変更されている場合、全ファイルに対してXOを実行する
- XOの設定ファイルに変更がなく、TSファイルに変更がない場合実行をskipする
- 上記以外のケースでは、変更があったTSファイルに対してXOを実行する
変更があったファイルがglobパターンに一致するか調べるために、以下のactionを使用しています。
# ディレクトリ構成の例
# path/to/workspace
# ├── src
# └── .xo-config.js
jobs:
other-than-unit-test:
- uses: dorny/paths-filter@v2
id: filter
with:
list-files: shell
# Pull Requestで変更があったファイルのpathを抽出する。
filters: |
changed:
- added|modified: 'path/to/workspace/**/*.ts'
- added|modified: 'path/to/workspace/**/*.tsx'
changed-config:
- 'path/to/workspace/.xo-config.js'
# Pull Requestで変更があったファイルに対してのみXOを実行する
- if: steps.filter.outputs.changed == 'true' && steps.filter.outputs.changed-config == 'false'
name: ESLint changed files
run: pnpm run lint ${{ steps.filter.outputs.changed_files }}
# XOの設定が変更されている場合は全ファイルに対して実行する
- if: steps.filter.outputs.changed-config == 'true'
name: ESLint all files
run: pnpm run lint
これにより約5分ほどのbillable time削減をすることができました。
XO実行に対するbillable time | 全ファイル実行 | 変更があったファイルのみ実行 |
---|---|---|
prtimes-ci | 7分 | 1分〜2分 |
Vitest
一方でユニットテストは変更があったファイルとそれらをimportしているファイルで実行する必要があるため、依存関係を考慮する必要があります。
prtimes-frontendでは、src/features
配下の各feature同士は依存関係を持たないような構成にしているため、以下の要件を満たすようにworkflowを設定しました。
src/features
ディレクトリより外側のファイルが変更されている場合、全テストを実行するsrc/features
ディレクトリに変更が閉じている場合、各feature配下に変更があればそのfeature内のテストを実行する
変更があったファイルがglobパターンに一致するか調べるために、以下のactionを使用しています。
(前述のdorny/paths-filterではfiles_ignore
のような否定形が使えなかったので、こちらのactionを採用しています。今後tj-actions/changed-filesに統一する方針です。)
# ディレクトリ構成の例
# path/to/workspace
# ├── src
# │ ├── features
# │ │ ├── feature-a
# │ │ └── feature-b
# │ └── components
# └── public
jobs:
install-deps:
outputs:
is-all-test-needed: ${{ steps.filter.outputs.any_modified }}
steps:
- name: Assess need for all tests
id: filter
uses: tj-actions/changed-files@cbda684547adc8c052d50711417fa61b428a9f88 # v41
# featuresディレクトリ外のファイルに変更があるか確認する
with:
files: |
path/to/workspace/**
files_ignore: |
path/to/workspace/src/features/**
unit-test-all:
needs: [install-deps]
# featuresディレクトリ外のファイルに変更がある場合は、全ファイルに対して実行する
if: needs.install-deps.outputs.is-all-test-needed == 'true'
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
steps:
- name: Unit Test(All)
run: pnpm run test --shard=${{ matrix.shard }}
unit-test-features:
needs: [install-deps]
# featuresディレクトリに変更が閉じている場合のみ、このjobを実行する
if: needs.install-deps.outputs.is-all-test-needed == 'false'
steps:
- name: Assess need for feature tests
id: filter
uses: tj-actions/changed-files@cbda684547adc8c052d50711417fa61b428a9f88 # v41
# features配下のうち、変更があるディレクトリにのみVitestを実行する
with:
files_yaml: |
src/features/feature-a:
- path/to/workspace/src/features/feature-a/**
src/features/feature-b:
- path/to/workspace/src/features/feature-b/**
- name: Unit Test(Only Features Directory)
if: steps.filter.outputs.modified_keys != ''
run: pnpm run test ${{ steps.filter.outputs.modified_keys }}
これにより変更がsrc/feature
に閉じている場合には、17分〜20分ほどのbillable time削減をすることができました。
Vitest実行に対するbillable time | 全テスト実行 | 変更があったfeatureのみ実行 |
---|---|---|
prtimes-ci | 21分 | 1分〜4分 |
3. OpenAPIのclientやnode_modulesのキャッシュの保存・取り出しに時間がかかっていた。
- キャッシュを復元・保存する必要がないjobで、OpenAPIのclientのキャッシュを保存しないようにする。
- node_modulesのキャッシュを行わず、全てのjobで毎回
pnpm install
する
OpenAPI Clientとnode_modulesで行った施策が異なるため、それぞれ分けて紹介します。
OpenAPI Client
今まではOpenAPIのclientをworkflow間でキャッシュして利用するため、以下のように各jobでcacheを保存する処理を実行していました。
しかし、キャッシュに更新が発生した場合でもinstall-depsで一度新規キャッシュを保存しているため、各jobでキャッシュを保存する必要はありませんでした。そこでactions/cache/restoreを利用して、各jobで無駄なキャッシュの保存を行わないように変更しました。
具体的には以下のように使うactionを変えるだけで対応できます。
steps:
- name: Cache
id: cache-client
-- uses: actions/cache@v3
++ uses: actions/cache/restore@v3
with:
path: ./packages/openapi/src
key: ${{ runner.os }}-prtimes-openapi-client-cache-${{ hashFiles('./packages/openapi/public/docs') }}
- name: Generate OpenAPI client when no cache hit
shell: bash
if: steps.cache-client.outputs.cache-hit != 'true'
run: make codegen
working-directory: ./packages/openapi
これによりbillable timeに最大5分ほどの改善が見られました。
workflow | before | after |
---|---|---|
prtimes-ci | 18分 | 13分 |
prtimes-public-ci | 19分 | 18分 |
clipping-ci | 23分 | 19分 |
さらに既存のキャッシュを利用できる場合、install-depsでのキャッシュの復元や保存も不要になるため、actions/cacheのlookup-onlyオプションをtrueにしました。
これによりキャッシュが利用できる場合、約1分ほどbillable timeを改善することができました。
job | before | after |
---|---|---|
install-deps(キャッシュがある場合) | 1分23秒 | 8秒 |
node_modules
上記のキャッシュ機構の改善を行った上でも、node_modulesのキャッシュの読み書きに時間がかかっていることがわかりました。node_modulesのキャッシュの復元、保存にそれぞれ一回1分以上かかる一方で、pnpm install
の実行時間は50秒ほどだったので、node_modulesに関してはキャッシュ機構を廃したほうが速くなることがわかりました。
具体的には以下の状態から、
以下のように変更しました。
この変更により、billable timeはworkflow全体で約3分ほど削減されました。
workflow | before | after |
---|---|---|
prtimes-ci | 14分 | 11分 |
4. Pull Requestの作成時にworkflowが実行されるようになっていたが、renovate botによって自動生成された大量のPull Request(1ヶ月に70件ほど)がその対象になっていた。さらにそれぞれが自動でrebaseされるごとにworkflowが実行されてしまっていた。
renovateが生成するPull Request上でのCI実行内容を最低限に絞る。またPull Requestの数自体を減らす。
以下の記事をご覧ください。
5. 同じPull Requestにpushするごとにworkflowが実行されてしまっていた。
同じPull Request上で同じworkflowが実行されているとき、古い方の実行をキャンセルするようにする
各workflowの設定ファイルの最上層に下記の設定を追加しました。
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
まだ設定してから日が浅くどれほど効果があるか測定中ですが、現段階で50回のworkflow実行に対して3回が重複実行のためキャンセルされているようです。
行わなかった施策
Vitestの—changed
オプションを使い、変更があったファイルのみユニットテストを実行する
テストが全ファイルに対して実行される課題 と同じ課題に対する解決策として検討しましたが、変更があったファイルがテストの依存関係に含まれるかどうかの判定が不十分で、実行してほしいテストが実行されなかったりしたので(Vitest v1.2.1時点)、導入を見送りました。
おそらくテストファイルのimport先を見て、それらの中に変更されたファイルがあればテスト実行というロジックになっているようなのですが、import先のファイルがさらにimportしている先までは見れていないようでした。
結果
削減対象にしていた3つのworkflow(clipping-ci、prtimes-ci、prtimes-public-ci)は合計で1月当たり20,000分削減でき、上述した施策を他のworkflowにも実施したこともあり、全体としては25,000分削減することができました。
一方で課題としては、Vitest、Playwrightなどのテストファイルの数が700件近くまで達しており、変更箇所によってはfeature単位で回る場面もあるとはいえ、全テストを実行する際の時間消費が増え続けていることです。
コード量に比例してテストの数が増えていくことは避けられないので、今後どのようにして本当に実行すべきテストだけを実行できるか、模索する必要があります。
まとめ
消費時間の多い箇所を特定し、変更ファイルに合わせたテスト実行、キャッシュの最適化、concurrencyの導入などによってGitHub Actionsのbillable timeを大きく削減することができました。
他のリポジトリで行える内容もあると思うので、参考になれば嬉しいです。
We are hiring!
フロントエンドはもちろん、各種ポジションで採用を行っています!