atama plus techblog
🐙

【CircleCI】モノレポでpath-filteringを使って既存のデプロイフローを維持したまま無駄なデプロイを減らす

に公開

atama plusのtakeiです。

この記事では、モノレポ構成においてCircleCIのpath-filteringを使って既存のデプロイフローを変更せずにデプロイを効率化する方法について紹介します。

背景と目的

私たちの開発中のプロジェクト※では、frontend、backend、infrastructureがすべて1つのリポジトリで管理されているモノレポ構成を採用しています。(※新規プロダクトで、エンジニア2名の最小限の開発チームです)

開発初期はCIの最適化が十分に行われておらず、たとえばfrontendのコードにわずかな変更を加えただけでも、backendやinfrastructureを含むすべてのコンポーネントがデプロイされてしまう状態でした。

その結果、不要なデプロイによる時間やコストの増大不要なサービス停止時間などが課題となっていました。

そこで、変更が加えられた部分に応じて、必要なコンポーネントのデプロイのみが実行されるようにし、無駄な処理を削減したいと考えました。

使用しているCIツールはCircleCIです。
既存のデプロイフローには極力手を加えずに改善を図ることを重視していました。

得られた効果

先に本記事で紹介する改善による効果を紹介します。

本記事で紹介する改善により、既存のデプロイフローを変更することなく、効率的なデプロイの仕組みを実現できました。

具体的には、CircleCIのpath-filtering を活用し、差分のあったディレクトリに応じて必要なジョブだけを実行するようにしたことで、無駄なデプロイを防げるようになりました。

デプロイ対象ごとにワークフローを分けるといった大がかりな改修は行っておらず、既存のジョブ構成を活かしたままの導入が可能だったため、実装コストやリスクも最小限に抑えることができました。

この仕組みにより、デプロイ時間は最大で約80%削減され、開発・運用効率の大幅な改善につながっています。

デプロイフローに大きな手を加えたくないが、効率化はしたい」というケースにおいて、本記事の内容が参考になれば幸いです。

課題:無駄なデプロイによる時間とリスクの増大

私たちの開発中のプロジェクトは、frontend、backend、infrastructureを1つのリポジトリで管理するモノレポ構成を採用しています。構成は以下のとおりです。

プロジェクト構成:
├── frontend/          # Next.js アプリケーション
├── backend/           # FastAPI + Lambda
└── infrastructure/    # AWS CDK

このモノレポでは、リポジトリ内のどこかに変更を加えると、すべてのコンポーネント(frontend、backend、infrastructure)のデプロイが実行される状態になっていました。その結果、以下のような問題が発生していました。

課題1: 無駄なデプロイによる時間とCIコストの増加

たとえば以下のように、実際には関係のないコンポーネントまでデプロイされてしまうことで、CI実行時間とコストが不必要に増加していました。

  • frontendのみの変更 → backendとinfrastructureもデプロイされる
  • backendのみの変更 → frontendもデプロイされる

課題2: 不要なサービス停止の発生

無関係なデプロイが走ることで、以下のような副作用が発生していました。

  • frontendの変更にもかかわらずLambdaの再デプロイが実行され、数分間のAPI停止が発生する
  • 不要なCloudFrontのキャッシュクリアが走ることで、パフォーマンスに影響が出る場合がある

課題3: インフラ変更に伴うリスクの増加

コードの変更のみであってもinfrastructureの再デプロイが実行されてしまうため、以下のようなインフラ関連のリスクがありました。

  • CDKによる意図しない構成変更が発生する可能性がある
  • 不要なインフラ更新により、本番環境に影響が及ぶリスクがある

要望:デプロイフローを維持したまま効率化したい

私たちのプロジェクトでは、デプロイフローに特有の複雑な依存関係がありました。たとえば、backendのデプロイが完了した後には、Lambdaのprovisioned concurrencyを更新する目的でAPIサーバーのデプロイを行う必要がありました。(※ backendデプロイの後にprovisioned concurrencyのバージョンを更新するスクリプトを実行する、なども考えられますが、設定を一箇所で管理したいため、便宜的にcdkデプロイをbackendに依存させるという選択肢を取りました。)

