GitLab CI で複数プロジェクト共通のパイプラインテンプレートを作りながら学んだこと

フロントエンドエンジニアが GitLab CI で複数プロジェクトから使える共通パイプラインテンプレートを作る中で直面した課題と解決策。

aonoJune 8, 2026

フロントエンドエンジニアとして、複数のプロジェクトから共通で使えるパイプラインテンプレートを作る活動をしていました。GitLab CI はもちろん、CI そのものにもほとんど触れたことがなく、shell もあまり書けない状態からのスタートです。

それでも手を動かしながらテンプレートを改善していく中で、調べてもすぐには答えが見つからない課題にいくつか出会いました。本記事では、その中から 3 つの課題を取り上げて、それぞれをどう解決したかを紹介します。

前提知識 : GitLab CI の仕組み

本題に入る前に、GitLab CI に馴染みのない方向けに、この記事を読むうえで必要な範囲だけ押さえておきます。GitHub Actions を使ったことがあれば、対応関係で捉えると分かりやすいと思います。

  • GitHub Actions と同様に、パイプラインは複数のジョブ (job) で構成されます。設定はリポジトリ直下の .gitlab-ci.yml に記述します
  • ジョブを実行するのは Runner というアプリケーションです。GitLab 本体からジョブを受け取って実行し、結果を GitLab に報告します。GitHub Actions の runner に相当します
  • ジョブは stage という単位でグループ化され、stage の順に実行されます。needs を使うと、ジョブ同士の依存関係を個別に定義できます
  • include を使うと、別プロジェクト(リポジトリ)で定義した CI 設定を取り込めます。GitHub Actions の reusable workflows に相当する仕組みです
  • variables で設定値を定義します。CI/CD 変数は環境変数の一種で、ジョブの実行時に Runner がジョブの実行環境に環境変数として設定します。そのため script の shell からは $PORT のように参照でき、shell から起動した Node.js などの子プロセスからも process.env.PORT のように参照できます。テンプレート側で定義した値を、利用者側の .gitlab-ci.yml から上書きすることもできます

今回作っているテンプレートは、この include を使って、共通のパイプラインとして各リポジトリから利用してもらう想定です。

作っているもの

対象は任意のアプリケーションのリポジトリです。CI 上で何らかの計測を行い、結果を 1 つの MR コメントに統合して投稿するためのパイプラインテンプレートを提供しています。用途の一例としては、アプリケーションのパフォーマンスを複数の観点で並列に計測する、といったものを想定しています。

利用者側は、自分のリポジトリの .gitlab-ci.yml でテンプレートを include し、テンプレートのジョブが属する performance / report stage を stages に追加するだけで導入できます。

# 利用者側 .gitlab-ci.yml — これだけで導入完了
stages:
  - performance # テンプレートのジョブが属する stage を追加
  - report
 
include:
  - project: "your-group/ci/pipeline-template" # 別リポジトリを指定
    ref: main
    file: "performance.yml"

初期のジョブ構成は次の通りです。perf-build で計測用にアプリをビルドし、その成果物を受け取った各計測ジョブ(perf-1 / perf-2 / perf-3)が並列に走り、最後に performance-report が結果をまとめて MR にコメントします。各ジョブは needs で連結しています。

perf-build: # 計測用にアプリをビルド
  stage: performance
 
perf-1: # ビルド成果物を受け取り、アプリを起動して計測
  stage: performance
  needs: [perf-build]
 
perf-2:
  stage: performance
  needs: [perf-build]
 
perf-3:
  stage: performance
  needs: [perf-build]
 
performance-report: # 計測結果を受け取り、MR にコメント
  stage: report
  needs: [perf-build, perf-1, perf-2, perf-3]

実際のパイプラインで見ると、利用者(sample-app)自身のジョブ(lint / tsc / build)と並んで、テンプレートが提供するジョブ群(点線枠内)が追加されます。

テンプレートが提供するジョブ群。利用者自身のジョブと並んで perf-build / perf-1 / perf-2 / perf-3 / performance-report が動く

この構成を多くのリポジトリに使ってもらえる形に育てていく中で、3 つの課題に直面しました。順番に見ていきます。

課題 1 : 設定値の variables が肥大化する

最初の課題は、設定値の渡し方です。

当初は、計測対象パス・計測回数・ポート・各計測の閾値などの設定値を、すべて GitLab CI の variables: で利用者に定義させる実装でした。実際の利用者側の .gitlab-ci.yml はこうなります。

variables:
  PERF_PATHS: "/, /list, /detail"
  PORT: "3000"
  BUILD_OUTPUT_DIR: "dist"
  PERF_RUNS: "3"
  PERF1_LEVEL: "warn"
  PERF2_LEVEL: "warn"
  # perf-1 のスコア閾値
  PERF1_SCORE_A: "0.9"
  PERF1_SCORE_B: "0.9"
  PERF1_SCORE_C: "0.9"
  PERF1_SCORE_D: "0.9"
  # perf-1 の指標上限
  PERF1_MAX_W: "1800"
  PERF1_MAX_X: "2500"
  PERF1_MAX_Y: "0.1"
  PERF1_MAX_Z: "200"

