运行时环境变量注入指南
解决前端构建时无法感知运行时配置值的结构性矛盾。一份镜像,多套环境——通过占位符 + sed 运行时替换,适配 dev/test/prod 任意环境
运行时环境变量注入指南
面向前端开发与 DevOps 部署的通用方案,解决"构建时不知道运行时配置值"的问题。
1. 概述
1.1 问题背景
前端代码需要引用后端 API 地址、第三方服务密钥等配置值。开发环境用 localhost,生产环境用 K8s 内部域名。但在实际部署中,存在一个结构性矛盾:
构建与部署是两件事,但配置值在构建时就被固化了。
具体来说:
-
前端构建工具(Vite/Create-React-App/Webpack)将
import.meta.env在pnpm build时内联到 JS 产物中。构建完成后,dist/是纯静态文件,无法再修改其中的配置值。 -
K8s 只能注入容器进程环境变量,无法修改 JS 文件内容。容器启动时,K8s 会将定义的环境变量注入进程环境空间,但前端 JS 跑在用户浏览器里,读不到容器环境变量。
-
传统方案是每个环境构建一次镜像——dev 打一次,test 打一次,prod 再打一次。这导致:
| 问题 | 说明 |
|---|---|
| 产物不一致 | 不同时间构建,可能拉取到不同依赖版本、不同 commit |
| 维护成本高 | 每次发版需要构建 N 个镜像,N 套版本号 |
| 不可追溯 | 出问题后难以确认 prod 镜像和 test 镜像是否真正一致 |
1.2 方案动机
本方案的核心目标:一份镜像,多套环境。
构建时留洞(占位符),运行时填(sed 替换)。同一份 JS bundle,通过不同环境变量适配任意环境。
1.3 为什么 K8s 不能直接注入前端配置
这是很多初学者的疑问。K8s 明明支持 env 配置环境变量,为什么还要搞这么复杂?
- K8s 环境变量注入到容器进程的环境空间
- JS 在用户浏览器的 JS 引擎中执行
- 两者是完全不同的进程空间,无法跨进程读取
entrypoint.sh 就是中间人——在容器启动时,把容器环境变量写入 JS 文件。这样 JS 加载时,文件里的字符串已经是正确值。
1.4 方案流程
构建时:写入占位符 __VAR_NAME__ → 打进 JS bundle
运行时:容器启动,读取 K8s 注入的环境变量 → sed 替换占位符为真实值 → 启动 Nginx同一份镜像,通过不同环境变量,适配 dev / test / prod 等任意环境。
1.5 适用场景
- Vite、Create-React-App、Webpack 等前端构建工具
- Docker 多阶段构建 + Nginx 静态服务
- K8s / Docker Compose 等支持容器环境变量注入的编排平台
2. 架构原理
为什么占位符能被替换
占位符是普通字符串,打包进 JS 后仍是普通字符串。sed 在 JS 文件中搜索并替换,等价于文本编辑。JS 引擎执行的是替换后的代码。
.env 文件
.env 文件在构建时被读取并固化。修改 .env 需要重新构建镜像。
3. 实施步骤
步骤 1:代码中使用占位符
在源码中,通过 import.meta.env 引用变量。构建工具会将其替换为 ENV 定义的值。
// src/config/env.ts
const API_BASE_URL = import.meta.env.VITE_API_URL;
export { API_BASE_URL };构建后,JS 产物中的值为 __VITE_API_URL__(占位符)。
步骤 2:Dockerfile 中声明占位符
# 构建阶段
FROM node:18 as builder
WORKDIR /usr/src/app
COPY package.json ./
RUN pnpm install
COPY ./ ./
# 声明环境变量,值为占位符(双下划线包裹)
ENV VITE_API_URL=__VITE_API_URL__
RUN pnpm build
# 运行阶段
FROM nginx:latest
WORKDIR /usr/share/nginx/html/
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html/
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]关键点:ENV 的值是占位符字符串本身,不是要引用的真实地址。真实地址由运行时注入。
步骤 3:编写 entrypoint.sh
#!/bin/sh
set -e
HTML_DIR=/usr/share/nginx/html
# 通用替换函数:在所有 JS 文件中将占位符替换为环境变量真实值
replace_env() {
placeholder="$1"
value="$2"
find "$HTML_DIR" -name '*.js' -exec sed -i "s|${placeholder}|${value}|g" {} +
}
# 执行替换(每个变量一行)
replace_env "__VITE_API_URL__" "${VITE_API_URL}"
replace_env "__VITE_WS_URL__" "${VITE_WS_URL}"
# 生成 Nginx 配置(如需要模板替换)
envsubst < /etc/nginx/mysite.template > /etc/nginx/conf.d/default.conf
# 启动 Nginx
exec nginx -g 'daemon off;'关键点:
- shebang 用
#!/bin/sh,而非#!/bin/bash——多数生产镜像(Alpine、distroless、Slim)不提供 bash 环境 set -e:替换失败时容器启动失败,避免静默错误- 分隔符用
|而非/,避免 URL 中的/导致 sed 语法错误 find ... {} +批量执行,比\;逐个执行更高效- ash(Alpine 默认 shell)不支持
local关键字,函数内变量直接赋值即可 - 新增变量只需添加一行
replace_env调用
步骤 4:K8s 部署时注入环境变量
apiVersion: v1
kind: Pod
metadata:
name: frontend
spec:
containers:
- name: frontend
image: harbor.example.com/project/frontend:latest
env:
- name: VITE_API_URL
value: "https://api.prod.example.com"
- name: VITE_WS_URL
value: "wss://ws.prod.example.com"容器启动时,K8s 将环境变量注入容器进程。entrypoint.sh 读取 ${VITE_API_URL} 执行替换。
4. 如何添加新变量
假设需要新增 VITE_OAUTH_URL:
| 位置 | 操作 | 示例 |
|---|---|---|
| 源码 | import.meta.env.VITE_OAUTH_URL 引用 | const oauthUrl = import.meta.env.VITE_OAUTH_URL |
| Dockerfile | 添加 ENV 声明 | ENV VITE_OAUTH_URL=__VITE_OAUTH_URL__ |
| entrypoint.sh | 添加 replace_env 调用 | replace_env "__VITE_OAUTH_URL__" "${VITE_OAUTH_URL}" |
| K8s | 添加 env 条目 | - name: VITE_OAUTH_URL value: "https://oauth.xxx" |
顺序:源码 → Dockerfile → entrypoint.sh → K8s 配置,重新构建镜像后部署。
5. 模板文件
Dockerfile 模板
# ===== 构建阶段 =====
# 替换 node:18 为项目实际 Node 版本
FROM node:18 as builder
WORKDIR /usr/src/app
COPY package.json ./
RUN pnpm install
COPY ./ ./
# 按需添加/删除 ENV 声明,值格式: __VITE_XXX__
ENV VITE_API_URL=__VITE_API_URL__
# ENV VITE_WS_URL=__VITE_WS_URL__
# ENV VITE_OAUTH_URL=__VITE_OAUTH_URL__
RUN pnpm build
# ===== 运行阶段 =====
# 替换 nginx:latest 为项目实际基础镜像
FROM nginx:latest
WORKDIR /usr/share/nginx/html/
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html/
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]entrypoint.sh 模板
#!/bin/sh
set -e
HTML_DIR=/usr/share/nginx/html
# 通用替换函数(使用 /bin/sh,兼容 Alpine)
replace_env() {
placeholder="$1"
value="$2"
find "$HTML_DIR" -name '*.js' -exec sed -i "s|${placeholder}|${value}|g" {} +
}
# 按需添加/删除 replace_env 调用
replace_env "__VITE_API_URL__" "${VITE_API_URL}"
# replace_env "__VITE_WS_URL__" "${VITE_WS_URL}"
# replace_env "__VITE_OAUTH_URL__" "${VITE_OAUTH_URL}"
# 如有 Nginx 模板需求,取消注释并配置模板路径
# envsubst < /etc/nginx/mysite.template > /etc/nginx/conf.d/default.conf
exec nginx -g 'daemon off;'6. 注意事项
占位符命名规范
- 格式:
__VITE_XXX__(双下划线包裹,全大写) - 避免使用单下划线或无前缀名称(可能误替换 JS 中的普通字符串)
- 占位符需与 Dockerfile
ENV值、entrypoint.sh 调用三处保持一致
sed 分隔符
使用 | 而非默认 /:
# 错误:URL 中的 / 与分隔符冲突
sed -i "s/__VAR__/https://api.example.com/g" file.js
# 正确:使用 | 作为分隔符
sed -i "s|__VAR__|https://api.example.com|g" file.js空值处理
如果 K8s 未注入对应环境变量,${VITE_XXX} 为空字符串,替换后 JS 中值变为空:
const API_BASE_URL = ""会导致请求失败。部署时务必确保所有声明的环境变量都有值。
可在 entrypoint.sh 中添加校验:
if [ -z "${VITE_API_URL}" ]; then
echo "ERROR: VITE_API_URL is not set"
exit 1
fi镜像无 bash 环境——必须使用 sh 脚本
陷阱:很多生产镜像不提供 /bin/bash。entrypoint.sh 若写成 #!/bin/bash,容器启动直接失败:
standard_init_linux.go:211: exec user process caused "no such file or directory"这个报错极具误导性——文件明明存在,但内核找不到 shebang 指定的解释器。
常见无 bash 的镜像
| 镜像 | 原因 |
|---|---|
nginx:*-alpine | Alpine Linux 默认只含 ash(/bin/sh) |
node:*-alpine | 同上 |
nginx:*-slim | Debian Slim 精简版,不含 bash |
distroless/static | Google distroless 镜像,无 shell |
scratch | 空镜像,无任何 shell |
三条规则
规则 1:shebang 统一用 #!/bin/sh
# 错误:alpine/slim/distroless 中不存在 /bin/bash
#!/bin/bash
# 正确:POSIX sh 是所有镜像的最大公约数
#!/bin/sh规则 2:避免 bash 专属语法
ash(Alpine 默认 shell)和 dash(Debian Slim 默认 shell)只支持 POSIX sh 子集。以下语法在 bash 中合法,但在 sh 中会报错:
| bash 语法 | sh 替代方案 |
|---|---|
local var=value | 直接赋值 var=value |
[[ ... ]] | 使用 [ ... ] |
function name() { | name() { |
${var:-default} 数组操作 | 用字符串逻辑替代 |
source file.sh | . file.sh |
# 错误:ash 不支持 local
replace_env() {
local placeholder=$1 # 语法错误
local value=$2
}
# 正确:直接赋值
replace_env() {
placeholder="$1"
value="$2"
}规则 3:find -exec 用 + 而非 \;
alpine 的 find 支持 + 语法(批量执行),比 \;(逐个执行)更高效:
# 推荐:+ 批量执行 sed
find "$HTML_DIR" -name '*.js' -exec sed -i "s|${placeholder}|${value}|g" {} +
# 兼容:\; 逐个执行(也能工作,但更慢)
find "$HTML_DIR" -name '*.js' -exec sed -i "s|${placeholder}|${value}|g" {} \;如果必须用 bash 怎么办
如果脚本确实依赖 bash 特性(如数组、正则匹配),在 Dockerfile 中显式安装:
# Alpine
RUN apk add --no-cache bash
# Debian Slim
RUN apt-get update && apt-get install -y bash然后 shebang 用 #!/bin/bash。但这会增加镜像体积,建议优先重构为 sh 兼容脚本。
修复后的完整 entrypoint.sh 模板
本文"模板文件"章节中的 entrypoint.sh 模板已使用 #!/bin/sh,可以直接复制使用。
误判 Nginx 启动日志为错误
entrypoint.sh 执行成功后,Nginx 会输出 [notice] 级别日志:
2026/04/16 10:46:45 [notice] 1#1: using the "epoll" event method
2026/04/16 10:46:45 [notice] 1#1: nginx/1.27.1
2026/04/16 10:46:45 [notice] 1#1: built by gcc 13.2.1 20240309
2026/04/16 10:46:45 [notice] 1#1: OS: Linux 5.4.0-216-generic这些是 Nginx 正常启动日志,不是错误。如果容器随后退出,问题出在 entrypoint.sh 阶段(变量未设置 / shebang 错误),而非 Nginx。
CSS 文件中的变量
如果变量出现在 CSS 文件中(如背景图 URL),需额外替换 CSS:
# find 已覆盖所有 .js 文件,需追加 .css
find "$HTML_DIR" -name '*.css' -exec sed -i "s|__VITE_BG_URL__|${VITE_BG_URL}|g" {} +特殊字符转义
如果环境变量值中包含 sed 特殊字符(如 &、\、|),需转义:
# & 在 sed 替换值中表示匹配内容本身,需转义为 \&通常 URL 中不含这些字符,无需特殊处理。
7. 本地测试
构建镜像
# 在 deploy 同级目录执行
docker build -t my-frontend:latest -f deploy/Dockerfile .运行容器
docker run \
-e VITE_API_URL=https://api.example.com \
-e VITE_WS_URL=wss://ws.example.com \
-p 8080:80 \
my-frontend:latest验证替换结果
# 进入容器
docker exec -it <container_id> sh
# 检查 JS 文件中占位符是否被替换
grep -r "__VITE_API_URL__" /usr/share/nginx/html/
# 无输出 = 替换成功
# 有输出 = 未替换(检查环境变量是否正确注入)
# 查看实际值
grep -r "api.example.com" /usr/share/nginx/html/清理
docker stop <container_id>
docker rm <container_id>