本地连测试库一切正常,部署后连错库或者密钥校验失败,多数情况不是密钥本身写错,而是进程启动时读到的文件与你在本地改的不是同一份。部署前要确认的只有一件事:这个进程的环境变量从哪里来、按什么顺序加载、最终生效的是哪一套。确认方式不复杂:看启动日志里的环境标识,再用一个非敏感变量做一次取值验证,全程不打印任何密钥。
判断路径:先按仓库和部署配置里实际存在的文件清点环境变量来源,不凭印象添加;再在服务启动最早期打印环境标识(如 APP_ENV)和几个非敏感变量,把「读的是哪一套」变成日志里能看到的事实;最后核对生产是读文件还是进程注入,两种情况对应的排查命令不同。全程只观察环境名与非敏感值,形如 *_KEY、*_SECRET、连接串的字段都不应出现在日志里。环境标识与预期不符时先停下,不要急着换密钥。
列出项目里所有环境变量文件,标明各自在什么阶段被读取
先不要动任何 .env 文件,先把「有几个来源」列清楚。同一个项目里来源往往不止一个,漏掉一个就会出现「改了没生效」。按下面几类逐项核对,只保留实际存在的:
- 启动脚本或 shell 配置里 export 的变量(~/.bashrc、.envrc、package.json 的 scripts)
- 项目根目录的 .env、.env.local、.env.development、.env.production 等文件
- 构建工具或框架的注入配置(Vite 的 .env.[mode]、Next 的 .env.production 等,通常只暴露带公开前缀的变量)
- 编辑器调试配置(.vscode/launch.json 的 env / envFile),只影响本地调试进程
- CI/CD 的变量面板与 Secret 配置
- 运行时进程注入:容器 env_file / environment、systemd 的 Environment= 与 EnvironmentFile=
加载顺序的一般规律是:shell 已有变量 → 文件加载 → 进程注入显式赋值。dotenv 默认不覆盖已存在的 process.env 键,所以「先出现的值赢了」是很常见的坑;容器编排里通常 environment 会覆盖 env_file。但不要凭规律下结论,要按项目里的实际配置确认。尤其注意相对路径的 .env 是相对进程工作目录解析的,systemd 或容器里的工作目录与本地不同,可能读到另一份文件,或者根本读不到。
在启动日志里打印当前加载的环境名,但不打印任何密钥值
把环境名打成一行日志,比事后翻配置文件可靠得多。这行日志要放在进程最早执行的入口里,早于任何读取 process.env 的业务模块。
// boot-env.ts —— 在应用入口最顶部 import
const ALLOWED = ['APP_ENV', 'NODE_ENV', 'LOG_LEVEL', 'SITE_TITLE'];
const envName = process.env.APP_ENV ?? process.env.NODE_ENV ?? 'unset';
console.log(`[boot] env=${envName} pid=${process.pid} cwd=${process.cwd()}`);
for (const k of ALLOWED) {
console.log(`[boot] ${k}=${process.env[k] ?? '<unset>'}`);
}
// 不要写:console.log(process.env)
白名单是硬约束:只输出环境标识、站点标题、日志级别这类非敏感字段。不要图省事整包打印 process.env,也不要在异常堆栈、健康检查接口或构建产物里带出数据库连接串和密钥。cwd 一并打印,是因为相对路径的 .env 依赖工作目录,线上读到错误文件的典型表现就是 cwd 与本地不一致。
如果日志组件在启动早期还没就绪,直接写标准输出或 stderr 即可,容器里一般会被收集。正常部署一次,先看这一行的 env 值是不是预期的 production,再去查别的。
核对生产环境的注入方式:读文件还是进程注入
环境标识对不上时,先分清生产是「读文件」还是「进程注入」。这两种情况查的地方完全不同:读文件要去核对文件路径与工作目录,进程注入要去核对编排配置和 Secret 面板。
读文件
import dotenv from 'dotenv';
import path from 'node:path';
const envName = process.env.APP_ENV ?? 'development';
dotenv.config({
path: path.resolve(process.cwd(), `.env.${envName}`), // 用绝对路径,别依赖隐式 cwd
override: false,
});
# 确认进程实际工作目录与目标文件
ls -l /proc/$(pgrep -f 'node .*app' | head -n1)/cwd
ls -l /srv/app/.env.production
grep -E '^(APP_ENV|LOG_LEVEL|SITE_TITLE)=' /srv/app/.env.production
进程注入
services:
app:
image: registry.example.com/app:tag
env_file: .env.production
environment:
APP_ENV: production
LOG_LEVEL: info
[Service]
WorkingDirectory=/srv/app
EnvironmentFile=/etc/app/production.env
Environment=APP_ENV=production
建议同一份变量只保留一种注入来源。Environment= 与 EnvironmentFile= 同时写、env_file 与 environment 同时写,覆盖关系容易记混,出问题时很难一眼看出实际生效值。
# 容器内进程实际环境,只取非敏感键
docker compose exec app printenv APP_ENV NODE_ENV LOG_LEVEL SITE_TITLE
# 宿主机上直接读进程环境,只输出匹配行
tr '\0' '\n' < /proc/$(pgrep -f 'node .*app' | head -n1)/environ | grep -E '^(APP_ENV|LOG_LEVEL)='
不要在共享终端或 CI 日志里执行不加过滤的 env 命令,输出会包含全部密钥。
用一个非敏感变量做一次取值验证
最后一层验证不需要碰密钥。挑一个非敏感变量,例如站点标题 SITE_TITLE 或日志级别 LOG_LEVEL,把生产环境的文件或注入值改成一个可辨认的值,重启服务,观察前端页面或日志是否跟着变。
- 在生产配置里把 SITE_TITLE 改成带标记的值,比如 Acme Console [prod];或者把 LOG_LEVEL 从 info 改成 debug。
- 按正常流程重启或重新部署,不手工改运行时进程。
- 看浏览器标签页标题、页面头部文案,或检查日志里是否出现 debug 级别条目。
- 值生效,说明读到的就是这一份,再回头处理密钥;值没变,说明实际读取的是另一份文件或另一个注入来源,回到上一节继续查。
- 验证完把值改回去,不要让调试标记停在生产页面上。
顺序很重要:先确认读的是哪一套,再动密钥。反过来的话,密钥改了不生效,很容易误判成密钥本身有问题,接着反复轮换,排查成本反而更高。