3.9 玩转Pod调度

在Kubernetes平台上,我们很少会直接创建一个Pod,在大多数情况下会通过RC、Deployment、DaemonSet、Job等控制器完成对一组Pod副本的创建、调度及全生命周期的自动控制任务。

在最早的Kubernetes版本里是没有这么多Pod副本控制器的,只有一个Pod副本控制器RC(Replication Controller),这个控制器是这样设计实现的:RC独立于所控制的Pod,并通过Label标签这个松耦合关联关系控制目标Pod实例的创建和销毁,随着Kubernetes的发展,RC也出现了新的继任者——Deployment,用于更加自动地完成Pod副本的部署、版本更新、回滚等功能。

严谨地说,RC的继任者其实并不是Deployment,而是ReplicaSet,因为ReplicaSet进一步增强了RC标签选择器的灵活性。之前RC的标签选择器只能选择一个标签,而ReplicaSet拥有集合式的标签选择器,可以选择多个Pod标签,如下所示:

与RC不同,ReplicaSet被设计成能控制多个不同标签的Pod副本。比如,应用MyApp目前发布了v1与v2两个版本,用户希望MyApp的Pod副本数保持为3个,可以同时包含v1和v2版本的Pod,就可以用ReplicaSet来实现这种控制,写法如下:

其实,Kubernetes的滚动升级就是巧妙运用ReplicaSet的这个特性来实现的,同时,Deployment也是通过ReplicaSet来实现Pod副本自动控制功能的。我们不应该直接使用底层的ReplicaSet来控制Pod副本,而应该通过管理ReplicaSet的Deployment对象来控制副本,这是来自官方的建议。

在大多数情况下,我们希望Deployment创建的Pod副本被成功调度到集群中的任何一个可用节点,而不关心具体会调度到哪个节点。但是,在真实的生产环境中的确也存在一种需求:希望某种Pod的副本全部在指定的一个或者一些节点上运行,比如希望将MySQL数据库调度到一个具有SSD磁盘的目标节点上,此时Pod模板中的NodeSelector属性就开始发挥作用了,上述MySQL定向调度案例的实现方式可分为以下两步。

(1)把具有SSD磁盘的Node都打上自定义标签disk=ssd。

(2)在Pod模板中设定NodeSelector的值为“disk:ssd”。

如此一来,Kubernetes在调度Pod副本的时候,就会先按照Node的标签过滤出合适的目标节点,然后选择一个最佳节点进行调度。

上述逻辑看起来既简单又完美,但在真实的生产环境中可能面临以下令人尴尬的问题。

(1)如果NodeSelector选择的Label不存在或者不符合条件,比如这些目标节点此时宕机或者资源不足,该怎么办?

(2)如果要选择多种合适的目标节点,比如SSD磁盘的节点或者超高速硬盘的节点,该怎么办?Kubernetes引入了NodeAffinity(节点亲和性设置)来解决该需求。

在真实的生产环境中还存在如下所述的特殊需求。

(1)不同Pod之间的亲和性(Affinity)。比如MySQL数据库与Redis中间件不能被调度到同一个目标节点上,或者两种不同的Pod必须被调度到同一个Node上,以实现本地文件共享或本地网络通信等特殊需求,这就是PodAffinity要解决的问题。

(2)有状态集群的调度。对于ZooKeeper、Elasticsearch、MongoDB、Kafka等有状态集群,虽然集群中的每个Worker节点看起来都是相同的,但每个Worker节点都必须有明确的、不变的唯一ID(主机名或IP地址),这些节点的启动和停止次序通常有严格的顺序。此外,由于集群需要持久化保存状态数据,所以集群中的Worker节点对应的Pod不管在哪个Node上恢复,都需要挂载原来的Volume,因此这些Pod还需要捆绑具体的PV。针对这种复杂的需求,Kubernetes提供了StatefulSet这种特殊的副本控制器来解决问题,在Kubernetes 1.9版本发布后,StatefulSet才可用于正式生产环境中。

(3)在每个Node上调度并且仅仅创建一个Pod副本。这种调度通常用于系统监控相关的Pod,比如主机上的日志采集、主机性能采集等进程需要被部署到集群中的每个节点,并且只能部署一个副本,这就是DaemonSet这种特殊Pod副本控制器所解决的问题。

(4)对于批处理作业,需要创建多个Pod副本来协同工作,当这些Pod副本都完成自己的任务时,整个批处理作业就结束了。这种Pod运行且仅运行一次的特殊调度,用常规的RC或者Deployment都无法解决,所以Kubernetes引入了新的Pod调度控制器Job来解决问题,并继续延伸了定时作业的调度控制器CronJob。

与单独的Pod实例不同,由RC、ReplicaSet、Deployment、DaemonSet等控制器创建的Pod副本实例都是归属于这些控制器的,这就产生了一个问题:控制器被删除后,归属于控制器的Pod副本该何去何从?在Kubernetes 1.9之前,在RC等对象被删除后,它们所创建的Pod副本都不会被删除;在Kubernetes 1.9以后,这些Pod副本会被一并删除。如果不希望这样做,则可以通过kubectl命令的--cascade=false参数来取消这一默认特性:

接下来深入理解和实践这些Pod调度控制器的各种功能和特性。

3.9.1 Deployment或RC:全自动调度

Deployment或RC的主要功能之一就是自动部署一个容器应用的多份副本,以及持续监控副本的数量,在集群内始终维持用户指定的副本数量。

下面是一个Deployment配置的例子,使用这个配置文件可以创建一个ReplicaSet,这个ReplicaSet会创建3个Nginx应用的Pod:

