跳到主要内容

3 篇博文 含有标签「环境变量」

查看所有标签

.env 改了不生效?带界面配置的应用 DB 优先级高于环境变量

· 阅读需 7 分钟

改了 .env 里的 AI 模型配置(供应商、密钥、模型名都换了),重启服务,应用的行为纹丝不动——还是老供应商的老模型在跑。.env 文件里明明白白写着新配置,像是随时会生效,但它就是不生效。

在维护一套客户交付的 AI 内容处理应用时遇到此问题,记录排查弯路与最终定性。

TL;DR​

应用支持后台界面改配置时,DB 里的配置会全量覆盖 .env——.env 里同名配置只是兜底,甚至是死配置。

排查顺序记住一句:先查应用内配置存储(DB 配置表),再查 .env。这次排查在 .env 和进程环境快照上浪费了两步,最后在 DB 配置表里找到了真相:表里的值是全套的另一家供应商配置,把 .env 整段 shadow。

问题现象​

服务器上的 .env 文件:

# /path/to/backend/.env —— 看起来「正在使用」
AI_API_KEY=sk-xxxx...xxxx
AI_BASE_URL=https://旧供应商的兼容端点
AI_MODEL=旧模型名

实际运行行为:模型调用走的是另一家供应商(真实 OpenAI 协议端点 + 另一套密钥 + 另一个模型名)。改 .env、重启、无效;再改、再重启、还是无效。

排查弯路:两步白走的检查​

这个坑的排查过程本身值得记录——两步看起来很专业的检查,都是白费的。

弯路一:盯着 .env 文件反复确认。 文件里配置齐整、格式正确、路径正确(服务的 systemd unit 也确实指向这个目录),怎么看都「像是在用」。但**「配置写在文件里」和「配置正在生效」是两回事**——应用的配置加载逻辑有优先级,.env 只是候选来源之一。

弯路二:查 /proc/<pid>/environ。 想确认进程实际拿到的环境变量,标准姿势是看进程环境快照:

cat /proc/<pid>/environ | tr '\0' '\n' | grep AI_

结果是空的——这步检查本身就漏了:dotenv 是进程启动后运行时注入 os.environ 的,/proc/<pid>/environ 只是启动那一刻的快照,永远看不到 dotenv 后注入的值。这个快照里没有,不能推出「进程里没有」;有,也不能推出「生效」。对 dotenv 应用,这个快照两头都不能证明。

第三步才走对:查 DB 配置表。 应用带后台界面改配置的功能,配置加载逻辑是「DB 优先、env 兜底」——查 DB 的配置表,里面存着全套另一家供应商的配置,优先级压过 .env,真相大白。

根因:界面配置功能要求 DB 压过 env​

为什么这类应用的优先级必然是 DB > env?从功能需求倒推:

应用支持「管理员在后台界面改 AI 配置、保存即生效」。如果 .env 优先级更高,界面改的配置永远被 .env 压住,功能就是死的。所以带这个功能的应用,配置加载逻辑一定长这样:

def _ensure_client():
cfg = load_db_config() # 1. 先读 DB 配置表
if cfg is None: # 2. DB 没有才回落到 env
cfg = from_env()
return build_client(cfg)

DB 有值就用 DB——而只要有人在后台保存过一次配置,DB 就永远有值。.env 从那天起就是死配置:文件里写什么都不会被读到,除非清空 DB 配置。

这个设计的坑在于不可见:.env 文件就在服务器上,内容齐整,运维自然的反应是「改这里」。没有任何报错提示「你的修改被 DB 覆盖了」——配置系统静默地按优先级工作,只有知道优先级链的人才能预测结果。

解法:先查 DB,再清理死配置​

第一步:确认生效配置的真实来源。 直接查应用的配置表(本例是 app_config 一类的 key-value 表):

SELECT key, value FROM app_config;

拿 DB 里的值和应用实际行为对一下(比如看实际请求打到哪个端点),对上了,结论就钉死了:DB 全量覆盖,.env 的同名段是残留死配置。

