Viteの『Runtime Injection』で実現するビルドレスな設定切り替え:環境変数をビルド後に動的置換する裏技
モダンなフロントエンド開発において、Viteはその圧倒的なビルドスピードとESMベースの開発サーバーで、Webpack時代の「待ち時間」を過去のものにした。しかし、エンタープライズ領域のCI/CDパイプラインやコンテナネイティブなインフラストラクチャに目を向けた途端、私たちはVite(および多くのSPAビルドツール)が抱える「ビルド時環境変数固定の呪縛」に直面する。
「1つのDockerイメージをビルドし、Staging環境を経て、一切の再ビルドなしでProduction環境にデプロイする」
これは、コンテナ設計におけるイミュータビリティ(不変性)の基本原則であり、DevOpsエンジニアであれば誰もが実現したい黄金律だ。しかし、Viteの `import.meta.env` は、ビルド(`vite build`)の瞬間にコードへ静的にインライン展開される。つまり、標準のままでは「環境ごとの数値を埋め込むために、環境の数だけ重いDockerビルドを繰り返す」という、非効率なループから逃れられない。
今回は、この制約を鮮やかに突破し、ビルド済みの静的アセットに対してコンテナ起動時に環境変数を動的注入(Runtime Injection)する極限のアーキテクチャを解説する。CI/CDパイプラインのパイプラインコストを劇的に削減し、デプロイ速度を秒単位へと昇華させる「裏技」の全貌を、低レイヤの挙動とともに紐解こう。
—
1. なぜ標準の `import.meta.env` ではコンテナの不変性が破壊されるのか?
まずは敵を知ることから始める。Viteは、コード内に記述された `import.meta.env.VITE_API_URL` のような変数を、Rollupによるバンドル時に文字列置換(Static Replacement)する。
// 開発時・ビルド前のコード
console.log(import.meta.env.VITE_API_URL);
// ビルド後のチャンクファイル(例)
console.log(“https://api.production.example.com”);
この仕様がもたらす実務上の致命傷は以下の通りだ。
- イメージの環境依存: `Dockerfile` の中で `ARG` や `ENV` を受け取って `npm run build` を叩いた瞬間、そのイメージは特定の環境専用にロックされる。
- CI/CDの無駄な肥大化: Staging用イメージとProduction用イメージで、全く同じソースコードであるにもかかわらず、ビルドとテスト(あるいはレジストリへのプッシュ)を2重に行う無駄が発生する。
- セキュリティリスク: ビルド時にAPIキーやエンドポイントの秘密情報がクライアントサイドコードにハードコーディングされ、イメージのレイヤーに残留する。
KubernetesやAWS ECSなどのコンテナオーケストレーション環境において、環境変数は「コンテナ起動時(Runtime)」に外部から注入されるべきだ。フロントエンドの静的ファイル配信においても、このパラダイムを強制しなければならない。
—
2. アーキテクチャ全体像:Runtime Injectionの仕組み
今回構築するアーキテクチャのコンセプトは極めてシンプルだ。
1. プレースホルダー付きビルド: `VITE_` 変数に一時的なプレースホルダー(例: `__RUNTIME_API_URL__`)を埋め込んでビルドする。
2. Nginx + Entrypointパターン: コンテナイメージにはこの静的ファイル群と、起動時に実行されるシェルスクリプト(Entrypoint)を同梱する。
3. 起動時置換: コンテナが `docker run` あるいはK8sのPodとして起動した瞬間、エントリーポイントスクリプトが環境変数(`process.env` 相当)を読み込み、NginxのドキュメントルートにあるJSファイル内のプレースホルダーをsed等で一括置換する。
4. Nginx起動: 置換完了後、本来のWebサーバープロセスへ処理をハンドオーバーする。
このアプローチにより、「Build Once, Deploy Anywhere(一度ビルドすれば、どこへでもデプロイ可能)」な完璧なイミュータブル・フロントエンド・コンテナが完成する。
—
3. 実装ステップ:コードと設定の完全解剖
Step 1: Vite設定と環境変数ラッパーの構築
まずは、ビルド時にプレースホルダーを安全に埋め込み、かつローカル開発時には通常の `.env` が機能する仕組みを作る。
プロジェクトルートに `env-config.js` を作成し、グローバルオブジェクトとしてランタイム設定を保持する枠組みを定義する。
// env-config.js
// ブラウザのグローバルスコープ(window)に設定オブジェクトのベースを定義
window.__APP_CONFIG__ = {
// コンテナ起動時に置換されるプレースホルダー文字列
VITE_API_URL: “__RUNTIME_API_URL__”,
VITE_SENTRY_DSN: “__RUNTIME_SENTRY_DSN__”,
VITE_FEATURE_FLAG_NEW_UI: “__RUNTIME_FEATURE_FLAG_NEW_UI__”,
};
このファイルを、`index.html` の `
` 内の最上部で読み込ませる。これにより、アプリのどのモジュールがロードされるよりも前に、グローバル設定が確実に初期化される。
TypeScriptやアプリケーションコード側からは、直接 `import.meta.env` を叩くのではなく、セーフに `window.__APP_CONFIG__` をラップしたモジュールを参照する。
// src/config.ts
// ウィンドウオブジェクトに型安全にアクセスするための拡張定義
declare global {
interface Window {
__APP_CONFIG__: {
VITE_API_URL: string;
VITE_SENTRY_DSN: string;
VITE_FEATURE_FLAG_NEW_UI: string;
};
}
}
// 実行時の環境変数、またはビルド時のフォールバックを取得するヘルパー
export const getConfig = (key: keyof Window[‘__APP_CONFIG__’]): string => {
const runtimeValue = window.__APP_CONFIG__?.[key];
// プレースホルダーが置換されずに残っている場合(ローカル開発時など)のフォールバック
if (runtimeValue && !runtimeValue.startsWith(‘__RUNTIME_’)) {
return runtimeValue;
}
// ローカル開発用フォールバック(import.meta.envを活用)
return (import.meta.env[key] as string) || ”;
};
export const AppConfig = {
apiUrl: getConfig(‘VITE_API_URL’),
sentryDsn: getConfig(‘VITE_SENTRY_DSN’),
isNewUiEnabled: getConfig(‘VITE_FEATURE_FLAG_NEW_UI’) === ‘true’,
};
—
Step 2: 起動時置換を行うエントリーポイントスクリプト
コンテナの起動時に走るシェルスクリプトを作成する。このスクリプトは、環境変数として渡された値を検知し、ビルド済みの `env-config.js` (あるいはバンドルされたJS群)の中身を書き換える。
ここでは、高速かつ確実な文字列置換を行うために `envsubst` または `sed` を活用する。
!/bin/sh
docker-entrypoint.sh
エラー発生時にスクリプトを即座に終了させる(堅牢性の担保)
set -e
CONFIG_FILE=”/usr/share/nginx/html/env-config.js”
echo “==> [Runtime Injection] Injecting environment variables into ${CONFIG_FILE}…”
環境変数が存在しない場合のデフォルト値をフォールバックしつつ、プレースホルダーを置換
sedの区切り文字に ‘|’ を使用することで、URLに含まれるスラッシュエスケープの手間を排除
sed -i “s|__RUNTIME_API_URL__|${VITE_API_URL:-https://default-api.example.com}|g” “$CONFIG_FILE”
sed -i “s|__RUNTIME_SENTRY_DSN__|${VITE_SENTRY_DSN:-}|g” “$CONFIG_FILE”
sed -i “s|__RUNTIME_FEATURE_FLAG_NEW_UI__|${VITE_FEATURE_FLAG_NEW_UI:-false}|g” “$CONFIG_FILE”
echo “==> [Runtime Injection] Injection completed successfully.”
DockerfileのCMDで指定されたプロセス(Nginxなど)に処理を移譲
exec “$@”
> アーキテクトの知見:
> なぜ `sed` を使うのか? JSのチャンクファイル(`assets/.js`)そのものを直接置換対象にすると、Viteが生成するハッシュ付きファイル名(例: `index.a3f89b.js`)が変わるたびにスクリプト側のファイル名追従が必要になり破綻する。設定値を完全に独立した `env-config.js` という非ハッシュの単一ファイルに分離し、そこだけをピンポイントで書き換えるのが、保守性とパフォーマンスを両立させる唯一の解である。
—
Step 3: マルチステージビルドを採用した堅牢な Dockerfile
プロダクション品質のコンテナイメージを作成する。ここでは、ビルド環境とランタイム環境を完全に分離したマルチステージビルドを構築する。
==========================================
ステージ 1: ビルド環境 (Node.js)
==========================================
FROM node:20-alpine AS builder
WORKDIR /app
依存関係のインストールを効率化するため、パッケージ定義のみを先にコピー
COPY package.json package-lock.json ./
RUN npm ci
ソースコードをコピーしてビルドを実行
COPY . .
RUN npm run build
==========================================
ステージ 2: ランタイム環境 (Nginx)
==========================================
FROM nginx:1.25-alpine AS runner
セキュリティ強化: デフォルトのHTMLを削除
RUN rm -rf /usr/share/nginx/html/
ステージ1の成果物(静的ファイル)をNginxのドキュメントルートへコピー
COPY –from=builder /app/dist /usr/share/nginx/html
ランタイム設定ファイルを明示的に配置(ビルド成果物に含まれない場合の保険としても機能)
COPY env-config.js /usr/share/nginx/html/env-config.js
エントリーポイントスクリプトをコンテナに配置し、実行権限を付与
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ポートの公開
EXPOSE 80
エントリーポイントとしてスクリプトを指定し、メインプロセスにNginxを渡す
ENTRYPOINT [“/docker-entrypoint.sh”]
CMD [“nginx”, “-g”, “daemon off;”]
—
4. CI/CDパイプラインとの実戦的インテグレーション
このアーキテクチャを採用したときのCI/CDパイプライン(GitHub Actionsの例)の変貌ぶりを見てほしい。環境ごとにビルドを回す必要が一切なくなるため、パイプラインの実行時間が劇的に短縮される。
name: Production Pipeline
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 【極意】環境変数を一切埋め込まずに、一度だけイメージをビルド&プッシュ
- name: Build and Push Immutable Image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/org/frontend-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-to-staging:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to Staging (Injecting Staging Config via Runtime Env)
run: |
echo “Deploying to Staging K8s cluster…”
# KubernetesのManifest等でコンテナ起動時に環境変数を指定
# VITE_API_URL: “https://api.staging.example.com”
# VITE_FEATURE_FLAG_NEW_UI: “true”
deploy-to-production:
needs: deploy-to-staging
runs-on: ubuntu-latest
environment: production # GitHubの承認ゲートを設置
steps:
- name: Deploy to Production (Injecting Production Config via Runtime Env)
run: |
echo “Deploying to Production K8s cluster…”
# まったく同じイメージを使用し、本番用の環境変数を注入して起動
# VITE_API_URL: “https://api.production.example.com”
# VITE_FEATURE_FLAG_NEW_UI: “false”
このパイプラインには、Staging用ビルドとProduction用ビルドの重複がない。レジストリに保存されるイメージは常に1つであり、デプロイ先環境の切り替えは「コンテナに渡す環境変数の差異のみ」で完結する。
—
5. パフォーマンス・メモリ消費・セキュリティにおけるアーキテクトの注意点
最後に、この手法をプロダクション環境に導入するにあたり、シニアエンジニアとして押さえておくべき低レイヤの最適化と注意点を共有しよう。
1. キャッシュ戦略(Cache-Control)の厳格化
`env-config.js` は、ランタイムで動的に値が変わる唯一のファイルである。したがって、このファイルに対してCDNやNginx側で強烈なキャッシュを効かせてしまうと、環境変数を変更してコンテナを再起動してもブラウザ側で古い設定値がキャッシュされ続けるという致命的なバグを踏む。
Nginxの設定(`nginx.conf`)において、ルート直下の `env-config.js` だけは絶対にキャッシュさせない設定を入れるのが鉄則だ。
location = /env-config.js {
add_header Cache-Control “no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0”;
expires off;
}
その他の静的ハッシュ付きアセットは長期キャッシュ(1年)で爆速配信
location /assets/ {
expires 1y;
add_header Cache-Control “public, immutable”;
}
2. XSS(クロスサイトスクリプティング)と機密情報の境界線
勘の良いエンジニアなら気づいたかもしれないが、`window.__APP_CONFIG__` はブラウザのグローバル空間に露出する。したがって、ここにAWSの秘密鍵やDBのパスワードといった真の機密情報(Secret)を絶対に載せてはならない。
あくまでも「公開されても問題のないエンドポイントURL」や「機能フラグ(Feature Flags)」の動的切り替えに用途を限定すること。真のシークレットは、フロントエンドではなくBFF(Backend for Frontend)やAPI Gateway層で隠蔽するのがセキュリティアーキテクチャの鉄則である。
3. 初期ロードの遅延(Network Waterfall)の回避
`env-config.js` を別ファイルとして外部読み込み(`