LoginSignup
15
5

More than 1 year has passed since last update.

GitLab の .gitlab-ci.yml を読み解いてみる(共通設定編)

Last updated at Posted at 2022-12-25

本記事は 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 が最下部にあるのが意外です。よくあるプログラムの記法を考えると最上部に持って行きたくなりそうですが、上から順に解釈されるわけではないことと、stagesvariables など全体に関わる情報を先に書き、具体的な処理は後に書く、という考え方でこのような順番になっているかもしれません。

少し、歴史を辿ってみます。

2019年4月に Refactor .gitlab-ci.yml というコミット があり、ここで include が最下部に移ったようです。

一つ前のコミット では、ファイル上部でソースコード品質分析ジョブの定義だけが include で読み込まれており、他のジョブは全てプロジェクトルートの .gitlab-ci.yml に大量に記載されています。2019年4月のリファクタリングの過程で、それら大量のジョブを .gitlab/ci ディレクトリに移行するとともに、 include も最下部に移動したようです。

2018年2月 に GitLab 10.5include 機能が追加されたことを考えると、公式で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 ステージの多くのジョブは依存するジョブが存在しないため、パイプラインが起動するとすぐに実行可能なジョブとしてキューイングされます。

image.png

実際には依存関係のないジョブが 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 イメージを差し替えられるようになっています。
  • GitLab Runner の タグは gitlab-org を基本とし、Docker-in-Docker (DinD) するときは .use-docker-in-docker を継承するように、と記載があります。
    • ジョブの処理の重さに応じて、スペックが異なる Runner を準備し利用するプラクティスがありますが、ここでは特に活用されていないようです。
  • interruptible: true
    • ジョブが完了する前に新しいパイプラインが開始されたとき、ジョブがキャンセルされます。無駄なジョブを起動しないための設定ですね。
    • .gitlab/ci/setup.gitlab-ci.yml において、interruptible:falsedont-interrupt-me ジョブが定義されています。特定の条件で起動したパイプラインを中断させないようにしています。
  • ジョブのタイムアウトを 90分 に設定しています。

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 ジョブで利用されています。

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 しています。

  1. 同プロジェクトの、GitLab CI/CD 用ディレクトリ内の .gitlab-ci.yml で終わるファイル群: .gitlab/ci/*.gitlab-ci.yml
  2. 別プロジェクト(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 の設定である keypath, 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 コンテナに関する設定と、対応する variableshiddenジョブとして部品化されています。 ジョブ名はすべて .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_scriptgit clone をして利用できるようにする手法が紹介されています。これらを参考にすることで、スクリプトの再利用性を高め、メンテナンス性を向上させることができるでしょう。

まとめ

パイプライン全体のグローバルの設定や、YAML アンカー、hiddenジョブを活用することで、様々な設定を効率よく組み立てていると感じました。
一方で、かなり構造化された実装となっているため、初見では読み解き難い実装になっている印象もあります。プロジェクトの規模に応じて、今回紹介した内容を取捨選択して活用することをおすすめします。

後日、個別のジョブの実装に関しても読み解いてみたいと思いますので、乞うご期待。

  1. "fixture" という用語が初見でした。解釈が間違っているかもしれません。

  2. Cloud Native GitLab container image (CNG); GitLab のコンポーネント(gitaly, sidekiq など)毎のコンテナイメージ

15
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
5