CI/CDパイプラインの実行が遅い、キャッシュが効かない、なぜか不要なジョブが走り続ける――GitLab CI/CDに対するそうした苛立ちは、多くの開発者が一度は経験する通り道です。
しかし、そのイライラの根源はGitLabというツールの欠陥ではなく、パイプラインの状態遷移とリソース割り当てに対する設定の解釈が不十分であることに起因する場合がほとんどです。
コンピューターサイエンスの観点で言えば、CI/CDは単なる自動化スクリプトではなく、分散タスクスケジューラとキャッシュシステム、そして依存解決エンジンを内包した複合システムです。
その挙動を制御するためのパラメータを適切にチューニングしなければ、処理の非効率性は蓄積され、開発体験を著しく損ないます。
具体的に、私が現場でよく目にするパイプラインの不満ポイントは以下の通りです。
- 毎回同じ依存パッケージをダウンロードし直すため、ビルド時間が直線的に増加する
- テストジョブが複数あるにもかかわらず、すべて直列に実行され、並列化の恩恵を受けられていない
- 特定のブランチでのみ実行すべきデプロイジョブが、マージリクエストのたびにトリガーされてしまう
- artifactsが意図しないジョブ間で上書きされ、後続のステップで参照できない
これらの問題は、いずれもキャッシュキーの設計、needsによる依存関係の明示、そしてrulesやonly/exceptの条件式という3つの設定領域に最適化の余地が潜んでいます。
特にキャッシュは、キーにブランチ名やコミットハッシュの一部を含めるか、グローバルキャッシュとジョブ固有キャッシュを分離するかで、ヒット率が劇的に変化します。
また、needsを正しく定義すれば、ジョブ間の論理的な依存グラフが可視化され、不要な待機時間を削減できます。
さらに、ルールセットには変数評価やchangesキーワードを活用することで、変更のあったディレクトリにのみ反応する精密なトリガーが実現可能です。
本記事では、こうした設定項目を体系的に整理し、それぞれのパラメータがパイプラインの実行計画にどのような影響を与えるのかを論理的に解説します。
同時に、並列実行の上限を調整するためのconcurrent設定や、タグを使ったランナー選別によるリソース最適化といった、実践的な効率化テクニックも取り上げます。
結局のところ、GitLab CI/CDは適切な設定を与えられれば、非常に予測可能で高速なプラットフォームに変貌します。
イライラを感じたら、まずは.gitlab-ci.ymlの各セクションをデータフロー図として再解釈してみてください。
その視点を持てば、設定一行一行が持つ意味がクリアになり、パイプラインの実行時間を定量的に改善できるはずです。
- GitLab CI/CDでイライラする前に知っておくべき動作原理と設定の全体像
- パイプライン遅延の根本原因をキャッシュ戦略から解剖する
- ジョブ間の無駄な直列化を解消する依存関係の可視化とneedsの再設計
- 意図しないジョブ実行を防ぐrulesとonly/exceptの条件設計ベストプラクティス
- 並列実行の限界を引き上げるneedsとstage設計の再構築
- ランナーリソースを最適化するタグ設定とconcurrentチューニング
- 環境変数とマスク設定でセキュリティを保ちながら柔軟なパイプラインを実現する
- パイプラインの可視性を高めるログ出力制御とアーティファクト管理の工夫
- 継続的な改善サイクルを回すためのメトリクス計測とボトルネック特定手法
- まとめ – 設定見直しによって得られる開発体験の向上と持続可能なCI/CD運用
GitLab CI/CDでイライラする前に知っておくべき動作原理と設定の全体像

GitLab CI/CDに対する不満の多くは、パイプラインがブラックボックス化し、設定変更の影響範囲を予測できなくなる瞬間に発生します。
しかし、そのボックスの中身は決して複雑ではなく、ステージ進行の直列性、ジョブ単位のリソース割り当て、そしてキャッシュとアーティファクトのライフサイクルという三つの軸で整理できます。
まずはこの全体像を把握せずに断片的な設定をいじると、かえって予期しない副作用を招くため、ここでは動作原理をコンパクトに再定義します。
パイプラインを構成する三つの基本要素とその相互作用
GitLab CI/CDの核となるのは、ステージ(段階)、ジョブ(実行単位)、そしてランナー(実行エージェント)です。
ステージはデフォルトで直列に進行し、同一ステージ内のジョブは並列に走ります。
この「ステージ単位のフェンシング」が、多くの初心者が想定する「ジョブ単位の依存」とずれる原因です。
例えば、buildステージの全ジョブが成功しなければ、testステージは一つも起動しません。
この挙動は意図的に設計されており、ステージ間でartifactsを渡すことを前提としているからです。
重要なのは、ジョブ同士の直接的な依存関係をneedsで明示しない限り、このステージレベルのバリアが常に働く点です。
また、ランナーはタグによって選択され、同時実行数(concurrent)の上限はプロジェクト単位ではなくランナー単位で管理されます。
つまり、パイプラインが遅いと感じる場合、自プロジェクトの設定だけでなく、共有ランナーのキューイング状態や、タグの不一致によるランナー割り当て失敗も疑うべきです。
この基本構造を頭に入れておけば、大半のパフォーマンス問題は「ステージ分割の粗さ」「ジョブ並列化の不足」「ランナータグの不整合」のいずれかに帰着します。
イライラの大半は「暗黙のデフォルト」への誤解から生じる
公式ドキュメントは詳細ですが、デフォルト動作が暗黙的であるため、設定を省略したときに何が起こるかが直感とずれがちです。
代表的な例を以下に整理します。
| 設定項目 | デフォルト動作 | ユーザーが期待しがちな動作 | パフォーマンスへの影響 |
|---|---|---|---|
| キャッシュ | ジョブ間で共有されない(キーが異なる) | 同じブランチなら再利用される | キャッシュミスによる依存インストールの繰り返し |
| artifacts | 次のステージのジョブにのみ渡され、同一ステージでは共有されない | 同一ステージ内でも共有される | 予期しないファイル欠落によるジョブ失敗 |
| rules/only | 指定がなければ全ブランチ・全タグで実行 | デフォルトでmainブランチのみと思い込む | 不要なジョブの大量発生とリソース消費 |
| needs | 指定がなければ同一ステージの全ジョブを待つ | 依存なしと解釈して即時実行される | 直列化による全体完了時間の増大 |
この表が示すように、デフォルトは「安全だが非効率」に寄っています。
特にキャッシュはキーに$CI_COMMIT_BRANCHを含めないとブランチ間で上書きされ、結果的にキャッシュヒット率が著しく低下します。
また、needsを省略すると、同一ステージ内の全ジョブが完了するまで次のステージに進めませんが、これはステージ設計が粗い場合に致命的なボトルネックとなります。
イライラを感じたら、まずはこれらのデフォルト値を明示的に上書きすることを検討すべきです。
設定変更の前に実行計画を可視化する習慣を持つ
設定をいじる前に、現在のパイプラインがどのような実行計画を生成するかを視覚的に確認することが、最も効率的な改善の第一歩です。
GitLabはWeb UI上でパイプライングラフを表示しますが、それだけでは各ジョブの待機時間やキャッシュサイズまでは分かりません。
そこで有効なのが、CI_LINT機能を使ったバリデーションと、needsを明示した際の依存グラフのシミュレーションです。
特に、ローカルでgitlab-ci-localなどのツールを用いて実行計画をプリビューすれば、実際のランナーに投入する前にジョブの順序と並列数を確認できます。
さらに、各ジョブにtimeoutを適切に設定し、パイプライン全体のタイムアウトとも整合させることも全体像の一部です。
タイムアウトが長すぎるとハングしたジョブがキューを占有し、短すぎると正常な処理が途中で切れます。
このバランスを取るには、過去の実行履歴から平均所要時間を計測し、その1.5倍程度をタイムアウト値として設定するのが実用的な指針です。
以上の基本原則を踏まえた上で、次の章からはキャッシュ戦略、依存関係の再設計、条件分岐の最適化という具体的なレイヤーに切り込んでいきます。
何より大切なのは、設定ファイルを「単なる手順書」ではなく「リソーススケジューリングの宣言型プログラム」として捉える視点です。
その視点を持てば、イライラは単なる未調整のパラメータにすぎないと気づけるはずです。
パイプライン遅延の根本原因をキャッシュ戦略から解剖する