さらに、backendのDBマイグレーションが成功した場合にのみ、frontendのデプロイを実行するという制約もありました。これは、DBマイグレーションが失敗するケースがあるため、失敗時にはfrontendの更新を避ける必要があったためです。

このように、各コンポーネントのデプロイが密に連携しているため、単純にワークフローを分割・簡略化することは難しく、既存のデプロイフローを大きく書き換えるのは一筋縄ではいきませんでした。特にリリースが間近に控えていたこともあり、大規模な変更を避けつつ、できるだけ低リスクでデプロイの効率化を図る必要がありました。

解決策の選定

デプロイ効率化の手段として、当初は以下の2つのアプローチを検討しました。

  1. モノレポ管理ツールの導入
  2. CircleCIのpath-filteringを利用

最終的には、2のCircleCIのpath-filteringを採用しました。理由は以下のとおりです。

まず、モノレポ管理ツールは変更の影響範囲を適切に把握できるという利点があるものの、導入には一定のキャッチアップコストがかかるうえ、既存のデプロイフローにも変更が必要になる可能性がありました。リリース直前というタイミングもあり、大きな構成変更はリスクが高いと判断しました。

一方、path-filteringであれば、既存のジョブ定義を活かしながら差分に応じたジョブの実行制御が可能であり、デプロイフロー自体に手を加えずに導入できる見込みがありました。
実際に簡単な検証を行ったところ、想定以上にスムーズに動作することが確認できたため、こちらの方法で進めることにしました。

なお、モノレポ管理ツールの導入については、長期的な選択肢として今後改めて検討する予定です。

実際の実装

ここからは、実際にどのようにpath-filteringを用いた仕組みをCircleCI上で実装したかを紹介します。

設定ファイルの分離

まず、.circleci/config.ymlにpath-filtering orbを導入し、差分ファイルに応じたフラグを設定しました。

.circleci/config.yml
version: 2.1
setup: true

orbs:
  path-filtering: circleci/path-filtering@2.0.0

workflows:
  setup:
    jobs:
      - path-filtering/filter:
          mapping: |
            frontend/.* frontend-changed true

            # backendの変更ではbackendとinfrastructureの両方をデプロイする
            backend/.* backend-changed true
            backend/.* infrastructure-changed true

            # infrastructureの変更では全てデプロイする
            infrastructure/.* infrastructure-changed true
            infrastructure/.* frontend-changed true
            infrastructure/.* backend-changed true
          config-path: .circleci/continue_config.yml

変更されたディレクトリに応じて、frontend-changed、backend-changed、infrastructure-changedといったパラメータにtrueを設定しています。

backendの変更時にはインフラ側の更新も必要になるため、infrastructure-changedもtrueにしています。(本来はmigrationも分けたい)
infrastructureの変更時はすべての依存コンポーネントを再デプロイする必要があるため、すべてのフラグを立てています。

続いて、.circleci/continue_config.yml にてこれらのパラメータを受け取るように設定します。

.circleci/continue_config.yml
parameters:
  frontend-changed:
    type: boolean
    default: false
  backend-changed:
    type: boolean
    default: false
  infrastructure-changed:
    type: boolean
    default: false

ジョブごとの条件分岐

次に、各ジョブの実行をこれらのパラメータに基づいて制御します。
ここでは例として、frontendのビルド・デプロイジョブの設定を示します。

.circleci/continue_config.yml
jobs:
  frontend-build-deploy:
    executor: node-executor
    steps:
      - when: # パラメータがtrueの場合のみ実行
          condition: << pipeline.parameters.frontend-changed >>
          steps:
            # ... 実際のデプロイ処理
      - unless: # ジョブ全体がスキップされないように何もしないステップを追加
          condition: << pipeline.parameters.frontend-changed >>
          steps:
            - run:
                name: Skip - No frontend changes
                command: echo "Skipping frontend-build-deploy - no frontend changes detected"