运行kubectl create命令创建这个Deployment:

查看Deployment的状态:

该状态说明Deployment已创建好所有3个副本,并且所有副本都是最新的可用的。

通过运行kubectl get rs和kubectl get pods可以查看已创建的ReplicaSet(RS)和Pod的信息。

从调度策略上来说,这3个Nginx Pod由系统全自动完成调度。它们各自最终运行在哪个节点上,完全由Master的Scheduler经过一系列算法计算得出,用户无法干预调度过程和结果。

除了使用系统自动调度算法完成一组Pod的部署,Kubernetes也提供了多种丰富的调度策略,用户只需在Pod的定义中使用NodeSelector、NodeAffinity、PodAffinity、Pod驱逐等更加细粒度的调度策略设置,就能完成对Pod的精准调度。下面对这些策略进行说明。

3.9.2 NodeSelector:定向调度

Kubernetes Master上的Scheduler服务(kube-scheduler进程)负责实现Pod的调度,整个调度过程通过执行一系列复杂的算法,最终为每个Pod都计算出一个最佳的目标节点,这一过程是自动完成的,通常我们无法知道Pod最终会被调度到哪个节点上。在实际情况下,也可能需要将Pod调度到指定的一些Node上,可以通过Node的标签(Label)和Pod的nodeSelector属性相匹配,来达到上述目的。

(1)首先通过kubectl label命令给目标Node打上一些标签:

这里为k8s-node-1节点打上一个zone=north标签,表明它是“北方”的一个节点:

上述命令行操作也可以通过修改资源定义文件的方式,并运行kubectl replace-f xxx.yaml命令来完成。

(2)然后,在Pod的定义中加上nodeSelector的设置,以redis-master-controller.yaml为例:

运行kubectl create-f命令创建Pod,scheduler就会将该Pod调度到拥有“zone=north”标签的Node上。

使用kubectl get pods-o wide命令可以验证Pod所在的Node:

如果我们给多个Node都定义了相同的标签(例如zone=north),则scheduler会根据调度算法从这组Node中挑选一个可用的Node进行Pod调度。

通过基于Node标签的调度方式,我们可以把集群中具有不同特点的Node都贴上不同的标签,例如“role=frontend”“role=backend”“role=database”等标签,在部署应用时就可以根据应用的需求设置NodeSelector来进行指定Node范围的调度。

需要注意的是,如果我们指定了Pod的nodeSelector条件,且在集群中不存在包含相应标签的Node,则即使在集群中还有其他可供使用的Node,这个Pod也无法被成功调度。

除了用户可以自行给Node添加标签,Kubernetes也会给Node预定义一些标签,包括:

◎ kubernetes.io/hostname;

◎ beta.kubernetes.io/os(从1.14版本开始更新为稳定版,到1.18版本删除);

◎ beta.kubernetes.io/arch(从1.14版本开始更新为稳定版,到1.18版本删除);

◎ kubernetes.io/os(从1.14版本开始启用);

◎ kubernetes.io/arch(从1.14版本开始启用)。

用户也可以使用这些系统标签进行Pod的定向调度。

NodeSelector通过标签的方式,简单实现了限制Pod所在节点的方法。亲和性调度机制则极大扩展了Pod的调度能力,主要的增强功能如下。

◎ 更具表达力(不仅仅是“符合全部”的简单情况)。

◎ 可以使用软限制、优先采用等限制方式,代替之前的硬限制,这样调度器在无法满足优先需求的情况下,会退而求其次,继续运行该Pod。

◎ 可以依据节点上正在运行的其他Pod的标签来进行限制,而非节点本身的标签。这样就可以定义一种规则来描述Pod之间的亲和或互斥关系。

亲和性调度功能包括节点亲和性(NodeAffinity)和Pod亲和性(PodAffinity)两个维度的设置。节点亲和性与NodeSelector类似,增强了上述前两点优势;Pod的亲和与互斥限制则通过Pod标签而不是节点标签来实现,也就是上面第4点内容所陈述的方式,同时具有前两点提到的优点。

NodeSelector将会继续被使用,随着节点亲和性越来越能够体现nodeSelector的功能,最终NodeSelector会被废弃。

3.9.3 NodeAffinity:Node亲和性调度

NodeAffinity意为Node亲和性的调度策略,是用于替换NodeSelector的全新调度策略。目前有两种节点亲和性表达。

◎ RequiredDuringSchedulingIgnoredDuringExecution:必须满足指定的规则才可以调度Pod到Node上(功能与nodeSelector很像,但是使用的是不同的语法),相当于硬限制。

◎ PreferredDuringSchedulingIgnoredDuringExecution:强调优先满足指定规则,调度器会尝试调度Pod到Node上,但并不强求,相当于软限制。多个优先级规则还可以设置权重(weight)值,以定义执行的先后顺序。

IgnoredDuringExecution的意思是:如果一个Pod所在的节点在Pod运行期间标签发生了变更,不再符合该Pod的节点亲和性需求,则系统将忽略Node上Label的变化,该Pod能继续在该节点上运行。

下面的例子设置了NodeAffinity调度的如下规则。

◎ requiredDuringSchedulingIgnoredDuringExecution:要求只运行在amd64的节点上(beta.kubernetes.io/arch In amd64)。

◎ preferredDuringSchedulingIgnoredDuringExecution:要求尽量运行在磁盘类型为ssd(disk-type In ssd)的节点上。

代码如下:

从上面的配置中可以看到In操作符,NodeAffinity语法支持的操作符包括In、NotIn、Exists、DoesNotExist、Gt、Lt。虽然没有节点排斥功能,但是用NotIn和DoesNotExist就可以实现排斥的功能了。

NodeAffinity规则设置的注意事项如下。

◎ 如果同时定义了nodeSelector和nodeAffinity,那么必须两个条件都得到满足,Pod才能最终运行在指定的Node上。

◎ 如果nodeAffinity指定了多个nodeSelectorTerms,那么其中一个能匹配成功即可。

◎ 如果在nodeSelectorTerms中有多个matchExpressions,则一个节点必须满足所有matchExpressions才能运行该Pod。

3.9.4 PodAffinity:Pod亲和与互斥调度策略

在实际的生产环境中有一类特殊的Pod调度需求:存在某些相互依赖、频繁调用的Pod,它们需要被尽可能地部署在同一个Node节点、机架、机房、网段或者区域(Zone)内,这就是Pod之间的亲和性;反之,出于避免竞争或者容错的需求,我们也可能使某些Pod尽可能地远离某些特定的Pod,这就是Pod之间的反亲和性或者互斥性。

Pod间的亲和性与反亲和性调度策略从Kubernetes 1.4版本开始引入。简单地说,就是相关联的两种或多种Pod是否可以在同一个拓扑域中共存或者互斥,前者被称为Pod Affinity,后者被称为Pod Anti Affinity。那么,什么是拓扑域,如何理解这个新概念呢?一个拓扑域由一些Node节点组成,这些Node节点通常有相同的地理空间坐标,比如在同一个机架、机房或地区,我们一般用region表示机架、机房等的拓扑区域,用Zone表示地区这样跨度更大的拓扑区域。在极端情况下,我们也可以认为一个Node就是一个拓扑区域。为此,Kubernetes内置了如下一些常用的默认拓扑域:

◎ kubernetes.io/hostname;

◎ topology.kubernetes.io/region;

◎ topology.kubernetes.io/zone。

需要注意的是,以上拓扑域是由Kubernetes自己维护的,在Node节点初始化时,controller-manager会为Node打上许多标签,比如kubernetes.io/hostname这个标签的值就会被设置为Node节点的hostname。另外,公有云厂商提供的Kubernetes服务或者使用cloud-controller-manager创建的集群,还会给Node打上topology.kubernetes.io/region和topology.kubernetes.io/zone标签,以确定各个节点所属的拓扑域。

Pod亲和与互斥的调度具体做法,就是通过在Pod的定义上增加topologyKey属性,来声明对应的目标拓扑区域内几种相关联的Pod要“在一起或不在一起”。与节点亲和相同,Pod亲和与互斥的条件设置也是requiredDuringSchedulingIgnoredDuringExecution和preferredDuringSchedulingIgnoredDuringExecution。Pod的亲和性被定义于PodSpec的affinity字段的podAffinity子字段中;Pod间的互斥性则被定义于同一层次的podAntiAffinity子字段中。

下面通过实例来说明Pod间的亲和性和互斥性策略设置。

1. 参照目标Pod

首先,创建一个名为pod-flag的Pod,带有标签security=S1和app=nginx,后面的例子将使用pod-flag作为Pod亲和与互斥的目标Pod:

2.Pod的亲和性调度

下面创建第2个Pod来说明Pod的亲和性调度,这里定义的亲和标签是“security=S1”,对应上面的Pod“pod-flag”,topologyKey的值被设置为“kubernetes.io/hostname”:

创建Pod之后,使用kubectl get pods-o wide命令可以看到,这两个Pod在同一个Node上运行。

有兴趣的读者还可以测试一下,在创建这个Pod之前,删掉这个节点的“kubernetes.io/hostname”标签,重复上面的创建步骤,将会发现Pod一直处于Pending状态,这是因为找不到满足条件的Node了。

3.Pod的互斥性调度

创建第3个Pod,我们希望它不与目标Pod运行在同一个Node上:

这里要求这个新Pod与security=S1的Pod为同一个zone,但是不与app=nginx的Pod为同一个Node。创建Pod之后,同样用kubectl get pods-o wide来查看,会看到新的Pod被调度到了同一Zone内的不同Node上。

与节点亲和性类似,Pod亲和性的操作符也包括In、NotIn、Exists、DoesNotExist、Gt、Lt。

原则上,topologyKey可以使用任意合法的标签Key赋值,但是出于性能和安全方面的考虑,对topologyKey有如下限制。

◎ 在Pod亲和性和RequiredDuringScheduling的Pod互斥性的定义中,不允许使用空的topologyKey。

◎ 如果Admission controller包含了LimitPodHardAntiAffinityTopology,那么针对Required DuringScheduling的Pod互斥性定义就被限制为kubernetes.io/hostname,要使用自定义的topologyKey,就要改写或禁用该控制器。

◎ 在PreferredDuringScheduling类型的Pod互斥性定义中,空的topologyKey会被解释为kubernetes.io/hostname、failure-domain.beta.kubernetes.io/zone及failure-domain.beta.kubernetes.io/region的组合。

◎ 如果不是上述情况,就可以采用任意合法的topologyKey了。

PodAffinity规则设置的注意事项如下。

◎ 除了设置Label Selector和topologyKey,用户还可以指定Namespace列表进行限制,同样,使用Label Selector对Namespace进行选择。Namespace的定义和Label Selector及topologyKey同级。省略Namespace的设置,表示使用定义了affinity/anti-affinity的Pod所在的命名空间。如果Namespace被设置为空值(""),则表示所有命名空间。