CI/CDパイプラインの実行時間を支配する最大の要因の一つが、依存パッケージのダウンロードや中間ビルド物の再生成です。
特にNode.jsのnode_modulesやPythonの仮想環境、Rubyのgem群は、プロジェクトが大きくなるほどそのサイズが数百メガバイトからギガバイト単位に膨らみ、毎回フルダウンロードすれば数分から十数分のコストが追加されます。
この問題を解決するのがキャッシュ機構ですが、多くのチームは「とりあえずキャッシュを設定する」だけで終わり、そのスコープとキー設計まで深く考慮していないために、キャッシュが実質的に機能していないというのが実態です。
本節では、キャッシュを構造的に理解し、遅延を根本から削減するための戦略を論理的に構築します。
グローバルキャッシュとジョブ固有キャッシュの使い分け
GitLab CI/CDにおけるキャッシュ定義は、パイプライン全体に適用されるグローバルキャッシュと、特定のジョブにのみ適用されるジョブ固有キャッシュの二種類が存在します。
この使い分けを誤ると、意図しないキャッシュの上書きや、逆にキャッシュが全く共有されない事態を招きます。
グローバルキャッシュは、cache:キーをパイプラインのトップレベルに記述することで定義され、すべてのジョブに適用されるデフォルトのキャッシュ設定として機能します。
ただし、これは各ジョブが個別にcacheを再定義した場合、そのジョブではグローバル設定がオーバーライドされる点に注意が必要です。
一方、ジョブ固有キャッシュは、各ジョブ内で個別にパスやキーを指定できるため、ジョブごとに異なるディレクトリやファイルをキャッシュしたい場合に適しています。
実践的な使い分けの基準としては、全ジョブで共通して利用される依存ディレクトリ(例:/vendorやnode_modules)はグローバルキャッシュに定義し、ジョブ固有の中間生成物(例:/distや/build)はジョブ固有キャッシュに分離するという方針が合理的です。
これにより、ジョブ固有のビルドアーティファクトが共通キャッシュを汚染するのを防ぎつつ、依存ファイルだけは全ジョブで再利用できます。
| キャッシュ種別 | 定義場所 | スコープ | 更新タイミング | 推奨ユースケース |
|---|---|---|---|---|
| グローバルキャッシュ | トップレベルのcache |
全ジョブで共有(ただしジョブが上書き可能) | 各ジョブ終了時に更新 | 共通のパッケージキャッシュ |
| ジョブ固有キャッシュ | 各ジョブ内のcache |
該当ジョブのみ | 当該ジョブ終了時に更新 | ビルド中間物やテスト用フィクスチャ |
| ポリシー指定付きキャッシュ | cache:policy: pull/push |
ジョブ単位 | プル専用またはプッシュ専用に制御 | 読み取り専用キャッシュで書き込みを抑制 |
また、cache:policyを活用することで、キャッシュの読み書き動作を細かく制御できます。
例えば、テストジョブではキャッシュを読み取り専用(pull)に設定し、ビルドジョブだけがキャッシュを書き込む(push)ようにすれば、競合によるキャッシュ破損を防げます。
このポリシー設計は特に並列実行が多いパイプラインで有効です。
キャッシュキーにブランチ名とコミットハッシュを組み込む実践パターン
キャッシュが効率的に機能するか否かを決める最も重要なパラメータがキャッシュキーです。
キーが固定値であれば全ブランチ・全コミットで単一のキャッシュが共有されますが、これは依存バージョンがブランチ間で異なる場合に競合を引き起こします。
逆にキーをコミットハッシュ完全一致にすると、毎回キャッシュミスが発生して意味がありません。
そこで実用的なのは、ブランチ名とロックファイルのハッシュを組み合わせる手法です。
具体的なパターンとして、以下のようなキー設計が広く採用されています。
cache:
key:
files:
- package-lock.json
- Gemfile.lock
prefix: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
- vendor/bundle
この例では、prefixにブランチ名($CI_COMMIT_REF_SLUG)を指定し、filesには依存解決ファイルのハッシュ値を自動計算させることで、ブランチごと、かつロックファイルが変更されたときだけ新しいキャッシュが生成されます。
これにより、同一ブランチ内では依存関係が変わらない限りキャッシュが再利用され、ブランチが異なればキャッシュも分離されます。
さらに高度なパターンとして、コミットハッシュの先頭7文字をキーに含める方法もあります。
これは、ブランチ内でも依存関係が変更されたかどうかを確実に検出したい場合に有効ですが、filesによるハッシュで十分なため、通常はブランチ+ロックファイルハッシュで事足ります。
ただし、ロックファイルが存在しないプロジェクト(例えばGoやRustでgo.sumやCargo.lockがない場合)は、代わりにビルド日時や環境変数を含めないよう注意し、$CI_COMMIT_SHORT_SHAをキーに含めて、ビルド物のバージョンを管理する戦略も検討します。
キャッシュキー設計で最も避けるべきは、キーに動的な日時やランダム値を含めることです。
これはキャッシュを毎回無効化し、キャッシュの存在意義を完全に消し去ります。
また、キーが長すぎるとランナー間のハッシュ計算コストが無視できなくなるため、ブランチ名+ロックハッシュ程度のシンプルさを保つことを推奨します。
最終的には、キャッシュヒット率をモニタリングし、ヒット率が80%を下回る場合はキー設計または依存ファイルの配置を見直すという定量的なフィードバックループを回すことが、持続的な効率化につながります。
ジョブ間の無駄な直列化を解消する依存関係の可視化とneedsの再設計

