ビルド・CI環境の再現性設計 — 使い捨てランナーで回すか共有環境に置き続けるか

CI/CDにおけるビルド環境の再現性問題は、「ローカルでは通るのにCIで落ちる」という現象の根本に潜んでいます。判断の核心は、使い捨てランナーによる毎回の完全初期化と、共有環境での高速な繰り返し実行のどちらを優先するかにあります。この選択は「どちらが優れているか」ではなく、プロジェクトの規模・セキュリティ要件・チームの運用コスト許容範囲によって答えが変わる設計判断です。本記事では、環境汚染の実態から始め、分離方式の比較・依存管理の置き場所・キャッシュ設計・シークレット注入、そしてローカルとCIの乖離許容度まで、構築担当者が直面する判断ポイントを体系的に整理します。

環境の汚染がビルドに影響する問題の実態と再現性の定義

環境汚染とは何か——「その場限りの成功」が積み上がる仕組み

ビルド環境の汚染とは、以前のジョブや手動操作が残したグローバルなファイル・環境変数・プロセスが後続のビルドに影響を与える状態を指します。共有ランナー上で複数のブランチが交互にビルドを走らせている現場では、あるブランチが書き込んだパッケージキャッシュや設定ファイルが別のブランチのビルドに混入することがあります。20台規模のオンプレランナーを運用しているチームでは、「先週まで通っていたテストが突然落ちる」という現象が月に3〜4回発生し、調査の結果、前のジョブが残したnpmのグローバルパッケージが原因だったことが判明した、という話を現場でよく聞きます。汚染の問題は個別の失敗ではなく、設計上の構造問題として捉えることが重要です。

再現性の定義——「同じコードは同じ結果を返す」の技術的意味

再現性の定義はシンプルに見えて、実装するには複数の次元で担保が必要です。「同じコードを同じ手順でビルドすれば、いつ・どのマシンで実行しても同じ成果物が得られる」という状態が再現性の目標です。これを達成するには、OS・ランタイム・依存ライブラリのバージョンが固定されているだけでなく、ビルド実行時のファイルシステムの状態やネットワークアクセスの有無まで制御下に置く必要があります。特に見落とされがちなのは「時刻依存の動作」で、ビルド時にタイムスタンプを埋め込むコードがあると、同じコミットから得られる成果物のハッシュ値が異なる結果になります。

汚染リスクの類型と影響範囲の見極め方

汚染リスクは大きく四つに分類できます。一つ目はファイルシステム汚染——前のジョブが残したキャッシュ・ログ・テンポラリファイルによるもの。二つ目は環境変数汚染——前ジョブが書き換えた変数が次のジョブに引き継がれるケース。三つ目はプロセス残留——デーモンやバックグラウンドプロセスがリソースを占有し続ける状態。四つ目はネットワーク依存汚染——外部レジストリの一時的な変化や、DNS解決の結果がランナーごとに異なるケースです。これらの影響範囲は、ランナーの分離レベル——物理マシン・VM・コンテナ・プロセスのどこで境界を引くか——によって大きく変わります。

コンテナビルダーとVM型ランナーの環境分離方式比較

コンテナベース分離の強みと限界

Dockerコンテナをビルド実行単位とする方式では、各ジョブが独立したファイルシステムを持つため、ファイルシステム汚染はほぼ防げます。起動時間も数秒以内に収まるケースが多く、高頻度のビルドに向いています。ただし、コンテナ内からDockerデーモン自体に接続するDocker-in-Docker構成を取ると、特権モードが必要になりセキュリティ境界が薄くなるというトレードオフがあります。また、コンテナはカーネルをホストと共有するため、カーネルレベルの挙動に依存するビルド(特定のsyscallを使うコードやカーネルモジュールのビルドなど)では完全な分離が難しい場面もあります。

VM型ランナーの分離保証とコスト設計

VM型ランナーは各ジョブに独立した仮想マシンを割り当てるため、カーネルまで含めた完全な分離が得られます。ジョブが終了すると環境ごと破棄されるこの「使い捨て」の保証は、再現性の観点では最も強力な選択肢です。一方、VMの起動には30秒から数分かかるケースがあり、1日に数百回ビルドを走らせるような規模では、起動待ち時間の合計が無視できなくなります。セルフホスト型のVM型ランナーをオンプレで構築する場合は、VMイメージの管理・更新サイクルと、同時実行数に応じたランナーの台数設計が重要な判断ポイントになります。