◎ 在所有关联requiredDuringSchedulingIgnoredDuringExecution的matchExpressions全都满足之后,系统才能将Pod调度到某个Node上。

关于Pod亲和性和互斥性调度的更多信息可以参考其设计文档的说明。

3.9.5 Taints和Tolerations(污点和容忍)

前面介绍的NodeAffinity节点亲和性,是在Pod上定义的一种属性,使得Pod能够被调度到某些Node上运行(优先选择或强制要求)。Taint则正好相反,它让Node拒绝Pod的运行。简单地说,被标记为Taint的节点就是存在问题的节点,比如磁盘要满、资源不足、存在安全隐患要进行升级维护,希望新的Pod不会被调度过来,但被标记为Taint的节点并非故障节点,仍是有效的工作节点,所以仍需将某些Pod调度到这些节点上时,可以通过使用Toleration属性来实现。

在默认情况下,在Node上设置一个或多个Taint之后,除非Pod明确声明能够容忍这些污点,否则无法在这些Node上运行。可以用kubectl taint命令为Node设置Taint信息:

这个设置为node1加上了一个Taint。该Taint的键为key,值为value,Taint的效果是NoSchedule。这意味着除非Pod明确声明可以容忍这个Taint,否则不会被调度到node1上。

然后,需要在Pod上声明Toleration。下面的两个Toleration都被设置为可以容忍(Tolerate)具有该Taint的Node,使得Pod能够被调度到node1上:

Pod的Toleration声明中的key和effect需要与Taint的设置保持一致,并且满足以下条件之一。

◎ operator的值是Exists(无须指定value)。

◎ operator的值是Equal并且value相等。

如果不指定operator,则默认值为Equal。

另外,有如下两个特例。

◎ 空的key配合Exists操作符能够匹配所有键和值。

◎ 空的effect匹配所有effect。

在上面的例子中,effect的取值为NoSchedule,还可以取值为PreferNoSchedule,这个值的意思是优先,也可以算作NoSchedule的软限制版本—一个Pod如果没有声明容忍这个Taint,则系统会尽量避免把这个Pod调度到这一节点上,但不是强制的。后面还会介绍另一个effect“NoExecute”。

系统允许在同一个Node上设置多个Taint,也可以在Pod上设置多个Toleration。Kubernetes调度器处理多个Taint和Toleration的逻辑顺序为:首先列出节点中所有的Taint,然后忽略Pod的Toleration能够匹配的部分,剩下的没被忽略的Taint就是对Pod的效果了。下面是几种特殊情况。

◎ 如果在剩余的Taint中存在effect=NoSchedule,则调度器不会把该Pod调度到这一节点上。

◎ 如果在剩余的Taint中没有NoSchedule效果,但是有PreferNoSchedule效果,则调度器会尝试不把这个Pod指派给这个节点。

◎ 如果在剩余的Taint中有NoExecute效果,并且这个Pod已经在该节点上运行,则会被驱逐;如果没有在该节点上运行,则也不会再被调度到该节点上。

例如,我们这样对一个节点进行Taint设置:

然后在Pod上设置两个Toleration:

这样的结果是该Pod无法被调度到node1 上,这是因为第 3 个Taint没有匹配的Toleration。但是如果该Pod已经在node1上运行了,那么在运行时设置第3个Taint,它还能继续在node1上运行,这是因为Pod可以容忍前两个Taint。

一般来说,如果给Node加上effect=NoExecute的Taint,那么在该Node上正在运行的所有无对应Toleration的Pod都会被立刻驱逐,而具有相应Toleration的Pod永远不会被驱逐。不过,系统允许给具有NoExecute效果的Toleration加入一个可选的tolerationSeconds字段,这个设置表明Pod可以在Taint添加到Node之后还能在这个Node上运行多久(单位为s):

上述定义的意思是,如果Pod正在运行,所在节点都被加入一个匹配的Taint,则这个Pod会持续在这个节点上存活3600s后被逐出。如果在这个宽限期内Taint被移除,则不会触发驱逐事件。

Taint和Toleration是一种处理节点并且让Pod进行规避或者驱逐Pod的弹性处理方式,下面列举一些常见的用例。

1. 独占节点

如果想要拿出一部分节点专门给一些特定应用使用,则可以为节点添加这样的Taint:

然后给这些应用的Pod加入对应的Toleration。这样,带有合适Toleration的Pod就会被允许同使用其他节点一样使用有Taint的节点。

通过自定义Admission Controller也可以实现这一目标。如果希望让这些应用独占一批节点,并且确保它们只能使用这些节点,则还可以给这些Taint节点加入类似的标签dedicated=groupName,然后Admission Controller需要加入节点亲和性设置,要求Pod只会被调度到具有这一标签的节点上。

2. 具有特殊硬件设备的节点

在集群里可能有一小部分节点安装了特殊的硬件设备(如GPU芯片),用户自然会希望把不需要占用这类硬件的Pod排除在外,以确保对这类硬件有需求的Pod能够被顺利调度到这些节点上。

可以用下面的命令为节点设置Taint:

然后在Pod中利用对应的Toleration来保障特定的Pod能够使用特定的硬件。

和上面独占节点的示例类似,使用Admission Controller来完成这一任务会更方便。例如,Admission Controller使用Pod的一些特征来判断这些Pod,如果可以使用这些硬件,就添加Toleration来完成这一工作。要保障需要使用特殊硬件的Pod只被调度到安装这些硬件的节点上,则还需要一些额外的工作,比如将这些特殊资源使用opaque-int-resource的方式对自定义资源进行量化,然后在PodSpec中进行请求;也可以使用标签的方式来标注这些安装有特别硬件的节点,然后在Pod中定义节点亲和性来实现这个目标。