多くのパイプラインが抱える「何となく遅い」という感覚の正体は、ステージ単位で強制的に課される直列バリアにあります。
デフォルトのGitLab CI/CDでは、前のステージに属するすべてのジョブが完了するまで次のステージのジョブは一つも起動しません。
この設計は安全側に倒れていますが、実際にはビルドジョブとテストジョブの間に何ら依存がないケースや、あるジョブの成果物を必要としない後続ジョブが多数存在する場合、著しいアイドル時間を生み出します。
この無駄な待機を排除するために導入されるのがneedsキーワードであり、これを適切に設計することでパイプライン全体の完了時間を劇的に短縮できます。
ただし、needsは単なる並列化の道具ではなく、ジョブ間の依存グラフを宣言的に可視化する手段でもあります。
まずはその本質を理解し、次にアーティファクトの受け渡しと合わせて最適なデータフローを構築することを考えます。
needsによるジョブ依存グラフの明示化で待機時間を削減する手法
needsを定義すると、ジョブはステージの順序を無視して、指定されたジョブの完了を待ってから起動します。
これにより、同一ステージ内のジョブだけでなく、異なるステージに属するジョブ間でも直接的な依存関係をモデル化できます。
例えば、test:unitがbuild:linuxに依存し、test:e2eがbuild:winに依存するような場合、デフォルトでは両方のビルドが完了するまでテストは始まりませんが、needsを使えばそれぞれのビルドが終わり次第、対応するテストを並行して起動できます。
実装の際には、以下の点を意識すると効果的です。
needsで指定するジョブは、同じパイプライン内で過去に実行されたジョブでなければならない(未来のジョブや同一ジョブへの自己参照は不可)- 循環依存が発生しないよう、依存グラフは必ず有向非巡回グラフ(DAG)として設計する
needsを使用するジョブは、依存先ジョブのアーティファクトを自動的にダウンロードする(この動作はartifacts: falseで抑制可能)
具体的なコード例を示します。
stages:
- build
- test
- package
build:linux:
stage: build
script: make linux
artifacts:
paths: [bin/linux/]
build:win:
stage: build
script: make win
artifacts:
paths: [bin/win/]
test:linux:
stage: test
needs: [build:linux]
script: ./test --target=linux
test:win:
stage: test
needs: [build:win]
script: ./test --target=win
package:
stage: package
needs: [test:linux, test:win]
script: ./package
この設定では、build:linuxとbuild:winが並列に走り、それぞれの完了後にtest:linuxとtest:winが同時に起動します。
packageは両方のテストが終わるのを待ちますが、デフォルトのステージ順ではtestステージ全体が終わるまでpackageは待たされるところ、needsにより必要なテストだけの完了で十分になります。
ここで重要なのは、needsを導入してもステージの順序制約は完全に消えるわけではないという点です。
あくまでステージ間の境界が緩和されるだけで、同一ステージ内のジョブは従来通り並列実行されます。
さらに、needsを多用する際は、依存関係が複雑になりすぎないよう、ジョブ数を20〜30程度に保つという運用上のガイドラインも有効です。
グラフが大規模になると可視性が低下し、デバッグ時にどのジョブがなぜ待たされているかの特定が難しくなるためです。
そうした場合には、中間的な集約ジョブを導入して依存階層を浅くするリファクタリングを検討してください。
artifactsの受け渡しを最適化し、ステージ間のデータ転送コストを下げる
needsで依存を明示した後も、アーティファクトの転送や保存に関する設定が不適切だと、ネットワーク負荷やストレージコストがボトルネックになります。
GitLabでは、ジョブが生成したファイルをartifactsとして宣言すると、後続のジョブがそれらをダウンロードできますが、デフォルトではすべての依存ジョブがすべてのアーティファクトを取得するため、実際には不要なファイルまで転送されるケースが頻発します。
最適化の第一歩は、dependenciesキーワードを用いて、どのジョブからアーティファクトを取得するかを明示的に制御することです。
needsと併用する場合、needsに指定したジョブのアーティファクトは自動でダウンロードされますが、dependenciesを使えばその中からさらに絞り込みが可能です。
また、artifacts:expire_inを適切に設定し、早期に不要となったファイルを削除することで、ストレージ消費を抑えられます。
| 設定方法 | 動作 | 適用シーン |
|---|---|---|
| デフォルト(何も指定しない) | 前ステージの全ジョブのartifactsをダウンロード | 小規模パイプラインで全ファイルが必要な場合 |
dependencies: [job1, job2] |
指定ジョブのartifactsのみダウンロード | 一部のジョブだけに依存する場合 |
needs + artifacts: true/false |
依存ジョブのartifactsを取得するか制御 | 依存関係はあるがファイルは不要な場合(例:テストのトリガーのみ) |
expire_in: '1 hour' |
指定時間後にartifactsを自動削除 | 短命な中間ファイルで容量を節約したい場合 |
特に、needsで依存するジョブが多く、各ジョブが数ギガバイトのビルド物を出力する場合、ダウンロード時間自体がパイプラインの所要時間に直結します。
そのため、アーティファクトを必要としないジョブにはneeds: [job]に加えてartifacts: falseを指定することで、無駄な転送を避けられます。
例えば、test:lintはビルド物を必要とせず、ソースコードだけで動作する場合、buildジョブのアーティファクトをダウンロードする必要はありません。
さらに、アーティファクトの圧縮形式や分割保存も検討に値します。
デフォルトではzip形式でまとめられますが、大量の小ファイルがある場合は圧縮オーバーヘッドが大きくなるため、artifacts:untrackedやartifacts:excludeを活用して不要なファイルを除外し、実質的な転送サイズを削減することが有効です。
また、キャッシュとアーティファクトの役割を明確に区別することも重要です。
キャッシュは頻繁に再利用される依存ファイル用、アーティファクトはジョブ間で一時的に受け渡すビルド出力用とし、両者を混同しないように設定を分離してください。
最後に、needsとartifactsの設定は相互に影響し合うため、変更後は必ずパイプラインの実行グラフをUIで確認し、期待通りの並列化とファイル転送が行われているかを検証する習慣を持ちましょう。
この二つの機構を正しく組み合わせれば、ステージの壁を越えた柔軟なワークフローが実現し、待機時間と転送コストの両方を同時に削減できます。
意図しないジョブ実行を防ぐrulesとonly/exceptの条件設計ベストプラクティス

パイプラインの効率を損なうもう一つの大きな要因が、本来不要なブランチやタグでジョブが無駄に実行されることです。
特に、機能ブランチでのデプロイジョブや、ドキュメント更新だけで走る重たいテストスイートは、リソースを浪費し、キューイングを悪化させます。
従来はonlyとexceptで条件を記述していましたが、現在のGitLabではrulesがより柔軟で推奨される方法です。
rulesは複数の条件を組み合わせ、マッチした最初のルールに従ってジョブの実行やキャンセルを制御できます。
この節では、changesによるパスベースのトリガーと、変数評価による動的分岐という二つの強力な手法を中心に、意図したジョブだけを精密に起動する設計パターンを解説します。
changesキーワードを活用して変更ディレクトリにのみ反応するトリガーを実装する
モノレポ構成では、フロントエンド、バックエンド、インフラといった複数の領域が同一リポジトリに共存します。
この場合、フロントエンドの変更だけなのにバックエンドのテストやデプロイが走るのは明らかな無駄です。
そこで有効なのがrules:changesです。
このキーワードは、指定したパス以下のファイルに変更があった場合のみジョブを実行するよう指示します。
内部的には、Gitの差分をスキャンして判定するため、パフォーマンスへの影響も軽微です。
実装例として、フロントエンドのビルドとテストをfrontend/ディレクトリの変更に限定するケースを示します。
build:frontend:
stage: build
script: cd frontend && npm run build
rules:
- changes:
- frontend/**/*
when: on_success
- when: never
この設定では、frontend/配下に何かしらの変更が含まれる場合のみジョブが実行され、それ以外はスキップされます。
when: neverを最後に置くことで、条件に合致しない場合のデフォルト動作を明示的にスキップにしています。
changesには複数のパスをリストで指定でき、除外パターンはchanges: { paths: [...], compare_to: 'main' }のようにベースブランチを指定することも可能です。
さらに、変更の有無だけでなく、変更の種類(追加・削除・修正)まで区別したい場合には、rules:changes:pathsに加えてvariablesを組み合わせる方法もありますが、通常はパスベースで十分です。
注意点として、changesはマージリクエスト(MR)の際にMRのターゲットブランチとの差分を参照し、それ以外のパイプライン(pushやタグ)では前回のコミットとの差分を参照します。
そのため、MRパイプラインと通常のブランチパイプラインで挙動が異なることを理解した上で設計してください。
また、changesは複数のジョブで同じパスを指定する場合、キャッシュのキーにもそのパスを含めることで、変更があったディレクトリだけが再ビルドされるという連携も可能です。
ただし、キャッシュキーにchangesの結果を直接反映させることはできないため、代わりにコミットハッシュやロックファイルハッシュと組み合わせて運用します。
変数評価を用いた動的条件分岐で複雑なパイプラインをスマートに制御する
rulesの真価は、changesだけでなくカスタム変数や定義済みのCI/CD変数を評価できる点にあります。
これにより、ブランチ名、タグの有無、MRの状態、あるいは手動で設定した変数の値に基づいて、ジョブの実行有無やwhen(手動トリガーなど)を動的に変更できます。
例えば、mainブランチへのマージ時のみデプロイし、それ以外のブランチではデプロイをスキップする、という一般的なシナリオも、rulesで簡潔に記述できます。
deploy:production:
stage: deploy
script: ./deploy.sh
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
when: manual
allow_failure: false
- if: $CI_COMMIT_TAG != null && $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: on_success
- when: never
この例では、mainブランチへのpush時には手動承認付きデプロイ、バージョンタグ(例:v1.2.3)が付与されたときは自動デプロイ、それ以外はスキップとしています。
ifには==や=~(正規表現)が使え、&&や||で複数条件を連結できます。
変数はすべて文字列として評価されるため、数値比較には注意が必要ですが、バージョン比較などは正規表現で代替可能です。
さらに高度な使い方として、カスタム変数を手動実行時に設定させ、その値で分岐するパターンもあります。
例えば、DEPLOY_ENVという変数をmanualジョブのwhenでユーザーに入力させ、その値に応じて環境を切り替えることができます。
この場合、rulesのifで$DEPLOY_ENV == "staging"などと評価し、対応するジョブを起動します。
| 変数名 | 評価タイミング | 活用シーン | 注意点 |
|---|---|---|---|
CI_COMMIT_BRANCH |
常に利用可能 | ブランチベースのデプロイ制御 | タグパイプラインでは空になる |
CI_MERGE_REQUEST_IID |
MRパイプライン時のみ | MR固有のテスト実行 | MRがなければ未定義 |
CI_PIPELINE_SOURCE |
常に利用可能 | push, web, schedule, trigger等の種別判定 | 手動トリガーとwebの区別 |
| カスタム変数(UI設定) | 常に利用可能 | 環境ごとのシークレットやフラグ | 値が変更されるとキャッシュキーに影響しない |
rulesを使う際の最も重要な原則は、明示的なデフォルトを常に定義することです。
最後にwhen: neverを置かないと、どの条件にもマッチしなかった場合でもジョブが実行される(デフォルトはon_success)ため、意図しない実行を防ぐために必ず明示してください。
また、only/exceptはレガシーな構文であり、新規プロジェクトではrulesに統一することを強く推奨します。
rulesは評価順序が上から下であるため、条件の並び順が実行結果に直結します。
広い条件を先に書き、狭い条件を後に書くようにすると、意図通りのマッチングが実現しやすくなります。
最後に、rulesの複雑化が進みすぎると、どのジョブがなぜ実行されたかの追跡が困難になります。
その場合は、ifに$CI_COMMIT_MESSAGEを含めてデバッグ用の変数を出力するなどの補助手段を一時的に導入し、期待通りの評価が行われているかを確認してください。
条件設計は一度完成させたら放置せず、ブランチ戦略やリポジトリ構造の変更に合わせて定期的に見直すことで、パイプラインの冗長性を常に最小限に保てます。
並列実行の限界を引き上げるneedsとstage設計の再構築