利用者の .gitlab-ci.yml には利用者自身のパイプラインの変数も存在するため、テンプレート用の変数と混在して、どれがどの変数か見分けられない状態になります。また、このテンプレートには「利用者側のコードにはなるべく変更を加えない」という前提があったのですが、上書きしたい設定だけでもこの分量になり、その前提に明らかに反していました。

解決策 : 設定ファイル + マージ

設定の受け渡しを variables からリポジトリ内の設定ファイルに移しました。利用者リポジトリに .performance/config.json を置いてもらい、テンプレート側のデフォルト JSON と利用者の JSON を jq でマージします。

利用者側の .gitlab-ci.yml からは、計測用の変数がほぼ消えます。

variables:
  BUILD_OUTPUT_DIR: "dist"

代わりに、利用者は .performance/config.json で設定を記述します。

{
  "paths": ["/", "/list", "/detail"],
  "port": 3000,
  "runs": 3,
  "perf-1": {
    "level": "warn",
    "score": {
      "a": 0.9,
      "b": 0.9,
      "c": 0.9,
      "d": 0.9
    },
    "max": {
      "w": 1800,
      "x": 2500,
      "y": 0.1,
      "z": 200
    }
  },
  "perf-2": {
    "level": "warn"
  }
}

テンプレート側では、デフォルトと利用者設定を jq でマージして読み込みます。

perf_load_config() {
  local defaults=".perf/config.defaults.json"     # .perf/ = perf-build が clone したテンプレート側
  local overrides=".performance/config.json"      # .performance/ = 利用者リポジトリ側
  local merged="/tmp/perf-config.merged.json"
 
  # 利用者の config があれば後勝ちでマージ、なければデフォルトのみ
  if [ -f "$overrides" ]; then
    jq -s '.[0] * .[1]' "$defaults" "$overrides" > "$merged"
  else
    cp "$defaults" "$merged"
  fi
 
  export PERF_CONFIG_PATH="$merged"
}

jq -s '.[0] * .[1]' は、複数の JSON を配列として読み込み(-s)、* 演算子でオブジェクトを再帰的にマージします。後ろ(利用者)の値が優先されるため、たとえば perf-1.max.x だけ上書きしたい場合でも perf-1 オブジェクト全体を書き直す必要はなく、ネストの一部だけを部分的に上書きできます。

注意点として、* はオブジェクトを再帰マージしますが、配列は丸ごと置換します。そのため paths(配列)は利用者の値で完全に上書きされます(要素単位のマージではありません)。

マージ済みの設定は PERF_CONFIG_PATH/tmp/perf-config.merged.json)に書き出され、後続のジョブはここから値を読み取ります。shell からは jq で必要な値を取り出し、Node.js などからは JSON をそのまま読み込んで参照します。

課題 2 : 実行条件 (rules) の変更が全ジョブに波及する

設定値の問題が片付いた次は、実行条件のカスタマイズです。

テンプレートのデフォルト実行条件は「MR イベント時かつ手動実行」としていましたが、利用者によっては、特定ブランチのみで動かしたいといったユースケースも想定されます。そこで、外から実行条件を柔軟に変えられるようにしたいと考えていました。

当初は、起点となる perf-buildrules だけを上書きすればよいと考えていました。ところが、条件に外れて perf-build がパイプラインから消えると、後続ジョブが needs の依存先を失い、依存関係エラーでパイプライン自体が壊れます。結局、利用者側で全ジョブ(5 個)の rules を上書きする必要がありました。

perf-build:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^refactor\//'
 
perf-1:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^refactor\//'
 
perf-2:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^refactor\//'
 
perf-3:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^refactor\//'
 
performance-report:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^refactor\//'

同じ条件を 5 回書くことになり、テンプレートのジョブ構成を利用者が知っている必要も出てきます。これでは共通テンプレートとして提供する意味が薄れてしまいます。

解決策 : 子パイプライン化で制御点を 1 つに集約する

テンプレート全体を子パイプライン(trigger job)として動かす構成に変更しました。テンプレートの実体は子パイプラインとして切り出し、親側にはそれを起動する trigger job だけを置きます。trigger job 自体もテンプレートに内包しているため、利用者は従来通り include するだけで使えます。

# 利用者側 .gitlab-ci.yml — 従来と変わらない
include:
  - project: "your-group/ci/pipeline-template"
    ref: main
    file: "performance.yml"

テンプレート側の performance.yml は、trigger job の定義だけになります。

performance:
  stage: performance
  trigger:
    include:
      - project: "your-group/ci/pipeline-template"
        file: "performance.child.yml"
        ref: "main"
    strategy: mirror
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: manual
      allow_failure: true

実際のパイプラインを見てみます。利用者側のパイプラインには、lint や build といった利用者自身のジョブと並んで、trigger job(performance)が 1 つだけ現れます。

