一、Kubernetes控制器介绍
Kubernetes 的核心就是控制理论,Kubernetes控制器中实现的控制回路是一种闭环反馈控制系统,该类型的控制系统基于反馈回路将目标系统的当前状态与预定义的期望状态相比较,二者之间的差异作为误差信号产生一个控制输出作为控制器的输入,以减少或消除目标系统当前状态与期望状态的误差,通过实时运行相应的程序代码尝试让对象的真实状态向期望状态逐渐逼近。如下图所示。这种控制循环在Kubernetes上也称为调谐循环(reconciliation loop)。
对Kubernetes来说,无论控制器的具体实现有多么简单或多么复杂,它基本都是通过定期重复执行如下3个步骤来完成控制任务。
(1)从 API Server 读取资源对象的期望状态和当前状态。
(2)比较二者的差异,而后运行控制器中的必要代码操作现实中的资源对象,将资源对象的真实状态修正为Spec中定义的期望状态,例如创建或删除 Pod 对象,以及发起一个云服务 API 请求等。
(3)变动操作执行成功后,将结果状态存储在API Server上的目标资源对象的status字段中。
控制器的作用包括管理Pod对象、使用标签关联Pod、对Pod进行滚动更新、扩/缩容等。
基于简化管理的目的,Kubernetes将数十种内置的控制器打包运行于controller-manager程序,这些都是基础型、核心型控制器,比如Service Controller、Deployment Controller。
还有一些第三方应用的专用控制器,比如Ingress插件的ingress-nginx的Controller,网络插件Calico的Controller。这些控制器又称为高级控制器,通常需要借助于基础型控制器完成其功能。它们往往以Pod形式运行于Kubernetes集群之上,且极有可能被内置的控制器所控制。
而以编排Pod应用为核心的控制器,被称为工作负载型控制器。应用可分为无状态应用、有状态应用、守护型应用、批处理应用。针对这些应用,工作负载型控制器也有相应的分类去管理这些应用。
- 无状态应用编排:RepliSet、Deployment
- 有状态应用编排:StatefulSet、第三方专用的Operator
- 系统级(守护型)应用:DeamonSet
- 作业型(批处理)应用:Job和CronJob
补充:不同类型应用的说明
- 无状态应用:应用实例不涉及事务交互,不产生持久化数据存储在本地,并且多个应用实例对于同一个请求响应的结果是完全一致的。举例:nginx或者tomcat
- 有状态应用:有状态服务可以说是需要数据存储功能的服务或者指多线程类型的服务、队列等。通常为分布式应用,会部署多个实例,实例之间会有依赖关系(主从)。举例:Mysql主从、Kafka、Redis Cluster、Zookeeper等。
- 守护型应用:类似守护进程一样,长期保持运行,监听持续的提供服务。举例:ceph、logstash、fluentd等。
- 批处理应用:工作任务型的服务,通常是一次性的。举例:运行一个批量改文件夹名字的脚本。
几乎所有的工作负载型控制器都是通过持续性地监控Kubernetes集群中运行着的Pod资源对象以确保受管控的资源严格符合用户期望的状态,比如副本数量要精准符合期望等。通常,一个工作负载型控制器应包含3个基本组成部分(配置字段)。
- selector:标签选择器,匹配并关联Pod对象,据此完成受它管控的Pod对象的计数。
- replicas: 期望的Pod副本数量,在集群中准确运行相关Pod的数量,使之与期望值相符。多退少补。
- template:Pod模板,用于新建Pod对象使用的模板资源。
注意:DaemonSet控制器用于确保集群中每个Node节点只运行一个Pod,而非某个预设的指定数量,因此无需配置replicas字段。
二、Deployment控制器
Deployment(简写为deploy)是建立在ReplicaSet控制器之上的更高级的控制器,借助ReplicaSet完成无状态应用的基本编排任务,基于ReplicaSet提供了滚动更新、回滚等更加强大的功能。
Deployment、ReplicaSet、Pod的关系见下图。
2.1 Deployment的配置说明
Deployment的spec字段嵌套使用的字段包含了RepliSet控制器支持的所有字段,Deployment基于这些信息完成其二级资源ReplicaSet对象的创建。此外,Deployment还支持几个专用于定义部署及相关策略的字段,具体介绍如下。
apiVersion: apps/v1 #API群组及版本
kind:Deployment #资源类型特有标识
metadata:
name <string> #资源名称,在作用域中唯一
namespace <string> #名称空间;Deployment隶属名称空间级别
spec:
minReadySeconds <integer> #Pod就绪多少秒内任一容器无崩溃方可视为”就绪”
replicas <integer> #期望的Pod副本数,默认为1
selector <object> #标签选择器,必须匹配template字段中Pod模板的标签
template <object> #Pod模板对象
revisionHistoryLimit <integer> #滚动更新历史记录数量,默认为10
strategy <Object> #滚动更新策略
type <string> #滚动更新类型,可用值为Recreate和Rollingupdate
rollingUpdate <Object> #滚动更新参数,专用于RollingUpdate类型
maxSurge <string> #更新期间可比期望的Pod多出的数量或比例。比例默认25%
maxUnavailable <string> #更新期间可比期望的Pod缺少的数量或比例,比例默认25%,默认值为1
progressDeadlineSeconds <integer> #滚动更新故障超时时长,默认600秒
paused <boolean> #是否暂停部署过程
2.2 Deployment 更新策略
如果是RS(ReplicaSet)滚动更新,会是什么样的情形?
RS的应用更新在不改变已有资源(简称为rs-old)的定义下,新创建一个有着新版本Pod模板的新RS资源(简称为rs-new)实现,新旧版本的RS使用不同的标签选择器,其中有一个标签会匹配到不同的值。Rs-new的初始副本数为0。在更新过程中,以指定的分批次策略逐步增加rs-new的副本数,并同步降低rs-old的副本数,直到rs-new副本数满足期望值,rs-old副本数为0时更新过程结束。同时我们还能保留最近一个副本数为0的旧版本的RS资源于更新历史中,便于回滚用。但这些操作步骤以手工方式完成,过于繁琐。
还有一种方式是蓝绿部署,即在rs-old版本的资源运行的同时直接创建一个新的rs-new版本的RS对象(即准备两组资源,但对服务器资源要求高),待所有的新Pod就绪后一次性地将客户端流量全部迁移到rs-new之上。这样可以避免重建式更新中服务长时间中断的情况。那么,假设这么一个情景:一个RS对象控制了若干Pod,有个Service基于标签选择器把流量调度给这些Pod。此时如果直接更新原有的RS,则导致RS对象原来控制的Pod同时不可用(被删除),后面要创建新版本的Pod,而在新版本的Pod就绪之前Service的流量无法调度过来(即此时Service无可用后端端点),这就导致服务中断。此外,为了避免新旧版本的RS资源共存时Service将流量发往不同版本的Pod对象(避免可能导致的新旧版本的兼容性问题),需要指定Service使用的标签选择器只能匹配其中一个版本的Pod对象。只不过,这么做也要手工创建新RS,手工修改Service的定义,仍然需要人工介入。
基于以上介绍,可以看出RepliSet控制器的应用更新需要手动分成多步以特定的次序运行,过程繁杂且易出错。而Deployment只需用户指定在Pod模板中要改动的内容(比如容器的镜像文件的版本),其它步骤由Deployment控制器自动完成。
Deployment支持滚动更新(rolling update)和重新创建(recreate)两种策略,默认使用滚动更新。Deployment控制器的滚动更新操作并非在同一个RS控制器下删除并新建Pod资源,而是将其分别置于两个不同的控制器之下,当前的RS对象的Pod副本数不断减少的同时,新的RS对象的Pod数量不断增加,直到当前RS对象的Pod副本数为0,新的RS的Pod副本数满足期望值为止。
滚动更新的优势是升级期间,容器中应用提供的服务不会中断,但要求应用程序能够应对新旧版本同时工作(兼容)的情形,例如新旧版本兼容同一个数据库方案等。不过,更新操作期间,不同客户端得到的响应内容可能会来自不同版本的应用。当应用的新旧版本不兼容时,建议采用蓝绿部署的方式。因为重建式更新策略会导致应用在更新期间不可用。
滚动更新的弊端在于它是以Pod数量为单位切割流量比例,不能精确控制流量路由比例(即新版本的Pod流量占x%,旧版本的Pod流量占100-x%,这个Service做不了)。
关于滚动更新,Deployment提供了两个字段,分别用于定义滚动更新期间的Pod总数可向上或向下偏离期望值的幅度。
- spec.strategy.rollingUpdate.maxSurge:指定升级期间存在的总Pod对象数量最多可超出期望值的个数,其值可以是0或正整数,也可以是一个期望值的百分比;例如,如果期望值为3,当前的属性值为1,则表示Pod对象的总数不能超过4个.
- spec.strategy.rollingUpdate.maxUnavailable:升级期间正常可用的Pod副本数(包括新旧版本)最多不能低于期望数值的个数,其值可以是0或正整数,也可以是一个期望值的百分比;默认值为1,该值意味着如果期望值是4,则升级期间至少要有3个Pod对象处于正常提供服务的状态。
通过组织maxSurge和maxUnavailable两个属性协调工作,可组合定义出3种不同的策略完成多批次的应用更新。
- 先增新,后减旧:将maxSurge设定为≤期望值的正整数或相对于期望值的一个百分比,而maxUnavailable的值设为0。
- 先减旧,后增新:将maxUnavailable设定为≤期望值的正整数或相对于期望值的一个百分比,而maxSurge的值设为0。
- 同时增减(少减多增):将maxSurge和maxUnavailable字段的值同时设定为≤期望值的正整数或相对于期望值的一个百分比,二者可使用不同值。
2.3 应用发布与回滚
Pod模板内容的变动是触发Deployment执行更新操作的必要条件。对于声明式配置的Deployment而言,Pod模板的修改适合用apply和patch命令执行。如果只是修改容器镜像,set image命令更适用。同时为了保存升级历史,可以在创建Deployment对象时在命令中使用--record选项。
2.4 金丝雀发布
Deployment资源允许用户控制更新过程中的滚动节奏,比如”暂停”或”继续”更新操作。可以在第一批新的Pod资源创建完成后立即暂停更新过程,此时,仅有一小部分新的Pod存在。之后当访问到新Pod时,观察其是否能稳定地按期望方式运行。确认没有问题后完成余下所有Pod的滚动更新,否则就立即回滚至第一步更新操作。这就是金丝雀部署。
暂停Deployment资源的更新过程,需要将其spec.pause字段的值从false修改为true,这可通过修改资源规范后再次apply完成,也可通过kubectl rollout pause命令进行。
运行一段时间后,若确认新版本没有必须通过回滚才能解决的问题,即可使用kubectl rollout resume命令继续后续更新步骤,以完成滚动更新过程。
以下结合具体的实战去分析。
三、实战:Kubernetes部署Nginx
3.1 部署资源对象&更新资源对象
配置Nginx的测试文件,输出Nginx的版本号,为创建ConfigMap使用
mkdir exercise && cd exercise
vim nginx_test.conf
server {
listen 80;
location / {
default_type text/plain;
return 200 'ver: $nginx_version\nsrv: $server_addr:$server_port\nhost: $hostname\n';
}
}
kubectl create namespace exercise
kubectl create configmap nginx-config --from-file=nginx_test.conf -n exercise
为Nginx设置NFS动态存储(CSI-NFS-Driver)
#部署nfs服务
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/deploy/example/nfs-provisioner/nfs-server.yaml --namespace exercise
#安装nfs driver
git clone https://github.com/iKubernetes/learning-k8s.git
cd learning-k8s/csi-driver-nfs/deploy/03-csi-driver-nfs-4.2/
kubectl apply -f .
创建StorageClass资源
cd ~/exercise
vim storageclass-nginx.yml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: storageclass-nginx
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: nfs.csi.k8s.io
parameters:
#server: nfs-server.default.svc.cluster.local
server: nfs-server.exercise.svc.cluster.local
share: /
reclaimPolicy: Retain
volumeBindingMode: Immediate
mountOptions:
- hard
- nfsvers=4.2
kubectl apply -f storageclass-nginx.yml
创建PVC,去使用刚才创建的storageclass资源。对应的PV可自动创建
vim dynamic-pvc-nginx.yml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nginx-dynamic-pvc
namespace: exercise
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi
storageClassName: storageclass-nginx
kubectl apply -f dynamic-pvc-nginx.yml
在Deployment的设置中,指定镜像版本号为1.24-alpine,实例数设置为4个。同时使用刚才创建的PVC资源和ConfigMap资源。
#这里为了便于区分,在Deployment资源的命名时添加Nginx镜像的版本号
vim nginx_deployment_1.24.yml
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: nginx-deployment
name: nginx-deployment
namespace: exercise
spec:
replicas: 4
selector:
matchLabels:
app: nginx-deployment
strategy: {}
template:
metadata:
labels:
app: nginx-deployment
spec:
volumes:
#引用之前创建的ConfigMap对象
- name: nginx-conf
configMap:
name: nginx-config
#nginx引用此动态PVC可用来存储业务数据
- name: nginx-dynamic-pvc
persistentVolumeClaim:
claimName: nginx-dynamic-pvc
containers:
- image: nginx:1.24-alpine
name: nginx
ports:
- containerPort: 80
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/
- name: nginx-dynamic-pvc
mountPath: /data/nginx
kubectl apply -f nginx_deployment_1.24.yml
为nginx创建Service对象,利用NodePort类型进行转发
vim nginx_svc.yml
apiVersion: v1
kind: Service
metadata:
labels:
app: nginx-deployment
name: nginx-svc
namespace: exercise
spec:
type: NodePort
selector:
app: nginx-deployment
ports:
- name: http
port: 80
protocol: TCP
targetPort: 80
kubectl apply -f nginx_svc.yml
执行curl NodeIP:NodePort
从 curl 命令的输出中可以看到,现在应用的版本是 1.24.0。
再次编写一个新版本的deployment对象nginx_deployment_1.25.yml,将里面的镜像版本升级为1.25-alpine。为了便于观察应用更新的过程,可以添加一个字段minReadySeconds,让 Kubernetes 在更新过程中等待一点时间,确认 Pod 没问题才继续其余 Pod 的创建工作。
vim nginx_deployment_1.25.yml
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: nginx-deployment
name: nginx-deployment
namespace: exercise
spec:
replicas: 4
selector:
matchLabels:
app: nginx-deployment
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: nginx-deployment
spec:
#确认Pod就绪的等待时间
minReadySeconds: 15
volumes:
#引用之前创建的ConfigMap对象
- name: nginx-conf
configMap:
name: nginx-config
#nginx引用此动态PVC可用来存储业务数据
- name: nginx-dynamic-pvc
persistentVolumeClaim:
claimName: nginx-dynamic-pvc
containers:
- image: nginx:1.25-alpine
name: nginx
ports:
- containerPort: 80
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/
- name: nginx-dynamic-pvc
mountPath: /data/nginx
kubectl apply -f nginx_deployment_1.25.yml
更新应用之后,由于改变了镜像,Pod模板变了,就会触发版本更新。Pod版本已替换为”74cc…”。可以使用kubectl rollout status查看应用更新的状态。用curl再次访问nginx,发现应用版本变成了1.25.1。
kubectl rollout status deploy nginx-deployment -n exercise
仔细查看kubectl rollout status的输出信息,发现Kubernetes并非一次性销毁全部旧Pod后再一次性创建新Pod,而是在逐个销毁旧Pod的同时逐个创建新Pod,保证系统内有足够数量的Pod在运行,不会有空窗期中断服务。
通过kubectl describe命令可以更清楚的看到Pod的变化情况。
kubectl describe deploy nginx-deployment -n exercise
分析如下:
1st. 一开始,V1版本(1.24.0)的Pod(nginx-deployment-7796d68bc)数量为4。
2nd. 滚动更新开始后,Kubernets创建一个V2版本(1.25.1)的Pod(nginx-deployment-74cc86c7b),并将V1版本的Pod数量减为3。
3rd. 接着再增加V2版本的Pod数量为2,同时V1版本的Pod数量减为2。
4th. 重复类似的步骤,到最后V2版本的Pod数量变为4,V1版本的Pod数量变为0,整个滚动更新过程结束。
其实滚动更新就是由 Deployment 控制的应用进行同步伸缩操作,老版本缩容到 0,同时新版本扩容到指定值,是一个此消彼长的过程。可结合下图做进一步认识。
此外,如果涉及金丝雀发布,可以等到第一批Pod滚动更新完成后先暂停更新,观察当访问新Pod时运行状况是否稳定且符合预期。确认无误后再继续接下来的更新,否则直接回退至原始版本。
#暂停应用nginx-deployment的更新
kubectl rollout pause deploy nginx-deployment -n exercise
#恢复(继续)应用nginx-deployment的更新
kubectl rollout resume deploy nginx-deployment -n exercise
3.2 Kubernetes管理应用更新
如果因各种原因导致滚动更新不能正常进行,比如镜像文件获取失败、新版本Pod触发未知bug等,都要回滚至之前版本。我们此前分别执行了nginx-deployment资源的一次部署和一次更新操作,因此修订记录分别记录了这两次操作,它们各有一个修订标识符,最大标识符为当前使用的版本(见REVISION字段显示的部分)。kubectl命令可以打印deployment资源的修订历史。
kubectl rollout history deploy nginx-deployment -n exercise
但 kubectl rollout history 的列表输出的有用信息太少,可以在命令后加上参数 --revision 来查看每个版本的详细信息,包括标签、镜像名、环境变量、存储卷等等,通过这些就可以大致了解每次都变动了哪些关键字段。
kubectl rollout history deploy nginx-deployment -n exercise --revision 2
某种意义上说,回滚也是一次更新操作,执行回滚操作意味着将当前版本切回前一个版本(即新版本的Pod数量为0,老版本的Pod扩展到指定数量),但历史记录中,其REVISION记录也随之变动,回滚操作被当做一次滚动更新追加到历史记录中,而被回滚的条目会被删除。因此,回滚后修订标识符从1变成3。回滚操作可使用kubectl rollout undo命令完成。此外,此命令还能使用--to-revision选项指定revision号码,这样可回滚至历史记录中的特定版本。
kubectl rollout undo deploy nginx-deployment -n exercise
其实V2->V1的版本降级过程本质上和V1->V2的版本升级过程一样,只不过就是版本号的变化方向不同罢了。也可以借助一张图去理解。
3.3 Kubernetes添加更新描述
kubectl rollout history 命令显示的版本列表信息似乎有些简单,只有一个版本更新序号,另一列CHANGE-CAUSE显示为<none>。要想像git那样,每次更新时都加上说明信息,则在Deployment 的 metadata 里加上一个新的字段 annotations即可。
annotations 字段的含义是注解,形式上和 labels 一样,都是 Key-Value,也都是给 API 对象附加一些额外的信息,但是用途上有区别。
- annotations 添加的信息一般是给 Kubernetes 内部的各种对象使用的,类似于扩展属性;
- labels 主要面对的是 Kubernetes 外部的用户,用来筛选、过滤对象的。
annotations 里的值可以任意写,但要编写更新说明就需要使用特定的字段 kubernetes.io/change-cause。
在相关的nginx_deployment的yml文件添加相关的更新说明。重新应用后再用 kubectl rollout history 来看一下更新历史。
nginx_deployment_1.24.yml
nginx_deployment_1.25.yml
这次显示的列表信息有了更新说明就详细多了,每个版本的主要变动情况列得非常清楚,和 Git 版本管理的感觉很像。
四、浅谈服务上线问题
说到这儿,再说说服务的上线问题。
服务的上线是一个危险的动作,很多事故都是因为上线引起的。之所以危险,原因有很多,比如(配置文件修改错误导致的)测试不到位、服务没有优雅退出、没有做好兼容等。所以对于服务的上线,需要足够的敬畏。
为了减少服务上线带来的事故,采取的措施一般有:
- 封线。比如节假日前后,流量高峰期时一般不允许上线。
- 周知。周知相关服务的负责人,让他们协助在服务上线期间一起观察服务有无异常。
- 分阶段升级。比如第一次只升级其中1个实例,第二次升级3个实例,逐步放量,缓慢上线。
- 做好观察。在服务上线期间,需要盯监控,关注告警,观察失败日志,在出现异常的时候第一时间回滚。
同时,为了保险起见,上线前最好将相关配置文件、软件包等做好备份,以防万一。