環境変数のSingle Source of Truthを見つける旅

17 min

はじめに

入社後初めて直面した環境変数の管理状況は、まさにカオスだった。

GitHub Secret、Google Sheet、Azure Key Vault…環境変数があちこちに分散しており、新しいプロジェクトをセットアップするたびに、チームメンバーがオンボーディングするたびに「この環境変数はどこから取得すればいいですか?」という質問が繰り返された。SlackのDMで.envファイルがやり取りされ、誰かは古い値を使っており、ステージごとにどの値が正しいのか誰も確信が持てなかった。

特に弊社はNext.jsを活用しているため、サーバーサイドでサードパーティAPIを直接呼び出したり、Identityサービスを通じてユーザーを操作するロジックもあった。さらに、クライアントごとにランタイムで環境を切り替える必要があるケースも多かった。リモートワークも混ぜながら働いていたため、ローカル環境を素早く構築し、チーム全体が同期された状態を維持することがますます切実になっていった。

この記事は、そのカオスから出発して*Single Source of Truth(SSOT)*を確立するまでの旅を記録したものである。

google sheetgithub secretazure key vault
Google Sheetに分散した環境変数GitHub Secretに保存された環境変数Azure Key Vault環境変数

さらに、Azure Key Vaultへのアクセス権限すらなく、権威的なDevOps担当者の態度は新入社員だった私にはプレッシャーだった。


1. 最初の試み:Google Sheetベースの CLI(sheetEnv)

最初に思いついたアイデアはシンプルだった。

「Google Sheetに環境変数を整理して、それをパースしてローカルに.envファイルを作るCLIを作ろう。」

実際にsheetEnvという名前で開発を始めた。Google Sheets APIでシートを読み取り、プロジェクト別・ステージ別の環境変数を.envファイルとして生成する構造だった。 軽い考えとしては、既存で使用中のシートをそのまま活用しようという目標だった。

しかし、すぐに壁にぶつかった。

WARNING

  • Google API認証のためのサービスアカウントキーが各開発者のローカルに必要だった
  • 結局「環境変数を管理するための環境変数」が必要という皮肉な状況
  • 車輪の再発明をしている感覚が強かった

2. 他のチームはどうしているのか?

sheetEnvを断念した後、周りの開発者に聞いてみた。回答をまとめると大体こうだった。

方式メリットデメリット
プライベートリポジトリに直接保存シンプル、Git履歴追跡セキュリティ脆弱、手動同期
HashiCorp Vault強力なセキュリティ、動的シークレットCI/CD連携が複雑、学習コスト
AWS Secrets ManagerAWSエコシステム統合AWSインフラが前提
Infisical / DopplerDX優秀、SDK提供外部SaaS依存

思ったよりもプライベートリポジトリに.envファイルを保存しているケースが多かった。Vaultは強力だがCI/CD連携と学習コストが負担だったし、AWS Secrets Managerは我々のインフラに合わなかった。

そこでふと思った。

「うちはAzureインフラを使っているじゃないか。Azureにも似たようなものがあるはずでは?」


3. Azure App Serviceの環境変数をSSOT

調べてみると、答えはすでに我々のインフラの中にあった。

  • Azure App Service → Application Settingsで環境変数がすでに管理されていた
  • AKS(Azure Kubernetes Service) → shared ConfigMapに環境変数があった
  • Azure CLIaz)でこれらの値をプログラマティックに取得できた

核心的なインサイトはこれだった:

IMPORTANT

デプロイ環境の環境変数こそが真実の源であるべきだ。

GitHub SecretだろうがGoogle Sheetだろうが、結局実際にサービスが動いている場所の環境変数が最も正確な値だ。であれば、そこから直接取得すればいいのではないか?

社内CLIの誕生

Azure CLIをベースに社内CLIパッケージを作った。コア機能は以下の通り。

# Azure App Serviceから環境変数を取得して.envファイルを生成
ich-cli env pull

App Serviceの場合:

  • Deployment Slotベースでステージ(dev、staging、production)を区別
  • CLIがslotを自動検出し、該当slotの環境変数をパース

AKSの場合:

  • shared ConfigMapから環境変数を読み取る方式

インフラリポジトリの場合:

  • Key Vaultから必要な環境変数を明示的に読み取る方式

