#DevOps

密钥管理:别再把密钥写进代码

API key、数据库密码、JWT secret——硬编码进代码 = 定时炸弹。本文讲清密钥管理的正确姿势:.env(开发)→ 环境变量(Docker)→ 密钥管理服务(HashiCorp Vault / AWS Secrets Manager 生产),轮换与审计,以及密钥泄露后的应急。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月14日·更新于 2026年7月23日

一、硬编码密钥的灾难

GitHub 公开仓库里每天新增的泄露密钥数以万计。一个新员工把公司代码 push 到自己的公开仓库"备份一下",一个不小心的 git log 截图发到群里,一次 CI 日志没脱敏直接输出——任何一次都足以让 AWS 账单一夜飙到五位数,让数据库被拖库,让 OpenAI key 被拿去刷量。

这不是小概率事件。GitKraken、Codecov、三星、Toyota 都因为密钥泄露上过头条。真正的灾难不是密钥泄露本身,而是密钥和代码绑在一起:仓库一旦泄露,所有密钥同时泄露,而且因为 Git 历史的存在,即使你删了那一行,密钥仍然躺在每一个 commit、每一个 tag、每一个 fork 里。

第一条原则,也是唯一不可妥协的一条:密钥必须和代码分离。代码仓库是给人看的、给 CI 跑的、会被 fork 会被镜像——它天然是公开的,即使现在是 private 的。把密钥当一等公民从代码里剥离出去,是后面所有方案的前提。

下面按"开发 → 容器 → 生产"三层,讲清楚每一层该用什么、不该用什么。

二、层级一:.env + .gitignore(开发够用,别进生产)

本地开发最务实的方案就是 .env 文件 + dotenv。简单、通用、所有语言都有库。

bash
# .env —— 永远加进 .gitignore
DATABASE_URL=postgresql://localhost:5432/devdb
JWT_SECRET=dev-only-not-for-production
STRIPE_SECRET_KEY=sk_test_xxx
gitignore
# .gitignore
.env
.env.local
.env.*.local

但 .env 有两个陷阱必须配套解决:

第一,提交一份 .env.example 做模板。 它列清楚项目需要哪些变量、什么格式,但只放占位符不放真实值。新人 clone 下来 cp .env.example .env 就知道要填什么。这是协作的刚需,不是可选项。

bash
# .env.example —— 进版本控制的模板
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=
STRIPE_SECRET_KEY=

第二,.env 只能停在开发。 把 .env 带进生产等于把生产密钥明文写进了部署机器的文件系统:谁登上去都能 cat,机器被攻破密钥就跟着丢,而且没法审计谁取过、没法轮换。生产环境请直接跳到第三、四层。

另外,在 .gitignore 里加上 .env 之后,养成一个习惯:git status 看一眼,确认 .env 没出现在待提交列表里。再加一层保险——用 git-secrets 或 trufflehog 做 pre-commit 扫描,在你真把密钥提交进去之前拦住。

三、层级二:环境变量(Docker / K8s Secret)

进入容器化部署,密钥的载体从文件变成了环境变量。原则不变:密钥不进镜像。

Docker 镜像是可被任意拉取、缓存、分发的制品。把 ENV DATABASE_URL=... 写进 Dockerfile,等于把生产密钥打包进了一个会被推到 registry、被开发者本地 pull 的东西。正确做法是构建时用占位符,运行时注入:

dockerfile
# Dockerfile —— 只声明,不赋真实值
ENV DATABASE_URL=""
CMD ["sh", "-c", "node server.js"]
bash
# 运行时通过 -e 或 --env-file 注入
docker run -e DATABASE_URL=$DATABASE_URL -e JWT_SECRET=$JWT_SECRET myapp:latest

到了 K8s,用 Secret 而不是 ConfigMap 存敏感数据,然后以环境变量或挂载文件的方式给 Pod:

yaml
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DATABASE_URL: postgresql://prod-db:5432/app
  JWT_SECRET: <从外部注入,别写进 YAML>
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          envFrom:
            - secretRef:
                name: app-secrets

