AI时代的Kubernetes教程36招-第16招-ConfigMap、Secret、Projected Volume和环境配置怎么治理?

Kubernetes 配置治理:ConfigMap、Secret 与 Projected Volume 的边界

配置治理的难点从来不是"放哪",而是"谁能读、多久生效"。

[读者] 在 Kubernetes 上运营多租户数字人平台的工程师
[痛点] 配置全塞进一个 ConfigMap,改了不生效,凭证还躺在 Git 里
[现在读] 审计要求凭证出仓,业务同时要求免重启热更新
[读完] 能用三类对象划出四条边界,并说清各自的更新语义与代价
[旧方案] 单个 ConfigMap + envFrom 全量注入
    |
    v
[新需求] 凭证不入仓、配置免重启生效、Token 自动轮换
    |
    v
[冲突] 敏感度与更新语义被压进同一个对象和同一种消费方式
    |
    v
[后果] 改一行模型地址要重启,重启又打断 Token 续期

我是老李,做了几年后端,现在带一个数字人平台的基础设施。平台跑在 Kubernetes 上,deploy/base/config/ 这个目录里放着一份 Kustomize 基线,最初只有孤零零一个 configmap.yaml。

那个 ConfigMap 叫 app-config,里面塞了四类东西:数字人模型推理服务的地址、租户开关表、数据库连接串、对接外部 Provider 的 Token 占位。Pod 用 envFrom 一把注入,应用启动时读环境变量,之后再也不管。

变化来得很集中。安全审计说凭证不得以明文出现在 ConfigMap 和 Git 历史里;业务说新增租户、切换 Provider、灰度模型地址都不能重启 Pod;运维说 ServiceAccount Token 希望和配置挂进同一个目录,别再散在四个 volume 里。

改一行模型地址要重启整个推理 Pod,重启又会打断正在续期的 Token——这三件事,你能同时满足吗?

你会先拆对象(哪些进 ConfigMap、哪些进 Secret),还是先拆消费方式(env 还是 volume)?

万一顺序搞反了,后果是配置不生效、凭证泄漏,还是两者同时发生?

01、故事

平台的核心工作负载是 dh-runtime Deployment,负责加载租户配置、连数据库、调用数字人模型推理服务。任务是:在不重启 Pod 的前提下,让新增租户、切换模型地址、轮换 Provider Token 三件事都能生效,同时把凭证从 ConfigMap 与 Git 里彻底挪走。

最初的做法非常"标准"——一个 ConfigMap,envFrom 全量注入:

envFrom:
  - configMapRef:
      name: app-config

应用里写的是 os.Getenv("MODEL_ENDPOINT")、os.Getenv("DB_PASSWORD")。

第一个变化来自审计:kubectl get cm app-config -o yaml 里能直接看到数据库密码。有人提"全改成 Secret 不就完了",但模型地址、租户开关本身是非敏感的,全变 Secret 之后每一次地址调整都要走 Secret 的 RBAC 与审计流程,运维成本反而上升。

第二个变化来自业务:租户表从 3 个长到 40 个,改一次要重启。有人去查文档,发现这不是 bug——环境变量本来就是容器创建时的一次性快照。

第三个变化来自运维:Provider Token 按租户隔离且有有效期,希望像 SA Token 那样被自动轮换,而不是靠人定期 kubectl create secret。

三件事指向同一个结论:一个对象加一种消费方式,承载不了"敏感度"和"生效方式"这两个互相独立的维度。

一物塞尽千般事
一改方知处处难
两维莫压一层里
边界先立后谈优

02、问题

旧方案失效的路径很清晰:envFrom 的注入时机是容器创建那一刻,与 ConfigMap 的版本无关;单对象承载全部配置,导致"敏感"和"非敏感"的权限、审计与变更流程被强制合并;凭证在 Git 与 ConfigMap 中明文可读,泄露面等于所有能 get configmap 的账号。

