业务场景里经常会碰到配置更新的问题,在 "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/