注意:K8s Secret 默认只是 base64 编码,不是加密——能 kubectl get secret -o yaml 的人就能解码。生产环境务必开启 encryption at rest(etcd 加密)和 RBAC 限制(只有特定 ServiceAccount 能读特定 Secret),或者干脆跳到下一层,用外部密钥管理服务通过 CSI Driver 挂载,让密钥根本不进集群 etcd。

CI/CD 这条线同理:密钥放 GitHub Actions Secrets / GitLab CI Variables,在 workflow 里通过 ${{ secrets.X }} 引用,构建时注入到环境变量,绝不写进仓库 YAML。

四、层级三:密钥管理服务(Vault / Secrets Manager / Doppler)

生产环境真正该用的,是专门的密钥管理服务(Secrets Management Service)。核心能力四个:集中存、动态取、可审计、能轮换。代表选手:

  • HashiCorp Vault —— 开源、可自建、最强大,支持动态密钥(按需向数据库申请临时账号)、丰富的 secret engine 和身份认证后端。
  • AWS Secrets Manager / Parameter Store —— 托管、和 IAM/EC2/ECS/Lambda 深度集成,按存储和 API 调用计费。
  • Doppler / 1Password Secrets Automation —— 开发者体验最好,跨环境(dev/staging/prod)统一管理,一行命令拉取。

以 Vault 为例,应用启动时不持有任何长期密钥,而是用机器身份(K8s ServiceAccount、AWS IAM、GCP IAM)向 Vault 认证,换取一个短期 token,再按需读取:

bash
# 应用机器上,用 K8s 身份登录 Vault
vault write auth/kubernetes/login role=my-app \
  jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)

# 读取数据库凭据 —— Vault 当场向数据库创建一个临时账号
vault read database/creds/my-app-role
# => username: v-root-my-app-abc123  password: ...  lease_duration: 1h

这种**动态密钥(dynamic secrets)**是 Vault 的杀手锏:密钥不是预先存在的,而是按需生成、用完即焚、自带过期时间。就算被偷,一小时后也是废纸一张。这是"轮换"理念最彻底的实现。

如果暂时上不了 Vault,用 AWS Secrets Manager 也比硬编码强一百倍——应用代码里只写 SDK 调用,真实值存在 AWS 那边,带版本、带轮换、带 CloudTrail 审计:

python
import boto3
client = boto3.client("secretsmanager")
resp = client.get_secret_value(SecretId="prod/myapp/db")
db_url = resp["SecretString"]

五、轮换:别让一把密钥用一辈子

密钥是有寿命的。一把数据库密码用三年,这三年里经手过的开发者、运维、外包、离职人员,任何一个环节出问题都不可挽回。轮换(Rotation)是密钥管理的第二支柱,和"分离"一样重要。

轮换分两种:

  • 定期轮换:每 30/90 天换一次,即使没出事。把轮换写进流程,而不是"出事了再换"。
  • 事件驱动轮换:有人离职、有 commit 可疑、有未授权访问告警——立即轮换相关密钥,别等。

手动轮换是反模式(谁来记得?谁来执行?会不会漏?),自动化才是正解:

  • Vault 动态密钥:天然按小时轮换,无需人工介入。
  • AWS Secrets Manager:内置 Lambda 轮换函数,一键开启定期轮换。
  • 数据库密码:用蓝绿双账号策略(同时维护 user_v1 和 user_v2,切换连接串后下线旧账号),避免轮换期间的服务中断。

衡量一个团队密钥管理成熟度的最快指标:问他们"现在轮换一次数据库密码要多久"。如果答案是"一天搞定、需要停机",那是初级阶段;"五分钟、零停机、自动化"才是及格线。

六、审计与最小权限

密钥管理服务的第三个核心能力是审计:谁、在何时、取了哪个密钥。Vault 默认开启 audit log,所有读写请求落盘成 JSON,可被 SIEM(如 Splunk、Elastic Security)消费;AWS Secrets Manager 的每次访问都进 CloudTrail。

bash
# Vault audit log 示例(节选)
{"type":"request","path":"secret/data/prod/db","auth":{"display_name":"my-app"}}
{"type":"response","path":"secret/data/prod/db","operation":"read"}