これにより、フロントエンドチームのすべてのプロジェクト(SaaSウェブアプリ、管理画面、クライアント、インフラ)が一つのCLIで環境変数を管理するようになった。

余談:npm i -gの落とし穴

最初はnpm i -g @icloudhospital/ich-cliでグローバルインストールを案内していた。しかしCLIをアップデートするたびにチームメンバーに「再インストールしてください」とアナウンスしなければならない手間が生まれた。バージョンがローカルに固定されてしまうため、誰かが古いバージョンで環境変数を取得している状況が発生した。結局npxに切り替えた。

npx @icloudhospital/ich-cli env pull

npxは常に最新バージョンを取得して実行するため、別途の同期なしにすべてのチームメンバーが同じバージョンのCLIを使えるようになった。


4. Next.jsの環境変数ロード順の活用

フロントエンドはすべてNext.jsを使っている。Next.jsは環境変数のロードに明確な優先順位を持っている。

1. process.env
2. .env.$(NODE_ENV).local
3. .env.local (Not checked when NODE_ENV is test)
4. .env.$(NODE_ENV)
5. .env

このロード順を活用して、CLIが生成するファイルを設計した。

.env.development        ← Azure dev環境変数(CLIが生成)
.env.development.local  ← 開発者ローカルオーバーライド(開発者が管理)
.env.production         ← Azure prd環境変数(CLIが生成)
.env.production.local   ← 開発者ローカルオーバーライド(開発者が管理)
TIP

.env.*.localファイルはロード順でより高い優先順位を持つ。つまり、開発者がローカルでAPI_URL=http://localhost:3000のような値を.env.development.localに入れておくと、CLIが生成した.env.developmentの値を上書きする。

# .env.development(CLIがAzureから取得した値)
API_URL=https://dev-api.example.com

# .env.development.local(開発者ローカルオーバーライド)
API_URL=http://localhost:3000

おかげで開発者はURLを手動で変更する必要がなくなり、next devnext buildNODE_ENVを切り替えるだけでdev/prd環境をローカルでテストし、Dockerビルドの検証までできるようになった。


5. 新たな問題:ビルドタイム vs ランタイム環境変数

ここまでは順調だった。しかし新たな問題が登場した。

Next.jsではNEXT_PUBLIC_プレフィックスが付いた環境変数はビルドタイムに値がインライン化される。 つまり、ビルド時点で実際の値に置き換えられてバンドルに含まれる。

// ビルド前
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

// ビルド後(バンドル内)
const apiUrl = "https://api.example.com";

ではなぜNext.jsはこのインライン戦略を選んだのか?この部分が気になって調べてみた。

根本的にブラウザはprocess.envにアクセスできない。 クライアントコードに環境変数を渡すには

(1)ビルドタイムにインライン
(2)サーバーがHTMLレスポンスに注入
(3)APIで取得する方法

Next.jsは静的ビルド(next export、SSG)のようにサーバーがない環境でも動作する必要があるため、最も汎用的な(1)をデフォルト戦略として選択したのだ。

このインラインは内部的にwebpackのDefinePluginを通じて行われる。process.env.NEXT_PUBLIC_Xを文字列リテラルに直接置換する方式だが、これは単純な値の代入ではなく、Dead Code Eliminationという副次的効果を持つ。

// DefinePlugin置換前
if (process.env.NEXT_PUBLIC_FEATURE_FLAG === 'true') {
  // feature code
}

// 置換後(値が'false'の場合)
if ('false' === 'true') {
  // feature code
}

// Terser最小化後 → 条件が常にfalseなのでコードブロックが丸ごと削除される

つまり、環境変数の値に応じて使用しないコードがバンドルから自動的に除去され、最終的なバンドルサイズを削減できる。このパターンはもともとCreate React AppのREACT_APP_コンベンションから始まったもので、Next.jsがv9.4で同じ方式を導入した。

CAUTION

合理的な設計判断だが、トレードオフは明確だ。値がビルド時点で固定されるため、 同じビルド成果物を異なる環境にデプロイすることが不可能になる。そしてこれがまさに我々にとって問題になったポイントだった。

これにより問題が発生した。

