フロントエンドのGitHub Actions実行時間を削減するために取り組んだこと

  • URLをコピーしました!

こんにちは、フロントエンドエンジニアの小張です。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-depsAPI ClientをOpenAPIから生成し、キャッシュに追加。
typechecktsc —noEmitを実行する
lintXOを実行する
unit-testVitestを実行する
playwright-integration-testPlaywrightを実行する
buildvite build もしくはnext buildを行う
previewbuild結果をbranch単位の検証環境にデプロイする
build-storybookStorybookをbuildする
preview-storybookStorybookのbuild結果をbranch単位の検証環境にデプロイする
test-storybookStorybook Test runnerを実行する

実行内容の詳細について以下をご覧ください。

あわせて読みたい
フロントエンドのLintツールをXOに統一した話 こんにちは、フロントエンドエンジニアのやなぎ( @apple_yagi )です。 PR TIMESではこれまでLintツールとしてESLintを使用していましたが、2023年9月からXO...
あわせて読みたい
PR TIMES のフロントエンドを支える技術 2023 こんにちは。PR TIMES でエンジニアをしている岩元 (@yoiwamoto) です! プレスリリース配信サイト PR TIMES のフロントエンドは、一昨年ごろまでほぼ全てのページが Sm...
あわせて読みたい
PR TIMES フロントエンドにおけるプレビュー環境の自動生成 こんにちは。PR TIMES のフロントエンドエンジニアをしています岩元 (@yoiwamoto) です。 今回は、先日改善を行ったフロントエンドのプレビュー環境の自動生成の構成に...

これらのjobの中で時間がかかっている箇所を探すため、まずKesin11/actions-timelineというActionsを導入し、実行時間の可視化を行いました。以下の記事を参考にしたのでご覧ください。

そして調査の結果、以下のような改善点が見つかりました。

  1. jobが過剰に分割されており、billable timeで分単位の切り上げや、パッケージインストールなどで余分な実行時間がかかってしまっていた。
  2. XO、VitestがPull Requestの差分に関わらず、全ファイルに対して回っていた。
  3. 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をまとめるようにしました。

以下の記事をご覧ください。

あわせて読みたい
並列で実行していたGitHub ActionsのJobをまとめ、Billable timeを削減した話 こんにちは、フロントエンドエンジニアのやなぎ( @apple_yagi )です。 GitHub ActionsのBillable timeの削減のために、複数に分けて実行していたJobを、ある程度の粒...

2. XO、VitestがPull Requestの差分に関わらず、全ファイルに対して回っていた。

Pull Requestの変更に合わせて、適切なファイルに対してXOやVitestを実行するようにしました。

XO

lintは変更があったファイルだけ確認すれば良いので、以下の要件を満たすようにworkflowを設定しました。

  • XOの設定ファイルが変更されている場合、全ファイルに対してXOを実行する
  • XOの設定ファイルに変更がなく、TSファイルに変更がない場合実行をskipする
  • 上記以外のケースでは、変更があったTSファイルに対してXOを実行する

変更があったファイルがglobパターンに一致するか調べるために、以下のactionを使用しています。

GitHub
GitHub - dorny/paths-filter: Conditionally run actions based on files modified by PR, feature branch... Conditionally run actions based on files modified by PR, feature branch or pushed commits - dorny/paths-filter
# ディレクトリ構成の例
# 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-ci7分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に統一する方針です。)

GitHub
GitHub - tj-actions/changed-files: :octocat: Github action to retrieve all (added, copied, modified,... :octocat: Github action to retrieve all (added, copied, modified, deleted, renamed, type changed, unmerged, unknown) files and directories. - tj-actions/changed...
# ディレクトリ構成の例
# 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-ci21分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分ほどの改善が見られました。

workflowbeforeafter
prtimes-ci18分13分
prtimes-public-ci19分18分
clipping-ci23分19分

さらに既存のキャッシュを利用できる場合、install-depsでのキャッシュの復元や保存も不要になるため、actions/cacheのlookup-onlyオプションをtrueにしました。

これによりキャッシュが利用できる場合、約1分ほどbillable timeを改善することができました。

jobbeforeafter
install-deps(キャッシュがある場合)1分23秒8秒

node_modules

上記のキャッシュ機構の改善を行った上でも、node_modulesのキャッシュの読み書きに時間がかかっていることがわかりました。node_modulesのキャッシュの復元、保存にそれぞれ一回1分以上かかる一方で、pnpm install の実行時間は50秒ほどだったので、node_modulesに関してはキャッシュ機構を廃したほうが速くなることがわかりました。

具体的には以下の状態から、

以下のように変更しました。

この変更により、billable timeはworkflow全体で約3分ほど削減されました。

workflowbeforeafter
prtimes-ci14分11分

4. Pull Requestの作成時にworkflowが実行されるようになっていたが、renovate botによって自動生成された大量のPull Request(1ヶ月に70件ほど)がその対象になっていた。さらにそれぞれが自動でrebaseされるごとにworkflowが実行されてしまっていた。

renovateが生成するPull Request上でのCI実行内容を最低限に絞る。またPull Requestの数自体を減らす。

以下の記事をご覧ください。

あわせて読みたい
Renovateを使ってフロントエンドのバージョンアップを改善した話 こんにちは、フロントエンドエンジニアの小張です。Renovateを使ってフロントエンドのパッケージやライブラリのバージョンアップを改善したことについて紹介します。 PR...

5. 同じPull Requestにpushするごとにworkflowが実行されてしまっていた。

同じPull Request上で同じworkflowが実行されているとき、古い方の実行をキャンセルするようにする

各workflowの設定ファイルの最上層に下記の設定を追加しました。

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
GitHub Docs
コンカレンシーの使用 - GitHub Docs 一度に 1 つのジョブを実行します。

まだ設定してから日が浅くどれほど効果があるか測定中ですが、現段階で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!

フロントエンドはもちろん、各種ポジションで採用を行っています!

株式会社PR TIMES
プレスリリース配信サービス「PR TIMES」のフロントエンドエンジニア募集! - 株式会社PR TIMES 株式会社PR TIMESでは現在03-4. 開発本部 フロントエンドエンジニアを募集しています。
株式会社PR TIMES
02.開発本部 の求人一覧 - 株式会社PR TIMES 株式会社PR TIMESが公開している、02.開発本部 の求人一覧です
  • URLをコピーしました!

この記事を書いた人

2021卒でフロントエンド開発を担当しています。

目次