业务影响:模型地址灰度需要滚动重启,几十个租户的推理 Pod 一起抖;Token 轮换靠人工,轮换当天存在窗口期。

技术表现:改配置后 kubectl exec 进去看到的仍是旧值;想改用 volume,又发现某些文件怎么都不更新;想把 Token 和配置放一起,得挂三个 volume 到三个不同目录。

可验证的完成标准,后面逐条验收:

  1. kubectl get cm -n dh-platform 与 Git 历史中都搜不到任何凭证明文;
  2. 修改模型地址后不重启 Pod,容器内 /etc/app/config/ 下的文件在 kubelet 同步周期与缓存传播延迟之和内更新,具体时长取决于集群配置;
  3. 同一个目录内同时出现来自 ConfigMap、Secret、ServiceAccountToken 的文件,权限分别为 0444、0400、0400;
  4. 删除某个租户的条目后,对应内容变化,应用重载后不再为该租户提供服务。

旧法崩于注入时
三层新需压顶来
若无标准可验收
争辩终究是空谈

03、原理

本篇只需要三个原理。

第一,ConfigMap 与 Secret 是"声明式配置对象",不是"配置分发通道"。它们只描述期望状态,把期望状态送进容器的是 kubelet 的卷投射机制。你改的是对象,生效靠的是投射,两者之间隔着 kubelet 的同步周期与本地缓存。

第二,Secret 的默认形态不是加密,而是编码。data 字段是 base64,stringData 只是写入时的便利字段;kubectl get secret -o yaml 能直接解回明文。Secret 与 ConfigMap 的真正区别是约定与处理方式:更窄的 RBAC、独立的审计路径,以及对 kubelet 内存中暂存内容的额外约束。

反直觉判断:把 ConfigMap 换成 Secret,并没有让数据变得更保密,它只是把数据放进了"默认不该被随手打印"的那一类。真正的保密要么靠 etcd 静态加密,要么靠外部密钥管理,这与对象选型是两件独立的事。

第三,同一份配置,env 与 volume 是两种一致性模型。env 是"容器创建时的一次性快照",生命周期与进程相同;volume 是"最终一致的投射",kubelet 会持续把对象的新版本同步到卷里。

卷投射在实现上是原子替换:目录里有一个 ..data 符号链接指向带时间戳的真实目录,更新时先写新目录、再原子替换 ..data,各个 key 文件则是指向 ..data 下文件的符号链接。所以更新是整体切换,不会读到写了一半的文件。

反直觉判断:真正决定"能不能热更新"的,既不是 ConfigMap 还是 Secret,也不是 env 还是 volume,而是这个 key 有没有走 ..data 这条原子替换路径。用了 subPath 的单文件挂载,就绕开了这条路径。

换名未改密与明
快照投射本两途
原子替换凭一链
绕开此链即不新

04、架构

[输入] deploy/base/config/ 下的 Kustomize 源
    |
    v
[模块] ConfigMap / Secret / ServiceAccountToken 三类投射源
    |
    v
[数据/状态] etcd 中的对象版本 + kubelet 本地投射缓存
    |
    v
[处理] Projected Volume 合成目录 + ..data 原子替换
    |
    v
[输出] 容器内 /etc/app/config/ 单一配置目录

边界怎么划:只有非敏感、可公开的配置进 ConfigMap;只有小体量敏感数据进 Secret,单个对象建议控制在 1 MiB 上限以内,凭证类通常只有几十字节;需要与 Pod 同生命周期、需要自动轮换的令牌,走 projected 的 serviceAccountToken 源,而不是自己手工 create secret。

收益:一个目录内聚所有运行时配置,应用只认一个路径;敏感度不同的配置走不同的 RBAC 与审计通道;免重启生效叠加令牌自动轮换。

代价:更新是最终一致,存在延迟,不能当作强一致配置中心用;应用必须自己实现重新读取逻辑,K8s 只负责投射文件,不会通知进程;items 与 mode 需要手工维护,配置项多了容易漏。

