Vite SPA 打进 nginx 镜像后:启动时生成 env.js,一份镜像跑遍所有环境
现象很具体:镜像在 staging 构建、推到生产之后,浏览器 Network 里的接口请求还是打到 https://api.stg.example.com。查代码没有任何硬编码,import.meta.env.VITE_API_URL 写得好好的——问题出在 RUN npm run build 那一步,值已经被写进 dist/ 了。改一个 URL 就得重新构建一次镜像,registry 里堆着一批只差一个域名的近似镜像,build-once-promote-everywhere 直接失效。
import.meta.env 在 build 期就被静态替换了
浏览器里没有 process.env,Vite 也不在运行时读取环境变量。vite build 做的是一次静态文本替换:define 把源码里每一处 import.meta.env.VITE_API_URL 换成字符串字面量,再压缩、tree-shake:
// 源码
const api = import.meta.env.VITE_API_URL + "/orders"
// VITE_API_URL=https://api.stg.example.com 执行 vite build 之后
const api = "https://api.stg.example.com/orders"
// 死代码消除连分支都能删掉
if (import.meta.env.VITE_FEATURE_X === "true") { ... }
// 值为 "false" 时整段被移除
Docker 构建里发生替换的就是 RUN npm run build 那一层,构建阶段存在哪些 VITE_*,就被焊进 dist/。
几种做法对照:
| 方案 | 镜像不可变 | 成本 |
|---|---|---|
| 每个环境单独构建镜像 | 否 | 最简单,但 N 次构建、N 个镜像,发布慢,配置漂移风险高 |
| 占位符 token + 启动时 envsubst/sed | 是 | 在压缩后的 JS 上做字符串替换,脆弱,token 可能误撞 |
| 容器启动时生成 env.js | 是 | 一个小脚本,本文推荐的做法 |
nginx sub_filter 改写响应 | 是 | 可行,但配置耦合到 Web 服务器,每请求都有开销 |
应用启动时 fetch /config.json | 是 | 干净,但多一个阻塞渲染的请求或加载门禁 |
运行时 env.js 的四个步骤
- 静态站点只构建一次,构建阶段不放任何真实密钥,产物进 nginx 镜像。
- 容器启动时,entrypoint 脚本读取真实环境变量,写出
/usr/share/nginx/html/env.js。 index.html在应用 bundle 之前加载env.js,填充window.__ENV__。- 应用代码统一通过一个 accessor 读配置:优先
window.__ENV__,本地vite dev时回落到import.meta.env。
1. 配置读取入口
// src/env.ts
type AppEnv = { API_URL: string; SENTRY_DSN: string; FEATURE_NEW_CHECKOUT: boolean }
const runtime = (typeof window !== "undefined" && (window as any).__ENV__) || {}
function read(key: string, devFallback: string | undefined): string {
const v = runtime[key] ?? devFallback
if (v === undefined || v === "") { console.warn("[env] missing config value: " + key); return "" }
return v
}
export const env: AppEnv = {
API_URL: read("API_URL", import.meta.env.VITE_API_URL),
SENTRY_DSN: read("SENTRY_DSN", import.meta.env.VITE_SENTRY_DSN),
FEATURE_NEW_CHECKOUT: read("FEATURE_NEW_CHECKOUT", import.meta.env.VITE_FEATURE_NEW_CHECKOUT) === "true",
}
其他地方一律 import { env } from "./env",不要再直接碰 import.meta.env 或 window.__ENV__。
2. index.html 先加载 env.js
提交一个占位文件 public/env.js,让 vite dev 和构建阶段不会 404:
// public/env.js:占位,容器运行时会被覆盖
window.__ENV__ = {}
3. 启动脚本
只输出显式白名单里的 key,容器环境里混进来的 AWS_SECRET_ACCESS_KEY 之类没有机会写进这个公开文件:
#!/bin/sh
set -eu
TARGET=/usr/share/nginx/html/env.js
KEYS="API_URL SENTRY_DSN FEATURE_NEW_CHECKOUT"
echo "window.__ENV__ = {" > "$TARGET"
for key in $KEYS; do
value=$(printenv "$key" || true)
printf ' "%s": "%s",\n' "$key" "$value" >> "$TARGET"
done
echo "};" >> "$TARGET"
cat "$TARGET"
exec "$@"
不受控的值要转义:值里可能出现双引号、反斜杠或换行,会产出非法 JavaScript,也等于开了一个注入口;用 jq 输出 JSON 更稳。
4. Dockerfile
# Stage 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: serve
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
nginx:alpine 在启动 nginx 之前会执行 /docker-entrypoint.d/*.sh,所以不需要自定义 ENTRYPOINT。换用其他基础镜像时,才需要显式接 ENTRYPOINT + CMD ["nginx","-g","daemon off;"]。
5. nginx:SPA fallback 与 env.js 不缓存
server {
listen 8080;
root /usr/share/nginx/html;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /env.js {
add_header Cache-Control "no-store, must-revalidate";
expires -1;
}
location / {
try_files $uri /index.html;
}
}
应用 bundle 的文件名带内容哈希,可以长期缓存;env.js 不行。
坑清单
- envsubst 不带参数:会替换模板里所有
${VAR}形状的 token,包括碰巧以字面形式出现的那些。显式传入变量名列表可以彻底消除这个风险。 - envsubst 不在镜像里:它来自 gettext 包,alpine 需要
apk add --no-cache gettext;chainguard 之类的硬化镜像则完全不带 envsubst(chainguard-images issue #93)。 /docker-entrypoint.d的执行顺序:官方 nginx 镜像本身就会跑这里的脚本,包括20-envsubst-on-templates.sh。文件名数字前缀决定执行次序,放成40-env.sh就排在 20 之后。在这里挂钩子,比替换镜像自带的 entrypoint 稳。- 覆盖 ENTRYPOINT 的代价:得自己处理
nginx -g "daemon off;"、SIGTERM 转发和模板解析,很容易把信号处理和配置流程搞坏。 - env.js 被 CDN 或浏览器缓存:镜像提升到生产,应用却还在跟 staging 说话,直到缓存过期。
env.js上的no-store不是可选项,这一条几乎人人都踩过一次。 - 值未转义:双引号、反斜杠、换行都能产出坏 JS,甚至构成简单的注入。
- 绝不能放密钥:
env.js会发给每一个访客。 window.__ENV__里全是字符串:布尔和数字要在 accessor 里转换。- script 标签放到 bundle 之后或加 async:应用会在
window.__ENV__填充前初始化,静默回落到默认值,问题还很难复现。 - 多一次 env.js 请求:能接受,仅仅因为
index.html本来就不缓存;每个 key 一个字符串,代价是一次小的同步请求。