こんにちは、InflabのDevOpsエンジニアのJakeです。
皆さんは、CIパイプラインの実行に時間がかかりすぎて、PRレビューに影響が出たことはありませんか?
プロジェクトの規模が大きくなるにつれて長くなっていくCI/CDパイプラインの所要時間に、頭を悩ませたことはありませんか?
本記事では、CIを最大4.6倍まで高速化できたノウハウを共有します。
この記事のポイント
-
CI(継続的インテグレーション)の所要時間を最大79%削減
-
Jenkins Pipelineの最適化:
- ビルド、テストを実行するDockerコンテナの依存関係キャッシュのパスを設定
-
モノレポのCIにインクリメンタルビルドを適用: turborepo remote cache
-
シンプルながら非常に効果的で、簡単に適用できるCI/CDパイプライン最適化のノウハウを共有します。
最適化の背景
Inflabの開発チームではモノレポを導入し、パッケージ単位でコードを管理しています。プロジェクトの規模が大きくなるにつれてコードベースとパッケージが増え、CIの所要時間が急増しました。
- プロジェクト初期: パッケージ1個、4分14秒
- プロジェクト後期: パッケージ9個、12分4秒
プロジェクトの成長とともにコードで使うライブラリがどんどん増え、依存関係(dependency)のインストール時間が長くなりました。
また、ビルドとテストを順番に実行していたため、所要時間はどんどん積み上がっていきました。ビルドパイプライン側で直接並列実行させるなどの手も試しましたが、依存するパッケージ間の複雑な関係や並行性の問題があり、別の解決策を探す必要がありました。
そこで pnpm と turborepo を導入し、モノレポプロジェクトの生産性を改善することになりました。
Yarnとpnpmの比較
次のグラフは、npm、pnpm、yarn、yarn PnPのベンチマークです。
画像出典: pnpm.io
グラフのとおり、依存関係のインストールでは条件によって約2倍速くなっています。私たちがまとめたそれぞれの特徴は次のとおりです。
-
yarn
- 依存関係のインストール時間: 最大120秒
- 複雑な
node_modulesの構造により、ディスクI/OとCPUリソースを消費
-
pnpm
- 依存関係のインストール時間: 最大63秒
- symlinkでグローバルキャッシュのパス(
.pnpm-store)にリンク -> 不要なファイルコピーが減少 -> ディスクI/Oが減少
node_modules
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar -> <store>/bar
│ ├── index.js
│ └── package.json
└── foo@1.0.0
└── node_modules
└── foo -> <store>/foo
├── index.js
└── package.json
これまで使っていた yarn には、依存関係をインストールすると複雑な node_modules ディレクトリ構造のせいでディスクI/OとCPUリソースが無駄に消費されるという欠点がありました。そのため、より高スペックなビルドサーバーが必要になり、所要時間も長くなっていました。
pnpm の導入後は、すべての依存パッケージを .pnpm-store にインストールし、各パッケージ内の node_modules ディレクトリにはsymlinkを張るだけの形になりました。これによりディスクへの書き込みが減り、依存関係のインストール時間を約60秒短縮できました。
Turborepo
Turborepoを説明する前に、モノレポについて少しおさらいしておきます。
Monorepoとは?
画像出典: monorepo.tools
A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.
モノレポとは、関係が明確に定義された複数の個別プロジェクトを含むリポジトリを指す。
モノレポを導入すると、毎回多数のリポジトリを作成・管理する手間を減らせます。詳しくはリンク先をご参照ください。
関係が明確に定義されておらず、カプセル化もされていない単なるコードの寄せ集めはモノレポではなく、Monolithとも異なると説明されています。
このようにモノレポでは、複数のアプリやライブラリを1つのリポジトリで管理するため、同じ作業を多数のリポジトリに繰り返し適用する必要がありません。
しかし、モノレポのコードベースの規模が大きくなるとどうなるでしょうか?
-
ビルドとテストを順次実行することによる所要時間の増加
-
変更されていない部分まで、不要なビルド & テストを繰り返し実行
次の例は、モノレポのCIパイプラインで、変更されたパッケージのビルドだけを並列実行するよう構成したものです。
stage('Docker Build') {
parallel {
stage('Package A') {
when {
anyOf {
changeset "cmd/package-alpha/**/*"
changeset "docker/package-alpha/**/*"
}
}
steps {
sh 'docker build -t package-alpha -f docker/package-alpha/Dockerfile .'
}
}
stage('Package B') {
when {
anyOf {
changeset "cmd/package-beta/**/*"
changeset "docker/package-beta/**/*"
}
}
steps {
sh "docker build -t package-beta -f docker/package-beta/Dockerfile ."
}
}
...(中略)...
プロダクトや組織の規模に応じて適切にリポジトリを分ける方法もありますが、結局のところ、モノレポを専用のツールなしで上記の例のようにCIパイプラインで直接管理しようとすると、かなりの工数がかかるでしょう。
-
パッケージが追加されるたびに手作業でCIパイプラインを修正するのは、本当に大変です。(とにかく手間がかかります)
-
パッケージ同士が複雑な依存関係で絡み合っている場合、それをすべてCIパイプラインで実装するのは困難です。
こんなときに必要になるのが、turborepo や nx のようなモノレポツールです。
さまざまなモノレポツールがありますが、本記事では私たちが導入した turborepo を中心に説明します。
主な機能
turborepo は、JavaScriptおよびTypeScriptのコード向けの高性能なビルドシステムです。公式ページで説明されている特徴は次のとおりです。
- Incremental Builds
- Content-aware hashing
- Parallel execution
- Remote Caching
- Zero runtime overhead
- Pruned subsets
- Task pipelines
- Meets you where you’re at
- Profile in your browser
ここで強調したいのは、Incremental Builds、Content-aware hashing、Parallel execution、Remote Cachingです。順番に見ていきましょう。
Incremental Builds
-
日本語に直訳すると「増分ビルド」です。
-
追加・変更された部分だけをテスト & ビルドすることで、大規模なモノレポのCI所要時間を大幅に短縮します。
Content-aware hashing
-
コードの内容からハッシュ値を計算し、変更を検知します。
-
Gitに依存するchangesetとは対照的です。
Parallel execution
-
従来はビルドを順番に実行していたため、CPUリソースに無駄がありました。
-
ビルドを並列で実行することで、CPUコアを効率的に使い、ビルドをより早く終わらせることができます。
Remote Caching
-
- taskの処理結果をローカルに保存して再利用する機能です。同じマシンでは、同じ状態のコードに対するビルドやテストを2回以上行いません。
Remote Cachingは、Distributed Computation Cachingと似た概念です。
-
同じコードベースを修正する作業者が複数人いると仮定しましょう。
-
作業者Aが先にビルドとテストを実行していた場合、その結果は共用のキャッシュサーバーに保存されます。
-
続いて作業者Bがビルドとテストを実行するときは、共用キャッシュサーバーにキャッシュされた作業者Aの処理結果を読み込み、不要なビルドの実行をスキップします。
-
-
共用キャッシュサーバーにはビルドの成果物ファイルもあわせて保存されるため、それをすぐにデプロイに活用できます。同じ作業が繰り返し実行されるCIパイプラインを効果的に改善できます。詳しい数値は後ほど共有します。
上記の4つの機能をうまく活用すれば、大規模なプロジェクトもぐっと楽に管理できます。
ところで、なぜ本記事のタイトルはTurborepoの導入ではなく、CIの改善なのでしょうか?
いくつか見落としていた点があり、これらのメリットを活かしきれていなかったからです。ここから説明していきます。
CI所要時間の最適化
Inflabでは、Jenkinsを使ってCI/CDパイプラインを構築しています。
CIジョブがトリガーされると、JenkinsのEC2エージェント上でDockerイメージを起動し、次の順序でジョブを実行します。
-
依存関係のインストール(Install Dependencies)
-
テスト(Test)
-
ビルド(Build)
-
静的解析(Static Analysis)
依存関係のインストール
依存関係のインストール処理をまとめると、次のとおりです。
pnpm i --frozen-lockfile
-
必要な依存ライブラリのバージョンを確認
-
--frozen-lockfile: 必要な依存関係のマニフェストファイル(pnpm-lock.yaml)が存在するかを確認し、記載されたバージョンに従って依存関係をインストール
-
-
npmjs.com、GitHub Registryのようなパッケージリポジトリから依存関係をダウンロードしてインストール -
post-installスクリプトを実行
先ほど、pnpm を導入しただけで依存関係のインストール時間を約60秒短縮できたとお伝えしました。
実行環境が毎回変わるCI環境では、依存関係のキャッシュを導入すれば、さらに多くの時間を節約できます。
環境ごとの依存関係インストールの結果
ローカル環境では、pnpm i を実行するとグローバルな依存関係キャッシュのディレクトリからキャッシュを読み込み、不要なインストールをスキップします。一方、CI環境では実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定する必要があります。
-
ローカル環境: インストールしたすべての依存関係はグローバルキャッシュのディレクトリ(
~/Library/pnpm/store/*)に蓄積- 一度インストールした依存関係をほかのプロジェクトでも参照し、不要なインストールを省略(キャッシュヒット時は3秒前後)
-
CI環境: 実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定しなければ毎回インストールが走る(約60秒)
CI環境での依存関係のキャッシュ
CI環境で依存関係をキャッシュする方法には、主に次のようなものがあります。
-
圧縮:
node_modulesのパスをtar & gzipで圧縮 -
CI用のキャッシュイメージを作る: 依存関係をあらかじめインストールしたDockerイメージを作成し、それをビルドに活用する方法
-
キャッシュボリュームのマウント: build agentに残っているglobal cacheを活用する方法
圧縮
GitHub Actions actions/cache@v3 の例
...
- id: cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-node-${{ hashFiles(format('**/{0}', steps.hash-file.outputs.hash-file)) }}
...
cache(maxCacheSize: 250, defaultBranch: 'develop', caches: [
arbitraryFileCache(path: 'node_modules', cacheValidityDecidingFile: 'package-lock.json')
]) {
// ...
}
依存関係が入った node_modules ディレクトリをtarballにまとめ、gzipやxzなどで圧縮する方法です。
キャッシュは pnpm-lock.yaml のようなマニフェストファイルのハッシュで区別されるため、必要な依存関係が変わるたびにインストールし直す必要があるのが欠点です。
また、node_modules ディレクトリの圧縮は基本的にシングルスレッドで行われるため、時間がかかります。
プロジェクトの規模が大きくなると node_modules ディレクトリも大きく複雑になるため、キャッシュファイルのダウンロードと展開だけで2分以上かかることもあります。
これでは本末転倒なので、いっそ圧縮しないほうが良い場合もあります。
プロジェクトの規模が大きい場合
-
pnpm installの所要時間: 60秒 -
キャッシュファイルのダウンロードと展開の所要時間: 120秒以上
-
場合によっては、実際に所要時間を計測・比較したうえで判断する必要あり
GitHub Actionsを使う場合は、pnpmの公式ドキュメントのガイドに従い、cache: 'pnpm' を設定するだけで簡単に適用できます。
pnpm公式ドキュメントで推奨されているGitHub Actionsパイプラインの例
name: pnpm Example Workflow
on:
push:
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
node-version: [15]
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
Jenkinsパイプラインを使う場合は、次のように設定することが推奨されています。
pipeline {
agent {
docker {
image 'node:lts-bullseye-slim'
args '-p 3000:3000'
}
}
stages {
stage('Build') {
steps {
sh 'corepack enable'
sh 'corepack prepare pnpm@latest-8 --activate'
sh 'pnpm install'
}
}
}
}
上記の例ではpnpmのstore pathを別途指定していないため、JENKINS_WORKSPACE の配下に .pnpm-store のパスを作成して依存関係をキャッシュします。
しかし、Jenkins Multibranch Pipelineの場合は、ブランチやPull Requestごとに別々のWorkspaceのパスを持ちます。
つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。
これを解決する方法は、キャッシュボリュームのマウントで後ほど説明します。
CI用のキャッシュイメージを作る
次に紹介するのは、CI用のキャッシュイメージを作る方法です。
次の例は、pnpm fetch を使って依存関係のキャッシュイメージを作る Dockerfile です。
FROM node:18-slim
# pnpmのインストール手順は省略
# pnpm fetch does require only lockfile
COPY pnpm-lock.yaml ./
RUN pnpm fetch --prod
上記のイメージをpullしてから依存関係をインストールすれば、キャッシュを活用してすぐにビルドできます。
イメージタグにはマニフェストファイル(pnpm-lock.yaml)のハッシュ値を指定します。そのため先ほどの方法と同じく、このファイルが変更されたらイメージを再ビルドする必要があります。
# when cache miss, build and push into image registry like ECR
docker build -t rallit-frontend-dep:02d93ab24 -f dep.Dockerfile .
docker push rallit-frontend-dep:02d93ab24
# when build
docker run -i --rm -v $WORKSPACE:/workspace rallit-frontend-dep:02d93ab24 'pnpm i --frozen-lockfile'
ただし、pnpm fetch --prod コマンドでインストールした依存関係をイメージとしてビルドしてpullするため、その過程でディスクのオーバーヘッドが発生します。
社内で所要時間を計測した結果、実際のCI環境ではイメージをpullして実行するのに約168秒かかることが分かりました。
キャッシュイメージを作る方法も、プロジェクトの規模が大きいほど時間がかかり、通常の pnpm install より約108秒も遅くなりました。
pnpm-lock.yaml が変更されるたびにこうしたキャッシュイメージをビルドし、コンテナイメージレジストリにアップロードするのは非効率だと判断しました。
Dockerのマルチステージビルドを使ってイメージをビルドしている方は、pnpmの公式ドキュメントを参考にcache type mountingを適用することをおすすめします。
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
DockerのRUN専用キャッシュを活用する
パッケージをインストールするたびに、毎回インターネットからすべてのパッケージを取得する必要はありません。
--mountフラグとともに明示的なキャッシュを使うと、ビルド間で対象ディレクトリの内容が保持されます。
このレイヤーを再ビルドする必要がある場合は、そのパスにあるキャッシュを活用します。
私たちはDockerのマルチステージビルドを使わず、ビルドの段階ごとに個別に最適化したイメージを使っているため、この方法は適用できませんでした。
続いて、依存関係のインストール時間を大きく短縮できた、キャッシュボリュームのマウントについて説明します。
キャッシュボリュームのマウント
先ほど、Jenkinsパイプラインでpnpmを使って依存関係をインストールする場合は、キャッシュを別途設定する必要があると述べました。
つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。
実際の .pnpm-store のパスを比較しながら見ていきましょう。
-
従来(AS-IS)
-
CACHE_PATHのパスがJENKINS_WORKSPACEの中にある -
異なるプロジェクト、ブランチ、PR間で依存関係のキャッシュを共有できない
-
# パスの比較
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/workspace/rallit_frontend_PR_15242/.pnpm-store
例えば、rallit_frontendのPR#15242のCIジョブが完了したとしましょう。
/workspace/rallit_frontend_PR_15242/.pnpm-store には、先ほどインストールした依存関係が残っています。
続いてPR#15243が作成されると、どうなるでしょうか?
PR#15243は /workspace/rallit_frontend_PR_15243/.pnpm-store を参照することになります。
このフォルダは実際には存在しないので、依存関係を最初からインストールすることになり、その分時間がかかります。
先にインストールしたPR#15242の依存関係キャッシュは、まったく使えません。
たった1行の変更でも約60秒余計に待たされるので、かなり不便です。
-
変更後(TO-BE)
-
エージェント内にGlobal Dependency Cacheのパスを設定
-
同じエージェントで実行するビルドはDependency Cacheを活用
-
# パスの比較
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/tmp/.pnpm-store
CIジョブを実行するすべてのコンテナで、pnpm store-path オプションに同じパスを指定しました。
これにより、rallit_frontendのPR#15242、PR#15243はどちらも同じパス(/tmp/.pnpm-store)を参照するようになります。
PR#15243のCIジョブでは、PR#15242ですでにインストールされた依存関係を再利用することになり、約60秒かかっていた依存関係のインストール時間をわずか3秒に短縮できました。
pnpm store-path オプションは、ビルド用のベースイメージを作る Dockerfile で次のように指定しました。
# set global node cache directory
ARG CACHE_PATH=/cache/node
RUN yarn config set cache-folder ${CACHE_PATH}/.yarn
RUN pnpm config set store-dir ${CACHE_PATH}/.pnpm-store
パッケージマネージャーにYarnを使っているプロジェクトでも依存関係のキャッシュパスを共有できるよう、Yarnにも同様の設定を入れています。
続いて、このDockerイメージを実行する Jenkinsfile 側でも CACHE_PATH 環境変数を設定します。
env.DOCKER_OPTION = '-v /tmp:/cache/node -e CI=1'
# (中略)
stage('Install') {
steps {
script {
sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm i --frozen-lockfile'"
}
}
}
JenkinsfileのDocker拡張機能を使わず、コマンドで直接Dockerコンテナを実行する場合
pnpmがCI環境かどうかの判定に使う環境変数の1つであるBUILD_NUMBERが渡されないため、CI=1のデフォルト値が適用されません。
-e BUILD_NUMBER=$BUILD_NUMBERで渡すか、-e CI=1環境変数を直接設定するのがおすすめです。
詳しくは公式ドキュメントをご参照ください。
CI=trueの場合、pnpmはロックファイル(pnpm-lock.yaml)を生成せず、ロックファイルがマニフェストと同期していない場合、更新が必要な場合、または存在しない場合はインストールに失敗します。
この対応によって、直前のビルドで使われた依存関係のキャッシュを、ほかのリポジトリのCIでも再利用できるようになりました。
適用前
+ pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Progress: resolved 1, reused 0, downloaded 0, added 0
Packages: +3357
(中略)
Progress: resolved 3357, reused 1253, downloaded 2025, added 3357, done
Done in 1m 28.6s
適用後
+ pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Packages: -11
-----------
Done in 2.6s
- 依存関係キャッシュMiss: 1分28秒
- 依存関係キャッシュHit: 2.6秒
- 依存関係のインストール時間を最大97%短縮
依存関係のインストールは従来の88秒から2.6秒へと85.4秒減り、約97%短縮できました。
もちろん、ビルドエージェントが入れ替わって依存関係のキャッシュパスが初期化されると、最初の1回だけは1分28秒のインストールが必要です。
それでも、ビルドのたびに依存関係をインストールしていた従来とは違い、エージェント内で依存関係のキャッシュを共有できるので、後続のビルドではインストール時間を97%以上短縮できます。
複数のリポジトリが同じライブラリを使っていても、それぞれバージョンが異なる場合があります。
この場合、使用するバージョンを1つに統一するとキャッシュヒット率(Cache Hit Rate)が上がり、上記と同様に依存関係のインストール時間を大幅に短縮できました。
例:
inflearn-frontendとrallit-frontendで使っている Datadog RUM(Real User Monitoring) エージェントのバージョンが一致している場合、npm registryからの依存関係のダウンロードとインストールをスキップし、ビルドエージェントにあるグローバルな依存関係キャッシュを活用して時間を短縮できる。
(rallit-frontendで先にインストールしていればinflearn-frontendでそれを利用でき、その逆も可能)
テストとビルドの所要時間の短縮
先に述べたとおり、モノレポでは一部のパッケージを修正しただけでも、すべてのパッケージをテスト・ビルドしなければなりません。
Turborepoを使えば、変更されたパッケージと、それに依存するパッケージだけをテスト・ビルドするので、CIの所要時間を大幅に短縮できます。これをTask Cacheと呼びます。
Inflabのフロントエンドチームは早くからTurborepoをローカル開発環境に導入していたため、実際に変更されたコードだけをテスト・ビルドして、作業内容を素早く検証できていました。
これはコンテキストスイッチの頻度を減らし、生産性の向上につながりました。(実験:遅いビルド時間の隠れたコスト in GeekNews)
Turborepo Task Caching
-
package.jsonにあるbuild、test、lintなどをtaskと呼びます。 -
Turborepoはtaskの実行結果とログをキャッシュし、遅いtaskを高速化します。
Content-aware hashingでソースコードの変更を検知し、変更されたパッケージに対してのみ build、test、lint などのtaskを実行します。
-
テストとビルドの結果は、まずLocal Filesystem Cacheとして
./node_modules/.cache/turbo/78awdk123.tar.zstのようなパスに保存されます。 -
Taskキャッシュがヒット(hit)したパッケージはビルドをスキップし、変更されたパッケージだけを新たにテスト・ビルドします。
-
実行結果をもとにTaskキャッシュを更新し、常に最新の状態に保ちます。
Taskキャッシュがミス(miss)した場合は、次のようにtaskを実行し、その結果でLocal Filesystem cacheを更新します。
/lib:build: cache miss, executing 86fb10d5583f361c
/app:build: cache miss, executing 7804de963ab93a0e
(中略)
Tasks: 5 successful, 5 total
Cached: 0 cached, 5 total
Time: 4m36.208s
...writing to cache...
Taskキャッシュがヒット(hit)した場合は、キャッシュ作成時のログを再生(replay)し、ビルド結果をキャッシュサーバーからダウンロードします。
/lib:build: cache hit, replaying logs 86fb10d5583f361c
/ds:build: cache hit, replaying logs 7804de963ab93a0e
/a:build: cache hit, replaying logs 8be7f0056074cbc3
/b:build: cache hit, replaying logs d01733e33b71a814
Tasks: 5 successful, 5 total
Cached: 5 cached, 5 total
Time: 4.114s >>> FULL TURBO
- TaskキャッシュMiss: 4分36秒
- TaskキャッシュHit: 4.114秒
- Taskキャッシュの導入で所要時間を約98.5%改善
Taskキャッシュも依存関係のキャッシュと同じく、キャッシュがない初回は時間がかかります。しかしそれ以降のビルドでは変更されたパッケージに対してのみTaskを実行するため、かなりの時間を節約できました。
問題は、別途設定をしなければLocal Filesystem Cacheしか使えないことです。
Jenkinsエージェントではworkspaceをブランチ・PR単位で分けているため、検証済みのPRを develop ブランチにマージすると、テストとビルドを最初からやり直す必要がありました。
CIパイプラインで一度ビルドしたものは再ビルドせずに済ませたいところです。どうすれば改善できるでしょうか?
Turborepo Remote Caching
Turborepo Remote Caching機能を使えば、この問題を解決できます。
Taskキャッシュを複数のビルドエージェント間で共有でき、インクリメンタルビルドを積極的に活用できるようになります。
Remote Cachingを使うには、追加費用が発生するVercelのクラウドを使うか、Vercelが公開しているAPI仕様に従ってTurborepo custom remote cache serverを自前で実装する必要があります。
私たちは、オープンソースとして公開されているducktors/turborepo-remote-cacheを使いました。
ducktors/turborepo-remote-cache
Turborepo custom remote cache serverのオープンソース実装で、self-hosted環境で使うのに適しています。
シンプルなWebサーバーアプリケーションで、次の実行方法をサポートしています。
-
Dockerコンテナとして実行
-
AWS Lambdaなどのサーバーレス関数として実行
ストレージについては、次のようなさまざまなオプションが用意されています。
-
Amazon S3(または互換ストレージ)
-
ファイルシステム
Lambda + S3の組み合わせで完全にサーバーレスで構築することもできます。それぞれのメリット・デメリットを踏まえて、環境に合った構成を選べば良いでしょう。
ここでは、Docker + ファイルシステムでカスタムキャッシュサーバーを手軽に構築する手順を紹介します。
次のように compose.yaml ファイルを作成します。${} の中に入る変数は .env ファイルで定義します。
services:
turborepo-remote-cache:
image: ducktors/turborepo-remote-cache:1.14.2
ports:
- 3000:3000
volumes:
- ./tmp:/tmp
environment:
- NODE_ENV=production
- PORT=3000
- TURBO_TOKEN=${TURBO_TOKEN}
- LOG_LEVEL=info
- STORAGE_PROVIDER=local
# - AWS_REGION=${AWS_REGION}
続いてガイドに従い、Turborepoのカスタムキャッシュサーバーのアドレスといくつかのパラメーターを、Dockerの環境変数として渡します。
-
TURBO_API: キャッシュサーバーのアドレス -
TURBO_TOKEN: 認証トークン -
TURBO_TEAM: キャッシュを使うteam(主にプロジェクト単位で指定)
stage('Build') {
steps {
script {
env.DOCKER_OPTION = "$DOCKER_OPTION -e TURBO_API=$TURBO_API -e TURBO_TOKEN=$TURBO_TOKEN -e TURBO_TEAM=$TURBO_TEAM"
sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm build'"
}
}
}
Dockerの環境変数ではなくturbo.jsonなどの設定ファイルに直接書く方法もありますが、今回はCI専用のキャッシュサーバーとの連携なので、上記のようにパイプライン側に記述しました。
適用後にビルドを何度か実行すると Remote caching enabled というログが出力され、Taskキャッシュが蓄積・再利用されていることが確認できます。
+ pnpm build:development
• Packages in scope: ......
• Running build:development in 6 packages
• Remote caching enabled
a:build: cache hit, replaying logs 83da8d8fb24dd1a9
b:build: cache hit, replaying logs 249bb66c4e03301b
c:build: cache hit, replaying logs 8be7f0056074cbc3
(中略)
Tasks: 5 successful, 5 total
Cached: 5 cached, 5 total
Time: 4.114s >>> FULL TURBO
ハッシュはコードの状態をもとに計算されるので、ブランチやPRが違っても、コードを修正していなければTaskが無駄に再実行されることはありません。
では、Continuous Deploymentのパイプラインでビルドキャッシュを使う場合、ビルド成果物はどうデプロイすれば良いでしょうか?
turbo.jsonでコマンドごとにoutputsのパスを設定すると、そのパスがビルド成果物として扱われ、Turborepoのリモートキャッシュサーバーに保存されます。キャッシュヒット時には、それがダウンロードされます。
そのため、デプロイ時の混乱を防ぐには、outputs オプションを正しく設定しておく必要があります。(関連する公式ドキュメント)
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"app#build": {
"outputs": ["dist/**"]
},
"util#build": {
"outputs": ["lib/**"]
}
}
}
このオプションを正しく設定していないと、デプロイパイプラインを経由しない緊急ロールバックのあとにパイプラインを再実行しても、本番環境に反映されないことがあるので注意してください。
便利さの裏には慎重さと正確さが欠かせないことを、心に留めておきましょう。
ソースコードの静的解析
依存関係のインストールと、テスト・ビルド。この2つを最適化した結果、すべてのキャッシュがヒットすれば、ここまでの処理は10秒前後で終わります。
静的解析では、コードのセキュリティ脆弱性から、重複やパフォーマンス上の問題が起こりうる箇所まで見つけられるため、SonarQubeを積極的に導入・活用しています。
しかし、CIのたびに静的解析を実行していたため、コードの規模によっては40秒から最大2分以上かかることもありました。
これを短縮するため、解析が不要なファイルを sonar-project.properties で除外しました。
sonar.exclusions=packages/**/public/mockServiceWorker.js, packages/**/src/@types/**/*, packages/**/src/mocks/**/*, packages/**/vite.config.ts,
sonar.analysis.mode=incrementalは使用不可
以前は -Dsonar.analysis.mode=incremental パラメーターを追加して変更箇所だけを解析する機能がありましたが、現在はサポートが終了しています。
代わりに、変更されたディレクトリだけを解析する仕組みをパイプラインに自前で実装する方法や、CIのポリシーを変更して develop ブランチへのマージ時にのみ解析する方法があります。
銀の弾丸(silver-bullet)は存在しないので、皆さんの環境に合った方法を選んで改善してください。
詳しくはSonarQubeのドキュメントをご参照ください。
私たちのContinuous Deploymentパイプラインには、コンテナのデプロイ完了を待つ処理がありました。その待ち時間にSonarQubeの解析を並行して実行することで、約40秒〜最大2分かかっていた静的解析の時間を短縮できました。
CI最適化の結果
キャッシュHitを想定(install、buildがともにヒットした場合)
pnpmの依存関係キャッシュとTurborepoのTaskキャッシュを適用したことで、CIの所要時間を最大79%短縮できました。
モノレポの中でも、パッケージ数とコード量が多いプロジェクトほど最適化の効果が際立ちました。パッケージが多いほど、修正による影響範囲が小さくなるためです。
さらに、turborepo もモノレポも導入していないプロジェクトE、Fでも、依存関係のインストールを最適化しただけでCIが約1.6〜2倍速くなりました。
調査対象の平均CI所要時間は6分3秒から2分4秒になり、最大66%改善できました。静的解析まで最適化すれば、さらに時間を節約できると期待しています。
おわりに
以上、簡単な修正でCIの所要時間を劇的に改善した経験を共有しました。皆さんもぜひ試してみて、得られた成果やより良い方法をコメントで教えていただけるとうれしいです。
最適化を進めるなかで私を信頼し、込み入った質問にも丁寧に答えてくださったInflab開発チームの皆さんのおかげで、無事に最適化を終えることができました。ありがとうございました。
次回の記事では、JenkinsのEC2エージェントのコンピューティングコストを最大70%節約できたスポットインスタンスの導入をテーマにお届けします。
Inflab DevOpsチームの最適化はまだまだ続きます。今後もご期待ください!
参考資料
※この記事は2023年11月1日に執筆されたものです。