分離方式の選定基準——どのリスクを優先的に遮断するか

分離方式 環境汚染遮断 起動速度 カーネル分離 主な採用ケース
プロセス分離(共有OS) 低 最速(数秒以下) なし 小規模・内部テスト限定
コンテナ(Docker等) 中〜高 速い(数秒) なし(カーネル共有) 一般的なアプリビルド・テスト
マイクロVM(Firecracker等) 高 速い(1秒以内も可) あり セキュリティ要件が高い場合
フルVM(qemu/KVM等) 最高 遅い(30秒〜数分) あり カーネルビルド・ドライバ開発

分離方式の選定では「どのリスクを優先的に遮断したいか」を起点にするのが合理的です。一般的なWebアプリやマイクロサービスのビルドであればコンテナ分離で十分なケースが多く、カーネルドライバや組み込みシステムのビルドを扱う場合はVM型が現実的な選択になります。マイクロVM(Firecrackerなど)は起動時間とカーネル分離を両立できる方式として注目されていますが、運用ノウハウの蓄積が必要です。

依存バージョンをランナー側に持つかコード側に持つかの判断

ランナーイメージに焼き込む方式の利点と更新コスト

Node.jsのバージョンやPythonのランタイム、コンパイラのバージョンをランナーイメージに事前インストールしておく方式は、ビルドの起動が速く、依存の解決がジョブ実行時に発生しないという利点があります。マイクロサービスを10サービス以上抱えるチームがこの方式を取ると、全サービスが共通のランナーイメージのバージョンに縛られます。あるサービスだけNode 18から20に上げたい場合、ランナーイメージの更新とサービスの更新を協調させる必要が生じ、リリースの足並みを揃えることが難しくなる、という問題を抱えた現場はよくあります。

コードリポジトリ側で依存を宣言する方式の設計思想

`.tool-versions`(asdf)や`mise.toml`、あるいはDockerfileをリポジトリに含めてビルド時に参照する方式では、「依存のバージョンがコードと一緒にバージョン管理される」という強みがあります。ランタイムのバージョンを変えたければ、プルリクエストで変更・レビュー・マージするという通常の開発フローに乗せられます。ただしこの方式では、ビルド時に毎回ランタイムをインストールするとビルド時間が伸びるため、キャッシュ戦略との組み合わせが必須です。ランナーイメージは最小限のベースにとどめ、言語ランタイムはリポジトリ側で管理するハイブリッドが多くの現場での落とし所になっています。

どこで線を引くか——OS・ランタイム・アプリ依存の責務分離

依存の置き場所を整理するには「OS層・ランタイム層・アプリ依存層」の三層で責務を分けて考えると判断しやすくなります。OS層(glibcのバージョン・カーネルのAPI互換性)はランナーイメージが担保し、ランタイム層(言語バージョン)はリポジトリ側の宣言ファイルで管理し、アプリ依存層(パッケージ・ライブラリ)はロックファイルで固定する——この三層構造が、更新の自由度と再現性のバランスをとりやすい設計です。ランナーイメージの更新頻度は、OSのセキュリティパッチ対応として月次程度に抑え、それ以上の頻度の変更はリポジトリ側で吸収するのが運用コストの観点からも安定しています。

ビルドキャッシュの保持方式とキャッシュ爆発の境界設計

キャッシュの保持場所ごとの特性と選択基準

ビルドキャッシュをどこに置くかで、ヒット率・転送コスト・整合性リスクの三者がトレードオフになります。ランナーローカルのディスクに置く方式はアクセスが最速ですが、使い捨てランナーでは次回のジョブで引き継げません。リモートキャッシュ(S3互換ストレージやGitHub Actions Cacheなど)に書き出す方式では、どのランナーが担当しても同じキャッシュにアクセスできる反面、ネットワーク転送時間が加わります。Dockerのレイヤーキャッシュをレジストリにプッシュして再利用する方式は、コンテナイメージのビルドに特化した強力なキャッシュ戦略ですが、キャッシュの有効期限とストレージコストの管理が伴います。

キャッシュキーの設計——細かすぎてもヒットしない、粗すぎても汚染する

