天行健,君子以自强不息;地势坤,君子以厚德载物;

Kubernetes--Pod 中的 ConfigMap 配置更新

业务场景里经常会碰到配置更新的问题,在 "GitOps"模式下,Kubernetes 的 ConfigMap 或 Secret 是非常好的配置管理机制。但是,Kubernetes 到目前为止(1.13版本)还没有提供完善的 ConfigMap 管理机制,当我们更新 ConfigMap 或 Secret 时,引用了这些对象的 Deployment 或 StatefulSet 并不会发生滚动更新。因此,我们需要自己想办法解决配置更新问题,让整个流程完全自动化起来。

这篇文章中的所有知识对 Secret 对象也是通用的,为了简明,下文只称 ConfigMap

概述

首先,我们先给定一个背景,假设我们定义了如下的 ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: test-config
data:
  config.yml: |-
    start-message: 'Hello, World!'
    log-level: INFO
  bootstrap.yml:
    listen-address: '127.0.0.1:8080'

这个 ConfigMap 的 data 字段中声明了两个配置文件,config.yml 和 bootstrap.yml,各自有一些内容。当我们要引用里面的配置信息时,Kubernetes 提供了两种方式:

  • 使用 configMapKeyRef 引用 ConfigMap 中某个文件的内容作为 Pod 中容器的环境变量;
  • 将所有 ConfigMap 中的文件写到一个临时目录中,将临时目录作为 volume 挂载到容器里,也就是 configmap 类型的 volume;

好了,假设我们有一个 Deployment,它的 Pod 模板中以引用了这个 ConfigMap。现在的问题是,我们希望当 ConfigMap 更新时,这个 Deployment 的业务逻辑也能随之更新,有哪些方案?

  • 最好是在当 ConfigMap 发生变更时,直接进行热更新,从而做到不影响 Pod 的正常运行
  • 假如无法热更新或热更新完成不了需求,就需要触发对应的 Deployment 做一次滚动更新

接下来,我们就探究一下不同场景下的几种应对方案

场景一:针对可以做热更新的容器,进行配置热更新

 ConfigMap 作为 volume 进行挂载时,它的内容是会更新的。为了更好地理解何时可以做热更新,我们要先简单分析 ConfigMap volume 的更新机制:

更新操作由 kubelet 的 Pod 同步循环触发。每次进行 Pod 同步时(默认每 10 秒一次),Kubelet 都会将 Pod 的所有 ConfigMap volume 标记为"需要重新挂载(RequireRemount)",而 kubelet 中的 volume 控制循环会发现这些需要重新挂载的 volume,去执行一次挂载操作。

 ConfigMap 的重新挂载过程中,kubelet 会先比较远端的 ConfigMap 与 volume 中的 ConfigMap 是否一致,再做更新。要注意,"拿远端的 ConfigMap" 这个操作可能是有缓存的,因此拿到的并不一定是最新版本。

由此,我们可以知道,ConfigMap 作为 volume 确实是会自动更新的,但是它的更新存在延时,最多的可能延迟时间是:

Pod 同步间隔(默认10秒) + ConfigMap 本地缓存的 TTL

kubelet 上 ConfigMap 的获取是否带缓存由配置中的 ConfigMapAndSecretChangeDetectionStrategy 决定

注意,假如使用了 subPath 将 ConfigMap 中的某个文件单独挂载到其它目录下,那这个文件是无法热更新的(这是 ConfigMap 的挂载逻辑决定的)

有了这个底,我们就明确了:

  • 假如应用对配置热更新有实时性要求,那么就需要在业务逻辑里自己到 ApiServer 上去 watch 对应的 ConfigMap 来做更新。或者,干脆不要用 ConfigMap,换成 etcd 这样的一致性 kv 存储来管理配置;
  • 假如没有实时性要求,那我们其实可以依赖 ConfigMap 本身的更新逻辑来完成配置热更新;

当然,配置文件更新完不代表业务逻辑就更新了,我们还需要通知应用重新读取配置进行业务逻辑上的更新。比如对于 Nginx,就需要发送一个 SIGHUP 信号量。这里有几种落地的办法。

热更新一:应用本身监听本地配置文件

假如是我们自己写的应用,我们完成可以在应用代码里去监听本地文件的变化,在文件变化时触发一次配置热更新。甚至有一些配置相关的第三方库本身就包装了这样的逻辑,比如说 viper

