
Summary
この文書の要点
- 変数名、秘密かどうか、必須条件、利用環境をサービス単位のマニフェストへ集約します。
- 秘密の実値は単一ソースにも生成物にも置かず、Secret Managerなどの注入経路と分離します。
- 生成envの空文字を未注入として正規化し、明示的に緩和した開発環境以外はfail-fastします。
- マニフェストと実行コードを両方向に照合し、未宣言の環境変数参照をCIで止めます。
- 共通env定義の変更を利用サービスの再ビルドと再検証へ確実に波及させます。
- Docker ComposeとTerraformの切り替えは一括で行わず、正規化差分と実stateを使ったplanを検証ゲートにします。
手動同期の箇所が増えると、設定漏れは構造的に起きる
小さな構成では、Docker Composeのenvironment、開発者向けの設定例、Terraformの変数と実行環境への結線、アプリケーション内のprocess.env参照を個別に保守しても大きな負担にはなりません。しかし、サービス数と連携先が増えると、一つの変数を追加するために複数箇所を同じ内容で更新する必要が生まれます。どこか一箇所だけ古い状態でも、レビューでは見つけにくく、実行環境ごとに異なる失敗になります。
さらに厄介なのは、コードが変数の存在を使う瞬間まで確認しない構成です。データベース接続や外部APIのクレデンシャルが欠けていてもプロセスだけは起動し、最初のリクエストや定期処理で初めて失敗します。デプロイの成功とアプリケーションが動作できることが一致しません。
対策の中心は、設定値を一つの巨大なファイルへ集めることではありません。各サービスが必要とする変数の名前と性質を一箇所で宣言し、下流の形式は生成することです。人が同じ情報を複数回書かない状態をつくり、生成を通さない変更はCIで検出します。
変数を、注入される時点と秘密性の3層に分ける
単一ソース化の前に、環境変数を三つの層へ分けます。一つ目は、フロントエンドのビルド時に埋め込む公開設定です。ViteのVITE_接頭辞を持つ値などは配信物から読めるため、クレデンシャルを置けません。二つ目は、実行時に渡す非秘密の設定です。実行モード、公開済みの接続先、バケット名などが該当します。三つ目が、データベース接続情報、署名鍵、外部APIキーなどの実行時の秘密です。
この分類は、保存場所と検証方法を決めます。ビルド時の公開設定はビルドグラフの入力として扱い、実行時の非秘密設定はTerraformやDocker Composeから渡します。実行時の秘密はSecret ManagerからCloud Runなどの実行環境へ注入し、リポジトリ内には実値を残しません。
三層を一つの仕組みで無理に扱うと、公開される値と秘密の境界が曖昧になります。すでに別の一次情報で管理しているビルド時設定は重複して取り込まず、今回のマニフェストは実行時の非秘密設定と秘密のメタデータに範囲を限定します。単一ソースは広いほどよいのではなく、責務が重ならない範囲で一つにすることが重要です。
- build-time: 配信物へ埋め込まれる公開設定。秘密は置かない。
- runtime・非秘密: 実行モードや公開済みの接続先。TerraformやDocker Composeから注入する。
- runtime・秘密: 接続情報や署名鍵。実値はSecret Managerなどから実行時に注入する。
単一ソースには実値ではなく、変数の契約を書く
人が編集する一次情報は、サービス単位のJSONマニフェストにします。各変数には、名前、秘密かどうか、起動時に必須か、利用する環境、秘密ストア上の識別子、開発時に使える明らかなダミー値などを持たせます。秘密の実値はマニフェストに含めません。単一ソース化するのはクレデンシャルそのものではなく、クレデンシャルをどう扱うかという契約です。
サービス単位に分けるのは、変更の影響範囲を狭くするためです。全サービスの変数を一つの巨大なJSONへ入れると、レビューのたびに無関係な差分が混ざり、所有範囲も曖昧になります。共通変数は生成器側で合成できますが、どのサービスが何を必要とするかは個別の宣言として残します。
既存構成から移行するときは、Docker Composeを正規化した出力とTerraformの定義からマニフェストの下書きを生成できます。ただし、変数名から秘密性を推測する処理は初期入力にしか使いません。PASSで終わる値は秘密でも、公開用のIDや公開鍵まで秘密と判定する場合があります。機械が下書きをつくり、人が注入経路と利用コードを照合して確定する役割分担が必要です。
未注入なら、弱い既定値で動かさず起動を止める
認証の署名鍵のような値に、開発を楽にするための固定フォールバックを置くと、本番で注入に失敗した時も既知の鍵で動き続けてしまいます。監視上は正常に見えても、署名を偽造できる状態になり得ます。クレデンシャルの欠落では、可用性を優先したフォールバックが安全性を下げる場合があります。
本番では、必須の秘密が無ければ起動時に例外を投げます。開発やテストでダミー値を許す場合も、明らかに開発専用だと分かる値にし、strictな実行モードでは使えない条件をコードで固定します。環境を判定するフラグ自体が未設定の時に緩い側へ倒れないことも重要です。
zodで共通スキーマとサービス固有スキーマを合成し、エントリポイントの最上流で同期的に検証します。Bunなどで待ち受け前にモジュールを評価する構成なら、ここでの例外はプロセスの起動失敗になります。検証を最初のリクエストより前へ移すことで、設定不備をデプロイ段階で検知できます。
開発用のrelaxedモードも、既定で緩くしてはいけません。開発またはテストであることに加え、明示的なopt-inがそろった時だけ有効にし、未設定、綴り間違い、想定外の値はstrictとして扱います。スキーマを組み立てる時と値を検証する時で参照する環境が異なると判定がぶれるため、環境のsourceはスキーマ生成側から明示的に渡します。
生成したenvファイルでは、秘密値の注入先だけを示すためにNAME=のような空値を出すことがあります。ここでz.string().min(1).optional()をそのまま使うと、空文字はundefinedではなく「存在する不正値」なので、任意項目や開発用既定値を持つ項目まで失敗します。検証前に空文字をundefinedへ正規化し、任意項目は未注入として扱い、必須項目だけを実行モードに応じて判定します。
- 本番の必須secretは、未注入なら起動を失敗させる。
- 開発用ダミー値は用途が明らかな名前と値にし、strictモードでは拒否する。
- 空文字は検証前に未注入へ正規化し、requiredとoptionalの規則を一貫させる。
- 任意のsecretも、実際に使う箇所では未注入を明示的に検査する。
宣言から生成し、CIのcheckでドリフトを止める
生成器はマニフェストを読み、まずzodのサービス別スキーマ、全スキーマを公開するbarrel、Docker Compose向けの環境ファイルを作ります。秘密の項目は名前だけを出し、コミットされる生成物へ実値を入れません。requiredと最小長は起動時検証へ、利用環境や秘密ストア上の識別子は後続のインフラ生成へ渡せる形に保ちます。
生成物をリポジトリへ含める場合は、生成コマンドとcheckコマンドを対にします。checkは一時的に再生成した内容とコミット済みファイルを比較し、差分があればunified diffを表示して失敗します。生成物の直接編集や、マニフェスト変更後の再生成忘れをレビュー前に検出できます。
スキーマの所有範囲も固定します。NODE_ENVやPORTのような全サービス共通の項目は共通スキーマだけが持ち、生成されたサービス別スキーマでは再定義しません。一方、DATABASE_URLのように利用先が限られる項目は、必要なサービス側でだけ必須にします。共通スキーマ、生成スキーマ、手書きの追加制約を合成する順序も決め、同名キーが意図せず上書きされないようにします。
ジェネレーターの入口では、未知のフィールド、変数名の重複、秘密の実値らしい文字列、利用範囲外のローカル値、弱すぎる開発用ダミー値を拒否します。マニフェストから削除されたのに残る孤立生成ファイルもcheckの対象です。単一ソースの誤りは全生成先へ広がるため、入力と生成後の両側で止めます。
ここで、すべての下流を一度に切り替える必要はありません。起動時スキーマとローカル用テンプレートを先に生成し、Terraformの結線や開発者向け設定例は、挙動の等価性を確認できるものから消費側として追加します。単一ソースの土台と、既存システムがそれを使う移行は別の完了条件です。
宣言と実行コードを両方向に照合する
マニフェストを持つだけでは、実行コードが参照する環境変数を網羅できません。実行対象のサービス一覧を正典として、各サービスに環境変数の所有者、別名として共有する関係、実行時設定を持たないことのいずれかを明示します。これにより、新しいサービスを追加したのにマニフェストが無い、という抜けを止められます。
次に、ビルドカタログからバックエンドとworkerのエントリポイントをたどり、first-partyのimport閉包にある環境変数参照を収集します。静的解析でprocess.envなどの参照とマニフェストを照合し、未宣言の名前や動的な参照を検出したら失敗させます。実装が先に変わっても、宣言が先に変わっても差を見つけられるため、片方向の台帳チェックより強い検証になります。
この照合は生成器のテストだけに置かず、生成ドリフトのcheck、マニフェストの網羅検査、実行コードの参照検査をCIで必須にします。静的解析だけでは値が実際に注入されるかまでは分からないため、後段の起動smokeと組み合わせることが前提です。
また、共通envパッケージやマニフェストを変更した時に、CIが利用サービスを再ビルドしなければ、リポジトリ内の宣言だけが新しく、実行イメージは古いまま残ります。変更ファイルから検証対象を選ぶ仕組みには、共通env定義から利用サービスへの依存関係を含めます。単一ソースの影響範囲を、生成だけでなくビルドと起動確認まで伝播させる必要があります。
- すべての実行対象サービスに、マニフェストの所有者・別名・設定不要のいずれかを明示する。
- エントリポイントとimport閉包から実際の環境変数参照を集め、宣言と照合する。
- 動的な参照や未宣言の変数は、レビュー任せにせずblockingな検査で止める。
- 共通env定義の変更時は、すべての利用サービスを再ビルドして起動確認する。
requiredの過剰指定と、共有依存の広がりを避ける
fail-fastを導入すると、すべてのクレデンシャルをrequiredにしたくなります。しかし、外部連携の秘密は、その機能を使うサービスにだけ条件付きで注入されることがあります。一律に必須へ格上げすると、現在はその機能を使わないサービスまで起動できなくなります。requiredにする前に、すべての対象環境で無条件に注入されているかを確認し、迷う値はoptionalと使用時検査の組み合わせから始めます。
共通の検証パッケージをどこから読むかも影響範囲を左右します。依存グラフの深い共有モジュールへ新しいパッケージ依存を加えると、そのモジュールを使う全サービスのDockerfileやインストール手順まで変更が波及する場合があります。共通化のために別の多点手作業を増やしては意味がありません。
そのため、検証パッケージはアプリケーションのエントリに寄せ、深い共有モジュールでは引数として検証済みの値を受け取る設計が扱いやすくなります。例外的に共有モジュール内で検査が必要なら、小さな使用時検査を残します。抽象化の統一より、依存の連鎖を増やさないことを優先する判断です。
Docker Composeは、正規化した設定の差分で段階移行する
Docker Composeのenvironmentには、ホストの環境変数があれば引き継ぎ、無ければ空にする指定が含まれることがあります。生成したenv_fileへ秘密の項目を空値で書き、既存のenvironment指定をまとめて置き換えると、ホストから渡していた値まで空で上書きする可能性があります。environment、env_file、シェル、.envには優先順位があるため、ファイルの見た目だけでは等価性を判断できません。
移行は、ホストからの引き継ぎを持たないサービスから始めます。変更前後にdocker compose configで正規化したサービス設定を出力し、環境変数の集合と値を比較します。差分がないことを確認できたサービスだけenv_fileへ移し、ホストから引き継ぐ変数はenvironmentへ残して併用します。
この確認は、生成器の単体テストでは代替できません。実際に使うDocker Composeの版、.env、シェル環境を含めて最終設定を比較する必要があります。一括置換よりも、等価性を証明できる単位で小さく進める方が、開発環境を壊した時の切り戻しも容易です。
Terraformの共通化は、resource addressを保つことから始める
Terraformでサービスごとの環境変数とSecret Managerの結線をmapとfor_eachへ畳むと、重複は大きく減ります。一方、既存のresourceをmoduleやfor_eachの配下へ移すとresource addressが変わります。Terraformが同じ実体だと認識できなければ、削除と再作成のplanになり、実行環境や秘密の結線を停止させるおそれがあります。
既存resourceを保持するには、変更前後のaddressをmovedブロックで対応づけます。その上で、実際のstateと認証情報を使ったterraform planが、移動以外の変更を含まないことを確認します。terraform validateが通ることは構文の確認であり、既存resourceを再作成しない保証にはなりません。
この作業は、ローカルでstateへ到達できないなら未完了として残します。一つのresourceまたは一つのサービスごとにmovedブロックとplanを確認し、異常があれば従来の定義へ戻せる順序で進めます。生成器が完成していても、実stateに対する空のplanを確認するまでは本番移行済みとは扱いません。
検証は、生成の整合性と実行環境の等価性を分ける
生成の層では、マニフェストのJSON Schema検証、変数名の重複検出、秘密項目に実値らしい文字列がないこと、孤立生成ファイルがないこと、生成スキーマを合成したzodテスト、checkによるドリフト検出を行います。strictと明示的にopt-inする開発用relaxedを分け、必須secretの欠落、空文字、最小長違反、任意secretの未設定をそれぞれテストします。
実行環境の層では、Docker Composeの正規化差分、コンテナの起動smoke、Secret Managerからの注入権限、Terraformのplanを確認します。特に、検証スキーマが正しくても実行環境から変数が渡らなければ起動できません。宣言、生成、注入、起動のどこで失敗したかを分けて観測できるログも必要です。
また、自動分類のheuristicとoptional指定は定期的に棚卸しします。機能追加によって、以前は任意だったsecretが無条件に必要になる場合もあります。単一ソース化は一度の移行作業ではなく、現実の注入経路と宣言が一致しているかを継続的に検査する運用です。
- 宣言: スキーマ、重複、秘密の実値混入を検査する。
- 生成: 再生成差分、zodスキーマ、strictとrelaxedの挙動を検査する。
- 注入: Composeの正規化差分、Secret Managerの権限、Terraform planを確認する。
- 起動: 最小設定でプロセスを開始し、待ち受け前のfail-fastと正常時のヘルスチェックを確認する。
単一ソース化で持ち帰る判断軸
環境変数の単一ソース化で重要なのは、すべての値を同じ場所へ保存することではありません。人が編集する契約を一つにし、秘密の実値は外部の秘密ストアに残し、利用先ごとの形式を生成することです。宣言、生成、注入を分けると、どこに何を書くべきかが明確になります。
また、起動時検証は強くしすぎても既存環境を壊します。requiredは無条件に注入される値へ限定し、条件付きの秘密は使用時検査を重ねます。Docker ComposeやTerraformの移行は、生成できたことではなく、変更前後の設定やplanが等価であることを完了条件にします。
同じ宣言を複数箇所へ手で写しているなら、単一ソースと生成器は有効です。ただし、その効果を保つのは、空値まで含めたスキーマの契約、実コードとの照合、利用サービスへ波及するCIのcheckです。生成物を手編集しないこと、実環境で確認できない変更を完了扱いにしないことが、環境変数管理を長く維持するための実践的な原則になります。


