モノレポでのCI/CDパイプラインの定義方法を考える(GitLab)

13 min read読了の目安(約12000字

リポジトリの管理にGitLabを使用しています。
GitLabの機能としてCI/CDパイプラインの実行が可能で、テストや静的解析を開発フローに組み込むことができるのですが、モノレポ構成の場合のCI/CDの組み方で悩んだ点があったので調べたことをまとめます。

モノレポとは

詳細は省きますが、以下のような特性を持つリポジトリ管理の手法です。

  • 複数のプロジェクトが、同一リポジトリ内にある
  • プロジェクトは相互依存可能で、コードも共有できる
  • 変更を行う場合、モノレポでは全てのプロジェクトで再ビルドや再テストをしない。その代わり、変更の影響を受ける可能性のあるプロジェクトだけを再ビルドと再テストする

モノレポについての誤解 - Misconceptions about Monorepos: Monorepo != Monolith を翻訳しました

複数のプロジェクトが同一リポジトリ内に存在し、それぞれで独立したビルドやテストの流れを持つため、CI/CDパイプラインの定義についても一定の工夫が必要になります。

悩み

定義ファイルの構成

  • パイプラインの管理
    • .gitlab-ci.ymlでパイプライン上で実行されるジョブの定義を行いますが、当然のことながらモノレポ構成になればそれだけ実行するジョブも多くなり、1ファイルでの定義内容が煩雑になります

パイプラインの実行時間

  • 実行時間が不必要に長くなる可能性がある
    • GitLabの基本的な仕組みとしてBuild, Test等のステージとその順番を定義し、Buildが終われば次のステージであるTestのジョブが動くといった前後関係を定められます。
    • モノレポの場合、特定のサービスはビルド時間やテスト時間が長くなるケースが考えられるため、一番処理時間が長いサービスの処理時間に影響を受ける可能性があります。

CIの実施結果との統合

  • CIの実施結果がMerge Request画面で確認できない
    • GitLabではレビューの参考となる情報(カバレッジやテスト結果、コード品質など)をMerge Request(GitHubでいうPull Request)の画面に表示してくれる機能があります。
    • いちいち個別のジョブまでテスト結果を見に行かなくてすむ非常に便利な機能ですが、一部これが動作しないケースがありました

プロジェクトのセットアップ

以下の通り2つのプロジェクトを同一リポジトリの中に作ります。
中身はサンプルで適当に作った関数とテストコードだけです。

.
├── README.md
├── project_a
│   ├── src
│   │   └── sample.py
│   └── tests
│       └── test_sample.py
└── project_b
    ├── src
    │   └── sample.py
    └── tests
        └── test_sample.py

まず、ローカルでテストが実行できるようにします。
ライブラリの管理はpoetryで行い、まずpytestをインストールします。

cd project_a
poetry init
poetry add pytest
def add(a, b):
    return a + b


def substract(a, b):
    return a - b
from src import sample

def test_add():
    result = sample.add(1, 2)
    expect = 3

    assert result == expect

def test_substract():
    result = sample.substract(3, 1)
    expect = 2

    assert result == expect

ローカルでテストを実行してパスすることが確認できれば準備は完了です。

$ poetry run python -m pytest
====================================================== test session starts ======================================================
platform linux -- Python 3.7.8, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /xxxxxxx/test-ci/project_a
collected 2 items                                                                                                               

tests/test_sample.py ..                                                                                                   [100%]

======================================================= 2 passed in 0.01s =======================================================

モノレポでのCI/CDの定義

ここからGitLab CIでCI/CDの定義をしていきます。
といっても検証したいのはジョブの関係性の定義だけですので、テスト以外は実態のないジョブになります。
ご了承ください。

定義方法については自分が確認した限りでは以下の4つがありそうでしたので、この4つについて確認していきます。

  • 全てを同じファイルで定義
  • includeでファイルを分離
  • Triggerにる親子パイプラインで定義
  • Needによるジョブ間の依存関係で定義

なお、GitLabには異なるプロジェクト間でパイプラインの呼び出しを行えるMulti-project pipelinesという機能があるようです。
これを有効に使えば異なるサービス間での前後関係等を構築できそうですが、モノレポでの管理という主題から外れるのと、私自身まだ試していないのでこの記事では対象外とさせていただきます。

全てを同じファイルで定義

一つの.gitlab-ci.ymlファイルで全てのジョブを定義します。

対応後のコードは以下のページから確認できます。

https://gitlab.com/panyoriokome/test-ci/-/tree/ci-base

ここでのポイントとしてはMerge Request画面にカバレッジとテスト結果を出力するため以下2点の対応を行っています。

  • カバレッジの取得
    • 事前にカバレッジ解析の設定を行った上でカバレッジを出力する
  • テスト結果の取得
    • テストリポートをJUnit形式で出力しアーティファクトとしてアップロード
.gitlab-ci.yml
image: python:3.8-slim

stages:
  - test

include:
  - template: Code-Quality.gitlab-ci.yml

test_project_a:
  stage: test
  before_script:
    - pip install poetry
    - poetry config virtualenvs.create false
    - cd project_a
    - poetry install
  script:
    - echo "Running tests"
    - poetry run pytest tests --junitxml=report.xml --cov=src -v
  artifacts:
    when: always
    reports:
      junit: project_a/report.xml

test_project_b:
  stage: test
  before_script:
    - pip install poetry
    - poetry config virtualenvs.create false
    - cd project_b
    - poetry install
  script:
    - echo "Running tests"
    - poetry run pytest tests --junitxml=report.xml --cov=src -v
  artifacts:
    when: always
    reports:
      junit: project_b/report.xml

code_quality:
  artifacts:
    paths: [gl-code-quality-report.json]

このコードをプッシュしてマージリクエストを提出すると、以下の通りカバレッジとテスト結果がマージリクエスト画面に表示されます。

CIの実施結果との統合は問題ありません。
ただ、今はまだ限られたジョブしかありませんが、数が増えていくと一ファイルで管理するのはなかなか辛そうです。
また、実行時間についてもステージの制約があるので、前述の問題は解消されていません。

includeでファイルを分離

includeキーワードを使用することで、外部ファイルの定義を読み込むことができます。(公式ドキュメント)
これにより定義ファイルの分割が実現可能になります。

対応後のコードは以下のページから確認できます。

https://gitlab.com/panyoriokome/test-ci/-/tree/ci-using-include
image: alpine:3.7  # 参考用に軽いalpineに変更

stages:
  - test

include:
  - '/project_a/.gitlab-ci.yml' # includeでプロジェクトごとの定義を分割
  - '/project_b/.gitlab-ci.yml'
  - template: Code-Quality.gitlab-ci.yml

注意点としては、ファイルを分離していても実行時には一つの.gitlab-ci.ymlファイルにマージされますので、呼び出し元のファイルでinclude先で使用するステージを全て定義する必要があります。

ファイルの分割が可能になったことにより以下2つの問題は解決できました。(実行時間に関してはステージ間の制約があるため未解決)

  • 定義ファイルの構成
  • CIの実施結果との統合

Triggerによる親子パイプラインの定義

includeキーワードとtriggerキーワードを組み合わせることで、親子関係のパイプラインを定義することができます。(公式ドキュメント)

この方法の特徴は親子関係のパイプラインという点で、includeキーワードを定義した場合と異なり、親パイプライン(Projectのルートで定義した.gitlab-ci.yaml)から呼び出した子パイプライン同士は完全に独立したものとして扱われます。

そのため、子パイプラインAの実行時間が子パイプラインBに影響を及ぼすことはなくなります。

実装

Triggerを使ってパイプラインの定義を行います。
対応後のコードは以下のページから確認できます。

https://gitlab.com/panyoriokome/test-ci/-/tree/ci-using-trigger

親パイプライン側の定義としては以下の通りで、includeキーワードだけを使用した場合と違い、
親子パイプラインはそれぞれが独立したものであるため、子パイプラインで実行するステージを親パイプラインで定義する必要はありません。

.gitlab-ci.yml
image: alpine:3.7  # 参考用に軽いalpineに変更

stages:
  - triggers

project_a:
  stage: triggers
  only:
    changes: # 以下ディレクトリ内に変更があった場合のみトリガーされる
      - project_a/**/*
  trigger:
    include: project_a/.gitlab-ci.yml # 子パイプライン定義
    strategy: depend

project_b:
  stage: triggers
  only:
    changes: # 以下ディレクトリ内に変更があった場合のみトリガーされる
      - project_b/**/*
  trigger:
    include: project_b/.gitlab-ci.yml # 別の子パイプライン定義
    strategy: depend

この方法を利用することでパイプライン間でそれぞれの実行時間に影響を受けることは無くなりました。ただ、現在のGitLabの仕様では一つ問題があり子パイプライン側で実行したテストやカバレッジがMerge Request画面に表示されません。

また、これはアーティファクトについても同様で、カバレッジレポート等をHTMLファイルで出力等していた場合もMerge Request画面からダウンロードはできず、子パイプラインからダウンロードする必要があります。

子パイプラインを定義した時の問題

まとめると、以下が子パイプライン側に行かないと確認できないのです。

  • テストやカバレッジ、Code Qualityの実行結果
  • アーティファクト

以下の通りGitLab側で対応Issueは上がっていますが、解決は先になりそうです

Needsによるジョブ間の依存関係で定義

公式ドキュメント - Needsに記載のある通り、Needsキーワードを使用してジョブの前後関係を指定することができる。

Needsキーワードの仕様と活用方法

GitLab公式のブログGet faster and more flexible pipelines with a Directed Acyclic Graphに書かれていることを以下に整理します。

  • パイプラインは一般的にはBuildから始まり、全てのBuildが終わってからTest等次のステージに進む
  • GitLabも定義したStageのジョブが全て終わってから次のステージに進む仕様になっている
  • これは理にかなっているが、モノレポ等で依存関係がそれぞれのサービス(パッケージ)ごとに異なる場合には時間のロスが発生する
    • 例えば以下のようにTaskCは1分しかかからないTaskAに飲み依存しているケースで、Task Bのビルドに5分かかる場合、Task Cの実行まで4分間待たないといけない


Get faster and more flexible pipelines with a Directed Acyclic Graph

DAGを使えばステージをまたいでジョブ間の依存関係を指定できるため、前述のロスをなくすことができる。
以下はiOS等、それぞれが独立した処理をNeedsキーワードを使用して前後関係を定義した例。


Get faster and more flexible pipelines with a Directed Acyclic Graph

ユースケースとして前述の環境ごと(iOS, Android)のフローを構築するケースのほか、

  • モノレポ
    • リポジトリ内に個別にビルド、テスト、デプロイを実施するものが存在する場合、個別のジョブごとに依存関係を定義することで、不要な待ち時間を削減できる
  • 非常に時間のかかるテストが存在する場合
    • できるだけ早めにスタートさせることでパイプライン全体の稼働時間を短縮できる

などが考えられる。

また、Needsで定義した場合にはジョブ間の前後関係を図示するための方法として、DAG(Directed Acyclic Graph)が利用できる。
パイプライン実行時にNeedsタブをクリックすると以下のような図が表示され、全体的な処理の流れを確認することができる。


https://docs.gitlab.com/ee/ci/directed_acyclic_graph/#needs-visualization

実装

前項のまとめを元に実際にNeedsキーワードを使用してパイプラインを定義してみます。

対応後のコードは以下のページから確認できます。

https://gitlab.com/panyoriokome/test-ci/-/tree/monorepo-using-needs

基本的にはincludeキーワードを使用してファイルを分割して定義し、各サービスごとの.gitlab-ci.ymlファイルで定義を行います。
また、ジョブの数が少ないとあまりイメージがわかないため、buildやdeployステージを追加します。

.gitlab-ci.yml
image: alpine:3.7  # 参考用に軽いalpineに変更

stages:
  - build
  - test
  - deploy

include:
  - '/project_a/.gitlab-ci.yml'
  - '/project_b/.gitlab-ci.yml'
  - template: Code-Quality.gitlab-ci.yml

具体的な処理内容は省略しますが、各ジョブの流れとしては以下の通りです。

project_a/.gitlab-ci.yml
stages:
  - build
  - test
  - deploy

project_a:
  ...
  stage: build

test_project_a:
  ...
  stage: test
  needs:
   - project_a 

lint_project_a:
  ...
  stage: test
  needs:
   - project_a 

deploy_staging_a:
  ...
  stage: deploy
  needs:
    - test_project_a
    - lint_project_a

この状態でパイプラインが稼働すると、ステージ間の前後関係ではなくジョブ間の前後関係で実行順が制御されます(project_aのBuildステージが終わっていない状態で、project_bのTestステージが開始されている)

また、Needsタブで全体の流れを確認することができます。

そして、マージリクエスト画面でカバレッジやテスト結果を確認することもできます。(実行順の制御が加わっただけで、パイプラインが実行される仕組みとしてはincludeキーワードを使った時と同様のため)

まとめ

モノレポでのパイプラインの定義方法を4つの方法で確認してきました。
当初抱えていた3つの問題を解決できるかでまとめると以下の通りになります。

定義方法 定義ファイル 処理時間 CIとの統合
全てを同じファイルで定義 × ×
includeでファイルを分離 ×
Triggerによる親子パイプラインの定義 ×
Needsによるジョブ間の依存関係で定義

これだけ見るとNeedsで定義するのが良さそうですが、実際にはプロジェクトの状況によりけりだと思います。
Needsは実行順を細かく定義しカスタマイズができますが、そこまで細かな制御が不要なケースもあると思いますし、逆に細かな実行順の設定が必要になるため定義としてはやや複雑になります。
そのため、まずはテンプレートの分離だけをincludeキーワードを使って実現し、パイプラインの複雑さが増してきた段階でneedsキーワードを導入して細かな制御を行うという流れがいいのかなと感じました。
(テンプレートを分離さえしておけば基本的な構成はほとんど変えずに後からneedsやtriggerのキーワードを追加することが可能なので)

また、Triggerを使った方法はパイプラインごとを独立させられるため、管理のしやすさやわかりやすさという意味では素晴らしいと思いますが、カバレッジやテスト結果が簡単に確認できなくなる点は辛いので、そこはGitLabの対応に期待したいと思います。

その他活用できそうな機能

番外編として、モノレポをGitLabで管理するときに活用できそうな機能を紹介します。

複数言語でのカバレッジの取得

test-coverage-parsingに記載がある通りブラウザからプロジェクトごとにカバレッジ率を取得するための正規表現を設定できます。

1つの言語しか扱っていない場合には問題ないですが、モノレポで複数言語が同一リポジトリに複数存在する場合にはブラウザからの設定では対応できません。

こうしたケースで利用できるGitLabの機能がジョブごとに設定できるcoverageキーワードで、テストを実行するジョブごとにカバレッジ率の取得に使用する正規表現を指定できます。

新しくJavaScript(Jest)でのテストジョブを追加してみます。

stages:
  - test

frontend_test:
  image: node:14
  stage: test
  before_script:
    - cd frontend
    - npm install
  script:
    - echo "Running tests"
    - npm run test -- --coverage --ci --reporters=default --reporters=jest-junit
  coverage: '/All\sfiles.*?\s+(\d+.\d+)/' # 正規表現の指定
  artifacts:
    when: always
    reports:
      junit: frontend/junit.xml

すると以下の通りテストジョブでカバレッジ率が取得できます。

また、リポジトリ全体のカバレッジとして3つのサービス(Pythonが2つ、JavaScriptが1つ)のカバレッジの平均値をマージリクエストに表示してくれます。

対応後のコードは以下のページから確認できます。

https://gitlab.com/panyoriokome/test-ci/-/tree/add_frontend_test

参考資料