💨

モノレポでの GitHub Actions CI の泥臭い高速化

2023/03/28に公開

はじめに

みなさんこんにちは、物流業界の価値最大化をミッションに掲げ運送会社のDXに寄り添うアセンド株式会社でCTOを務めている丹羽です。

1日5.2回のリリースを実現するプロダクトチームの開発体験を支えるCIの高速化についてご紹介します(先週3/20週の平均値)。1日に数回デプロイというレベルでの素早く開発するにおいて、 push 時の CI Check の速さは地味ですが開発体験にとって見逃せない存在になります。特にモノレポ環境ではジョブが複数ある中でいかに省略ができるかが鍵となり、泥臭くも数十秒でも高速化のため戦ったポイントを紹介します。

アセンドでは顧客課題を中心にプロダクト開発をするためにフルサイクルエンジニアという開発スタイルを取り、1エンジニアがフロント・バックエンドだけでなく設計からリリース・サポートまでのソフトウェアのライフサイクル全体にオーナーシップを持って開発しています。フルサイクルを実現し1日に数回というレベルで顧客フィードバックを求めるために、高い生産性と開発体験は欠かせません。

https://www.ascendlogi.co.jp/logix/

前提条件

  • Monorepo で複数のアプリケーション&共通モジュールを管理
  • Full TypeScript アーキテクチャでフロント・バックエンドなど全てを開発
  • 1日に数回リリースでき変更差分は少なく数秒で切り戻せるため、事故リスクを低く見積もれる

高速化のサマリー

  • モジュール毎に細かくジョブに分け、 actions/cache を使いビルドの再利用ができるようにする
  • 各ジョブ毎の実行前にまとめてビルドの要否を求めるジョブを作り、ジョブ間の待ち時間も削減
  • node_modules そのものをキャッシュした方が高速

現在の workflow

最初に高速化した結果の現在の workflow を紹介します。

全モジュールはFullTypeScriptで開発しており、 yarn workspace を利用してモノレポとして管理しています。
frontend と backend で共通して Entity 定義などを持つために core のモジュールを作成しています。またコード内にはありませんが、アセンドは logix のアプリケーション以外に認証の accounts のアプリケーションも同一のリポジトリで管理しており、それとの共通モジュールを common-modules でまとめています。他にも特殊性を持ったサブモジュールに切り出したものがあります。合計8個のモジュールを効率良くCIチェックすることが今回の課題でした。全体の依存関係は以下のチャートを見ていただければと思います。

workflow chart

workflow
name: logix/on-push

on:
  push:
    branches-ignore:
      - "release/**"
    paths:
      - "yarn.lock"
      - "packages/logix/core/**"
      - "packages/logix/backend/**"
      - "packages/logix/frontend/**"

env:
  NODE_VERSION: 18

