54.5 配置注入

一句话

配置注入只回答三件事——值从哪来、什么时候进进程、谁能看见;把环境相关的部分从制品里赶出去,但不要赶进代码。

先破除四个误区

误区 现实
“配置就是环境变量” 环境变量只是注入方式之一。它会进 /proc/<pid>/environ、被子进程继承、可能进崩溃转储;大小有限;改了必须重启
“Secret 是加密的” K8s Secret 默认只是 base64。能读 etcd、有 get secret 权限、能拿到备份的人,都能还原明文
“配置改了要重新构建镜像” 构建期注入(ARG/ENV)等于把环境焊进制品:每个环境一个 digest,回到 54.1 不可变制品 的老路
“所有配置都应该动态化” 动态配置要有默认值、校验、灰度、回滚。一次性参数动态化,只是给系统多开一个故障面

配置的三个时机

时机 载体 变更代价 放什么
构建期 ARG / ENV、编译开关 重新构建 + 重新测试 版本号、编译特性、确定不变的内容
启动期 环境变量、挂载文件、启动参数 重启实例(滚动一次) DB 地址、端口、池大小、日志级别初值
运行期 配置中心、文件 watch 秒级生效,无需重启 开关、限流阈值、采样率、灰度比例

判断标准:这个值变了,进程需不需要重新建立连接/重新分配内存? 需要,就只能启动期注入;不需要且需要灰度回滚,才值得做成运行期。

注入方式对比

方式 生效时机 大小限制 密钥暴露面 可热更
环境变量 启动 Linux 单条 128 KiB,总量受 ARG_MAX 约束 /proc、子进程、日志 否
挂载文件(ConfigMap/Secret 卷) 启动 + 卷同步 1 MiB(K8s 文档上限) 需读卷权限 部分(subPath 不更新)
启动参数 启动 同 ARG_MAX ps 可见 否
镜像内置 构建 无 任何能拉镜像的人 否
配置中心 SDK 运行期 视产品 凭据泄露面 是
Sidecar / CSI(Vault 等) 运行期 视后端 最小(按需签发) 是

平台差异要实测:Linux 单条参数/环境变量上限是 MAX_ARG_STRLEN = 32 × 页大小 = 128 KiB;本机 macOS getconf ARG_MAX 为 1,048,576,200 KB 的环境变量能设置成功。同一份“把证书链塞环境变量”的配置,在 macOS 上没事,到 Linux 容器里会 argument list too long。

优先级:永远能回答“这个值从哪来”

默认值  <  配置文件  <  环境变量  <  启动参数  <  运行期覆盖
最泛化                                                最具体

三条规则:

  1. 只允许更具体的层覆盖更泛化的层,不允许反向(否则默认值永远生效,配置“改了没用”)。
  2. 启动时打印最终生效配置 + 每个 key 的来源,敏感值脱敏。配置事故大多不是“值错”,而是“以为生效的是另一个值”。
  3. 未知 key 要报出来:拼错的 key 不会报错,只会静默走默认值。

白名单里的“允许差异项”要和检查器共用同一份定义(见 54.4 环境一致性),否则一边允许、一边阻断。

密钥:别把“编码”当“加密”

做法 风险 替代做法
Secret YAML(base64)提交 git 等于明文入库,改密码还要改历史 SOPS / SealedSecrets / External Secrets 拉取
密钥放环境变量 子进程继承、崩溃转储、日志打印 挂载文件(tmpfs)+ 定期轮换
所有服务共用一个密钥 一处泄露,全站失守 每服务最小权限、短期 token
手工轮换、无记录 长期不换;换的时候不知道谁在用 自动轮换 + 双密钥过渡窗口
密钥打进镜像 任何拉镜像的人都能拿到 运行时注入(见 54.1 不可变制品)

🔑 Secret 的安全边界不在“它叫 Secret”,而在“谁有权限读、etcd 是否加密、出了事能不能轮换”。 默认安装的 K8s 不对 Secret 做静态加密;RBAC 给宽了,Secret 只是换个名字的 ConfigMap。

生效延迟:热更不一定赢过重启

