《后端架构设计·架构基础与边界》11.2 配置、密钥与环境差异

以 arch-lab 的配置加载器为例,说明默认值、配置文件、环境变量与密钥的分层优先级,为什么未知键和危险默认值必须在启动时失败;给出可运行的 config 包与失败测试,覆盖缺失、非法、密钥入文件、生产环境误用假支付和沙箱地址,并附本机真实 go test 输出。

一次把密钥写进镜像的发布

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,环境判断集中在配置加载和校验;业务逻辑不知道当前是哪个环境,只知道自己拿到的超时、地址和渠道。这样测试可以用同一套代码路径,只替换配置源。

项devstagingprod
APP_ENVdevstagingprod
支付渠道fakesandbox真实渠道
支付地址localhostsandbox 域名https 正式域名
日志级别debuginfoinfo/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 有状态服务的边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查