第二步:清理死配置前,先验证 DB 是不是真的全量覆盖。 逐项对比 DB 配置与 .env:如果 DB 覆盖了全部关键项,.env 里的残留删掉理论上无影响;但删之前要重启服务验证一遍——万一加载逻辑里某个字段还是 env 兜底,删了就会炸。验证通过再删,这是清理死配置的安全顺序。

第三步:把优先级链写进运维文档。 这次排查浪费的两步,根因都是「不知道这个应用有配置优先级」。交接文档里一句话就能省掉后来者的两小时:「配置以后台界面(DB)为准,.env 仅兜底,改 .env 前先查 DB」。

注意事项

「配置文件存在且内容正确」永远不等于「配置正在生效」——生效与否取决于加载逻辑和优先级链。同理,/proc/<pid>/environ 对 dotenv 应用既不能证明「有」也不能证明「生效」。配置类排查的正确起点是应用的配置加载代码,从代码里读出优先级链,再按链排查。

常见问题​

环境变量的优先级是怎么算的?​

以带后台配置功能的应用为例,从低到高:系统级环境变量 → 进程启动 env → dotenv 注入值 → 应用内配置存储(DB 或配置文件里的运行时配置)。排在最后的 DB 配置优先级最高——界面改配置要立即生效,就必须压过 env。排查配置问题时按「先查应用内配置存储,再查 env」的顺序,能少走大半弯路。

环境变量要重启才生效吗?​

分层看:系统级环境变量改了要重启 shell 或服务;dotenv 在进程启动时读一次 .env,改文件后同样要重启进程;而带 DB 配置层的应用,后台改配置立即生效——配置加载逻辑在每次建客户端时都读 DB。三层生效时机各不相同,混着排查就会得出「改了没用」的错误结论。

/proc/pid/environ 为什么看不到 dotenv 注入的变量?​

/proc/pid/environ 是进程启动那一刻的环境快照;dotenv 是进程起来之后才运行时注入 os.environ 的,快照里自然没有。想确认 dotenv 的值,要么在进程内打印,要么直接看 .env 文件——但注意文件里有不等于生效,还要再确认没有更高优先级的配置层压着它。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

运行时改了环境变量不生效?ESM import 把旧值缓存成常量

· 阅读需 5 分钟

登录脚本运行时成功刷新了凭据,写进了 process.env;业务模块里请求照样 401——它 import 进来的那个常量,还是进程启动时的旧值。

在为客户构建数据采集工具时遇到此问题,记录根因与解法。

TL;DR​

ESM 模块只求值一次,import 进来的是加载那一刻的只读快照——之后 process.env 怎么更新,都传导不到已经 import 的常量里。

// ❌ 加载时缓存,之后永远是旧值
import { AUTH_TOKEN } from './config.js'

// ✅ 每次使用时读现值
function getToken() {
return process.env.AUTH_TOKEN || ''
}

原则一句话:需要运行时更新的值,消费侧必须动态读 process.env,不能 import 成模块级常量。

问题现象​

三个文件的分工:config.js 集中导出配置常量,login.js 负责登录并刷新凭据,runtime.js 拿凭据发业务请求:

// config.js —— 集中导出
export const AUTH_TOKEN = process.env.AUTH_TOKEN || ''
// login.js —— 登录后刷新凭据
process.env.AUTH_TOKEN = newToken // 运行时更新
console.log('[login] token refreshed')
// runtime.js —— 业务请求
import { AUTH_TOKEN } from './config.js'

fetch(url, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } })
// → 401:AUTH_TOKEN 还是进程启动时的旧值(或空串)

日志显示 token 刷新成功,请求头里带的却是旧凭据。打印 AUTH_TOKEN 的值和 process.env.AUTH_TOKEN 的值对比,两者不一致——process.env 是新的,import 来的常量是旧的。

根因:ESM 模块只求值一次​

ESM 规范里,一个模块的代码从首次被 import 到进程结束只执行一次,第二次 import 拿到的是同一个模块实例的缓存。

所以 runtime.js 里那行 import { AUTH_TOKEN } from './config.js' 的实际语义是:加载 config.js(求值 export const AUTH_TOKEN = process.env.AUTH_TOKEN || '',此刻把 process.env 的现值固定进常量),把这个值绑定给 runtime.js 作用域里的 AUTH_TOKEN。