方式 生效时间 依据
环境变量 永不(必须重建 Pod) 进程启动时拷贝环境
ConfigMap/Secret 卷挂载 最长约 2 分钟 kubelet 同步周期 1m + 缓存 TTL 1m(官方文档默认值,本机无集群未验证)
subPath 挂载 不更新 已知行为:subPath 不接收卷更新
配置中心 watch 约 100ms–秒级 见 48.1 配置中心
滚动重启(3 副本) 约 1–2 分钟 起新 + 就绪 + 删旧

我的判断:副本少于 10 个、变更不频繁时,“改配置 + 滚动重启”比引入动态配置更简单、更可回滚——重启的代价被高估了,动态配置的一致性成本被低估了。只有当“变更必须秒级生效、且要按比例灰度”时,动态配置才值得。

一个能跑的合并器:来源、类型、脱敏、fail-fast

package main

import (
	"fmt"
	"os"
	"sort"
	"strconv"
	"strings"
)

// layered 按“默认值 < 文件 < 环境变量 < 启动参数”合并配置,并记住每个值来自哪一层。
// 记住来源是排障的关键:同一个 key,在 file 里改了不生效,通常是 env 层盖住了它。
type layered struct {
	val map[string]string
	src map[string]string
}

func newLayered() *layered {
	return &layered{val: map[string]string{}, src: map[string]string{}}
}

func (l *layered) merge(name string, m map[string]string) {
	for k, v := range m {
		l.val[k] = v
		l.src[k] = name
	}
}

func envLayer(prefix string) map[string]string {
	out := map[string]string{}
	for _, kv := range os.Environ() {
		k, v, ok := strings.Cut(kv, "=")
		if !ok || !strings.HasPrefix(k, prefix) || len(k) == len(prefix) {
			continue
		}
		key := strings.ToLower(strings.ReplaceAll(strings.TrimPrefix(k, prefix), "_", "."))
		out[key] = v
	}
	return out
}

func flagLayer(args []string) map[string]string {
	out := map[string]string{}
	for _, a := range args {
		if !strings.HasPrefix(a, "--") {
			continue
		}
		k, v, ok := strings.Cut(strings.TrimPrefix(a, "--"), "=")
		if ok {
			out[k] = v
		}
	}
	return out
}

func isSecret(k string) bool {
	for _, s := range []string{"password", "secret", "token", "key"} {
		if strings.Contains(k, s) {
			return true
		}
	}
	return false
}

var types = map[string]string{
	"db.port": "int", "db.pool": "int", "feature.checkout": "bool",
	"log.level": "string", "db.host": "string", "db.password": "secret",
}