3. 定义Pod驱逐行为,以应对节点故障

前面提到的NoExecute这个Taint效果对节点上正在运行的Pod有以下影响。

◎ 没有设置Toleration的Pod会被立刻驱逐。

◎ 配置了对应Toleration的Pod,如果没有为tolerationSeconds赋值,则会一直留在这一节点中。

◎ 配置了对应Toleration的Pod且指定了tolerationSeconds值,则会在指定的时间后驱逐。注意,在节点发生故障的情况下,系统将会以限速(rate-limiting)模式逐步给Node设置Taint,这样就能避免在一些特定情况下(比如Master暂时失联)有大量的Pod被驱逐。

注意,Kubernetes会自动给Pod添加下面几种Toleration:

◎ key为node.kubernetes.io/not-ready,并配置tolerationSeconds=300;

◎ key为node.kubernetes.io/unreachable,并配置tolerationSeconds=300。

以上添加的这种自动机制保证了在某些节点发生一些临时性问题时,Pod默认能够继续停留在当前节点运行5min等待节点恢复,而不是立即被驱逐,从而避免系统的异常波动。

另外,Kubernetes从1.6版本开始引入两个与Taint相关的新特性:TaintNodesByCondition及TaintBasedEvictions,用来改善异常情况下的Pod调度与驱逐问题,比如在节点内存吃紧、节点磁盘空间已满、节点失联等情况下,是否自动驱逐某些Pod或者暂时保留这些Pod等待节点恢复正常。这个过程的完整逻辑基本如下。

(1)不断地检查所有Node状态,设置对应的Condition。

(2)不断地根据Node Condition设置对应的Taint。

(3)不断地根据Taint驱逐Node上的Pod。

其中,检查Node的状态并设置Node的Taint就是TaintNodesByCondition特性,即在Node满足某些特定的条件时,自动为Node节点添加Taint。目前主要有以下几种条件。

◎ node.kubernetes.io/not-ready:节点未就绪。对应NodeCondition Ready为False的情况。

◎ node.kubernetes.io/unreachable:节点不可触达。对应NodeCondition Ready为Unknown的情况。

◎ node.kubernetes.io/out-of-disk:节点磁盘空间已满。

◎ node.kubernetes.io/network-unavailable:节点网络不可用。

◎ node.kubernetes.io/unschedulable:节点不可调度。

◎ node.cloudprovider.kubernetes.io/uninitialized:如果kubelet是由“外部”云服务商启动的,则该污点用来标识某个节点当前为不可用状态。在云控制器(cloud-controller-manager)初始化这个节点以后,kubelet会将此污点移除。

自Kubernetes 1.13开始,上述两个特性被默认启用。TaintNodesByCondition这个特性只会为节点添加NoSchedule效果的污点,TaintBasedEviction则为节点添加NoExecute效果的污点。在TaintBasedEvictions特性被开启之后,kubelet会在有资源压力时对相应的Node节点自动加上对应的NoExecute效果的Taint,例如node.kubernetes.io/memory-pressure、node.kubernetes.io/disk-pressure。如果Pod没有设置对应的Toleration,则这部分Pod将被驱逐,以确保节点不会崩溃。

3.9.6 Pod Priority Preemption:Pod优先级调度

对于运行各种负载(如Service、Job)的中等规模或者大规模的集群来说,出于各种原因,我们需要尽可能提高集群的资源利用率。而提高资源利用率的常规做法是采用优先级方案,即不同类型的负载对应不同的优先级,同时允许集群中的所有负载所需的资源总量超过集群可提供的资源,在这种情况下,当发生资源不足的情况时,系统可以选择释放一些不重要的负载(优先级最低的),保障最重要的负载能够获取足够的资源稳定运行。

在Kubernetes 1.8版本之前,当集群的可用资源不足时,在用户提交新的Pod创建请求后,该Pod会一直处于Pending状态,即使这个Pod是一个很重要(很有身份)的Pod,也只能被动等待其他Pod被删除并释放资源,才能有机会被调度成功。Kubernetes 1.8版本引入了基于Pod优先级抢占(Pod Priority Preemption)的调度策略,此时Kubernetes会尝试释放目标节点上低优先级的Pod,以腾出空间(资源)安置高优先级的Pod,这种调度方式被称为“抢占式调度”。在Kubernetes 1.11版本中,该特性升级为Beta版本,默认开启,在后续的Kubernetes 1.14版本中正式Release。如何声明一个负载相对其他负载更重要?我们可以通过以下几个维度来定义:Priority:优先级;QoS:服务质量等级;系统定义的其他度量指标。

优先级抢占调度策略的核心行为分别是驱逐(Eviction)与抢占(Preemption),这两种行为的使用场景不同,效果相同。Eviction是kubelet进程的行为,即当一个Node资源不足(under resource pressure)时,该节点上的kubelet进程会执行驱逐动作,此时kubelet会综合考虑Pod的优先级、资源申请量与实际使用量等信息来计算哪些Pod需要被驱逐;当同样优先级的Pod需要被驱逐时,实际使用的资源量超过申请量最大倍数的高耗能Pod会被首先驱逐。对于QoS等级为“Best Effort”的Pod来说,由于没有定义资源申请(CPU/Memory Request),所以它们实际使用的资源可能非常大。Preemption则是Scheduler执行的行为,当一个新的Pod因为资源无法满足而不能被调度时,Scheduler可能(有权决定)选择驱逐部分低优先级的Pod实例来满足此Pod的调度目标,这就是Preemption机制。

