🦀

GitHub Actionsの実行時間を短縮するまでにやったこと

2024/03/02に公開

はじめに

GitHub Actionsで構成しているCIパイプラインのワークフローの完了に時間がかかるようになっていたため、短縮のためにやったことを書きます。
技術的には初歩的な内容になりますが(具体的で有用なtipsは他の記事で参考になるものがたくさんある)、tips以外の改善までの道のりも合わせて時系列で書いてみたので、参考になれば幸いです。

想定読者

GitHub Actionsを完全に理解した人。

実行時間の計測

改善後どれくらい短縮されたのかを計測したいので、まずは、今実行にどれくらいかかっているのか調べました。実行時間は、毎回表示されるのでなんとなくは分かるのですが、実行ごとに数十秒前後するので、平均タイムを出します。
今回はGutHubのAPIを使って実行されたActionsのデータを取得し、直近2ヶ月で正常に終了したActionの平均実行時間を、今かかっている時間として扱うことにしました。
計算用のスクリプトはChatGPTにサクッと書いてもらいました。

(参考)使った計算用スクリプト
import axios from 'axios'

const token = '' // itHubトークン
const owner = '' // リポジトリのオーナー名
const repo = '' // リポジトリ名
const workflow_id = '' // ワークフローID(ファイル名)
const startDate = new Date('2024-01-01')
const endDate = new Date('2024-02-20')

const fetchWorkflows = async (page) => {
  const url = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow_id}/runs?per_page=100&page=${page}`
  const headers = {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
  }

  try {
    const response = await axios.get(url, { headers })
    return response.data.workflow_runs
  } catch (error) {
    console.error('Error fetching workflow runs:', error)
    return []
  }
}

const calculateAverageDuration = async () => {
  let runs = []
  let page = 1
  let lastRunDate = new Date()

  while (lastRunDate > startDate) {
    await new Promise((resolve) => setTimeout(resolve, 1000))
    const workflows = await fetchWorkflows(page)
    runs = runs.concat(workflows)
    lastRunDate = new Date(workflows[workflows.length - 1].created_at)
    page++
  }

  runs = runs.filter((run) => {
    const isSuccessful = run.status === 'completed' && run.conclusion === 'success'
    const isWithinDateRange = new Date(run.created_at) > startDate && new Date(run.created_at) < endDate
    return isSuccessful && isWithinDateRange
  })

  if (runs.length === 0) {
    console.info('No workflow runs found.')
    return
  }

  const totalDuration = runs.reduce((acc, run) => {
    const duration = new Date(run.updated_at).getTime() - new Date(run.run_started_at).getTime()
    return acc + duration
  }, 0)

  const averageDurationMs = totalDuration / runs.length
  const averageDurationMinutes = averageDurationMs / 1000 / 60
  console.info(`Average Workflow Duration: ${averageDurationMinutes.toFixed(2)} minutes`)
}

calculateAverageDuration()

不要なステップの削除

改善にあたって、まず、ワークフローに不要なものがないか確認をしました。
そもそも要らないものをなくせば、その時間分まるっと短縮できます。

そこで、eslintとprettierによる静的解析の実行を全てのブランチが更新されるたびに行っていたのですが、develop環境のブランチが更新された時に限定しました。代わりにコードの品質と整合性を担保するため、huskyを使ってコミット時に解析と自動修正が行われるようにしました。
最初は完全にワークフローから削除する予定でしたが、開発メンバーのローカルマシンでhuskyが実行されたことは担保できないため、実行タイミングを絞る形にしました。

他のActionsの改善系の記事を読んでいると、そもそも不要なことをしていないか?という視点で切り込んでいるものが少なかったので、こういった改善案もあると参考になれば幸いです。

複数のジョブに分割

さて、次は実行しなくてはいけないものを早く終わらせるようにします。
アプローチはいくつか存在してると思いますが、インパクトが大きいのは、やるべきことを並列に実行することです。

今回は同じジョブの中で実行していた各ステップを、2つのジョブに分割しました。各ジョブは並列に実行されるため、分割することでワークフロー終了までの時間が短くすることが可能です。
ただ、異なるジョブは異なるマシンで実行されるため、各ジョブで依存関係のインストールが必要になります。そのため、ジョブの分割で単純に実行時間が短くなるわけではないので、注意です。

この対策として、キャッシュを利用することで依存関係のインストールを高速化することが可能です。以下のパッケージを使えば簡単に実現できます。(おそらくこの記事を読んでいる人はみんな使ってると思うので、詳しくは書きません。)

結果

今まで実行終了まで平均5分30秒かかっていたところを、約3分にまで短縮することができました。

分割前と分割後のワークフローを図にすると以下のようになります。

改善前

改善後

また、改善前と改善後のワークフローファイルは以下のようになります。
(各ステップの内容はjob以外の箇所は省略してます)

before.yml
jobs:
  checks:
    name: Checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.node-version }}
          cache: yarn
      - name: Install dependencies
        run: yarn
      - name: Typecheck
        run: yarn typecheck
      - name: Codegen
        run: yarn codegen
      - name: Lint
        run: yarn lint
      - name: Unit test
        run: yarn test
after.yml
jobs:
  test:
    name: Unit test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.node-version }}
          cache: yarn
      - name: Install dependencies
        run: yarn
      - name: Unit test
        run: yarn test

  checks:
    name: Typecheck / Codegen
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.node-version }}
          cache: yarn
      - name: Install dependencies
        run: yarn install
      - name: Typecheck
        run: yarn typecheck
      - name: Codegen
        run: yarn codegen

コストについて

GitHub Actionsはプライベートリポジトリの場合、料金が発生します。
ジョブの実行時間に対して1分単位で課金される仕組みのため、ジョブの実行時間の削減は直接的なコストカットになります。
詳しい料金体系はGitHubのサイトで確認できます。

今回の改善ではワークフローが終了するまでの時間は短縮されたものの、全体としてのジョブの実行時間は削減できなかったため、コストの削減をすることはできませんでした。
self-hostedランナー(GitHubが用意してくれているランナーではなく、自ら用意したランナー)を使用することで、Github Actionsの実行は無料になるので、
コスト削減の方向で改善が必要になったら、この案を検討したいと思っています。

まとめ

不要なステップの削除とジョブの分割をして、実行終了までの時間を2分30秒短縮することができました。
ちょっとした修正でも、再レビューの依頼やマージをするのに、5分以上の待ち時間が発生して開発メンバーのストレスが溜まっていたので、解消できてよかったです。

GitHubで編集を提案

Discussion