今回の目的は既存のワークフロー構造を保ったまま、不要な処理だけをスキップすることでした。
そのため、ジョブ自体をスキップするのではなく、when/unlessを使ってステップ単位で制御しています。

ただしこの方法には注意点もあります。
たとえ実行されないとしてもジョブそのものは開始されるため、CI上では「スキップされた」ことが視覚的にわかりづらいという欠点があります。
また、不要なジョブの起動が走るため、完全に無駄なリソース消費を抑えることはできません。

依存関係に影響しない構成であれば、ジョブレベルで除外する設計のほうが望ましい場合もあるため、ユースケースに応じて使い分けが必要です。

差分比較の基準を定める

path-filteringでは、どのブランチやコミットを基準に差分を比較するかをbase-revisionパラメータで指定できます。
デフォルトではmainブランチとの比較になりますが、これが最適とは限らず、この設定には特に悩まされました。

当初はmainブランチとの差分を基準にしていましたが、複数のコミットをまとめてマージした場合などに、想定と異なる差分が検出される問題が発生しました。

例えば複数のコミットを一気に入れた際に、最後のコミットとその1つ前のコミットの差分が差分として扱われてしまい期待しない結果になりました。

状況 コミット履歴 差分比較対象(期待) 差分比較対象(実際) 結果の違い
複数コミットをまとめて push 1. A(前回push)
2. B
3. C(新規push)
C と A の差分(全体の変更) C と B の差分(直前のコミットの変更のみ) 本来含めるべき B の変更が差分から漏れる

このような問題を回避するため、「最後にデプロイが成功したコミット」を base-revision にすることが理想だと考えました。
しかし、CircleCIのAPIを利用して最後にデプロイが成功したコミットを取得しようとするとProject tokenが使えず、Personal API Tokenを用いる必要がありました。

Currently, Personal API tokens are the only supported tokens on API v2. Project tokens are not currently supported on API v2.
CircleCI Developer Docs

CI環境で個人トークンを扱うのは管理・運用上の懸念があったため、別の方法を模索しました。

最終的には、CircleCIが提供しているpipeline.git.base_revisionという組み込みパラメータを使うことにしました。
これは、前回パイプラインが実行されたときのコミット SHAを指し、base-revisionに利用できます。

ただし注意点として、pipeline.git.base_revision前回のパイプライン実行が成功したか失敗したかを問わないため、次のようなケースでは差分検出が正しく行えないリスクがあります。

  • 例:インフラの変更をデプロイして失敗 → 次のフロントエンドの変更をデプロイして成功 → 失敗したインフラの差分のデプロイがスキップされる

このようなケースは頻度としては多くないと考えましたが、万一の見落としを防ぐため、手動で対象コンポーネントを指定して再デプロイできる仕組みを別途用意することでリスクを回避しました(後述)。

最終的な設定は以下です。

.circleci/config.yml
- path-filtering/filter:
    name: check-updated-files
    base-revision: << pipeline.git.base_revision >>
    filters:
      branches:
        only: [main] # ビルドブランチ

- path-filtering/filter:
    name: check-updated-files
    base-revision: main
    filters:
      branches:
        ignore: [main] # ビルドブランチ以外(開発ブランチ)

このように、mainブランチでは前回パイプライン実行時のコミットを比較対象とし、それ以外のブランチではmainとの比較とすることで、開発時と本番デプロイ時で差分比較の基準を使い分けています。

手動実行機能の追加

前述したように今回の差分検知のロジックでは、想定どおりにジョブが実行されないケースがまれに発生します。
また、強制的に特定のコンポーネントのデプロイを実行したい場合もあります。

こうしたケースに対応するため、パラメータによって任意のジョブを手動で実行できる仕組みを追加しました。
この機能の実装には、CircleCIのcontinuation orbを利用しています。

config.yml 側の定義

まず、手動実行時に利用するパラメータをconfig.ymlに定義し、それに応じてジョブを分岐するワークフローを追加します。

.circleci/config.yml
version: 2.1
setup: true