Dockerイメージをビルドする際、NEXT_PUBLIC_*環境変数を必ず注入しなければならなかったため、これらの値がGitHub Secretにも必要だった。すると再び環境変数が分散する問題に逆戻りしてしまう。

また、NEXT_PUBLIC_*環境変数をクライアントコードでランタイムに参照したい状況も生まれた。しかしビルドタイムに値が置き換えられてしまうため、同じDockerイメージで異なる環境(dev、staging、prod)にデプロイしながら環境変数だけを変えることが不可能だった。

// こう書くとビルド時点の値に固定される
const value = process.env.NEXT_PUBLIC_VALUE // ❌ ランタイムに変更不可

従来はこの問題をNext.jsのpublicRuntimeConfigserverRuntimeConfigで回避していた。

// next.config.js
module.exports = {
  publicRuntimeConfig: {
    API_URL: process.env.API_URL,
  },
  serverRuntimeConfig: {
    SECRET_KEY: process.env.SECRET_KEY,
  },
}

ビルドタイム、ランタイム、サーバーサイドなど複数の場所で共通で使われる環境変数をここに明示し、getConfig()を通じてアクセスする方式だった。しかしこの方法には限界があった。

WARNING

  • 公式ドキュメントでももはや推奨されない方式と明示されている
  • App Routerではサポートされない
  • getServerSidePropsを使用するページでのみ動作するという制約があった

非推奨になりつつある方式に依存し続けるわけにはいかず、別の方向を探す必要があった。


6. ランタイム環境変数の解決:next-runtime-env

この問題を考えていた中、カカオエンターテインメント技術ブログの記事を発見した。同じ問題を扱っており、その過程でnext-runtime-envパッケージを知った。

expatfilenext-runtime-env

Loading repository data...

-- -- --

原理は非常にシンプルだ。

  1. サーバーでprocess.envNEXT_PUBLIC_*値を読み取り、<script>タグでwindow.__ENVに注入
  2. クライアントではwindow.__ENVを通じてランタイムに環境変数を参照
// app/layout.tsx
import { PublicEnvScript } from 'next-runtime-env'

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <PublicEnvScript />
      </head>
      <body>{children}</body>
    </html>
  )
}
// クライアントコンポーネントで
import { env } from 'next-runtime-env'

const apiUrl = env('NEXT_PUBLIC_API_URL') // ✅ ランタイムに値を読み取る

コード自体はシンプルだが、抽象化がよくできていたため、すぐに導入できた。これをすべてのプロジェクトに適用することで:

  • 一つのDockerイメージで複数環境にデプロイ可能
  • NEXT_PUBLIC_*環境変数をGitHub Secretに重複管理する必要なし
  • ビルドプロセスの簡素化

最終構成

すべての改善を経た最終的な環境変数管理構成はこうだ。

┌─────────────────────────────────────────────────┐
│              Single Source of Truth              │
│                                                  │
│   Azure App Service (Slots) / AKS ConfigMap      │
└──────────────────────┬──────────────────────────┘

                   社内CLI

         ┌─────────────┴─────────────┐
         │                           │
    ローカル開発                 Docker Build
         │                           │
  .env.development              next-runtime-env
  .env.production               (ランタイム環境変数)

  .env.*.local
  (開発者オーバーライド)
BeforeAfter
GitHub Secret、Google Sheet、Key Vaultに分散Azure App Service/AKSがSSSOT
Slackで.envファイルを共有CLIコマンド一つで環境変数を同期
ステージごとに手動管理Slotベースの自動検出
環境ごとにDockerイメージをビルド一つのイメージ + ランタイム環境変数

おわりに

振り返ると、核心は「環境変数の真実の源をどこに置くか」という一つの問いだった。

Google Sheetも、プライベートリポジトリも、Vaultも結局はコピーだ。実際にサービスが動いているAzure環境の設定値が最も正確な原本であり、そこから直接取得すれば同期の問題は自然と解決される。

完璧な解決策ではない。Azure CLI認証が必要だし、オフラインでは使用できないという限界もある。しかし少なくとも「この環境変数の値、合ってる?」という質問はもう出なくなった。

環境変数管理に苦しんでいるチームがあれば、大仰なソリューションを探す前に、すでに使っているインフラの中に答えがないかまず確認してみることをお勧めする。

ich-cli env pull実行画面
ich-cli env pull実行画面