课程信息 k8s技能图谱 Kubernetes项⽬与基础设施“⺠主化”的探索
Docker 一举走红的重要原因
例:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
selector:
matchLabels:
app: nginx
replicas: 2
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80
volumeMounts:
- mountPath: "/usr/share/nginx/html"
name: nginx-vol
volumes:
- name: nginx-vol
emptyDir: {}
Deployment 是一个定义多副本应用的对象,还负责在 Pod 定义发生变化时,对每个副本进行滚动更新
Labels 是一组 key-value 格式的标签。而像 Deployment 这样的控制器对象,就可以通过这个 Labels 字段从 Kubernetes 中过滤出它所关心的被控制对象
Volume 属于 Pod 对象的一部分
$ kubectl create -f 我的配置文件
$ kubectl get:从 Kubernetes 里面获取(GET)指定的 API 对象
$ kubectl describe:查看API对象细节
$ kubectl apply:进行 Kubernetes 对象的创建和更新操作
$ kubectl delete
控制循环(control loop):也被称作“Reconcile Loop”(调谐循环)或“Sync Loop”(同步循环)
控制器是由控制器定义(包括期望状态),加上被控制对象的模板(template)组成的
Deployment实现 Deployment 是一个两层控制器。它通过ReplicaSet 的个数来描述应用的版本,通过ReplicaSet 的属性保证 Pod 的副本数量。Deployment 控制器实际操纵的正是ReplicaSet 对象,而不是 Pod 对象。
Kubernetes deployment strategies
Kubernetes对 有状态应用(Stateful Application) 编排功能的支持,就是StatefulSet。 StatefulSet 的核心功能,就是通过某种方式记录这些状态,然后在 Pod 被重新创建时,能够为新 Pod 恢复这些状态。它把应用状态抽象成两种情况:
拓扑状态 Service 是 Kubernetes 项目中用来将一组 Pod 暴露给外界访问的一种机制。访问Service的方式有:
...svc.cluster.local
有了这个“可解析身份”,只需知道 Pod 的名字和它对应 Service 的名字,就可以访问到 Pod 的 IP 地址(Pod 的 DNS 记录本身不会变,但它解析到的 Pod 的 IP 地址并不是固定的)。StatefulSet 通过给它所管理的所有 Pod 进行编号(Pod 的“名字 + 编号”),严格按照编号顺序进行创建。
存储状态 PV/PVC:Kubernetes 中 PVC(Persistent Volume Claim) 和 PV(Persistent Volume) 的设计,实际上类似于“接口”和“实现”的思想。开发者只要知道并会使用 PVC ,而运维人员则负责给 PVC 绑定具体的实现,即 PV。 即使 Pod 被删除,它所对应的 PVC 和 PV 依然会保留下来。
StatefulSet 的工作原理:
实践
一个“主从复制”(Maser-Slave Replication)的 MySQL 集群
DaemonSet 的主要作用,是在 Kubernetes 集群里运行一个 Daemon Pod(比如网络、存储、日志等插件的Agent)。
在DaemonSet 的控制循环中,只需要遍历Etcd所有节点,然后根据节点上是否有被管理 Pod 的情况,来决定是否要创建或者删除一个 Pod。
DaemonSet 只管理 Pod 对象,然后通过 nodeAffinity 和 Toleration 保证了每个节点上有且只有一个 Pod:
DaemonSet 使用 ControllerRevision来保存和管理自己的“版本”(StatefulSet也是)。
注:v1.11 之前版本DaemonSet 所管理的 Pod 的调度过程,实际上都是由 DaemonSet Controller 自己,而不是由调度器完成的。这种方式很快会被废除
Deployment、StatefulSet,以及 DaemonSet 主要编排的对象,都是“在线业务”,即 Long Running Task(长作业)。比如 Nginx、Tomcat,以及 MySQL 等等。 离线业务,或叫 Batch Job(计算业务)。这种业务在计算完成后就直接退出了。
Job 对象在创建后,它的 Pod 模板被自动加上了一个 controller-uid=< 一个随机字符串 > 这样的 Label。而这个 Job 对象本身,则被自动加上了这个 Label 对应的 Selector,从而保证了 Job 与它所管理的 Pod 之间的匹配关系。
restartPolicy 在 Job 对象里只允许被设置为 Never(失败后创建新Pod)和 OnFailure(失败后重启Pod里的容器);而在 Deployment 对象里,restartPolicy 则只允许被设置为 Always。
Job Controller 直接管理Pod。它控制了作业执行的并行度,以及总共需要完成的任务数这两个重要参数。在 Job 对象中,负责并行控制的参数有两个:
使用 Job 对象的方法(大多数情况下用户更倾向于自己控制 Job 对象):
CronJob(定时任务)是一个专门用来管理 Job 对象的控制器。它创建和删除 Job 的依据,是 schedule 字段定义的“Unix Cron”表达式。
声明式 API,才是 Kubernetes 项目编排能力“赖以生存”的核心所在。Kubernetes“声明式 API”的独特之处:
kubectl apply 执行一个对原有 API 对象的 PATCH 操作,一次能处理多个写操作,并且具备 Merge 能力。
Istio 最根本的组件,是运行在每一个应用 Pod 里的 Envoy 容器(以 sidecar 容器的方式)。 Envoy 容器就能够通过配置 Pod 里的 iptables 规则,把整个 Pod 的进出流量接管下来。这时候,Istio 的控制层(Control Plane)里的 Pilot 组件,就能够通过调用每个 Envoy 容器的 API,对这个 Envoy 代理进行配置,从而实现微服务治理。
Istio 使用“热插拔”式的 Dynamic Admission Control(也叫 Initializer)功能,实现在应用 Pod YAML 被提交给 Kubernetes 之后,在它对应的 API 对象里自动加上 Envoy 容器的配置(Admission Controller 的代码可以选择性地被编译进 APIServer 中,在 API 对象创建之后会被立刻调用到)。
Istio 要做的,就是编写一个用来为 Pod“自动注入”Envoy 容器的 Initializer:
声明式API工作原理 在 Kubernetes 项目中,一个 API 对象在 Etcd 里的完整资源路径,是由:Group(Group 的分类是以对象功能为依据的,核心 API 对象不需要 Group)、Version(apiVersion后半段)和 Resource(kind字段)三个部分组成的。
CRD(Custom Resource Definition)机制
自定义对象包括两部分内容(代码示例):
Kubernetes Deep Dive: Code Generation for CustomResources, 翻译
Informer:带有本地缓存 Store 和索引 Index 机制的、可以注册 EventHandler 的 client。职责:
Informer 使用了 Reflector 包,它是一个可以通过 ListAndWatch 机制获取并监视 API 对象变化的客户端封装。
Reflector 和 Informer 之间,用到了一个“增量先进先出队列”进行协同。而 Informer 与你要编写的控制循环之间,则使用了一个工作队列来进行协同。
基本概念
Role 和 RoleBinding 对象都是 Namespaced 对象(Namespaced Object),它们对权限的限制规则仅在它们自己的 Namespace 内有效,roleRef 也只能引用当前 Namespace 里的 Role 对象。 对于非 Namespaced对象(比如:Node),或者某一个 Role 想要作用于所有的 Namespace 的时候,可以使用 ClusterRole 和 ClusterRoleBinding 这两个组合。 在 Kubernetes 中已经内置了很多个为系统保留的 ClusterRole,它们的名字都以 system: 开头。一般来说,这些系统 ClusterRole是绑定给 Kubernetes 系统组件对应的 ServiceAccount 使用的。 除此之外,Kubernetes 还提供了四个预先定义好的 ClusterRole 来供用户直接使用:cluster-admin、admin、edit、view。
Role对象rules字段定义权限规则。 RoleBinding对象“subjects”字段即“被作用者”;“roleRef”字段通过名字来引用前面定义的 Role 对象,从而定义了 Subject 和 Role 之间的绑定关系。
利用 Kubernetes 的自定义 API 资源(CRD),来描述我们想要部署的“有状态应用”;然后在自定义控制器里,根据自定义 API 对象的变化,来完成具体的部署和运维工作。
Etcd Operator工作原理 Etcd Operator 的特殊之处在于,它为每一个 EtcdCluster 对象都启动了一个控制循环,“并发”地响应这些对象的变化。这种做法不仅可以简化 Etcd Operator 的代码实现,还有助于提高它的响应速度。
Cluster 对象具体负责:
Etcd Operator 把一个 Etcd 集群抽象成了一个具有一定“自治能力”的整体。而当这个“自治能力”本身不足以解决问题的时候,我们可以通过两个专门负责备份和恢复的 Operator 进行修正。
Operator 和 StatefulSet 并不是竞争关系。你完全可以编写一个 Operator,然后在 Operator 的控制循环里创建和控制 StatefulSet 而不是 Pod。比如prometheus-operator
etcd:从应用场景到实现原理的全方位解读
资源模型设计
可压缩资源(compressible resources):当可压缩资源不足时,Pod 只会“饥饿”,但不会退出。如CPU 不可压缩资源(uncompressible resources):当不可压缩资源不足时,Pod 就会被内核杀掉。如内存
其中,Kubernetes 里为 CPU 设置的单位是“CPU 的个数”。具体“1 个 CPU”在宿主机上如何解释,完全取决于宿主机的 CPU 实现方式。可以是 1 个 CPU 核心、 1 个 vCPU,或 1 个 CPU 的超线程(Hyperthread)。
Kubernetes 里 Pod 的 CPU 和内存资源,分为 limits 和 requests 两种情况。在调度的时候,kube-scheduler 只会按照 requests 的值进行计算。在真正设置 Cgroups 限制的时候,kubelet 则会按照 limits 的值来进行设置。
Kubernetes 用户在提交 Pod 时,可以声明一个相对较小的 requests 值供调度器使用,而 Kubernetes 真正设置给容器 Cgroups 的,则是相对较大的 limits 值。这种对 CPU 和内存资源限额的设计,参考了 Borg 论文对“动态资源边界”的定义。因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额。
QoS模型
在 Kubernetes 中,不同的 requests 和 limits 的设置方式,会将 Pod 划分到不同的 QoS 级别当中:
QoS 划分的主要应用场景,是当宿主机资源紧张的时候,kubelet 对 Pod 进行 Eviction时需要用到的。Eviction 在 Kubernetes 里其实分为 Soft 和 Hard 两种模式:
当宿主机的 Eviction 阈值达到后,就会进入 MemoryPressure 或者 DiskPressure 状态,从而避免新的 Pod 被调度到这台宿主机上。 而当 Eviction 发生的时候,kubelet 会参考这些 Pod 的 QoS 类别挑选 Pod 进行删除操作。顺序:BestEffort -> Burstable -> Guaranteed。 Kubernetes 会保证只有当 Guaranteed 类别的 Pod 的资源使用量超过了其 limits 的限制,或者宿主机本身正处于 Memory Pressure 状态时,Guaranteed 的 Pod 才可能被选中进行 Eviction 操作。对于同 QoS 类别的 Pod 来说,Kubernetes 还会根据 Pod 的优先级来进行进一步地排序和选择。
cpuset 的设置要求:Pod 必须是 Guaranteed 的 QoS 类型;Pod 的 CPU 资源的 requests 和 limits 值相等。 通过设置 cpuset 把容器绑定到某个 CPU 的核上,而不是像 cpushare 那样共享 CPU 的计算能力。由于操作系统在 CPU 之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升。
Kubernetes 默认调度器的主要职责,就是为一个新创建出来的 Pod,寻找一个最合适的节点(Node)
在具体的调度流程中,默认调度器会首先调用一组叫作 Predicate 的调度算法,来检查每个 Node。然后,再调用一组叫作 Priority 的调度算法,来给上一步得到的结果里的每个 Node 打分。最终的调度结果,就是得分最高的那个 Node。
Kubernetes 的调度器的核心,实际上就是两个相互独立的控制循环:
乐观绑定 为了不在关键调度路径里远程访问 APIServer,Kubernetes 的默认调度器在 Bind 阶段(将 Pod 对象的 nodeName 字段的值修改为选出 Node 的名字),只会更新 Scheduler Cache 里的 Pod 和 Node 的信息。这种基于“乐观”假设的 API 对象更新方式,在 Kubernetes 里被称作 Assume。 Assume 之后,调度器才会创建一个 Goroutine 来异步地向 APIServer 发起更新 Pod 的请求,来真正完成 Bind 操作。如果这次异步的 Bind 过程失败了,等 Scheduler Cache 同步之后一切就会恢复正常。 当一个新的 Pod 完成调度需要在某个节点上运行起来之前,该节点上的 kubelet 还会通过一个叫作 Admit 的操作(把一组叫作 GeneralPredicates 的、最基本的调度算法再执行一遍,作为 kubelet 端的二次确认)来再次验证该 Pod 是否确实能够运行在该节点上。
无锁化 在 Scheduling Path 上,调度器会启动多个 Goroutine 以节点为粒度并发执行 Predicates 算法,从而提高这一阶段的执行效率。而与之类似的,Priorities 算法也会以 MapReduce 的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。Kubernetes 调度器只有对调度队列和 Scheduler Cache 进行操作时,才需要加锁。而这两部分操作,都不在 Scheduling Path 的算法执行路径上。
Kubernetes 默认调度器的可扩展机制 Scheduler Framework: 这些可插拔式逻辑,都是标准的 Go plugin 机制,也就是说,你需要在编译的时候选择把哪些插件编译进去。
调度策略
Predicates 在调度过程中的作用,可以理解为 Filter,它按照调度策略,从当前集群的所有节点中,“过滤”出一系列符合条件的节点。这些节点,都是可以运行待调度 Pod 的宿主机。 默认的调度策略有:
当开始调度一个 Pod 时,Kubernetes 调度器会同时启动 16 个 Goroutine,来并发地为集群里的所有 Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。需要注意的是,在为每个 Node 执行 Predicates 时,调度器会按照固定的顺序来进行检查。这个顺序,是按照 Predicates 本身的含义来确定的。
在 Predicates 阶段完成了节点的“过滤”之后,Priorities 阶段的工作就是为这些节点打分。 常用打分规则:
在实际的执行过程中,调度器里关于集群和 Pod 的信息都已经缓存化,所以这些算法的执行过程还是比较快的。
优先级与抢占机制
优先级和抢占机制,解决的是 Pod 调度失败时该怎么办的问题。
Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过10 亿(1 billion),并且值越大代表优先级越高。而超出 10 亿的值,其实是被 Kubernetes 保留下来分配给系统 Pod 使用的。
调度器里维护着一个调度队列。当 Pod 拥有了优先级之后,高优先级的 Pod 就可能会比低优先级的 Pod 提前出队,从而尽早完成调度过程。
而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。
当抢占过程发生时,调度器只会将抢占者的 spec.nominatedNodeName 字段,设置为被抢占的 Node 的名字。然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。 把抢占者交给下一个调度周期再处理。主要有两方面原因:被抢占节点“优雅退出”期间集群的可调度性可能会发生变化;抢占节点在等待调度过程中,允许更高优先级节点抢占。
而 Kubernetes 调度器实现抢占算法的一个最重要的设计,就是在调度队列的实现里使用了两个不同的队列:
调度失败之后,抢占者就会被放进 unschedulableQ 里面。然后,这次失败事件就会触发调度器为抢占者寻找牺牲者的流程:
调度器会检查缓存副本里的每一个节点,然后从该节点上最低优先级的 Pod 开始,逐一“删除”这些 Pod。而每删除一个低优先级 Pod,调度器都会检查一下抢占者是否能够运行在该 Node 上。一旦可以运行,调度器就记录下这个 Node 的名字和被删除 Pod 的列表,这就是一次抢占过程的结果了。当遍历完所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果。而这一步的判断原则,就是尽量减少抢占对整个系统的影响。
在得到了最佳的抢占结果之后,调度器就可以真正开始抢占的操作:
在为某一对 Pod 和 Node 执行 Predicates 算法的时候,如果待检查的 Node 是一个即将被抢占的节点。那么调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍:
只有这两遍 Predicates 算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定(bind)的。
Device Plugin机制
以 NVIDIA 的 GPU 设备为例,当用户的容器被创建之后,容器里必须出现如下两部分:
kubelet 将上述两部分内容设置在了创建该容器的 CRI参数里面。这样,等到该容器启动之后,对应的容器里就会出现 GPU 设备和驱动的路径了。
Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段,而是使用了一种叫作 Extended Resource(ER)的特殊字段来负责传递 GPU 的信息。 在 Kubernetes 中,对所有硬件加速设备进行管理的功能,都是由一种叫作 Device Plugin 的插件来负责的。其中也就包括了对该硬件的 Extended Resource 进行汇报的逻辑。
Kubernetes 的 Device Plugin 机制: 对于每一种硬件设备,都需要有它所对应的 Device Plugin (如FPGA、SRIOV、RDMA)进行管理,这些 Device Plugin,都通过 gRPC 的方式,同 kubelet 连接起来。
kubelet 会负责从它所持有的硬件设备列表中,为容器挑选一个硬件设备,然后调用 Device Plugin 的 Allocate API 来完成这个分配操作。
Kubernetes 里对硬件设备的管理,只能处理“设备个数”这唯一一种情况。一旦你的设备是异构的、不能简单地用“数目”去描述具体使用需求的时候,Device Plugin 就完全不能处理了。在很多场景下,我们其实希望在调度器进行调度的时候,就可以根据整个集群里的某种硬件设备的全局分布,做出一个最佳的调度选择。 Kubernetes 里缺乏一种能够对 Device 进行描述的 API 对象。如果你的硬件设备本身的属性比较复杂,并且 Pod 也关心这些硬件的属性的话,那么 Device Plugin 也是完全没有办法支持的。