orbs:
  continuation: circleci/continuation@2.0.0

parameters:
  manual-frontend:
    type: boolean
    default: false
    description: "手動実行時にフロントエンドのジョブを実行"

  manual-backend:
    type: boolean
    default: false
    description: "手動実行時にバックエンドのジョブを実行"

  manual-infrastructure:
    type: boolean
    default: false
    description: "手動実行時にインフラストラクチャのジョブを実行"

workflows:
  manual-execution:
    when:
      or:
        - << pipeline.parameters.manual-frontend >>
        - << pipeline.parameters.manual-backend >>
        - << pipeline.parameters.manual-infrastructure >>
    jobs:
      - continuation/continue:
          configuration_path: .circleci/continue_config.yml
          parameters: |
            {
              "frontend-changed": << pipeline.parameters.manual-frontend >>,
              "backend-changed": << pipeline.parameters.manual-backend >>,
              "infrastructure-changed": << pipeline.parameters.manual-infrastructure >>
            }
  setup:
    unless:
      or:
        - << pipeline.parameters.manual-frontend >>
        - << pipeline.parameters.manual-backend >>
        - << pipeline.parameters.manual-infrastructure >>
    jobs:
      - ...

continue_config.yml側の定義

上記のパラメータはcontinue_config.yml側でも定義されている必要があります。
(※未定義だとcontinueジョブがエラーになりました。改善方法を知っている方がいれば教えてください)

.circleci/continue_config.yml
parameters: # 使用しないがパラメータを定義しないとエラーになるので追加
  manual-frontend:
    type: boolean
    default: false
  manual-backend:
    type: boolean
    default: false
  manual-infrastructure:
    type: boolean
    default: false

実行方法

これらの設定を行い、CircleCIのUI上から「Trigger Pipeline」を選択すると設定したパラメータの入力フォームが表示されます。
ここで実行したいコンポーネントに対応するパラメータをtrueに設定することで、該当のジョブだけを選んで実行することが可能になります。

結果

デプロイ時間の短縮

今回の導入により、差分のあるコンポーネントのみを対象としたデプロイが可能になった結果、デプロイ時間を大幅に短縮できました。

変更内容 Before After 削減率
frontendのみ 10分 2分 80%削減
backendのみ(※インフラのデプロイも伴う) 10分 8分 20%削減

※ backendはインフラのデプロイも伴うため削減効果は限定的でしたが、それでも改善が見られました。

リスク軽減効果

デプロイの実行範囲が適切に制御されることで、以下のようなリスクの軽減にもつながりました。

  1. サービス停止時間の最小化

    • frontendの変更時に不要なLambdaの再デプロイが発生しなくなり、APIの停止時間を回避できるようになりました。
  2. インフラ関連のリスク低減

    • コード変更のみの場合、CDKによる不要なスタック更新が発生しなくなり、予期しないインフラ構成の変更を防止できます。

今後の改善点

差分検知方法の改善

現在はpipeline.git.base_revisionを基準に差分比較を行っていますが、これは前回パイプライン実行時のコミットであり、前回のデプロイが成功したコミットとは一致しません。
期待するデプロイが実行されないケースがあるので、将来的には「最後にデプロイに成功したコミット」からの差分を基準にできるようにしたいです。

まとめ

CircleCIのpath-filteringを活用することで、既存の複雑なデプロイフローを維持したまま、無駄な処理を省いて効率的なデプロイを実現することができました。

導入にあたってはジョブ定義やワークフローに大きな変更を加えることなく、少ない実装コストで運用への影響も最小限に抑えられました。

主な成果:

  • デプロイ時間を最大80%削減
  • 不要な処理によるサービス停止の回避
  • インフラデプロイのリスク軽減

技術的な工夫ポイント:

  • 既存のデプロイフローを維持しながら最適化
  • 差分比較の基準を前回デプロイしたコミット(改善の余地あり)
  • 手動実行機能で緊急時の柔軟性を確保

参考資料

atama plus techblog
atama plus techblog

Discussion