キャッシュキーの粒度設計は見落とされやすい判断ポイントです。ロックファイルのハッシュをキャッシュキーにすると、依存が1つでも変わるたびに全依存を再インストールするコールドスタートが発生します。一方、ブランチ名だけをキーにすると、異なる依存セットのブランチが同じキャッシュを参照してしまう汚染リスクがあります。多くのケースでは「ロックファイルのハッシュ + フォールバックとしてプレフィクスキー」という二段構えが有効です。プレフィクスキーは完全ミス時のコールドスタートを避けつつ汚染リスクを抑えるための設計で、ヒット率と整合性の両立を図れます。

キャッシュ爆発の境界設計——いつ捨てるかを先に決める

キャッシュは積み上がるものであり、削除の設計を後回しにするとストレージが膨張します。現場では「半年前に追加したキャッシュエントリがストレージの70%を占めていた」という事例もあります。境界設計のアプローチとしては、TTL(有効期間)によるキャッシュの自動失効と、サイズ上限によるLRU削除の組み合わせが現実的です。長期間使われないブランチのキャッシュを定期的に削除するワークフローを別途組んでおく設計も、安定運用につながります。キャッシュの設計は「どう保持するか」より「どう捨てるか」を先に決めることが原則です。

シークレットをビルド環境にどう注入し、どこで破棄するか

シークレットの注入タイミングと漏洩リスクの関係

シークレット(APIキー・証明書・署名キーなど)をビルド環境に注入するタイミングは、「必要な最小スコープ」で絞り込む設計が基本です。ビルドプロセス全体を通じて環境変数として保持する方式は実装が簡単ですが、ビルドログへの意図しない出力・デバッグツールによる漏洩・成果物への埋め込みといったリスクが高くなります。署名処理やレジストリへのプッシュなど、シークレットが必要なステップを独立したジョブとして分離し、そのジョブの実行時間だけシークレットを保持する設計が、リスクの最小化につながります。

シークレットの破棄設計——キャッシュへの混入防止

ジョブ終了後のシークレットの破棄は、使い捨てランナーであれば環境ごと消えるため自動的に達成されます。共有ランナーを使う場合は、ジョブの終了フックでの明示的なunset処理や、キャッシュ対象ディレクトリからシークレットを含むファイルを除外する設定が必要です。特に注意が必要なのはビルドキャッシュへの混入で、シークレットを参照して生成した中間ファイルがキャッシュに含まれ、別のブランチやフォークのビルドで参照されるケースです。キャッシュ対象のパスは明示的に列挙する方式にして、デフォルトで全部キャッシュするような設定は避けるのが安全側の設計です。

シークレット管理の外部化と注入方式の選択

シークレットをCIプラットフォームの設定画面に直接登録する方式は手軽ですが、ローテーション・監査ログ・アクセス制御が各CIプラットフォームの機能に依存します。規模が大きくなると、専用のシークレット管理基盤からビルド時に動的取得する方式が選ばれます。この方式ではシークレット自体はCIプラットフォームに保存せず、取得するための認証情報(短命のIAMロールや動的トークン)だけを渡すため、シークレットの有効期限を細かく制御できます。どちらの方式を選ぶかは、扱うシークレットの数・ローテーション頻度・コンプライアンス要件によって判断します。

ローカル開発と本番CIの乖離をどこまで許容するか

乖離が生まれる構造的な理由

ローカル開発環境とCI環境の乖離は、放置すれば広がり続ける性質があります。開発者のマシンには長年の作業で蓄積した設定・ツール・バージョンが存在し、新しいメンバーが加わるたびに「自分の環境でだけ動かない」という問題が発生します。一方、CIは自動化されているため、誰かが意図的に更新しなければ特定のバージョンに固定されたまま残ります。その結果、ローカルでは新しいランタイムで開発が進み、CIでは古いランタイムが動いているという「二重管理」状態に陥りやすくなります。

乖離の許容範囲をどう設定するか

乖離をゼロにしようとするのは現実的ではありません。ローカルにはIDEの統合・デバッガの接続・ホットリロードなど、CI環境にはないツールが必要であり、完全に同一の環境にはなりません。判断の基準は「ビルドの再現性に影響する変数」を乖離させないことです。具体的には、OS・言語ランタイム・ビルドツールのバージョンをリポジトリのファイルで宣言し、ローカルとCIの両方がそれを読んで環境を構築するフローを整備することで、乖離を許容可能な範囲に抑えられます。DevContainerやDockerベースのローカル開発環境を整備している現場では、ローカルとCIで共通のコンテナイメージを使うことで乖離を構造的に排除しているケースもあります。