jobs:
  prepare:
    name: Check the needs of each job execution
    runs-on: ubuntu-20.04
    timeout-minutes: 15
    outputs:
      node-modules-key: ${{ steps.node-modules-key.outputs.result }}
      node-modules-sub-key: ${{ steps.node-modules-sub-key.outputs.result }}
      core-key: ${{ steps.core-key.outputs.result }}
      core-cached: ${{ steps.core-cached.outputs.cache-hit }}
      backend-key: ${{ steps.backend-key.outputs.result }}
      backend-cached: ${{ steps.backend-cached.outputs.cache-hit }}
      frontend-key: ${{ steps.frontend-key.outputs.result }}
      frontend-cached: ${{ steps.frontend-cached.outputs.cache-hit }}
    steps:
      - uses: actions/checkout@v3
      - id: yarn-hash
        run: echo "result=${{ env.NODE_VERSION }}-${{ hashFiles('**/yarn.lock') }}" >> $GITHUB_OUTPUT
      - id: core-hash
        run: echo "result=${{ hashFiles('packages/logix/core/lib/**/*.ts') }}" >> $GITHUB_OUTPUT
      - id: backend-hash
        run: echo "result=${{ hashFiles('packages/logix/backend/src/**/*.ts') }}" >> $GITHUB_OUTPUT
      - id: frontend-hash
        run: echo "result=${{ hashFiles('packages/logix/frontend/src/**/*.[jt]s', 'packages/logix/frontend/src/**/*.[jt]sx') }}" >> $GITHUB_OUTPUT
      - id: node-modules-key
        run: echo "result=node-modules-${{ steps.yarn-hash.outputs.result }}" >> $GITHUB_OUTPUT
      - id: node-modules-sub-key
        run: echo "result=node-modules-${{ env.NODE_VERSION }}-" >> $GITHUB_OUTPUT
      - id: core-key
        run: echo "result=core-build-${{ steps.yarn-hash.outputs.result }}-${{ steps.core-hash.outputs.result }}" >> $GITHUB_OUTPUT
      - name: Check core build cache
        uses: actions/cache@v3
        id: core-cached
        with:
          path: packages/logix/core/dist
          key: ${{ steps.core-key.outputs.result }}
      - id: backend-key
        run: echo "result=backend-build-required-${{ steps.yarn-hash.outputs.result }}-${{ steps.core-hash.outputs.result }}-${{ steps.backend-hash.outputs.result }}" >> $GITHUB_OUTPUT
      - name: Check backend build cache
        uses: actions/cache@v3
        id: backend-cached
        with:
          path: packages/logix/backend/package.json
          key: ${{ steps.backend-key.outputs.result }}
      - id: frontend-key
        run: echo "result=frontend-build-required-${{ steps.yarn-hash.outputs.result }}-${{ steps.core-hash.outputs.result }}-${{ steps.frontend-hash.outputs.result }}" >> $GITHUB_OUTPUT
      - name: Check frontend build cache
        uses: actions/cache@v3
        id: frontend-cached
        with:
          path: packages/logix/frontend/package.json
          key: ${{ steps.frontend-key.outputs.result }}

  build-core:
    name: Build core package
    runs-on: ubuntu-20.04
    needs:
      [prepare]
    if: needs.prepare.outputs.core-cached != 'true' && !failure()
    timeout-minutes: 15
    defaults:
      run:
        working-directory: ./packages/logix/core
    steps:
      - uses: actions/checkout@v3
      - name: Check core build cache
        uses: actions/cache@v3
        id: build-required
        with:
          path: packages/logix/core/dist
          key: ${{ needs.prepare.outputs.core-key }}
      - name: Use Node.js ${{ env.NODE_VERSION }}
        timeout-minutes: 1
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
      - name: Restore node-modules cache
        uses: actions/cache@v3
        id: node-modules-cache
        with:
          path: "**/node_modules"
          key: ${{ needs.prepare.outputs.node-modules-key }}
          restore-keys: ${{ needs.prepare.outputs.node-modules-sub-key }}
      - run: yarn install --frozen-lockfile
        if: steps.node-modules-cache.outputs.cache-hit != 'true'
      - run: yarn build
        timeout-minutes: 5

  build-backend:
    name: Build backend package
    runs-on: ubuntu-20.04
    needs: [prepare, build-core]
    if: needs.prepare.outputs.backend-cached != 'true' && !failure()
    timeout-minutes: 15
    defaults:
      run:
        working-directory: ./packages/logix/backend
    steps:
      - uses: actions/checkout@v3
      - name: Check backend build is required
        uses: actions/cache@v3
        id: build-required
        with:
          path: packages/logix/backend/package.json
          key: ${{ needs.prepare.outputs.backend-key }}
      - name: Use Node.js ${{ env.NODE_VERSION }}
        timeout-minutes: 1
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
      - name: Restore core build cache
        uses: actions/cache@v3
        with:
          path: packages/logix/core/dist
          key: ${{ needs.prepare.outputs.core-key }}
      - name: Restore node-modules cache
        uses: actions/cache@v3
        id: node-modules-cache
        with:
          path: "**/node_modules"
          key: ${{ needs.prepare.outputs.node-modules-key }}
          restore-keys: ${{ needs.prepare.outputs.node-modules-sub-key }}
      - run: yarn install --frozen-lockfile
        if: steps.node-modules-cache.outputs.cache-hit != 'true'
      - run: yarn build
        timeout-minutes: 5

  build-frontend:
    name: Build frontend package
    runs-on: ubuntu-20.04
    needs: [prepare, build-core]
    if: needs.prepare.outputs.frontend-cached != 'true' && !failure()
    timeout-minutes: 15
    defaults:
      run:
        working-directory: ./packages/logix/frontend
    steps:
      - uses: actions/checkout@v3
      - name: Check frontend build is required
        uses: actions/cache@v3
        id: build-required
        with:
          path: packages/logix/frontend/package.json
          key: ${{ needs.prepare.outputs.frontend-key }}
      - name: Use Node.js ${{ env.NODE_VERSION }}
        timeout-minutes: 1
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
      - name: Restore core build cache
        uses: actions/cache@v3
        with:
          path: packages/logix/core/dist
          key: ${{ needs.prepare.outputs.core-key }}
      - name: Restore Next build cache
        uses: actions/cache@v3
        with:
          path: packages/logix/frontend/.next/cache
          key: nextjs-${{ env.NODE_VERSION }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/logix/frontend/src/**/*.[jt]s', 'packages/logix/frontend/src/**/*.[jt]sx') }}
          restore-keys: |
            nextjs-${{ env.NODE_VERSION }}-${{ hashFiles('**/yarn.lock') }}-
            nextjs-${{ env.NODE_VERSION }}-
      - name: Restore node-modules cache
        uses: actions/cache@v3
        id: node-modules-cache
        with:
          path: "**/node_modules"
          key: ${{ needs.prepare.outputs.node-modules-key }}
          restore-keys: ${{ needs.prepare.outputs.node-modules-sub-key }}
      - run: yarn install --frozen-lockfile
        if: steps.node-modules-cache.outputs.cache-hit != 'true'
      - run: yarn build
        timeout-minutes: 5

  notify-failure:
    runs-on: ubuntu-20.04
    needs:
      [
        prepare,
        build-core,
        build-backend,
        build-frontend,
      ]
    timeout-minutes: 15
    if: failure() &&
      ( github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop')
    steps:
      - id: url
        run: echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
      - uses: slackapi/slack-github-action@v1.23.0
        with:
          payload: |
            { "url": "${{steps.url.outputs.url}}" }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_BUILD_FAILED_ALERT_WEBHOOK_URL }}

