1.3 Kubernetes 核心架构
与传统的高性能计算及虚拟化云平台类似,Kubernetes 也遵循主从结构。通常将固定规模的计算节点组成一个集群,在集群中挑选数台计算节点作为管理节点(Master),其余的计算节点作为工作节点(Minion)。针对较大规模的集群,建议将独占节点作为管理节点,而针对较小规模的集群,可以将管理节点和工作节点混用。
如图1-9 所示,管理节点和非管理节点的区别是,管理节点上运行的是控制平面组件,而工作节点运行的是用户业务。Kubernetes 还拥有各种功能插件,例如监控模块、日志模块、DNS 服务、Ingress 等,这些组件通常以用户态应用的形式存在。
图1-9 Kubernetes 系统架构
1.3.1 核心控制平面组件
控制平面组件是由集群管理员部署和维护的,用来支撑平台运行的组件,Kubernetes的主要控制平面组件包括API Server、etcd、Scheduler 和Controller Manager。通常将控制平面组件安装在多个主节点上,彼此协同工作,保证平台的高可用性。是否需要进行高可用配置,以及创建多少个高可用副本,是依据具体生产化需求而定的,具体细节会在第3章构建高可用集群中详述,本章将介绍单节点上控制平面组件的细节。
1.3.1.1 etcd
etcd 是高可用的键值对的分布式安全存储系统,用于持久化存储集群中所有的资源对象,例如集群中的Node、Service、Pod 的状态和元数据,以及配置数据等。为了持久性和高可用性,生产环境中的etcd 集群成员需分别在多个节点上运行,并定期对其进行备份。如图1-10 所示,在多个etcd 成员组成的etcd 集群中,etcd 成员间使用Raft 共识算法复制请求并达成协议。
图1-10 etcd 工作原理
对于etcd,这里有几个概念:领导者、选举和任期。任何etcd 集群成员都可以处理读请求,不需要共识。但只有领导者才能处理写请求,包括更改、新增、删除等。如图1-10所示,当来自客户端API Server 的写请求被提交到etcd 成员处时,如果它不是领导者,那么它会将此请求转移给领导者。领导者会将请求复制到集群的其他成员中进行仲裁,当超过半数成员同意更改时,领导者才将更改后的新值提交到日志wal 中,并通知集群成员进行相应的写操作,将日志中的新值写入磁盘中。
每个集群在任何给定时间都只能有一个领导者。如果领导者不再响应,那么其余成员在预定的选举计时器超时后会开始新的选举,将自己标记为候选者,并请求其他节点投票来开始新的选举。每个节点为请求其投票的第一个候选者投票。
如果候选人从集群中的大多数节点获得投票,那么它将成为新的领导者。每个节点维护的选举计时器的超时时间不同,因此第一个候选人通常会成为新的领导者。但是,如果存在多个候选人并获得相同数目的选票,则现有的选举任期将在没有领导人的情况下结束,而新任期将以新的随机选举计时器开始。因此,我们建议在部署etcd 集群时采用奇数个成员为佳。根据Raft 的工作机制,每个写请求需要集群中的每个成员做仲裁,因此我们建议etcd 集群成员数量不要超过7 个,推荐是5 个,个数越多仲裁时间会越多,写的吞吐量会越低。如果集群中的某个成员处理请求特别慢,就会让整个etcd 集群不稳定,且性能受到限制。因此,我们要实时监测每个etcd 成员的性能,及时修复或者移除性能差的成员。
1.3.1.2 API Server
API Server,也就是常说的kube-API Server。它承担API 的网关职责,是用户请求及其他系统组件与集群交互的唯一入口。所有资源的创建、更新和删除都需要通过调用API Server 的API 接口来完成。对内,API Server 是各个模块之间数据交互的通信枢纽,提供了etcd 的封装接口API,这些API 能够让其他组件监听到集群中资源对象的增、删、改的变化;对外,它充当着网关的作用,拥有完整的集群安全机制,完成客户端的身份验证(Authentication)和授权(Authorization),并对资源进行准入控制(Admission Control)。
用户可以通过kubectl 命令行或RESTful 来调用HTTP 客户端(例如curl、wget 和浏览器)并与API Server 通信。通常,API Server 的HTTPS 安全端口默认为6443(可以通过--secure-port 参数指定),HTTP 非安全端口(可以通过--insecure-port 参数指定,默认值为8080)在新版本中已经被弃用。Kubernetes 集群可以是包含几个节点的小集群,也可以扩展到成千节点的规模。作为集群的 “大脑”,API Server 的高可用性是至关重要的。
如图1-11 所示,一个集群允许有多个API Server 的实例。API Server 本身是无状态的,可以横向扩展。借助Haproxy 或负载均衡器就能非常容易地让他们协同工作。在图1-11中,我们通过10.2.1.4:443 或者API Server.cluster.example.io:443,就能访问到集群中的某个 API Server。具体是哪个实例,根据负载均衡器的转发策略(例如 round-robin、least-connection 等)来定。不管是用Haproxy 还是负载均衡器(软件或硬件)的方式,都需要支持Health Check,以防某API Server 所在的Master 节点宕机,其上的流量能够迅速转移到其API Server 上。
图1-11 API Server 的高可用架构
另外,API Server 会在Default Namespace 下创建一个类名为Kubernetes 的Service 对象,同时负责将它自己的podIP 更新到对应的Endpoint 对象中。依托CoreDNS 插件的辅助,集群内部的Pod 就可以通过服务名访问API Server。Pod 到API Server 的流量只会在集群内部转发,而不会被转发到外部的负载均衡器上。值得注意的是,此Endpoint 对象的Subnets Addresses 数组长度有限制,由API Server 的参数--API Server-count 来指定,默认是1。也就是说,只需添加保留一个API Server 实例的IP 地址。如果集群中有多个API Server实例,需将此值设置为实际值,否则集群内部通过此Service 访问API Server 的所有流量只会转到一个API Server 实例上。
1.3.1.3 Controller Manager
控制器是Kubernetes 集群的自动化管理控制中心,里面包含30 多个控制器,有Pod管理的(Replication 控制器、Deployment 控制器等)、有网络管理的(Endpoints 控制器、Service 控制器等)、有存储相关的(Attachdetach 控制器等),等等。在1.2.5 节的例子中,我们已经见识到部分控制器是如何工作的。大多数控制器的工作模式雷同,都是通过API Server 监听其相应的资源对象,根据对象的状态来决定接下来的动作,使其达到预期的状态。
很多场景都需要多个控制器协同工作,比如某个节点宕机,kubelet 将会停止汇报状态到Node 对象。NodeLifecycle 控制器会发现节点状态没有按时更新,超过一段时间(可通过参数--pod-eviction-timeout 来指定)后,它将驱逐节点上的Pod。如果这个Pod 属于某个Deployment 对象,那么Deployment 对象所需的副本数量将减少,这时Deployment 控制器将会补齐Pod 副本数量,替换掉因为宕机而被删除的Pod。
控制器采用主备模式和Leader Election 机制来实现故障转移(Fail Over),如图1-12所示,也就是说允许多个副本处于运行状态,但是只有一个副本作为领导者在工作,其他副本作为竞争者则不断尝试获取锁,试图通过竞争成为领导者。一旦领导者无法继续工作,其他竞争者就能立刻竞争上岗,而无须等待较长的创建时间。在Kubernetes 中,锁就是一个资源对象,目前支持的资源是 Endpoint 和 Configmap。控制器的锁在 kube-system Namespace 下名为kube-controller-manager 的Endpoint 对象中。
图1-12 Leader Election 的工作机制
Leader Election 有三个与时间相关的参数:leaseDuration、renewDeadline 和retryPeriod。第一个参数leaseDuration 是指资源锁定后的租约时间,竞争者在该时间间隔内不能锁定资源,如果领导者在这段时间间隔后没有更新锁时间,则竞争者可以认为领导者已经挂掉,不能正常工作了,将重新选举领导者。
第二个参数renewDeadline 是指,领导者主动放弃锁,当它在renewDeadline 内没有成功地更新锁,它将释放锁。当然如果更新锁无法成功地执行,那么释放锁大概率也无法成功地执行,所以在Kubernetes 中这种情况很少见。
第三个参数retryPeriod 是指竞争者获取锁和领导者更新锁的时间间隔。这种Leader Election 机制保证了集群组件的高可用性,如果领导者因为某种原因无法继续提供服务,则由其他竞争者副本竞争成为新的领导者,继续执行业务逻辑。
1.3.1.4 Scheduler
集群中的调度器负责Pod 在集群节点中的调度分配。我们常说Kubernetes 是一个强大的编排工具,能够提高每台机器的资源利用率,将压力分摊到各个机器上,这主要归功于调度器。调度器是拓扑和负载感知的,通过调整单个和集体的资源需求、服务质量需求、硬件和软件的策略约束、亲和力和反亲和力规范、数据位置、工作负载间的干扰、期限等,来提升集群的可用性、性能和容量。
与控制器类似,调度器也是采用 Leader Election 的主备模式,通过 kube-system Namespace 下名为kube-scheduler 的Endpoint 对象进行领导者仲裁。调度器监听 API Server 处Pod 的变化,当新的Pod 被创建后,如果其Pod 的spec.nodeName 为空,就会根据这个Pod 的Resouces、Affinity 和Anti-Affinity 等约束条件和Node 的实时状态等为该Pod 选择最优节点,然后更新节点名字到Pod 的spec.NodeName 字段。接下来后续的工作就由节点上的kubelet 接管了。
调度器调度Pod 的过程可以分为两个阶段:调度周期(Scheduling Cycle)阶段和绑定周期(Binding Cycle)阶段。调度周期阶段是为Pod 选择最优节点的过程,绑定周期阶段是通知API Server 这个决定的过程。调度周期阶段是串行运行的,绑定周期阶段是可以并行运行的。如图1-13 所示,目前调度器采用插件式的框架,使用户定制更加方便,向框架内的插件扩展点添加自定义的插件组即可。调度器插件只需实现一组API 并编译到调度器中,通过配置文件来决定 “使能” 还是 “禁止”。默认情况下的default-scheduler 是没有扩展这些插件的。如图1-13 所示,Filter 就相当于我们平常所说的Predicate 的功能,Scoring相当于Priority 的功能。
图1-13 调度器的插件式框架
Predicate 的功能可以理解为硬性条件(Hard Constraits)预选,将所有不能运行Pod的节点排除出去。如果在这个过程中任何一个策略将节点标记成 “不可用”,那么接下来的策略也就不会再考虑这个节点。Predicate 完成后,我们最终会得到一个候选节点列表。如果候选节点列表为空,那么Pod 暂时无法安排,调度器会将其再次放回队列中。下面是调度器可选的预选策略:
● PodFitsHostPorts:判断Pod 所要求的端口是否在节点中被占用。
● PodFitsHost:判断节点是否是Pod 的spec.nodeName 指定的节点。
● PodFitsResources:判断节点是否能够满足Pod 中申请的Resources(例如CPU、Memory)的要求。
● PodMatchNodeSelector:判断节点是否满足Pod 的spec.nodeSelector 限制。
● NoVolumeZoneConflict:在给定存储卷的 Failure Zone 的限制下,评估 Pod 的spec.volume 申请的存储卷是否在这个节点可用。
● NoDiskConflict:判断Pod 的Volumes 和该节点上已挂载的磁盘是否有冲突。
● MaxCSIVolumeCount:判断节点上挂载的CSI Volumes 是否超出最大值。
● CheckNodeMemoryPressure:判断节点是否已经在汇报有内存压力。
● CheckNodePIDPressure:判断节点是否已经在汇报PID 即将耗尽。
● CheckNodeDiskPressure:判断节点是否已经在汇报有存储压力(系统磁盘满了或者接近满了)。
● CheckNodeCondition:根据节点的status.conditions 判断节点状态,如果节点网络不可用、Kubelet 的状态不是Ready 的,等等,那么这个节点是不适合运行Pod 的。
● PodToleratesNodeTaints:判断Pod 上的Toleration 是否能满足节点上的Taints。
● CheckVolumeBinding:检查节点是否能满足Pod 所有的Volume 请求,包括bound和unbound 的PVCs。
Priority 的功能,可以理解为软性条件(Soft Contraints)优选,根据各个策略对可行节点列表中的每个节点打分,最终总得分最高的节点就是我们所说的最优节点。如果多个节点都是相同的分数,它将会在它们之中任选一个。
● SelectorSpreadPriority:尽量将相同的Service、StatefulSet 或者ReplicaSet 的Pod分布在不同节点。
● InterPodAffinityPriority:遍历weighted 的PodAffinityTerm 的元素,如果节点满足相应的PodAffinityTerm 条件,则总和加上该条件的 “权重”,再计算总和。总和越高的节点,分数越高。
● LeastRequestedPriority:节点上的已有Pod 所申请的资源总数越少,节点得分越高。这个策略能使负载在各个节点上更均衡。
● MostRequestedPriority:节点上的已有Pod 申请的资源总数越多,节点得分越高。这个策略能使Pod 调度到小规模的节点上。
● RequestedToCapacityRatioPriority:资源利用率(requested/capacity)越低,节点得分越高。
● BalancedResourceAllocation:各项资源使用率越均衡,节点得分就越高。
● NodePreferAvoidPodsPriority:如果节点的Annotation scheduler.alpha.kubernetes.io/preferAvoidPods 没有显示指定规避此Pod,则节点得分高。
● NodeAffinityPriority:满足Pod 的PreferredDuringSchedulingIgnoredDuringExecution条件的节点得分高。
● TaintTolerationPriority:Pod 不满足节点上的Taints 的数量越少,节点得分越高。
● ImageLocalityPriority:如果已经有了Pod 所需的容器镜像的节点,则得分相对高。
● ServiceSpreadingPriority:保障Service 后端的Pod 运行在不同节点上。对于Service服务来说,更能容忍单节点故障。
● CalculateAntiAffinityPriorityMap:尽量使属于同一Service 的Pod 在某个节点上的数量最少。
● EqualPriorityMap: 所有节点都具有相同的权重。
由于调度器在整个系统中承担着 “承上启下” 的重要功能,所以调度器的性能也就容易成为系统的瓶颈。在Kubernetes 1.12 之前,调度器做Predicate 时都是检查所有的节点的。1.12 版本添加了一个新的特性,允许调度器在发现一定数量的候选节点后,暂时停止寻找更多的候选节点, 这会提高调度器在大集群中的性能。 这个参数由percentOfNodesToScore 的配置选项控制,范围在1 到100 之间。0 表示未设置此选项。如果候选节点总体数量小于50 个,这个参数也是无效的。在1.14 版本中,如果没有指定参数,调度器会根据集群的大小找到合适的节点百分比。它使用一个线性公式,对于一个100节点的集群,该值为50%。对于一个具有5000 节点的集群,该值为10%。这个值的下限是5%。换句话说,除非用户为此选项提供的值小于5,否则调度程序始终会为至少5%的集群节点进行评分。
1.3.2 工作节点控制平面组件
工作节点是Kubernetes 集群的负责运行用户容器的载体。它可以是虚拟机,也可以是物理机。初期,社区主推虚拟机方案是为了跟虚拟云平台共存。现在随着容器技术的不断成熟,社区为了降低负载度,开始抽离虚拟化层,因此工作节点的选择方案大趋势是物理机。一个新的Node 加入集群是非常容易的,在节点上安装kubelet、kube-proxy、容器运行时和网络插件服务,然后将kubelet 和kube-proxy 的启动参数中的API Server URL 指向目标集群的API Server 即可。API Server 在接受kubelet 的注册后,会自动将此节点纳入当前集群的调度范围,这样Pod 就能调度该节点了。
1.3.2.1 kubelet
kubelet 是运行在每个节点上的负责启动容器的重要的守护进程。在启动时,Kubelet进程加载配置参数,向API Server 处创建一个Node 对象来注册自身的节点信息,例如操作系统、Kernel 版本、IP 地址、总容量(Capacity)和可供分配的容量(Allocatable Capacity)等。然后kubelet 须定时(默认值是每10s 通过NodeStatusUpdateFrequency 设置参数)向API Server 汇报自身情况,例如磁盘空间是否用满、CPU 和Memory 是否有压力,自身服务是否Ready 等,这些信息都将被调度器使用,在调度Pod 时给节点打分。如果kubelet停止汇报这些信息,那么NodeLifecycle 控制器将认为kubelet 已经不能正常工作,会将Node 状态设置为Unknown,并在一段时间后开始驱逐其上的Pod 对象。
节点上的Pod 来源有两个:普通Pod 和静态Pod(Static Pod)。普通Pod,也就是通过API Server 创建的Pod,是被Scheduler 调度到该节点上的。静态Pod 是不经过API Server的,kubelet 通过观测本地目录或者HTTP URL 下的定义文件所创建的Pod。静态Pod 始终绑定到kubelet 所在的节点上。
kubelet 会自动尝试在API Server 上为每个静态Pod 创建一个镜像Pod(Mirror Pod)。这意味着在节点上运行的静态Pod 在API Server 上可见,但不能从那里进行控制。本地目录可以通过配置参数staticPodPath 来指定。HTTP URL 通过配置参数staticPodURL 来指定。在这个目录和URL 下的文件的所有更新都可以被kubelet 监测到,周期默认是20s,可以通过配置参数FileCheckFrequency 和HTTPCheckFrequency 来指定。
当Pod 被调度到kubelet 所在的节点上时,kubelet 首先将Pod 中申请的Volume 挂载到当前节点上。当Volume 挂载完毕后,kubelet 才会调用容器运行时为Pod 创建容器沙箱(PodSandbox)和容器。kubelet 也会周期性地查询容器的状态,并定期汇报容器状态,通过cAdvisor 监控容器资源的使用情况。
容器沙箱是 “pause” 容器的抽象概念,有时也称为infra 容器,与用户容器 “捆绑” 运行在同一个 Pod 中,共享 CGroup、Namespace 等资源,与其他 Pod 资源隔离。在PodSandbox 中运行一个非常简单的pause 进程,它不执行任何功能,一启动就永远把自己阻塞住了(pause 系统调用)。容器沙箱最大的作用是维护 Pod 网络协议栈。
在创建容器之前,kubelet 首先会调用容器运行时为该Pod 创建容器沙箱,容器运行时为容器沙箱设置网络环境。当容器沙箱成功启动后,kubelet 才会调用容器运行时在该容器沙箱的网络命名空间(Net Namespace)中创建和启动容器。用户的容器可能因为各种原因退出,但是因为有容器沙箱存在,容器的网络命名空间不会被摧毁,当重新创建用户容器时,无须再为它设置网络了。
可以看出,kubelet 并不是直接进行容器操作的,如图1-14 所示。它都是通过容器运行时的接口(Container Runtime Interface,CRI)调用容器运行时对容器和镜像进行操作的,例如创建、启动、停止和删除容器,下载镜像等。容器运行时的选用,这里有多条路可选:使用内置的dockershim 和远端的容器运行时等。目前默认情况下,kubelet 是通过内置的dockershim 调用 Docker 来完成容器操作的。我们也可以指定 remote 模式(通过参数--container-runtime 来指定),使用外部的遵循CRI 的容器运行时。虽然kubelet 不直接参与容器的创建与运行,但是它是管理和监控该节点上Pod 及Pod 中容器 “生老病死” 的核心。
图1-14 kubelet 的组织架构
如图1-15 所示,kubelet 的核心函数是syncLoop。此函数是由事件驱动的。kubelet 会从API Server 的静态Pod 的本地目录和HTTP URL 处监听到Pod 资源对象的变化,产生新增、更改、删除事件。kubelet 还会启动一个PLEG(Pod Lifecycle Event Generator)线程,每秒钟重新查询一次容器运行时容器的状态,更新Pod 的缓存,并根据容器的状态产生同步的事件。
图1-15 kubelet 管理Pod 的核心流程
kubelet 的syncLoop 函数将Pod 对象及容器状态的变化产生的4 类UpdatePodOptions( SyncPodUpdate、 SyncPodCreate、 SyncPodKill 和 SyncPodSync) 分发给 Pod 对应的PodWorker 进行处理。每个Pod 都有一个PodWorker。PodWorker 会根据UpdatePodOptions的类型调用相关容器运行时接口进行相关操作。对于SyncPodUpdate 和SyncPodSync 类型的 UpdatePodOptions , PodWorker 会事先根据当前 Pod 的 Spec 和 Status 调用computePodActions 函数计算下一步操作,例如需要停止的容器有哪些,是否需要删除容器沙箱等。kubelet 就是通过这样的 “闭环反馈” 控制Pod 的Status 及其Spec 最终达到一致的。
从图1-15 也可以看出,除管理Pod 外,kubelet 还有很多其他功能:
● 对容器的Liveness 和Readiness 进行检测。Liveness 用来探测容器是否处于 “存活状态”,如果kubelet 检测容器当前处于 “死亡状态”,则kubelet 会停止此容器,并重新创建新的容器。Readiness 用来探测容器中的用户进程是否处于 “可服务状态”,如果kubelet 检测容器当前处于 “不可服务状态”,则kubelet 不会重启容器,但会把Pod 中的容器状态更新为ContainersReady=false。这对Service 的高可用而言非常重要。如果Pod 的容器处于 “不可服务状态”,Endpoint 控制器就会将该Pod 的IP 地址从Endpoint 中移除,该Pod 将不能再接收任何用户请求。
● 保护节点不被容器抢占所有资源。如果镜像占用磁盘空间的比例超过高水位(默认值为90%,可以通过参数ImageGCHighThresholdPercent 进行配置),kubelet 就会清理不用的镜像。当节点CPU、Memory 或磁盘少于某特定值或者比例(由参数EvictionHard 配置)时,kubelet 就会驱逐低优先级的Pod(例如BestEffort 的Pod)。通过这些操作,保障节点上已有的Pod 能够在保证的QoS(Quality of Service)下继续正常运行。
处理Master 节点下发到本节点的任务,比如exec、logs 和attach 等请求。API Server是无法完成这些工作的,此时API Server 需要向kubelet 发起请求,让kubelet 完成此类请求处理。
1.3.2.2 kube-proxy
kube-proxy 也是在每个节点上都运行的。它是实现Kubernetes Service 机制的重要组件。毫无意外,kube-proxy 也是一个“控制器”。它也从API Server 监听Service 和Endpoint对象的变化,并根据Endpoint 对象的信息设置Service 到后端Pod 的路由,维护网络规则,执行TCP、UDP 和SCTP 流转发。如图1-16 所示,标签app=example 的Pod 都是此Service的后端Pod,他们的Pod IP 将会被Endpoint 控制器实时更新到Endpoint 对象中。此Serivce被分配的ClusterIP 为192.168.232.109,nodePort 为30004。Pod 的8080 端口映射到Service的80 端口。也就是说,在集群内部通过192.168.232.109:80 就能访问此Service 的后端Pod 8080 端口提供的服务;集群外的主机可以通过nodeIP:30004 来访问此Service。
图1-16 Service 和Pod 的关系
kube-proxy 有两种模式都可以实现流量转发,分别是iptables 模式和IPVS(IP Virtual Server)模式(可以通过参数--proxy-mode 来指定)。默认是iptables 模式,该模式是通过每个节点上的iptables 规则来实现的。我们可以通过iptables 命令查看相关的iptables rules:
从上面iptables rules 的代码片段来看,在集群内,用ClusterIP: 192.168.232.109(规则②)或 nodePort 30004(规则①)访问 Service 时,会被跳转到 Chain KUBE-SVC- BBVI5ZF6XS3KVW42。对于Chain KUBE-SVC-BBVI5ZF6XS3KVW42,它有三条可以跳转的路径(规则③④⑤)。当我们查询到规则③时,它将有33.33%的概率命中,并跳转到KUBE-SEP-RXBFMC7CATPNMAHP。如果规则③未命中,则接下来我们考虑规则④,它将有50%的概率进入Chain KUBE-SEP-CCTNN4A277RJLBDD。如果此条仍没有命中,就会进入Chain KUBE-SEP-HJGBTFNTDVVP5Q3I。因此分别进入这三个Chain 的概率是一样的,kube-proxy 也是利用iptables 的这一特性实现流量的负载均衡。
随着Service 数量的增大,iptables 模式由于线性查找匹配、全量更新等特点,其性能会显著下降。从Kubernetes 的1.8 版本开始,kube-proxy 引入了IPVS 模式,IPVS 与iptables同样基于netfilter,但是采用的是哈希表而且运行在内核态,当Service 数量达到一定规模时,哈希表的查询速度优势就会显现出来,从而提高Service 的服务性能。我们可以通过ipvsadm 命令查看IPVS 模式下的转发规则:
1.3.2.3 容器运行时
容器运行时是真正删除和管理容器的组件。容器运行时可以分为高层运行时和底层运行时。高层运行时主要包括Docker、Containerd 和Cri-o,底层运行时包含运行时runc、kata 及gVisor。底层运行时kata 和gVisor 都还处于小规模落地或实验阶段,其生态成熟度和使用案例都比较欠缺,所以除非有特殊的需求,否则runc 几乎是必然的选择。因此,在对容器运行时的选择上,主要聚焦于上层运行时的选择。
Docker 是Kubernetes 支持的第一个容器运行时,kubelet 通过内嵌的DockerShim 操作Docker API 来操作容器,进而达到一个面向终态的效果。在这之后,又出现了一种新的容器运行时——rkt,它也想要成为 Kubernetes 支持的一个容器运行时,当时它也合到了Kubelet 的代码之中。这两个容器运行时的加入使得Kubernetes 的代码越来越复杂、难以维护。因此在1.5 版本后,Kubernetes 推出了CRI(Container Runtime Interface)接口,把容器运行时的操作抽象出一组接口,如图1-17 所示。Kubelet 能够通过CRI 接口对容器、沙盒及容器镜像进行操作。
图1-17 kubelet 和容器运行时的关系
Docker 是Kubernetes 一直默认支持的运行时,也是目前使用最广泛的运行时。Docker内部关于容器运行时功能的核心组件是Containerd,后来Containerd 也可以直接与kubelet通过CRI 对接,独立在Kubernetes 中使用。相对Docker 而言,Containerd 减少了Docker所需的处理模块Dockerd 和Docker-shim,并且对Docker 支持的存储驱动进行了优化,因此在容器的创建、启动、停止、删除,以及对镜像的拉取上,都具有性能上的优势。架构的简化同时也带来了维护的便利。当然Docker 也具有很多Containerd 不具有的功能,例如支持zfs 存储驱动,支持对日志的大小和文件限制,在以overlayfs2 做存储驱动的情况下,可以通过xfs_quota 来对容器的可写层进行大小限制等。尽管如此,Containerd 目前也基本上能够满足容器的众多管理需求,因此将它作为运行时的Kubernetes 也越来越多。
容器技术的细节会在第2 章深入讲解。
1.3.2.4 网络插件
Kubernetes 网络模型设计的基础原则是:
● 所有的Pod 能够不通过NAT 就能相互访问。
● 所有的节点能够不通过NAT 就能相互访问。
● 容器内看见的IP 地址和外部组件看到的容器IP 地址是一样的。
在Kubernetes 的集群里,IP 地址是以Pod 为单位进行分配的,每个Pod 都拥有一个独立的IP 地址。一个Pod 内部的所有容器共享一个网络栈,即宿主机上的一个网络命名空间,包括它们的IP 地址、网络设备、配置等都是共享的。也就是说,Pod 里面的所有容器能通过localhost:port 来连接对方。在Kubernetes 中,提供了一个轻量的通用容器网络接口CNI(Container Network Interface),专门用于设置和删除容器的网络连通性。容器运行时通过CNI 调用网络插件来完成容器的网络设置。
如图1-18 所示,容器运行时在启动时会从CNI 的配置目录中读取Json 格式的配置文件,文件后缀为 “.conf”“.conflist”“.json”。如果配置目录中包含多个文件,那么通常会按照名字排序,选用第一个配置文件作为默认的网络配置,并加载获取其中指定的 CNI插件名称和配置参数。一个配置文件中可以指定多个插件,容器运行时会保存这些网络插件到一个 “待执行插件” 的列表中。当需要为容器添加或者删除网络时,容器运行时会在CNI 的可执行目录中找到这些插件的可执行文件并逐一执行。CNI 插件通过参数传入网络设置操作命令(ADD 或DEL)、容器的ID 及被分配的网络命名空间等信息。
图1-18 容器网络的配置流程
关于容器网络管理,容器运行时一般需要配置两个参数--cni-bin-dir 和--cni-conf-dir。有一种特殊情况,kubelet 内置的Docker 作为容器运行时,是由kubelet 来查找CNI 插件的,通过运行插件来为容器设置网络,这两个参数应该配置在kubelet 处:
● cni-bin-dir:网络插件的可执行文件所在目录。默认是/opt/cni/bin。
● cni-conf-dir:网络插件的配置文件所在目录。默认是/etc/cni/net.d。
CNI 设计的时候要考虑以下几方面内容:
● 容器运行时必须在调用任何插件之前为容器创建一个新的网络命名空间。
● 容器运行时必须决定这个容器属于哪些网络,针对每个网络,哪些插件必须要执行。
● 容器运行时必须加载配置文件,并确定设置网络时哪些插件必须被执行。
● 网络配置采用Json 格式,可以很容易地存储在文件中。
● 容器运行时必须按顺序执行配置文件里相应的插件。
● 在完成容器生命周期后,容器运行时必须按照与执行添加容器相反的顺序执行插件,以便将容器与网络断开连接。
● 容器运行时被同一容器调用时不能并行操作,但被不同的容器调用时,允许并行操作。
● 容器运行时针对一个容器必须按顺序执行ADD 和DEL 操作,ADD 后面总是跟着相应的DEL。DEL 可能跟着额外的DEL,插件应该允许处理多个DEL。
● 容器必须由ContainerID 来唯一标识,需要存储状态的插件,需要使用网络名称、容器ID 和由网络接口组成的主key(用于索引)。
● 容器运行时针对同一个网络、同一个容器、同一个网络接口,不能连续调用两次ADD 命令。
除配置文件指定的CNI 插件外,Kubernetes 还需要标准的CNI 插件lo,最低版本为0.2.0 版本。网络插件除支持设置和清理 Pod 网络接口外,还需要支持 iptables。如果kube-proxy 工作在iptables 模式下,那么网络插件需要确保容器流量能使用iptables 转发。例如,如果网络插件将容器连接到Linux 网桥,则必须将net/bridge/bridge-nf-call-iptables参数sysctl 设置为1,网桥上的数据包将遍历iptables 规则。如果插件不使用Linux 桥接器(而是类似Open vSwitch 或其他某种机制的插件),则应确保容器流量被正确设置了路由。
ContainerNetworking 组维护了一些CNI 插件,包括网络接口创建的bridge、ipvlan、loopback、macvlan、ptp、host-device 等,IP 地址分配的dhcp、host-local 和static,其他的Flannel、tunning、portmap、firewall 等。社区还有第三方网络策略方面的插件,例如Calico、Cilium 和Weave 等。可用选项的多样性意味着大多数用户将能够找到适合其当前需求和部署环境的CNI 插件,并在情况变化时迅速转换解决方案。各个用户之间要求差异很大,Kubernetes 拥有不同级别的复杂性和功能性的成熟解决方案,能够提供更好的用户体验。
Flannel 是由CoreOS 开发的项目,是CNI 插件早期的入门产品,简单易用。Flannel使用Kubernetes 集群现有的etcd 集群来存储其状态信息,从而不必提供专用的数据存储,只需要在每个节点上运行flanneld 来守护进程。
每个节点都被分配一个子网,为该节点上的Pod 分配IP 地址。如图1-19 所示,同一主机内的Pod 可以使用网桥进行通信,而不同主机上的Pod 将通过flanneld 将其流量封装在UDP 数据包中,以路由到适当的目的地。封装方式默认和推荐的方法是使用VxLAN,因为它具有良好的性能,并且比其他选项要少一些人为干预。虽然使用VxLAN 进行封装的解决方案效果很好,但缺点是该过程使流量跟踪变得困难。
图1-19 Flannel 的网络架构
Calico 是Kubernetes 生态系统中的另一个流行的联网选项。Calico 以其性能、灵活性和网络策略而闻名。不仅涉及在主机和Pod 之间提供网络连接,而且还涉及网络安全性和策略管理。如图1-20 所示,对于同网段通信,基于第3 层,Calico 使用BGP 路由协议在主机之间路由数据包,使用BGP 路由协议也意味着数据包在主机之间移动时不需要包装在额外的封装层中。这样,当出现网络问题时,它允许使用标准的调试工具进行更常规的故障排除,从而使开发人员和管理员更容易定位问题。对于跨网段通信,基于IPinIP 使用虚拟网卡设备tunl0,用一个IP 数据包封装另一个IP 数据包,外层IP 数据包头的源地址为隧道入口设备的IP 地址,目标地址为隧道出口设备的IP 地址。
网络策略是Calico 最受欢迎的功能之一,它通过ACLs 协议和kube-proxy 来创建iptables 过滤规则,从而实现隔离容器网络的目的。此外,Calico 还可以与服务网格Istio集成,在服务网格层和网络基础结构层上解释和实施集群中工作负载的策略。这意味着您可以配置功能强大的规则,以描述Pod 应该如何发送和接收流量、提高安全性,以及加强对网络环境的控制。Calico 属于完全分布式的横向扩展结构,允许开发人员、管理员快速和平稳地扩展部署规模。对于性能和功能(如网络策略)要求高的环境,Calico 是一个不错的选择。
表1-1 总结了几款当前主流的容器网络方案的异同点,在是否支持网络策略、是否支持ipv6、所在的网络层级和部署方式等方面做了比较。
图1-20 Calico 的网络架构
表1-1 各种容器网络方案的对比
1.3.3 Pod 详解
Pod 作为承载容器化应用的基本调度和运行单元,是Kubernetes 集群中最重要的对象,本节将详细介绍与Pod 管理相关的方方面面。
1.3.3.1 Pod 的生命周期
我们已经在1.2.5 节总结了从Deployment 对象到Pod 运行这个过程中,各个控制器是如何协同工作的。接下来,我们再总结一下从Pod 创建到容器运行起来的整个周期中,各个模块做了哪些工作,Pod 的创建流程如图1-21 所示。
图1-21 Pod 的创建流程
具体地,Pod 创建过程中每个模块的处理逻辑如下(这里我们以Containerd 为例,其他遵循CRI 的容器运行时的相关调用都是类似的):
(1)用户或者控制器通过kubectl、Rest API 或其他客户端向API Server 提交Pod 创建请求。
(2)API Server 将Pod 对象写入etcd 中进行永久性存储。如果写入成功,那么API Server会收到来自etcd 的确认信息并将创建结果返回给客户端。
(3)API Server 处就能反映出Pod 资源发生的变化,一个新的Pod 被创建出来。
(4)Scheduler 监听到API Server 处新的Pod 被创建。它首先会查看该Pod 是否已经被调度(spec.nodeName 是否为空)。如果该Pod 并没有被调度到任何节点,那么Scheduler会给它计算分配一个最优节点,并把它更新到spec.nodeName 中,从而完成Pod 的节点绑定。
(5)Scheduler 对Pod 的更新也将被API Server 写回到etcd 中。Scheduler 同样会监听到Pod 对象发生了变化。但是由于它已经调度过该Pod(spec.nodeName 不为空),所以它将不做任何处理。
(6)kubelet 也会一直监听API Server 处Pod 资源的变化。当其发现Pod 被分配到自己所在节点上(自身节点名称和Pod 的spec.nodeName 相等)时,kubelet 将会调用CRI gRPC向容器运行时申请启动容器。
(7)kubelet 首先会调用CRI 的RunPodSandbox 接口。Containerd 要确保PodSandbox(即Infra 容器)的镜像是否存在。因为所有PodSandbox 都使用同一个pause 镜像,如果节点上已经有运行的 Pod,那么这个 pause 镜像就已经存在。接着它会创建一个新的Network Namespace,调用CNI 接口为Network Namespace 设置容器网络,Containerd 会使用这个Network Namespace 启动PodSandbox。
(8)当PodSandbox 启动成功后,kubelet 才会在PodSandbox 下请求创建容器。这里kubelet 会先检查容器镜像是否存在,如果容器镜像不存在,则调用CRI 的PullImage 接口并通过Containerd 将容器镜像下载下来。
(9)当容器镜像下载完成后,kubelet 调用CRI 的CreateContainer 接口向容器运行时请求创建容器。
(10)当容器创建成功后,kubelet 调用CRI 的StartContainer 接口向容器运行时请求启动容器。
(11)无论容器是否创建和启动成功,kubelet 都会将最新的容器状态更新到Pod 对象的Status 中,让其他控制器也能够监听到Pod 对象的变化,从而采取相应的措施。
Pod 的删除流程如图1-22 所示。
图1-22 Pod 的删除流程
具体地,Pod 删除过程中每个模块的处理逻辑如下:
(1)用户或者控制器通过kubectl、Rest API 或其他客户端向API Server 提交Pod 删除请求。
(2)Pod 对象不会立刻被API Server 删除。API Server 会在Pod 中添加deletionTimestamp和deletionGracePeriodSeconds(默认值是30s)字段,并将Pod 的Spec 更改写回etcd 中。
(3)API Server 将Pod 已删除的信息返回用户,此时用户通过kubectl 查看Pod 的状态,发现Pod 被标记成Terminating 了。
(4)当kubelet 监听到API Server 处的Pod 对象的deletionTimestamp 被设置时,就会准备删除这个Pod(killPod)。
(5)kubelet 首先会停止Pod 内的所有容器,调用CRI 的StopContainer 接口向容器运行时发起停止容器的请求。这里我们同样以Containerd 为例,Containerd 会先调用runC 向容器发送SIGTERM 信号,容器停止或者deletionGracePeriodSeconds 超时,再发送SIGKILL信号去杀死所有容器进程,完成容器的停止过程。
(6)当容器被停止后,容器运行时会向Kubelet 发送消息,表示容器状态发生了改变。Kubelet 这时会把容器被停止的信息更新到Pod 的Status 中。
(7)Endpoint 控制器监听到这个Pod 的状态变化,接着将Pod 的IP 地址从相关的Endpoint 对象中移除。
(8)Kube-proxy 监听到Endpoint 对象的变化,将根据新的Endpoint 设置转发规则,从而移除关于Pod IP 的转发规则。
(9)当Pod 内所有容器被停止后,kubelet 可以通过StopPodSandbox 停止PodSandbox。Containerd 首先调用CNI 将容器的网络删除,然后停止PodSandbox。PodSandbox 停止后,kubelet 会进行一些清理工作,例如清除Pod 的CGroup 等。
(10)如果Pod 上有finalizer,即使Pod 的容器和PodSandbox 被全部停止,这个Pod也不能消失,需要等到其他控制器完成相关的清理工作,并将Pod 上的finalizer 删掉。
(11)当kubelet 再次监听到Pod 的变化时,finalizer 被清理干净了,canBeDeleted 方法返回true,kubelet 将给出最后一“击”,发起deletionGracePeriodSeconds 为零的删除请求。
(12)API Server 将这个Pod 从etcd 中彻底移除。
1.3.3.2 Pod 的质量保证
Kubernetes 根据CPU、Memory 资源的requests 和limits 来划分Pod 的质量保证(Quality of Service,或QoS)级别。针对CPU 或Memory,每个容器都可以指定requests 和limits的值,requests 表示Kubernetes 将给容器的可使用的资源,limits 表示Kubernetes 允许容器可使用的最大资源。如果系统保证分配给容器的CPU 是500m,Memory 是200Mi,那么当系统Memory 压力不大时,容器最多可以使用300Mi 的Memory,相关代码如下:
Pod 的QoS 级别有三种:
(1)Guarantee:Pod 的每个容器申请的CPU 使用量与Memory 的limits 值和requests值相等。如果容器只指定了Memory 或者CPU 的limits,没有指定requests,那么Kubernetes会自动给它填写一个与limits 值相等的requests。Pod 的QoS 是Guarantee 的。Guarantee是Kubernetes 的最高优先级。Kubelet 不会主动杀死Pod,除非它们所用的资源超过了Pod的limits。
(2)Burstable:Pod 内至少有一个容器指定了Memory 或CPU 的requests 或limits,Pod 不满足Guarantee 的条件,requests 的值和limits 的值不相等。requests 和limits 的配置规则如下:
● 如果容器指定了CPU 的requests 和limits,那么当容器的CPU 使用量超过limits时,容器进程将会被限制。
● 如果容器只指定了CPU 的requests,没有指定limits,那么当节点压力不大时,该容器可以使用超过requests 值的节点上的剩余可用的CPU。
● 假设容器指定了Memory 的requests 和limits,当容器的内存使用超过requests 且没有超过limits 时,如果节点内存不足(已经没有BestEffort 的Pod 了),那么这个容器将被 “杀” 掉;当容器的内存使用量超过limits 时,容器将会被kernel “杀” 掉,容器状态将变成OOMkilled(Out-Of-Memory killed)。
● 如果容器只指定了Memory 的requests,没有指定limits,那么当容器的内存使用超过requests 时,容器可以使用节点上剩余的内存,但是当节点内存不足以满足其他容器时,该容器可能会被 “杀” 掉。
(3)BestEffort:Pod 的每个容器都没有Memory 和CPU 的requests 或limits 的值。这个级别的Pod 的优先级是最低的。当系统有了CPU 和Memory 的压力时,Pod 会率先被“杀” 掉。
1.3.3.3 Pod 的节点亲和性
我们知道,Scheduler 负责实现Pod 的调度,通过执行一系列复杂的算法为每个Pod最终计算出每个Node 的得分,得分最高的就是最佳目标节点。对用户和运维者来说,无法预先知道Pod 最终会被调度到哪个节点上。但是有些特殊场景,我们又需要将Pod 调度到某些特定的节点上,这时我们就需要使用节点亲和性。
节点亲和性的使用方式有两种:nodeSelector 和nodeAffinity。nodeSelector 是一个最简单的方法,采用label selector 选择节点。如果Pod 指定了nodeSelector,那么它将会被调度到标签满足dedicated=demo 且run=nginx 的节点上。如果这些节点中没有一个能满足Pod 的资源申请(CPU 或Memory 不能够满足),那么这个Pod 将不会被调度。它会被Scheduler 加回调度队列重新调度,直到nodeSelector 和资源都能够被满足,示例代码如下:
nodeAffinity 相当于nodeSelector 的高级功能,其规则的表达方式更丰富一些,且可以指定哪些规则是软性条件,哪些规则是硬性条件。如果规则是软性的,并且调度器不能完全满足它,那么这个Pod 也能被调度。preferredDuringSchedulingIgnoredDuringExecution 中的规则就是软性的,调度器将尝试执行但是不能保证满足所有条件;requiredDuringScheduling IgnoredDuringExecution 中的规则就是硬性的,就像nodeSelector 一样,是节点必须要满足的规则。IgnoredDuringExecution 意味着当节点上的标签在Pod 运行时发生更改以致不再满足Pod 上指定的规则时,Pod 仍将继续在该节点上运行。
下面给出nodeAffinity 的示例代码。在这个示例中,只能将Pod 放置在标签key 为dedicated 且value 为demo 或test 的节点上。另外在满足前面标准的节点中,应优先选择具有key 为dedicated 且value 为demo 的节点。
nodeAffinity 的operator 值支持运算符In、NotIn、Exists、DoesNotExist、Gt、Lt。
对于nodeSelector 和nodeAffinity 的使用,需要注意以下几点:
● 如果Pod 同时指定nodeSelector 和nodeAffinity,则必须同时满足两个条件才能将Pod 调度到候选节点上。
● 如果nodeAffinity 中关联多个nodeSelectorTerms,且节点满足nodeSelectorTerms之一,就可以将Pod 调度到这个节点上。
● 如果nodeSelectorTerms 中关联了多个matchExpressions,则只有在节点满足所有matchExpressions 的情况下,才能将Pod 调度到节点上。
● preferredDuringSchedulingIgnoredDuringExecution 中的weight 字段在1~100 的范围内。如果一个节点满足该字段下的MatchExpressions,那么调度器会在该节点的调度优先级评分上加上weight。
1.3.3.4 Pod 的节点容忍性
与affinity 相反,taints 允许节点 “排斥” 或者 “驱逐” 某一类Pods。将某些节点加上一个或多个taints,这个节点就不会被调度没有相关Tolerations 的Pod。反过来,如果Pod有Tolerations,那么调度器允许Pod 调度到有相应taints 的节点上,但是不会强制调度到这些节点上。
“专用节点”是taints 最常见的使用场景之一,也就是说,特定Pod 必须运行在这些“专用节点” 上,其他Pod 不允许运行在这些节点上。在这种情况下,只有与节点亲和性配合使用,调度器才会将这些Pod 调度到 “专用节点” 上。下面我们来看一个 “专用节点” 的taints 字段的示例代码:
如上代码所示,满足此taints 的Tolerations 可以有多个,我们可以根据Operator 的值来确定是否需要指定Tolerations 的各项值(key、value 和effect)。如果Operator 的值为Equal,则需指定Tolerations 的各项值(key、value、effect 的值与taints 的key、value、effect的值相同);如果Operator 的值为Exists,则只需指定key、effect 的值,无须指定value的值。示例代码如下:
对于taints 的effect,除了NoSchedule,还可以指定PreferNoSchedule 和NoExecute。PreferNoSchedule 是NoSchedule 的软限制,它告诉调度器,尽量不要将没有此Tolerations的Pod 调度到节点上。如果我们将NoExecute 的taints 添加到节点上,并且已经运行在此节点上的Pod 没有相应的Tolerations,那么Pod 将会立刻被驱逐。
● 如果在节点上放置多个taints,那么Pod 需要有相应的Toleration 才能被调度到此节点上。
● 如果Pod Tolerations 的effect 为空,那么所有的effect 都可以容忍。
● 对DaemonSet 来说,它需要在各种 “花式taints” 的节点上运行,这里有种 “霸道” 的Tolerations 方式:当key 为空时,operator 为Exists,这意味着,只要所有的key存在就可以容忍任何taints。
在Kubernetes 1.13 版本后,基于taints 的TaintBasedEvictions 默认在集群中使能。当节点出现问题时,NodeController 或者kubelet 会自动添加tiants。如果Pod 上没有相应的Tolerations,那么该Pod 将被驱逐。内置的taints 如下:
● node.kubernetes.io/not-ready:节点状态是NotReady。
● node.kubernetes.io/unreachable:NodeController 不清楚节点状态。
● node.kubernetes.io/out-of-disk:节点的磁盘空间不足。
● node.kubernetes.io/memory-pressure:节点有内存压力。
● node.kubernetes.io/disk-pressure:节点磁盘有压力。
● node.kubernetes.io/network-unavailable:节点的网络不可用。
● node.kubernetes.io/unschedulable:节点不可调度。
● node.cloudprovider.kubernetes.io/uninitialized:当kubelet 用external 的provider 时,taints 会被添加到节点上。在cloud-controller-manager 初始化该节点后,即可移除taints。