密钥管理实战——Vault、K8s Secret、环境变量的安全使用规范
密钥管理实战——Vault、K8s Secret、环境变量的安全使用规范
适读人群:Java 工程师、DevOps 工程师 | 阅读时长:约19分钟 | 核心价值:彻底搞清楚密钥应该怎么管,三种方式各自的适用场景和安全边界
我见过的最严重的密钥泄露事故,是这样发生的。
一个同事在本地调试一个功能,需要访问生产数据库来复现问题。他在代码里临时硬编码了数据库连接字符串,调好了之后提交代码,但忘记把那段硬编码的配置删掉。
更糟糕的是,那个项目的 GitHub 仓库是公开的(开源项目)。
三天之后,GitHub 的 secret scanning 告警发过来,那个数据库密码已经在 GitHub 上暴露了 3 天。公司紧急修改了数据库密码,排查了 3 天的数据库访问日志,确认没有被滥用——算是侥幸。
那次事故之后,我们建立了一套密钥管理规范。这篇文章把这套规范分享出来。
密钥管理的层级
密钥管理不是一个技术问题,是一个工程体系问题。要从以下几个层面来考虑:
- 不要把密钥放进代码(最基础)
- 密钥要有合理的生命周期管理(轮换、到期)
- 密钥要有访问审计(谁在什么时候用了什么密钥)
- 密钥泄露要有应急响应(发现泄露后多快能换掉)
环境变量:最简单但也最危险的方式
环境变量是最常见的密钥注入方式:
export DB_PASSWORD=mysecretpassword
java -jar myapp.jarSpring Boot 可以直接通过环境变量覆盖 application.yml 里的任何配置:
# application.yml
spring:
datasource:
password: ${DB_PASSWORD} # 从环境变量读取环境变量的安全问题:
- 环境变量在进程的内存里,
/proc/<pid>/environ里可以看到(Linux) - 如果应用 crash 产生 core dump,密码会出现在 dump 文件里
- 应用日志如果不小心输出了所有环境变量(
env命令、Spring Boot Actuator 的某些端点),密码会泄露
适用场景:开发环境、CI/CD 里的临时密钥注入。生产环境建议用更安全的方案。
Spring Boot Actuator 的风险:
# 一定要关闭 env 端点(或者只开放给内部 IP)
management:
endpoints:
web:
exposure:
include: health,info,metrics # 不要包含 envKubernetes Secret:容器环境的标准方案
创建和使用 K8s Secret
# 创建 Secret
apiVersion: v1
kind: Secret
metadata:
name: payment-service-secrets
namespace: production
type: Opaque
stringData:
db-password: "supersecret"
api-key: "myapikey12345"
# 注意:stringData 会被 base64 编码存储,但 base64 不是加密!在 Pod 里使用:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
template:
spec:
containers:
- name: payment-service
image: myapp:latest
env:
# 方式一:作为环境变量
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: payment-service-secrets
key: db-password
volumeMounts:
# 方式二:挂载为文件(更安全,应用从文件读,不暴露在进程环境里)
- name: secrets
mountPath: /secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: payment-service-secrets
defaultMode: 0400 # 只有 owner 可读从文件读密钥的 Spring Boot 配置:
spring:
datasource:
password: ${DB_PASSWORD:${spring.datasource.password-from-file}}
config:
import: optional:file:/secrets/K8s Secret 的安全问题
K8s Secret 默认只是 base64 编码,不是加密。这意味着:
- 有
get secret权限的人可以轻松 decode 看到明文 - etcd(K8s 的存储后端)里的 Secret 默认也是明文
必须做的事:
开启 etcd 加密:
# /etc/kubernetes/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64 encoded 32-byte random key>
- identity: {} # 向后兼容,未加密的 secret 仍可读最小权限 RBAC:
# 只给需要的 ServiceAccount 读取特定 Secret 的权限
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: payment-secret-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["payment-service-secrets"] # 只允许访问这一个 Secret
verbs: ["get"]HashiCorp Vault:生产级密钥管理
Vault 是解决密钥管理问题的完整方案。它提供:
- 密钥集中存储,加密保护
- 动态密钥(每次请求生成新密钥,用完即销毁)
- 完整的访问审计日志
- 自动密钥轮换
Vault 基础配置
# vault-config.hcl
ui = true
cluster_addr = "https://vault.company.internal:8201"
api_addr = "https://vault.company.internal:8200"
storage "raft" {
path = "/opt/vault/data"
node_id = "vault-node-1"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/opt/vault/tls/vault.crt"
tls_key_file = "/opt/vault/tls/vault.key"
}
seal "awskms" {
region = "ap-northeast-1"
kms_key_id = "your-kms-key-id"
# 用 AWS KMS 自动 unseal,避免每次重启需要手动输入 unseal keys
}Spring Boot 集成 Vault
Spring Cloud Vault 提供了与 Vault 的原生集成:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-vault-config</artifactId>
</dependency>bootstrap.yml(Spring Cloud Config 在 application.yml 之前加载):
spring:
cloud:
vault:
host: vault.company.internal
port: 8443
scheme: https
authentication: KUBERNETES # 用 K8s Service Account 认证 Vault
kubernetes:
role: payment-service
service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
kv:
enabled: true
backend: secret
application-name: payment-service
profile-separator: '/'Vault 里存储的路径:secret/payment-service/production/,Spring Boot 会自动把这个路径下的所有 key-value 映射为 Spring 的 properties。
# 在 Vault 里存储密钥
vault kv put secret/payment-service/production \
db-password="supersecret" \
api-key="myapikey"Spring Boot 里就可以直接用:
@Value("${db-password}")
private String dbPassword;Vault 动态数据库密钥
这是 Vault 最强大的功能之一:每次应用需要数据库连接时,Vault 临时创建一个只有该应用使用的数据库用户,用完后销毁。
# 在 Vault 里配置数据库 secret engine
vault secrets enable database
vault write database/config/my-postgresql-database \
plugin_name=postgresql-database-plugin \
allowed_roles="payment-service-role" \
connection_url="postgresql://{{username}}:{{password}}@db.company.internal:5432/paymentdb" \
username="vault-admin" \
password="vaultadminpassword"
vault write database/roles/payment-service-role \
db_name=my-postgresql-database \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"应用每次启动时向 Vault 申请一个临时数据库账号,1 小时后过期,Vault 自动删除。即使这个临时账号泄露,攻击者只有 1 小时的时间窗口。
踩坑实录
踩坑一:把数据库连接字符串推到了 GitHub 公开仓库
这就是我在开头讲的故事。
事后建立的防护:
- Pre-commit hook:在本地提交代码前自动检查有没有密钥:
# .git/hooks/pre-commit
#!/bin/bash
# 检查常见密钥模式
if git diff --cached | grep -iE '(password|secret|api_key|access_key)\s*=\s*['"'"'"][^'"'"'"]{8,}'; then
echo "ERROR: Potential secret detected in staged changes!"
exit 1
fiGitHub Secret Scanning:GitHub 有内置的 secret scanning,能自动检测常见格式的密钥。确保仓库开启这个功能(Settings > Security & analysis > Secret scanning)。
Git hooks 工具:用
detect-secrets或gitleaks这类工具做更全面的检测:
# 安装 gitleaks
brew install gitleaks
# 在 CI 里运行
gitleaks detect --source . --verbose踩坑二:K8s Secret 通过 kubectl describe 暴露
kubectl describe secret my-secret 会显示 Secret 的所有信息,包括 base64 编码的值,任何有 describe 权限的人都能看。
# 这样可以直接看到密码
kubectl get secret my-secret -o jsonpath='{.data.password}' | base64 -d限制权限:不要给研发团队 get secret 权限。他们需要的是应用能用到密钥,而不是人能看到密钥值。
# 只允许 describe,不允许 get(describe 不输出数据)
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "update", "delete"] # 不包含 get踩坑三:Vault 在重启后需要手动 unseal
Vault 重启后(比如 Pod 被调度到新节点),会进入 sealed 状态,无法提供服务,直到手动输入 unseal keys。
这在生产环境里很危险——应用连不上 Vault,连不上数据库,服务不可用,而且是凌晨重启的话没人处理。
解决方案:使用 Auto Unseal。配合云厂商的 KMS(AWS KMS、阿里云 KMS)自动 unseal,Vault 重启后自动解封,不需要人工干预。上面的 Vault 配置文件里 seal "awskms" 就是这个配置。
深度解析:为什么动态密钥比静态密钥安全得多
在安全领域有一个基本原则:有效期越短的凭证,泄露造成的损害越小。这个原则驱动了"动态密钥"这一方向。
传统的密钥管理方式是静态密钥——数据库用户名密码创建好之后,可能几年都不会变。这意味着:
第一,密钥的暴露窗口期极长。如果今天密钥泄露了,但你三个月后才发现,攻击者已经有了三个月的访问时间。静态密钥的有效期通常是"直到被发现问题为止",这在实践中可能是数年。
第二,密钥轮换成本高。想象一下手动轮换一个被 20 个服务使用的数据库密码——要更新 20 个服务的配置,重启所有服务,还要确保轮换期间没有服务中断。高成本导致大家不愿意做轮换,密钥就越来越老。
第三,权限边界模糊。一个共用的数据库账号被 10 个服务使用,出了问题,很难追查是哪个服务的操作导致的。
动态密钥彻底改变了这个模型:
Vault 的动态数据库密钥,每次应用向 Vault 申请连接凭证时,Vault 临时在数据库里创建一个新用户,这个用户的有效期可以设置成 1 小时或更短。一小时后,Vault 自动删除这个用户。
这带来了几个重要的安全特性:
- 凭证泄露损失最小化:即使凭证被窃取,攻击者只有 1 小时的有效窗口
- 天然的审计:每个临时用户对应一个特定的服务实例,数据库的操作日志可以精确追踪到哪个服务实例做了什么操作
- 零人工干预的轮换:动态凭证不需要"轮换",因为它本来就是一次性的
动态密钥不只是数据库,Vault 还支持动态 AWS IAM 凭证、动态 SSH 证书等。对于云原生环境,这是密钥安全的终极形态。
深度解析:密钥的生命周期管理
密钥管理不只是"怎么存密钥",更重要的是密钥的完整生命周期管理。很多团队在密钥的创建和存储上做得很好,但在生命周期的其他环节上存在严重漏洞。
创建(Creation):密钥在哪里创建、由谁创建、用什么算法?
很多密钥问题从创建阶段就埋下了。比如密码复杂度不够(短密码、常见密码),或者密钥在不安全的渠道传递(通过钉钉消息发给同事,然后同事再配置进去)。
规范:密钥在安全系统(Vault、AWS Secrets Manager)里直接生成,高复杂度随机字符串,不经过任何人手。
分发(Distribution):密钥如何到达需要它的应用?
最危险的做法是把密钥"告诉"人,让人手动配置。密钥经过人,就意味着密钥可能被记住、被截获、被传播。
规范:密钥直接从密钥管理系统注入到应用,不经过人。在 K8s 里,Vault Agent 可以直接把密钥写入 Pod 的文件系统,应用从文件读取,整个过程不需要人参与。
使用(Usage):密钥在被使用时是否有审计?
你需要能回答这样的问题:过去 30 天里,哪些服务使用了生产数据库的凭证?有没有异常的访问时段或访问量?
Vault 的 Audit Log 功能记录每一次密钥的读取操作,包括时间、调用方身份、IP 地址。这个日志可以接入 SIEM 系统,设置异常访问告警。
到期与轮换(Rotation):密钥多久轮换一次?轮换时如何保证不中断服务?
密钥轮换是最难的环节,因为轮换意味着所有使用这个密钥的服务都要同时更新。处理不好,就是一次生产事故。
平滑轮换的方法:使用"双密钥"策略。Vault 同时维护当前密钥和新密钥,先让所有服务支持新旧两个密钥,等所有服务更新完成后,再废弃旧密钥。整个过程零停机。
吊销(Revocation):发现密钥泄露时,能多快废掉这个密钥?
这是"应急响应能力"的核心。如果一个密钥泄露了,你需要能在 5 分钟内废掉它,并让所有使用旧密钥的服务自动切换到新密钥。
Vault 的动态密钥天然支持快速吊销——一条命令 vault lease revoke 可以立即废掉某个密钥,或者废掉某个服务申请的所有密钥。
归档(Archival):密钥历史如何保存?
出于合规要求,有些密钥的历史需要保存一定时间(比如加密数据的密钥,即使已经不再用于新数据的加密,但解密旧数据时还需要它)。密钥的归档和销毁需要有明确的策略。
把密钥生命周期的每个环节都考虑到,才算是真正做到了密钥管理,而不只是"有地方存密钥"。
密钥管理的选型建议
| 场景 | 推荐方案 |
|---|---|
| K8s + 中小规模团队 | K8s Secret + etcd 加密 + 严格 RBAC |
| K8s + 需要审计 + 动态密钥 | Vault + K8s Auth + Spring Cloud Vault |
| 非容器化环境 | Vault + AppRole 认证 |
| AWS 环境 | AWS Secrets Manager(原生集成,省事) |
| 本地开发 | .env 文件 + .gitignore |
.env 文件的规范:
# .env(不提交到 git)
DB_PASSWORD=localdev_password
API_KEY=localdev_key
# .env.example(提交到 git,只有 key 没有 value)
DB_PASSWORD=
API_KEY=所有项目都应该有 .env.example 文件,告诉新入职同事需要配置哪些环境变量。
深度解析:密钥泄露的应急响应
密钥管理做得再好,也无法保证100%不泄露。泄露一旦发生,响应速度决定了损失大小。
判断泄露的影响范围
发现密钥泄露后,第一件事不是立刻换密钥,而是先搞清楚:这个密钥被谁看到了?暴露了多长时间?
如果是推送到了 GitHub 公开仓库,需要查 GitHub 的 push 日志和该文件的访问记录(GitHub 的 REST API 可以查询 traffic 数据)。但通常情况下,公开仓库的内容很快会被各种 secret scanner 机器人扫描,可以认为泄露是实质性的。
如果是在内部系统里泄露(比如日志里出现了密钥),需要排查哪些系统能访问那份日志,哪些人有权限看到。
查数据库访问日志,看泄露时间段内是否有异常的访问(非正常服务发来的连接、异常时段的访问、异常的查询量)。这步很关键——如果日志显示没有异常访问,危险程度就低很多;如果有异常访问,需要进一步排查做了什么操作。
换密钥的操作步骤
换密钥听起来简单,但在生产环境里需要很仔细地操作,稍有不慎就是一次故障。
正确的步骤:
第一步,生成新密钥,在密钥管理系统里存储,同时保留旧密钥。
第二步,逐服务灰度更新。先更新一个服务,让它支持新旧两个密钥(比如数据库层面先创建新账号,不删旧账号)。验证新密钥工作正常。
第三步,等所有服务都更新完成,确认没有服务还在使用旧密钥。
第四步,撤销旧密钥。在数据库里删除旧账号,或者在 Vault 里 revoke 旧 lease。
如果使用 Vault 的动态密钥,这个过程要简单得多——只需要 revoke 旧的 lease,所有使用旧密钥的服务在下次 lease 续期时会自动获得新密钥,整个过程可以在几分钟内完成。
事后复盘
密钥泄露事故结束后,一定要做事后复盘(Post-mortem),但重点不是追责,而是理解根本原因和改进系统。
常见的根本原因:开发者习惯在本地调试时使用生产密钥(应该建立独立的开发/测试密钥);密钥轮换流程繁琐导致密钥长期不换(应该自动化轮换);没有 pre-commit hook 或 CI 扫描(应该建立技术防线)。
每一次密钥泄露事故,都是改善密钥管理体系的机会。
总结
密钥管理的核心原则只有三条:
- 密钥永远不进代码仓库:用 pre-commit hook + CI 扫描双重保障
- 密钥要有最小化的访问权限:谁需要才给谁,给了权限要有审计
- 密钥要有轮换机制:泄露能快速换,到期自动换
对于不同规模的团队,方案可以不同,但这三条原则适用于所有场景。