需要注意的是,Scheduler可能会驱逐Node A上的一个Pod以满足Node B上的一个新Pod的调度任务。比如下面的这个例子:

一个低优先级的Pod A在Node A(属于机架R)上运行,此时有一个高优先级的Pod B等待调度,目标节点是同属机架R的Node B,其中一个或全部都定义了anti-affinity规则,不允许在同一个机架上运行,此时Scheduler只好“丢车保帅”,驱逐低优先级的Pod A以满足高优先级的Pod B的调度需求。

Pod优先级调度示例如下。

首先,由集群管理员创建PriorityClass,PriorityClass不属于任何命名空间:

上述YAML文件定义了一个名为high-priority的优先级类别,优先级为100000,数字越大,优先级越高,超过一亿的数字被系统保留,用于指派给系统组件。

我们可以在任意Pod上引用上述Pod优先级类别:

如果发生了需要抢占的调度,高优先级Pod就可能抢占节点N,并将其低优先级Pod驱逐出节点N,高优先级Pod的status信息中的nominatedNodeName字段会记录目标节点N的名称。需要注意,高优先级Pod仍然无法保证最终被调度到节点N上,在节点N上低优先级Pod被驱逐的过程中,如果有新的节点满足高优先级Pod的需求,就会把它调度到新的Node上。而如果在等待低优先级的Pod退出的过程中,又出现了优先级更高的Pod,调度器就会调度这个更高优先级的Pod到节点N上,并重新调度之前等待的高优先级Pod。

优先级抢占的调度方式可能会导致调度陷入“死循环”状态。当Kubernetes集群配置了多个调度器(Scheduler)时,这一行为可能就会发生,比如下面这个例子:

Scheduler A为了调度一个(批)Pod,特地驱逐了一些Pod,因此在集群中有了空余的空间可以用来调度,此时Scheduler B恰好抢在Scheduler A之前调度了一个新的Pod,消耗了相应的资源,因此,当Scheduler A清理完资源后正式发起Pod的调度时,却发现资源不足,被目标节点的kubelet进程拒绝了调度请求!这种情况的确无解,因此最好的做法是让多个Scheduler相互协作来共同实现一个目标。

高优先级Pod抢占节点并驱逐低优先级的Pod,这个问题对于普通的服务型的Pod来说问题不大,但对于执行批处理任务的Pod来说就可能是个灾难,当一个高优先级的批处理任务的Pod创建后,正在执行批处理任务的某个低优先级的Pod可能因为资源不足而被驱逐,从而导致对应的批处理任务被搁置。为了避免这个问题发生,PriorityClass增加了一个新的属性——preemptionPolicy,当它的值为preemptionLowerPriorty(默认)时,就执行抢占功能,当它的值被设置为Never时,就默认不抢占资源,而是静静地排队,等待自己的调度机会。

最后要指出一点:使用优先级抢占的调度策略可能会导致某些Pod永远无法被成功调度。因此优先级调度不但增加了系统的复杂性,还可能带来额外不稳定的因素。因此,一旦发生资源紧张的局面,首先要考虑的是集群扩容,如果无法扩容,则再考虑有监管的优先级调度特性,比如结合基于命名空间的资源配额限制来约束任意优先级抢占行为。

3.9.7 DaemonSet:在每个Node上都调度一个Pod

DaemonSet是Kubernetes 1.2 版本新增的一种资源对象,用于管理在集群中的每个Node上仅运行一份Pod的副本实例,如图3.3所示。

图3.3 DaemonSet示例

这种用法适合有这种需求的应用。

◎ 在每个Node上都运行一个GlusterFS存储或者Ceph存储的Daemon进程。

◎ 在每个Node上都运行一个日志采集程序,例如Fluentd或者Logstach。

◎ 在每个Node上都运行一个性能监控程序,采集该Node的运行性能数据,例如Prometheus Node Exporter、collectd、New Relic agent或者Ganglia gmond等。

DaemonSet的Pod调度策略与RC类似,除了使用系统内置的算法在每个Node上进行调度,也可以在Pod的定义中使用NodeSelector或NodeAffinity来指定满足条件的Node范围进行调度。

下面的例子定义了为在每个Node上都启动一个fluentd容器,配置文件fluentd-ds.yaml的内容如下,其中挂载了物理机的两个目录“/var/log”和“/var/lib/docker/containers”:

使用kubectl create命令创建该DaemonSet:

查看创建好的DaemonSet和Pod,可以看到在每个Node上都创建了一个Pod:

DaemonSet调度不同于普通的Pod调度,所以没有用默认的Kubernetes Scheduler进行调度,而是通过专有的DaemonSet Controller进行调度。但是随着Kubernetes版本的改进和调度特性不断丰富,产生了一些难以解决的矛盾,最主要的两个矛盾如下。

◎ 普通的Pod是在Pending状态触发调度并被实例化的,DaemonSet Controller并不是在这个状态调度Pod的,这种不一致容易误导和迷惑用户。

◎ Pod优先级调度是被Kubernetes Scheduler执行的,而DaemonSet Controller并没有考虑到Pod优先级调度的问题,也产生了不一致的结果。

从Kubernetes 1.18开始,DaemonSet的调度默认切换到Kubernetes Scheduler进行,从而一劳永逸地解决了以上问题及未来可能的新问题。因为默认切换到了Kubernetes Scheduler统一调度Pod,因此DaemonSet也能正确处理Taints和Tolerations的问题。