审计的前提是最小权限(least privilege)。每个服务只给它真正需要的那几个密钥,而不是图省事给一个 wildcard。在 Vault 里用 policy 精确控制:

hcl
# my-app 这个服务只能读 prod/db 和 prod/stripe,其他一律拒绝
path "secret/data/prod/db"     { capabilities = ["read"] }
path "secret/data/prod/stripe" { capabilities = ["read"] }
path "secret/data/*"           { capabilities = ["deny"] }

最小权限不只是防御外部攻击者,更是防御内部威胁——那个被钓鱼的实习生、那个要离职的老员工、那个被攻陷的边缘节点。 blast radius 永远要尽可能小。

七、前端的"密钥"幻觉

一句话:没有"前端密钥"这种东西。浏览器里的所有内容——JS bundle、网络请求、localStorage、环境变量——用户都能看到,F12 一按全暴露。

Next.js 的 NEXT_PUBLIC_ 前缀经常被误解。它不是"安全的公开配置",而是明确告知"这会被打包进客户端代码,所以它本身就不是秘密"。

bash
# 这个会被打进 bundle,用户能看到 —— 只能放公开配置
NEXT_PUBLIC_GA_ID=G-XXXXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx

# 这个只在服务端,不会泄露 —— 真正的密钥放这里
STRIPE_SECRET_KEY=sk_live_xxx
DATABASE_URL=...

判断标准很简单:这个值被滥用会不会造成损失? Google Analytics ID 不会,公钥不会——可以放前端;能扣钱的 key、能查数据库的凭据、能签 JWT 的 secret——一律只能在服务端。所有需要"鉴权"的操作,都应该是前端调你的后端 API,后端再带着密钥去调第三方。前端永远只持有"身份标识",不持有"权限凭据"。

八、泄露应急:filter-repo + 立即轮换

万一真的发现密钥进了仓库,按这个顺序处理,不要先想着删那一行——删了也没用,Git 历史里还在。

第一步,立即轮换所有可能泄露的密钥。 这一步优先于清理历史,因为只要密钥还在生效,攻击者随时能用。数据库密码、API key、JWT secret、SSH key——全部换掉,同时撤销旧密钥。

第二步,清理 Git 历史。 用 git filter-repo(官方推荐的 filter-branch 替代品,快且安全):

bash
# 安装
pip install git-filter-repo

# 新建一个文件列出要清除的字符串
echo "sk_live_old_key_value" >> secrets-to-remove.txt
echo "postgresql://old:password@" >> secrets-to-remove.txt

# 重写所有历史
git filter-repo --replace-text secrets-to-remove.txt

# 强推到所有 remote(包括 fork)
git push origin --force --all
git push origin --force --tags

注意:这会改写所有 commit hash,所有基于旧历史的 fork、PR、本地仓库都得重新对齐。而且如果仓库是 public 的,应当假设历史已经被爬走了——清理只是善后,轮换才是真正的止损。

第三步,审计未授权使用。 登录云厂商控制台看 API 调用记录(CloudTrail、Audit Log),数据库看异常查询,第三方服务(Stripe、OpenAI)看账单和调用量。如果发现异常,按安全事件流程上报,而不是悄悄处理。

小结

  • 密钥必须和代码分离——代码天然是公开的,即使现在是 private 的。
  • 三层递进:.env 管开发、环境变量/K8s Secret 管容器、Vault/Secrets Manager 管生产。生产环境上密钥管理服务不是"高级选项",是基本盘。
  • 轮换和审计是密钥管理的另一半:别让一把密钥用一辈子,别让任何服务都能读所有密钥。
  • 没有前端密钥:NEXT_PUBLIC_ 是公开配置,不是秘密;能扣钱的凭据一律留在服务端。
  • 泄露应急的顺序:先轮换,再清历史,最后审计。删那一行是没用的——Git 历史永不忘记。

金句送你:密钥一旦进过 Git,就当它已经泄露——立即轮换,而不是赌没人看到。 安全这件事,从来都是按最坏情况准备,而不是按侥幸心理设计。

Related

相关推荐