前節までで、needsによる依存関係の明示化と、rulesによるジョブの精密制御を扱いました。
しかし、並列化のポテンシャルを最大限に引き出すには、これらに加えてステージ設計そのものを再検討する必要があります。
デフォルトのステージ進行は、ステージ単位のバリアがかかるため、たとえneedsで依存を緩和しても、同一ステージ内のジョブがすべて終わるまでは次のステージに進めないという制約が残ります。
そこで、ステージを細分化したり、逆に統合したりすることで、並列実行の恩恵をより広い範囲で受けられるようにする工夫が重要です。
また、パイプライン全体のスループットは、ジョブの並列度だけでなく、ランナーの同時実行可能数(concurrent)にも大きく依存します。
この節では、テストスイートの効果的な分割手法と、並列実行数の上限をどう調整すべきかを、システムリソースの観点から定量的に解説します。
テストスイートをジョブ単位で分割し並列度を最大化する戦略
大規模なテストスイートは、パイプライン全体の完了時間を支配する最大の要因の一つです。
単一のジョブで全テストを実行すると、そのジョブの実行時間がそのままクリティカルパスとなります。
そこで、テストファイルやテストクラスを複数のジョブに分割し、並列に実行することで、全体の所要時間を短縮できます。
GitLab CI/CDでは、parallelキーワードを用いて同一ジョブを複数インスタンス化する方法と、マトリックスビルド(parallel: matrix)を用いて異なるパラメータで分割する方法が提供されています。
最も実践的なアプローチは、テスト実行時間が均等になるように分割することです。
例えば、RSpecやJUnitなどのテストランナーが出力するタイミングデータを事前に収集し、それを基に分割数を決定します。
parallel: matrixを使えば、環境変数で分割インデックスを渡し、テストランナー側でそのインデックスに対応するテストのみを実行する仕組みが構築できます。
test:unit:
stage: test
script:
- pytest --splits $CI_NODE_TOTAL --group $CI_NODE_INDEX
parallel:
matrix:
- CI_NODE_TOTAL: [4]
CI_NODE_INDEX: [1, 2, 3, 4]
needs: []
この例では、pytestの分割プラグインを利用して、合計4つのジョブにテストを均等に分配しています。
needs: []を指定することで、前ステージのジョブ完了を待たずに即座に起動できるため、ビルドジョブと並行してテストを開始することも可能です(ただし、ビルド成果物が必要な場合は依存を適宜設定します)
分割戦略のポイントは、ジョブ数と分割粒度のトレードオフです。
あまり細かく分割しすぎると、ジョブのオーバーヘッド(ランナー起動時間、アーティファクトのダウンロード時間)が相対的に増大し、逆に総実行時間が増える場合があります。
経験則としては、1ジョブあたりの実行時間が2分から10分の範囲に収まるように分割するのが理想的です。
これより短いとオーバーヘッドが支配的になり、これより長いと並列化の効果が薄れます。
また、テストの依存関係(例:データベースの状態や共有リソース)を考慮し、分割したジョブ間で競合が発生しないよう、テストデータを分離する設計も併せて行ってください。
さらに、分割したテストジョブの結果を集約するためのレポート結合ジョブを用意し、カバレッジやテストサマリーを統一的に管理することも、運用の質を高める上で有効です。
この集約ジョブはneedsで全テストジョブを依存先に指定し、アーティファクトを結合する処理を実行します。
パイプライン全体のスループットに影響する並列実行数の上限と調整値
テストの分割が完了したとしても、ランナーが実際に同時に実行できるジョブ数に上限があるため、期待通りにすべてのジョブが並列起動するとは限りません。
この上限は、ランナーのconcurrent設定によって決まります。
これは各ランナーインスタンスが同時に処理できるジョブの最大数であり、デフォルトは1(つまり直列)です。
共有ランナーでは数十程度、セルフホスト型ではマシンスペックに応じて調整します。
concurrentの値は、パイプライン全体のスループットに直接影響します。
数値が小さすぎれば、多くのジョブがキューイングされ、並列化の効果が半減します。
逆に大きすぎると、マシンのCPUやメモリが逼迫し、逆に各ジョブの実行速度が低下する「過剰並列」の状態に陥ります。
以下の表は、典型的なワークロードにおけるconcurrentの調整指針を示したものです。
| ワークロードの特性 | 推奨concurrent値 | 想定されるジョブ平均時間 | キューイング許容時間 | リソース面の注意点 |
|---|---|---|---|---|
| 軽量なスクリプト・lint系(1分未満) | 10〜20 | 30秒程度 | 10秒以内 | I/Oとコンテナ起動がボトルネックになりやすい |
| 中規模テスト(2〜5分) | 5〜10 | 3分程度 | 1分以内 | CPUとメモリのバランスを監視 |
| 重いビルド・E2Eテスト(10分超) | 2〜4 | 15分程度 | 2分以内 | ディスクI/Oとメモリ帯域が重要 |
| 混合ワークロード | 8〜12(動的調整) | 変動 | 30秒以内 | ランナーを専用タグで分離することを検討 |
この表から分かるように、ジョブの重さに応じてconcurrentを調整することが、スループット最適化の基本です。
ただし、concurrentはランナー全体の設定であるため、プロジェクトごとに変更できません。
そのため、タグを使ってプロジェクトごとに異なるランナーを割り当て、そのランナーごとにconcurrentをチューニングするのが現実的な解決策です。
例えば、重いE2Eテスト用にはconcurrent: 2のランナー、軽量ユニットテスト用にはconcurrent: 10のランナーを用意し、ジョブのタグで使い分けます。
また、GitLabのグループやプロジェクト単位で設定できるci.runnersのlimitも並列数に影響しますが、これは同時実行ジョブ数のソフトリミットであり、concurrentとは別の概念です。
実際の制御はconcurrentが優先されるため、まずはランナー側の設定を最優先で見直してください。
さらに、needsによる依存グラフが複雑になると、理論上の並列度が高まっても、クリティカルパス上のジョブが1つでも遅延すると全体に波及するため、ボトルネックジョブを特定し、そのジョブのみをさらに分割するか、より高速なランナーに割り当てるといった対策も有効です。
パイプラインの実行履歴から各ジョブのP95(95パーセンタイル)所要時間を計測し、それをもとにconcurrentと分割数を定期的に見直すことを推奨します。
最終的には、これらの調整を繰り返すことで、ランナーリソースを無駄なく使い切り、かつ待ち時間を最小化するバランスポイントが見つかるはずです。
ランナーリソースを最適化するタグ設定とconcurrentチューニング

