弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

1400 行の一枚岩な .gitlab-ci.yml を分割して CI を高速化した

こんにちは。弁護士ドットコム クラウドサイン事業本部で SRE をしています、大内と申します。 クラウドサイン事業本部の SRE ではサービスの可用性、信頼性の向上や開発の高速化、省力化を目指した開発を日々行っています。

クラウドサイン事業本部では本体アプリケーション(以下本体)のソースコード管理を GitLab で行っています。 そして、本体とその関連サービス、バッチなどを 1 つのリポジトリで管理するモノレポ構成を取っています。

今回は、そんなモノレポ構成のリポジトリの GitLab CI パイプラインを分割し、開発速度を大きく改善した話をご紹介します。

CI 分割以前の CI 構成

2021 年 7 月当時、本体リポジトリの master ブランチが更新されると、 並列して 19 個の CI ジョブが同時に実行されていました。

GitLab CI では CI ジョブの定義を YAML(.gitlab-ci.yml)で行います。

このとき、テストやデプロイなど、全く同じ処理だけれど、 パラメータだけが異なるようなジョブ定義する場合、 extends キーワードを使用することで CI ジョブ定義を共通化できます。

クラウドサインでもこの extends を多用しており、 以下のように大量のジョブ定義を共通化して実装しておりました。

.build:
  stage: test
  before_script:
    - # ...
  script:
    - cd "$APP"
    - docker build --target app .

build:app1:
  extends: .build
  variables:
    APP: app1

build:app2:
  extends: .build
  variables:
    APP: app2

build:app3:
  extends: .build
  variables:
    APP: app3

# 以降も同様の設定

しかしながら、リポジトリがモノレポ構成を取るようになって、いくつか CI に課題が見えてきました。

モノレポ構成プロダクトの CI が抱えていた課題

以下の 3 つの課題がありました。これらの課題について詳細に説明します。

  1. 他のテストの待ち時間の影響を受けて待ち時間が伸びる
  2. テストが失敗するとすべてのデプロイがブロックされる
  3. .gitlab-ci.yml の肥大化による、コードの保守性の低下

他のテストの待ち時間の影響を受けて待ち時間が伸びる

CI 構成は基本的に test ステージと deploy ステージの 2 段階構成にしています。

test ステージの CI ジョブがすべてパスすると deploy ステージに進行します。 図にすると、以下のようになります。

CI 分割以前の CI 概略図

このとき、 test ステージの CI ジョブの内、 どれか 1 つでも時間がかかると、他のすべてのデプロイジョブも待たされてしまいます。

依存関係のある CI ジョブをグルーピングしたものをここではパイプラインと定義し、 図に起こしたものが以下です。

1 つの CI ジョブが待たされると、すべてのデプロイジョブが待たされる図

特に、当時 test ステージの CI ジョブの中には 10 分以上かかるものも存在しておりました。 この待ち時間は開発スピードに影響を与えていると感じていました。

テストが失敗するとすべてのデプロイがブロックされる

前述のとおり、test ステージの CI ジョブがすべてパスすると deploy ステージに進行します。 つまり、 1 つでも test ステージの CI ジョブが失敗すると、すべてのデプロイジョブは実行されません。

1 つの CI ジョブが失敗すると、すべてのデプロイがブロックされる図

しかしながら、デプロイジョブが依存する CI ジョブはそれぞれ異なります。

デプロイジョブに関係ないテストジョブが失敗した場合は、 影響を受けずにそのままデプロイできたほうが良いだろうと感じておりました。

.gitlab-ci.yml の肥大化による、コードの保守性の低下

もともと 1 つの .gitlab-ci.yml にすべての CI ジョブが定義されていました。 その行数はその当時で 1400 行ありました。

前述のとおり、複数のステージにまたがって CI ジョブが定義されて、それぞれ異なる依存関係を持っていました。 前述の図は依存関係を簡略化したもので、もう少し細かく書くと以下のように複雑な依存関係にありました。 (これですべてではありません)

パイプラインごとにジョブの依存関係が異なることを表した図

これらパイプラインごとの依存関係の違いを理解して .gitlab-ci.yml を変更するのはハードルが高く、 CI を更新できるエンジニアが属人化してしまう懸念がありました。

課題解決のために実施した CI 分割の詳細

前述の問題を解決するために GitLab CI パイプラインの分割を実施しました。 実施した内容を 3 行で説明すると、以下のとおりです。

  1. CI ジョブ間の依存関係ごとに CI パイプラインを分割する
  2. CI ジョブの依存関係をまとめた CI パイプラインの起動トリガーにファイルの変更を追加する
  3. 変数や処理の無理な共通化をしない

CI ジョブ間の依存関係ごとに CI パイプラインを分割する

前述の図で示したとおり、一枚岩の巨大な .gitlab-ci.yml で定義される CI ジョブ郡の中には、それぞれ異なる依存関係が存在します。

これらの依存関係を整理し、依存関係ごとに CI パイプラインを分割することで、他パイプラインの影響を受けない構成にしました。

これは GitLab CI が提供しているParent-child pipelines機能を利用することで実現できます。

