.env 与敏感配置模板:从本机启动到运行时安全注入
一个服务在测试环境运行正常,发布后却持续返回认证失败。值班人员检查 CI,变量确实存在;检查仓库,.env.example 也有同名字段;重跑流水线仍没有改善。最终发现三个看似细小的问题叠在了一起:Compose 用宿主机 .env 完成了 YAML 插值,却没有把该值传进容器;镜像里又残留一个旧的 ENV 默认值;应用没有在启动时验证占位符,因而带着 __INJECT_AT_RUNTIME__ 正常监听端口,直到第一笔真实请求才暴露故障。
这类事故不是“少写了一行环境变量”,而是配置契约、值的权威来源、注入时机和运行时证据没有分开。.env.example 应回答应用需要哪些字段、字段采用什么格式、缺失时为何必须失败;真实 .env 只是某台开发机上的临时载体;进程环境是操作系统交给当前进程的一份启动快照;CI secret、Compose secret、Docker build secret 与 Kubernetes Secret 则分别控制不同执行边界。它们最后都可能被应用读成字符串,但安全属性、更新方式和可见范围完全不同。
先把五类对象分开,配置才不会互相覆盖
.env.example 是可提交的配置契约。它包含字段名、非敏感默认值和显式占位符,不包含任何真实密码、令牌、私钥、连接串或可直接访问环境的地址。新人复制它,只会得到一个“结构完整但敏感值尚未满足”的配置,启动门禁随后告诉他缺少哪类注入,而不是让服务以错误值继续运行。
真实 .env 是开发机上的明文覆盖文件。它适合离线实验和短期本机启动,不提供集中授权、审计、撤销或远程删除。把它加入 .gitignore 只能降低未来被 Git 新增跟踪的概率;已经跟踪过的文件、历史提交、编辑器备份、终端输出和云同步副本都不会因此消失。Git 的 gitignore 手册明确说明,忽略规则只作用于未跟踪文件。
进程环境是运行时输入,不是秘密保险箱。父进程在创建子进程时复制环境;应用通常在启动时读取并构造配置对象,之后修改 secret 存储不会神奇地更新这份内存。具有足够权限的调试器、同一安全边界内的进程检查工具、崩溃转储、诊断端点和不谨慎的 env 输出都可能看到它。
CI secret 是流水线平台保存并按作业授权注入的值。它比把值写入 YAML 好,但执行中的脚本、第三方 Action、依赖安装钩子和自托管 runner 仍可读取它。日志遮罩是降低误打印的补救措施,不是阻止恶意代码外传的访问控制。
文件型 secret 把值挂载成受权限控制的文件,应用只拿到路径。它减少“打印整个环境”造成的暴露,也更适合证书和多行内容;但进程只要被授权读取该文件,仍能复制或外传内容。文件挂载改变的是交付面,不会替代身份、最小权限和轮换设计。
链路的关键不是“值藏在哪里”,而是每次跨边界时都能回答:谁提供值,谁能读取,何时装载,怎样证明新值生效,旧值何时失效,哪些副本仍需清理。
用 Node 建立一个可执行配置契约
下面的实验使用 Node.js 22 或更高版本。Node 内置 dotenv 解析和 --env-file 入口,不需要安装第三方包;Node 环境变量文档定义了 .env 语法、process.env、process.loadEnvFile 和 util.parseEnv。选择明确的运行时基线很重要,因为较早版本没有相同的内置入口;团队若使用 dotenv 包,应单独锁定依赖版本并验证其 override 选项,不能默认与 Node 内置行为完全一致。
建立下面的目录:
env-contract-lab/
├─ .env.example
├─ .gitignore
├─ package.json
├─ package-lock.json
├─ compose.yaml
├─ Dockerfile
├─ src/
│ ├─ config.mjs
│ └─ server.mjs
└─ scripts/
├─ init-local-env.mjs
└─ check-repository.mjspackage.json 只使用 Node 内置模块,因此没有依赖安装带来的供应链脚本:
{
"name": "env-contract-lab",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"env:init": "node scripts/init-local-env.mjs",
"config:check": "node --env-file-if-exists=.env src/config.mjs",
"start": "node --env-file-if-exists=.env src/server.mjs",
"repository:check": "node scripts/check-repository.mjs"
}
}--env-file-if-exists=.env 让本机文件成为可选入口:文件存在就解析,不存在也允许 CI 或容器纯粹依靠运行时注入。Node 的规则是,宿主进程已经存在的同名环境变量优先于 env 文件;传入多个 --env-file 时,后出现的文件覆盖前一个文件中的同名值。优先级必须固定在启动命令中,不能让不同 IDE、shell 和进程管理器各自猜测。
首次建立实验目录后运行 npm install --package-lock-only --ignore-scripts 生成并提交 package-lock.json。即使当前没有第三方依赖,容器里的 npm ci 也要求 lockfile;将来加入配置校验库时,lockfile 还能固定解析器及其传递依赖。不要用“本机已经有 node_modules”掩盖一个干净构建无法恢复依赖图的问题。
模板声明字段,不赠送一个能连上环境的秘密
.env.example 使用保留域名和显式占位符:
APP_ENV=local
HTTP_PORT=3000
UPSTREAM_URL=https://api.example.invalid
SERVICE_SECRET=__INJECT_AT_RUNTIME__
SERVICE_SECRET_FILE=
SECRET_VERSION=local-unsetAPP_ENV、端口和保留域名是可以公开的配置。SERVICE_SECRET 与 SERVICE_SECRET_FILE 是二选一入口:前者接收环境变量,后者接收只读文件路径。模板故意不能直接通过校验,这样复制模板后忘记注入不会变成静默故障。占位符不能伪装成一段“看起来能用”的固定 token,否则扫描器、读者和后续维护者都难以判断它究竟是假值还是泄漏值。
.gitignore 至少保留这些规则:
.env
.env.*
!.env.example
!.env.*.example
.runtime/
*.log
*.core这不是秘密治理方案,只是一道防误操作护栏。还需要提交前 secret 扫描、代码评审、CI 门禁、历史泄漏响应和权威 secret 平台。尤其不要因为 .env 已忽略,就允许应用日志回显配置对象,或让 IDE、网盘和 AI 助手索引工作区中的所有隐藏文件。
生成合成值,而不是在教程里固定一个假密码
scripts/init-local-env.mjs 每次在本机生成新的合成值,并拒绝覆盖已有 .env:
import { randomBytes } from "node:crypto";
import { writeFile } from "node:fs/promises";
const value = randomBytes(24).toString("base64url");
const content = [
"APP_ENV=local",
"HTTP_PORT=3000",
"UPSTREAM_URL=https://api.example.invalid",
`SERVICE_SECRET=${value}`,
"SERVICE_SECRET_FILE=",
"SECRET_VERSION=local-generated",
"",
].join("\n");
try {
await writeFile(".env", content, {
encoding: "utf8",
flag: "wx",
mode: 0o600,
});
console.log("LOCAL_ENV_CREATED path=.env value=not-printed");
} catch (error) {
if (error.code === "EEXIST") {
console.error("LOCAL_ENV_EXISTS action=inspect-or-remove-explicitly");
process.exitCode = 2;
} else {
throw error;
}
}flag: "wx" 保证已存在文件不会被悄悄覆盖,mode: 0o600 在支持 POSIX 权限的平台限制为当前用户读写。Windows 的实际 ACL 由目录和用户安全描述符决定,不能看到 0o600 就假定权限已经等价;敏感开发目录仍应放在受控用户目录,不放共享盘。
运行:
npm run env:init
Get-Item -Force .env | Select-Object Name,Length预期只看到文件名和长度,终端不出现生成值。第二次运行应以退出码 2 失败并输出 LOCAL_ENV_EXISTS。这种失败证明初始化脚本没有替换正在使用的本机凭据。
启动门禁把缺失、占位和格式错误分开
src/config.mjs 在监听端口之前完成解析。错误只携带字段名和错误码,不携带收到的值:
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const placeholder = /^(__.*__|<.*>|CHANGE_ME|REPLACE_ME)$/i;
function fail(code, field) {
const error = new Error(`CONFIG_INVALID code=${code} field=${field}`);
error.code = code;
throw error;
}
function required(name) {
const value = process.env[name];
if (value === undefined || value === "") fail("MISSING", name);
if (placeholder.test(value)) fail("PLACEHOLDER", name);
return value;
}
function loadSecret() {
const inlineValue = process.env.SERVICE_SECRET ?? "";
const filePath = process.env.SERVICE_SECRET_FILE ?? "";
if (Boolean(inlineValue) === Boolean(filePath)) {
fail("SECRET_SOURCE_XOR", "SERVICE_SECRET|SERVICE_SECRET_FILE");
}
if (inlineValue) {
if (placeholder.test(inlineValue)) fail("PLACEHOLDER", "SERVICE_SECRET");
if (inlineValue.length < 24) fail("TOO_SHORT", "SERVICE_SECRET");
return { value: inlineValue, source: "environment" };
}
let value;
try {
value = readFileSync(filePath, "utf8");
} catch (error) {
fail(error.code === "ENOENT" ? "SECRET_FILE_MISSING" : "SECRET_FILE_READ", "SERVICE_SECRET_FILE");
}
if (value.endsWith("\n")) value = value.slice(0, -1);
if (value.includes("\n") || value.includes("\r")) fail("SECRET_FILE_MULTILINE", "SERVICE_SECRET_FILE");
if (placeholder.test(value)) fail("PLACEHOLDER", "SERVICE_SECRET_FILE");
if (value.length < 24) fail("TOO_SHORT", "SERVICE_SECRET_FILE");
return { value, source: "file" };
}
export function loadConfig() {
const appEnv = required("APP_ENV");
if (!["local", "test", "staging", "production"].includes(appEnv)) {
fail("ENUM", "APP_ENV");
}
const portText = required("HTTP_PORT");
const port = Number(portText);
if (!Number.isInteger(port) || port < 1024 || port > 65535) fail("PORT_RANGE", "HTTP_PORT");
const upstreamText = required("UPSTREAM_URL");
let upstream;
try {
upstream = new URL(upstreamText);
} catch {
fail("URL", "UPSTREAM_URL");
}
if (appEnv === "production" && upstream.protocol !== "https:") fail("TLS_REQUIRED", "UPSTREAM_URL");
const secret = loadSecret();
return Object.freeze({
appEnv,
port,
upstream,
serviceSecret: secret.value,
secretSource: secret.source,
secretVersion: process.env.SECRET_VERSION || "unknown",
});
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const config = loadConfig();
console.log(JSON.stringify({
event: "CONFIG_VALID",
appEnv: config.appEnv,
port: config.port,
secretSource: config.secretSource,
secretVersion: config.secretVersion,
secretPresent: true,
}));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}这里的长度只是实验约束,不是密码强度标准。真实系统应按凭据类型校验格式、签发者、受众、到期时间和用途,不能把“字符够长”当作有效凭据。文件读取只允许单行合成 token;证书、JSON 或多行私钥应采用各自解析器,不应粗暴删除换行。
配置对象被冻结,是为了让业务代码不再到处读取 process.env。冻结只阻止顶层属性重新赋值,不会擦除内存,也不是机密计算容器;价值在于建立唯一装载点,便于审计哪些字段进入日志、遥测和下游客户端。
正向实验同时证明值存在和日志没有值
先初始化,再运行校验:
npm run env:init
npm run config:check若 .env 已存在,只运行第二条。预期输出类似:
{"event":"CONFIG_VALID","appEnv":"local","port":3000,"secretSource":"environment","secretVersion":"local-generated","secretPresent":true}输出证明配置来自哪个通道、版本标签是什么以及值确实存在,但不打印上游主机、长度、摘要、前后缀或原值。对低熵密码做哈希后记录也不安全,攻击者可以离线枚举;排障关联应记录 secret 版本 ID、签发批次或轮换事件 ID,而不是值的派生物。
再验证宿主环境优先级。PowerShell 中临时设置 APP_ENV,运行结束后清理:
$env:APP_ENV = "test"
npm run config:check
Remove-Item Env:APP_ENV输出中的 appEnv 应为 test,而不是 .env 中的 local。Bash 对应入口是 APP_ENV=test npm run config:check。如果 IDE 的运行配置、shell profile 或进程管理器预先设置了同名变量,它也会覆盖文件;这就是“本机 .env 明明改了却不生效”的常见根因。
反向实验要让错误在端口监听前稳定出现
首先故意只使用模板。不要覆盖已有 .env,而是在临时目录运行,或先把本机文件改名:
Move-Item .env .env.saved
Copy-Item .env.example .env
npm run config:check
Remove-Item .env
Move-Item .env.saved .env预期退出码非零,并出现:
CONFIG_INVALID code=PLACEHOLDER field=SERVICE_SECRET接着验证完全缺失:
Move-Item .env .env.saved
npm run config:check
Move-Item .env.saved .env校验按固定顺序执行,因此这里首先出现 CONFIG_INVALID code=MISSING field=APP_ENV。这不是 secret 检查失效,而是连最先需要的非敏感字段也没有获得。若要单独验证 secret 缺失,把非敏感字段临时注入后再运行:
$env:APP_ENV = "test"
$env:HTTP_PORT = "3000"
$env:UPSTREAM_URL = "https://api.example.invalid"
npm run config:check
Remove-Item Env:APP_ENV,Env:HTTP_PORT,Env:UPSTREAM_URL此时预期出现 CONFIG_INVALID code=SECRET_SOURCE_XOR field=SERVICE_SECRET|SERVICE_SECRET_FILE,证明两种 secret 来源都没有提供。最后测试互相冲突的双来源:
$tempSecret = Join-Path $env:TEMP "env-contract-secret.txt"
node -e "require('node:fs').writeFileSync(process.argv[1], require('node:crypto').randomBytes(24).toString('base64url'))" $tempSecret
$env:SERVICE_SECRET_FILE = $tempSecret
npm run config:check
Remove-Item Env:SERVICE_SECRET_FILE
Remove-Item $tempSecret因为 .env 已提供 SERVICE_SECRET,临时环境又提供文件路径,预期仍以 SECRET_SOURCE_XOR 失败。门禁拒绝猜测优先级,防止轮换期间旧环境值压过新挂载文件。端口错误可用 $env:HTTP_PORT="80" 复现 PORT_RANGE;生产环境的非 HTTPS 上游可稳定复现 TLS_REQUIRED。每个失败都只暴露字段和判定原因。
应用日志只保留运行证据,不序列化配置对象
src/server.mjs 在校验通过后才监听端口:
import { createServer } from "node:http";
import { loadConfig } from "./config.mjs";
let config;
try {
config = loadConfig();
} catch (error) {
console.error(error.message);
process.exit(1);
}
const server = createServer((request, response) => {
if (request.url === "/health/ready") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
status: "ready",
configVersion: config.secretVersion,
secretSource: config.secretSource,
}));
return;
}
response.writeHead(404).end();
});
server.listen(config.port, () => {
console.log(JSON.stringify({
event: "SERVER_READY",
port: config.port,
secretSource: config.secretSource,
secretVersion: config.secretVersion,
}));
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => server.close(() => process.exit(0)));
}不要写 console.log(process.env)、console.log(config),也不要把错误请求头、数据库 URL 或第三方 SDK 配置直接附加到异常。通用日志脱敏器只能作为第二道防线:键名可能被改成 credential、值可能进入 URL、base64、堆栈局部变量或多行消息,无法靠一个正则覆盖所有变形。
崩溃转储同样重要。Node 的 --abort-on-uncaught-exception 可以生成供事后分析的 core 文件,这种文件可能包含进程环境和内存中的 secret。生产系统启用转储时,目录权限、加密、上传目标、保留期和删除审计必须按敏感数据处理;公开工单和普通 artifact 不得附带原始转储。APM resource attribute、span attribute、metric label 和 profiling 标签也不接收 secret,因为它们会被复制到采集器、缓存和长期存储。
AI 辅助开发又增加了一条出口:工作区索引、终端上下文、报错粘贴和自动读取工具都可能摄取 .env。项目应在 AI 工具忽略规则中排除真实 env、临时 secret、转储和凭据目录,同时把代理的文件读取权限限定到任务所需路径。忽略规则仍不是撤销能力;一旦值进入远端上下文,应按泄漏处理并轮换。
CI secret 的安全边界是作业代码,不是变量设置页
GitHub Actions 的仓库、组织和 environment secret 只有被工作流显式引用后才进入作业。下面的作业只给验证步骤注入值,并把环境部署与审批策略绑定:
name: config-contract
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
environment: test
env:
APP_ENV: test
HTTP_PORT: "3000"
UPSTREAM_URL: https://api.example.invalid
SERVICE_SECRET: ${{ secrets.SERVICE_SECRET }}
SECRET_VERSION: ${{ vars.SERVICE_SECRET_VERSION }}
steps:
# actions/checkout v4、actions/setup-node v4;用完整 SHA 固定供应链输入。
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
cache: npm
- run: npm ci --ignore-scripts
- run: npm run config:checkGitHub 的 secret 文档说明,secret 必须显式映射到输入或环境变量;引用未设置的 secret 会得到空字符串,正好由启动门禁阻断。平台会遮罩已知 secret,但变换、拆分或跨作业值不保证全部遮罩。第三方 Action 与仓库脚本运行在同一作业安全边界内,锁定到可审查版本或提交摘要、限制权限,并避免让不可信 fork 代码接触有权环境。
GitLab CI 中,敏感值应在设置页或外部 secret 提供方保存,而不是写入 .gitlab-ci.yml。变量可设置环境作用域、Protected、Masked/Hidden 或 File 类型;GitLab CI/CD 变量文档同时提醒,恶意作业代码仍能外传 masked 和 protected 变量。File 类型把变量值写入临时文件,并把变量本身设为该文件路径,适合与 SERVICE_SECRET_FILE 对接,但 runner 工作区、缓存和 after_script 仍要清理。
流水线不运行 set -x、printenv、PowerShell Dir Env: 或过度调试。自托管 runner 必须按信任级别隔离并清理工作区;在同一持久主机上运行不可信合并请求和生产发布,会让前一个作业留下的进程、文件或容器读取后一个作业的凭据。云平台认证优先使用 CI 的 OIDC 身份交换短期凭据,减少需要长期保存的静态 secret 数量。
Compose 的三套 env 语义不能混成一套
Compose 工作流里常同时出现三个文件或字段:
项目目录 .env 或 --env-file 为 Compose 模型提供插值值,例如镜像标签和端口;它不会仅凭存在就自动进入容器。服务的 env_file 把文件中的键值注入容器环境。服务的 environment 显式设置容器环境,并覆盖 env_file 中同名值。
Docker Compose 环境优先级还规定,docker compose run -e 优先级更高,镜像 ENV 则在没有 Compose 覆盖时才生效。排障时先运行 docker compose config 检查模型插值,再检查最终容器环境来源;不要因为 docker compose config 展示了值,就认定应用进程一定获得了相同值。Compose 文件本身属于受信任输入,env_file、secrets.file、include 与 extends 都可能读取宿主机文件;完整配置和调试输出可能暴露插值结果或敏感路径,因此只在受控终端检查,不把原始输出附到普通工单。
敏感值不应通过 env_file 批量注入。下面的 compose.yaml 只把非敏感配置放进环境,并把合成 secret 挂载为文件:
services:
app:
build:
context: .
environment:
APP_ENV: local
HTTP_PORT: "3000"
UPSTREAM_URL: https://api.example.invalid
SERVICE_SECRET_FILE: /run/secrets/service_secret
SECRET_VERSION: compose-mounted
secrets:
- service_secret
ports:
- "3000:3000"
secrets:
service_secret:
file: ./.runtime/service-secret先生成临时源文件,不在命令行中携带 secret 值:
New-Item -ItemType Directory -Force .runtime | Out-Null
node -e "require('node:fs').writeFileSync('.runtime/service-secret', require('node:crypto').randomBytes(24).toString('base64url'))"
docker compose up --build -d
docker compose psDocker Compose secrets 文档说明,服务只有显式声明 secret 才能访问,Compose 将其挂载到 /run/secrets/<name>。这比环境变量降低了 docker inspect 直接列出值的风险,但本地 Compose 的源文件仍在宿主机,能控制 Docker daemon 或进入容器的主体也可能读取它。Compose 服务级 secret 语义进一步说明,文件源通过 bind mount 实现;即使长语法填写 uid、gid 与 mode,这些字段也会被静默忽略,不能拿 YAML 中的 0400 证明权限已经收紧。宿主机要用 POSIX 权限或 Windows ACL 限制源文件,镜像中的非 root 运行用户还必须实际读取一次挂载文件;若权限不匹配,应调整宿主机 ACL 或受控的启动复制流程,不能把容器改回 root。它是交付机制,不是权威存储。
验证时允许检查变量名和文件元数据,不输出内容:
docker compose exec app node -e "const fs=require('node:fs'); const p=process.env.SERVICE_SECRET_FILE; console.log({path:p,bytes:fs.statSync(p).size,inlinePresent:Boolean(process.env.SERVICE_SECRET)})"
docker compose exec app node -e "console.log(Object.keys(process.env).filter(k=>k.includes('SECRET')).sort())"预期 inlinePresent 为 false,环境中只有文件路径和版本标签。若改用 env_file: .env,SERVICE_SECRET 会进入容器环境;这可以作为反向实验观察通道差异,但不要运行打印值的命令,也不要把 docker inspect 输出上传到工单。
构建时需要凭据,就让它只活在一条 RUN 指令里
私有依赖下载常把凭据误塞进 ARG 或 ENV。Docker 明确说明,构建参数和环境变量不适合传递 secret;它们可能进入镜像元数据、历史或构建 provenance。BuildKit secret mount 只在指定 RUN 指令中临时出现。
Dockerfile 可以接收一份临时 npm 配置文件:
# syntax=docker/dockerfile:1
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,required=true,target=/run/secrets/npmrc \
NPM_CONFIG_USERCONFIG=/run/secrets/npmrc npm ci --ignore-scripts
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=dependencies /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
CMD ["node", "src/server.mjs"]构建入口使用文件源。这个实验的 package.json 没有私有依赖,下面的临时文件只验证挂载通道,不是可用 registry 凭据;接入私有 registry 时由 CI 或受控本机 secret store 生成真实的短期文件,并在构建后撤销或删除:
New-Item -ItemType Directory -Force .runtime | Out-Null
Set-Content -NoNewline -Encoding ascii .runtime/npmrc "//registry.example.invalid/:_authToken=synthetic-build-only"
docker buildx build --secret id=npmrc,src=.runtime/npmrc --load -t env-contract-lab:local .required=true 让缺失挂载在下载前失败,而不是退化到匿名源或缓存结果;省略 --secret 应在该 RUN 指令失败。构建完成后,用 docker history --no-trunc env-contract-lab:local 和导出的镜像文件系统检查 secret 内容没有进入镜像。/run/secrets/npmrc 这样的挂载路径可能出现在 Dockerfile 指令历史中,不构成内容泄漏;私有 registry 主机名是否属于敏感拓扑,由团队的数据分级决定。检查日志时也不能开启会回显认证头的调试模式。
Build secret 的内容变化不会自动使相应 RUN 缓存失效。若轮换值同时意味着依赖解析结果必须重跑,应使用不敏感的版本号或轮换批次作为独立 cache-bust 输入,而不是把 secret 本身放进 ARG。另外,npm ci --ignore-scripts 会禁止依赖生命周期脚本,但是否可用取决于项目;需要脚本时必须审查依赖,因为安装脚本能够读取构建挂载和网络。
Kubernetes Secret 是 API 对象,不等于加密保险箱
Kubernetes Secret 把少量敏感数据从 Pod 模板和镜像中分离。data 字段是 base64 编码,stringData 接收明文后由 API server 合并到 data;Kubernetes Secret 文档明确指出,base64 只是编码,不提供保密性。默认情况下 Secret 在 etcd 中并未加密,集群需要配置静态加密或 KMS、最小 RBAC、审计与备份保护。
实验时从临时文件创建对象,避免把带值 manifest 提交到 Git:
$secretFile = Join-Path $env:TEMP "env-contract-k8s-secret.txt"
node -e "require('node:fs').writeFileSync(process.argv[1], require('node:crypto').randomBytes(24).toString('base64url'))" $secretFile
kubectl create secret generic env-contract-runtime --from-file=service-secret=$secretFile --dry-run=client -o yaml | kubectl apply -f -
Remove-Item $secretFile不要用 --from-literal=SERVICE_SECRET=<value> 处理真实值,命令行可能进入 shell 历史、进程列表和审计事件。即使管道没有把 YAML 显示到终端,具备 Secret get、list、watch 权限的主体仍能读到数据;能在同一 namespace 创建 Pod 的主体也可能通过挂载间接读取 Secret。RBAC 不能只禁止人手执行 kubectl get secret,还要约束工作负载创建和控制器权限。
Pod 使用文件投影:
apiVersion: v1
kind: ServiceAccount
metadata:
name: env-contract-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: env-contract-lab
spec:
replicas: 1
selector:
matchLabels:
app: env-contract-lab
template:
metadata:
labels:
app: env-contract-lab
spec:
serviceAccountName: env-contract-lab
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
containers:
- name: app
image: registry.example.invalid/env-contract-lab@sha256:REPLACE_WITH_APPROVED_DIGEST
env:
- name: APP_ENV
value: production
- name: HTTP_PORT
value: "3000"
- name: UPSTREAM_URL
value: https://api.example.invalid
- name: SERVICE_SECRET_FILE
value: /var/run/app-secrets/service-secret
- name: SECRET_VERSION
value: runtime-v2
volumeMounts:
- name: runtime-secret
mountPath: /var/run/app-secrets
readOnly: true
readinessProbe:
httpGet:
path: /health/ready
port: 3000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: runtime-secret
secret:
secretName: env-contract-runtime
defaultMode: 0440Secret 卷由 kubelet 投影并最终一致更新;使用 subPath 挂载则不会自动收到更新。示例让固定的非 root 用户通过 fsGroup 和 0440 读取投影文件,实际镜像必须支持该 UID;不同 CSI 驱动、Windows 节点或强制访问控制策略可能有不同的所有权语义,上线前要在目标节点检查 stat 和拒绝证据,不能只审 YAML。环境变量通过 secretKeyRef 或 envFrom 注入时,运行中的容器不会看到 Secret 更新,必须替换 Pod。Kubernetes 的凭据注入说明明确记录了这一点。这个示例不调用 Kubernetes API,因此关闭自动挂载的 ServiceAccount token;需要调用 API 的工作负载应改为最小权限的专用身份。即使文件内容更新,应用若只在启动时读取一次,也仍需重启或实现受控热加载;平台“文件变了”不等于连接池、SDK 客户端和业务请求已经使用新值。
外部 secret 控制器解决同步,不自动消除 Kubernetes 副本
当权威值位于 Vault 或云 secret 服务时,可以用 External Secrets Operator 把远端对象映射为 Kubernetes Secret。声明只引用远端 key,不写值:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: env-contract-runtime
spec:
refreshPolicy: Periodic
refreshInterval: 15m
secretStoreRef:
name: application-secret-store
kind: SecretStore
target:
name: env-contract-runtime
creationPolicy: Owner
data:
- secretKey: service-secret
remoteRef:
key: applications/env-contract/runtime
property: service-secretExternal Secrets Operator 的资源模型由 SecretStore 描述如何访问外部提供方,ExternalSecret 描述取哪些字段,控制器随后创建并更新 Kind=Secret。因此远端集中存储并没有自动消除集群内 Secret 副本;控制器服务账号同时拥有外部读取和集群写入能力,是高价值身份。优先使用 workload identity,限制 SecretStore 的 provider 路径和 namespace,谨慎开放集群级 ClusterSecretStore。
refreshInterval 只说明控制器何时重新同步。远端值更新后,还要等待控制器调和、Secret 卷投影、应用重读或 Pod 重建,最后才是业务请求成功。监控至少区分远端版本、ExternalSecret Ready 条件、目标 Secret resourceVersion、Pod 模板版本和应用报告的 secret 版本标签。只看到控制器同步成功,不能宣布轮换完成。
若使用 Secrets Store CSI Driver 等直接挂载外部值的方案,副本位置和更新链会变化,但节点插件、provider、工作负载身份和文件读取权限仍构成安全边界。选型时不能只问“值是否进入 Kubernetes Secret”,还要评估平台可用性、启动依赖、离线缓存、轮换延迟、故障恢复、审计完整性和多租户权限。
轮换需要双值窗口和可观测的收敛点
静态 secret 轮换常在“平台已保存新值”后失败,因为消费者并未同步切换。支持双凭据的上游可以采用下面的状态机:
SECRET_VERSION 记录不敏感的版本标签,让健康检查、部署状态和日志能证明实例处于哪一批;它不能由应用自行声称,最好来自 secret 平台版本或发布系统。验证新值时应建立真实但受控的认证请求,观察成功率和旧值拒绝证据,不记录请求中的认证内容。
如果上游只允许一个有效值,双写不可用。此时要先缩短连接寿命、降低缓存时间,设计能够重建连接池的滚动发布,预留旧配置回退入口,并在切换窗口内控制并发。强行同时把新旧值拼进一个环境变量,只是把协议不支持的问题推给应用解析,还会扩大泄漏面。
回退只在旧值尚未撤销时成立。旧值撤销后,回退动作必须重新签发,不能从聊天记录、旧 .env 或 CI 日志“找回旧密码”。轮换完成的判断是:全部活跃消费者报告新版本,持续业务探针成功,旧凭据认证被确定拒绝,旧 env、临时文件、runner、容器和开发机副本已经清理。
一旦进入 Git,先撤销,再讨论改写历史
发现 .env 被提交时,第一动作不是把文件加入 .gitignore,也不是马上 force push。先停止继续分发,确认凭据能访问什么,撤销或轮换它,保留事故时间线和受影响消费者。只要旧值仍有效,任何历史清理都不能降低即时访问风险。
GitHub 的敏感数据清理说明同样把撤销或轮换放在历史改写之前。git-filter-repo 能重写中央仓库历史,却不能擦除已有 clone、fork、PR 引用、缓存页面、构建 artifact 和搜索索引;改写还会改变提交哈希并可能让未清理的旧 clone 再次污染仓库。
处置流程应由仓库 owner、安全 owner 和凭据 owner 协同:冻结相关合并,撤销值,识别提交、分支、标签、fork、artifact 和 runner 副本,评估是否需要历史改写,通知协作者重新克隆或清理本地对象,最后增加 secret 扫描和模板门禁。不要把真实泄漏值作为扫描规则样本写回仓库;使用提供方指纹、字段模式或本地受控规则。
scripts/check-repository.mjs 可以阻止最明显的 env 误跟踪,并确认模板仍只有占位符:
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
const tracked = execFileSync("git", ["ls-files"], { encoding: "utf8" })
.split(/\r?\n/)
.filter(Boolean);
const forbidden = tracked.filter((name) =>
/(^|\/)\.env(?:\.|$)/.test(name) && !/\.example$/.test(name)
);
if (forbidden.length) {
console.error(`TRACKED_ENV_FILE count=${forbidden.length}`);
process.exit(1);
}
const template = readFileSync(".env.example", "utf8");
if (!template.includes("SERVICE_SECRET=__INJECT_AT_RUNTIME__")) {
console.error("TEMPLATE_SECRET_MUST_BE_PLACEHOLDER");
process.exit(1);
}
if (/SERVICE_SECRET=(?!__INJECT_AT_RUNTIME__$).+/m.test(template)) {
console.error("TEMPLATE_CONTAINS_NON_PLACEHOLDER_SECRET");
process.exit(1);
}
console.log("REPOSITORY_ENV_CONTRACT_OK");这道检查只覆盖文件名和一个字段,不能替代 Gitleaks、TruffleHog 或托管平台 secret scanning。它的价值是把项目特有的不变量变成快速、确定、跨平台的本地与 CI 证据。
清理回滚要覆盖进程、容器、构建缓存和诊断副本
本机实验结束时先停进程,再删除明文载体:
docker compose down --remove-orphans
Remove-Item -Recurse -Force .runtime -ErrorAction SilentlyContinue
Remove-Item .env -ErrorAction SilentlyContinue
Remove-Item Env:SERVICE_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:SERVICE_SECRET_FILE -ErrorAction SilentlyContinue
git status --short删除文件不保证磁盘介质、备份和同步服务中的历史副本立即消失;高敏感值依靠撤销失效,而不是依靠安全删除承诺。Docker 构建若曾错误使用 ARG、ENV、COPY 或把 secret 写入某层,必须删除并重建受影响镜像、registry tag、构建缓存和 provenance,随后轮换值。仅在最后一层 rm 文件无法从旧层移除内容。
Kubernetes 实验结束可执行:
kubectl delete deployment env-contract-lab --ignore-not-found
kubectl delete externalsecret env-contract-runtime --ignore-not-found
kubectl delete secret env-contract-runtime --ignore-not-foundcreationPolicy: Owner 影响 ExternalSecret 与目标 Secret 的所有权行为,但不能代替对外部权威值的撤销。删除集群对象后,还要确认外部 secret、工作负载身份、审计记录、节点或插件缓存、日志和备份的保留责任。回滚控制器版本前也要检查 CRD 兼容性和目标 Secret 的 ownership,避免旧控制器误删仍被工作负载使用的对象。
架构选型看权威来源、刷新语义和故障半径
个人本机实验可以使用被忽略的 .env,前提是值权限低、寿命短、能随时撤销,且启动门禁与扫描已经启用。共享开发环境不应靠群聊分发 .env;应使用团队密码库、加密配置或集中 secret 平台,再由每个开发身份按权限取得值。
CI 平台 secret 适合平台自身作业的小量凭据,但仓库写权限、工作流评审、runner 信任和环境审批决定真实安全边界。跨多个平台和运行环境的大量长期 secret 更适合集中权威存储或工作负载身份;否则每个平台都会形成独立副本和轮换队列。
Compose secret 改善本机容器的文件交付方式,不提供远端授权和审计。Kubernetes Secret 提供 API 对象、namespace、RBAC 和 Pod 注入,但必须补上 etcd 加密、控制器权限、备份和轮换。外部 secret 控制器减少手工复制并提供调和,却增加高权限控制器和外部平台依赖。直接运行时获取动态 secret 能减少静态副本,但应用启动和平台可用性耦合更强,还要处理租约续期、缓存和故障降级。
成本不能只看产品费用。需要计算平台接入与值班、每个 secret 的 owner 维护、轮换演练、审计保留、KMS/API 调用、控制器资源、开发等待时间和退出迁移。最便宜的 .env 方案可能把事故响应和离职回收成本转嫁给团队;最强的平台若没有可用性和 owner,也会让开发者建立旁路。
长期治理以可证明的不变量收尾
团队不需要在看板上统计“创建了多少 secret”,而要持续证明几项不变量:模板和仓库没有真实值;每个运行环境只有一个权威来源;应用在使用前拒绝缺失、占位、冲突和错误格式;生产进程不会从个人 .env 或个人账号取值;日志、遥测、构建层、artifact、转储和 AI 上下文不保存 secret;轮换能观察新版本收敛并证明旧值失效;离职、仓库归档和平台退出都有副本清理责任人。
准入评审要同时看代码和执行边界。新增依赖、Action、容器镜像、初始化脚本、观测 SDK 与 AI 工具都可能读取环境或文件;依赖升级不能只跑功能测试,还要检查权限、网络出口和安装脚本变化。配置 schema 也需要版本化:删字段前先确认所有部署模板和消费者已经迁移,新增必填字段先让预发布失败可见,再进入生产门禁。
运行指标只记录不敏感状态,例如配置校验失败次数、按错误码分类的启动拒绝、ExternalSecret 调和失败、目标 Secret 版本落后、实例 secret 版本分布和轮换超时。阈值来自部署规模、同步周期与 SLO,不使用脱离负载的万能数字。任何指标标签都不放 secret 值、用户名、完整路径或可枚举租户信息。
当新人可以从模板得到明确失败并独立完成本机启动,当 CI、Compose 和 Kubernetes 都能说明值由谁注入、何时更新、在哪里可见,当轮换能够证明新值已收敛而旧值已拒绝,这套配置链才算真正可运营。.env 仍然可以是高效的开发入口,但它不再承担自己做不到的存储、授权、审计和撤销责任。