高速化・省略化のポイント

CIビルドの最初に実行するジョブを求める

これがCI高速化で最も効いたポイントです。
prepare のジョブで各ビルド成果物のキャッシュの有無を確認しています。ビルド成果物が存在している場合はジョブを丸ごとスキップすることで省略化ができます。
この prepare の処理を作る前は各ジョブの中でキャッシュの有無を確認し、以降のステップをスキップする作りとしていました。この作りでは以下の課題が発生し読みづらいかつ非効率となっていました。

  • ジョブのコンテナ起動のオーバーヘッドが掛かる為、GitHub Actions の実行時間には現れない 5~15秒の待ち時間が発生する。
    • 合計8のジョブを実行していたため、最大で810=80秒の待ち・平均で310=30秒の待ち時間が発生していました。
  • GitHub Actions の課金は 1min 毎の切り上げであるため、キャッシュ確認するだけで 1min 分の課金が発生する。
    • キャッシュがある場合は6秒程度の実行時間であり、workflow全体で実行時間は 4min であるのに課金対象は 12min と歪な状態になっていました。
  • if: steps.build-required.outputs.cache-hit != 'true' が各ステップに書かれ見通しが悪くなる
改修以前のworkflow抜粋
  build-core:
    name: Build core package
    runs-on: ubuntu-20.04
    timeout-minutes: 15
    defaults:
      run:
        working-directory: ./core
    steps:
      - uses: actions/checkout@v3
      - name: Check core build cache
        uses: actions/cache@v3
        id: build-required
        with:
          path: core/dist
          key: core-build-${{ env.NODE_VERSION }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('core/lib/**/*.ts') }}
      - name: ~~省略~~
      - run: yarn build
        timeout-minutes: 5
        if: steps.build-required.outputs.cache-hit != 'true'