这条绑定有两个特征,正是坑的来源:

  1. 只读:import 绑定的值在消费方不可重新赋值(ESM 的 import 绑定虽然指向导出的「实时绑定」,但 config.js 导出的是 const 常量,永远不会有新值)
  2. 与 process.env 脱钩:login.js 后来执行的 process.env.AUTH_TOKEN = newToken 只是改了 process.env 对象上的一个属性——config.js 里的求值早就结束了,没有任何机制把这次修改传导回已导出的常量

一句话:process.env 是一个可变的运行时对象,而 export const X = process.env.Y 是对它某一行的一次性快照。快照不会跟着原件变。

解法:消费侧动态读​

改法一(最小改动):用时取现值。 把 import 常量改成读 process.env:

// runtime.js
// import { AUTH_TOKEN } from './config.js' ← 删掉

fetch(url, {
headers: { Authorization: `Bearer ${process.env.AUTH_TOKEN || ''}` },
})

改法二(多处使用):收敛成一个 getter。 使用点多时,散落的 process.env.XXX 不好维护,集中到动态读取的函数:

// config.js —— 导出函数而不是常量
export const getToken = () => process.env.AUTH_TOKEN || ''
// runtime.js —— 调用时求值,永远现值
import { getToken } from './config.js'

fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })

改法三(配置项多):导出对象、按属性取。 对象属性访问天然是动态的:

// config.js
const env = {
get token() { return process.env.AUTH_TOKEN || '' },
}
export default env

// runtime.js
import env from './config.js'
env.token // 每次访问都读 process.env

三种写法同一个原则:把「求值时机」从模块加载推迟到每次使用。

同一个工具里,.env 的存放路径还有一层打包后的坑(userData 目录而非 cwd),两件事都属「配置读取时机与位置」,见 Electron 打包后 .env 读不到?配置在 userData 目录而非项目根。

注意事项

dotenv 也一样:dotenv.config() 只在调用那一刻读一次 .env 文件,之后再改文件、再调用普通 config 都不会更新已注入的值(override: true 重调才会覆盖,但仅对之后读 process.env 的代码生效——import 成常量的照旧是死值)。「改了 .env 不生效」先查是不是进程没重启,再查是不是 import 成了常量。

常见问题​

Node.js 环境变量怎么配置和读取?​

配置走 .env 文件加 dotenv,或系统级 export;代码里统一从 process.env.XXX 动态读取。关键结论:process.env 是唯一会被运行时更新传导的通道——模块顶层 import 进来的常量在首次加载时就固定了,之后 process.env 怎么变它都不会跟着变。

为什么更新了 process.env,其他模块读到的还是旧值?​

因为消费模块写的是 import { TOKEN } from './config.js' —— 这行代码在模块首次加载时求值一次,把当时的值复制进了本地常量,之后 config.js 内部和 process.env 的任何变化都传导不过来。改成每次用时读 process.env.TOKEN 即可拿到现值。

dotenv 更新 .env 文件后需要重启进程吗?​

最稳妥是重启:dotenv.config() 只在调用那一刻读一次文件。如果必须进程内热更新,要重新调用 dotenv.config({ override: true }) 且所有消费方都动态读 process.env——只要有一处 import 成常量,热更新就在那里断掉。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Vite 环境变量读不到?变量必须加 VITE_ 前缀才暴露给前端

· 阅读需 5 分钟

在 Vite 项目的 .env 里写好了 API_URL=http://localhost:3005,前端代码里 import.meta.env.API_URL 打印出来却是 undefined,接口请求全部落到错误地址。

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR​

Vite 只把 VITE_ 前缀的环境变量暴露给客户端代码,这是防止服务端密钥被打进浏览器 bundle 的安全设计。

# ❌ 不会暴露给前端
API_URL=http://localhost:3005

# ✅ 暴露给前端
VITE_API_URL=http://localhost:3005

