KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 配置与密钥:为什么改了 `ConfigMap` 而 Pod 里没变 — keel 龙骨

这一章回答:配置有两种注入方式,它们的更新语义完全不同;密钥为什么不该进 git,以及"引用了不存在的对象"这个检查结果该怎么处理。

这一章回答:配置有两种注入方式,它们的更新语义完全不同;密钥为什么不该进 git,以及"引用了不存在的对象"这个检查结果该怎么处理。

这一章是《Docker 应用课》第 05 章"三道边界"在 K8s 里的延续:那边讲配置怎么穿过构建上下文 → 镜像层 → 运行时环境三道边界,这边处理运行时的配置从哪儿来、以及它会不会更新。

现场

一个服务的日志级别需要从 info 调到 debug(排查线上问题)。操作:

$ kubectl edit configmap orders-api-config     # 把 LOG_LEVEL 从 info 改成 debug
configmap/orders-api-config edited

$ kubectl exec deploy/orders-api -- printenv LOG_LEVEL
info                                            ← 没变

再确认 ConfigMap 确实改了:

$ kubectl get configmap orders-api-config -o jsonpath='{.data.LOG_LEVEL}'
debug                                           ← 对象里是新的

对象里是新的,容器里是旧的——于是产生了两种猜测:

要分清哪个对,得先知道配置有两种注入方式。

一、两种注入方式,两种更新语义

环境变量(env / envFrom) 卷挂载(volumeMounts,目录形式)
怎么进容器 容器启动时由运行时写入进程环境 由 kubelet 把内容同步成一个只读卷挂进容器
后续更新会不会生效 不会。进程环境在启动后不再被改 会,kubelet 周期性同步(有延迟)
更新机制 无(没有任何东西会去改运行中进程的环境) 替换卷内文件的符号链接,指向新的内容目录
应用需要做什么 不需要(也不需要:反正不会变) 需要主动重新读文件
什么时候适合用 简单的开关、少量立即需要的值 配置文件、证书、(应)需要热更新的值

决定性的差别在于"谁是读者":

二、更新的完整分支

flowchart TD
  A["ConfigMap / Secret 对象被更新"] --> K["kubelet 周期性同步<br/>(有延迟,不保证及时)"]
  K --> B{"容器是怎么引用它的?"}

  B -->|"env / envFrom"| ENV["kubelet 不做任何事<br/>运行中进程的环境变量不会被改"]
  ENV --> STALE1["容器里仍是启动时的旧值<br/>只能靠重建容器才能变"]

  B -->|"volumeMounts:挂载目录"| VOL["替换卷里的符号链接<br/>容器内看到的文件内容更新"]
  VOL --> RELOAD{"应用会重新读这个文件吗?"}
  RELOAD -->|"会(主动 watch 或周期性重读)"| NEW["新配置在进程里生效"]
  RELOAD -->|"只在启动时读一次"| STALE2["文件变了,进程里的值还是旧的"]

  B -->|"volumeMounts + subPath"| SUB["subPath 挂载的是文件的快照<br/>不随 ConfigMap 更新"]
  SUB --> STALE1

这张图上有三条通向"容器里还是旧值"的路:

  1. 第 B 跳走环境变量分支(现场那个 bug);
  2. 第 RELOAD 跳走"只读一次"分支——文件确实更新了,但进程不理会;
  3. 第 B 跳走 subPath 分支——这是最容易踩、也最容易被忽略的一条。

关于 subPath 这个坑

把 ConfigMap 的单个 key 挂成一个文件时,写法是 subPath:

          volumeMounts:
            - name: config
              mountPath: /app/config/app.yaml
              subPath: app.yaml          # ← 用了 subPath

subPath 的实现方式是把那个文件复制/绑定到目标路径,而不是建立一个指向整个目录的符号链接。后果:ConfigMap 更新后这个文件不会更新。 这是 K8s 里一处明确的行为差异(官方文档在 volumes 一节里专门提醒过),而它和"目录挂载会更新"这个通行的印象直接冲突。

所以"卷挂载会自动更新"这句话要加限定:挂载整个目录会更新;用 subPath 挂单个文件不会。

三、那"改配置"的正确姿势是什么

既然环境变量不会更新、卷挂载的更新又依赖应用配合,那么工程上更可靠的做法是:不要指望热更新,把配置变更变成一次发布。

有两种实现,正好对应第 01 章的"命令式 vs 声明式":

命令式:手工重启 声明式:内容哈希
做法 kubectl rollout restart deploy/orders-api 用构建工具按内容生成资源名
谁记得做 人(或 CI 脚本) 构建系统
触发条件 人意识到"配置变了" 配置内容变化自动产生新名字
是否可审计 命令历史里 在清单的 diff 里