适用条件:Pod 生命周期长、配置变更需要免重启、凭证需要轮换。反过来,如果是启动即用、跑完就退的 Job,env 的快照语义反而更简单,没必要强行上投射。

三源合一一目录
一图划出四条边
收益代价并肩立
适用之外莫强求

05、实战一次

环境与版本:Kubernetes 1.30 及以上,kubectl 与之匹配。serviceAccountToken 投射源在 1.30 已是稳定能力;clusterTrustBundle、podCertificate 等投射源处于较新的特性门控之后,具体门控名称与版本需按当前官方文档核验,本文 V1 不使用它们。

依赖:一个可用集群(kind 或 minikube 均可),以及 Go 1.21+,用来写重载器。

先建命名空间:

kubectl create namespace dh-platform

deploy/base/config/configmap.yaml:

apiVersion: v1
kind: ConfigMap
metadata:
  name: dh-tenant-config
  namespace: dh-platform
data:
  tenant.yaml: |
    tenants:
      - id: acme
        provider: provider-a
        model_endpoint: https://model-a.internal/v1
      - id: globex
        provider: provider-b
        model_endpoint: https://model-b.internal/v1

deploy/base/config/secret.yaml,这里是示例数据,真实环境不要入库:

apiVersion: v1
kind: Secret
metadata:
  name: dh-db-credential
  namespace: dh-platform
type: Opaque
stringData:
  db-dsn: "postgres://dh_app:CHANGE_ME@pg.internal:5432/digitalhuman?sslmode=verify-full"

deploy/base/config/runtime.yaml 中的 Pod 片段:

spec:
  serviceAccountName: dh-runtime
  containers:
    - name: dh-runtime
      image: registry.internal/dh-runtime:v1
      volumeMounts:
        - name: app-config
          mountPath: /etc/app/config
          readOnly: true
  volumes:
    - name: app-config
      projected:
        defaultMode: 0440
        sources:
          - configMap:
              name: dh-tenant-config
              items:
                - key: tenant.yaml
                  path: tenant.yaml
                  mode: 0444
          - secret:
              name: dh-db-credential
              items:
                - key: db-dsn
                  path: db-dsn
                  mode: 0400
          - serviceAccountToken:
              path: token
              audience: dh-platform
              expirationSeconds: 3600

注意三点:mountPath 是目录而不是文件,没有 subPath;敏感与非敏感文件的 mode 不同;Token 的 expirationSeconds 交给 kubelet 负责轮换。

核心实现 main.go,一个能感知投射更新的最小重载器:

package main

import (
	"log"
	"os"
	"path/filepath"
	"sync"
	"time"

	"github.com/fsnotify/fsnotify"
)

const configDir = "/etc/app/config"

type config struct {
	TenantYAML string
	DBDSN      string
	Token      string
}

var (
	mu  sync.RWMutex
	cur config
)

func load() (config, error) {
	var c config
	b, err := os.ReadFile(filepath.Join(configDir, "tenant.yaml"))
	if err != nil {
		return c, err
	}
	c.TenantYAML = string(b)
	if b, err = os.ReadFile(filepath.Join(configDir, "db-dsn")); err == nil {
		c.DBDSN = string(b)
	}
	if b, err = os.ReadFile(filepath.Join(configDir, "token")); err == nil {
		c.Token = string(b)
	}
	return c, nil
}

func report(c config, tag string) {
	log.Printf("%s tenant_bytes=%d dsn_present=%t token_present=%t",
		tag, len(c.TenantYAML), c.DBDSN != "", c.Token != "")
}