利用者側のパイプライン。利用者自身のジョブと並んで trigger job が定義されている

trigger job を実行すると、テンプレートの実体が子パイプラインとして動きます。

子パイプライン。テンプレートのジョブ一式が親パイプラインから切り離されて動いている

実行条件や variables を変えたい場合は、この performance ジョブだけを再定義します。

include:
  - project: "your-group/ci/pipeline-template"
    ref: main
    file: "performance.yml"
 
performance:
  variables:
    GIT_SSL_NO_VERIFY: "true"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^feature\//'
      when: manual
      allow_failure: true

上書きの対象が全ジョブから単一の trigger job に集約されました。strategy: mirror を指定しているのは、子パイプラインの成否を親の trigger job に反映させるためです(旧来の strategy: depend の後継で、depend はステータスが一致しないことがあるため、現在は mirror が推奨されています)。

これで実行条件の問題は解決しました。ただ、この構成変更が次の課題を生むことになります。

課題 3 : 子パイプライン化で artifacts:expire_in に値が届かない

子パイプライン化によって、利用者側から子パイプライン内の各ジョブを直接上書きすることができなくなりました。課題 2 の解決が生んだ、新しい制約です。

この制約を意識したのは、計測結果(artifacts)の保存期間 expire_in を扱ったときでした。テンプレートをなるべく汎用的にするため、保存期間も利用者側から上書きできるようにしておきたいと考えていました。ところが GitLab CI の仕様上、artifacts:expire_in には variables を展開できません。以前の構成であれば子ジョブを直接上書きする方法もありましたが、子パイプライン化によりその手段も使えなくなっていました。

解決策 : 生成時に評価される inputs を使う

ここで導入したのが CI/CD Inputsspec: inputs)です。公式ドキュメントの整理を踏まえると、variablesinputs は次のように対比できます。

variablesinputs
評価タイミングジョブ実行時(環境変数として設定)パイプライン生成時(設定に補間される)
主な用途実行時に参照する値、ジョブ間の受け渡し再利用可能な設定の型付きパラメータ
値の変更実行中に動的に生成・変更できる生成時に確定し、実行中は固定
検証最小限(キーと値のペア)型・選択肢・正規表現による検証を定義できる
expire_in への展開不可

variables がジョブの実行時に環境変数として設定されるのに対し、inputsパイプラインの生成(作成)時に評価され、設定に補間されます。expire_in は生成時に確定していればよい値なので、inputs であれば安全に渡せます。

利用者側は、includeinputs を渡すだけです。

include:
  - project: "your-group/ci/pipeline-template"
    ref: main
    file: "performance.yml"
    inputs:
      artifacts-expire-in: "3 weeks"

テンプレート側は spec: inputs で受け取り、trigger 経由で子パイプラインへ伝播させます。

spec:
  inputs:
    artifacts-expire-in:
      default: "1 week"
      description: "計測結果 artifacts の保存期間"
---
performance:
  trigger:
    include:
      - project: "your-group/ci/pipeline-template"
        file: "performance.child.yml"
        ref: "main"
        inputs:
          artifacts-expire-in: $[[ inputs.artifacts-expire-in ]]

渡された値は trigger job 経由で子パイプラインの inputs に伝播し($[[ inputs.artifacts-expire-in ]])、子ジョブの artifacts:expire_in に展開されます。

余談ですが、expire_in を変数化したいケース自体が多くないためか、「expire_invariables は使えない」という情報はあっても、「代わりに inputs を使う」という解法はあまり書かれていませんでした。同じ場面に出会った方の参考になれば幸いです。

まとめ

3 つの課題と解決策を一枚にまとめます。

課題解決策鍵になった仕組み
設定値の variables が肥大化するリポジトリ内の config.json + デフォルトとのマージjq の再帰マージ
実行条件の変更が全ジョブに波及する子パイプライン化で制御点を 1 つに集約trigger job / strategy: mirror
子パイプラインの expire_in を上書きできない生成時に評価される inputs で値を渡すspec: inputs

振り返ると、学んだことは次の 3 点に集約されます。

設定の受け渡しは「変数」一択ではない、ということ。リポジトリ内ファイルとマージの組み合わせで、利用者のコードを汚さずに柔軟な設定を実現できます。

needs で連結したパイプラインの実行条件は、起点だけ変えても壊れる、ということ。子パイプライン化で制御点を 1 か所に集約できますが、その一方で「子ジョブを直接上書きできない」という新しい制約も生まれます。一つの解決が次の制約を生むことがある、というのは今回の活動を通じた一番の学びでした。

そして、variables(実行時評価)と inputs(生成時評価)の違いを理解すると、expire_in のように実行時の変数では届かない場所へ値を渡せる、ということです。

おわりに

CI も shell もほとんど未経験の状態から始めた活動でしたが、課題に一つずつ向き合う中で、GitLab CI の評価タイミングやパイプラインの構造といった土台の部分に多少は詳しくなれた気がします。ここで得た知見は、社内の GitHub Actions などにも活用していきたいと考えています。