上記の課題から事前に各ジョブの実行の要否を求める1つの prepare のジョブにまとめることとしました。
この作りとした場合に全てのジョブで prepare が実行され +30sec のオーバーヘッドが掛かりますが、実際の開発時には3~7のジョブがスキップされるため十分に効果があります。

ジョブをスキップした際の後続の依存関係があるジョブを実行する際には if: !failure() の条件を入れなければ実行できないため注意が必要です。

ジョブのスキップ方法
  build-frontend:
    needs: [prepare, build-core]
    if: needs.prepare.outputs.frontend-cached != 'true' && !failure()

キャッシュキーの生成を共通化する

キャッシュを利用する上で注意すべきことは、キャッシュの input と key がブレないことです。
hashFiles('packages/logix/core/src/**/*.ts') で input を元に key を生成しますが、複数のジョブで横断的に利用する key を都度生成する形ではメンテナンス性が悪い状態にありました。
prepare のジョブを追加したことで、key の生成処理を1カ所にまとめることができ、GITHUB_OUTPUT を利用して複数のジョブ間で共有できる変数として管理できるようになります。

キャッシュキーの変数化
  prepare:
    outputs:
      core-key: ${{ steps.core-key.outputs.result }}
    steps:
      - uses: actions/checkout@v3
      - id: yarn-hash
        run: echo "result=${{ env.NODE_VERSION }}-${{ hashFiles('**/yarn.lock') }}" >> $GITHUB_OUTPUT
      - id: core-hash
        run: echo "result=${{ hashFiles('packages/logix/core/lib/**/*.ts') }}" >> $GITHUB_OUTPUT
      - id: core-key
        run: echo "result=core-build-${{ steps.yarn-hash.outputs.result }}-${{ steps.core-hash.outputs.result }}" >> $GITHUB_OUTPUT

node-modules そのものをキャッシュする

actions/setup-node に npm のキャッシュ機構があるので、こちらを使うのが一般的です。この場合にキャッシュされるのは node_modules ではなく global cache となり、 yarn install が必要となります。
モノレポでは node_modules は共通しており、1秒でも実行時間を削る為に global cache から yarn install することもやめました。 キャッシュキーは node-modules-key で全ジョブで共通としているため、最低で1度実行すれば再利用できる状態となっています。 ジョブの依存関係によっては初回ジョブが並列となってキャッシュが効かず並列で2つ以上 yarn install が走ることもありえるので、適宜 prepare に逃すことも検討ください。

node_modulesのキャッシュ復元
      - name: Restore node-modules cache
        uses: actions/cache@v3
        id: node-modules-cache
        with:
          path: "**/node_modules"
          key: ${{ needs.prepare.outputs.node-modules-key }}
          restore-keys: ${{ needs.prepare.outputs.node-modules-sub-key }}
      - run: yarn install --frozen-lockfile
        if: steps.node-modules-cache.outputs.cache-hit != 'true'

補足

  • TypeScript でモノレポ管理をするのであれば、 Turborepo の利用するのが良い流れがあると思います。
    • 以前に導入検証をした時点では、上記の cache を効率化させた方がCIが早かったため導入は見送りとしました。
    • また Renovate を利用してほぼ毎日で依存packageの最新化を行なっているためキャッシュヒット率が低いことも理由にあります。
    • 一方で Turborepo に関する知見はまだないため誤りや助言あれば、ぜひコメントをお願いします。

https://turbo.build/repo

終わりに

今回はアセンドの開発生産性を支えるCIの高速化についてご紹介しました。みなさんの開発体験が少しでも良くなる助けとなれば幸いです。

アセンドでは共に顧客・社会課題を中心に開発するエンジニアを求めています。
1日に5.2回リリースする高い生産性のある環境で共に社会を良くするためにエンジニアリングしませんか?
興味のある方は弊社HPでも私の Twitter への連絡でも良いのでお待ちしております。

またエンジニアミートアップも実施しております。直近では 2023年03月30日 にログラスさんと旨いトンカツを食べながら楽しむ企画となっています。
皆さんとお会いして話せることを楽しみにしております!

https://www.wantedly.com/projects/1278336

アセンドプロダクトチーム

Discussion