Jenkins CI/CDを高速化する効果的なビルドキャッシュ戦略

JenkinsのCI/CDパイプラインをビルドキャッシュ戦略で高速化します。このガイドでは、依存関係、コンパイラ出力、Dockerレイヤーをビルド間で再利用する実践的な方法を詳しく説明します。ワークスペースの保持、Dockerビルドオプション、共有キャッシュ技術を活用して、冗長なタスクを最小限に抑え、統合とデプロイのプロセスを大幅に高速化する方法を学びます。

Jenkins CI/CDを高速化する効果的なビルドキャッシュ戦略

Jenkinsのビルドキャッシュは単一の機能ではありません。ビルド間で安全に再利用できるものを決定する一連の判断です。適切なキャッシュは、依存関係のダウンロード、Dockerレイヤー、コンパイラ作業、パッケージマネージャのメタデータを節約します。不適切なキャッシュは、壊れたビルドを隠したり、ディスクを満たしたり、パイプラインがあるエージェントでは成功し別のエージェントでは失敗する原因になります。

まず、ジョブログで最も遅い繰り返しステップを確認します。すべてのビルドがMavenアーティファクトのダウンロードに2分かかっている場合は、Mavenをキャッシュします。Dockerが毎回同じベースレイヤーを再構築している場合は、Dockerキャッシュを修正します。テストが遅い理由が毎回コンパイルがゼロから始まるためなら、Jenkinsレベルのアーカイブを発明する前に、ビルドツール独自のキャッシュを使用します。

ワークスペースを役立つときは保持し、害になるときはクリーンアップする

最もシンプルなキャッシュは、永続エージェント上の既存のJenkinsワークスペースです。同じジョブが同じノードで実行される場合、前のビルドから残されたファイルを再利用できます。

これはMaven、Gradle、npm、pnpm、Cargo、Goなどのツールで役立ちます。ただし、生成されたファイル、古いテストレポート、古いビルド出力がワークスペースに残っていると、奇妙な障害を引き起こす可能性もあります。

一般的な妥協案は、ソースツリーのみをクリーンし、専用のキャッシュディレクトリをその外に保持することです:

pipeline {
  agent { label 'linux-build' }
  environment {
    MAVEN_OPTS = '-Dmaven.repo.local=/var/cache/jenkins/maven'
    npm_config_cache = '/var/cache/jenkins/npm'
  }
  stages {
    stage('Checkout') {
      steps {
        deleteDir()
        checkout scm
      }
    }
    stage('Build') {
      steps {
        sh 'mvn -B test'
      }
    }
  }
}

これにより、ダウンロードした依存関係を再利用しながら、ワークスペースの再現性が保たれます。キャッシュディレクトリの権限がエージェントを実行するユーザーと一致していることを確認してください。

ツールのルールに従って依存関係をキャッシュする

依存関係キャッシュは、パッケージマネージャが制御するときに最も効果的に機能します。強い理由がない限り、node_modulesを無関係なエージェント間でアーカイブして復元しないでください。通常は、パッケージマネージャのダウンロードストアをキャッシュする方が安全です。

npmの場合:

environment {
  npm_config_cache = "${WORKSPACE}/.npm-cache"
}
steps {
  sh 'npm ci'
}

pnpmの場合:

environment {
  PNPM_STORE_PATH = "${WORKSPACE}/.pnpm-store"
}
steps {
  sh 'pnpm install --frozen-lockfile'
}

Mavenの場合、安定したローカルリポジトリパスを使用します:

sh 'mvn -B -Dmaven.repo.local=/var/cache/jenkins/m2 test'

Gradleの場合、GRADLE_USER_HOMEを安定させ、必要に応じてプロジェクトでGradleビルドキャッシュを有効にします:

environment {
  GRADLE_USER_HOME = '/var/cache/jenkins/gradle'
}
steps {
  sh './gradlew test --build-cache'
}

ロックファイルが安全レールです。package-lock.jsonpnpm-lock.yamlpom.xml、またはbuild.gradleが変更された場合、ツールは正しい新しい依存関係を取得する必要があります。キャッシュが古いまたは破損したパッケージを提供し続ける場合は、削除してツールに再構築させてください。