热更新二:使用 sidecar 来监听本地配置文件变更

Prometheus 的 Helm Chart 中使用的就是这种方式。这里有一个很实用的镜像叫做 configmap-reload,它会去 watch 本地文件的变更,并在发生变更时通过 HTTP 调用通知应用进行热更新。

但这种方式存在一个问题:Sidecar 发送信号(Signal)的限制比较多,而很多开源组件比如 Fluentd,Nginx 都是依赖 SIGHUP 信号来进行热更新的。主要的限制在于,kubernetes 1.10 之前,并不支持 pod 中的容器共享同一个 pid namespace,因此 sidecar 也就无法向业务容器发送信号了。而在 1.10 之后,虽然支持了 pid 共享,但在共享之后 pid namespace 中的 1 号进程会变成基础的 /pause 进程,我们也就无法轻松定位到目标进行的 pid 了。

当然了,只要是 k8s 版本在 1.10 及以上并且开启了 ShareProcessNamespace 特性,我们多写点代码,通过进程名去找 pid,总是能完成需求的。但是 1.10 之前就是完全没可能用 sidecar 来做这样的事情了。

热更新三:胖容器

既然 sidecar 限制重重,那我们只能回归有点"反模式"的胖容器了。还是和 sidecar 一样的思路,但这次我们通过把主进程和sidecar 进程打在同一个镜像里,这样就直接绕过了 pid namespace 隔离的问题。当然,假如允许的话,还是用上面的一号或二号方案更好,毕竟容器本身的优势就是轻量可预测,而复杂则是脆弱之源。

场景二:无法热更新时,滚动更新 Pod

无法热更新的场景有很多:

  • 应用本身没有实现热更新逻辑,而一般来说自己写的大部分应用都不会特意去设计这个逻辑;
  • 使用 subPath 进行 ConfigMap 的挂载,导致 ConfigMap 无法自动更新;
  • 在环境变量或 init-container 中依赖了 ConfigMap 的内容;

最后一点额外解释一下,当使用 configMapKeyRef 引用 ConfigMap 中的信息作为环境变量时,这个操作只会在 Pod 创建时执行一次,因此不会自动更新。而 init-container 也只会运行一次,因此假如 init-contianer 的逻辑依赖了 ConfigMap 的话,这个逻辑肯定也不可能按新的再来一遍了。

当碰到无法热更新的时候,我们就必须去滚动更新 Pod 了。相信你一定想到了,那我们写一个 controller 去 watch ConfigMap 的变更,watch 到之后就去给 Deployment 或其它资源做一次滚动更新不就可以了吗?没错,但就我个人而言,我更喜欢依赖简单的东西,因此我们还是从简单的方案讲起。

Pod 滚动更新一:修改 CI 流程

这种办法异常简单,只需要我们写一个简单的 CI 脚本:给 ConfigMap 算一个 Hash 值,然后作为一个环境变量或 Annotation 加入到 Deployment 的 Pod 模板当中。

举个例子,我们写这样的一个 Deployment yaml 然后在 CI 脚本中,计算 Hash 值替换进去:

...
spec:
  template:
    metadata:
      annotations:
        com.aylei.configmap/hash: ${CONFIGMAP_HASH}
...

这时,假如 ConfigMap 变化了,那 Deployment 中的 Pod 模板自然也会发生变化,k8s 自己就会帮助我们做滚动更新了。另外,如何 ConfigMap 不大,直接把 ConfigMap 转化为 JSON 放到 Pod 模板中都可以,这样做还有一个额外的好处,那就是在排查故障时,我们一眼就能看到这个 Pod 现在关联的 ConfigMap 内容是什么。

Pod 滚动更新二:Controller

还有一个办法就是写一个 Controller 来监听 ConfigMap 变更并触发滚动更新。在自己动手写之前,推荐先看看一下社区的这些 Controller 能否能满足需求:

Pod 滚动更新三:patch

更新 ConfigMap 目前并不会触发相关 Pod 的滚动更新,可以通过修改 pod annotations 的方式强制触发滚动更新。

$ kubectl patch deployment my-nginx --patch '{"spec": {"template": {"metadata": {"annotations": {"version/config": "20180411" }}}}}'

这个例子里我们在 .spec.template.metadata.annotations 中添加 version/config,每次通过修改 version/config 来触发滚动更新。

Pod 滚动更新四:Liveness Probe / Readiness Probe

