一次把密钥写进镜像的发布
2026 年 9 月,arch-lab 的 staging 镜像在 Dockerfile 里用 ENV PAYMENT_API_KEY=... 写死了支付渠道的测试密钥。密钥后来泄露,团队在密钥管理平台轮换,流水线重新构建,看起来事情结束了。两周后安全扫描发现,旧镜像层仍留在镜像仓库里,任何人拉取旧 tag 都能解出原始密钥;更糟的是,staging 的部署模板被复制到生产,APP_ENV 还是 staging,生产进程连上了沙箱支付地址。
这两件事都不是业务代码的 bug,而是配置与密钥没有边界。本节不写 Docker 或 KMS 教程,只处理四个可检查的规则:配置分层与优先级、启动时校验、密钥不落盘、环境差异显式化。11.1 把这些配置装进部署单元,本节决定它们从哪里来、什么时候必须让进程拒绝启动。
配置分三层,优先级要可预测
配置不是一堆环境变量,而是有来源、有优先级、有生命周期的数据。arch-lab 只用三层:代码里的安全默认值、随代码评审的非密配置文件、部署时注入的环境变量。密钥属于第三层,但它的读取和普通配置分开,不能出现在文件和镜像里。
| 层 | 来源 | 典型内容 | 能否放密钥 | 优先级 |
|---|---|---|---|---|
| 默认值 | 代码 | HTTP 端口、超时、日志级别 | 否 | 最低 |
| 配置文件 | 仓库中的 per-env 文件 | 支付渠道名、业务开关 | 否 | 中 |
| 环境变量 | 部署平台注入 | 数据库地址、密钥引用 | 是 | 最高 |
优先级必须是“后者覆盖前者”,而不是“谁先读到算谁”。arch-lab 的实现是 defaults < File < Env,测试里先让文件把超时改成 500ms,再让环境变量改成 300ms,断言最终值是 300。规则简单到可以写进一行文档,才不会出现“本地生效、线上不生效”的争论。
不要把这些配置放进数据库。进程启动时数据库可能还没就绪,配置读不出来就无法决定连接哪个库;数据库的备份、只读副本和慢查询日志还会把密钥复制到更多地方。配置中心可以是数据库的替代品,但它必须解决启动期可用性和密钥加密,不能只是“把表换个名字”。
启动即失败,不带病运行
配置错误的正确暴露时间是进程启动,不是第一笔订单。Load 返回 Config 和全部 Issue,main 只要看到非空就记录错误码并退出,绝不带着半套配置开始监听端口。错误码比错误值重要:日志里不能打印 DATABASE_URL 和 PAYMENT_API_KEY 的原文,否则启动失败会把密钥写进日志系统。
arch-lab 在启动时检查五类问题:必填缺失、值非法、未知键、危险默认值、环境不匹配。必填缺失包括 DATABASE_URL 和 APP_ENV;值非法包括超时不在 1–60000ms、TTL 不在 1–10080 分钟、日志级别不认识;未知键包括 DATABSE_URL 这种拼写错误;危险默认值指生产环境仍然使用开发用的 fake 支付渠道;环境不匹配指生产环境配置了 sandbox 或 localhost 的支付地址。
“先警告,观察一段时间再改成错误”是常见的妥协,但配置警告通常淹没在启动日志里,没人会在发布窗口逐条阅读。配置错误属于部署错误,应该在流量进入前失败;如果担心误杀,可以在 CI 里先跑一次 config-check,把失败提前到构建阶段。
密钥的四条硬规则
第一,密钥不进 Git,包括历史提交。删除文件不等于删除历史,密钥一旦推送就必须轮换,而不是只加一条 .gitignore。第二,密钥不进镜像。ENV、ARG、COPY 都会留在镜像层里,后续 RUN rm 只会在新层标记删除,旧层仍可提取;镜像还会被推送到多个仓库、被缓存、被扫描。第三,密钥不进配置文件,也不进数据库;配置文件会随代码分发,数据库会随备份扩散。第四,密钥不进日志、错误信息和链路追踪,异常里打印整个配置结构是最常见的泄露方式。
arch-lab 的做法是:普通配置走文件和环境变量,密钥只从部署平台的 secret 注入,进程启动后只保留在内存。高敏感场景可以把密钥挂成 0400 的文件,由进程读取后立即删除或转为内存凭证;环境变量对同 UID 的进程和 root 可见,不是强隔离,但在“不进镜像、不进仓库”这个基线上已经明显更好。轮换时采用双密钥重叠:新密钥先生效,旧密钥保留一个发布周期,确认没有残留调用后再撤销。
环境差异要显式
dev、staging、prod 的差异不应该散落在 if env == "prod" 里。业务代码只接收一个 Config,环境判断集中在配置加载和校验;业务逻辑不知道当前是哪个环境,只知道自己拿到的超时、地址和渠道。这样测试可以用同一套代码路径,只替换配置源。
| 项 | dev | staging | prod |
|---|---|---|---|
| APP_ENV | dev | staging | prod |
| 支付渠道 | fake | sandbox | 真实渠道 |
| 支付地址 | localhost | sandbox 域名 | https 正式域名 |
| 日志级别 | debug | info | info/warn |
| 密钥 | 本地假值 | 测试密钥 | 托管密钥,定期轮换 |
APP_ENV 没有默认值,这是刻意的。忘记设置时进程直接报 INVALID_VALUE,而不是默默按 dev 运行。staging 允许 sandbox 地址,prod 明确拒绝包含 sandbox 或 localhost 的地址;同一份校验代码,通过环境分支只收紧、不放松。生产仍然使用 fake 支付渠道时,报 DANGEROUS_DEFAULT,因为那会导致订单显示支付成功、钱却没有动。
把规则写成加载器
下面的 config 包把分层、白名单、密钥来源和环境保护写成代码。Load 不遇到第一个错误就返回,而是一次收集全部问题;调用方可以一次性修完,CI 也不会出现“修一个冒一个”。
// file: internal/platform/config/config.go
// tier: L1
// verify: go test ./internal/platform/config/
package config
import (
"fmt"
"net/url"
"strconv"
"strings"
)
type Env string
const (
Dev Env = "dev"
Staging Env = "staging"
Prod Env = "prod"
)
// Config 是进程启动后唯一的配置来源。业务代码只接收这个结构,
// 不直接读 os.Getenv,环境差异才不会散落到各个包里。
type Config struct {
Env Env
HTTPAddr string
DatabaseURL string
PaymentBaseURL string
PaymentProvider string
PaymentAPIKey string
RequestTimeoutMS int
IdempotencyTTLMinutes int
LogLevel string
}
// Source 把两个非密来源分开:File 是随代码评审的配置文件,
// Env 是部署时注入的环境变量。密钥只允许出现在环境变量或密钥挂载里。
type Source struct {
File map[string]string
Env map[string]string
}
type Issue struct {
Code string
Key string
Detail string
}
func (i Issue) String() string {
return fmt.Sprintf("%s %s: %s", i.Code, i.Key, i.Detail)
}
// allowed 是配置键的白名单。未知键通常意味着拼写错误或旧版本残留,
// 静默忽略会让进程带着一个“以为生效了”的值继续运行。
var allowed = map[string]bool{
"APP_ENV": true, "HTTP_ADDR": true, "DATABASE_URL": true,
"PAYMENT_BASE_URL": true, "PAYMENT_PROVIDER": true, "PAYMENT_API_KEY": true,
"REQUEST_TIMEOUT_MS": true, "IDEMPOTENCY_TTL_MINUTES": true, "LOG_LEVEL": true,
}
// defaults 只放开发环境也安全的非密值。任何“生产忘了设置就危险”的值
// 都不在这里给默认,例如 APP_ENV 和支付渠道。
func defaults() map[string]string {
return map[string]string{
"HTTP_ADDR": ":8080",
"PAYMENT_PROVIDER": "fake",
"REQUEST_TIMEOUT_MS": "2000",
"IDEMPOTENCY_TTL_MINUTES": "30",
"LOG_LEVEL": "info",
}
}
// Load 的优先级是 defaults < File < Env,后两者都只接受白名单键。
// 返回值同时包含 Config 和全部问题;调用方必须在有任何 Issue 时退出。
func Load(src Source) (Config, []Issue) {
var issues []Issue
add := func(code, key, detail string) {
issues = append(issues, Issue{Code: code, Key: key, Detail: detail})
}
merged := defaults()
for k, v := range src.File {
merged[k] = v
if !allowed[k] {
add("UNKNOWN_KEY", k, "config file key is not in the allowlist")
}
}
for k, v := range src.Env {
merged[k] = v
if !allowed[k] {
add("UNKNOWN_KEY", k, "environment key is not in the allowlist")
}
}
// 密钥出现在配置文件里就视为泄露风险,哪怕当前值是空的。
if _, ok := src.File["PAYMENT_API_KEY"]; ok {
add("SECRET_IN_FILE", "PAYMENT_API_KEY", "secrets must come from env or a secret mount")
}
env := Env(strings.TrimSpace(merged["APP_ENV"]))
switch env {
case Dev, Staging, Prod:
default:
add("INVALID_VALUE", "APP_ENV", fmt.Sprintf("want dev|staging|prod, got %q", env))
}
databaseURL := strings.TrimSpace(merged["DATABASE_URL"])
if databaseURL == "" {
add("MISSING_REQUIRED", "DATABASE_URL", "required in every environment")
} else if u, err := url.Parse(databaseURL); err != nil || u.Scheme != "postgres" {
add("INVALID_VALUE", "DATABASE_URL", "must be a postgres:// URL")
}
timeout := parseInt(merged["REQUEST_TIMEOUT_MS"], 1, 60000, "REQUEST_TIMEOUT_MS", add)
ttl := parseInt(merged["IDEMPOTENCY_TTL_MINUTES"], 1, 10080, "IDEMPOTENCY_TTL_MINUTES", add)
logLevel := strings.ToLower(strings.TrimSpace(merged["LOG_LEVEL"]))
switch logLevel {
case "debug", "info", "warn", "error":
default:
add("INVALID_VALUE", "LOG_LEVEL", fmt.Sprintf("unknown level %q", logLevel))
}
provider := strings.TrimSpace(merged["PAYMENT_PROVIDER"])
apiKey := strings.TrimSpace(merged["PAYMENT_API_KEY"])
baseURL := strings.TrimSpace(merged["PAYMENT_BASE_URL"])
if env == Prod {
// 开发默认值 fake 一旦被带进生产,订单会“支付成功”但钱没动。
if provider == "fake" {
add("DANGEROUS_DEFAULT", "PAYMENT_PROVIDER", "fake provider is dev-only; set the real provider")
}
if apiKey == "" {
add("MISSING_SECRET", "PAYMENT_API_KEY", "required when APP_ENV=prod")
}
if baseURL == "" {
add("MISSING_REQUIRED", "PAYMENT_BASE_URL", "required when APP_ENV=prod")
} else {
if !strings.HasPrefix(baseURL, "https://") {
add("ENV_MISMATCH", "PAYMENT_BASE_URL", "prod must use https")
}
if strings.Contains(baseURL, "sandbox") || strings.Contains(baseURL, "localhost") {
add("ENV_MISMATCH", "PAYMENT_BASE_URL", "sandbox or local endpoint in prod")
}
}
} else if baseURL == "" {
baseURL = "http://localhost:9090/fake"
}
return Config{
Env: env,
HTTPAddr: merged["HTTP_ADDR"],
DatabaseURL: databaseURL,
PaymentBaseURL: baseURL,
PaymentProvider: provider,
PaymentAPIKey: apiKey,
RequestTimeoutMS: timeout,
IdempotencyTTLMinutes: ttl,
LogLevel: logLevel,
}, issues
}
func parseInt(raw string, min, max int, key string, add func(string, string, string)) int {
v, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || v < min || v > max {
add("INVALID_VALUE", key, fmt.Sprintf("want %d..%d, got %q", min, max, raw))
return 0
}
return v
}
代码里有三个不显眼但重要的决定。defaults 只放开发环境也安全的值,APP_ENV 和支付渠道没有默认;allowed 白名单同时检查文件和环境变量,未知键立即失败;SECRET_IN_FILE 只检查键是否存在,因为即使当前值为空,配置文件本身也不应该出现密钥键。返回值保留 PaymentAPIKey 是因为运行时真的要用,但它不参与日志输出。
失败测试比成功路径更重要
配置加载器的成功路径只有一条,失败路径很多。测试先构造一份合法的生产配置,断言零问题;再逐条变异,覆盖缺失、非法、危险默认、密钥入文件、沙箱地址和未知键;最后单独验证“文件覆盖默认值、环境变量覆盖文件”和 dev 默认值。
// file: internal/platform/config/config_test.go
// tier: L1
// verify: go test ./internal/platform/config/
package config
import "testing"
func validProd() Source {
return Source{
File: map[string]string{"HTTP_ADDR": ":8443"},
Env: map[string]string{
"APP_ENV": "prod",
"DATABASE_URL": "postgres://orders:secret@db.internal:5432/orders?sslmode=require",
"PAYMENT_BASE_URL": "https://pay.example.com/v1",
"PAYMENT_PROVIDER": "acme",
"PAYMENT_API_KEY": "sk_live_123",
},
}
}
func TestValidProductionConfig(t *testing.T) {
cfg, issues := Load(validProd())
if len(issues) != 0 {
t.Fatalf("valid prod config should load, got %v", issues)
}
if cfg.Env != Prod || cfg.RequestTimeoutMS != 2000 || cfg.IdempotencyTTLMinutes != 30 {
t.Fatalf("unexpected defaults: %+v", cfg)
}
t.Logf("env=%s provider=%s timeout=%dms ttl=%dmin issues=%d",
cfg.Env, cfg.PaymentProvider, cfg.RequestTimeoutMS, cfg.IdempotencyTTLMinutes, len(issues))
}
func TestLoadRejectsBadConfig(t *testing.T) {
cases := []struct {
name string
mutate func(*Source)
code string
key string
}{
{"missing database url", func(s *Source) {
delete(s.Env, "DATABASE_URL")
}, "MISSING_REQUIRED", "DATABASE_URL"},
{"invalid timeout", func(s *Source) {
s.Env["REQUEST_TIMEOUT_MS"] = "0"
}, "INVALID_VALUE", "REQUEST_TIMEOUT_MS"},
{"dangerous default provider", func(s *Source) {
delete(s.Env, "PAYMENT_PROVIDER")
}, "DANGEROUS_DEFAULT", "PAYMENT_PROVIDER"},
{"secret in config file", func(s *Source) {
s.File["PAYMENT_API_KEY"] = "sk_live_123"
}, "SECRET_IN_FILE", "PAYMENT_API_KEY"},
{"sandbox endpoint in prod", func(s *Source) {
s.Env["PAYMENT_BASE_URL"] = "https://sandbox.pay.example.com"
}, "ENV_MISMATCH", "PAYMENT_BASE_URL"},
{"unknown key typo", func(s *Source) {
s.Env["DATABSE_URL"] = "postgres://typo"
}, "UNKNOWN_KEY", "DATABSE_URL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
src := validProd()
tc.mutate(&src)
_, issues := Load(src)
if !hasIssue(issues, tc.code, tc.key) {
t.Fatalf("want %s on %s, got %v", tc.code, tc.key, issues)
}
})
}
}
func TestPrecedenceAndDevDefaults(t *testing.T) {
src := validProd()
src.File["REQUEST_TIMEOUT_MS"] = "500"
cfg, issues := Load(src)
if len(issues) != 0 || cfg.RequestTimeoutMS != 500 {
t.Fatalf("file layer should win over default: %+v %v", cfg, issues)
}
src.Env["REQUEST_TIMEOUT_MS"] = "300"
cfg, issues = Load(src)
if len(issues) != 0 || cfg.RequestTimeoutMS != 300 {
t.Fatalf("env layer should win over file: %+v %v", cfg, issues)
}
dev := Source{Env: map[string]string{
"APP_ENV": "dev",
"DATABASE_URL": "postgres://localhost:5432/orders",
}}
cfg, issues = Load(dev)
if len(issues) != 0 {
t.Fatalf("dev defaults should load, got %v", issues)
}
if cfg.HTTPAddr != ":8080" || cfg.PaymentProvider != "fake" || cfg.PaymentBaseURL == "" {
t.Fatalf("unexpected dev defaults: %+v", cfg)
}
t.Logf("dev addr=%s provider=%s payment_url=%s", cfg.HTTPAddr, cfg.PaymentProvider, cfg.PaymentBaseURL)
}
func hasIssue(issues []Issue, code, key string) bool {
for _, issue := range issues {
if issue.Code == code && issue.Key == key {
return true
}
}
return false
}
测试故意不打印配置结构体。%+v 会把 PaymentAPIKey 一起打出来,失败信息里也不应该出现密钥原文;真实项目里可以给 Config 加一个脱敏的 String(),但更简单的规则是:日志只记录键名和错误码。
实测输出
在本机 Go 1.26.0 下执行 go test ./...,六个失败用例和两个成功用例全部通过;gofmt -l . 无输出,go vet ./... 无告警。下面是合法生产配置的真实输出;完整套件 go test ./... 也已通过。
$ go test ./internal/platform/config/ -run TestValidProductionConfig -v
=== RUN TestValidProductionConfig
config_test.go:29: env=prod provider=acme timeout=2000ms ttl=30min issues=0
--- PASS: TestValidProductionConfig (0.00s)
PASS
ok archlab/internal/platform/config 0.239s
基线输出里的 issues=0 只说明这份配置合法,不说明它安全。安全性来自测试覆盖的那些失败路径:少了数据库地址、超时写成 0、生产忘了换掉 fake 渠道、密钥出现在文件里、生产指向沙箱,每一种都必须让进程启动失败。
ADR-012:未知键直接失败
背景:一次发布把 DATABASE_URL 拼成了 DATABSE_URL,旧实现忽略未知键,进程用空值启动,直到第一笔查询才报错。决策:文件和环境变量都只接受白名单键,未知键产生 UNKNOWN_KEY 并阻止启动。备选一:静默忽略,兼容性最好,但拼写错误和旧配置永远不会被发现。备选二:只告警,理论上可见,实际会被启动日志淹没,也无法阻止带病运行。后果:配置和代码的发布顺序被绑定——新键必须先让认识它的代码上线,再注入配置;删除键则相反。跨版本滚动发布时,旧实例可能因为新配置键而启动失败,因此要采用先加后删的扩展-收缩节奏,并在发布清单里把配置变更单独列一项。
常见错误与边界
第一,把密钥放在 CI 的普通变量里,构建日志一路打印。第二,用 latest 标签加镜像内配置,回滚时配置和代码一起回滚,无法单独恢复。第三,生产保留 fake 支付渠道或 sandbox 地址,测试全绿但钱没动。第四,把配置校验写成警告,发布窗口没人看,故障时才发现。边界也要说清楚:本节只覆盖进程内的配置加载,不涉及 KMS、Vault 的动态凭证、密钥轮换编排和文件权限加固;Kubernetes Secret 默认只是 base64,不等于加密。环境变量也不是强隔离,同 UID 的进程可以读取;如果威胁模型要求更高,应使用短期凭证和内存加密。本节保持 L1,不声称在 Kubernetes 或多节点环境验证过。
小结
配置与密钥的边界不在“用了哪个配置中心”,而在四个可检查的规则:分层优先级固定、启动时聚合校验、密钥不落仓库/镜像/文件/日志、环境差异集中在配置层。arch-lab 用 defaults < File < Env 的三层加载、九键白名单和六类失败测试,把拼写错误、危险默认值和沙箱地址挡在监听端口之前。下一节转向另一类运行时边界:有状态服务的数据放在哪里,以及“有状态”是否等于“不能部署”。
阅读导航:上一节:11.1 部署单元与故障域:订单系统怎么切 · 下一节:11.3 有状态服务的边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。