Dockerレイヤーキャッシング

Dockerキャッシングは、Dockerデーモンがレイヤーを保存する場所に依存します。長期稼働のVMエージェントでは、通常のdocker buildが自動的にレイヤーを再利用できます。エフェメラルなKubernetesエージェントでは、次のビルドはレイヤー履歴のない新しいポッドに配置されることがよくあります。

永続的なDockerエージェントの場合、変更が少ないDockerfile行を先に配置します:

FROM node:22-bookworm
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm test

npm ciの前にリポジトリ全体をコピーすると、ソースの変更ごとに依存関係レイヤーが無効になります。

エフェメラルエージェントの場合、BuildKitレジストリキャッシュを使用します:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/my-app:buildcache \
  --cache-to type=registry,ref=registry.example.com/my-app:buildcache,mode=max \
  -t registry.example.com/my-app:${GIT_COMMIT} \
  --push .

このキャッシュはローカルデーモンではなく、レジストリを介して共有されます。これは、書き込み可能なDockerレイヤーディレクトリを多数のポッドにマウントしようとするよりも、KubernetesベースのJenkinsエージェントに適していることが多いです。

アーカイブするのはキャッシュではなくアーティファクト(意図がある場合を除く)

archiveArtifactsは、保持したいビルド出力(JAR、テストレポート、カバレッジファイル、生成されたパッケージ)に便利です。これは汎用的なキャッシュストアとしては適していません。大規模な依存関係アーカイブはコントローラを遅くし、ストレージの負荷を増やし、クリーンアップを困難にします。

クロスエージェントキャッシュが必要な場合は、実際の外部キャッシュロケーション(アーティファクトリポジトリ、オブジェクトストレージバケット、パッケージプロキシ、レジストリキャッシュ)を優先してください。Javaビルドの場合、NexusやArtifactoryなどのリポジトリマネージャは、Jenkinsエージェント間で.m2をコピーするよりも良い結果をもたらすことがよくあります。Dockerの場合、レジストリバックアップのBuildKitキャッシュは、レイヤーディレクトリをtarボール化するよりも予測可能です。

キャッシュキーを可視化する

キャッシュには有効である理由が必要です。適切なキャッシュキーには、オペレーティングシステム、アーキテクチャ、主要な言語バージョン、パッケージマネージャのバージョン、ロックファイルのハッシュが含まれます。

たとえば、Nodeキャッシュキーは次のようになります:

linux-amd64-node22-npm10-sha256(package-lock.json)

このアイデアを適用するために派手なキャッシュプラグインは必要ありません。ディレクトリ名にキーを含めることもできます:

sh '''
LOCK_HASH=$(sha256sum package-lock.json | awk '{print $1}')
export npm_config_cache="/var/cache/jenkins/npm/node22-${LOCK_HASH}"
npm ci
'''

これにより、互換性のないツールバージョン間で依存関係キャッシュが再利用されるのを防ぎます。また、古いキーを簡単に識別できるため、クリーンアップの謎が減ります。

障害モードに注意する

キャッシュにはいくつかよくある障害パターンがあります:

  • エージェントに隠しファイルがあるため、ビルドが1つのエージェントでのみ成功する。
  • 古いキャッシュが削除されないため、ディスクがいっぱいになる。
  • Dockerfileが揮発性ファイルを早すぎるタイミングでコピーするため、Dockerイメージが毎回ゼロから再構築される。
  • 複数のビルドが同時に書き込むため、共有キャッシュが破損する。
  • 巨大なキャッシュの復元が、近くのパッケージプロキシから依存関係をダウンロードするよりも時間がかかる。

ビルドログにはキャッシュが使用されているかどうかが表示されるべきです。Mavenは依存関係をダウンロードするときに表示します。DockerはBuildKit出力でCACHEDまたはキャッシュミスを表示します。Gradleはビルドスキャンとコンソール出力でキャッシュ動作を表示します。キャッシュが不可視の場合は、そのパス、サイズ、キーに関するログを追加してください。

実用的なロールアウト計画

