改了 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 卡在旧配置」这种监控看不到的漂移。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。