3.9.8 Job:批处理调度

Kubernetes从1.2版本开始支持批处理类型的应用,我们可以通过Kubernetes Job资源对象来定义并启动一个批处理任务。批处理任务通常并行(或者串行)启动多个计算进程去处理一批工作项(Work item),处理完成后,整个批处理任务结束。按照批处理任务实现方式的不同,批处理任务可以分为如图3.4所示的几种模式。

图3.4 批处理任务的几种模式

◎ Job Template Expansion模式:一个Job对象对应一个待处理的Work item,有几个Work item就产生几个独立的Job,通常适合Work item数量少、每个Work item要处理的数据量比较大的场景,比如有一个100GB的文件作为一个Work item,总共有10个文件需要处理。

◎ Queue with Pod Per Work Item模式:采用一个任务队列存放Work item,一个Job对象作为消费者去完成这些Work item,在这种模式下,Job会启动N个Pod,每个Pod都对应一个Work item。

◎ Queue with Variable Pod Count模式:也是采用一个任务队列存放Work item,一个Job对象作为消费者去完成这些Work item,但与上面的模式不同,Job启动的Pod数量是可变的。

还有一种被称为Single Job with Static Work Assignment的模式,也是一个Job产生多个Pod,但它采用程序静态方式分配任务项,而不是采用队列模式进行动态分配。

如表3.4所示是这几种模式的一个对比。

表3.4 批处理任务的模式对比

考虑到批处理的并行问题,Kubernetes将Job分以下三种类型。

(1)Non-parallel Jobs:通常一个Job只启动一个Pod,除非Pod异常,才会重启该Pod,一旦此Pod正常结束,Job将结束。

(2)Parallel Jobs with a fixed completion count:并行Job会启动多个Pod,此时需要设定Job的.spec.completions参数为一个正数,当正常结束的Pod数量达至此参数设定的值后,Job结束。此外,Job的.spec.parallelism参数用来控制并行度,即同时启动几个Job来处理Work item。

(3)Parallel Jobs with a work queue:任务队列方式的并行Job需要一个独立的Queue,Work item都在一个Queue中存放,不能设置Job的.spec.completions参数,此时Job有以下特性。

◎ 每个Pod都能独立判断和决定是否还有任务项需要处理。

◎ 如果某个Pod正常结束,则Job不会再启动新的Pod。

◎ 如果一个Pod成功结束,则此时应该不存在其他Pod还在工作的情况,它们应该都处于即将结束、退出的状态。

◎ 如果所有Pod都结束了,且至少有一个Pod成功结束,则整个Job成功结束。

下面分别讲解常见的三种批处理模式在Kubernetes中的应用示例。

首先是Job Template Expansion模式,由于在这种模式下每个Work item都对应一个Job实例,所以这种模式首先定义一个Job模板,模板里的主要参数是Work item的标识,因为每个Job都处理不同的Work item。如下所示的Job模板(文件名为job.yaml.txt)中的$ITEM可以作为任务项的标识:

通过下面的操作,生成了3个对应的Job定义文件并创建Job:

观察Job的运行情况:

然后,我们看看Queue with Pod Per Work Item模式,在这种模式下需要一个任务队列存放Work item,比如RabbitMQ,客户端程序先把要处理的任务变成Work item放入任务队列,然后编写Worker程序、打包镜像并定义成为Job中的Work Pod。Worker程序的实现逻辑是从任务队列中拉取一个Work item并处理,在处理完成后结束进程。并行度为2的Demo示意图如图3.5所示。

图3.5 并行度为2的Demo示意图

最后,我们看看Queue with Variable Pod Count模式,如图3.6所示。由于这种模式下,Worker程序需要知道队列中是否还有等待处理的Work item,如果有就取出来处理,否则就认为所有工作完成并结束进程,所以任务队列通常要采用Redis或者数据库来实现。

图3.6 Queue with Variable Pod Count模式示意图

3.9.9 Cronjob:定时任务

Kubernetes从1.5版本开始增加了一种新类型的Job,即类似Linux Cron的定时任务Cron Job,下面看看如何定义和使用这种类型的Job。

首先,确保Kubernetes的版本为1.8及以上。

其次,需要掌握Cron Job的定时表达式,它基本上照搬了Linux Cron的表达式,格式如下:

其中每个域都可出现的字符如下。

◎ Minutes:可出现“,”“-”“*”“/”这4个字符,有效范围为0~59的整数。

◎ Hours:可出现“,”“-”“*”“/”这4个字符,有效范围为0~23的整数。

◎ DayofMonth:可出现“,”“-”“*”“/”“?”“L”“W”“C”这8个字符,有效范围为1~31的整数。

◎ Month:可出现“,”“-”“*”“/”这4个字符,有效范围为1~12的整数或JAN~DEC。

◎ DayofWeek:可出现“,”“-”“*”“/”“?”“L”“C”“#”这8个字符,有效范围为1~7的整数或SUN~SAT。1表示星期天,2表示星期一,以此类推。

表达式中的特殊字符“*”与“/”的含义如下。

◎ *:表示匹配该域的任意值,假如在Minutes域使用“*”,则表示每分钟都会触发事件。

◎ /:表示从起始时间开始触发,然后每隔固定时间触发一次,例如在Minutes域设置为5/20,则意味着第1次触发在第5min时,接下来每20min触发一次,将在第25min、第45min等时刻分别触发。

比如,我们要每隔1min执行一次任务,则Cron表达式如下:

掌握这些基本知识后,就可以编写一个Cron Job的配置文件了:

该例子定义了一个名为hello的Cron Job,任务每隔1min执行一次,运行的镜像是busybox,运行的命令是Shell脚本,脚本运行时会在控制台输出当前时间和字符串“Hello from the Kubernetes cluster”。

接下来运行kubectl create命令完成创建:

然后每隔1min运行kubectl get cronjob hello查看任务状态,发现的确每分钟调度了一次:

还可以通过查找Cron Job对应的容器,验证每隔1min产生一个容器的事实:

查看任意一个容器的日志,结果如下:

运行下面的命令,可以更直观地了解Cron Job定期触发任务执行的历史和现状:

其中SUCCESSFUL列为1的每一行都是一个调度成功的Job,以第1行的“hello-1498761060”的Job为例,它对应的Pod可以通过下面的方式得到:

查看该Pod的日志:

最后,不需要某个Cron Job时,可以通过下面的命令删除它:

在Kubernetes 1.9版本后,kubectl命令增加了别名cj来表示cronjob,同时kubectl set image/env命令也可以作用在CronJob对象上。

3.9.10 自定义调度器

如果Kubernetes调度器的众多特性还无法满足我们的独特调度需求,则还可以用自己开发的调度器进行调度。从1.6版本开始,Kubernetes的多调度器特性也进入了快速发展阶段。

一般情况下,每个新Pod都会由默认的调度器进行调度。但是如果在Pod中提供了自定义的调度器名称,那么默认的调度器会忽略该Pod,转由指定的调度器完成Pod的调度。

在下面的例子中为Pod指定了一个名为my-scheduler的自定义调度器:

如果自定义的调度器还未在系统中部署,则默认的调度器会忽略这个Pod,这个Pod将会永远处于Pending状态。

下面看看如何创建一个自定义的调度器。

我们可以用任意语言实现简单或复杂的自定义调度器。下面的简单例子使用了Bash脚本进行实现,调度策略为随机选择一个Node(注意,这个调度器需要通过kubectl proxy来运行):

一旦这个自定义调度器成功启动,前面的Pod就会被正确调度到某个Node上。

3.9.11 Pod容灾调度

我们可以将Pod的各种常规调度策略认为是将整个集群视为一个整体,然后进行“打散或聚合”的调度。当我们的集群是为了容灾而建设的跨区域的多中心(多个Zone)集群,即集群中的节点位于不同区域的机房时,比如北京、上海、广州、武汉,要求每个中心的应用相互容灾备份,又能同时提供服务,此时最好的调度策略就是将需要容灾的应用均匀调度到各个中心,当某个中心出现问题时,又自动调度到其他中心均匀分布,调度效果如图3.7所示,不管每个中心的Node节点数量如何。

图3.7 Pod的多中心均匀分布调度效果图

用普通的基于Node标签选择的调度方式也可以实现上述效果,比如为每个Zone都建立一个Deployment,Pod的副本总数除以Zone的数量就是每个分区的Pod副本数量。但这样做有个问题:如果某个Zone失效,那么这个Zone的Pod就无法迁移到其他Zone。

另外,topology.kubernetes.io/zone就是Kubernetes默认支持的重要拓扑域之一,那是否可以用Pod的亲和性调度来解决这个问题呢?不能,因为Pod的亲和性调度用于解决相关联的Pod的调度问题,不能保证被依赖的Pod被均匀调度到多个Zone。

为了满足这种容灾场景下的特殊调度需求,在Kubernetes 1.16版本中首次引入Even Pod Spreading特性,用于通过topologyKey属性识别Zone,并通过设置新的参数topologySpreadConstraints来将Pod均匀调度到不同的Zone。举个例子,假如我们的集群被划分为多个Zone,我们有一个应用(对应的Pod标签为app=foo)需要在每个Zone均匀调度以实现容灾,则可以定义YAML文件如下:

在以上YAML定义中,关键的参数是maxSkew。maxSkew用于指定Pod在各个Zone上调度时能容忍的最大不均衡数:值越大,表示能接受的不均衡调度越大;值越小,表示各个Zone的Pod数量分布越均匀。

为了理解maxSkew,我们需要先理解skew参数的计算公式:skew[topo]=count[topo]-min(count[topo]),即每个拓扑区域的skew值都为该区域包括的目标Pod数量与整个拓扑区域最少Pod数量的差,而maxSkew就是最大的skew值。假如在上面的例子中有3个拓扑区域,分别为Zone A、Zone B及Zone C,有3个目标Pod需要调度到这些拓扑区域,那么前两个毫无疑问会被调度到Zone A和Zone B,调度效果如图3.8所示。

图3.8 Even Pod Spreading调度效果

那么,第3个Pod会被调度到哪里呢?我们可以手动计算每个Zone的skew,首先计算出min(count[topo])是0,对应Zone C,于是Zone A的skew=1-0=1,Zone B的skew=1-0=0,Zone C的skew=0-0=0,于是第3个Pod应该被放在Zone C,此时min(count[topo])的值就变成了1,而实际的maxSkew的值为0,符合预期设置。如果我们把maxSkew设置为2,则在这种情况下,第3个Pod被放在Zone A或Zone B都是符合要求的。

有了新的Even Pod Spreading调度特性的加持,再加上之前就已成熟的Pod亲和性调度,Kubernetes就可以完美实现特定应用的容灾部署目标了。具体做法也很简单:将一个应用中需要部署在一起的几个Pod用亲和性调度声明捆绑,然后选择其中一个Pod,加持Even Pod Spreading调度规则即可。最终的部署效果图如图3.9所示。

图3.9 应用容灾部署效果图