MySQL集群的流程迁移到 Kubernetes
- Master 节点和 Slave 节点需要有不同的配置文件(即:不同的 my.cnf);
- Master 节点和 Salve 节点需要能够传输备份信息文件;
- 在 Slave 节点第一次启动之前,需要执行一些初始化 SQL 操作;
而由于 MySQL 本身同时拥有拓扑状态(主从节点的区别)和存储状态(MySQL 保存在本地的数据),我们自然要通过 StatefulSet 来解决这“三座大山”的问题。
其中,“第一座大山:Master 节点和 Slave 节点需要有不同的配置文件”,很容易处理:我们只需要给主从节点分别准备两份不同的 MySQL 配置文件,然后根据 Pod 的序号(Index)挂载进去即可。
- 第一个名叫“mysql”的 Service 是一个 Headless Service是通过为 Pod 分配 DNS 记录来固定它的拓扑状态,比如“mysql-0.mysql”和“mysql-1.mysql”这样的 DNS 名字。其中,编号为 0 的节点就是我们的主节点。所有用户的写请求,则必须直接以 DNS 记录的方式访问到 MySQL 的主节点,也就是:“mysql-0.mysql“这条 DNS 记录。
- 第二个名叫“mysql-read”的 Service,则是一个常规的 Service。所有用户的读请求,都必须访问第二个 Service 被自动分配的 DNS 记录,即:“mysql-read”(当然,也可以访问这个 Service 的 VIP)。这样,读请求就可以被转发到任意一个 MySQL 的主节点或者从节点上
第二座大山:Master 节点和 Salve 节点需要能够传输备份文件
首先,我们先为 StatefulSet 对象规划一个大致的框架,如下图所示:
第一步:从 ConfigMap 中,获取 MySQL 的 Pod 对应的配置文件。
第二步:在 Slave Pod 启动前,从 Master 或者其他 Slave Pod 里拷贝数据库数据到自己的目录下。
第三座大山:如何在 Slave 节点的 MySQL 容器第一次启动之前,执行初始化 SQL。
我们可以为这个 MySQL 容器额外定义一个 sidecar 容器,来完成这个操作。在这个名叫 xtrabackup 的 sidecar 容器的启动命令里,其实实现了两部分工作。
第一部分工作,当然是 MySQL 节点的初始化工作。这个初始化需要使用的 SQL,是 sidecar 容器拼装出来、保存在一个名为 change_master_to.sql.in 的文件里的
在完成 MySQL 节点的初始化后,这个 sidecar 容器的第二个工作,则是启动一个数据传输服务。
DaemonSet
daemon pod:
- 这个 Pod 运行在 Kubernetes 集群里的每一个节点(Node)上;
- 每个节点上只有一个这样的 Pod 实例;
- 当有新的节点加入 Kubernetes 集群后,该 Pod 会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的 Pod 也相应地会被回收掉。
意义:
- 各种网络插件的 Agent 组件,都必须运行在每一个节点上,用来处理这个节点上的容器网络;
- 各种存储插件的 Agent 组件,也必须运行在每一个节点上,用来在这个节点上挂载远程存储目录,操作容器的 Volume 目录;
- 各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集。
DaemonSet 又是如何保证每个 Node 上有且只有一个被管理的 Pod 呢?
显然,这是一个典型的“控制器模型”能够处理的问题。
DaemonSet Controller,首先从 Etcd 里获取所有的 Node 列表,然后遍历所有的 Node。这时,它就可以很容易地去检查,当前这个 Node 上是不是有一个携带了 name=fluentd-elasticsearch 标签的 Pod 在运行。而检查的结果,可能有这么三种情况:
没有这种 Pod,那么就意味着要在这个 Node 上创建这样一个 Pod;
有这种 Pod,但是数量大于 1,那就说明要把多余的 Pod 从这个 Node 上删除掉;
正好只有一个这种 Pod,那说明这个节点是正常的。
其中,删除节点(Node)上多余的 Pod 非常简单,直接调用 Kubernetes API 就可以了。但是,如何在指定的 Node 上创建新 Pod 呢?如果你已经熟悉了 Pod API 对象的话,那一定可以立刻说出答案:用 nodeSelector,选择 Node 的名字即可。
我们的 DaemonSet Controller 会在创建 Pod 的时候,自动在这个 Pod 的 API 对象里,加上这样一个 nodeAffinity 定义。DaemonSet会给这个 Pod 自动加上另外一个与调度相关的字段,叫作 tolerations。这个字段意味着这个 Pod,会“容忍”(Toleration)某些 Node 的“污点”(Taint)。
这个 Toleration 的含义是:“容忍”所有被标记为 unschedulable“污点”的 Node;“容忍”的效果是允许调度。
在正常情况下,被标记了 unschedulable“污点”的 Node,是不会有任何 Pod 被调度上去的(effect: NoSchedule)。可是,DaemonSet 自动地给被管理的 Pod 加上了这个特殊的 Toleration,就使得这些 Pod 可以忽略这个限制,继而保证每个节点上都会被调度一个 Pod。当然,如果这个节点有故障的话,这个 Pod 可能会启动失败,而 DaemonSet 则会始终尝试下去,直到 Pod 启动成功。
这时,你应该可以猜到,我在前面介绍到的DaemonSet 的“过人之处”,其实就是依靠 Toleration 实现的。
而通过这样一个 Toleration,调度器在调度这个 Pod 的时候,就会忽略当前节点上的“污点”,从而成功地将网络插件的 Agent 组件调度到这台机器上启动起来。
DaemonSet 其实是一个非常简单的控制器。在它的控制循环中,只需要遍历所有节点,然后根据节点上是否有被管理 Pod 的情况,来决定是否要创建或者删除一个 Pod。
只不过,在创建每个 Pod 的时候,DaemonSet 会自动给这个 Pod 加上一个 nodeAffinity,从而保证这个 Pod 只会在指定节点上启动。同时,它还会自动给这个 Pod 加上一个 Toleration,从而忽略节点的 unschedulable“污点”。
DaemonSet 使用 ControllerRevision,来保存和管理自己对应的“版本”。这种“面向 API 对象”的设计思路,大大简化了控制器本身的逻辑,也正是 Kubernetes 项目“声明式 API”的优势所在。StatefulSet 也是直接控制 Pod 对象的,也在使用 ControllerRevision 进行版本管理。在 Kubernetes 项目里,ControllerRevision 其实是一个通用的版本管理对象。
Job和CronJob
Job 对象在创建后,它的 Pod 模板,被自动加上了一个 controller-uid=< 一个随机字符串 > 这样的 Label。而这个 Job 对象本身,则被自动加上了这个 Label 对应的 Selector,从而 保证了 Job 与它所管理的 Pod 之间的匹配关系。Job Controller 之所以要使用这种携带了 UID 的 Label,就是为了避免不同 Job 对象所管理的 Pod 发生重合。需要注意的是,这种自动生成的 Label 对用户来说并不友好,所以不太适合推广到 Deployment 等长作业编排对象上。
需要在 Pod 模板中定义 restartPolicy=Never
如果这个离线作业失败了要怎么办?比如,我们在这个例子中定义了 restartPolicy=Never,那么离线作业失败后 Job Controller 就会不断地尝试创建一个新 Pod,直到满足重试次数上限。如果你定义的 restartPolicy=OnFailure,那么离线作业失败后,Job Controller 就不会去尝试创建新的 Pod。但是,它会不断地尝试重启 Pod 里的容器。
Job Controller 对并行作业的控制方法
在 Job 对象中,负责并行控制的参数有两个:
- spec.parallelism,它定义的是一个 Job 在任意时间最多可以启动多少个 Pod 同时运行;
- spec.completions,它定义的是 Job 至少要完成的 Pod 数目,即 Job 的最小完成数。
需要创建的 Pod 数目 = 最终需要的 Pod 数目 - 实际在 Running 状态 Pod 数目 - 已经成功退出的 Pod 数目 = 4 - 0 - 0= 4。也就是说,Job Controller 需要创建 4 个 Pod 来纠正这个不一致状态。
可是,我们又定义了这个 Job 的 parallelism=2。也就是说,我们规定了每次并发创建的 Pod 个数不能超过 2 个。所以,Job Controller 会对前面的计算结果做一个修正,修正后的期望创建的 Pod 数目应该是:2 个。
CronJob
CronJob 是一个 Job 对象的控制器(Controller)。CronJob 与 Job 的关系,正如同 Deployment 与 Pod 的关系一样。CronJob 是一个专门用来管理 Job 对象的控制器。只不过,它创建和删除 Job 的依据,是 schedule 字段定义的、一个标准的Unix Cron格式的表达式。比如,"*/1 * * * *"。
由于定时任务的特殊性,很可能某个 Job 还没有执行完,另外一个新 Job 就产生了。这时候,你可以通过 spec.concurrencyPolicy 字段来定义具体的处理策略。比如:
- concurrencyPolicy=Allow,这也是默认情况,这意味着这些 Job 可以同时存在;
- concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;
- concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job。
声明式 API——kubectl apply 命令
如果要滚动更新,原来的做法是先kubectl create -f xxx.yaml。然后再更新yaml文件后,执行kubectl replace 触发。而用apply则可以先kubectl apply -f xxx.yaml。然后更新yaml后再执行一次kubectl apply -f xxx.yaml。kubectl replace 的执行过程,是使用新的 YAML 文件中的 API 对象,替换原有的 API 对象;而 kubectl apply,则是执行了一个对原有 API 对象的 PATCH 操作。类似地,kubectl set image 和 kubectl edit 也是对已有 API 对象的修改。
这意味着 kube-apiserver 在响应命令式请求(比如,kubectl replace)的时候,一次只能处理一个写请求,否则会有产生冲突的可能。而对于声明式请求(比如,kubectl apply),一次能处理多个写操作,并且具备 Merge 能力。
Istio 项目,实际上就是一个基于 Kubernetes 项目的微服务治理框架。它的架构非常清晰,如下所示:
Istio 最根本的组件,是运行在每一个应用 Pod 里的 Envoy 容器。
这个 Envoy 项目是 Lyft 公司推出的一个高性能 C++ 网络代理,也是 Lyft 公司对 Istio 项目的唯一贡献。
而 Istio 项目,则把这个代理服务以 sidecar 容器的方式,运行在了每一个被治理的应用 Pod 中。我们知道,Pod 里的所有容器都共享同一个 Network Namespace。所以,Envoy 容器就能够通过配置 Pod 里的 iptables 规则,把整个 Pod 的进出流量接管下来。
这时候,Istio 的控制层(Control Plane)里的 Pilot 组件,就能够通过调用每个 Envoy 容器的 API,对这个 Envoy 代理进行配置,从而实现微服务治理。
假设这个 Istio 架构图左边的 Pod 是已经在运行的应用,而右边的 Pod 则是我们刚刚上线的应用的新版本。这时候,Pilot 通过调节这两 Pod 里的 Envoy 容器的配置,从而将 90% 的流量分配给旧版本的应用,将 10% 的流量分配给新版本应用,并且,还可以在后续的过程中随时调整。这样,一个典型的“灰度发布”的场景就完成了。比如,Istio 可以调节这个流量从 90%-10%,改到 80%-20%,再到 50%-50%,最后到 0%-100%,就完成了这个灰度发布的过程。
Istio 项目使用的,是 Kubernetes 中的一个非常重要的功能,叫作 Dynamic Admission Control。也就是一些需要初始化的工作,需要在k8s项目正式处理之前进行。选择性的被编译进APIServer中,在API对象创建之后会被立刻调用到。k8s提供了这种热插拔式的Admission机制,也叫做Initializer。比如自动加上Envoy容器的配置。
首先,Istio 会将这个 Envoy 容器本身的定义,以 ConfigMap 的方式保存在 Kubernetes 当中。然后在pod的yaml提交到k8s后自动将定义的envoy容器merge(TwoWayMergePatch)到用户提交的yaml中。
接下来,Istio 将一个编写好的 Initializer,作为一个 Pod 部署在 Kubernetes 中。
当你在 Initializer 里完成了要做的操作后,一定要记得将这个 metadata.initializers.pending 标志清除掉。这一点,你在编写 Initializer 代码的时候一定要非常注意。
Istio 项目的核心,就是由无数个运行在应用 Pod 中的 Envoy 容器组成的服务代理网格。这也正是 Service Mesh 的含义。
- 首先,所谓“声明式”,指的就是我只需要提交一个定义好的 API 对象来“声明”,我所期望的状态是什么样子。
- 其次,“声明式 API”允许有多个 API 写端,以 PATCH 的方式对 API 对象进行修改,而无需关心本地原始 YAML 文件的内容。
- 最后,也是最重要的,有了上述两个能力,Kubernetes 项目才可以基于对 API 对象的增、删、改、查,在完全无需外界干预的情况下,完成对“实际状态”和“期望状态”的调谐(Reconcile)过程。
所以说,声明式 API,才是 Kubernetes 项目编排能力“赖以生存”的核心所在。
YAML 文件提交给 Kubernetes 之后,创建出一个 API 对象的过程
首先,Kubernetes 会匹配 API 对象的组。
然后,Kubernetes 会进一步匹配到 API 对象的版本号。
首先,当我们发起了创建 CronJob 的 POST 请求之后,我们编写的 YAML 的信息就被提交给了 APIServer。而 APIServer 的第一个功能,就是过滤这个请求,并完成一些前置性的工作,比如授权、超时处理、审计等。
然后,请求会进入 MUX 和 Routes 流程。如果你编写过 Web Server 的话就会知道,MUX 和 Routes 是 APIServer 完成 URL 和 Handler 绑定的场所。而 APIServer 的 Handler 要做的事情,就是按照我刚刚介绍的匹配过程,找到对应的 CronJob 类型定义。
接着,APIServer 最重要的职责就来了:根据这个 CronJob 类型定义,使用用户提交的 YAML 文件里的字段,创建一个 CronJob 对象。而在这个过程中,APIServer 会进行一个 Convert 工作,即:把用户提交的 YAML 文件,转换成一个叫作 Super Version 的对象,它正是该 API 资源类型所有版本的字段全集。这样用户提交的不同版本的 YAML 文件,就都可以用这个 Super Version 对象来进行处理了。
接下来,APIServer 会先后进行 Admission() 和 Validation() 操作。比如,我在上一篇文章中提到的 Admission Controller 和 Initializer,就都属于 Admission 的内容。而 Validation,则负责验证这个对象里的各个字段是否合法。这个被验证过的 API 对象,都保存在了 APIServer 里一个叫作 Registry 的数据结构中。也就是说,只要一个 API 对象的定义能在 Registry 里查到,它就是一个有效的 Kubernetes API 对象。
最后,APIServer 会把验证过的 API 对象转换成用户最初提交的版本,进行序列化操作,并调用 Etcd 的 API 把它保存起来。
Custom Resource Definition——自定义 API 资源
先需编写一个 CRD 的 YAML 文件,它的名字叫作 network.yaml。——类比xsd文件
再编写Network 对象的 YAML 文件,名叫 example-network.yaml。——类比xml文件
为这个 API 对象编写一个自定义控制器(Custom Controller)
这样, Kubernetes 才能根据 Network API 对象的“增、删、改”操作,在真实环境中做出相应的响应。比如,“创建、删除、修改”真正的 Neutron 网络。
从 Kubernetes 的 APIServer 里获取它所关心的对象,也就是我定义的 Network 对象。Informer 与 API 对象是一一对应的,所以传递给自定义控制器的,正是一个 Network 对象的 Informer(Network Informer)。
Informer 所使用的 Reflector 包使用的是一种叫作ListAndWatch的方法,来“获取”并“监听”这些 Network 对象实例的变化。在 ListAndWatch 机制下,一旦 APIServer 端有新的 Network 实例被创建、删除或者更新,Reflector 都会收到“事件通知”。这时,该事件及它对应的 API 对象这个组合,就被称为增量(Delta),它会被放进一个 Delta FIFO Queue(即:增量先进先出队列)中。
-
Informe 会不断地从这个 Delta FIFO Queue 里读取(Pop)增量。每拿到一个增量,Informer 就会判断这个增量里的事件类型,然后创建或者更新本地对象的缓存。这个缓存,在 Kubernetes 里一般被叫作 Store。
这个同步本地缓存的工作,是 Informer 的第一个职责,也是它最重要的职责。
而Informer 的第二个职责,则是根据这些事件的类型,触发事先注册好的 ResourceEventHandler。这些 Handler,需要在创建控制器的时候注册给它对应的 Informer。所谓 Informer,其实就是一个带有本地缓存和索引机制的、可以注册 EventHandler 的 client。
更具体地说,Informer 通过一种叫作 ListAndWatch 的方法,把 APIServer 中的 API 对象缓存在了本地,并负责更新和维护这个缓存。首先,通过 APIServer 的 LIST API“获取”所有最新版本的 API 对象;然后,再通过 WATCH API 来“监听”所有这些 API 对象的变化。而通过监听到的事件变化,Informer 就可以实时地更新本地缓存,并且调用这些事件对应的 EventHandler 了。
控制循环(Control Loop)
- 首先,等待 Informer 完成一次本地缓存的数据同步操作;
- 然后,直接通过 goroutine 启动一个(或者并发启动多个)“无限循环”的任务。
而这个“无限循环”任务的每一个循环周期,执行的正是我们真正关心的业务逻辑。首先从工作队列里出队(workqueue.Get)了一个成员,也就是一个 Key,使用 networksLister 来尝试获取这个 Key 对应的 Network 对象。这个操作,其实就是在访问本地缓存的索引。实际上,在 Kubernetes 的源码中,你会经常看到控制器从各种 Lister 里获取对象,比如:podLister、nodeLister 等等,它们使用的都是 Informer 和缓存机制。
而如果能够获取到对应的 Network 对象,我就可以执行控制器模式里的对比“期望状态”和“实际状态”的逻辑了。
- 首先使用 Kubernetes 的 client(kubeClient)创建了一个工厂;
- 然后,用跟 Network 类似的处理方法,生成了一个 Deployment Informer;
- 接着,把 Deployment Informer 传递给了自定义控制器;当然,也要调用 Start 方法来启动这个 Deployment Informer。
而有了这个 Deployment Informer 后,这个控制器也就持有了所有 Deployment 对象的信息。接下来,它既可以通过 deploymentInformer.Lister() 来获取 Etcd 里的所有 Deployment 对象,也可以为这个 Deployment Informer 注册具体的 Handler 来。更重要的是,这就使得在这个自定义控制器里面,我可以通过对自定义 API 对象和默认 API 对象进行协同,从而实现更加复杂的编排功能。
总结:
所谓的 Informer,就是一个自带缓存和索引机制,可以触发 Handler 的客户端库。这个本地缓存在 Kubernetes 中一般被称为 Store,索引一般被称为 Index。
Informer 使用了 Reflector 包,它是一个可以通过 ListAndWatch 机制获取并监视 API 对象变化的客户端封装。
Reflector 和 Informer 之间,用到了一个“增量先进先出队列”进行协同。而 Informer 与你要编写的控制循环之间,则使用了一个工作队列来进行协同。
在实际应用中,除了控制循环之外的所有代码,实际上都是 Kubernetes 为你自动生成的,即:pkg/client/{informers, listers, clientset}里的内容。
而这些自动生成的代码,就为我们提供了一个可靠而高效地获取 API 对象“期望状态”的编程库。
所以,接下来,作为开发者,你就只需要关注如何拿到“实际状态”,然后如何拿它去跟“期望状态”做对比,从而决定接下来要做的业务逻辑即可。