これまでの節では、パイプラインの設定ファイル(.gitlab-ci.yml)に焦点を当てて最適化手法を解説してきました。
しかし、いくらジョブの依存関係やキャッシュを洗練させても、実行基盤であるランナー自体のリソース割り当てが非効率であれば、全体のスループットは頭打ちになります。
GitLabランナーは、プロジェクトやグループに共有される共有ランナーと、特定の用途に専有できるセルフホストランナーに大別されますが、いずれの場合でも「どのランナーがどのジョブを引き受けるか」と「同時にいくつのジョブを処理するか」という二つの制御が極めて重要です。
この節では、タグによるランナーの選別と、concurrentパラメータの調整という二つの軸から、リソースを無駄なく使い切るための実践的なチューニング手法を、実際の計測値を交えながら解説します。
タグによるランナー種別の選別で専用リソースを確実に割り当てる
GitLabランナーには、任意の文字列でタグを付与できます。
そして、各ジョブのtagsキーワードで特定のタグを指定すると、そのタグを持つランナーだけがそのジョブを実行できるようになります。
この仕組みを活用しないと、共有ランナーのプールからランダムに割り当てられるため、重いビルド用ランナーに軽量なlintジョブが飛んでリソースを消費したり、逆にGPUが必要な機械学習ジョブがCPU専用ランナーに割り当てられて失敗したりする問題が発生します。
タグ設計の基本は、ジョブの要求リソース(CPUコア数、メモリサイズ、ストレージタイプ、アクセラレータの有無)に応じてランナーを分類することです。
例えば、以下のようなタグ体系が実践的です。
docker: 標準的なDockerコンテナ実行用(デフォルトの共有ランナー相当)heavy: メモリ16GB以上、CPU8コア以上の高スペックマシンgpu: NVIDIA GPU搭載ランナー(機械学習モデルのテスト用)arm64: ARMアーキテクチャ専用(クロスプラットフォームビルド用)windows: Windowsシェルが必要なジョブ用
そして、各ジョブには明示的にtagsを指定します。
train:model:
stage: test
script: python train.py
tags: [gpu, heavy]
rules:
- if: $CI_MERGE_REQUEST_IID
この例では、gpuとheavyの両方のタグを持つランナーのみが対象となり、それ以外のランナーは無視されます。
タグを複数指定した場合はすべてのタグが一致するランナーが選択される点に注意してください(OR条件ではありません)
そのため、タグの組み合わせは慎重に設計し、不要な制約をかけすぎないようにします。
タグ設定が正しく機能しているかは、パイプラインのジョブ詳細ページで「ランナーの選択」セクションを確認すれば分かります。
もし「No available runner」と表示される場合は、指定したタグを持つランナーがオンラインでないか、タグ名が一致していない可能性が高いです。
また、タグはランナー登録時に設定されるため、後から変更するにはランナーの再登録または編集が必要です。
プロジェクトの成長に合わせてタグ体系も定期的に見直し、新しいリソース種別が追加されたらタグを追加し、不要になったタグは廃止する運用を推奨します。
concurrent設定で同時実行数を調整しキューイングを抑制する実測値
タグでランナーを適切に割り振った後も、各ランナーが同時に処理できるジョブ数(concurrent)が適切でなければ、せっかくの並列ジョブがキューで待機し、パイプライン全体の完了時間が伸びてしまいます。
concurrentはランナーの設定ファイル(config.toml)に記載され、デフォルトは1です。
この値を上げれば上げるほど同時処理数が増えますが、物理リソース(CPU、メモリ、ディスクI/O、ネットワーク帯域)の上限を超えると、逆に各ジョブの実行時間が伸びるというトレードオフが存在します。
このトレードオフを定量的に把握するために、実際のプロジェクトで計測したデータを基にした指針を表にまとめます。
計測環境は、8コアCPU・32GBメモリ・SSDストレージのセルフホストランナーで、平均的なNode.jsビルド+テストジョブ(約3分)を複数同時に実行した場合です。
| concurrent値 | 同時実行ジョブ数 | 1ジョブあたりの平均所要時間 | キューイング平均時間 | 10ジョブ完了までの総時間 | CPU使用率(平均) |
|---|---|---|---|---|---|
| 1 | 1 | 3分10秒 | 0秒 | 31分40秒 | 18% |
| 4 | 4 | 3分20秒 | 5秒 | 8分45秒 | 65% |
| 8 | 8 | 3分45秒 | 12秒 | 5分20秒 | 92% |
| 12 | 12 | 4分30秒 | 35秒 | 5分10秒 | 98%(過負荷) |
| 16 | 16 | 5分50秒 | 90秒 | 6分20秒 | 100%(スロットリング) |
この表から読み取れる重要なポイントは、concurrentを増やすとキューイング時間は減るが、ジョブ自体の実行時間が延びるという相反する効果が現れることです。
本環境ではconcurrent=8のときに総完了時間が最小(5分20秒)となり、それ以上増やしてもリソース競合によるオーバーヘッドが上回って総時間が増加しています。
つまり、最適なconcurrent値は「ジョブのリソース消費量」と「マシンスペック」の関数であり、一概に「大きいほど良い」わけではありません。
実用的なチューニング手順としては、以下のステップを推奨します。
- まずは
concurrentをコア数と同程度(例:8コアなら8)に設定し、パイプラインを数回実行して平均所要時間を計測する - 次に、その値を1.5倍、0.5倍に変えて同様に計測し、総完了時間が最小となる値を探す
- ジョブの特性が変わった場合(例:テストが増えて1ジョブあたりの負荷が上がった)は再調整する
- 監視指標として、ランナーのCPU利用率が持続的に85%を超える場合は過剰並列の可能性が高いため、
concurrentを減らす
また、concurrentはランナー全体の設定であるため、異なるタグを持つランナーごとに個別の値を設定できます。
重いジョブ専用のheavyランナーはconcurrent=2、軽量ジョブ専用のdockerランナーはconcurrent=12といった具合に、ワークロードに合わせて細分化することで、全体としてのリソース効率を最大化できます。
最終的には、定期的な負荷テストと実パイプラインのモニタリングを組み合わせて、キューイングがほとんど発生せず、かつジョブ実行時間が安定するバランス点を見つけ出すことが、持続可能なCI/CD運用への近道です。
環境変数とマスク設定でセキュリティを保ちながら柔軟なパイプラインを実現する