第二种是本课推荐的方式,而且可以用纯离线工具验证。kustomize 的 configMapGenerator 会按内容算一个哈希后缀,把这个后缀加在资源名上:

configMapGenerator:
  - name: orders-api-config
    envs:
      - app.env

内容为 LOG_LEVEL=info 时,构建产物(本机实测):

kind: ConfigMap
metadata:
  name: orders-api-config-6f627t5gf4

只改一行(LOG_LEVEL=debug)之后:

metadata:
  name: orders-api-config-k57thfg8hc

而关键在于——容器里的引用也被同步改写了:

      - envFrom:
        - configMapRef:
            name: orders-api-config-k57thfg8hc      # ← 跟着变

于是整个链条闭合了:

配置内容变化 → ConfigMap 名带上新哈希 → Pod 模板里的引用变了
            → Pod 模板变化 = 一次新 revision → 触发滚动更新
            → 新 Pod 用新名字读出新的 ConfigMap → 配置生效

这是"配置变更自动生效"的声明式解法:它不依赖任何人记得重启,也不依赖应用会重读文件。名字变了,就是新版本——这与第 05 章"Pod 模板任何改动都产生新 revision"是同一条机制。

代价也要说清楚:每次配置变更都会引起一次完整的滚动更新(拉起新 Pod、等就绪、减旧 Pod),它比"热更新"重。所以选择取决于这个值有多需要"立即生效"——日志级别这类可以等一次滚动的,用哈希;而那些真的需要秒级生效的东西(开关、限流阈值),更合适的方案是运行时配置中心(应用主动拉取/订阅),而不是把它塞进 ConfigMap。

四、密钥:Secret 不是加密,且它不该进 git

两件必须先纠正的事:

第一,Secret 不提供加密。 它的 data 字段只是 base64 编码(stringData 会在写入时自动转成 base64)。base64 是编码不是加密——任何人都能解回来:

base64 编码的目的是让二进制内容能安全地放进 YAML/JSON,
它的作用域是"传输与格式",不是"保密"。

所以 Secret 提供的实际能力是:把凭据从普通配置里分离出来,从而可以对它单独施加 RBAC 与审计。它是访问控制的对象,不是加密的容器。(真正的加密要靠 etcd 静态加密、KMS 插件、或外部的密钥管理服务。)

第二,Secret 的清单不应该进 git。 这与《Docker 应用课》第 05 章第 5 节那张决策表是同一套判断,只是边界换成了"仓库 → 集群"。

于是产生了一个静态检查会碰到的真实矛盾。本课示例的清单里,容器引用了 Secret/orders-db,而清单目录里没有这个对象。静态检查如实报了:

=== 检查 2 · ConfigMap / Secret 引用 ===
  ✗ orders-api → Secret/orders-db 未找到
  ✗ orders-migrate → Secret/orders-db 未找到

  [严重] orders-api 引用了 Secret/orders-db,但本次构建里没有这个对象 → Pod 会卡在 CreateContainerConfigError

这个报警是对的,但它不是缺陷。 它准确地描述了两种情况中的一种,而你需要决定是哪一种:

情况 该怎么做
密钥由集群外提供(SealedSecrets / SOPS 解密后 apply / ExternalSecrets / 运维手工创建) 这是正常的。给检查器一份外部提供对象的清单(白名单),让这类引用不再报警
密钥确实漏了(新人克隆仓库、新环境初始化时忘了创建) 这就是真问题——Pod 会卡在 CreateContainerConfigError

这一节的通用教训比 K8s 本身更值钱:任何"目标对象由外部提供"的系统里,静态检查都会产生这类噪声。处理方式不是关掉检查,而是把"哪些对象由外部负责"变成一份显式声明——否则检查要么被忽略(失去价值),要么被迫放宽到抓不到真问题。

一个实用的分级

配置内容 放哪里 为什么
环境名、日志级别、功能开关 ConfigMap(可进 git) 不是秘密,且需要被审计(谁改了什么)
数据库地址、外部服务 URL ConfigMap 同上;注意用户名可以进,口令不行
数据库口令、API key、TLS 私钥 Secret + 外部注入 进 git 就等于永久泄漏(git log 里删不掉)
需要轮换的短期凭据 密钥管理服务 + 应用主动拉取 轮换不需要重建 Pod

最后一行值得展开一句:需要轮换的凭据不适合用环境变量承载。因为按第 01 节,环境变量在进程启动后不会更新——轮换一次就得重建一遍 Pod。这正是密钥管理服务(应用主动拉取)比"把值塞进环境变量"更适合密钥的原因。

