改了 ConfigMap,Pod 里的配置还是旧的:subPath 与热更新的边界
限流阈值从 200 改到 500,等了十分钟容器里还是旧值。kubelet 换的是符号链接,subPath 绑的是启动时的 inode。挂载方式怎么选,哪些配置本就该重启生效。
上周调一个限流阈值,从 200 改到 500。流程很标准:kubectl edit configmap gateway-config,改完 kubectl rollout status 看了一眼——没有滚动,因为 Deployment 没变。我想着 ConfigMap 是热更新的,等一分钟就好。
十分钟后,压测机上那个接口还在 200 就开始拒。kubectl exec 进 Pod,cat /app/config/application.yaml,里面的值还是 200。再看 ConfigMap 本身,是 500。文件和配置对不上,这件事有点意思。
一、改了配置,等了十分钟还是旧值
先把三处都查一遍,确认不是「改错了地方」这种低级问题。
# 1. ConfigMap 本身
kubectl -n prod get cm gateway-config -o jsonpath='{.data.application\.yaml}' | grep -A2 rate-limit
# 2. 集群里记录的最后更新时间
kubectl -n prod get cm gateway-config -o jsonpath='{.metadata.resourceVersion}{"\n"}'
# 3. Pod 里实际看到的文件
kubectl -n prod exec deploy/gateway -- cat /app/config/application.yaml | grep -A2 rate-limit
1 和 2 都显示是新值,3 是旧值。而且不是「差一点」,是十分钟都没变,kubelet 的同步周期(默认一分钟)早该跑过十轮了。
看 Pod 的挂载:
kubectl -n prod get deploy gateway -o jsonpath='{range .spec.template.spec.containers[0].volumeMounts[*]}{.name}{" -> "}{.mountPath}{" subPath="}{.subPath}{"\n"}{end}'
# config -> /app/config/application.yaml subPath=application.yaml
subPath=application.yaml。看到这个字段,答案基本就定了。
二、kubelet 是怎么同步 ConfigMap 的
要理解为什么 subPath 会失效,得先知道正常情况下的同步机制。
ConfigMap 挂载到 Pod 上,本质是一个 tmpfs,kubelet 定期(--sync-frequency,默认 1 分钟)把最新的内容写进去。它用的手法很特别:不是原地覆盖文件,而是建一个新的隐藏目录,把内容写进去,然后把符号链接原子地指过去。
在 Pod 里能看到这个结构:
kubectl -n prod exec deploy/gateway -- ls -la /app/config/
# lrwxrwxrwx 1 root root 15 Nov 9 09:02 application.yaml -> ..data/application.yaml
# drwxr-xr-x 2 root root 4096 Nov 9 09:02 ..2026_11_09_09_02_11.382910
# lrwxrwxrwx 1 root root 31 Nov 9 09:02 ..data -> ..2026_11_09_09_02_11.382910
application.yaml 是一个指向 ..data/application.yaml 的符号链接,..data 又指向一个带时间戳的目录。更新时 kubelet 新建一个时间戳目录、写入新内容、把 ..data 这个链接换掉。整个过程是原子的,正在读文件的进程不会读到半截内容。
这是 ConfigMap 热更新的核心机制:换链接,不换文件。
而 subPath 挂载做的是另一件事:它在容器启动时把 /app/config/application.yaml 直接 bind mount 到 ConfigMap 里对应文件的实际内容上。这个 bind mount 绑定的是「当时那个 inode」,符号链接换掉之后,bind mount 还指着老 inode。所以无论 kubelet 同步多少次,容器里看到的永远是启动那一刻的内容。
三、subPath 为什么绕过了同步
官方文档里有一句很短的话:「使用 subPath 挂载的 ConfigMap 不会收到更新」。这句话背后就是上面的机制,但很多人(包括当时的我)不清楚为什么。
用 findmnt 能直观看到差别:
# subPath 方式:单个文件被 bind mount 住
kubectl -n prod exec deploy/gateway -- findmnt -T /app/config/application.yaml
# TARGET SOURCE FSTYPE OPTIONS
# /app/config/application.yaml /dev/sdb ext4 rw,relatime
# 整个目录挂载方式:挂的是 tmpfs,跟随 ..data 链接
kubectl -n prod exec deploy/gateway -- findmnt -T /app/config
# TARGET SOURCE FSTYPE OPTIONS
# /app/config tmpfs tmpfs rw,relatime
第一种挂的是磁盘上的实际文件(kubelet 在宿主机上准备的那份),第二种挂的是 tmpfs 目录。前者和 ConfigMap 的同步逻辑完全脱钩。
那为什么当初要用 subPath?因为我们把整个 ConfigMap 挂到 /app/config/ 会把镜像里自带的 logback.xml 覆盖掉——ConfigMap 里只有 application.yaml 一个文件,目录挂载会把目标目录变成「只有 ConfigMap 里那些文件」,镜像里的其他配置全部消失。subPath 能只覆盖一个文件,保留目录里的其他内容。
这是一个真实的取舍:subPath 解决了「部分覆盖」,代价是失去热更新。
四、三种挂载方式的取舍
| 挂载方式 | 热更新 | 目录里其他文件 | 适用场景 |
|---|---|---|---|
整目录挂载 mountPath: /app/config |
支持 | 会被清空,只剩 ConfigMap 里的 | 配置目录完全由 ConfigMap 管理 |
单文件 subPath |
不支持 | 保留 | 只覆盖个别文件,且接受重启生效 |
subPathExpr + 环境变量 |
不支持 | 保留 | 按 Pod 名/命名空间区分配置 |
| 挂到独立目录 + 应用侧指定路径 | 支持 | 互不影响 | 推荐:-Dspring.config.additional-location |
第三种 subPathExpr 值得单独说,因为它同样不支持热更新,但很多人以为它和 subPath 不一样。它只是把 subPath 的路径做成了可变量(比如按 Pod 名取不同文件),更新语义是一样的。
我们最终选了第四种:把 ConfigMap 挂到一个独立目录 /etc/app-config/,应用启动时通过 spring.config.additional-location 把这个目录加进配置搜索路径。这样既不覆盖镜像里的文件,又保留了热更新能力。
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
spec:
replicas: 3
template:
spec:
containers:
- name: gateway
image: registry.example.com/gateway:1.8.2
args:
- "-Dspring.config.additional-location=file:/etc/app-config/"
volumeMounts:
# 整目录挂载到独立路径,不碰镜像里的 /app/config
- name: config
mountPath: /etc/app-config
readOnly: true
volumes:
- name: config
configMap:
name: gateway-config
items:
- key: application.yaml
path: application.yaml
注意这里没有 subPath,mountPath 是一个独立目录。这样 kubelet 的同步机制正常工作,..data 链接会被替换。
五、应用到底读不读新文件
挂载改对了,还有第二个问题:应用读不读。
Spring Boot 在启动时把配置文件读进 Environment,之后不会再去看文件。所以就算文件热更新了,application.yaml 里的 rate-limit 也不会自动生效——这一点经常被混淆:文件更新 ≠ 配置生效。
真正能热更新的只有两类:用了 @ConfigurationProperties + @RefreshScope(Spring Cloud 那套),或者应用自己监听文件变化重新加载。
我们的网关不是 Spring Cloud,也没有引入配置中心。所以对 application.yaml 这类启动期配置,实际的做法是「改 ConfigMap + 滚动重启」:
kubectl -n prod edit cm gateway-config
kubectl -n prod rollout restart deploy/gateway
kubectl -n prod rollout status deploy/gateway --timeout=180s
rollout restart 会给 Pod 模板加一个 kubectl.kubernetes.io/restartedAt 注解,触发滚动更新。这样新 Pod 启动时读到的是新 ConfigMap,旧 Pod 逐渐退出。
既然是「改完要重启」,那 subPath 的问题其实就不存在了——反正都要重启,重启后 bind mount 会重新建立,读到的就是新内容。我们当初把它当成 bug,一半原因是我以为它会热更新。
但这不是说 subPath 没问题。它有两个真实的坑:一是「以为热更新了其实没有」,排查时浪费大量时间;二是 subPath 挂载的 ConfigMap 在 Pod 重建之前不会反映任何变化,包括删除。如果 ConfigMap 被删了,subPath 挂载的 Pod 还能继续跑(inode 还在),但新 Pod 起不来,这种「半死不活」的状态很难发现。
六、分开处理:配置与证书
这套东西跑顺之后,我把「需要热更新的」和「需要重启的」彻底分开了,因为它们的更新节奏完全不同。
application.yaml 这类业务配置,改完走滚动重启。滚动重启 30 秒,业务能接受,而且重启本身是「配置生效」的明确信号。
证书、密钥这类不能重启的(重启会断连接),单独用 subPath 挂载,配合应用自己的文件监听重新加载。比如 TLS 证书,Nginx 侧用 nginx -s reload,Java 侧用 SSLContext 重建。
volumeMounts:
- name: tls
mountPath: /etc/tls/tls.crt
subPath: tls.crt # 证书必须固定路径,且不能靠 kubelet 换链接
readOnly: true
- name: tls
mountPath: /etc/tls/tls.key
subPath: tls.key
readOnly: true
volumes:
- name: tls
secret:
secretName: gateway-tls
证书用 subPath 是刻意的:应用里有文件监听,检测到文件内容变化就 reload;而 subPath 保证了这个路径始终是同一个文件(虽然内容不会自动变),避免「链接被替换导致监听失效」这种更麻烦的问题。证书更新时由运维执行 kubectl rollout restart 或者触发应用 reload 接口。
这听起来绕,但比「所有配置混在一起、指望全都热更新」清晰得多。分类的原则是:能不能接受重启。能接受就走整目录挂载 + 重启;不能接受就单独设计一条更新路径,别指望 kubelet 帮你。
七、防复发
三件事。
一是把「配置是否热更新」写进部署文档,每个 ConfigMap 都要标一行:热更新 / 需重启。我们现在的 deploy/README.md 里有一张表,列了每个 ConfigMap 的挂载方式和生效方式,新人不用再猜。
二是加一条校验:Deployment 里出现 subPath 且 volume 类型是 configMap 时,在 CI 里打一条 warning。不阻断,但会提醒评审的人「这个配置不会热更新,确认一下是不是期望的」。
三是给「配置漂移」加监控。每周跑一次脚本,对比 ConfigMap 的 resourceVersion 和各 Pod 实际读到的文件内容哈希,不一致就记一条。这条检查抓出过一次真实问题:某个 Pod 因为节点故障被重建失败,卡在旧版本两天,配置一直是老的,而监控只看接口成功率,完全没发现。
# 对比 ConfigMap 内容哈希与 Pod 内文件哈希
cm_hash=$(kubectl -n prod get cm gateway-config -o jsonpath='{.data.application\.yaml}' | sha256sum | cut -d' ' -f1)
for p in $(kubectl -n prod get pod -l app=gateway -o name); do
pod_hash=$(kubectl -n prod exec "$p" -- sha256sum /etc/app-config/application.yaml | cut -d' ' -f1)
[ "$cm_hash" = "$pod_hash" ] || echo "DRIFT $p"
done
小结
subPath挂载的是启动时的那个 inode,kubelet 换..data符号链接的方式对它无效,所以永远不会热更新。- ConfigMap 的更新机制是「新建带时间戳的目录 + 原子替换
..data链接」,理解这一点就能推出所有挂载方式的更新语义。 - 文件更新不等于配置生效,Spring Boot 启动后不再读配置文件,除非常用了
@RefreshScope或配置中心。 - 推荐整目录挂到独立路径(如
/etc/app-config)配合spring.config.additional-location,既保留热更新又不覆盖镜像内文件。 - 按「能否接受重启」分类:业务配置走滚动重启,证书密钥单独设计 reload 路径。
- 加一条 ConfigMap 与 Pod 内文件的哈希对比检查,能发现「Pod 卡在旧配置」这种监控看不到的漂移。
正在加载评论…