前述の CI の図では 3 つのアプリケーションが存在しました。

  1. app1
  2. app2
  3. app3

これらはそれぞれ test と deploy のジョブを 1 つ以上持っており、 deploy は 1 つ以上の test に依存します。

この依存関係ごとに .gitlab-ci.yml を作成し、 リポジトリ直下の .gitlab-ci.yml(以降ルートファイルと呼称)から include することで実現できます。

これを実現する 4 つの .gitlab-ci.yml を以下に例示します。

  1. /.gitlab-ci.yml(ルートファイル)
  2. app1/.gitlab-ci.yml
  3. app2/.gitlab-ci.yml
  4. app3/.gitlab-ci.yml
app1:
  stage: test
  trigger:
    include: app1/.gitlab-ci.yml
    strategy: depend
  rules:
    - if: '$CI_COMMIT_REF_NAME =~ /^(master|staging)$/ || $CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - app1/**/*

app2:
  stage: test
  trigger:
    include: app2/.gitlab-ci.yml
    strategy: depend
  rules:
    - if: '$CI_COMMIT_REF_NAME =~ /^(master|staging)$/ || $CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - app2/**/*

app3:
  stage: test
  trigger:
    include: app3/.gitlab-ci.yml
    strategy: depend
  rules:
    - if: '$CI_COMMIT_REF_NAME =~ /^(master|staging)$/ || $CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - app3/**/*
# app2, app3 も同様の構成
stages:
  - test
  - deploy

test:app1:
  stage: test
  script:
    - ...
  rules:
    - when: on_success

prd:deploy:app1:
  stage: deploy
  script:
    - ...
  rules:
    - if: '$CI_COMMIT_REF_NAME == "master"'
      when: on_success

stg:deploy:app1:
  stage: deploy
  script:
    - ...
  rules:
    - if: '$CI_COMMIT_REF_NAME == "staging"'
      when: on_success

これらのファイル構成にすることで、以下のような CI パイプライン構成になります。

新しい CI パイプライン

CI パイプラインが分離されていることで、前述の 3 つの問題は解決しました。 それを図示したものが以下です。

新しい CI パイプライン 2

CI ジョブの依存関係をまとめた CI パイプラインの起動トリガーにファイルの変更を追加する

CI パイプラインの分割をしたことで、依存関係が 1 つのパイプライン定義内で閉じるようになりました。

結果、パイプライン自体の起動トリガー 1 箇所だけにトリガー設定を入れるだけで同じことが実現できるようになりました。 それまでは、個別ジョブに YAML のエイリアスとアンカーで設定して回る必要があったのですが、これが不要になりました。

以下のように設定します。

 app1:
   stage: test
   trigger:
     include: app1/.gitlab-ci.yml
     strategy: depend
   rules:
     - if: '$CI_COMMIT_REF_NAME =~ /^(master|staging)$/ || $CI_PIPELINE_SOURCE == "merge_request_event"'
+      changes:
+        - app1/**/*

これにより、特定のディレクトリ配下のファイルの変更をトリガーに、そのパイプラインの CI ジョブだけが起動するようになりました。 結果、 1 つの Merge Request などで実行される CI はせいぜい 1 , 2 個程度になったため、大幅に無駄な CI 実行コストを削減できました。

変数や処理の無理な共通化をしない

ファイルの分割をしたことで、似たようなジョブ定義や設定値が複数ファイルへ散らばるようになりました。 これらの設定値をルートファイルに定義して、子パイプラインに継承させることも可能でしたが、これはやらない方針にしました。

「.gitlab-ci.yml の肥大化による、コードの保守性の低下」で書いたとおり、 一枚岩の .gitlab-ci.yml ですべての CI を管理するのは、保守性の低下と属人化を招きます。

そして、複数ファイルにまたがる設定値の共有も同様の問題を起こすと考えました。 つまり「設定値をどこで使っているか分からないのでファイルに手を出せない」事態を避けたかったのです。

故に、似たような設定値は個別に .gitlab-ci.yml に設定することで「その CI を変更したいときは、そのファイルだけ見れば良い」状態にしました。 当然、コード量は以前よりも多くなりましたが、属人化の問題よりは軽い問題と判断しました。

CI 分割の結果得られた効果測定

CI 分割が無事完了したため、効果測定も行いました。 以下の 2 つの効果について説明します。

  1. CI ジョブの待ち時間の変化
  2. .gitlab-ci.yml のファイル行数の変化

CI ジョブの待ち時間の変化

CI パイプラインが分割されたことで、テストジョブが終わったら随時デプロイを開始するようになりました。 効果測定をするために GitLab CI の API を使って、 CI ジョブのデータを 30,000 件ほど取得して計測しました。

以下のシェルスクリプトでデータを取得します。

#!/bin/bash
#
# 必要な環境変数:
# - GITLAB_TOKEN

set -eu

readonly PROJECT_ID=1234 # プロジェクトID
readonly API_URL="https://*********/api/v4/projects/$PROJECT_ID/jobs"

(
  for i in {1..300}; do
    curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" -s "$API_URL?per_page=100&page=$i" | jq -cr '.[]'
  done
) | tee rawdata.json