这个手段需要深入一下,初步想法是用liveness调用一个脚本,脚本判断文件是否变动,如果变动,liveness得到false,重启pod,也可以同时设置readiness。

滚动更新需要考虑的问题

举个例子,我们用场景二中提到的方式去更新:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 3
  template:
    annotations:
      nginx-config-md5: d41d8cd98f00b204e9800998ecf8427e
    spec:
      containers:
      - name: nginx
        image: nginx
        volumeMounts:
        - name: nginx-config
          mountPath: /etc/config
      volumes:
      - name: config-volume
        configMap: 
          name: nginx-config
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
data:
  nginx.conf: |-
        ## some configurations...
  • 每次部署的时候,计算configMap的MD5,填入pod的template中.
  • 加入configMap发生变化,摘要也会变化,会触发一次Deployment的滚动更新。

这个流程看起来比较美,但思考一下如果我们更新了一个配置,但这个配置是有问题的,如果pod使用了错误的配置会无法工作(比如无法通过readinessProb检查)。最后,滚动更新的流程就会卡住,错误的配置不会把Deployment搞崩掉。

这个逻辑看着也挺好,但是有个问题却忽视了,如果nginx-config更新成了错误的值,虽然还没有重建的Pod暂时是健康的,但是如果Pod挂掉发生重建,或者其中的容器重新读取了一次配置,那么这些Pod就会陷入异常。所以整个集群的状态是很不稳定的。

因此问题的本质是:在原地更新configMap或者secret的时候,我们并没有进行滚动发布,而是一次性把新的配置更新到整个集群的所有实例当中。而我们所说的滚动更新就是控制各个实例读取新的配置的时机,可是由于我们无法把控Pod挂掉的时机,我们无法准确进行过程控制。

解决方案

上述问题的问题在于原地更新,要解决这个问题,只需要在每次ConfigMap变化的时候,重新生成一个ConfigMap,再更新Deployment使用这个新的ConfigMap就行了。而重新生成ConfigMap最简单的方式就是在其命名中加上ConfigMap的data值计算出的摘要,比如:

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config-d41d8cd98f00b204e9800998ecf8427e
data:
  nginx.conf: |-
        ## some configurations...

ConfigMap的Rollout在社区中也是历经很久还没有解决(#22368),目前为止,解决这个问题的方向也是immutable configmap模式。

但是这种方案会有几个问题:

  • 如何做到每次配置文件更新时,都创建一个新的ConfigMap?
  • 目前社区的态度是把这一步放到Client解决,比如helm和kustomize。
  • 历史configMap不断积累,能怎么回收?
  • 针对这点,社区希望在服务端实现一个GC机制来清理没有任何资源引用的configMap。

把更新逻辑放在client端虽然会有重复造轮子的问题,但是至少目前为止,configMap的新建和Deployment等对象的更新是最成熟的configMap滚动更新方案。

Kustomize的实践方式

Kustomize对这个方案有内置的支持,只需要使用configGenerator

configMapGenerator:
- name: my-configmap
  files:
  - common.properties

这段yaml就能在kustomize中生成一个configMap对象,这个对象的data来自于common.properties文件,而且name中会加上这个文件的SHA值作为后缀。

在kustomize的其他layer中,只要以my-configmap作为name引用这个configMap即可,当最终渲染的时候,kustomize会自动进行替换操作。

Helm的实践方式

附录

facilitate ConfigMap rollouts/management discussion

结尾

上面就是我针对 ConfigMap  Secret 热更新总结的一些方案。最后我们选择的是使用 sidecar 进行热更新,因为这种方式更新配置带来的开销最小,我们也为此主动避免掉了"热更新环境变量这种场景"。

当然了,配置热更新也完全可以不依赖 ConfigMap,Etcd + Confd, 阿里的 Nacos, 携程的 Apollo 包括不那么好用的 Spring-Cloud-Config 都是可选的办法。但它们各自也都有需要考虑的东西,比如 Etcd + Confd 就要考虑 Etcd 里的配置项变更怎么管理;Nacos, Apollo 这种则需要自己在 client 端进行代码集成。相比之下,对于刚起步的架构,用 k8s 本身的 ConfigMap  Secret 可以算是一种最快最通用的选择了。

转自:https://zhuanlan.zhihu.com/p/57570231

转自:https://cctoctofx.netlify.app/post/cloud-computing/k8s-config-update/

 

点赞

发表回复