func main() {
	c, err := load()
	if err != nil {
		log.Fatalf("initial load failed: %v", err)
	}
	mu.Lock()
	cur = c
	mu.Unlock()
	report(c, "initial-load")

	w, err := fsnotify.NewWatcher()
	if err != nil {
		log.Fatalf("watcher: %v", err)
	}
	defer w.Close()
	if err := w.Add(configDir); err != nil {
		log.Fatalf("watch %s: %v", configDir, err)
	}

	debounce := time.NewTimer(0)
	if !debounce.Stop() {
		<-debounce.C
	}
	dirty := false

	for {
		select {
		case ev := <-w.Events:
			if ev.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Rename|fsnotify.Remove) == 0 {
				continue
			}
			dirty = true
			debounce.Reset(500 * time.Millisecond)
		case err := <-w.Errors:
			log.Printf("watch error: %v", err)
		case <-debounce.C:
			if !dirty {
				continue
			}
			dirty = false
			nc, err := load()
			if err != nil {
				log.Printf("reload skipped, keep old config: %v", err)
				continue
			}
			mu.Lock()
			cur = nc
			mu.Unlock()
			report(nc, "reloaded")
		}
	}
}

关键点是 w.Add(configDir) 监听的是目录本身。原子替换改的是 ..data 这个符号链接,事件落在目录上,而不是落在 tenant.yaml 上。

启动:

kubectl -n dh-platform apply -f deploy/base/config/configmap.yaml
kubectl -n dh-platform apply -f deploy/base/config/secret.yaml
kubectl -n dh-platform apply -f deploy/base/config/runtime.yaml
kubectl -n dh-platform rollout status deploy/dh-runtime

验证目录。未在当前环境实测,以下为预期结果:

kubectl -n dh-platform exec deploy/dh-runtime -- ls -l /etc/app/config/

示例输出:

total 0
drwxr-xr-x 3 root root 100 ..2026_01_01_00_00_00.000000000
lrwxrwxrwx 1 root root  32 ..data -> ..2026_01_01_00_00_00.000000000
lrwxrwxrwx 1 root root  32 db-dsn -> ..data/db-dsn
lrwxrwxrwx 1 root root  32 tenant.yaml -> ..data/tenant.yaml
lrwxrwxrwx 1 root root  32 token -> ..data/token

..data 的存在是判断"是否走原子替换路径"的最直接证据。

然后触发一次更新,不重启 Pod:

kubectl -n dh-platform create configmap dh-tenant-config \
  --from-file=tenant.yaml=./tenant-v2.yaml \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl -n dh-platform get pod -l app=dh-runtime \
  -o jsonpath='{.items[0].metadata.creationTimestamp}{"\n"}'

等一个 kubelet 同步周期加缓存传播延迟之后:

kubectl -n dh-platform exec deploy/dh-runtime -- cat /etc/app/config/tenant.yaml
kubectl -n dh-platform logs deploy/dh-runtime --tail=20

示例输出里关键的一行:

2026/01/01 00:02:11 reloaded tenant_bytes=214 dsn_present=true token_present=true

Pod 的 creationTimestamp 没有变化,说明这是投射更新加应用重载的效果,不是滚动重启。

V1 到这里跑通了,但留下三个问题:为什么一开始用 env 时怎么改都不生效;为什么有人加了 subPath 之后又失效;Token 轮换后应用有没有真的换掉。

先跑最小一条链
再留三问待后解
未测不写已测数
示例输出亦留痕

06、排查

诊断一:改了 ConfigMap,容器里的环境变量没变。

现象: 把 app-config 里的 MODEL_ENDPOINT 改掉,等了二十分钟,进容器 env | grep MODEL_ENDPOINT 还是旧值。

怀疑: kubelet 没拿到新对象,或者本地缓存没失效。

检查:

kubectl -n dh-platform get cm app-config -o jsonpath='{.data.MODEL_ENDPOINT}{"\n"}'
kubectl -n dh-platform get pod -l app=dh-runtime -o jsonpath='{.items[0].metadata.creationTimestamp}{"\n"}'
kubectl -n dh-platform describe pod -l app=dh-runtime | grep -A5 Events

