3.2 密钥管理不落盘
上一节我们把「普通配置」放进了 YAML 文件,并且明确划出一条线:密钥不走配置文件。这一节解释为什么,以及密钥该走哪里。
区别在于三种性质。普通配置是「公开的、可版本化的、改了不影响安全」;密钥是机密的、需要轮换的、访问要审计的。把两者混在一起,迟早会有人把 config.yaml 提交上去,而里面躺着生产数据库的密码。
本节给 TaskHub 实现一个密钥加载器,只接受环境变量和权限受限的文件两种来源,实测权限检查与缺失处理,并给出日志脱敏与轮换的工程约定。
3.2.1 密钥可能从哪里泄露
「不落盘」不是一句口号,得先看清泄露的几条路径:
| 泄露路径 | 怎么发生 | 后果 |
|---|---|---|
| 源码 | 硬编码在 .go 文件里 | 进了 git 历史,永久 |
| 配置文件 | 写进 config.yaml 并提交 | 同上 |
| 镜像层 | COPY .env 或 ENV PASSWORD= | docker history 可读出 |
| 日志 | log.Printf("%+v", cfg) 打印了配置 | 日志系统长期留存 |
| 命令行参数 | --password=xxx | ps 可见,shell history 留存 |
| 构建缓存 | CI 里把密钥写进临时文件 | 缓存被复用 |
每一条都对应一个必须避免的反模式。下面逐条给做法。
3.2.2 两种合法来源
TaskHub 的密钥加载器只接受两种来源,按优先级:
<NAME>_FILE指向的文件——适用于 Kubernetes Secret 挂载、Docker secret。<NAME>环境变量——适用于本地开发、CI 变量。
// LoadSecret 优先从 *_FILE 指向的文件读取,其次从环境变量读取。
func LoadSecret(envName string) (string, error) {
if path := os.Getenv(envName + "_FILE"); path != "" {
info, err := os.Stat(path)
if err != nil {
return "", err
}
if info.Mode().Perm()&0o077 != 0 {
return "", fmt.Errorf("密钥文件 %s 权限过宽: %v", path, info.Mode().Perm())
}
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
if v := os.Getenv(envName); v != "" {
return v, nil
}
return "", fmt.Errorf("未找到密钥 %s", envName)
}
三个设计决定:
*_FILE优先:文件来源是「编排平台主动注入」的,比环境变量更可控;且环境变量在/proc/<pid>/environ里可被同机进程读到,文件权限则受操作系统保护。- 读取后
TrimSpace:echo写入的文件常带尾换行,不 trim 会让密码多一个\n,认证莫名失败。 - 找不到就报错:绝不返回空字符串当默认。空密码比「启动失败」危险得多。
3.2.3 权限检查:为什么是 0600
文件模式里,0o077 是「除属主之外的所有权限位」。perm & 0o077 != 0 表示组或其他用户能读/写/执行这个文件,即权限过宽。
if info.Mode().Perm()&0o077 != 0 {
return "", fmt.Errorf("密钥文件 %s 权限过宽: %v", path, info.Mode().Perm())
}
实测三种情况:
# 1) 从环境变量读取
$ TASKHUB_DB_PASSWORD='s3cr3t-p@ss-2026' ./secretbin TASKHUB_DB_PASSWORD
加载 TASKHUB_DB_PASSWORD 成功: len=16 masked=s3************26
# 2) 从 0600 文件读取
$ printf 'filesecret-abc123\n' > db.pw && chmod 600 db.pw
$ TASKHUB_DB_PASSWORD_FILE=./db.pw ./secretbin TASKHUB_DB_PASSWORD
加载 TASKHUB_DB_PASSWORD 成功: len=17 masked=fi*************23
# 3) 文件权限 0644(过宽)
$ chmod 644 db.pw
$ TASKHUB_DB_PASSWORD_FILE=./db.pw ./secretbin TASKHUB_DB_PASSWORD
错误: 密钥文件 ./db.pw 权限过宽: -rw-r--r--
第 2 条的 len=17 来自 filesecret-abc123(10 + 1 + 6 = 17 个字符),第 1 条的 len=16 来自 s3cr3t-p@ss-2026——两者长度本就不同,与文件尾的换行无关(TrimSpace 已经把它去掉了)。
为什么是 0600 而不是别的:0600 意味着只有属主可读写,组和其他用户无任何权限。0644 允许其他用户读——在多租户机器或共享 CI runner 上,这就是泄露。权限检查的成本是一行 if,收益是把「不小心 chmod 644」这类低级错误挡在启动阶段。
3.2.4 日志脱敏:绝不打全量
密钥最隐蔽的泄露路径是日志。一句 log.Printf("%+v", cfg) 就能把配置结构体里的密码打进日志系统。对策是任何可能含密钥的值,打印前必须脱敏:
func mask(s string) string {
if len(s) <= 4 {
return "****"
}
return s[:2] + strings.Repeat("*", len(s)-4) + s[len(s)-2:]
}
保留前两位和后两位,中间全部替换成 *。这样既能确认「密钥加载到了、长度对」,又不暴露内容。实测输出 s3************26、fi*************23——运维看到能判断「是不是加载了」,攻击者看到却拿不到明文。
更彻底的做法是给密钥类型实现 String() 方法,永远返回 [REDACTED]:
type Secret string
func (s Secret) String() string { return "[REDACTED]" }
这样即使有人误把它放进 %v,也不会泄露。代价是每次使用都要显式转成 string,多一次类型转换——但这点麻烦换来的是「不可能误打印」的保证。
3.2.5 反模式清单
把本节反对的做法整理成一张「看到就该改」的清单:
| 反模式 | 为什么错 | 正确做法 |
|---|---|---|
const DBPassword = "..." | 进了源码与 git 历史 | 环境变量 |
密钥写进 config.yaml | 配置文件常被提交 | 单独注入 |
ENV DB_PASSWORD=xxx 在 Dockerfile | 镜像层可读 | 运行时 --env-file 或 Secret |
flag.String("password", ...) | ps 与 history 可见 | 环境变量或文件 |
log.Printf("%+v", cfg) | 全量打印含密钥 | 脱敏或 Secret 类型 |
把 .env COPY 进镜像 | 镜像层留存 | .dockerignore 排除 |
| 密钥写进 CI 明文变量 | 可能被日志带出 | 用平台的加密 Secret |
共同点是:任何「会持久留存」的地方,都不能出现明文密钥——git 历史、镜像层、日志、shell history、ps 输出,都是持久留存的。
3.2.6 Kubernetes 场景:Secret 挂载为文件
在 Kubernetes 里,*_FILE 模式正好对上 Secret 的挂载方式。一个 Secret 可以同时以环境变量和文件两种形式注入:
apiVersion: v1
kind: Secret
metadata:
name: taskhub-db
type: Opaque
stringData:
password: "s3cr3t-p@ss-2026"
挂载为文件时,K8s 会把每个 key 写成一个文件,默认权限 0644:
spec:
containers:
- name: taskhubd
env:
- name: TASKHUB_DB_PASSWORD_FILE
value: /etc/taskhub/db/password
volumeMounts:
- name: db-secret
mountPath: /etc/taskhub/db
readOnly: true
volumes:
- name: db-secret
secret:
secretName: taskhub-db
defaultMode: 0400
注意 defaultMode: 0400——K8s 默认给 0644,必须显式改成 0400,否则我们 3.2.3 的权限检查会直接拒绝启动。这正是那个检查的价值:它逼你把权限配对。
3.2.7 密钥轮换
密钥不是配一次就完了。轮换(rotation)意味着「定期换掉旧密钥」,难点在于换的过程中不能中断服务。三种策略:
| 策略 | 做法 | 复杂度 |
|---|---|---|
| 双密钥并存 | 新旧都能用,验证通过旧的后再下线 | 中 |
| 重启轮换 | 换 Secret 后滚动重启 | 低 |
| 动态密钥 | 从 Vault 等按需签发短时凭证 | 高 |
TaskHub 早期用重启轮换:更新 Secret,触发滚动更新(见第 15 章)。代价是轮换期间有短暂的重启,但实现最简单。等规模上来、不能承受重启时,再上双密钥或动态凭证。
一个实用约束:密钥要有明确的过期时间与轮换负责人。没有轮换计划的密钥,本质上和硬编码没区别——它只是「还没泄露的硬编码」。
3.2.8 密钥加载的启动期检查
密钥问题最好在启动期暴露,而不是第一次用到时才失败。TaskHub 在 main 开头就把所有必需的密钥加载并校验:
func mustSecret(name string) string {
v, err := LoadSecret(name)
if err != nil {
log.Fatalf("启动失败: %v", err)
}
return v
}
func main() {
dbPassword := mustSecret("TASKHUB_DB_PASSWORD")
jwtKey := mustSecret("TASKHUB_JWT_KEY")
// ... 用密钥构造依赖
}
「缺密钥就 log.Fatalf」是刻意的:让容器启动失败、被编排平台标记为 CrashLoopBackOff,比「服务启动了但每次请求都 500」更容易被发现和定位。启动期失败是响亮的失败。
3.2.9 本地开发怎么办
「不落盘」在本地开发时会带来不便——总不能在终端里 export 一堆密钥。折中方案是本地 .env 文件 + .gitignore:
# .env(被 .gitignore 排除,绝不提交)
TASKHUB_DB_PASSWORD=devpassword
TASKHUB_JWT_KEY=dev-jwt-key-not-for-production
本地用 env $(cat .env | xargs) go run . 之类的方式注入,CI 和生产则走平台 Secret。关键是这个文件必须进 .gitignore,并且里面只能放开发用的假密钥——即便泄露也无所谓。
3.2.10 常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
| 启动报「权限过宽」 | Secret 挂载默认 0644 | 设 defaultMode: 0400 |
| 认证失败但密码看着对 | 文件尾换行没 trim | 读取后 TrimSpace |
| 密钥为空却启动成功 | 缺密钥时返回了空字符串 | 缺失即 Fatalf |
| 日志里出现明文密码 | 打印了整个配置结构体 | 脱敏或 Secret 类型 |
| 轮换后老连接仍可用 | 只换了配置没重启 | 滚动更新或双密钥 |
| git 历史里有旧密码 | 曾经提交过 | 改密钥 + git filter-repo 清理 |
密钥管理的原则可以浓缩成一句:密钥只存在于「内存」和「编排平台」两个地方,绝不进任何会持久留存的东西。下一节我们把配置的最后一环补上:当配置需要在不重启的情况下生效时,怎么做热更新,以及为什么热更新必须先校验。
阅读导航:上一节:3.1 分层配置与环境覆盖 · 下一节:3.3 配置热更新与校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。