再現可能なビルドが先だ
一言でいうと
CIはビルドを代わりに押してくれるロボットではなく、同じ入力なら、いつどこで実行しても同じアーティファクトが出るように強制する、再現性のための仕組みです。
なぜ必要なのか
手作業でビルドしていた時代の事故は、いつも同じ形でした。自分のノートPCでは動くのにサーバーでは動かず、昨日作ったイメージと今日作ったイメージが違うのに、何が変わったのか誰も説明できませんでした。原因はたいてい「固定していないもの」にあります。依存関係のバージョンを固定していない、アクションやベースイメージをタグだけで参照している、アーティファクトの名前に何から作ったのかが残っていない、といったことです。
タグは人が付け替えられるラベルです。tj-actions/changed-filesのサプライチェーン攻撃では、攻撃者がアクションのタグを悪意のあるコミットに付け替えました。リポジトリに侵入したのではなくラベルを移しただけなのに、そのタグを参照していた多くのパイプラインがそのまま悪意のあるコードを実行しました。だからルールは短く済みます。タグは悪意をもって変更できますが、コミットSHAは変更できません。アクションとイメージは、タグではなくコミットSHAまたはダイジェストで固定します。
本番環境でlatestを使うと、2つを同時に失います。どのバージョンがデプロイされたのか追跡できなくなり、障害が起きたときに戻す対象そのものがなくなります。だからデプロイに使うタグは必ず不変でなければなりません。コミットSHAやセマンティックバージョンのように、一度決まったら別のものを指さない値である必要があります。
どう動くのか
再現可能なビルドは、4つの軸で作ります。
- 入力の固定。ロックファイルは必ずコミットし、CIではnpm ci、--frozen-lockfile、-lockfile=readonlyのように、ロックファイルを更新せず書かれたとおりにインストールするコマンドを使います。通常のインストール系コマンドはロックファイルをこっそり更新し、実行のたびに異なる依存関係ツリーを作ります。
- 出力の識別。アーティファクト名とイメージタグに、コミットごとに変わる不変の値を入れます。latestはその上に重ねるエイリアスにすぎず、識別子ではありません。
- キャッシュ。キャッシュキーにロックファイルのハッシュを入れると、依存関係が変わったときにキーが自然に変わり、自動で無効化されます。
${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}にrestore-keysの接頭辞チェーンを付ける形が標準です。効果的なキャッシュキーの戦略はビルド時間を50–70%短縮します。あるチームの実測では、CIビルドの平均が28分から8分へと71%短くなりました。ただしキャッシュストレージ1つあたりの上限は10GBで、超えると古いものから追い出されるため、スコープをコミット単位で細かく分けすぎると、キャッシュ同士が互いを押し出してヒット率がかえって下がります。キャッシュは信頼境界でもあります。フォークからのPRが悪意のある依存関係をキャッシュに注入すると、以降のビルドがそのキャッシュをそのまま使うキャッシュポイズニングが成立します。 - 失敗の扱い。マトリックスビルドのfail-fastの既定値はtrueなので、1つが失敗すると残りはキャンセルされます。速いフィードバックが大事ならtrue、全体の互換性確認が大事ならfalseに変えます。並行実行の制御を設定しないと、連続してPushしたときに複数のデプロイJobが同時に実行され、ロールバックが複雑になる事故が起きます。
現場での姿
障害の振り返りで最もよく出る言葉が「あのとき本番にあったのは、正確には何だったのか」です。イメージタグがlatestだけだと、この問いに誰も答えられません。逆にタグにコミットハッシュが入っていれば、レジストリとGitのログだけで5分以内に答えが出ます。
数字で管理しているチームは、目標を次のように置きます。リードタイム1時間以下、デプロイ頻度1日10回以上、変更失敗率5%以下、MTTR 30分以下、パイプライン実行15分以下、カバレッジ80%以上。このうちパイプラインの実行時間が15分を超え始めると、人はCIの結果を待たずに別の作業へ移ってしまい、フィードバックループが丸ごと崩れます。
同じ入力なら同じ結果が出るビルド
CIを初めて導入しても、「自分のパソコンでは動いたのに」が「CIでは動いたのに」に変わるだけです。 その余地をなくすことが、ビルド自動化の本当の目標です。
バージョンを固定しないと、昨日の成功が今日を保証しません。npm installとnpm ciの違いがここにあります。前者はpackage.jsonの範囲内で最新のものを取得するので、ロックファイルを更新してしまいます。後者はロックファイルと違っていると、そもそも失敗します。CIでは失敗するほうが正しい動作です。Pythonのpip install -rも、ハッシュを書いた要件ファイルと--require-hashesを一緒に使ったときだけ、同じ性質が得られます。
ベースイメージのタグもバージョンです。FROM python:3.12は明日には別のものを指しているかもしれません。ダイジェストで固定すれば再現できますが、セキュリティ更新を受け取れなくなります。そのため、ダイジェストで固定することと、更新を自動化することは一緒に進める必要があります。固定だけして更新しないと、数か月後に脆弱性の一覧が長くなります。
キャッシュは正確なときだけ得になります。キーにロックファイルのハッシュを入れます。
key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: deps-${{ runner.os }}-
キーが緩すぎると、古い依存関係のまま通るビルドが生まれます。これはキャッシュがないよりも悪い状況です。失敗すべきものが成功してしまうからです。
ビルドのアーティファクトは一度だけ作ります。開発環境にデプロイするときに1回ビルドし、本番環境にデプロイするときにもう一度ビルドすると、テストしたものとデプロイしたものが別物になります。一度作ってレジストリに上げ、以降のステップでは同じダイジェストをプロモーションします。タグは人が読むためのラベルにすぎず、同じものであることを保証するのはダイジェストです。
失敗したビルドの証拠を残します。ログだけ残すと、再現しようとして同じビルドをもう一度実行することになります。テストレポート、カバレッジ、生成された設定ファイルをアーティファクトとして上げておけば、失敗したその瞬間の状態をそのまま見られます。再実行すると消えてしまうものが、たいてい原因です。
次のラボですること
このPodには、JenkinsもGitHub Actionsのランナーもありません。そこで、build → test → packageの3ステップをシェルスクリプトで自作します。大事なのはベンダーではなく仕組みだからです。ソースのハッシュで名前を付けたアーティファクトを1つだけ作り、同じ入力で再実行したら再利用し、最後にハッシュタグとlatestを同じイメージに付けて、何が不変の識別子で何が動くエイリアスなのかを目で確認します。