五、误判澄清

读者常见的理解 核对后的事实 为什么会被误导
"改了 ConfigMap,等一会儿 Pod 里就会变" 环境变量永远不会变(没有任何机制去改运行中进程的环境);卷挂载会变但依赖应用重读 "配置中心"式的直觉:改了就该传播
"卷挂载会自动更新,所以配置能热更新" 目录挂载会更新,但 subPath 挂单文件不会 subPath 只是多写了三个字母,看起来是同一件事
"Secret 是加密的" 只是 base64 编码。它的价值是 RBAC 与审计,不是保密 名字叫 Secret,且值在命令行里看起来不可读
"配置变更必须靠人记得重启" configMapGenerator 按内容加哈希后缀,改内容自动触发滚动(本机实测) 不知道构建工具提供了这个能力
"静态检查报了'对象不存在',说明清单坏了" 可能是密钥由外部提供这一正常情况;需要的是白名单机制,而不是关检查 检查器看不到集群外的对象
"把密钥放进 Secret 就安全了" 清单进 git 就等于永久泄漏;Secret 解决的是访问控制,不是存储安全 两个不同的威胁模型被合并了

生产边界

教学替身 真实替换点 要注意什么
configMapGenerator 的内容哈希 配置变更走发布流水线,或接运行时配置中心 哈希方案的代价是每次改配置都要滚动一次;真需要秒级生效的走配置中心
目录挂载 + 应用重读文件 应用实现 watch(如 inotify / 周期性重载) 应用不重读,文件更新就没有意义
手工创建 Secret SealedSecrets / SOPS / ExternalSecrets / 云厂商密钥服务 前两者是"加密后进 git",后两者是"运行时从外部拉"——威胁模型不同
静态检查的外部对象白名单 准入策略里的"允许引用未在本次提交中定义的对象"规则 白名单要显式、可审计,不能靠关掉检查
环境变量承载凭据 密钥服务 + 应用主动拉取(支持轮换) 环境变量在进程启动后不可更新,轮换必然要重建 Pod
etcd 静态未加密 启用 etcd 静态加密 / KMS 插件 Secret 在 etcd 里默认是明文存储的(除非开启加密)

动手

  1. 列出你清单里所有的 env / envFrom 引用,逐个回答"这个值如果不更新,会有什么后果";
  2. 对有热更新需求的值,检查它是不是用 subPath 挂的单文件——如果是,改成挂目录再让应用重读;
  3. 用 kubectl kustomize 对比"改一行配置前后"的构建产物,确认资源名(或其哈希)是否变化;
  4. 判断标准:能回答"我改一个配置值,到它在进程里生效,中间要走几步"——并指出哪一步需要人;
  5. 完成标志:写出你的凭据注入路径(谁创建、怎么进集群、怎么轮换),以及为什么它不该进 git。

故障注入

注入方式 观察什么 说明的现象
改 ConfigMap 后(envFrom 引用)看容器内的环境变量 值是否变化 不变化;必须重建容器(现场那个 bug)
同一个改动,但用目录挂载引用,且应用会重读文件 容器内文件与进程里的值 文件会更新(有延迟),进程里的值取决于应用是否重读
把目录挂载改成 subPath 挂单文件,再改 ConfigMap 容器内那个文件 不更新——subPath 是文件快照
改用 configMapGenerator,改一行内容再构建 资源名与引用 名字带新哈希、引用自动改写 → 自动触发滚动(本机实测)
引用一个清单里不存在的 Secret Pod 状态与静态检查 Pod 卡 CreateContainerConfigError;静态检查会报(需白名单区分"外部提供")
从 git 历史里删掉曾提交的 Secret 清单 凭据是否真的消失 不消失——git log 里还在;凭据必须轮换

自测

  1. 环境变量与目录挂载在"对象更新后容器内是否变化"这一点上有什么差别?机制上的原因是什么?
  2. subPath 为什么会让更新失效?它和"卷挂载会自动更新"这句话冲突在哪?
  3. configMapGenerator 的内容哈希为什么能"自动触发滚动更新"?这条链路上哪一环是靠 Pod 模板变化起作用的?
  4. 哈希方案的代价是什么?什么类型的配置不该用它?
  5. Secret 提供的是什么能力、不提供什么能力?为什么"把凭据放进 Secret"不等于"安全"?
  6. 静态检查报"引用了不存在的对象",有哪两种可能?正确的处理方式分别是什么?

现在能解释什么

最后一章把前面所有失败分支收口成一张分流表:看到某个异常状态时,第一个该看的字段是什么。

进入 keel 阅读