代码里对应改为 import.meta.env.VITE_API_URL。TypeScript 项目再加一步 env.d.ts 类型声明,补全智能提示。

问题现象​

两个典型表现:

表现一:变量是 undefined

// .env: API_URL=http://localhost:3005
console.log(import.meta.env.API_URL) // undefined
console.log(import.meta.env) // 里面有 BASE_URL、MODE、PROD...,就是没有 API_URL

.env 文件确实被加载了(MODE、PROD 这些内置变量都在),唯独自己写的变量不见——说明不是加载失败,是暴露规则把它拦下了。

表现二:加了前缀但拼错访问名

// .env: VITE_API_URL=...
const url = import.meta.env.VITE_APIURL // undefined —— 大小写必须完全一致

服务端 Node.js 项目里「环境变量读出来是 undefined」另有常见根因(dotenv 加载顺序),前端和后端这两类 undefined 根因不同,别混着排查。

根因:Vite 的暴露规则是一道安全边界​

Vite 的设计:.env 里可能同时存在两类值——前端要用的公开配置(接口地址)和绝不能进浏览器的密钥(数据库连接串、第三方 API key)。如果全部暴露,一次疏忽就把密钥打进了公开的静态产物。

所以 Vite 定了硬规则:只有 VITE_ 前缀的变量会出现在 import.meta.env 里,其余变量只在 vite.config.ts(Node 侧)通过 loadEnv 可见。

暴露的实现方式也值得知道:构建时静态替换。Vite 打包时直接把 import.meta.env.VITE_API_URL 替换成字符串字面量,运行时根本没有「读环境变量」这个动作。两个推论:

  1. 值会明文出现在产物 JS 里——VITE_ 变量天然是公开的
  2. 运行时改环境(改容器 env、改系统变量)不影响已构建的产物——换环境必须重新构建,或把配置改成运行时注入(如 window.__CONFIG__)

解法:加前缀 + 类型声明​

第一步:.env 里的变量加 VITE_ 前缀。 命名保持语义,前缀只是暴露标记:

# .env
VITE_API_URL=http://localhost:3005

第二步:代码统一走 import.meta.env.VITE_XXX。 建议收敛到一个配置模块,别在组件里散着读:

// src/config.ts
export const API_URL = import.meta.env.VITE_API_URL

第三步(TypeScript 项目):src/env.d.ts 补类型声明,拿到完整智能提示:

/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_URL: string
}

interface ImportMeta {
readonly env: ImportMetaEnv
}

开发、生产两套地址用模式文件分开,Vite 按场景自动选:

.env.development    # dev server 用
.env.production # npm run build 用
.env # 两者都加载,放公共配置

改完任何 .env 文件必须重启 dev server——环境变量只在启动时加载一次,热更新不覆盖它们,这是「明明改了却没生效」的第二大来源。

注意事项

VITE_ 变量等于公开信息:值会被明文内联进客户端 bundle,任何人可见。接口地址、功能开关可以放;数据库连接串、第三方密钥绝不放,密钥只走服务端环境变量。前端需要受保护的配置时,由后端接口下发,而不是塞进 .env。

常见问题​

Vite 环境变量配置在哪个文件里?​

项目根目录的 .env 系列:.env 两种场景都加载,.env.development 只在 dev server 生效,.env.production 只在构建时生效。改完任何 .env 文件都必须重启 dev server——Vite 只在启动时加载一次环境变量,热更新不覆盖它们。

为什么 VITE_ 前缀的变量会泄露?不能放密钥吗?​

不能放密钥。VITE_ 变量在构建时被静态内联进客户端 bundle,任何访问页面的人打开源码就能看到明文。前缀机制的设计目的就是把「允许公开的配置」(接口地址、功能开关)和「必须留在服务端的密钥」分开,密钥走后端环境变量。

import.meta.env 和 process.env 在 Vite 里有什么区别?​

浏览器端代码只能用 import.meta.env——Vite 会把 VITE_ 前缀变量静态替换进去;process.env 是 Node.js 的 API,只在 Vite 配置文件(vite.config.ts)和 SSR 场景可用,前端代码里写 process.env.XXX 打包后就是 undefined。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询