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标签,如下所示:
selector: matchLabels: tier: frontend matchExpressions: - {key: tier, operator: In, values: [frontend]}
与RC不同,ReplicaSet被设计成能控制多个不同标签的Pod副本。一种常见的应用场景是,应用MyApp目前发布了v1与v2两个版本,用户希望MyApp的Pod副本数保持为3个,可以同时包含v1和v2版本的Pod,就可以用ReplicaSet来实现这种控制,写法如下:
selector: matchLabels: version: v2 matchExpressions: - {key: version, operator: In, values: [v1,v2]}
其实,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磁盘的节点或者超高速硬盘的节点,该怎么办?Kubernates引入了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都无法解决,所以Kubernates引入了新的Pod调度控制器Job来解决问题,并继续延伸了定时作业的调度控制器CronJob。
与单独的Pod实例不同,由RC、ReplicaSet、Deployment、DaemonSet等控制器创建的Pod副本实例都是归属于这些控制器的,这就产生了一个问题:控制器被删除后,归属于控制器的Pod副本该何去何从?在Kubernates 1.9之前,在RC等对象被删除后,它们所创建的Pod副本都不会被删除;在Kubernates 1.9以后,这些Pod副本会被一并删除。如果不希望这样做,则可以通过kubectl命令的--cascade=false参数来取消这一默认特性:
kubectl delete replicaset my-repset --cascade=false
接下来深入理解和实践这些Pod调度控制器的各种功能和特性。
3.9.1 Deployment或RC:全自动调度
Deployment或RC的主要功能之一就是自动部署一个容器应用的多份副本,以及持续监控副本的数量,在集群内始终维持用户指定的副本数量。
下面是一个Deployment配置的例子,使用这个配置文件可以创建一个ReplicaSet,这个ReplicaSet会创建3个Nginx应用的Pod:
nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80
运行kubectl create命令创建这个Deployment:
# kubectl create -f nginx-deployment.yaml deployment "nginx-deployment" created
查看Deployment的状态:
# kubectl get deployments NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE nginx-deployment 3 3 3 3 18s
该状态说明Deployment已创建好所有3个副本,并且所有副本都是最新的可用的。
通过运行kubectl get rs和kubectl get pods可以查看已创建的ReplicaSet(RS)和Pod的信息。
# kubectl get rs NAME DESIRED CURRENT READY AGE nginx-deployment-4087004473 3 3 3 53s # kubectl get pods NAME READY STATUS RESTARTS AGE nginx-deployment-4087004473-9jqqs 1/1 Running 0 1m nginx-deployment-4087004473-cq0cf 1/1 Running 0 1m nginx-deployment-4087004473-vxn56 1/1 Running 0 1m
从调度策略上来说,这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打上一些标签:
kubectl label nodes <node-name> <label-key>=<label-value>
这里,我们为k8s-node-1节点打上一个zone=north标签,表明它是“北方”的一个节点:
$ kubectl label nodes k8s-node-1 zone=north
NAME LABELS STATUS
k8s-node-1 kubernetes.io/hostname=k8s-node-1,zone=north Ready
上述命令行操作也可以通过修改资源定义文件的方式,并执行kubectl replace -f xxx.yaml命令来完成。
(2)然后,在Pod的定义中加上nodeSelector的设置,以redis-master-controller.yaml为例:
apiVersion: v1 kind: ReplicationController metadata: name: redis-master labels: name: redis-master spec: replicas: 1 selector: name: redis-master template: metadata: labels: name: redis-master spec: containers: - name: master image: kubeguide/redis-master ports: - containerPort: 6379 nodeSelector: zone: north
运行kubectl create -f命令创建Pod,scheduler就会将该Pod调度到拥有zone=north标签的Node上。
使用kubectl get pods -o wide命令可以验证Pod所在的Node:
# kubectl get pods -o wide NAME READY STATUS RESTARTS AGE NODE redis-master-f0rqj 1/1 Running 0 19s k8s-node-1
如果我们给多个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)的节点上。
代码如下:
apiVersion: v1 kind: Pod metadata: name: with-node-affinity spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: beta.kubernetes.io/arch operator: In values: - amd64 preferredDuringSchedulingIgnoredDuringExecution: - weight: 1 preference: matchExpressions: - key: disk-type operator: In values: - ssd containers: - name: with-node-affinity image: gcr.io/google_containers/pause:2.0
从上面的配置中可以看到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间的亲和与互斥从Kubernetes 1.4版本开始引入。这一功能让用户从另一个角度来限制Pod所能运行的节点:根据在节点上正在运行的Pod的标签而不是节点的标签进行判断和调度,要求对节点和Pod两个条件进行匹配。这种规则可以描述为:如果在具有标签X的Node上运行了一个或者多个符合条件Y的Pod,那么Pod应该(如果是互斥的情况,那么就变成拒绝)运行在这个Node上。
这里X指的是一个集群中的节点、机架、区域等概念,通过Kubernetes内置节点标签中的key来进行声明。这个key的名字为topologyKey,意为表达节点所属的topology范围。
◎ kubernetes.io/hostname
◎ failure-domain.beta.kubernetes.io/zone
◎ failure-domain.beta.kubernetes.io/region
与节点不同的是,Pod是属于某个命名空间的,所以条件Y表达的是一个或者全部命名空间中的一个Label Selector。
和节点亲和相同,Pod亲和与互斥的条件设置也是requiredDuringSchedulingIgnoredDuringExecution和preferredDuringSchedulingIgnoredDuringExecution。Pod的亲和性被定义于PodSpec的affinity字段下的podAffinity子字段中。Pod间的互斥性则被定义于同一层次的podAntiAffinity子字段中。
下面通过实例来说明Pod间的亲和性和互斥性策略设置。
1.参照目标Pod
首先,创建一个名为pod-flag的Pod,带有标签security=S1和app=nginx,后面的例子将使用pod-flag作为Pod亲和与互斥的目标Pod:
apiVersion: v1 kind: Pod metadata: name: pod-flag labels: security: "S1" app: "nginx" spec: containers: - name: nginx image: nginx
2.Pod的亲和性调度
下面创建第2个Pod来说明Pod的亲和性调度,这里定义的亲和标签是security=S1,对应上面的Pod“pod-flag”,topologyKey的值被设置为“kubernetes.io/hostname”:
apiVersion: v1 kind: Pod metadata: name: pod-affinity spec: affinity: podAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: security operator: In values: - S1 topologyKey: kubernetes.io/hostname containers: - name: with-pod-affinity image: gcr.io/google_containers/pause:2.0
创建Pod之后,使用kubectl get pods -o wide命令可以看到,这两个Pod在同一个Node上运行。
有兴趣的读者还可以测试一下,在创建这个Pod之前,删掉这个节点的kubernetes.io/hostname标签,重复上面的创建步骤,将会发现Pod一直处于Pending状态,这是因为找不到满足条件的Node了。
3.Pod的互斥性调度
创建第3个Pod,我们希望它不与目标Pod运行在同一个Node上:
apiVersion: v1 kind: Pod metadata: name: anti-affinity spec: affinity: podAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: security operator: In values: - S1 topologyKey: failure-domain.beta.kubernetes.io/zone podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: app operator: In values: - nginx topologyKey: kubernetes.io/hostname containers: - name: anti-affinity image: gcr.io/google_containers/pause:2.0
这里要求这个新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。如果Namespace被设置为空值(""),则表示所有Namespace。
◎ 在所有关联requiredDuringSchedulingIgnoredDuringExecution的matchExpressions全都满足之后,系统才能将Pod调度到某个Node上。
关于Pod亲和性和互斥性调度的更多信息可以参考其设计文档,网址为https://github.com/kubernetes/kubernetes/blob/master/docs/design/podaffinity.md。
3.9.5 Taints和Tolerations(污点和容忍)
前面介绍的NodeAffinity节点亲和性,是在Pod上定义的一种属性,使得Pod能够被调度到某些Node上运行(优先选择或强制要求)。Taint则正好相反,它让Node拒绝Pod的运行。
Taint需要和Toleration配合使用,让Pod避开那些不合适的Node。在Node上设置一个或多个Taint之后,除非Pod明确声明能够容忍这些污点,否则无法在这些Node上运行。Toleration是Pod的属性,让Pod能够(注意,只是能够,而非必须)运行在标注了Taint的Node上。
可以用kubectl taint命令为Node设置Taint信息:
$ kubectl taint nodes node1 key=value:NoSchedule
这个设置为node1加上了一个Taint。该Taint的键为key,值为value,Taint的效果是NoSchedule。这意味着除非Pod明确声明可以容忍这个Taint,否则就不会被调度到node1上。
然后,需要在Pod上声明Toleration。下面的两个Toleration都被设置为可以容忍(Tolerate)具有该Taint的Node,使得Pod能够被调度到node1上:
tolerations: - key: "key" operator: "Equal" value: "value" effect: "NoSchedule"
或
tolerations: - key: "key" operator: "Exists" effect: "NoSchedule"
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设置:
$ kubectl taint nodes node1 key1=value1:NoSchedule $ kubectl taint nodes node1 key1=value1:NoExecute $ kubectl taint nodes node1 key2=value2:NoSchedule
然后在Pod上设置两个Toleration:
tolerations: - key: "key1" operator: "Equal" value: "value1" effect: "NoSchedule" - key: "key1" operator: "Equal" value: "value1" effect: "NoExecute"
这样的结果是该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):
tolerations: - key: "key1" operator: "Equal" value: "value1" effect: "NoExecute" tolerationSeconds: 3600
上述定义的意思是,如果Pod正在运行,所在节点都被加入一个匹配的Taint,则这个Pod会持续在这个节点上存活3600s后被逐出。如果在这个宽限期内Taint被移除,则不会触发驱逐事件。
Taint和Toleration是一种处理节点并且让Pod进行规避或者驱逐Pod的弹性处理方式,下面列举一些常见的用例。
1.独占节点
如果想要拿出一部分节点专门给一些特定应用使用,则可以为节点添加这样的Taint:
$ kubectl taint nodes nodename dedicated=groupName:NoSchedule
然后给这些应用的Pod加入对应的Toleration。这样,带有合适Toleration的Pod就会被允许同使用其他节点一样使用有Taint的节点。
通过自定义Admission Controller也可以实现这一目标。如果希望让这些应用独占一批节点,并且确保它们只能使用这些节点,则还可以给这些Taint节点加入类似的标签dedicated=groupName,然后Admission Controller需要加入节点亲和性设置,要求Pod只会被调度到具有这一标签的节点上。
2.具有特殊硬件设备的节点
在集群里可能有一小部分节点安装了特殊的硬件设备(如GPU芯片),用户自然会希望把不需要占用这类硬件的Pod排除在外,以确保对这类硬件有需求的Pod能够被顺利调度到这些节点。
可以用下面的命令为节点设置Taint:
$ kubectl taint nodes nodename special=true:NoSchedule $ kubectl taint nodes nodename special=true:PreferNoSchedule
然后在Pod中利用对应的Toleration来保障特定的Pod能够使用特定的硬件。
和上面的独占节点的示例类似,使用Admission Controller来完成这一任务会更方便。例如,Admission Controller使用Pod的一些特征来判断这些Pod,如果可以使用这些硬件,就添加Toleration来完成这一工作。要保障需要使用特殊硬件的Pod只被调度到安装这些硬件的节点上,则还需要一些额外的工作,比如将这些特殊资源使用opaque-int-resource的方式对自定义资源进行量化,然后在PodSpec中进行请求;也可以使用标签的方式来标注这些安装有特别硬件的节点,然后在Pod中定义节点亲和性来实现这个目标。
3.定义Pod驱逐行为,以应对节点故障(为Alpha版本的功能)
前面提到的NoExecute这个Taint效果对节点上正在运行的Pod有以下影响。
◎ 没有设置Toleration的Pod会被立刻驱逐。
◎ 配置了对应Toleration的Pod,如果没有为tolerationSeconds赋值,则会一直留在这一节点中。
◎ 配置了对应Toleration的Pod且指定了tolerationSeconds值,则会在指定时间后驱逐。
◎ Kubernetes从1.6版本开始引入一个Alpha版本的功能,即把节点故障标记为Taint(目前只针对node unreachable及node not ready,相应的NodeCondition "Ready"的值分别为Unknown和False)。激活TaintBasedEvictions功能后(在--feature-gates参数中加入TaintBasedEvictions=true),NodeController会自动为Node设置Taint,而在状态为Ready的Node上,之前设置过的普通驱逐逻辑将会被禁用。注意,在节点故障的情况下,为了保持现存的Pod驱逐的限速(rate-limiting)设置,系统将会以限速的模式逐步给Node设置Taint,这就能避免在一些特定情况下(比如Master暂时失联)大量的Pod被驱逐。这一功能兼容于tolerationSeconds,允许Pod定义节点故障时持续多久才被逐出。
例如,一个包含很多本地状态的应用可能需要在网络发生故障时,还能持续在节点上运行,期望网络能够快速恢复,从而避免被从这个节点上驱逐。
Pod的Toleration可以这样定义:
tolerations: - key: "node.alpha.kubernetes.io/unreachable" operator: "Exists" effect: "NoExecute" tolerationSeconds: 6000
对于Node未就绪状态,可以把Key设置为node.alpha.kubernetes.io/notReady。
如果没有为Pod指定node.alpha.kubernetes.io/notReady的Toleration,那么Kubernetes会自动为Pod加入tolerationSeconds=300的node.alpha.kubernetes.io/notReady类型的Toleration。
同样,如果Pod没有定义node.alpha.kubernetes.io/unreachable的Toleration,那么系统会自动为其加入tolerationSeconds=300的node.alpha.kubernetes.io/unreachable类型的Toleration。
这些系统自动设置的toleration在Node发现问题时,能够为Pod确保驱逐前再运行5min。这两个默认的Toleration由Admission Controller“DefaultTolerationSeconds”自动加入。
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优先级调度示例如下。
首先,由集群管理员创建PriorityClasses,PriorityClass不属于任何命名空间:
apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for XYZ service pods only."
上述YAML文件定义了一个名为high-priority的优先级类别,优先级为100000,数字越大,优先级越高,超过一亿的数字被系统保留,用于指派给系统组件。
我们可以在任意Pod中引用上述Pod优先级类别:
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
env: test
spec:
containers:
- name: nginx
image: nginx
imagePullPolicy: IfNotPresent
priorityClassName: high-priority
如果发生了需要抢占的调度,高优先级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永远无法被成功调度。因此优先级调度不但增加了系统的复杂性,还可能带来额外不稳定的因素。因此,一旦发生资源紧张的局面,首先要考虑的是集群扩容,如果无法扩容,则再考虑有监管的优先级调度特性,比如结合基于Namespace的资源配额限制来约束任意优先级抢占行为。
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”:
apiVersion: apps/v1 kind: DaemonSet metadata: name: fluentd-cloud-logging namespace: kube-system labels: k8s-app: fluentd-cloud-logging spec: template: metadata: namespace: kube-system labels: k8s-app: fluentd-cloud-logging spec: containers: - name: fluentd-cloud-logging image: gcr.io/google_containers/fluentd-elasticsearch:1.17 resources: limits: cpu: 100m memory: 200Mi env: - name: FLUENTD_ARGS value: -q volumeMounts: - name: varlog mountPath: /var/log readOnly: false - name: containers mountPath: /var/lib/docker/containers readOnly: false volumes: - name: containers hostPath: path: /var/lib/docker/containers - name: varlog hostPath: path: /var/log
使用kubectl create命令创建该DaemonSet:
# kubectl create -f fluentd-ds.yaml daemonset "fluentd-cloud-logging" created
查看创建好的DaemonSet和Pod,可以看到在每个Node上都创建了一个Pod:
# kubectl get daemonset --namespace=kube-system NAME DESIRED CURRENT NODE-SELECTOR AGE fluentd-cloud-logging 2 2 <none> 3s # kubectl get pods --namespace=kube-system NAME READY STATUS RESTARTS AGE fluentd-cloud-logging-7tw9z 1/1 Running 0 1h fluentd-cloud-logging-aqdn1 1/1 Running 0 1h
在Kubernetes 1.6以后的版本中,DaemonSet也能执行滚动升级了,即在更新一个DaemonSet模板的时候,旧的Pod副本会被自动删除,同时新的Pod副本会被自动创建,此时DaemonSet的更新策略(updateStrategy)为RollingUpdate,如下所示:
apiVersion: apps/v1 kind: DaemonSet metadata: name: goldpinger spec: updateStrategy: type: RollingUpdate
updateStrategy的另外一个值是OnDelete,即只有手工删除了DaemonSet创建的Pod副本,新的Pod副本才会被创建出来。如果不设置updateStrategy的值,则在Kubernetes 1.6之后的版本中会被默认设置为RollingUpdate。
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可以作为任务项的标识:
apiVersion: batch/v1 kind: Job metadata: name: process-item-$ITEM labels: jobgroup: jobexample spec: template: metadata: name: jobexample labels: jobgroup: jobexample spec: containers: - name: c image: busybox command: ["sh", "-c", "echo Processing item $ITEM&& sleep 5"] restartPolicy: Never
通过下面的操作,生成了3个对应的Job定义文件并创建Job:
# for i in apple banana cherry > do > cat job.yaml.txt | sed "s/\$ITEM/$i/" > ./jobs/job-$i.yaml > done # ls jobs job-apple.yaml job-banana.yaml job-cherry.yaml # kubectl create -f jobs job "process-item-apple" created job "process-item-banana" created job "process-item-cherry" created
首先,观察Job的运行情况:
# kubectl get jobs -l jobgroup=jobexample NAME DESIRED SUCCESSFUL AGE process-item-apple 1 1 4m process-item-banana 1 1 4m process-item-cherry 1 1 4m
其次,我们看看Queue with Pod Per Work Item模式,在这种模式下需要一个任务队列存放Work item,比如RabbitMQ,客户端程序先把要处理的任务变成Work item放入任务队列,然后编写Worker程序、打包镜像并定义成为Job中的Work Pod。Worker程序的实现逻辑是从任务队列中拉取一个Work item并处理,在处理完成后即结束进程。并行度为2的Demo示意图如图3.5所示。
图3.5 Queue with Pod Per Work Item示例
最后,我们看看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的表达式,区别是第1位是分钟而不是秒,格式如下:
Minutes Hours DayofMonth Month DayofWeek Year
其中每个域都可出现的字符如下。
◎ Minutes:可出现“,”“-”“*”“/”这4个字符,有效范围为0~59的整数。
◎ Hours:可出现“,”“-”“*”“/”这4个字符,有效范围为0~23的整数。
◎ DayofMonth:可出现“,”“-”“*”“/”“?”“L”“W”“C”这8个字符,有效范围为0~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表达式如下:
*/1 * * * *
掌握这些基本知识后,就可以编写一个Cron Job的配置文件了:
cron.yaml apiVersion: batch/v1 kind: CronJob metadata: name: hello spec: schedule: "*/1 * * * *" jobTemplate: spec: template: spec: containers: - name: hello image: busybox args: - /bin/sh - -c - date; echo Hello from the Kubernetes cluster restartPolicy: OnFailure
该例子定义了一个名为hello的Cron Job,任务每隔1min执行一次,运行的镜像是busybox,执行的命令是Shell脚本,脚本执行时会在控制台输出当前时间和字符串“ Hello from the Kubernetes cluster”。
接下来执行kubectl create命令完成创建:
# kubectl create -f cron.yaml cronjob "hello" created
然后每隔1min执行kubectl get cronjob hello查看任务状态,发现的确每分钟调度了一次:
# kubectl get cronjob hello NAME SCHEDULE SUSPEND ACTIVE LAST-SCHEDULE hello */1 * * * * False 0 Thu, 29 Jun 2017 11:32:00-0700 ...... # kubectl get cronjob hello NAME SCHEDULE SUSPEND ACTIVE LAST-SCHEDULE hello */1 * * * * False 0 Thu, 29 Jun 2017 11:33:00-0700 ...... # kubectl get cronjob hello NAME SCHEDULE SUSPEND ACTIVE LAST-SCHEDULE hello */1 * * * * False 0 Thu, 29 Jun 2017 11:34:00-0700
还可以通过查找Cron Job对应的容器,验证每隔1min产生一个容器的事实,如下所示:
# docker ps -a | grep busybox 83f7b86728ea busybox@sha256:be3c11fdba7cfe299214e46edc642e09514dbb9bbefcd0d3836c05a1e0cd0642 "/bin/sh -c 'date; ec" About a minute ago Exited (0) About a minute ago k8s_hello_hello-1498795860-qqwb4_default_207586cf-5d4a-11e7-86c1-000c2997487d_0 36aa3b991980 busybox@sha256:be3c11fdba7cfe299214e46edc642e09514dbb9bbefcd0d3836c05a1e0cd0642 "/bin/sh -c 'date; ec" 2 minutes ago Exited (0) 2 minutes ago k8s_hello_hello-1498795800-g92vx_default_fca21ec0-5d49-11e7-86c1-000c2997487d_0 3d762ae35172 busybox@sha256:be3c11fdba7cfe299214e46edc642e09514dbb9bbefcd0d3836c05a1e0cd0642 "/bin/sh -c 'date; ec" 3 minutes ago Exited (0) 3 minutes ago k8s_hello_hello-1498795740-3qxmd_default_d8c75d07-5d49-11e7-86c1-000c2997487d_0 8ee5eefa8cd3 busybox@sha256:be3c11fdba7cfe299214e46edc642e09514dbb9bbefcd0d3836c05a1e0cd0642 "/bin/sh -c 'date; ec" 4 minutes ago Exited (0) 4 minutes ago k8s_hello_hello-1498795680-mgb7h_default_b4f7aec5-5d49-11e7-86c1-000c2997487d_0
查看任意一个容器的日志,结果如下:
# docker logs 83f7b86728ea Thu Jun 29 18:33:07 UTC 2017 Hello from the Kubernetes cluster
运行下面的命令,可以更直观地了解Cron Job定期触发任务执行的历史和现状:
# kubectl get jobs --watch NAME DESIRED SUCCESSFUL AGE hello-1498761060 1 1 31m hello-1498761120 1 1 30m hello-1498761180 1 1 29m hello-1498761240 1 1 28m hello-1498761300 1 1 27m hello-1498761360 1 1 26m hello-1498761420 1 1 25m
其中SUCCESSFUL列为1的每一行都是一个调度成功的Job,以第1行的“hello-1498761060”的Job为例,它对应的Pod可以通过下面的方式得到:
# kubectl get pods --show-all | grep hello-1498761060 hello-1498761060-shpwx 0/1 Completed 0 39m
查看该Pod的日志:
# kubectl logs hello-1498761060-shpwx Thu Jun 29 18:31:07 UTC 2017 Hello from the Kubernetes cluster
最后,当不需要某个Cron Job时,可以通过下面的命令删除它:
# kubectl delete cronjob hello cronjob "hello" deleted
在Kubernetes 1.9版本后,kubectrl命令增加了别名cj来表示cronjob,同时kubectl set image/env命令也可以作用在CronJob对象上了。
3.9.10 自定义调度器
如果Kubernetes调度器的众多特性还无法满足我们的独特调度需求,则还可以用自己开发的调度器进行调度。从1.6版本开始,Kubernetes的多调度器特性也进入了快速发展阶段。
一般情况下,每个新Pod都会由默认的调度器进行调度。但是如果在Pod中提供了自定义的调度器名称,那么默认的调度器会忽略该Pod,转由指定的调度器完成Pod的调度。
在下面的例子中为Pod指定了一个名为my-scheduler的自定义调度器:
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
app: nginx
spec:
schedulerName: my-scheduler
containers:
- name: nginx
image: nginx
如果自定义的调度器还未在系统中部署,则默认的调度器会忽略这个Pod,这个Pod将会永远处于Pending状态。
下面看看如何创建一个自定义的调度器。
可以用任何语言来实现简单或复杂的自定义调度器。下面的简单例子使用Bash脚本进行实现,调度策略为随机选择一个Node(注意,这个调度器需要通过kubectl proxy来运行):
#!/bin/bash SERVER='localhost:8001' while true; do for PODNAME in $(kubectl --server $SERVER get pods -o json | jq '.items[] | select(.spec.schedulerName == "my-scheduler") | select(.spec.nodeName == null) | .metadata.name' | tr -d '"'); do NODES=($(kubectl --server $SERVER get nodes -o json | jq '.items[].metadata.name' | tr -d '"')) NUMNODES=${#NODES[@]} CHOSEN=${NODES[$[ $RANDOM % $NUMNODES ]]} curl --header "Content-Type:application/json" --request POST --data '{"apiVersion":"v1", "kind": "Binding", "metadata": {"name": "'$PODNAME'"}, "target": {"apiVersion": "v1", "kind": "Node", "name":"'$CHOSEN'"}}' http://$SERVER/api/v1/namespaces/default/pods/$PODNAME/binding/ echo "Assigned $PODNAME to $CHOSEN" done sleep 1 done
一旦这个自定义调度器成功启动,前面的Pod就会被正确调度到某个Node上。