1. 别把"配置环境变量"当成"export 一下"
我在生产环境排查过不少启动失败、连错库、读不到配置的问题,最后根因经常不是代码,不是网络,而是环境变量配错了层。生产环境的环境变量配置,看起来就是"把值填对"的事,但真正的问题从来不是某个值,而是这套变量在整个部署链路里怎么被注入、被继承、被覆盖的。
1.1 环境变量不是"系统的全局设置",而是"进程的私有上下文"
很多刚转运维或后端的同学有个根深蒂固的误解:以为在终端里 export 一个变量,整个服务器就"记住"了,以后所有进程都能用。实际上环境变量只是当前 shell 和它启动的子进程的私有上下文。你在 /etc/profile 里写 export JAVA_HOME=/opt/jdk17,登录 shell 会读,但你用 systemd 启动的 Java 服务,或者 docker-compose 启动的容器,根本不经过这个 shell。所以经常出现排查时的经典一幕:在服务器终端里 echo $DB_HOST 明明有值,重启服务后照样报"找不到数据库",因为你服务进程的环境和终端环境不是一回事。
我还见过更隐蔽的情况:线上服务明明配了 DB_HOST=10.0.0.5,但某个 Java 应用启动时连到了测试库。后来查下来,是因为 systemd 的服务文件里没有配 DB_HOST,而 /etc/profile.d/ 里残留了开发环境 export 的旧变量,应用在某个启动脚本里用 source /etc/profile 把它带了进来。环境变量不是全局变量,它说的是"这个进程从父进程那里继承了什么、被启动器注入了什么"。
1.2 跨环境漂移是怎么发生的
很多人以为只要把配置写进环境变量,就不会有 dev/test/prod 漂移。其实恰恰相反,环境变量用不好,漂移得更隐蔽。开发机上有个 .env,里面有 REDIS_HOST=127.0.0.1;测试环境复制了一份,改成测试库地址;生产环境忘了把 .env 传上去,于是应用代码里 os.getenv("REDIS_HOST", "localhost") 这个默认值"救了命"。服务没崩,但生产环境 Redis 连的是本机,而本机根本没跑 Redis,结果就是请求莫名超时、缓存全部穿透。这种问题比直接报错难查十倍,因为服务进程是活着的,只是每一处行为都不对。
十二要素应用里有一条原则:配置要和代码严格分离。把配置从代码里拿出来放进环境变量,这个方向是对的,但还不够。环境变量不是让你往一个地方一塞就完了,而是要明确"谁来注入、注入给谁、何时生效"。生产环境里,同一个服务可能由 systemd 托管、可能跑在 docker-compose 里、可能跑在 Kubernetes 上,每一层的变量注入机制完全不同。不把这条链路理清楚,环境变量就是你线上故障的隐形炸弹。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从单机到容器,环境变量到底该配在哪一层
先说结论:生产环境没有一个"万能位置"适合放所有环境变量。它取决于你用什么方式拉起进程。我习惯把候选位置分成四层:登录 shell 配置、systemd 的 Environment/EnvironmentFile、docker-compose 的 environment/env_file、Kubernetes 的 ConfigMap/Secret。选层之前,先看懂每一层的生效范围。
| 配置位置 | 生效范围 | 生产环境适用性 | 典型坑 |
|---|---|---|---|
| /etc/profile、~/.bashrc | 登录 shell 和交互终端启动的子进程 | 只建议放 PATH、JAVA_HOME 这类基础变量 | systemd/容器不读,重启后服务丢变量 |
| /etc/environment | 登录阶段全局环境 | 适合基础项,不适合业务密钥 | 需要重启登录进程,解析规则和 shell 不一样 |
| systemd unit 的 Environment/EnvironmentFile | 被 systemd 托管的服务进程 | 单机部署首选 | EnvironmentFile 路径权限、字段格式容易出错 |
| docker-compose 的 environment/env_file | 容器内进程 | 容器化部署常用 | .env 和 env_file 混用,覆盖顺序靠猜 |
| K8s 的 ConfigMap/Secret | Pod 内进程 | 多节点/集群环境必须用 | 配置更新后 Pod 不重启不生效 |
2.1 systemd 的 EnvironmentFile:单机部署的"标准答案"
如果你的生产环境是传统的一台服务器部署一个 Java/Python/Node 服务,我强烈建议别再用 /etc/profile 那一套。把服务交给 systemd 之后,用 EnvironmentFile 是最稳的。示例:
ini复制# /etc/app/app.env
APP_ENV=production
DB_HOST=10.0.0.5
DB_PORT=3306
DB_USER=app
DB_PASSWORD=change_me
LOG_LEVEL=info
JAVA_OPTS=-Xms512m -Xmx1024m
然后在 service 文件里指定:
ini复制[Service]
EnvironmentFile=/etc/app/app.env
ExecStart=/usr/bin/java $JAVA_OPTS -jar /opt/app/app.jar
Restart=always
这里有个值得注意的细节:EnvironmentFile 里不要写 export,一行一个 KEY=VALUE 就行,注释用 # 开头。值里如果有特殊字符,比如密码里的 $、引号、空格,我通常建议放到单独的 secret file 里让应用自己读,或者用 systemd 的 LoadCredential 机制,而不是跟解析器硬刚。因为 EnvironmentFile 的解析规则和 shell 不完全一样,你以为写了个双引号就能解决,实际不同版本解析行为可能有差异,线上环境没必要在这种地方赌运气。
2.2 登录 shell 里的基础变量也要收敛
这不是说 /etc/profile、/etc/environment 就该完全禁用。像 JAVA_HOME、MAVEN_HOME、NODE_HOME 这类工具链路径,放在 /etc/profile.d/ 里让运维同学登录后能用,是合理的。但生产服务不要依赖用户 shell 里的 PATH。我见过有人写启动脚本时用 java -jar,结果 root 用户和 deploy 用户的 JAVA_HOME 指向不同版本,同一套代码在两个用户下启动结果不一样。正解是:systemd unit 的 ExecStart 里用绝对路径,或者显式写 Environment=JAVA_HOME=/opt/jdk-17。这样无论谁登录、什么 shell 加载情况,服务进程的行为都是可预期的。
2.3 容器编排里的 env 注入:先分清三个东西
到了 docker-compose 这层,最大的坑是 .env、env_file、environment 三个东西长得像,作用完全不同。
.env:是给 Compose 做变量替换的,不是自动注入到容器里的。env_file:是让 Compose 把某个文件里的KEY=VALUE读进去,作为容器环境变量。environment:是直接在 compose 文件里写的容器环境变量。
很多人写 docker-compose.yml 时,在服务同级放了一个 .env,里面写了 DB_PASSWORD=xxx,以为容器里就有 DB_PASSWORD 了。但如果你 compose 文件里没有通过 ${DB_PASSWORD} 引用它,也没有写 env_file,容器内部根本看不到这个变量。验证方式是 docker compose config,这个命令会把你经过变量替换后的最终 compose 配置打印出来,能直观看到哪些变量真正生效了。
3. 常见技术栈里,读取和注入环境变量的正确姿势
不同技术栈读取环境变量的方式大同小异,但在生产环境里的侧重点很不一样。我按 Java、Python、Node 三类最常见的应用分别说,看完基本能覆盖大多数后端服务的配置场景。
3.1 Java/Spring Boot:别只盯着 JAVA_HOME
Java 服务生产环境里,JAVA_HOME 确实重要,但更常见的问题是业务配置没有和代码解耦。Spring Boot 项目里,很多人喜欢在 application.yml 里写:
yaml复制spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/db?useSSL=false
username: root
password: 123456
这种写法在本地开发效率高,但一旦有人把代码推到测试环境跑,很容易因为改漏了某个配置连错库。生产环境应该用占位符引用环境变量:
yaml复制spring:
datasource:
url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false
username: ${DB_USER}
password: ${DB_PASSWORD}
然后在 systemd unit 或容器环境里注入 DB_HOST、DB_PORT、DB_NAME 这些变量。这里有个容易踩的坑:Spring Boot 的占位符支持默认值写法,比如 db.url: ${DB_HOST:localhost}。开发时很方便,但在生产环境里,我建议重要配置不要留默认值。一旦环境变量没传,默认值会让服务"带病启动",连接一个你根本没打算连的地址,日志里还看不到明显报错,这是排障成本最高的一种故障。
另一个容易忽略的点是 Spring Boot 的配置优先级。环境变量、Java 系统属性、命令行参数、application.yml,它们之间有明确的覆盖关系。我见过有人在启动命令里写 --spring.profiles.active=prod,systemd unit 里也写 Environment=SPRING_PROFILES_ACTIVE=prod,结果两边不一致时行为非常难判断。我的习惯是:环境相关的开关统一放在 systemd 或容器环境变量这一层,启动脚本里不写死 profile,这样换环境时只需要改环境变量,不用改启动命令。
关于 Maven 和 JDK,单独提一句:构建机器上的 JAVA_HOME 和 MAVEN_OPTS 属于构建环境变量,它们影响的是编译和打包,不是生产运行。很多 CI 流水线里通过 Jenkins 配置了一堆环境变量,但这些变量只是在构建机上生效。部署的时候,构建产物应该把运行所需的环境变量清单一起带过去,而不是依赖目标服务器上的全局配置。
3.2 Python:os.environ 与 .env 的边界
Python 应用里,环境变量最常见的用法是 os.environ["KEY"] 或者 os.getenv("KEY", "default")。前者的好处是变量缺失时直接抛 KeyError,启动即暴露问题;后者带了默认值,用起来灵活,但在生产环境却可能变成隐患。
我之前排查过一个 Django 项目,线上 Redis 突然总是连不上,后来发现配置里写的是 REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1"),而生产环境根本没设 REDIS_HOST。代码不会报错,默认值让它连到了本机,但本机没有 Redis。这种问题在开发环境遇到得少,因为开发环境默认值往往是"能用的",到了生产环境默认值就变成了"错的但不报错"。所以我在生产项目里要求:所有敏感和关键配置,都用 os.environ["KEY"] 取,不加默认值。
另外,Python 社区流行的 python-dotenv 适合本地开发,但我不建议生产环境依赖它。它会从当前工作目录找 .env 文件,如果线上某个目录恰好残留了一个旧的 .env,应用启动时会优先读到它,而你的 systemd 配置和环境变量反而被忽略了。生产环境的环境变量来源应该是明确的、统一的,不应该让应用自己去磁盘上猜文件。
关于容器里跑 Python/AI 推理服务,最近经常有人问我 vLLM 这类模型服务怎么配环境变量。其实逻辑一样:HUGGINGFACE_TOKEN、模型缓存目录、并发数、显存策略这类配置,尽量在 docker-compose 的 environment 里显式设置,不要散落在各种启动脚本里。比如:
yaml复制services:
vllm:
image: vllm/vllm-openai:latest
environment:
- HUGGINGFACE_TOKEN=${HF_TOKEN}
- VLLM_CACHE_DIR=/models/cache
command: >
--model Qwen/Qwen2.5-7B-Instruct
--gpu-memory-utilization 0.9
这样模型服务每次启动的配置都是可预期的,换模型、换 token 时也只需要改环境变量,不需要动镜像和代码。
3.3 Node.js:process.env 与构建变量别搞混
Node.js 读取环境变量很简单,process.env.NODE_ENV、process.env.DB_HOST 就是全部。实际在部署中容易出问题的是两类:一类是开发依赖的 .env 文件被带到了生产环境,另一类是前端构建变量和 Node 服务运行变量混在一起。
先说完前者。Node 项目几乎人手一个 .env,本地跑非常方便,但如果你们根本没做环境隔离,生产机器上还是原来那套开发环境变量,那 process.env.DB_HOST 就会读到 127.0.0.1。我推荐的做法是:仓库里只提交 .env.example,里面列出所有需要的 key 和说明;真实环境的变量文件通过 CI/CD 或者配置管理系统分发到服务器,并且保证文件权限是 600。
再说明后者。如果你用 Vite/Webpack 打包前端,构建时会把 VITE_API_BASE_URL 这类变量打进 bundle,这是构建期变量;而 Node 后端服务启动时读的 process.env 是运行期变量。两者不能混为一谈。CI 里执行 npm run build 时设置的是构建变量,跟 server 进程跑起来后读取的运行变量是两套。很多人上线后发现前端页面请求地址不对,就是因为把生产环境变量配置在了构建阶段,但后端服务起来时根本没有这些变量。
Node 生态里的 pnpm/npm 安装路径也一样,属于工具链环境变量。运维在部署机上加 PNPM_HOME、把 pnpm 加到 PATH,是为了让部署脚本能执行 pnpm install,但这和 Node 应用运行时的 process.env 没有任何关系。不要把工具链变量和服务业务变量混在同一个文件里,最好分开管理,避免权限泄露。
4. 容器化部署里最容易翻车的 env 注入细节
容器化部署以后,环境变量的注入链路又变长了。 Compose 的 Variable Substitution 先是把宿主机 shell/.env 的值替换进 compose 文件,然后 compose 再决定哪些变量作为容器的环境变量。任何一个环节理解偏差,容器里读到的值就可能和你想的不一样。
4.1 docker-compose 的优先级:environment > env_file > 镜像 ENV
先说个容易记错的重点:如果同一个变量在 environment 和 env_file 里都出现了,environment 会覆盖 env_file。如果两者都没写,容器启动时才叠加镜像 Dockerfile 里 ENV 定义的变量。所以排查容器环境变量时,不要只盯着一个地方,而要看最终合并后的结果。
同样,宿主机 shell 里的 export 变量在 Compose 里的角色是"插值来源"。你在宿主机上 export DB_HOST=1.2.3.4,然后 compose 文件里写 DB_HOST=${DB_HOST},这个值会被替换进去。但如果你宿主机有 env 变量而 compose 文件里没有用 ${} 引用,那容器内就不会出现这个变量。这个区别非常重要。
一个直观例子:
yaml复制# docker-compose.yml
services:
app:
image: myapp:latest
env_file:
- ./backend.env
environment:
- APP_ENV=production
- DB_HOST=${DB_HOST}
如果宿主机 .env 文件里定义了 DB_HOST=10.0.0.8,Compose 会把它替换进 DB_HOST。如果宿主机没这个变量,Compose 会替换成空字符串(新版可能会警告),容器里 DB_HOST 变成空值,应用连数据库时就会报一个莫名其妙的 host 错误。所以每次变更环境变量后,先用 docker compose config 检查最终渲染结果,是一个成本极低、收益极高的习惯。
4.2 YAML 值类型陷阱,以及 entrypoint 里的 envsubst
Compose 文件里的 environment 值,如果写的是裸的 false、true、数字,YAML 解析时可能先当成布尔值和数字处理。比如:
yaml复制environment:
- APP_DEBUG=false
某些版本会把 false 转成字符串,也可能直接丢给容器一个 YAML 布尔值转换后的字符串,表现很不稳定。我的习惯是全部加引号:
yaml复制environment:
- APP_DEBUG="false"
- RETRY_TIMES="3"
另一个常见场景是:镜像里的默认配置文件是模板,容器启动时需要把环境变量替换进去。这时很多人会写一堆 sed 命令,但特殊字符一多就崩。更稳的办法是用 envsubst:
bash复制command: /bin/sh -c 'envsubst < /etc/app/config.tpl > /etc/app/config.json && exec app'
注意 envsubst 默认会替换所有 $VAR 形式的内容,如果你的模板里有需要保留的 $,要么用 export 白名单,要么换更强的模板方案。这是我在线上部署 Nginx 和 Java 服务时经常要碰到的细节。
4.3 Kubernetes 的 env 更新不等于"马上生效"
K8s 里用 ConfigMap/Secret 注入环境变量,是个很标准的方式:
yaml复制apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_ENV: production
DB_HOST: mysql-svc
Deployment 里这样引用:
yaml复制envFrom:
- configMapRef:
name: app-config
但有一个坑我栽过:修改 ConfigMap 后,已经运行中的 Pod 里的环境变量不会自动变。必须 rollout restart Deployment,Pod 重建后新配置才会生效。这不是 K8s 在"偷懒",而是环境变量的设计如此:进程启动时一次性读取,之后除非进程主动监听,否则不会动态更新。
更麻烦的是,如果配置里有密码,可能涉及敏感信息。K8s Secret 虽然值只是 base64 编码,不是加密,但它避免了把密码明文写进镜像和部署 YAML。真正要做到静态加密,得依赖 etcd 加密或者外部密钥管理服务。在这之前,至少保证 Secret 的访问权限是受限的,别把它放到任何人都能读的仓库里。
5. 密码到底该不该放环境变量:我的答案是"分情况"
几乎每隔一段时间就有人问:"生产环境把数据库密码放到环境变量里,可以吗?"我的回答是:比硬编码强,但远不是终点。环境变量只是把密码从代码仓库里挪到了进程环境里,并没有解决密钥存储和轮换的所有问题。
5.1 环境变量不是保险箱
很多人以为环境变量是"藏在系统内部"的,所以安全。但技术上一旦进程跑起来,同一用户和有权限的进程就能通过 /proc/<pid>/environ 看到全部环境变量。在容器里也类似,docker inspect 可以直接看到容器的环境变量配置。K8s 里如果把 Secret 通过环境变量注入,Pod Spec 下载到集群后,能看到 Secret 值(base64)的人比你以为的要多。
所以我的建议是:如果你处理的只是低敏感配置,比如日志级别、开关类参数,环境变量完全没问题;如果是数据库密码、API Key、私钥这类高敏感信息,要用专门的密钥管理方案。至少要做到:
- 配置文件的权限是
600,属主是运行服务的用户; - 不再使用的环境变量及时清理;
- 日志系统里不要打印环境变量值;
- 不要在启动命令的进程列表里暴露密码(例如
ExecStart=/usr/bin/app --password=xxx)。
5.2 从 EnvironmentFile 到 K8s Secret,密钥管理的分层策略
单机场景下,我最常用的是 systemd EnvironmentFile,配合权限控制。把 /etc/app/app.env 的属主设为 app 用户,权限 600,这样其他用户无法读取。在此基础上,还可以用 systemd 的加密凭据特性(LoadCredential)把密钥以文件方式挂给服务,但这需要一定学习成本,不是所有团队都值得一上来就上。
容器场景下,docker-compose 本身没有完整的 secret 管理。单机 Docker Compose 可以用 env_file + 权限控制,Swarm 模式下有原生的 secret 机制,但大部分团队并没有用 Swarm。所以更现实的方案是:开发测试用 env_file 可以接受,生产环境要么用 K8s Secret,要么接 Vault 这类外部密钥服务。
K8s 里把 Secret 通过文件挂载而不是环境变量注入,是我个人偏好的方式。原因很简单:环境变量注入的 Secret 一旦被 Pod 管理员日志、CI 日志或调试命令打出来,回收和旋转的成本很高;而挂载成文件,应用启动时读文件,密钥更新时可以选择滚动方式让应用重新加载,不用每次重启 Pod。但这样做需要应用代码支持从文件读配置,对传统应用可能改造量较大。如果现阶段没法改代码,用环境变量注入 Secret 也无可厚非,但一定要把访问权限和审计做好。
5.3 一个具体建议:避免把密码写在连接串里
我见过不少 Java 项目把数据库密码拼在 JDBC URL 里:
yaml复制url: jdbc:mysql://user:password@10.0.0.5:3306/db
这种写法的危害是:连接串往往会被记录到应用日志、监控系统、错误上报平台里,密码和价值高的密钥一样被"顺便"泄露。更合理的姿势是连接串里只放 host/port/db,用户名和密码单独从环境变量或密钥文件读取:
yaml复制spring:
datasource:
url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}
username: ${DB_USER}
password: ${DB_PASSWORD}
这样即使日志里打印了连接串,也不至于把账号密码一起暴露。类似的还有 Redis URL、消息队列连接串,原则一样。
6. 生产环境变量排障:我处理线上故障时最常用的检查路径
环境变量出问题的表现千奇百怪,但排查链路是有规律可循的。我的习惯是从"进程实际看到的环境变量"出发,而不是从配置文件或 shell 环境出发。因为配置写在哪儿不重要,进程真正读到什么才重要。
6.1 先看进程环境,而不是 shell 环境
登录服务器后第一件事,不是 env 或 echo $VAR,而是找到目标进程。比如 Java 服务,先找到 PID:
bash复制pgrep -f app.jar
然后直接看这个进程的环境:
bash复制cat /proc/12345/environ | tr '\0' '\n'
这个命令读的是 /proc/<pid>/environ,也就是当前进程真正继承和注入的环境变量。如果这个文件里没有 DB_HOST,那么无论你 shell 里显示什么,应用代码里都读不到 DB_HOST。
systemd 托管的服务,可以用 systemctl show <service> -p Environment 看 unit 文件里配置的环境变量,但它显示的主要是 unit 层面的 Environment/EnvironmentFile 内容,不一定包含进程最终合成后的完整环境。更可靠的还是看 /proc/<pid>/environ。
容器场景更简单:
bash复制docker exec <container> env
或者:
bash复制docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' <container>
K8s 里就是:
bash复制kubectl exec <pod> -- env
这三种方式能直接回答"进程看到的环境变量是什么",比看一堆配置文件上的推断快得多。
6.2 三类最常见的"环境变量故障"和分析思路
我把生产环境遇到的环境变量问题分成三类:
| 现象 | 大概率原因 | 验证方向 |
|---|---|---|
| 服务起来就连错库/连错缓存 | 环境变量没有真正注入进程,读到的是默认值或旧值 | 检查进程 environ,比对 DB_HOST |
| 重启后服务起不来 | 变量写在 shell profile 里,而服务由 systemd/容器托管 | 查看 systemd unit 或 container env,确认重启后来源 |
| 某些环境的配置正常,生产环境行为异常 | 代码里带默认值,生产环境没传变量,默认值掩盖了问题 | 审查代码中所有 getenv 调用,确认关键配置不允许 fallback |
第一类问题的处理方式很直接:把进程 environment 和期望值做 diff。有一次我们一个 Spring Boot 服务连到了测试库,就是因为 docker-compose 的 env_file 指向了 backend.dev.env,而部署脚本没有任何文件名校验。这种问题用 docker compose config 就能看出来,但因为没人看,线上跑了一下午。
第二类问题最典型的是:有人为了让某个服务启动时拿到变量,把 export DB_HOST=xxx 写进了 /etc/rc.local 或者 /etc/profile。登录验证没问题,但 systemd 服务启动时根本不经过这些文件。于是重启后服务挂了,手动在生产终端里再 source 一下又能起。解决方法是把变量从 shell profile 挪到 systemd 的 EnvironmentFile 里,并从重启后的进程环境验证。
第三类问题最隐蔽,因为服务没有报错,只是行为不对。比如 Python 应用里 os.getenv("CACHE_HOST", "localhost"),生产环境没设置 CACHE_HOST,进程起来后连的是本机,但本机没有缓存服务,于是大量请求绕过缓存直接打数据库。遇到这类问题,我在代码审查阶段就会拦下:所有关键外部依赖的地址,不允许带默认值;如果没有配置就启动失败,而不是假装自己知道该连哪。
6.3 在启动环节做一次"配置自检"
治本的方法,是让应用启动时对环境变量做校验。比如 Spring Boot 项目可以加一个启动检查类,判断必要配置是否存在,缺失就直接抛异常,阻止进程进入"半健康"状态。Python 项目也一样:
python复制import os
required_env = [
"DB_HOST",
"DB_PORT",
"DB_USER",
"DB_PASSWORD",
]
for key in required_env:
if key not in os.environ:
raise RuntimeError(f"Missing required env: {key}")
Node.js 里可以用类似断言:
javascript复制const required = ["DB_HOST", "DB_PORT"];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing env: ${key}`);
}
}
这个步骤看起来简单,实际价值非常大。它能把"环境变量没配好"这个问题从运行期模糊故障,提前到启动期明确报错。一旦配置缺失,第一时间就能定位到是哪个变量没传,而不是在数据访问层里猜。
7. 长期维护:环境变量配置也要有"版本与清单"
环境变量配置写得再清楚,过了几个月、换了几拨人,照样会变成一团乱麻。我自己的长期维护经验可以总结成三条:有模板、有清单、有变更记录。
7.1 仓库里放 .env.example,生产值永远不提交
每个项目仓库里都应该有一个 .env.example,它描述的是"这个项目需要哪些环境变量"。值可以用空字符串或占位符,但 key、注释、是否必填、敏感级别要写清楚。例如:
code复制# 运行环境: dev / test / prod
APP_ENV=production
# 数据库连接
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=app
# 生产环境通过密钥分发,不写在此文件
DB_PASSWORD=change_me
生产环境的真实 .env 或 EnvironmentFile,通过 CI/CD 流水线、配置管理工具或密钥服务分发到目标服务器,并且加入 .gitignore。这样即使有人误操作 git add -A,也不会把真实密码带上公仓。
7.2 构建变量和运行变量分开管理
Jenkins/GitHub Actions 里配置的环境变量,本质上是构建/部署流程的变量,不是生产服务运行时的变量。比如 BUILD_NUMBER、GIT_COMMIT,这些是 CI 内置变量;JAVA_HOME、MAVEN_OPTS 是构建工具变量;而生产服务的 DB_HOST、REDIS_PASSWORD 是运行变量。把它们分开管理,能避免很多"构建机上能跑,服务器上不能跑"的混乱。
我在 CI 里的做法是:构建阶段只负责产出可发布的产物和一份"部署变量模板";部署阶段再把模板和真实环境变量合并,生成目标服务器或 K8s 清单。CI 里的环境变量不直接写进生产配置,因为那样会让构建配置和生产配置强耦合。一旦两套变量混在一起,排查问题时要同时看构建日志、部署脚本、服务器环境,非常痛苦。
7.3 每次变更都留下记录,并做一次"最小验证"
环境变量变更没有代码 review 那么显眼,很多人改完就跑,出了事才回来翻。我现在的习惯是:
- 改环境变量前,先备份当前生效的配置;
- 改完之后,用
docker compose config或systemctl show验证渲染结果; - 重启服务后,立刻检查进程 environ 和关键日志;
- 在变更记录里写清楚改了哪个变量、为什么改、影响什么服务。
另外有一点容易被忽略:密钥轮换后,服务不一定自动生效。systemd 里的 EnvironmentFile 改了,必须 restart 服务;K8s 里的 ConfigMap 改了,必须 rollout restart Deployment。这个"改配置 + 重启"的动作一定要在同一份操作记录里体现,否则很容易出现"配置已经改了,但线上还在用旧值"的情况。
我个人的最后一个小建议是:不要在部署脚本里用 set -x 打印每一条命令,尤其当环境变量里有密码时,这条命令会把整个环境变量列表打进 CI 日志。如果真的需要调试,打印变量名就可以了,不要打印值。线上环境变量配置这件事,更多是给自己留后路——配置可以错,但错了之后要能在最短时间内定位到是哪一个变量、从哪一层注入出了问题。想明白这一点,你的维护成本会低很多。
