モノレポでの GitHub Actions CI の泥臭い高速化
はじめに
みなさんこんにちは、物流業界の価値最大化をミッションに掲げ運送会社のDXに寄り添うアセンド株式会社でCTOを務めている丹羽です。
1日5.2回のリリースを実現するプロダクトチームの開発体験を支えるCIの高速化についてご紹介します(先週3/20週の平均値)。1日に数回デプロイというレベルでの素早く開発するにおいて、 push 時の CI Check の速さは地味ですが開発体験にとって見逃せない存在になります。特にモノレポ環境ではジョブが複数ある中でいかに省略ができるかが鍵となり、泥臭くも数十秒でも高速化のため戦ったポイントを紹介します。
アセンドでは顧客課題を中心にプロダクト開発をするためにフルサイクルエンジニアという開発スタイルを取り、1エンジニアがフロント・バックエンドだけでなく設計からリリース・サポートまでのソフトウェアのライフサイクル全体にオーナーシップを持って開発しています。フルサイクルを実現し1日に数回というレベルで顧客フィードバックを求めるために、高い生産性と開発体験は欠かせません。
前提条件
- Monorepo で複数のアプリケーション&共通モジュールを管理
- Full TypeScript アーキテクチャでフロント・バックエンドなど全てを開発
- 1日に数回リリースでき変更差分は少なく数秒で切り戻せるため、事故リスクを低く見積もれる
高速化のサマリー
- モジュール毎に細かくジョブに分け、
actions/cache
を使いビルドの再利用ができるようにする - 各ジョブ毎の実行前にまとめてビルドの要否を求めるジョブを作り、ジョブ間の待ち時間も削減
-
node_modules
そのものをキャッシュした方が高速
現在の workflow
最初に高速化した結果の現在の workflow を紹介します。
全モジュールはFullTypeScriptで開発しており、 yarn workspace を利用してモノレポとして管理しています。
frontend と backend で共通して Entity 定義などを持つために core
のモジュールを作成しています。またコード内にはありませんが、アセンドは logix
のアプリケーション以外に認証の accounts
のアプリケーションも同一のリポジトリで管理しており、それとの共通モジュールを common-modules
でまとめています。他にも特殊性を持ったサブモジュールに切り出したものがあります。合計8個のモジュールを効率良くCIチェックすることが今回の課題でした。全体の依存関係は以下のチャートを見ていただければと思います。
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'
が各ステップに書かれ見通しが悪くなる
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
に逃すことも検討ください。
- 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
に関する知見はまだないため誤りや助言あれば、ぜひコメントをお願いします。
終わりに
今回はアセンドの開発生産性を支えるCIの高速化についてご紹介しました。みなさんの開発体験が少しでも良くなる助けとなれば幸いです。
アセンドでは共に顧客・社会課題を中心に開発するエンジニアを求めています。
1日に5.2回リリースする高い生産性のある環境で共に社会を良くするためにエンジニアリングしませんか?
興味のある方は弊社HPでも私の Twitter への連絡でも良いのでお待ちしております。
またエンジニアミートアップも実施しております。直近では 2023年03月30日 にログラスさんと旨いトンカツを食べながら楽しむ企画となっています。
皆さんとお会いして話せることを楽しみにしております!
Discussion