证据: 对象里已经是新值;Pod 的 creationTimestamp 没变;Events 里没有任何异常。

根因: 环境变量在容器进程创建时写入进程环境块,之后与 ConfigMap 的版本再无关系。这是 env 消费方式的定义,不是故障。

修复: 把 MODEL_ENDPOINT 从 envFrom 里拿出来,改为投射成文件,应用从文件读。

错误尝试: 一开始怀疑是 kubelet 的 ConfigMap 本地缓存 TTL 太长,去翻 kubelet 参数、给节点补 ConfigMap 读权限、甚至重启了 kubelet。为什么错:这条路径默认"对象会被重新投射到容器环境里",而 env 根本没有投射环节,它只在容器创建那一刻发生一次。权限和缓存影响的是卷投射,与 env 无关。

诊断二:改成 volume 之后,配置文件仍然不更新。

现象: /etc/app/config/tenant.yaml 的内容始终是第一次创建时的版本,ls -l 看目录里也没有 ..data。

怀疑: 仍然是 kubelet 缓存问题。

检查:

kubectl -n dh-platform get deploy dh-runtime \
  -o jsonpath='{.spec.template.spec.containers[0].volumeMounts}{"\n"}'
kubectl -n dh-platform exec deploy/dh-runtime -- ls -la /etc/app/config/

证据: volumeMounts 里有 "subPath": "tenant.yaml";目录列表里没有 ..data 与时间戳目录。

根因: subPath 挂载把 ConfigMap 里的某个 key 直接绑定到容器内的一个路径上,它不走 ..data 原子替换流程,因此后续的对象变更不会被投射到这个路径。

修复: 去掉 subPath,把整个投射卷挂到目录上,用 items[].path 控制文件名。

错误尝试: 曾试图给 ConfigMap 加 metadata.annotations 强制触发更新,或者干脆把 Deployment 复制一份重新 apply。为什么错:前者改变不了挂载方式;后者改变了 Pod 模板,等于用重启掩盖问题——一旦上线,每次改配置都要重建 Deployment,热更新的诉求直接归零。

诊断三:文件更新了,应用行为没变。

现象: cat /etc/app/config/tenant.yaml 已经是新内容,但应用日志里打印的还是旧模型地址。

怀疑: 应用没有重新读取配置。

检查:

kubectl -n dh-platform exec deploy/dh-runtime -- ls -l /etc/app/config/
kubectl -n dh-platform logs deploy/dh-runtime --tail=50 | grep -i reload

证据: ..data 的符号链接目标已经指向新的时间戳目录,mtime 变了,但应用日志里没有任何重载记录。

根因: kubelet 的职责是"把文件投射到容器文件系统",它不负责让目标进程重新读取。配置的生效必须由应用侧完成。

修复: 应用监听配置目录而不是监听具体文件,收到事件后按 500ms 去抖重新读取全部配置,并原子替换内存中的副本。

先疑缓存后疑权
两度错判方见真
证据落处根因现
一改一验方收兵

07、优化

严格基于 06 的三条证据,V2 的改动如下。

第一,根因是对象选型把敏感度与更新语义混在了一起,修改方式是按四个边界拆对象:数字人模型地址落到 ConfigMap dh-model-endpoint,非敏感、变更频繁、需要热更新;租户配置落到 ConfigMap dh-tenant-config,非敏感、体量随时间增长;数据库凭证落到 Secret dh-db-credential,敏感、变更极少;Provider Token 落到按租户拆分的 Secret dh-provider-token-<tenant>,敏感、有有效期。原因:变更频率与敏感度相乘,决定了这个 key 该走哪条 RBAC 与审计路径。

第二,根因是 env 是快照,修改方式是所有需要热更新的 key 一律走投射卷,envFrom 只保留纯启动期参数,例如 LOG_LEVEL、以及从 Downward API 来的 POD_NAME。

