遅い e2e 一つのせいでドキュメント公開が 8 秒待たされた
目標
遅いジョブが混ざったパイプラインを実際に動かして、ステージとneedsがジョブの出発時刻をどう変えるかを測り、アーティファクトの受け渡し・条件付きジョブの待機・失敗の許容の方式を、実行結果で確認します。
なぜ重要なのか
ステージ方式は理解しやすいですが、最も遅いジョブが、後ろのすべてのジョブの出発を引き止めます。needsで必要なものだけを待たせればパイプラインは速くなりますが、その代わり、何を待ち何を受け取るのかを、ジョブごとに正確に書く必要があります。rulesで抜けうるジョブを待つとパイプラインがそもそも作られず、失敗を広く許容しすぎると、本当の事故が緑色のまま通り過ぎます。これらの選択は、ドキュメントを読むときよりも、時刻と結果を見たときのほうがはっきりします。
ステップ
/root/glci-dagをgitリポジトリにし、.gitlab-ci.ymlにstages[build, test, deploy]とジョブ4つを置いてください。compile(build、sleep 3のあとでbin/appファイルを作り、artifactsでbin/をアップロードする)、unit(test、test -f bin/appのあとでecho unit-ok)、e2e(test、sleep 8のあとでecho e2e-ok)、publish-docs(deploy、test -f bin/appのあとでecho docs-published)です。gitlab-ci-local --shell-isolation --no-artifacts-to-source --timestampsで実行して、publish-docsが遅いe2eが終わってからようやく始まることを見てください。publish-docsにneeds: [compile]を追加してください。実行すると、publish-docsがe2eの終了前に始まり、それでもcompileのアーティファクト(bin/app)は受け取れなければなりません。- ジョブ
lint(stageはtest、sleep 1のあとでecho lint-ok)をneeds: []で追加してください。実行すると、lintがcompileの終了前に始まるはずです。 - ジョブ
audit(stageはtest)をneedsの長い形式でjob: compile、artifacts: falseとし、scriptをtest ! -e bin/app && echo no-artifactにしてください。実行すると、auditは成功し(bin/appがない)、unitはそれでもbin/appを受け取れなければなりません。 - ジョブ
integration(stageはtest)には、$RUN_INTEGRATION == "yes"のときだけ作られるようにrulesを置き、sleep 2のあとでecho integration-okを実行します。ジョブrelease(stageはdeploy)は、needsにunitとjob: integration, optional: trueを置いて、echo releaseを実行します。変数なしで実行するとintegrationなしでreleaseが動き、--variable RUN_INTEGRATION=yesで実行するとintegrationが終わったあとにreleaseが始まるはずです。 - ジョブを3つ追加してください。
flaky(test)はecho flaky-runのあとでexit 1しますが、allow_failure: trueです。cleanup(deploy)はwhen: alwaysでecho cleanupを、notify-failure(deploy)はwhen: on_failureでecho notifyを実行します。実行すると、flakyは警告で終わってパイプラインは成功し、cleanupは動き、notify-failureは動かないはずです。採点ツールは、コピーでflakyのallow_failureを外した場合でも実行して確認します。 - ジョブ
check-configをstage.preに追加してください(stagesのリストには書きません)。sleep 2のあとにtest ! -e STOPで、リポジトリにSTOPファイルがあれば失敗し、なければecho config-okを実行します。実行すると、check-configが終わってからcompileが始まるはずです。採点ツールは、コピーにSTOPファイルを入れて、事前検査が失敗したらcompileがそもそも動かないかも確認します。
参考
- このVMにはGitLabサーバーもランナーもなく、gitlab-ci-local 4.75.1が.gitlab-ci.ymlをGitLabと同じルールで解釈し、シェルでジョブを実行します。
image:を書くとDockerで動かそうとするので使いません。保護変数・マスキング・CI_JOB_TOKEN・ランナーのタグ・マージリクエストパイプラインの作成はサーバーの機能なので、ここでは再現されません。 - 実行はリポジトリのルートで
gitlab-ci-local --shell-isolation --no-artifacts-to-source(ジョブごとに別の作業ディレクトリを使い、アーティファクトをリポジトリに書き戻さない)、ジョブ一覧はgitlab-ci-local --list-csv-all、解釈された設定はgitlab-ci-local --previewです。gitlab-ci-localはgitが追跡しているファイルだけをジョブに渡すので、ファイルを作ったらgit addしてください。採点ツールはリポジトリをコピーし、すべてのファイルをコミットしたあとで、同じツールでもう一度実行します。 - gitlab-ci-localは、needsで指したジョブが同じステージかまだ終わっていないステージにある場合、そのステージが終わるまで待ちます(実測)。GitLabは指したジョブだけを待つので、実際のサーバーではより早く出発します。このラボの時刻比較は、2つのツールの結果が同じ場合だけを使います。
- gitlab-ci-localは、rulesで抜けたジョブをoptionalなしでneedsに書いても拒否しません。GitLabはその場合、パイプラインを作らずにエラーを出します。
- gitlab-ci-local 4.75.1は、retry・timeout・allow_failure:exit_codes・.postステージをGitLabとは違う方法で処理します(実測: 再試行せず、時間制限を超えても動き続け、許可していない終了コードで失敗しても後ろのステージを進め、.postジョブは一覧にだけあって実行しません)。そのため、このラボでは扱いません。
- needs・CI/CD YAML syntax reference(allow_failure・when)・Job artifacts・Pipeline efficiency
ステージは前のステージのすべてを待つ
/root/glci-dagをgitリポジトリにし、.gitlab-ci.ymlにstages[build, test, deploy]とジョブ4つを置いてください。compile(build、sleep 3のあとでbin/appファイルを作り、artifactsでbin/をアップロードする)、unit(test、test -f bin/appのあとでecho unit-ok)、e2e(test、sleep 8のあとでecho e2e-ok)、publish-docs(deploy、test -f bin/appのあとでecho docs-published)です。gitlab-ci-local --shell-isolation --no-artifacts-to-source --timestampsで実行して、publish-docsが遅いe2eが終わってからようやく始まることを見てください。
needsのないジョブは、前のステージのすべてのジョブが終わって初めて出発し、前のステージすべてのアーティファクトを受け取ります。--timestampsを付けると各行の前に時刻が出力されるので、開始(starting shell)と終了(finished in)を比較できます。
ドキュメントの公開はコンパイルだけを待てばよい
publish-docsにneeds: [compile]を追加してください。実行すると、publish-docsがe2eの終了前に始まり、それでもcompileのアーティファクト(bin/app)は受け取れなければなりません。
needsは、待つジョブを直接指定します。書いたジョブのアーティファクトだけを受け取り、ステージの順序はもう出発時刻を決めません。一度needsを使うと、書いていないジョブは待ちもせず、アーティファクトも受け取りません。
needs: []はパイプラインが始まるとすぐに出発する
ジョブlint(stageはtest、sleep 1のあとでecho lint-ok)をneeds: []で追加してください。実行すると、lintがcompileの終了前に始まるはずです。
空のneedsは「誰も待たない」という意味です。ステージがtestでも、buildが終わるのを待ちません。ソースだけ見ればよい検査をこのように前倒しすると、失敗を数分早く知ることができます。
順序は待つが、アーティファクトは受け取らない
ジョブaudit(stageはtest)をneedsの長い形式でjob: compile、artifacts: falseとし、scriptをtest ! -e bin/app && echo no-artifactにしてください。実行すると、auditは成功し(bin/appがない)、unitはそれでもbin/appを受け取れなければなりません。
needsの長い形式では、ジョブごとにアーティファクトを受け取るかどうかを切れます。順序だけが必要でファイルは要らないジョブが、大きなアーティファクトをダウンロードするせいで遅くなるのを防ぎます。
あるかもしれないし、ないかもしれないジョブを待つ
ジョブintegration(stageはtest)には、$RUN_INTEGRATION == "yes"のときだけ作られるようにrulesを置き、sleep 2のあとでecho integration-okを実行します。ジョブrelease(stageはdeploy)は、needsにunitとjob: integration, optional: trueを置いて、echo releaseを実行します。変数なしで実行するとintegrationなしでreleaseが動き、--variable RUN_INTEGRATION=yesで実行するとintegrationが終わったあとにreleaseが始まるはずです。
rulesで抜けうるジョブをそのままneedsに書くと、GitLabはそのジョブがないとき、パイプライン自体を作りません。optional: trueは「あれば待ち、なければ先へ進む」という意味です。
失敗を許容するジョブ、失敗しても動くジョブ、失敗したときに動くジョブ
ジョブを3つ追加してください。flaky(test)はecho flaky-runのあとでexit 1しますが、allow_failure: trueです。cleanup(deploy)はwhen: alwaysでecho cleanupを、notify-failure(deploy)はwhen: on_failureでecho notifyを実行します。実行すると、flakyは警告で終わってパイプラインは成功し、cleanupは動き、notify-failureは動かないはずです。採点ツールは、コピーでflakyのallow_failureを外した場合でも実行して確認します。
allow_failureは、失敗を「警告」に変えて、後ろのステージを止めないようにします。when: on_failureは前に失敗したジョブがあるときだけ、alwaysは結果に関係なく動きます。許容された失敗は、on_failureを呼びません。
すべてのステージより先に動く事前検査
ジョブcheck-configをstage.preに追加してください(stagesのリストには書きません)。sleep 2のあとにtest ! -e STOPで、リポジトリにSTOPファイルがあれば失敗し、なければecho config-okを実行します。実行すると、check-configが終わってからcompileが始まるはずです。採点ツールは、コピーにSTOPファイルを入れて、事前検査が失敗したらcompileがそもそも動かないかも確認します。
.preは、stagesに書かなくても常に一番前にある予約ステージです(一番後ろには.post)。ステージの一覧に手を触れずに、パイプライン全体の事前検査を付けるときに使います。前のステージが失敗すると、後ろのステージの通常のジョブは、作られるだけで動きません。