artifactsとcacheは別の物だ
一言でいうと
artifactsは、ジョブとジョブの間を渡っていくアーティファクトなので、なければ後ろのジョブが失敗するのが正しく、cacheは、実行と実行の間で時間を節約する一時的な資産なので、なくてもすべてのジョブがそのまま動くのが正しいです。
なぜ必要なのか
どちらも「ディレクトリを指定するとどこかに保管されて、あとで戻ってくる」ように見えます。そのため、最初は適当に使ってしまい、しばらくして2種類の事故が起きます。
キャッシュをアーティファクトのように使ったチームは、ある日、デプロイジョブが空のディレクトリをデプロイします。キャッシュは保証ではなく最適化なので、ランナーが変わったり期限が切れたりすると単に空になり、そのときパイプラインは失敗せず、何もない状態のまま静かに成功します。反対に、アーティファクトをキャッシュのように使ったチームは、ストレージ容量の警告を受けます。有効期限を決めていないアーティファクトが、コミットのたびに積み上がるからです。
どう動くのか
artifactsは、ジョブが終わるときに指定したパスをGitLabサーバーにアップロードします。そして、後ろのジョブが始まるときにダウンロードします。ここで重要なのは誰がダウンロードするかですが、デフォルトが「前のステージすべて」なので、何も設定しないと、デプロイジョブが使いもしないテストレポートまですべてダウンロードします。パイプラインが遅くなるよくある理由がこれです。
受け取る側を絞る方法は、needsの長い形式です。needsを文字列のリストの代わりに{job: build-app, artifacts: true}の形で書けば、ジョブごとに受け取るかどうかを切れます。順序だけ待てばよくてファイルは要らないジョブには、artifacts: falseを明記します。そしてexpire_inは選択ではなく、事実上必須です。ロールバック対象になるリリースのアーティファクトだけを長く置き、残りは数日と短くします。
cacheは違います。ジョブが始まるときにキーに合う圧縮ファイルをダウンロードして展開し、終わるときにもう一度アップロードします。すべて最適化なので、失敗してもジョブは続行します。そのため、キャッシュ設計の核心は何を入れるかではなく、キーを何で作るかです。
キーを固定文字列にすると、依存関係が変わっても同じキーを使い続けて、古いキャッシュを引きずり回します。そこで、ロックファイルのハッシュをキーに入れます。key: {files: [requirements.txt]}のように書くと、そのファイルが変わったときにキーがひとりでに変わり、キャッシュが自動的に無効化されます。ここにpolicyを添えると、さらにもう一段節約できます。キャッシュを作ってアップロードするジョブ1つだけをpull-pushにし、残りの利用者はpullにすれば、利用者が終わるたびに同じ内容を再び圧縮してアップロードする時間が、まるごとなくなります。
最後に、2つの一覧が重なってはいけません。同じディレクトリをアーティファクトとしてもキャッシュとしても管理すると、どちらが最新なのか誰にもわからなくなり、その混乱は再現が困難です。
現場での姿
キャッシュ関連の事故のうち、最も危険なのは性能ではなく信頼です。フォークから来たマージリクエストが悪意のある依存関係をキャッシュに仕込んでおくと、そのあとのビルドがそのキャッシュをそのまま持ってきて使います。キャッシュポイズニングと呼ばれるこの問題のため、信頼境界を越えるキャッシュはスコープを分離します。
容量の面も現実的な制約です。キャッシュの保存領域には上限があり、超えると古いものから押し出されます。そのため、コミットごとに新しいキャッシュを作るようにキーを細かく刻みすぎると、キャッシュ同士が互いを押し出し合い、ヒット率がかえって下がります。キーは再利用の単位で決めなければなりません。
次のラボですること
ここまで読んだことを、設定ファイル1枚で自分で積み上げます。ステージと最初のジョブから始めて、隠しジョブとextends、needsで作るDAG、rulesのブランチ条件と手動承認、artifactsとcacheまで、8つのステップで付け足していきます。採点は、ファイルを目で流し読みするのではなく、YAMLパーサーで読んで構造を確認します。最後のステップでは、パイプラインの解釈器を自分で組み、初めて見る設定でも、ジョブが何番目のウェーブで出発するかを計算し、needsの循環を見つけ出します。