第三,根因是 subPath 绕开了原子替换,修改方式是彻底禁用 subPath,全部用 items[].path 控制路径,mountPath 一律是目录。

第四,根因是应用不重读,修改方式就是 05 里那段监听目录加去抖加原子替换的重载器。

还有一个取舍点必须点出来:Kustomize 的 configMapGenerator 默认会给名字加内容哈希后缀,名字变了会导致 Pod 模板变化,从而触发滚动更新,这与热更新是互斥的。

generatorOptions:
  disableNameSuffixHash: true

需要热更新的 ConfigMap 关掉哈希后缀;需要"改了必滚动"的配置,例如影响进程启动参数的,保留哈希后缀,用滚动更新换取确定性。两条路都能走,但不能对同一个对象同时抱两种期待。

验证方式只写方案,不写实测数据:用 kubectl create configmap ... --dry-run=client -o yaml | kubectl apply -f - 更新 dh-model-endpoint 并记录更新时刻;轮询 kubectl exec ... cat /etc/app/config/model-endpoint,记录第一次读到新值的时刻,差值应落在 kubelet 同步周期与缓存传播延迟之和以内;同时用 kubectl get pod -o jsonpath='{.metadata.creationTimestamp}' 确认时间戳未变;用 kubectl logs 确认出现过一次 reloaded;最后删除 dh-tenant-config 中某个租户的条目,确认内容变化且应用重载后不再为该租户服务。

根因几条改几条
哈希后缀两难全
热更重启各有路
择一而行莫贪全

08、演进

[同一输入] 修改模型地址 / 轮换凭证 / 新增租户
    |
    +--[V1] envFrom 快照 + 单 ConfigMap
    |       代价:必须重建 Pod,凭证在 Git 与 ConfigMap 中明文
    |
    +--[V2] projected 目录 + 对象按边界拆分
    |       代价:最终一致(有延迟),应用必须自己实现重载
    |
[Trade-off]
得到:免重启生效、凭证可审计、令牌自动轮换、单一配置路径
失去:强一致的"改完立刻生效"、env 带来的零代码读取便利
适用边界:长生命周期 Pod、凭证需要轮换、配置变更需要灰度

正确性:V1 无法满足"不重启生效",这是架构层面的不可达,不是调参能解决的;V2 在文档定义的延迟窗口内可满足。

稳定性:V1 每次改配置都要重建 Pod,重启风暴本身就是风险源;V2 把风险从"重建"转移到"应用重载",需要重载逻辑本身幂等,并且能容忍部分文件暂缺。

复杂度:V1 只有一行 envFrom;V2 需要维护 items、mode、重载器以及对象边界。复杂度是真实上升的。

成本:V2 多了一次应用侧代码改动和一套目录约定,换来的是免重启与凭证出仓。这项成本在 Pod 数量与配置变更频率都不高的小系统里可能并不划算。

适用范围:长生命周期、多租户、有审计要求的系统适合 V2;Job、CronJob、启动即固定的批处理适合 V1。

遗留问题:投射是最终一致的,缺少"更新完成"的回执;应用重载失败时旧配置仍在使用,需要额外的可观测性来暴露"配置版本滞后"。

同一输入两条路
一重确定一重快
取舍写进边界里
遗留问题亦须说

09、洞见

9.1 敏感度与生效方式是正交的两个维度

把"要不要保密"和"多久生效"当成一个问题,是这类故障的共同起点。ConfigMap 与 Secret 回答的是前一个问题,env 与 volume 回答的是后一个问题。两个维度各有两种取值,组合出四种情形:非敏感加免重启,用 ConfigMap 配 volume,例如模型地址与租户表;非敏感加启动期固定,用 ConfigMap 配 env,例如日志级别与进程参数;敏感加免重启,用 Secret 配 volume,例如数据库凭证;敏感加启动期固定,用 Secret 配 env,例如初始化用的管理员口令。一旦把这四个格子压成两个,任何一次变更都会撞上另一半需求。