乖離の検出をCI設計に組み込む方法

乖離は気づかないうちに広がるため、検出の仕組みをCI設計に組み込んでおくことが重要です。一つの手法は、ローカル環境の設定ファイルとCI設定の依存バージョン記述が一致しているかをCIで検証するジョブを追加することです。別のアプローチは、ビルドの全ステップをコンテナ内で実行し、そのコンテナをローカルでも起動できるようにすることで「ローカルでCIの動作を完全再現できる状態」を担保する方法です。乖離の許容範囲を決めたら、それを超えた場合に検出できる仕組みを同時に設計するのがセットです。

落とし穴 — 「ローカルで動く」がCIで壊れる設計パターン

依存の暗黙解決に頼った設計

最もよく踏む落とし穴は、ローカルにグローバルインストールされているツールをCI環境でも「あるはず」と仮定する設計です。特定のCLIツールが開発者全員のマシンにインストール済みで問題なく動いていた結果、Dockerfileやランナーの依存宣言から抜け落ちたまま数ヶ月が過ぎ、新しいランナーイメージに更新したタイミングで突然落ちる、というケースは頻繁に発生します。構築を任されたある担当者が「ローカルで動くのになぜCIで落ちるのか」と数時間費やした末にこの原因を突き止めた、という話は珍しくありません。「ビルドに必要な全依存はリポジトリ内で宣言されていなければならない」という原則をチームで合意しておくことが予防の核心です。

タイムゾーン・ロケール依存のビルド

ローカルのタイムゾーンやロケール設定に依存したテストや日時処理がCIで失敗するケースは、見逃されやすい落とし穴です。開発者のローカル環境が日本時間(JST)で動作しており、CIがUTCで動作している場合、日付の境界に関わるテストが実行時刻によって成否が変わります。ロケール依存のソートや文字列比較が含まれるテストも同様で、CIのLinuxコンテナがC.UTF-8で動作しているのに対してmacOSのローカル環境がja_JP.UTF-8で動作していると、文字列のソート順が異なる結果になります。これらは「テスト自体の問題」として扱われがちですが、本質は「環境依存の前提をコードに持ち込んでいる」という設計の問題です。

設計判断チェックリスト——CI再現性の担保に向けて

  • ビルドに必要な全依存(ランタイム・CLIツール・コンパイラ)はリポジトリ内のファイルで宣言されているか
  • ランナーの分離方式(コンテナ・VM)はセキュリティ要件と起動時間の許容範囲から選定されているか
  • 依存バージョンはOS層・ランタイム層・アプリ依存層の三層で責務が分離されているか
  • キャッシュキーにはロックファイルのハッシュが含まれ、フォールバックキーが設定されているか
  • キャッシュの削除ポリシー(TTL・サイズ上限)が事前に設計されているか
  • シークレットは必要な最小スコープのジョブでのみ注入されているか
  • シークレットを含む中間ファイルがキャッシュ対象から除外されているか
  • ローカルとCIの依存バージョン宣言の一致を検証するジョブが存在するか
  • タイムゾーン・ロケール依存のコードがCI環境の設定に依存しない形で書かれているか
  • 「ローカルでCIを完全再現する手順」がリポジトリのドキュメントに記載されているか

設計の落とし穴の多くは、個別のツールや設定の問題ではなく「前提が明文化されていない」ことに起因します。このチェックリストをコードレビューやオンボーディングの起点にして、暗黙の前提をコードとドキュメントに落とし込む習慣が、CI再現性を長期的に保つ最も確実な手段です。手を動かす具体的な設定手順については、各ツールの専門書や実践の場に譲りますが、判断の軸は本記事で整理した構造で対応できます。

▶ 手を動かして本番まで到達する

設計の型は掴めても、本番でTerraformを通しきる経験は別物です

ここまでの設計判断は、読めば腹に落ちます。ですが「自分の手で本番まで通しきる自信」は、それとは別の話になります。

2日間の集中ハンズオンで、AWS上にIaCで構築しきる経験を持ち帰れます。設計判断を、動くコードと本番環境の手応えに変える場です。

再現性のあるCI環境をIaCで設計するTerraformハンズオンへ →

Terraformマスターセミナー|2日間ハンズオンでAWS上にIaCで本番構築