1つのパイプラインと1つのボトルネックを選びます。そのステップのみにキャッシュを追加します。同じコミットを2回実行し、ログを比較します。次に、依存関係のロックファイルを変更した後に実行し、キャッシュが正しく無効化されることを確認します。最後に、クリーンアップを追加します。

永続エージェントの場合、クリーンアップはcronジョブまたはJenkinsメンテナンスジョブにできます:

find /var/cache/jenkins/npm -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
find /var/cache/jenkins/m2 -type f -name '*.lastUpdated' -delete
docker system prune -af --filter 'until=168h'

これらのコマンドを環境に合わせて調整してください。同じDockerデーモンやファイルシステムを他に何が使用しているかを確認せずに、共有ホストで広範なpruneコマンドを実行しないでください。

最良のJenkinsビルドキャッシュ戦略は退屈なものです:高価な繰り返し作業をキャッシュし、ビルドツールに正確性を検証させ、キャッシュパスを明示的にし、エージェントのディスクが次のボトルネックになる前に古いデータを削除します。

キャッシュをエージェントモデルに合わせる

永続VMエージェントと使い捨てクラウドエージェントでは、異なるキャッシュ設計が必要です。永続VMでは、ローカルディレクトリは安価で高速です。/var/cache/jenkins/m2のMavenキャッシュやデーモン上のDockerレイヤーキャッシュは数週間存続できます。リスクはディスクの増加と古い状態です。

使い捨てエージェントでは、ローカルキャッシュは各ビルド後に消えます。それでもキャッシュは可能ですが、キャッシュは別の場所(オブジェクトストレージ、パッケージプロキシ、コンテナレジストリ、永続ボリューム)に存在する必要があります。Kubernetesの永続ボリュームは機能しますが、共有書き込み可能キャッシュには注意が必要です。2つのビルドが同時に同じキャッシュに書き込むと、ロック競合が発生したり、ツールによっては部分的なダウンロードが破損したりする可能性があります。

多くのチームにとって、最良の最初の投資はJenkinsプラグインではありません。それは近くの依存関係プロキシです:

  • MavenまたはGradleをNexusまたはArtifactory経由で。
  • npmをプライベートレジストリまたはプロキシ経由で。
  • Dockerをレジストリミラー経由で。
  • Pythonをパッケージミラーまたは内部インデックス経由で。

これにより、各ジョブの開始時に巨大なtarballを復元することなく、すべてのエージェントが改善されます。

キャッシュすべきでない場合を知る

シークレット、生成された認証情報、トークンを含むデプロイマニフェスト、ビルド出力と環境固有の設定が混在するディレクトリはキャッシュしないでください。テストスイートがスナップショット復元用に明示的に設計されていない限り、テストデータベースをキャッシュしないでください。信頼できるジョブと信頼できないジョブ間で可変キャッシュを共有しないでください。

また、復元ステップが節約する作業よりも大きい場合、キャッシュには懐疑的でいてください。2 GBのアーカイブをダウンロードして展開するのに90秒かかる場合、パッケージマネージャがローカルプロキシから45秒でクリーンインストールできるなら、役に立ちません。

コールドビルドとウォームビルドを測定します:

cold build: ローカルキャッシュなし
warm build: 同じコミット、同じキャッシュキー
changed dependency build: ロックファイル変更
changed source build: ソースのみ変更

これら4つの実行により、キャッシュが実際のワークフローに役立っているのか、それとも1つの人工的な再ビルドを良く見せているだけなのかがわかります。

キャッシュクリーンアップを設計の一部にする

すべてのキャッシュには、稼働前に有効期限のストーリーが必要です。静的エージェントでは、ピークビルド時間外にクリーンアップをスケジュールします。共有レジストリやオブジェクトストレージでは、ライフサイクルポリシーを使用します。Jenkinsでは、キャッシュサイズを運用メトリクスとして追跡し、後付けにしないでください。

インシデント中にキャッシュを削除するのは正常です。適切なパイプラインは、キャッシュ削除後は遅くなるべきですが、壊れてはいけません。キャッシュを削除するとビルドが壊れる場合、そのキャッシュは欠落している依存関係宣言や環境の前提を隠しています。