9.2 同一份配置,env 与 volume 是两种一致性模型

env 是进程生命周期内的不可变快照,volume 是最终一致的投射。这不是实现细节的差异,而是两种可以写进契约的语义。

反直觉判断:选择 volume 并不等于获得了实时配置中心,它只获得了最终一致。文档明确说明这个延迟取决于 kubelet 同步周期与缓存传播延迟,而不是零。把它当成即时生效的配置源,会在灰度场景里制造出难以复现的不一致。

9.3 反直觉判断:Secret 默认并不加密,换对象不等于提高保密强度

Secret 的 data 是 base64,stringData 是写入时的便利字段,kubectl get secret -o yaml 能直接解回明文;默认情况下 etcd 中存储的也不是密文。把 ConfigMap 改名成 Secret,实质是把数据搬进了"默认不该被打印"的通道,缩小了误暴露面,但没有改变"谁能在存储层读到它"。真正的边界要落在 etcd 静态加密、RBAC 收紧以及准入控制上。对象选型是必要动作,不是充分条件。

9.4 反直觉判断:想热更新,就要主动放弃 subPath 与哈希后缀

两个看起来更好用的机制都在破坏热更新:subPath 让单个 key 能挂到任意路径,代价是绕开 ..data 原子替换;configMapGenerator 的哈希后缀让改名自动触发滚动更新,代价是每次都重建 Pod。

反直觉判断:这两个特性的价值恰恰在于确定性——它们把配置变更变成了一次可预测的重新部署。所以正确的做法不是全盘弃用,而是把它们留给那些"改了就应该重启"的配置,把热更新的期待集中在另一批 key 上。分类比选择更重要。

9.5 边界要落在三个可回答的问题上

任何一条配置项,只要你能回答这三个问题,边界就清楚了。谁能读,决定它在 ConfigMap 还是 Secret,以及对应的 RBAC;谁能改,决定它走代码评审、配置中心还是紧急通道;多久生效,决定它走 env 还是 volume,是否需要哈希后缀触发滚动。三个问题里只要有一个答不上来,这条配置就迟早会在生产上给你出题。

两维正交各成行
四格分明少纠缠
反直觉处多一问
三问能答边自成

10、系统落地

原来有什么:deploy/base/config/ 里一个 configmap.yaml,一个 app-config 对象承载全部配置,Pod 用 envFrom 注入,改配置靠重启。

本篇新增了什么:configmap.yaml 拆成 dh-model-endpoint 与 dh-tenant-config 两个非敏感对象;新增 dh-db-credential 与 dh-provider-token-<tenant> 两组 Secret 边界;新增 runtime.yaml 中的 projected volume 定义,把三类投射源合成到 /etc/app/config;新增应用侧的目录监听重载器;新增 generatorOptions.disableNameSuffixHash 这个显式选择。

现在能做什么:改模型地址或租户表不需要重启 Pod;凭证不再出现在 ConfigMap 与 Git 中;ServiceAccount Token 与配置同目录并可自动轮换;四种配置情形各有明确落点。

还缺什么:缺少配置版本的可观测指标,无法快速判断某个 Pod 的配置是否滞后;缺少重载失败的告警,目前只打日志;Provider Token 的轮换仍依赖外部 Provider 的签发流程,投射只是最后一公里。

下一步如何演进:把重载结果暴露成指标,例如 config_reload_success_total 与 config_load_timestamp_seconds,并在 Deployment 上用注解注入期望的配置版本哈希,让滞后变成可查询的状态,而不是日志里的一行字。

原有之物先盘点
新增之边界可查
所缺之处如实记
下一步才算落地

11、小结