そして依存関係のある CI パイプラインのテストジョブとデプロイジョブをそれぞれ抽出して パイプライン ID で JOIN して 1 行のデータに加工して CSV 形式で出力します。

join -a 1 -a 2 -t , \
  <(cat rawdata.json |
    jq -sr '.[] | [.pipeline.id, .name, .status, .stage, .created_at, .started_at, .finished_at] | @csv' |
    grep "ビルドジョブ名" |
    grep success |
    sort -t, -nk1) \
  <(cat rawdata.json |
    jq -sr '.[] | [.pipeline.id, .name, .status, .stage, .created_at, .started_at, .finished_at] | @csv' |
    grep "デプロイジョブ名" |
    grep success |
    sort -t, -nk1) |
  awk -F , 'NF==13' |
  sed -E -e 's/\+[0-9]{2}:[0-9]{2}//g' -e 's/([0-9])T([0-9])/\1 \2/g'

出来上がった CSV のうちテストジョブの finished_at とデプロイジョブの started_at を引き算し、その秒数を wait_time として算出します。

CI パイプラインの分割前のデータから算出した結果を表にまとめたものが以下になります。

wait_time - 時-分(24 時制) wait_time の COUNTA ジョブ起動数の内の割合 (%)
0:00 83 30.5
0:01 3 1.1
0:02 3 1.1
0:03 11 4.0
0:04 5 1.8
0:05 29 10.7
0:06 30 11.0
0:07 30 11.0
0:08 24 8.8
0:09 16 5.9
0:10 16 5.9
0:11 7 2.6
0:12 4 1.5
0:13 3 1.1
0:14 3 1.1
0:15 2 0.7
0:16 1 0.4
0:17 1 0.4
0:18 1 0.4

上記表からは、以下のことが分かります。

  • 1 分未満でデプロイまで進んでいるジョブが 30 %ある
  • デプロイが開始するまでに 5 分ほど待たされているジョブが 10.7 %ある
  • デプロイが開始するまでに 6 分ほど待たされているジョブが 11.0 %ある
  • デプロイが開始するまでに 7 分ほど待たされているジョブが 11.0 %ある
  • つまり「デプロイが開始するまでに 5 〜 7 分待っているジョブが全体の 30 %を占める」

この結果から、他の CI ジョブの待ち時間の影響を受けていることが分かります。

次に、今回の CI 分割の対応を入れた後の CI ジョブデータに対して、同様の集計を実施します。

結果を表にまとめたものが以下になります。

wait_time - 時-分(24 時制) wait_time の COUNTA ジョブ起動数の内の割合 (%)
0:00 32 100

計測した当時のデータのままのため、データ数がとても少ないですが 「すべてのジョブが、テストを完了してから 1 分以内 にデプロイを開始している」ことがわかります。

CI パイプラインが分割されたことにより、他のテストジョブの影響を受けなくなったため、 依存するテストが完了したら随時デプロイに移行していると言えます。

.gitlab-ci.yml のファイル行数の変化

CI 分割を実施した当時のルートファイルの行数が 600 行、 個別の .gitlab-ci.yml の行数は 30 行〜 200 行程度になりました。

それらファイル行数すべてを合算すると 1600 行ほどで、分割後の全体のファイル行数は増加しました。

しかしながら、個別ファイルの行数はコンパクトになったため、 CI 設定の見通しがとても良くなりました。

CI パイプラインの分割を実施した当時から 1 年ほど経過し、現在 .gitlab-ci.yml がどうなっているか調べました。 以下のコマンドで確認します。

find . -name '*.gitlab-ci.yml' | grep -v node_modules | xargs wc -l | tail -n 1
    5681 total

⟩ find . -name '*.gitlab-ci.yml' | grep -v node_modules | awk 'END{print NR}'
53

どうやら .gitlab-ci.yml すべての合算ファイル行数は 5681 行で、ファイル数は 53 個になったようです。 1 年もの間に行数が 4000 行も増えたのは驚きです。

もし CI パイプラインを分割しないで 1 つの .gitlab-ci.yml だけで管理してたら、どうなっていたか想像したくありません。

まとめ

話した内容をまとめると、以下のとおりです。

  • CI 分割以前の状態について話しました
    • リポジトリがモノレポ構成になったことで CI で大量のテストジョブが同時に走るようになった
  • CI 分割以前の課題について話しました
    • 時間のかかるテストジョブが 1 つでも存在すると、すべてのデプロイが待たされる
    • テストジョブが 1 つでも失敗すると、すべてのデプロイが止まる
    • すべての CI ジョブを 1 つの .gitlab-ci.yml で管理するため、保守性が下がる
  • 課題を改善するべく、CI パイプラインを分割しました
    • CI ジョブの依存関係ごとに、 .gitlab-ci.yml ファイルを分割した
    • CI パイプラインの起動トリガーにファイルの変更を追加した
    • 変数や処理の無理な共通化を諦めた
  • 改善結果の計測をしました
    • 他のパイプラインの CI ジョブの影響を受けなくなり、テストが完了すると即座にデプロイが開始するようになった
    • CI 全体の見通しがよくなり、保守性が向上した

以上です。