本記事は GitLab Advent Calendar 2022 の 24 日目の記事です。(遅れた)
はじめに
GitLab のソースは https://gitlab.com/gitlab-org/gitlab で公開され、開発されています。もちろん、ビルドや lint、テスト、テスト環境へのデプロイなどには GitLab CI/CD が利用されています。そこで、GitLab の中の人が書いた .gitlab-ci.yml
を読むことで、パイプラインの構成に関する知見が得られるのではないかと考えました。
本記事では、GitLab の .gitlab-ci.yml
のうち、パイプラインやジョブの共通設定をしている部分に関して紹介します。個別のジョブの実装に関しては、読むのに少し時間がかかりそうなので、また後日まとめる予定です。
準備
公式のプロジェクトでは、自分に編集権限がないため Pipeline Editor が利用できなさそうです。便利のために、プロジェクトをフォークしておきます。本記事執筆開始時点での master ブランチのコミットは bfe7a336fd9b44a1624f2df6fed3387043414a99
です。以降の情報はこのコミットの内容に基づきます。
プロジェクトルートの .gitlab-ci.yml
プロジェクトルートの .gitlab-ci.yml
には、ジョブは定義されておらず、Global Keywords のみが定義されていました。Global Keywords とその概要は次のとおりです。キーワードの並び順は、.gitlab-ci.yml
での出現順で記載しています。
キーワード | 概要 |
---|---|
stages | パイプラインのステージ名と順番を定義する。 |
default | ジョブレベルのキーワードのデフォルト値を定義する。 |
workflow | パイプラインが起動する条件を定義する。 |
variables | パイプラインのすべてのジョブで利用できる CI/CD Variables を定義する。 |
include | 他の YAML ファイルからのパイプライン定義をインポートする。 |
include が最下部にあるのが意外です。よくあるプログラムの記法を考えると最上部に持って行きたくなりそうですが、上から順に解釈されるわけではないことと、stages
や variables
など全体に関わる情報を先に書き、具体的な処理は後に書く、という考え方でこのような順番になっているかもしれません。
少し、歴史を辿ってみます。
2019年4月に Refactor .gitlab-ci.yml
というコミット があり、ここで include
が最下部に移ったようです。
一つ前のコミット では、ファイル上部でソースコード品質分析ジョブの定義だけが include
で読み込まれており、他のジョブは全てプロジェクトルートの .gitlab-ci.yml
に大量に記載されています。2019年4月のリファクタリングの過程で、それら大量のジョブを .gitlab/ci
ディレクトリに移行するとともに、 include
も最下部に移動したようです。
2018年2月 に GitLab 10.5 で include
機能が追加されたことを考えると、公式で1年間も活用されていないのは意外ですね。
stages
12ステージ定義されています。各ステージの概要は 公式ドキュメント内の CI configuration internals に記載されていました。
ステージ | 概要 |
---|---|
sync |
gitlab-org/gitlab の変更を gitlab-org/gitlab-foss (プロプライエタリコードが除去されたプロジェクト)に同期する。 |
prepare |
後続のジョブで必要な artifact を準備する。 |
build-images |
後続のジョブで必要な Dockerイメージ を準備する。 |
fixtures |
フロントエンドテストで必要な fixture (バックエンドからの応答を固定化するファイル)1 を生成する。 |
lint |
lint と 静的解析 を実行する。 |
test |
大多数のテストと、DB/マイグレーション のテストを実行する。 |
post-test |
ビルドレポートや、test ステージのジョブのデータ(カバレッジなど)を収集する。 |
review |
CNG イメージ2 をビルドしてデプロイし、Review Apps に対する E2E テストを実行する。 |
qa |
review ステージでデプロイされた Review App に対して QA タスクを実行する。 |
post-qa |
ビルドレポートや、qa ステージのジョブのデータ(Review App の性能レポートなど)を収集する。 |
pages |
様々なレポートを GitLab Pages としてデプロイする。 |
notify |
様々な失敗をslackに通知する。 |
ステージ名は定義されていますが、ジョブ間の前後関係は needs
キーワードで指定されているので、必ずしも定義したステージの順にジョブが実行されるわけではありません。バックエンドコードのMRパイプラインの例 を参照してみましょう。(Group jobs By のトグルを Job Dependencies に設定し、Show Dependencies を ON にすると、ジョブの依存関係を可視化することができます。)例えば、lint
ステージの多くのジョブは依存するジョブが存在しないため、パイプラインが起動するとすぐに実行可能なジョブとしてキューイングされます。
実際には依存関係のないジョブが stages
の指定によって直列で呼び出す作りにしてしまうと、パイプライン全体の実行時間が延びうるだけでなく、ジョブの結果が開発者にフィードバックされるまでのリードタイムも長くなってしまいます。したがって、例のように needs
キーワードを活用し、できるだけジョブを並列実行可能にしておくと良いですね。
default
default
では、ジョブのデフォルトのイメージや、ジョブで利用する GitLab Runner のタグなど、いくつかの設定についてデフォルト値を定めることができます。
default
の内容は次の通りです。
# always use `gitlab-org` runners, however
# in cases where jobs require Docker-in-Docker, the job
# definition must be extended with `.use-docker-in-docker`
default:
image: $DEFAULT_CI_IMAGE
tags:
- gitlab-org
# All jobs are interruptible by default
interruptible: true
# Default job timeout set to 90m https://gitlab.com/gitlab-com/gl-infra/infrastructure/-/issues/10520
timeout: 90m
- ジョブのデフォルトのイメージは環境変数
DEFAULT_CI_IMAGE
で指定しています。- Global の
variables
で定義された環境変数です。DEFAULT_CI_IMAGE: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}.patched-golang-${GO_VERSION}-node-16.14-postgresql-${PG_VERSION}:rubygems-3.2-git-2.36-lfs-2.9-chrome-${CHROME_VERSION}-yarn-1.22-graphicsmagick-1.3.36
- 様々なソフトウェアのバージョンが変数化されており、各変数の値を変更するだけで Docker イメージを差し替えられるようになっています。
- Global の
- GitLab Runner の タグは
gitlab-org
を基本とし、Docker-in-Docker (DinD) するときは.use-docker-in-docker
を継承するように、と記載があります。- ジョブの処理の重さに応じて、スペックが異なる Runner を準備し利用するプラクティスがありますが、ここでは特に活用されていないようです。
-
interruptible: true
- ジョブが完了する前に新しいパイプラインが開始されたとき、ジョブがキャンセルされます。無駄なジョブを起動しないための設定ですね。
-
.gitlab/ci/setup.gitlab-ci.yml
において、interruptible:false
のdont-interrupt-me
ジョブが定義されています。特定の条件で起動したパイプラインを中断させないようにしています。
- ジョブのタイムアウトを 90分 に設定しています。
-
.gitlab/ci/review-apps/dast.gitlab-ci.yml
においては、DAST の実行のためにtimeout: 3h
としてオーバーライドしています。
-
workflow
workflow
はパイプラインの動作を制御するための仕組みです。パイプライン全体の起動条件を定めたり、条件に応じてパイプライン全体で利用する環境変数を制御する等の機能を提供します。workflow の実装内容は こちら を参照してください。
workflow で気になった実装を以下にピックアップします。
-
workflow: name
を使い、パイプラインに名前を付与している。workflow: name: '$PIPELINE_NAME'
- GitLab 15.5 から feature flag で導入され、15.7 でデフォルトで有効になった機能です。
- Pipeline の一覧や詳細画面において、パイプラインの名前を表示できます。以下キャプチャの
Scheduled ruby 3 pipeline
,merged_result MR pipeline
等です。利点としては、ちょっとした情報が追加できるくらいでしょうか・・・?
-
workflow: if
の条件と合わせて、特定条件のときにパイプライン名が変わるようにしています。例# For merge requests running exclusively in Ruby 3.0 - if: '$CI_MERGE_REQUEST_LABELS =~ /pipeline:run-in-ruby3/' variables: RUBY_VERSION: "3.0"細分化され PIPELINE_NAME: 'Ruby 3 $CI_MERGE_REQUEST_EVENT_TYPE MR pipeline' # For (detached) merge request pipelines. - if: '$CI_MERGE_REQUEST_IID' variables: PIPELINE_NAME: '$CI_MERGE_REQUEST_EVENT_TYPE MR pipeline'
- 環境変数
FORCE_GITLAB_CI
を設定すると、workflow の他の条件に一致しなくとも、パイプラインが起動される。-
workflow: if
に起動条件を用意していない、通常とは異なる開発フローが生じたとき、わざわざ.gitlab-ci.yml
を更新しなくて済むような工夫ですね。
-
- 障害通知チャンネルの指定
- 特定のパイプラインにおいて、パイプラインが失敗したときに slack チャンネルへ通知するための変数を追加しています。パイプラインの用途によって、通知するチャンネルを変更しているようです。
# For `$CI_DEFAULT_BRANCH` branch, create a pipeline (this includes on schedules, pushes, merges, etc.). - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' variables: CREATE_INCIDENT_FOR_PIPELINE_FAILURE: "true" NOTIFY_PIPELINE_FAILURE_CHANNEL: "master-broken"
- 上記の変数は
.gitlab./ci/notify.gitlab-ci.yml
内のnotify-pipeline-failure
ジョブで利用されています。
- 特定のパイプラインにおいて、パイプラインが失敗したときに slack チャンネルへ通知するための変数を追加しています。パイプラインの用途によって、通知するチャンネルを変更しているようです。
variables
variables
では、パイプライン全体で利用できる環境変数を定義することができます。variables の実装内容は こちら を参照してください。
定義されている環境変数は、概ね以下のように分類できそうです。記載している環境変数は例として抜粋しています。
-
バージョンや環境、ツールの基本的な設定
-
PG_VERSION
: PostgreSQL のバージョン -
NODE_ENV:
:node
実行時の環境設定 -
GIT_DEPTH
: git clone 時の深さ(コミット数)
-
-
テストに関する設定
-
FLAKY_RSPEC_SUITE_REPORT_PATH
: テストの結果が不安定なテスト(flaky test)に関するレポートの格納パス -
JUNIT_RESULT_FILE
: JUnit の結果を格納するパス
-
-
データの扱いに関する設定
-
ELASTIC_URL
: おそらく、Elasticsearch integration を試す際の接続先URLと思われます。 -
FF_USE_FASTZIP
: cache の圧縮、展開を高速化するための設定 -
DECOMPOSED_DB
: バックエンドの DB を分解した構成にするか否か
-
-
レビュー用リソースに関する設定
-
DOCS_REVIEW_APPS_DOMAIN
: ドキュメントレビュー用のドメイン -
REVIEW_APPS_DOMAIN
: Review Apps 用のドメイン -
REVIEW_APPS_GCP_REGION
: Review Apps デプロイ先リージョン
-
-
その他
-
SIMPLECOV
: ruby のカバレッジ計測用ツール SimpleCov を動かすか否か -
REGISTRY_HOST
: コンテナイメージの格納先ホスト
-
バージョンや環境定義を記載しておくのは良いアイデアだなと思いました。ジョブのイメージを差し替える場合に、image:
キーワードの部分を書き換えるのではなく、グローバルの環境変数で差し替えるようにしておけば、パイプラインで利用している各種ソフトウェアのバージョンが一目瞭然になります。
テストに関する設定で、結果やレポートの格納パスが環境変数になっているのも参考にできそうです。ジョブ毎に artifact
にパスをベタ書きするのではなく、事前に格納パスを環境変数で決めておくことで、結果を出力する部分と、後続でそれを利用する部分とで不整合の発生を防ぐことができます。テストに限らず、ビルド成果物を後続ジョブに渡すケースで活用できます。
include
次の2つの場所からパイプライン定義を include しています。
- 同プロジェクトの、GitLab CI/CD 用ディレクトリ内の
.gitlab-ci.yml
で終わるファイル群:.gitlab/ci/*.gitlab-ci.yml
- 別プロジェクト(gitlab-org/frontend/untamper-my-lockfile)にある 1つの YAML ファイル
-
yarn.lock
の改ざんをチェックするジョブが1つ定義されているだけであり、それ以外の全てのジョブは 1. で定義されています。
-
include する YAML のファイル名末尾に .gitlab-ci.yml
をつけるのは、CI用の YAML ファイルであることが明確になるのが良いと思いました。プロジェクト内に CI 用以外の YAML ファイルが存在する場合でも、ワイルドカードを使って一括で include しやすくなりますね。
include で導入されるジョブ
先述したとおり、include される YAMLファイルのファイル名は <任意名>.gitlab-ci.yml
の形式となっています。
$ cd .gitlab/ci/
$ ls -1 *.gitlab-ci.yml
as-if-jh.gitlab-ci.yml
build-images.gitlab-ci.yml
caching.gitlab-ci.yml
ci-templates.gitlab-ci.yml
dev-fixtures.gitlab-ci.yml
docs.gitlab-ci.yml
frontend.gitlab-ci.yml
glfm.gitlab-ci.yml
global.gitlab-ci.yml
graphql.gitlab-ci.yml
memory.gitlab-ci.yml
notify.gitlab-ci.yml
pages.gitlab-ci.yml
qa.gitlab-ci.yml
rails.gitlab-ci.yml
releases.gitlab-ci.yml
reports.gitlab-ci.yml
review.gitlab-ci.yml
rules.gitlab-ci.yml
setup.gitlab-ci.yml
static-analysis.gitlab-ci.yml
test-metadata.gitlab-ci.yml
vendored-gems.gitlab-ci.yml
workhorse.gitlab-ci.yml
yaml.gitlab-ci.yml
ファイルの構成
ファイルはステージ別に分類されているわけではなく、目的や機能別に分類されているようです。例えば、docs.gitlab-ci.yml
には、lint
ステージでドキュメントの lint を実行するジョブと、review
ステージでドキュメントレビュー用のデプロイを実行するジョブが含まれています。他にも setup.gitlab-ci.yml
には、prepare
, sync
, test
ステージのジョブが含まれています。
これまでの私の経験では、ステージ別にディレクトリを切り、そのステージ内で利用するジョブの YAML ファイルを管理する構成が多かったのですが、一連の関連するジョブ(例:APIテストの準備、実行、結果回収)を把握しづらく、修正時の影響確認などがしづらいと感じていました。上記の構成のように 目的/機能別に定義を分割することで、一連のジョブが把握しやすくなりそうです。
rules.gitlab-ci.yml
rules.gitlab-ci.yml
では、ジョブの実行条件を制御する rules
キーワードが細分化されて定義されています。
構成要素は大別すると以下の3つです。
-
rules:if
部分を1行だけ定義した YAML アンカー 用 hidden ジョブ.if-default-branch-or-tag: &if-default-branch-or-tag if: '$CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_TAG'
-
rules:change
部分を定義した YAML アンカー用 hidden ジョブ.code-backstage-qa-patterns: &code-backstage-qa-patterns - "{package.json,yarn.lock}" - ".browserslistrc"キーワード - "babel.config.js" - "jest.config.{base,integration,unit}.js" - ".csscomb.json" # (以下省略)
- 上の2つの YAML アンカーを組み合わせて
rules
キーワードを構成する hidden ジョブ.setup:rules:cache-gems: rules: - <<: *if-not-canonical-namespace when: never - <<: *if-default-branch-or-tag changes: *code-backstage-qa-patterns - <<: *if-dot-com-gitlab-org-merge-request changes: [".gitlab/ci/setup.gitlab-ci.yml"] when: manual allow_failure: true
基本の条件を YAML アンカーとして定義しておき、それらの組み合わせで様々なrules
のパターンを作り上げる、構造化された方式で実装されています。これだけ構造化をしてもなお rules.gitlab-ci.yml
は 2200行近くに膨らんでおり、GitLab ほど規模なプロジェクトになると、ジョブの起動条件のパターンが大量かつ複雑であることを実感しました。
小規模なプロジェクトの場合は、ここまで細かく構造化すると却ってメンテナンスしにくくなる可能性もあるので、開発規模とジョブ起動条件の重複度を鑑みて構造化するレベルを調整すべきでしょう。
global.gitlab-ci.yml
global.gitlab-ci.yml
では、ジョブのデフォルト設定群が定義されています。
リトライ設定
ジョブのリトライ設定をする retry
のデフォルト設定が hidden ジョブとして定義されています。デフォルトのリトライ回数 2
と、リトライの条件が設定されています。.gitlab-ci.yml
において設定可能な条件には、always
(ジョブ失敗時は常にリトライする)を除き、想定外の事象が生じている場合が条件として用意されています。そのため、ジョブ間でほとんど差異が無い設定項目になることが多く、このように共通化するのはメリットが大きいと思われます。
.default-retry:
retry:
max: 2 # This is confusing but this means "3 runs at max".
when:
- unknown_failure
- api_failure
- runner_system_failure
- job_execution_timeout
- stuck_or_timeout_failure
なお、retry
は Global の default
キーワード配下でも設定できるのですが、リトライさせたくないジョブがある場合にわざわざ retry: 0
を書く必要が生じるため、別途定義しているものと推測しています。
cache に関する設定
GitLab CI/CD には、ジョブ実行を効率化するためのキャッシュ機能があり、cache
キーワードで制御します。キャッシュの利用例は こちら を参照してください。
global.gitlab-ci.yml
では、cache
の設定である key
や path
, policy
のみが YAMLアンカー用の hidden ジョブとして定義されており、キャッシュの基本設定を部品化しているようです。
.ruby-gems-cache: &ruby-gems-cache
key: "ruby-gems-debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}"
paths:
- vendor/ruby/
policy: pull
.ruby-gems-cache-push: &ruby-gems-cache-push
<<: *ruby-gems-cache
policy: push # We want to rebuild the cache from scratch to ensure stale dependencies are cleaned up.
.gitaly-ruby-gems-cache: &gitaly-ruby-gems-cache
key: "gitaly-ruby-gems-debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}"
paths:
- vendor/gitaly-ruby/
policy: pull
そして、rules
のときと同様、YAMLアンカーを組み合わせて cache
の設定を組み立てています。
.setup-test-env-cache:
cache:
- *ruby-gems-cache
- *gitaly-ruby-gems-cache
- *gitaly-binaries-cache
- *go-pkg-cache
.setup-test-env-cache-push:
cache:
- *ruby-gems-cache-push
- *gitaly-ruby-gems-cache-push
- *go-pkg-cache-push
services に関する設定
主にテストに利用する service コンテナに関する設定と、対応する variables
がhiddenジョブとして部品化されています。 ジョブ名はすべて .use
から始まっており、起動するソフトウェアに応じた名前が付けられています。以下に例を示します。
-
.use-pg11
: PostgreSQL 11 と、redis を起動する。.use-pg11: services: - name: postgres:11.6 command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] - name: redis:5.0-alpine variables: POSTGRES_HOST_AUTH_METHOD: trust PG_VERSION: "11"
-
.use-pg11-es7-ee
: PostgreSQL 11 と、ElasticSearch 7 と、redis を起動する。.use-pg11-es7-ee: services: - name: postgres:11.6 command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] - name: redis:5.0-alpine - name: elasticsearch:7.17.6 command: ["elasticsearch", "-E", "discovery.type=single-node", "-E", "xpack.security.enabled=false"] variables: POSTGRES_HOST_AUTH_METHOD: trust PG_VERSION: "11"
-
.use-pg12-opensearch2-ee
: PostgreSQL 12 と、OpenSearch 2 と、redis を起動する。.use-pg12-opensearch2-ee: services: - name: postgres:12 command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] - name: redis:6.0-alpine - name: opensearchproject/opensearch:2.2.1 alias: elasticsearch command: ["bin/opensearch", "-E", "discovery.type=single-node", "-E", "plugins.security.disabled=true"] variables: POSTGRES_HOST_AUTH_METHOD: trust PG_VERSION: "12"
service コンテナを使いたいジョブでは、必ず .use
から始まる hidden ジョブを継承することで、services
部分を定義することなく使うことができます。ここでは、指定したソフトウェアのコンテナを最小設定で起動するだけの設定となっているため、ジョブ本体の実装からは切り離して考えやすくなっています。このように部品化することにより、様々なジョブで使い回すことが可能な設計になっているようです。
特殊なジョブ用のデフォルト実装
コンテナビルド用に kaniko や buildX(buildkit) を使う場合のデフォルト実装も、hiddenジョブで定義されていました。
以下は kaniko を利用する際に継承する hidden ジョブです。
.use-kaniko:
image:
name: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images:kaniko
entrypoint: [""]
before_script:
- source scripts/utils.sh
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
汎用的な作りであり、image
と 認証情報の環境変数あたりを書き換えることで、別プロジェクトでも活用ができそうですね。
その他
3つほど、before_script
だけを定義した hidden ジョブがありました。その中で、.default-utils-before-script
という hidden ジョブが気になったので紹介します。
.default-utils-before-script
では before_script として
5行のスクリプトが実装されています。
.default-utils-before_script:
before_script:
- echo $FOSS_ONLY
- '[ "$FOSS_ONLY" = "1" ] && rm -rf ee/ qa/spec/ee/ qa/qa/specs/features/ee/ qa/qa/ee/ qa/qa/ee.rb'
- export GOPATH=$CI_PROJECT_DIR/.go
- mkdir -p $GOPATH
- source scripts/utils.sh
1-4 行目では FOSS版(プロプライエタリコードを抜いたバージョン)かどうかで、特定のファイルを削除する処理と、環境変数 GOPATH
に関する処理をしています。5行目では、script/utils.sh
の内容を source
コマンドで読み込んでいます。この script/utils.sh
には20個程度のシェルスクリプトの関数が定義されていました。
script/utils.sh
内に定義されている関数の例として、echo用の汎用的な関数を以下に示します。それぞれの関数の名前に合わせて、文字列に色を付けて標準エラー出力に出力する処理が実装されています。
function echoerr() {
local header="${2:-no}"
if [ "${header}" != "no" ]; then
printf "\n\033[0;31m** %s **\n\033[0m" "${1}" >&2;
else
printf "\033[0;31m%s\n\033[0m" "${1}" >&2;
fi
}
function echoinfo() {
local header="${2:-no}"
if [ "${header}" != "no" ]; then
printf "\n\033[0;33m** %s **\n\033[0m" "${1}" >&2;
else
printf "\033[0;33m%s\n\033[0m" "${1}" >&2;
fi
}
function echosuccess() {
local header="${2:-no}"
if [ "${header}" != "no" ]; then
printf "\n\033[0;32m** %s **\n\033[0m" "${1}" >&2;
else
printf "\033[0;32m%s\n\033[0m" "${1}" >&2;
fi
}
上記のように、ユーティリティ的な関数をスクリプトとして作成しておき before_script
にて source
コマンドで読み込み、ジョブ内で利用できるようにするテクニックは、いくつかのテックブログで言及されています。例えば、https://threedots.tech/post/keeping-common-scripts-in-gitlab-ci/ ではスクリプトを別のGitリポジトリで管理し、before_script
で git clone
をして利用できるようにする手法が紹介されています。これらを参考にすることで、スクリプトの再利用性を高め、メンテナンス性を向上させることができるでしょう。
まとめ
パイプライン全体のグローバルの設定や、YAML アンカー、hiddenジョブを活用することで、様々な設定を効率よく組み立てていると感じました。
一方で、かなり構造化された実装となっているため、初見では読み解き難い実装になっている印象もあります。プロジェクトの規模に応じて、今回紹介した内容を取捨選択して活用することをおすすめします。
後日、個別のジョブの実装に関しても読み解いてみたいと思いますので、乞うご期待。
-
"fixture" という用語が初見でした。解釈が間違っているかもしれません。 ↩
-
Cloud Native GitLab container image (CNG); GitLab のコンポーネント(gitaly, sidekiq など)毎のコンテナイメージ ↩