Q1 → 先拆对象还是先拆消费方式:两者正交,必须先分类再选型
Q2 → 三件事能否同时满足:能,前提是所有热更新 key 都走投射卷且应用自己重载
Q3 → 顺序搞反的后果:先选消费方式会让边界坍塌,出现配置不生效与凭证泄漏并存
状态 → deploy/base/config/ 下一套可热更新、可审计、可轮换的配置基线

三条可带走的判断:ConfigMap 与 Secret 的差别是约定而不是加密;env 与 volume 的差别是一致性模型而不是风格;subPath 与哈希后缀的差别是确定性而不是便利。

三问三答收一线
一句状态定成败
带走判断可复用
其余细节皆可查

12、作业

12.1 理解题:为什么把配置从 ConfigMap 换成 Secret,并不能提升保密强度?

参考答案:Secret 的 data 默认只是 base64 编码,stringData 是写入便利字段,这不是加密。默认情况下 Secret 在 etcd 中并未加密存储,kubectl get secret -o yaml 可还原明文。对象选型缩小的是误暴露面,也就是更窄的 RBAC 与独立审计通道,真正的保密要靠 etcd 静态加密、外部 KMS,以及更严格的 RBAC 与准入控制。

12.2 实战题:把 deploy/base/config/ 下的 app-config 拆成"非敏感加免重启"和"敏感加免重启"两个对象,并让容器在同一个目录看到它们,写出关键 manifest 片段。

参考答案:

volumes:
  - name: app-config
    projected:
      sources:
        - configMap:
            name: dh-model-endpoint
            items:
              - key: endpoint
                path: model-endpoint
                mode: 0444
        - secret:
            name: dh-db-credential
            items:
              - key: db-dsn
                path: db-dsn
                mode: 0400

挂载时 mountPath: /etc/app/config,不使用 subPath。关键点有三个:mountPath 是目录;敏感与非敏感文件用不同的 mode;所有需要热更新的 key 都通过 items[].path 落到同一个目录。

12.3 排障题:Pod 里的配置文件始终不更新,ls -la 看不到 ..data,先查什么?

参考答案:先查 volumeMounts 里有没有 subPath。subPath 会把单个 key 直接绑定到容器路径,不走 ..data 原子替换,因此不会收到后续更新。其次确认挂载的是投射卷或 ConfigMap 卷的目录而不是单个文件,最后确认 kubelet 版本与特性门控没有改变投射行为。修复方式是去掉 subPath,用整体目录挂载加 items[].path 控制文件名。

12.4 架构判断题:Kustomize 的 configMapGenerator 默认加内容哈希后缀,它和配置热更新能否共存?

参考答案:两者方向相反。哈希后缀会改变 ConfigMap 名字,进而改变 Pod 模板,触发滚动更新,语义是改了必重启。如果目标是免重启热更新,需要对这类对象设置 generatorOptions.disableNameSuffixHash: true,并把重新读取的责任交给应用。正确的做法是按配置语义分批:影响进程启动参数的配置保留哈希后缀,靠滚动确保一致性;运行期可变的配置关闭哈希后缀,靠投射与重载完成更新。不要对同一个对象同时期待两种行为。

理解实战排障后
再问架构判断题
四题答罢回头看
前文证据皆可用

13、思考

回到最初那个冲突:一个对象、一种消费方式,承载不了"敏感度"与"生效方式"两个独立维度。

可复用的判断是,先给每一条配置回答三个问题——谁能读、谁能改、多久生效——再决定它落在哪个对象、用哪种消费方式。对象选型解决暴露面,消费方式解决一致性,两者不能互相替代。

真正的工程边界不在 API 对象的名字上,而在"更新之后,谁负责让新值真正生效"这个问题的归属上。K8s 负责把文件投射到容器,应用负责重新读取,中间那段延迟必须被写进契约,而不是被假设为零。

一物难承两维事
一分边界见真章
一守延迟明契约
一改方知取舍深

posted @ 2026-10-12 10:16  李福春  阅读(2)  评论(0)    收藏  举报