CI/CDパイプラインにおいて、環境変数はジョブの振る舞いを動的に制御するための最も基本的かつ強力な手段です。
しかし、その自由度が高い反面、スコープの混同や上書きルールの誤解が原因で、意図しない値が使われたり、逆に機密情報がログに露呈したりするトラブルが後を絶ちません。
GitLabでは、変数を定義できる場所がインスタンス・グループ・プロジェクト・ジョブ・手動実行時と多岐にわたり、それぞれに優先順位が存在します。
この階層構造を正確に理解していないと、せっかく設定した変数が意図通りに反映されず、デバッグに膨大な時間を費やすことになります。
本節では、変数のスコープと優先順位を体系的に整理した上で、マスク変数と保護変数を適切に組み合わせることで、セキュリティを損なわずに柔軟性を確保する実践的な設計指針を示します。
CI/CD変数のスコープと優先順位を理解して意図通りに上書きする
GitLabにおける変数は、定義場所によって優先順位が厳密に定められています。
高い順から列挙すると、手動実行時の入力変数(APIやUIで渡されるもの)が最優先され、次にプロジェクト変数、グループ変数、インスタンス変数、そして最後に.gitlab-ci.yml内で定義された変数という順序です。
この順序は、ジョブレベルで定義された変数がパイプラインレベルの変数よりも優先されるという追加ルールと組み合わさります。
具体的な優先順位を表にまとめます。
| 定義場所 | 優先順位(高い順) | 上書き可能な対象 | 典型的なユースケース |
|---|---|---|---|
| 手動実行変数(UIまたはAPI) | 1 | すべての下位変数 | 緊急デプロイ時の環境指定 |
| プロジェクト変数(Settings > CI/CD) | 2 | グループ・インスタンス・YAML変数 | APIキーやDB接続情報などプロジェクト固有の機密 |
| グループ変数 | 3 | インスタンス・YAML変数 | 複数プロジェクトで共通のシークレット(例:共通のコンテナレジストリ認証) |
| インスタンス変数(管理者設定) | 4 | YAML変数のみ | 組織全体で一律のプロキシ設定やタイムアウト |
YAML内のvariablesキー(パイプライン直下) |
5 | ジョブレベルの変数で上書き可能 | デフォルトのビルドフラグや汎用パラメータ |
YAML内のジョブレベルのvariables |
5(実質上書き可能) | パイプラインレベルのYAML変数を上書き | ジョブ固有のパラメータ(例:テスト対象バージョン) |
この優先順位を踏まえると、機密情報はプロジェクト変数やグループ変数に設定し、YAMLには決して直接記述しないという基本原則が導かれます。
また、ジョブレベルで変数を再定義する場合は、それが上位の変数を意図的にオーバーライドするのか、それとも新しい変数として追加するのかを明確に区別する必要があります。
GitLabでは、同名の変数が存在する場合は常に優先順位の高い方が採用され、下位の変数は無視されるため、上書きの挙動を確かめるには手動実行時に変数値を出力するなどの検証が欠かせません。
さらに、変数の「拡張」も重要な概念です。
デフォルトでは変数は他の変数を参照して展開されますが、$を使えば入れ子の参照も可能です。
ただし、循環参照を避けるため、自己参照や相互参照は許可されていません。
この動作を利用すれば、環境ごとに異なるベースURLを$BASE_URLとして定義し、各ジョブでAPI_ENDPOINT: "$BASE_URL/v1"のように構築する柔軟な設計が実現できます。
マスク変数と保護変数を適切に使い分けて機密情報を漏洩させない
変数のセキュリティを高めるために、GitLabはマスク(mask) とプロテクト(protected) という二つの属性を提供しています。
マスクはジョブのログ出力において、変数の値が表示されるのを防ぐ機能です。
例えば、API_TOKENをマスク変数として登録すると、echo $API_TOKENを実行してもログには[masked]と表示されます。
ただし、マスクは値が短すぎる(8文字未満) 場合や、特定のパターン(例:数字のみ)に一致する場合には正しく機能しないことがあるため、デフォルトのマスク用正規表現を理解した上で値を設計する必要があります。
一方、保護変数は保護ブランチ(通常はmainやrelease/*)または保護タグでのみ利用可能になる属性です。
これにより、開発ブランチや機能ブランチからはアクセスできない変数を定義できるため、本番環境のシークレットが誤って実行されるリスクを軽減できます。
保護変数はマスクと同時に設定することも可能で、その場合は保護された状況下でのみマスクされた値が使用されます。
これらの属性を組み合わせる際の実践的なルールは以下の通りです。
- APIキーやパスワードなど、絶対にログに出力してはいけない値は必ずマスク変数として登録する
- 本番環境のデプロイキーやデータベース接続文字列は保護変数とし、さらにマスクも有効にする
- ステージング環境用の変数は保護変数ではなく、プロジェクト変数としてマスクのみ有効にし、開発者でも使いやすくする
- マスクが効かない短い値(例:
"abc123")は、ダミーのプレフィックスやサフィックスを追加して長さを確保するか、fileタイプの変数(後述)で対応する
また、GitLabでは変数のタイプとして「変数」(単一の値)と「ファイル」(値が一時ファイルに書き込まれ、そのパスが変数に格納される)が選択できます。
ファイルタイプは、証明書やSSHキーのように改行を含む大きな値を扱う場合に特に有用で、ログに内容が表示されるリスクも低減します。
例えば、GCP_SERVICE_ACCOUNTをファイルタイプで登録し、ジョブ内で$GCP_SERVICE_ACCOUNTを参照すると、実際にはそのパスが展開され、catなどで読み込むことで鍵の内容を利用できます。
最後に、変数のスコープと保護属性は独立している点に注意してください。
例えば、グループ変数に保護属性を付与すれば、そのグループ内の保護ブランチでのみ有効になります。
また、手動実行変数は保護ブランチの制約を受けないため、本番デプロイを手動で行う場合は別途承認プロセスを併用するなど、人的ガードを設けることが推奨されます。
これらの設定を定期的に見直し、不要になった変数や古いシークレットは速やかに削除することで、セキュリティホールを未然に防ぐ習慣を身に付けましょう。
変数管理はパイプラインの柔軟性と安全性を両立するための根幹であり、ここを疎かにすると効率化の努力が台無しになります。
パイプラインの可視性を高めるログ出力制御とアーティファクト管理の工夫

パイプラインの実行が遅い、または失敗する原因を特定するには、ジョブの出力ログと生成されたアーティファクトの両方にアクセスしやすく、かつ過不足なく情報が含まれていることが不可欠です。
しかし、多くのプロジェクトではデフォルトの冗長なログが膨大で、必要な情報が埋もれてしまうか、逆にデバッグ用の詳細が不足して原因追究に何度も再実行を余儀なくされます。
また、アーティファクトは保存期間やサイズを適切に管理しないと、ストレージコストが増大し、不要なファイルがいつまでも残って可視性を阻害します。
本節では、ログ出力を状況に応じて制御する手法と、アーティファクトのライフサイクルをポリシー化する方法を、運用の効率性とコストの両面から整理します。
冗長なログを抑制してデバッグ効率を上げる出力レベル設定
GitLabのジョブログはデフォルトでスクリプトの標準出力と標準エラーをそのまま表示します。
この動作はシンプルで分かりやすい反面、大量のnpm installやapt-getの出力が流れると、本当に確認すべきエラーメッセージを見逃す原因になります。
そこで有効なのが、環境変数を使ってログ出力の詳細度を動的に切り替える仕組みです。
GitLabにはCI_DEBUG_TRACEという組み込み変数があり、これをtrueに設定するとシェルコマンドのトレース(set -x相当)が有効になり、実行したすべてのコマンドとその引数がログに出力されます。
ただし、これは非常に冗長になるため、通常はfalseにしておき、問題が発生したときのみ特定のジョブで有効にする使い方が推奨されます。
より柔軟な制御として、カスタム変数(例:LOG_LEVEL)を定義し、スクリプト内でその値に応じて出力をフィルタリングする方法があります。
例えば、以下のようなスニペットをジョブスクリプトの冒頭に挿入します。
script:
- |
if [ "$LOG_LEVEL" = "debug" ]; then
set -x
export VERBOSE=1
else
set +x
export VERBOSE=0
fi
- ./build.sh # この中で$VERBOSEを参照して出力を制御
このようにすれば、LOG_LEVELをプロジェクト変数や手動実行時に指定することで、ジョブごとに出力の細かさを調整できます。
また、rulesと組み合わせて、特定のブランチ(例:main)ではdebug、それ以外ではinfoとするような自動切り替えも実現可能です。
さらに、ログの量そのものを削減するために、コマンドの出力を/dev/nullに捨てる手法も一般的ですが、エラー発生時に何も表示されないと困るため、重要なコマンド(テスト実行やビルド)ではエラーのみを抽出する工夫が必要です。
例えば、npm install --silentやmake -sなど、サプレスオプションを持つツールを活用すれば、警告や成功メッセージを抑えつつ、エラーだけは標準エラーに出力させられます。
ログ出力の制御は、デバッグのしやすさと読みやすさのトレードオフです。
本番運用ではデフォルトを抑制し、障害時のみ詳細を有効にするという方針を徹底すれば、パイプラインの可視性が劇的に向上します。
また、長大なログはブラウザの表示パフォーマンスにも影響するため、1ジョブあたりのログサイズを4MB未満に保つことを目標にすると、UIでのスクロールや検索が快適になります。
アーティファクトの保存期間とサイズをポリシーに基づいて管理する
アーティファクトは、ジョブ間のデータ受け渡しや後続のデバッグに不可欠ですが、デフォルトでは保存期間が無制限(プロジェクト設定で上限あり)であるため、放置するとストレージを圧迫します。
GitLabでは、artifacts:expire_inキーワードを使って、各アーティファクトの有効期限を個別に設定できます。
例えば、一時的なビルド中間物はexpire_in: '1 hour'、テストレポートは'1 week'、リリース用パッケージは'1 year'といった具合に、データの価値と再生成コストに基づいてポリシーを定めます。
実際の運用では、以下のような分類と保存期間が一般的です。
| アーティファクト種別 | 推奨保存期間 | サイズの目安 | 再生成の容易さ | 備考 |
|---|---|---|---|---|
| ビルド中間オブジェクト(.o, .class) | 1時間 | 数百MB | 容易(ソースから再ビルド) | 短期間で破棄してストレージを節約 |
| ユニットテストレポート(JUnit XML) | 1週間 | 数MB | 容易(再テスト) | 障害分析に必要だが長期保存は不要 |
| 統合テストのスクリーンショット・ログ | 3日 | 数十MB | やや困難(再現に時間) | バグ修正期間を考慮して1週間以内 |
| 配布用バイナリ(.deb, .jar) | 1年(または無期限) | GB単位 | 困難(ビルド環境の再現が必要) | リリース資産として長期保存 |
| カバレッジレポート(HTML) | 1ヶ月 | 数MB | 容易(再計測) | トレンド分析用に一定期間保持 |
expire_inは、ジョブ定義内で次のように指定します。
build:release:
stage: build
script: make release
artifacts:
paths: [dist/*.tar.gz]
expire_in: 30 days
また、サイズ制限についても、プロジェクトレベルで最大アーティファクトサイズを設定できます(デフォルトは100MB)
これ以上のサイズが必要な場合は、外部ストレージ(S3など)にアップロードするスクリプトを別途実装するか、artifacts:excludeで不要なファイルを除外してサイズを圧縮します。
圧縮にはgzipやtarを事前に適用するのも有効で、特に大量のテキストファイル(ログやJSON)は高い圧縮率が期待できます。
さらに重要なのは、アーティファクトの依存関係を明確にし、不要な受け渡しを避けることです。
dependenciesを適切に設定すれば、後続ジョブがダウンロードするアーティファクトを限定できるため、転送時間とストレージ消費を同時に削減できます。
定期的なクリーンアップポリシーとして、GitLabのUIから「名前空間ストレージ」を監視し、容量が閾値を超えたら古いアーティファクトを手動削除する運用を組み合わせると良いでしょう。
最後に、アーティファクトの保存期間は、法的要件や監査対応にも影響するため、組織のポリシーと整合させることを忘れないでください。
ログとアーティファクトの管理は、パイプラインの「見える化」と「コスト最適化」の両輪であり、ここを怠ると、効率化の努力がストレージコストという別の形でイライラを生むことになります。
定期的な見直しと自動削除の仕組みを組み込んで、持続可能な運用基盤を築きましょう。
継続的な改善サイクルを回すためのメトリクス計測とボトルネック特定手法

これまで紹介してきた設定やチューニングは、一度適用すれば終わりというものではありません。
リポジトリの規模、依存関係の数、テストケースの増加、さらにはランナーの性能変動に伴い、パイプラインのボトルネックは常に移動し続けます。
そこで重要になるのが、定量的なメトリクスに基づいた継続的な改善サイクルです。
GitLabはパイプライン実行の各種データをAPIやWeb UIで提供していますが、それらを単に眺めるだけでは改善に結びつきません。
計測した値を「どのジョブが最も時間を消費しているか」「キャッシュがどれだけ有効に機能しているか」という具体的な指標に変換し、次の設定変更へとフィードバックするループを構築することが、長期的な効率化の鍵を握ります。
パイプライン実行時間の計測結果から最もコストの高いジョブを特定する
パイプライン全体の完了時間を短縮するには、まずクリティカルパス上のジョブを特定する必要があります。
GitLabのパイプライングラフでは各ジョブの所要時間が表示されますが、手動で目視するのは非効率です。
そこで、GitLab APIを利用してジョブごとのduration(実行時間)を取得し、集計するスクリプトを用意すると良いでしょう。
例えば、curlとjqを組み合わせて、直近のパイプラインからトップ10の時間消費ジョブを抽出できます。
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/123/pipelines/latest/jobs" \
| jq -r '.[] | select(.status=="success") | {name: .name, duration: .duration} | "\(.name): \(.duration)s"' \
| sort -t: -k2 -rn | head -10
この出力から、ビルド、テスト、デプロイの各フェーズでどのジョブが支配的かを把握できます。
特に、頻繁に実行されるジョブ(例:プッシュごとに走るテスト)は、たとえ1回あたりの時間が短くても、累積コストが大きくなるため優先的に最適化すべきです。
累積実行時間を算出するには、各ジョブの実行回数と平均所要時間を掛け合わせます。
以下の表は、あるプロジェクトでの1週間の累積データを擬似的に示したものです。
| ジョブ名 | 平均実行時間(秒) | 実行回数 | 累積時間(分) | 累積割合 | 改善優先度 |
|---|---|---|---|---|---|
| test:integration | 420 | 120 | 840 | 41% | 高(分割または並列化) |
| build:frontend | 180 | 150 | 450 | 22% | 中(キャッシュ最適化) |
| test:unit | 60 | 150 | 150 | 7% | 低(現状維持) |
| deploy:staging | 90 | 20 | 30 | 1% | 低(頻度が少ない) |
| lint | 15 | 150 | 37.5 | 2% | 低(軽量) |
この表から、test:integrationが全体の4割以上を占めることが分かれば、そのジョブをparallel:matrixで分割するか、不要なテストをスキップするルールを追加するといった対策が具体的に検討できます。
また、build:frontendは実行回数が多いため、キャッシュキーの見直しで数秒削減できれば累積効果が大きいことも読み取れます。
さらに、GitLabのパイプラインインサイト機能(Premium以上)を活用すれば、期間ごとの平均実行時間の推移をグラフで表示でき、設定変更前後の効果を視覚的に評価できます。
無償版でも、APIで取得したデータをGrafana等で可視化することで同様の分析が可能です。
重要なのは、改善施策を打った後には必ず同じメトリクスを再計測し、改善効果が統計的に有意かどうかを確認することです。
たった一度の計測で判断せず、少なくとも10回以上のパイプライン実行をサンプリングして平均値を比較する習慣が、誤った方向へのチューニングを防ぎます。
キャッシュヒット率をモニタリングし、キー設計の改善にフィードバックする
キャッシュの効果を定量的に評価するには、キャッシュヒット率(キャッシュが再利用されたジョブの割合)を計測することが不可欠です。
GitLabはジョブのログに「キャッシュが見つかりました」または「キャッシュが見つかりません」というメッセージを出力するため、これを収集することでヒット率を算出できます。
API経由でジョブのログを取得し、"Downloading cache"や"Cache hit"といったキーワードをカウントする簡易スクリプトを作成すると良いでしょう。
具体的な指標としては、キャッシュヒット率 = キャッシュが存在したジョブ数 / キャッシュを要求したジョブ数 で定義します。
この値が80%未満であれば、キー設計やキャッシュパスに問題がある可能性が高いです。
ヒット率が低い原因としては、以下のようなケースが考えられます。
- キャッシュキーに
$CI_COMMIT_SHORT_SHAを含めており、ほぼ毎回ミスしている - ブランチごとにキーが分かれているが、頻繁にブランチを切り替えるため、実質的に再利用されていない
- キャッシュパスに指定したディレクトリが、ジョブごとに異なるバージョンの依存ファイルを生成する(例:異なるNode.jsバージョンで
node_modulesが互換性を持たない)
ヒット率が低い場合の改善策として、まずキーからコミットハッシュを除外し、ロックファイルのハッシュのみに変更することを検討します。
また、ブランチ間で依存関係が変わらないのであれば、キーにブランチ名を含めず、固定のdefaultキーを使用する選択肢もあります。
ただし、その場合は異なるブランチ間でキャッシュが競合しないよう、policy: pull/pushを適切に設定する必要があります。
さらに、キャッシュのサイズもモニタリング対象です。
キャッシュが大きすぎるとダウンロードに時間がかかり、ヒットしても速度向上効果が薄れることがあります。
目安として、キャッシュサイズが500MBを超える場合は、不要なファイル(例:__pycache__や*.log)を除外するようキャッシュパスを再定義してください。
GitLabではcache:pathsに複数ディレクトリを指定できますが、それぞれを個別のキャッシュとして分割することも可能です(例:node_modulesとvendor/bundleを別キャッシュにすることで、片方だけが変更された場合でも無駄な再ダウンロードを防げます)
最後に、これらのメトリクスは週次または月次でレビューし、定例のパイプライン健康診断としてチーム内で共有することをお勧めします。
改善施策の効果が数値で明らかになれば、チームメンバーも積極的に設定の最適化に参加しやすくなり、イライラの種を未然に除去する文化が醸成されます。
計測なくして改善なし――この原則を忘れずに、データドリブンなアプローチでCI/CDを進化させ続けてください。
まとめ – 設定見直しによって得られる開発体験の向上と持続可能なCI/CD運用

ここまで、キャッシュキーの最適化からneedsによる依存グラフの再設計、rulesを用いた精密なジョブ制御、テスト分割とランナーリソースのチューニング、環境変数のセキュリティ管理、ログとアーティファクトのライフサイクル、そしてメトリクスに基づく継続的改善まで、GitLab CI/CDを構成するほぼ全てのレイヤーにわたって設定の見直しポイントを解説してきました。
これらの施策を一つひとつ適用すること自体はそれほど難しいものではありませんが、本当に価値があるのは、それらを個別最適ではなくシステム全体として統合的に捉え、チームのワークフローに組み込むことにあります。
本稿の最後として、これらの設定見直しがもたらす開発体験の具体的な変化と、その恩恵を持続させるための運用指針をまとめます。
まず、設定の最適化を完了したパイプラインは、実行時間が劇的に短縮されるだけでなく、予測可能性が飛躍的に向上します。
キャッシュヒット率が安定すれば、ジョブの実行時間のバラつきが小さくなり、「このブランチではなぜか遅い」という不可解な現象が減ります。
また、needsとparallelを適切に組み合わせることで、大規模なテストスイートでも常にほぼ同じ時間で完了するようになり、開発者はマージリクエストを作成してから結果を得るまでの待機ストレスから解放されます。
さらに、rulesによって不要なジョブが自動的にスキップされるため、無駄なリソース消費が抑えられ、結果として共有ランナーのキューイング待ちも減少します。
これらの効果が重なると、開発者の認知負荷が低下し、コードレビューや機能追加といった本来の創造的作業に集中できる環境が整います。
持続可能なCI/CD運用を実現するためには、設定を一度完成させて終わりではなく、リポジトリや依存関係の変化に追従する仕組みが不可欠です。
私が推奨するのは、以下の三つのプラクティスをチームの標準として定着させることです。
- パイプライン設定ファイル(
.gitlab-ci.yml)に変更を加えた際は、必ずCI_LINTで構文チェックを行い、さらに小さなサンプルブランチで実際に実行してからメインブランチにマージする - 毎月一度、パイプラインの実行履歴から各ジョブの平均所要時間とキャッシュヒット率を抽出し、前月比で悪化している項目があれば改善チケットを起票する
- 新たな依存パッケージやテストフレームワークを導入したタイミングで、キャッシュキーと分割戦略が依然として適切かを再評価する
これらのルーチン化により、パイプラインの性能劣化を早期に検知し、大規模なリファクタリングが必要になる前に微調整で対応できるようになります。
また、設定変更の履歴をコミットログに詳細に残すことも、後続の開発者がなぜその設定になったのかを理解する上で極めて有効です。
例えば、「キャッシュキーに$CI_COMMIT_REF_SLUGを追加。
理由:ブランチ間のキャッシュ競合を解消するため」といった明確なコメントを併記すれば、無用な変更を防げます。
さらに、チームの成熟度に応じて、パイプラインのパフォーマンスを可視化するダッシュボードの導入も検討に値します。
GrafanaやGitLabのインサイト機能を活用し、ジョブごとの実行時間トレンドやキャッシュヒット率、キューイング時間の推移をグラフ化すれば、数値の異常を直感的に把握できます。
この可視化は、開発者個人の努力に頼らず、組織としての改善カルチャーを醸成する土壌となります。
最後に、本稿で取り上げた各設定は、いずれも独立して機能するものではなく、相互に影響を及ぼし合います。
例えば、needsを導入すればアーティファクトの受け渡しパターンが変わり、それに伴ってexpire_inの設定も再考する必要が生じます。
同様に、concurrentを上げればランナーの負荷が増え、結果としてジョブの実行時間が伸びるため、キャッシュヒット率の目標値も変わってきます。
したがって、一つのパラメータを変更したら、必ず全体のメトリクスを再計測し、トレードオフが許容範囲内かを確認するという姿勢が何よりも大切です。
CI/CDパイプラインは、ソフトウェア開発の生産性を支える重要なインフラです。
イライラの原因は多くの場合、ツールの欠陥ではなく、設定に対する理解不足や変化への対応遅れにあります。
本稿が、その理解を深め、データ駆動型の改善サイクルを回すきっかけとなれば幸いです。
設定ファイルは生き物です。
恐れずに、そして計測を忘れずに、継続的に育てていく――それが、GitLab CI/CDと健全に向き合うための最終的な答えだと確信しています。


コメント