設定でパイプラインの実行モデルを作る
目標
.gitlab-ci.yml1枚を8つのステップにわたって積み上げながら、GitLab CIの実行モデル(ステージの順序、needsが作るDAG、rulesが分けるジョブ生成の有無、artifactsとcacheの違い)を、自分の手で作ります。最後に、そのモデルを計算する解釈器を自分で組みます。
なぜ重要なのか
このPodにはGitLabサーバーもGitLab Runnerもありません。そのため、このラボはパイプラインが実際に動くふりをしません。代わりに、ランナーがなくても誠実に学べるものだけを扱います。設定言語とその実行モデルです。実務でパイプラインのせいで行き詰まる瞬間は、ほとんどの場合、コマンドを知らないからではなく、なぜこのジョブが作られなかったのか、なぜあのジョブがまだ待っているのかを説明できないからです。その答えはすべて、このファイルの構造から出てきます。採点も、ファイルを目で流し読みするのではなく、YAMLパーサーで読んで構造を確認します。インデントが1つずれてジョブが見当違いのキーの下にぶら下がった設定は、人の目には問題なく見えるのに、実際には動かないからです。
ステップ
/root/glci/.gitlab-ci.ymlを作成してください。トップレベルのstagesにbuild、test、deployをこの順序で置き、build-appジョブをstage: buildにして、scriptを1つ以上書きます。- ジョブを追加して、3つのステージをすべて埋めてください。
lintとunit-testはstage: test、deploy-stagingはstage: deployです。すべてのジョブにscriptが必要で、すべてのstageの値はstagesのリストの中にある必要があります。 .python-baseという隠しジョブを作ってimageとbefore_scriptを入れ、build-app・lint・unit-testの3つのジョブがextends: .python-baseで引き継ぐようにしてください。引き継いだジョブではimageを書き直しません。トップレベルのdefaultにデフォルトのimageを置きます。needsを付けてDAGを作ってください。lintはneeds: []、unit-testはneedsにbuild-app、deploy-stagingはneedsにunit-testを書きます。build-appにはneedsを置きません。deploy-stagingにrulesを付けて、$CI_COMMIT_BRANCH == "main"のときwhen: on_successとし、最後の項目は条件のないwhen: neverで閉じてください。deploy-prodジョブをstage: deployで新しく作ってneedsにdeploy-stagingを書き、同じブランチ条件でwhen: manual・allow_failure: falseとし、やはり条件のないwhen: neverで閉じます。onlyとexceptは使いません。build-appにartifactsを付けて、pathsとexpire_inを書いてください。unit-testのneedsを長い形式に変えてjob: build-app・artifacts: trueとし、deploy-stagingのneedsはartifacts: falseにします。/root/glci/requirements.txtを中身のあるファイルとして作ってください。build-appとunit-testにcacheを付け、keyはfilesでrequirements.txtを指すようにします。build-appはpolicy: pull-push、unit-testはpolicy: pullです。cache.pathsはartifacts.pathsと重ならないようにします。/root/glci/plan.pyを作成してください。引数で受け取った設定ファイルを読んで、{"stages": [...], "waves": [[...], ...]}を標準出力にJSONで出力します。needsがあればその一覧だけを、なければ前のステージのすべてのジョブを待ちます(デフォルトのstageはtestです)。ドットで始まるキーと予約キー(stages・variables・default・include・workflow)は、ジョブではありません。各ウェーブは名前順に並べます。どのジョブも出発できなければ、cycleという語を出力して、0以外の終了コードで終わらせます。最後に、自分の設定に対して実行した出力を/root/glci/plan.jsonに保存します。
参考
- パーサーで確認する習慣をつけると、事故が半分に減ります。
python3 -c "import yaml,sys;print(list(yaml.safe_load(open(sys.argv[1]))))" /root/glci/.gitlab-ci.ymlでトップレベルのキーの一覧を出力してみると、ジョブが消えたかどうかがすぐにわかります。 needs: []とneedsキーがまったくないことは、正反対の意味です。前者は何も待たず、後者は前のステージのすべてを待ちます。rulesは、最初に引っかかった項目1つだけを適用して止まります。条件のないwhen: neverは、必ずリストの一番最後に置きます。- よくあるミスは、断片の名前でドットを忘れて実行されるジョブにしてしまうこと、
extendsで引き継いだのにimageをもう一度書くこと、キャッシュのパスとアーティファクトのパスを同じにしてしまうこと、解釈器の診断メッセージを標準出力に送ってJSONを壊すことです。
ステージの順序と最初のジョブ
/root/glci/.gitlab-ci.ymlを作成してください。トップレベルのstagesにbuild、test、deployをこの順序で置き、build-appジョブをstage: buildにして、scriptを1つ以上書きます。
/root/glci/.gitlab-ci.ymlを作り、トップレベルにstagesのリストを置きます。このリストの順序がそのままデフォルトの実行順序です。その下にbuild-appというトップレベルのキーを置き、stageとscriptをその中にインデントして書きます。採点はgrepではなくYAMLパーサーで読むので、インデントが1つずれただけでも、ジョブが別のキーの下位項目になって失敗します。
3つのステージをすべて埋める
ジョブを追加して、3つのステージをすべて埋めてください。lintとunit-testはstage: test、deploy-stagingはstage: deployです。すべてのジョブにscriptが必要で、すべてのstageの値はstagesのリストの中にある必要があります。
lintとunit-testをtestステージに、deploy-stagingをdeployステージに追加します。同じステージにジョブが2つあれば、その2つは互いに並列で動きます。stagesのリストにない名前をstageに書くと、GitLabはその設定をまるごと拒否するので、綴りを確認してください。すべてのジョブにはscriptが必要です。
隠しジョブとextendsで重複を取り除く
.python-baseという隠しジョブを作ってimageとbefore_scriptを入れ、build-app・lint・unit-testの3つのジョブがextends: .python-baseで引き継ぐようにしてください。引き継いだジョブではimageを書き直しません。トップレベルのdefaultにデフォルトのimageを置きます。
名前がドットで始まるキーは、実行されない断片です。.python-baseにimageとbefore_scriptを入れ、build-app・lint・unit-testの3つのジョブがextendsで引き継ぐようにしてください。引き継いだなら、ジョブでimageを書き直しません。断片を使わないジョブのために、トップレベルのdefaultにデフォルトのイメージを置きます。
needsでステージの壁を越える
needsを付けてDAGを作ってください。lintはneeds: []、unit-testはneedsにbuild-app、deploy-stagingはneedsにunit-testを書きます。build-appにはneedsを置きません。
unit-testはneedsでbuild-appだけを待たせ、deploy-stagingはunit-testだけを待たせます。lintにはneeds: []を与えます。キーがないことと空のリストは正反対の意味なので、空のリストであって初めて、前のステージを1つも待たずに一番前で出発します。build-appにはneedsを置きません。
rulesでブランチ条件と手動承認を掛ける
deploy-stagingにrulesを付けて、$CI_COMMIT_BRANCH == "main"のときwhen: on_successとし、最後の項目は条件のないwhen: neverで閉じてください。deploy-prodジョブをstage: deployで新しく作ってneedsにdeploy-stagingを書き、同じブランチ条件でwhen: manual・allow_failure: falseとし、やはり条件のないwhen: neverで閉じます。onlyとexceptは使いません。
deploy-stagingは$CI_COMMIT_BRANCH == "main"のときwhen: on_success、deploy-prodは同じ条件でwhen: manualにします。2つのジョブとも、最後の項目は条件のないwhen: neverでなければなりません。rulesは最初に引っかかった1つだけを適用するので、条件のない項目を途中に置くと、その下がすべて死にます。only/exceptは使いません。deploy-prodはdeployステージで、deploy-stagingを待ちます。
artifactsと受け取る側を決める
build-appにartifactsを付けて、pathsとexpire_inを書いてください。unit-testのneedsを長い形式に変えてjob: build-app・artifacts: trueとし、deploy-stagingのneedsはartifacts: falseにします。
build-appにartifactsを付け、pathsで渡すディレクトリを書き、expire_inも一緒に書きます。そしてneedsを長い形式(- job: 이름とartifacts: true|false。プレースホルダーはジョブ名です)に変え、アーティファクトを実際に使うunit-testだけをtrueで受け取り、順序だけ待つdeploy-stagingはfalseで切ります。使わないジョブにまでダウンロードさせると、パイプラインが静かに遅くなります。
cacheのキーをロックファイルのハッシュにする
/root/glci/requirements.txtを中身のあるファイルとして作ってください。build-appとunit-testにcacheを付け、keyはfilesでrequirements.txtを指すようにします。build-appはpolicy: pull-push、unit-testはpolicy: pullです。cache.pathsはartifacts.pathsと重ならないようにします。
まず/root/glci/requirements.txtを作ります。キャッシュのキーがハッシュする実際のファイルが必要です。そのあとbuild-appとunit-testにcacheを付け、keyは固定文字列ではなくfilesの形でそのファイルを指すようにしてください。キャッシュを作るbuild-appはpolicy: pull-push、読むだけのunit-testはpolicy: pullです。cache.pathsはartifacts.pathsと重なってはいけません。
パイプラインの解釈器で実行順序を計算する
/root/glci/plan.pyを作成してください。引数で受け取った設定ファイルを読んで、{"stages": [...], "waves": [[...], ...]}を標準出力にJSONで出力します。needsがあればその一覧だけを、なければ前のステージのすべてのジョブを待ちます(デフォルトのstageはtestです)。ドットで始まるキーと予約キー(stages・variables・default・include・workflow)は、ジョブではありません。各ウェーブは名前順に並べます。どのジョブも出発できなければ、cycleという語を出力して、0以外の終了コードで終わらせます。最後に、自分の設定に対して実行した出力を/root/glci/plan.jsonに保存します。
/root/glci/plan.py <설정파일>で呼び出したとき、標準出力に{"stages": [...], "waves": [[...], ...]}を出す必要があります(プレースホルダーは設定ファイルです)。ウェーブの計算ルールは3つです。needsがあればその一覧だけを、なければ前のステージのすべてのジョブを待ちます(デフォルトのstageはtest)。そして、ドットで始まるキーと予約キーはジョブではありません。各ウェーブは名前順に並べます。誰も出発できない状態になったら循環なので、cycleを出力して、0以外のコードで終了してください。診断は標準エラー出力に送らないと、標準出力がJSONのまま残りません。最後に、自分の設定に対して実行した結果を/root/glci/plan.jsonとして保存します。