func main() {
	defaults := map[string]string{
		"log.level": "info", "db.host": "127.0.0.1", "db.port": "5432",
		"db.pool": "10", "db.password": "", "feature.checkout": "false",
	}
	// 仓库里的文件只放非敏感项:密码由环境变量/密钥管理注入。
	file := map[string]string{
		"db.host": "db.prod", "db.pool": "50",
	}
	c := newLayered()
	c.merge("defaults", defaults)
	c.merge("file", file)
	c.merge("env", envLayer("APP_"))
	c.merge("flags", flagLayer(os.Args[1:]))

	fmt.Println("① 合并结果(值来自哪一层,比值本身更重要)")
	keys := make([]string, 0, len(c.val))
	for k := range c.val {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	fmt.Printf("%-18s %-9s %-10s %s\n", "KEY", "SOURCE", "TYPE", "VALUE")
	for _, k := range keys {
		t := types[k]
		if t == "" {
			t = "string"
		}
		v := c.val[k]
		if isSecret(k) {
			v = "***"
		}
		fmt.Printf("%-18s %-9s %-10s %s\n", k, c.src[k], t, v)
	}

	fmt.Println("\n② 启动日志打印一次生效配置(脱敏后)")
	var banner []string
	for _, k := range keys {
		v := c.val[k]
		if isSecret(k) {
			v = "***"
		}
		banner = append(banner, k+"="+v)
	}
	fmt.Println("   " + strings.Join(banner, " "))

	fmt.Println("\n③ 启动前校验:类型 + 必填")
	var errs []string
	tkeys := make([]string, 0, len(types))
	for k := range types {
		tkeys = append(tkeys, k)
	}
	sort.Strings(tkeys)
	for _, k := range tkeys {
		t := types[k]
		v, ok := c.val[k]
		if !ok {
			errs = append(errs, k+" 缺失")
			continue
		}
		switch t {
		case "int":
			if _, err := strconv.Atoi(v); err != nil {
				errs = append(errs, fmt.Sprintf("%s=%q 不是整数", k, v))
			}
		case "bool":
			if _, err := strconv.ParseBool(v); err != nil {
				errs = append(errs, fmt.Sprintf("%s=%q 不是布尔值", k, v))
			}
		case "secret":
			if v == "" {
				errs = append(errs, fmt.Sprintf("%s 为空(source=%s)", k, c.src[k]))
			}
		}
	}
	if n, err := strconv.Atoi(c.val["db.pool"]); err == nil && n <= 0 {
		errs = append(errs, "db.pool 必须大于 0")
	}
	if len(errs) > 0 {
		fmt.Println("   FAIL: " + strings.Join(errs, ";"))
		fmt.Println("   启动中止:配置错误要在启动时炸,不要等到第一个请求")
		os.Exit(1)
	}
	fmt.Printf("   OK:%d 个 key,其中 %d 个来自环境变量、%d 个来自启动参数\n",
		len(keys), countSrc(c.src, "env"), countSrc(c.src, "flags"))
}

func countSrc(src map[string]string, name string) int {
	n := 0
	for _, s := range src {
		if s == name {
			n++
		}
	}
	return n
}

第一次运行:注入完整。

$ APP_DB_POOL=80 APP_DB_PASSWORD=s3cr3t go run main.go --log.level=debug
① 合并结果(值来自哪一层,比值本身更重要)
KEY                SOURCE    TYPE       VALUE
db.host            file      string     db.prod
db.password        env       secret     ***
db.pool            env       int        80
db.port            defaults  int        5432
feature.checkout   defaults  bool       false
log.level          flags     string     debug

② 启动日志打印一次生效配置(脱敏后)
   db.host=db.prod db.password=*** db.pool=80 db.port=5432 feature.checkout=false log.level=debug

③ 启动前校验:类型 + 必填
   OK:6 个 key,其中 2 个来自环境变量、1 个来自启动参数

第二次:忘记注入密码。

$ APP_DB_POOL=80 go run main.go --log.level=debug
① 合并结果(值来自哪一层,比值本身更重要)
KEY                SOURCE    TYPE       VALUE
db.host            file      string     db.prod
db.password        defaults  secret     ***
db.pool            env       int        80
db.port            defaults  int        5432
feature.checkout   defaults  bool       false
log.level          flags     string     debug

② 启动日志打印一次生效配置(脱敏后)
   db.host=db.prod db.password=*** db.pool=80 db.port=5432 feature.checkout=false log.level=debug

③ 启动前校验:类型 + 必填
   FAIL: db.password 为空(source=defaults)
   启动中止:配置错误要在启动时炸,不要等到第一个请求

两次运行说明:同一份代码,配置不同则行为不同;来源可查,缺失即失败。 如果这段代码在每个服务各写一遍,就会出现“有的服务校验、有的不校验”——把合并与校验做成公司级库,比写进代码规范更有效。

生产落地:指名 → 可抄 → 可验 → 可退

指名:非敏感配置用 ConfigMap,密钥用 Secret;密钥的源头用 External Secrets Operator(从 Vault / 云 Secrets Manager 同步)或 Secrets Store CSI Driver(直接挂载)。清单生成用 Kustomize 的 configMapGenerator,它会给名字加内容哈希后缀,内容一变就触发滚动,省掉手写 checksum 注解。ConfigMap/Secret 字段语义见 7.4 配置与密钥 (ConfigMap / Secret),密钥管理整体方案见 13.4 密钥与凭证管理。

可抄:

# kustomization.yaml:内容变 → 名字变 → Deployment 引用变 → 自动滚动
configMapGenerator:
  - name: order-config
    files:
      - application.yaml
# Deployment 中 containers[0] 的片段:非敏感走 envFrom,密钥走 secretKeyRef
envFrom:
  - configMapRef: {name: order-config}
env:
  - name: APP_DB_PASSWORD
    valueFrom:
      secretKeyRef: {name: order-db, key: password}
# 本机无集群,以下命令未验证
kubectl -n prod get cm order-config -o yaml | head
kubectl -n prod exec deploy/order -- printenv | grep '^APP_'
kubectl -n prod auth can-i get secret/order-db --as=system:serviceaccount:prod:order  # 以目标 SA 验证权限
kubectl -n prod rollout restart deploy/order        # 环境变量方式改了必须重启

可验:本节 Go 程序真跑过,两次输出如上,第二次退出码 1。集群侧至少验三件事:printenv 里能看到注入值;改 ConfigMap 后不重启 Pod 看不到变化(证明“env 不热更”);auth can-i --as=... 显示只有目标服务的 ServiceAccount 能读该 Secret,其他 SA 返回 no。

可退:

  • 配置用版本化名字(configMapGenerator 的哈希后缀),旧版本保留,回滚 = 切回旧名字。
  • 启动校验失败即退出(fail-fast),编排层保留旧 Pod 服务(见 54.3 部署编排),避免“半配置”实例接流量。
  • 动态开关必须有 kill switch 和默认值;变更走灰度(48.1 配置中心)与特性开关(55.2 特性开关)。
  • 密钥轮换保留“双密钥窗口”:新密钥生效、旧密钥仍可验证,窗口结束后再吊销。

反模式

反模式 后果
密钥写进代码/镜像 泄露面 = 所有能拉代码和镜像的人;轮换要重新发版
Secret 用 base64 提交 git 等于明文入库,历史无法清除
改环境变量后不重启 以为生效了,实际跑的还是旧值
ConfigMap 用 subPath 挂载还期待热更 永远不会更新,只能重启
配置项没有默认值 缺一项就启动失败;配置中心故障时无法扩容
配置 key 没有命名空间(timeout) 说不清是谁的超时,改一个影响全部
启动日志打印全量配置 密钥进日志/日志平台,一次泄露全网
动态配置没有校验和回滚 一份坏配置秒级全量生效,扩散速度超过发布事故
用环境变量传大证书链/大 JSON Linux 单条 128 KiB 上限,直接启动失败
所有服务共用一套 staging/prod 命名空间 改测试配置打到生产

自检

Q1 为什么 K8s Secret 默认不等于“加密”?

▸ 答案:它默认只是 base64 编码,etcd 里存的是明文(除非专门配置了静态加密);有 get secret 权限、能读 etcd 备份、能 exec 进 Pod 的人都能还原。真正的防护是组合:RBAC 最小权限 + etcd 静态加密 + 外部密钥管理(Vault/云 Secrets Manager)+ 定期轮换 + 审计。

Q2 同一个 key 在配置文件里改了却不生效,最可能是什么原因?按什么顺序排查?

▸ 答案:按“层 → 挂载 → 进程”排查:① 是否被更高优先级层覆盖(环境变量/启动参数/配置中心),看启动日志里的 source;② 是否用 subPath 挂载(不更新),或卷同步还没到(最长约 2 分钟);③ 进程是否只在启动时读一次(env 方式改了必须重启)。先看“生效配置 + 来源”,能省掉大半排查。

Q3 环境变量和挂载文件各自的边界在哪?

▸ 答案:环境变量简单、随进程生命周期、启动即定,但不热更、可见性大(/proc、子进程、日志)、有大小限制(Linux 单条 128 KiB);挂载文件适合较大/结构化内容,可挂 tmpfs、可被 watch,但传播有分钟级延迟,subPath 不更新,需要读卷权限。密钥优先走文件挂载 + 轮换,而不是 env。

Q4 什么配置不该动态化?

▸ 答案:影响启动/连接建立的(DB 地址、端口、TLS 证书路径)、必须整体一致的多个参数(连接池上下限、超时与重试总数)、以及变更后无法自动回滚的。动态化的门槛是:有默认值、有校验、有灰度、有回滚。缺一条,就老老实实“改配置 + 滚动重启”。

Q5 为什么要在启动时打印“最终生效配置 + 来源”?

▸ 答案:配置事故多数不是“值写错”,而是“以为生效的是另一个值”:被上层覆盖、没重启、加载了旧文件、拼错 key 走了默认值。打印 key、来源、脱敏值,让“生效的是什么”变成可观测事实,比任何“配置管理规范”都管用。注意只打印一次、脱敏、且不要打印全量密钥。

关联