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 ← 对象里是新的
对象里是新的,容器里是旧的——于是产生了两种猜测:
- "配置改了但没生效,是不是要重启服务?" —— 这个猜测是对的,但理由往往是错的。
- "kubelet 还没同步,等一会儿就好" —— 这个猜测是错的,等多久都不会变。
要分清哪个对,得先知道配置有两种注入方式。
一、两种注入方式,两种更新语义
环境变量(env / envFrom) |
卷挂载(volumeMounts,目录形式) |
|
|---|---|---|
| 怎么进容器 | 容器启动时由运行时写入进程环境 | 由 kubelet 把内容同步成一个只读卷挂进容器 |
| 后续更新会不会生效 | 不会。进程环境在启动后不再被改 | 会,kubelet 周期性同步(有延迟) |
| 更新机制 | 无(没有任何东西会去改运行中进程的环境) | 替换卷内文件的符号链接,指向新的内容目录 |
| 应用需要做什么 | 不需要(也不需要:反正不会变) | 需要主动重新读文件 |
| 什么时候适合用 | 简单的开关、少量立即需要的值 | 配置文件、证书、(应)需要热更新的值 |
决定性的差别在于"谁是读者":
- 环境变量的读者是进程。进程的环境在其启动那一刻由内核确定,之后没有任何机制会去修改一个已经在运行的进程的环境。所以 kubelet 即便收到了 ConfigMap 的更新通知,它也只知道容器用了这个 ConfigMap,却无法把新值塞进已运行进程的环境变量里——它唯一的选择是重启容器,而它默认不会主动这么做。
- 卷的读者是文件系统。文件可以被覆盖。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
这张图上有三条通向"容器里还是旧值"的路:
- 第
B跳走环境变量分支(现场那个 bug); - 第
RELOAD跳走"只读一次"分支——文件确实更新了,但进程不理会; - 第
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 里默认是明文存储的(除非开启加密) |
动手
- 列出你清单里所有的
env/envFrom引用,逐个回答"这个值如果不更新,会有什么后果"; - 对有热更新需求的值,检查它是不是用
subPath挂的单文件——如果是,改成挂目录再让应用重读; - 用
kubectl kustomize对比"改一行配置前后"的构建产物,确认资源名(或其哈希)是否变化; - 判断标准:能回答"我改一个配置值,到它在进程里生效,中间要走几步"——并指出哪一步需要人;
- 完成标志:写出你的凭据注入路径(谁创建、怎么进集群、怎么轮换),以及为什么它不该进 git。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
改 ConfigMap 后(envFrom 引用)看容器内的环境变量 |
值是否变化 | 不变化;必须重建容器(现场那个 bug) |
| 同一个改动,但用目录挂载引用,且应用会重读文件 | 容器内文件与进程里的值 | 文件会更新(有延迟),进程里的值取决于应用是否重读 |
把目录挂载改成 subPath 挂单文件,再改 ConfigMap |
容器内那个文件 | 不更新——subPath 是文件快照 |
改用 configMapGenerator,改一行内容再构建 |
资源名与引用 | 名字带新哈希、引用自动改写 → 自动触发滚动(本机实测) |
引用一个清单里不存在的 Secret |
Pod 状态与静态检查 | Pod 卡 CreateContainerConfigError;静态检查会报(需白名单区分"外部提供") |
从 git 历史里删掉曾提交的 Secret 清单 |
凭据是否真的消失 | 不消失——git log 里还在;凭据必须轮换 |
自测
- 环境变量与目录挂载在"对象更新后容器内是否变化"这一点上有什么差别?机制上的原因是什么?
subPath为什么会让更新失效?它和"卷挂载会自动更新"这句话冲突在哪?configMapGenerator的内容哈希为什么能"自动触发滚动更新"?这条链路上哪一环是靠 Pod 模板变化起作用的?- 哈希方案的代价是什么?什么类型的配置不该用它?
Secret提供的是什么能力、不提供什么能力?为什么"把凭据放进Secret"不等于"安全"?- 静态检查报"引用了不存在的对象",有哪两种可能?正确的处理方式分别是什么?
现在能解释什么
- 配置有两种注入语义:环境变量是快照(永不更新),目录挂载会更新(有延迟、且依赖应用重读)。
subPath挂单文件不会随ConfigMap更新——这是"卷会更新"这个印象最重要的一处例外。- 让配置变更可靠生效的方式是把它变成一次发布;
configMapGenerator的内容哈希把这件事声明式化了(实测:内容变 → 名字变 → 引用变 → 触发滚动)。 Secret只是 base64 编码,它提供的是 RBAC 与审计,不是加密;清单进 git 等于永久泄漏,只能靠轮换补救。- 静态检查会报"对象不存在",而其中一个原因是密钥由外部提供这一正常状态——需要显式的白名单,而不是关掉检查。
最后一章把前面所有失败分支收口成一张分流表:看到某个异常状态时,第一个该看的字段是什么。