kubernetes核心组件解读

kubenetes的整体架构

​ Kubernetes由两种节点组成:master节点和工作节点,前者是管理节点,后者是容器运行的节点。其中master节点中主要有3个重要的组件,分别是APIServer,scheduler和controller manager。APIServer组件负责响应用户的管理请求、进行指挥协调等工作;scheduler的作用是将待调度的pod绑定到合适的工作节点上;controller manage提一组控制器的合集,负责控制管理对应的资源,如副本(replication)和工作节点(node)等。工作节点上运行了两个重要组件,分别为kubelet和kube-proxy。前者可以被看作一个管理维护pod运行的agent,后者则负责将service的流量转发到对应的endpoint。在实际生产环境中,不少用户都弃用了kube-proxy,而选择了其他的流量转发组件。

​ Kubernetes架构可以用下图简单描述。可以看到,位于master节点上的APIServer将负责与master节点、工作节点上的各个组件之间的交互,以及集群外用户(例如用户的kubectl命令)与集群的交互,在集群中处于消息收发的中心地位;其他各个组件各司其职,共同完成应用分发、部署与运行的工作。

在这里插入图片描述

​ Kubernetes的架构体现了很多分布式系统设计的最佳实践,比如组件之间松耦合,各个组件之间不直接存在依赖关系,而是都通过APIServer进行交互。又比如,作为一个不试图形成技术闭环的项目,Kubernetes只专注于编排调度等工作,而在存储网络等方面留下插件接口,保证了整体的可扩展性和自由度,例如可以注册用户自定义的调度器、资源管理控制插件、网络插件和存储插件等,这使得用户可以在不hack核心代码的前提下,极大地丰富Kubernetes的适用场景。

APIServer

​ Kubernetes APIServer负责对外提供Kubernetes API服务,它运行在Kubernetes的管理节点master节点中。作为系统管理指令的统一人口,APIServer担负着统揽全局的重任,任何对资源进行增删改查的操作都要交给APIServer处理后才能提交给etcd。

​ Kubernetes APIServer总体上由两个部分组成:HTTP/HTTPS服务和一些功能性插件。其中这些插件又可以分成两类:一部分与底层IaaS平台(Cloud Provider)相关,另一部分与资源的管理控制(admission control湘关。

APIServer的职能

APIserve咋为Kubernetes集群的全局掌控者,主要负责以下5个方面的工作。

  • 对外提供基于RESTfuI的管理接口,支持对Kubernetes的资源对象譬如:pod, service,replication controller、工作节点等进行增、删、改、查和监听操作。例如,GET<apiserver-ip>:<apiserver-port>/api/v1/pods表示查询默认namespace中所有pod的信息。GET<apiserver-ip>:<apiserver-port>/api/v1/watch/pods表示监听默认namespace中所有pod的状态变化信息,返回pod的创建、更新和删除事件。该功能在前面的设计讲解中经常提到,这样一个ge睛求可以保持TCP长连接,持续监听pod的变化事件。
  • 配置Kubernetes的资源对象,并将这些资源对象的期望状态和当前实际存储在etcd中供Kubernetes其他组件读取和分析。(Kubernetes除了etcd之外没有任何持久化节点)
  • 提供可定制的功能性插件(支持用户自定义),完善对集群的管理。例如,调用内部或外部的用户认证与授权机制保证集群安全性,调用admission control插件对集群资源的使用进行管理控制,调用底层IaaS接口创建和管理Kubernetes工作节点等。
  • 系统日志收集功能,暴露在/logs API。
  • 可视化的API(用Swagger实现)。

APIServer启动过程

​ APIServer的启动程序读者可以参考cmd/kube-apiserver/apiserver.go的main函数,其启动流程如下所示。

  • 新建APIServer,定义一个APIServer所需的关键信息。

    首先是组件自身所需信息及其所需的依赖和插件配置,如表所示:

    在这里插入图片描述

  • 接受用户命令行输入,为上述各参数赋值。

  • 解析并格式化用户传入的参数,最后填充APIServer结构体的各字段。

  • 初始化log配置,包括log输出位置、log等级等。Kubernetes组件使用glog作为日志函数库,Kubernetes能保证即使APIServer异常崩溃也能够将内存中的log信息保存到磁盘文件中。

  • 启动运行一个全新的APIServer。 APIServer作为master节点上的一个进程(也可以运行在容器中)通常会监听2个端口对外提供Kubernetes API服务,分别为一个安全端口和一个非安全端口,如图所示。

    在这里插入图片描述

APIServer对etcd的封装

​ Kubernetes使用etcd作为后台存储解决方案,而APIServer基于etcd实现了一套RESTfuI API,用于操作存储在etcd中的Kubernetes对象实例。所有针对Kubernetes资源对象的操作都是典型的RESTfuI风格操作,如下所示:

  • GET /<resourceNamePlural> 返回类型为resourceName的资源对象列表,例如GET /pods返回一个pod列表。
  • POST /<resourceNamePlural>根据客户端提供的描述资源对象的JSON文件创建一个新的资源对象。
  • GET /<resourceNamePlural>/<name>根据一个指定的资源名返回单个资源对象信息,例如GET /pods/first返回一个名为first的pod信息。
  • DELETE /<resourceNamePlural>/<name>根据一个指定的资源名删除一个资源对象。
  • POST /<resourceNamePlural>/<name>根据客户端提供的描述资源对象的JSON文件创建或更新一个指定名字的资源对象。
  • GET /watch/<resourceNamePlural>使用etcd的watch机制,返回指定类型资源对象实时的变化信息。
  • GET /watch/<resourceNamePlural>/<name>使用etcd的watch机制,根据客户端提供的描述资源对象的JSON文件,返回一个名为name的资源对象实时的变化信息。

APIServer如何操作资源

​ APIServer将集群中的资源都存储在etcd中,默认情况下其路径都由/registry开始,用户可以通过传人etcd-prefix参数来修改该值。
​ 当用户向APIServer发起请求之后,APIServer将会借助一个被称为registry的实体来完成对etcd的所有操作,这也是为什么在etcd中,资源的存储路径都是以registry开始的。

​ Kubernetes目前支持的资源对象很多,如表所示:

在这里插入图片描述

一次创建pod请求的响应流程

  • (1) APIServer在接收到用户的请求之后,会根据用户提交的参数值来创建一个运行时的pod对象。

  • (2)根据API请求的上下文和该pod对象的元数据来验证两者的namespace是否匹配,如不匹配则创建pod失败。

  • (3) namespace验证匹配后,APIServer会向pod对象注入一些系统元数据,包括创建时间和uid等。如果定义pod时未提供pod的名字,则APIServe侩将pod的uid作为pod的名字。

  • (4) APIServer}’}下来会检查pod对象中的必需字段是否为空,只要有一个字段为空,就会抛出异常并终止创建过程。

  • (5)在etcd中持久化该pod对象,将异步调用返回结果封装成restful.Response,完成操作结果反馈。

    ​ 至此,APIServer在pod创建的流程中的任务已经完成,剩余步骤将由Kubernetes其他组件(kube-scheduler和kubelet)通过watch APIServer继续执行下去。

APIServer如何保证API操作的原子性

​ 由于Kubernetes使用了资源的概念来对容器云进行抽象,就不得不面临APIServer响应多个请求时竞争和冲突的问题。所以,Kubernetes的资源对象都设置了一个resourceVersion作为其元数据(详见pkg/api/v1/types.go的ObjectMeta结构体)的一部分,APIServer以此保证资源对象操作的原子性。

​ resourceVersion是用于标识一个资源对象内部版本的字符串,客户端可以通过它判断该对象是否被更新过。每次Kubernetes资源对象的更新都会导致APIServer修改它的值,该版本仅对当前资源对象和namespace限定域内有效。

scheduler

​ 资源调度器本身经历了长足的发展,一向受到广泛关注。Kubernetes scheduler是一个典型的单体调度器。它的作用是根据特定的调度算法将pod调度到指定的工作节点上,这一过程通常被称为绑定(bind)。

​ scheduler的输人是待调度pod和可用的工作节点列表,输出则是应用调度算法从列表中选择的一个最优的用于绑定待调度pod的节点。如果把这个scheduler看成一个黑盒,那么它的工作过程正如图所示。

在这里插入图片描述

scheduler的数据采集模型

​ 不同于很多平台级开源项目(比如Cloud Foundry ), Kubernetes里并没有消息系统来帮助用户实现各组件间的高效通信,这使得scheduler需要定时地向APIServer获取各种各样它感兴趣的数据,比如已调度、待调度的pod信息,node状态列表、service对象信息等,这会给APIServe谴成很大的访问压力。
​ 所以scheduler专门为那些感兴趣的资源和数据设置了本地缓存机制,以避免一刻不停的暴力轮询APIServer带来额外的性能开销。这里的缓存机制可以分为两类,一个是简单的cache对象(缓存无序数据,比如当前所有可用的工作节点),另一个是先进先出的队列(缓存有序数据,比如下一个到来的pod)。scheduler使用reflector来监测APIServer端的数据变化。
​ 最后,我们总结一下scheduler调度器需要的各项数据、如何捕获这些数据,以及这些数据存储在本地缓存的什么数据结构中,如表所示。

在这里插入图片描述

scheduler调度算法

​ 在Kubernetes的最早期版本中,scheduler为pod选取工作节点的算法是round robin —即依次从可用的工作节点列表中选取一个工作节点,并将待调度的pod绑定到该工作节点上运行,而不考虑譬如工作节点的资源使用情况、负载均衡等因素。这种调度算法显然不能满足系统对资源利用率的需求,而且极容易引起竞争性资源的冲突,譬如端口,无法适应大规模分布式计算集群可能面临的各种复杂情况。当然,这之后scheduler对调度器的算法框架进行了较大的调整,已经能够支持一定程度的资源发现。目前默认采用的是系统自带的唯一调度算法default,当然,scheduler调度器提供了一个可插拔的算法框架,开发者能够很方便地往scheduler添加各种自定义的调度算法。接下来将以default法为例,详细解析scheduler调度算法的整体设计。

​ Kubernetes的调度算法都使用如下格式的方法模板来描述:

func RegisterAlgorithmProvider(name string, predicateKeys, priorityKeys sets.String) string {
    //TODO
}

其中,第1个参数即算法名(比如default),第2个和第3个参数组成了一个算法的调度策略。

​ Kubernetes中的调度策略分为两个阶段:Predicates和Priorities,其中Predicates回答“能不能”的问题,即能否将pod调度到某个工作节点上运行,而Priorities则在Predicates回答“能”的基础上,通过为候选节点设置优先级来描述“适合的程度有多高”。

​ 具体到default算法,目前可用的Predicates包括:PodFitsHostPorts , PodFitsResources ,NoDiskConflict, NoVolumeZoneConflict, MatchNodeSelector, HostName, MaxEBSVoIumeCount和MaxGCEPDVoIumeCount。所以,工作节点能够被选中的前提是需要经历这几个Predicates条件的检验,并且每一条都是硬性标准。一旦通过这些筛选,候选的工作节点就可以进行打分(评优先级)了。

​ 打分阶段的评分标准(Priorities)有7项:LeastRequestedPriority, BalancedResourceAllocation、SelectorSpreadPriority、NodeAffinityPriority、EqualPriority、ServiceSpreadingPriority和Image-LocalityPriority。每一项都对应一个范围是。}10的分数,0代表最低优先级,10代表最高优先级。除了单项分数,每一项还需要再分配一个权值(weight )。以default算法为例,它包含了LeastRequestedPriority, BalancedResourceAllocation, SelectorSpreadPriority和NodeAffmityPriority这三项,每一项的权值均为1。所以一个工作节点最终的优先级得分是每个Priorities计算得分的加权和,即Sum(score*weight)。最终,scheduler调度器会选择优先级得分最高的那个工作节点作为pod调度的目的地,如果存在多个优先级得分相同的工作节点,则随机选取一个工作节点。

Predicates

  • PodFitsHostPorts

    ​ PodFitsHostPorts的评估依据就是宿主机上的端口是否冲突,即检查待调度的pod中所有容器需要用到的HostPort集与工作节点上已使用的端口是否冲突。需要注意容器内部打开的端口(ContainerPort)和HostPort的区别,在同一个工作节点上,ContainerPort可以随意重复,但HostPort不能冲突。具体检测过程如下所示。
    ​ (1)枚举待调度的pod要用到的所有HostPort,即查询pod中每个容器的ContainerPort所对应的HostPort。由于HostPort是一个1~65535的整数,这里使用了一个key为int型,value为bool型的map结构,value值为true用于标记某个HostPort需要被该pod使用。
    ​ (2)根据cache中存储的node相关信息,采用步骤((1)中的方法获得node上运行的所有pod中每个容器的ContainerPort所对应的HostPort。
    ​ (3)比较步骤((1)和步骤(2)得到的两个HostPort集合是否有交集。如果有交集则表明将pod调度到该工作节点上会产生端口冲突,返回一个false值表示不适合调度;否则表明不会产生端口冲突,返回一个true值表示适合调度。

  • podFitsResources

    ​ podFitsResources的评估依据就是node上的资源是否够用,即检测每个node上已经在运行的所有pod对资源的需求总量与待调度pod对资源的需求量之和是否会超出工作节点的资源容量(node的capacity )。目前,这条规则检查node上允许部署的最大pod数目,以及CPU ( milliCPURequested )和Mem (memoryRequested)这两种资源的容量是否满足条件。需要注意的是,对于CPU和Mem,podFitsResources只计算资源的请求量而不是资源的实际使用量。

  • NoDiskConflict

    ​ NoDiskConflict对应的实现函数是NoDiskConflict,它的评估依据就是容器挂载的卷(volume)是否有冲突。

    具体的检测过程如下所示:
    (1)使用两层嵌套循环,交叉对比待调度pod包含的所有volume信息(即pod.Spec.Volumes)和node上已调度的pod的volume信息,进行判断。

    ​ (2)如果待调度pod上的volume不是GCEPersistentDisk, AWSElasticBlockStore或者RBD类型,不进行任何操作,继续检查下一个volume;反之,则将该volume与工作节点上所有pod的每一个volume进行比较,如果发现相同,则表示有磁盘冲突,检查结束,反馈给调度器不适合调度。
    ​ (3)如果检查完待调度的pod的所有volume均未发现冲突,则反馈给调度器表示该工作节点适合调度。

  • NoVolumeZoneConflict

    ​ NoVolumeZoneConflict用于检查pod的挂载卷的zone限制是否与node对应的zone-label相匹配,目前只支持PersistentVolumeClaims,确切地说,仅在它们bound到的PersistentVolume范围内检查。
    ​ 在检查过程中,我们首先获取node的zone-label信息,查询以failure-domain.beta.kubernetes.io/zone和failure-domain.beta.kubernetes.io/region两个label为key的value值。顾名思义,这些label对应的是工作节点调度限制,所以如果没有发现任何限制条件,则检查结束,说明该工作节点可以调度。接下来我们查找pod manifest中PersistentVolumeClaims对应的PersistentVolume的failure-domain.beta.kubernetes.io/zone和failure-domain.beta.kubernetes.io/regionlabel下对应的值,并与node对应的label进行交叉匹配,一旦发现有不相符的一项,则返回zone冲突信息,说明该node不适合被调度。
    ​ 特别地,我们允许工作节点有除了与pod所指定的label之外的其他zone限制,也就是说,工作节点的zone-label与pod的zone label必须是包含关系。这条规则其实也非常直观,即如果某个pod上的挂载volume可能在A zone调度失败,而它被调度到的工作节点一定不能位于A zone,即一定存在相应的zone-label。

  • MatchNodeSelector

    ​ MatchNodeSelector对应的实现函数是podSelectorMatches,它的评估依据是node是否能被pod的NodeSelector选中以及该node是否符合pod对于NodeAffinity的要求。也就是说,调度器会首先检查工作节点的labels属性和pod的NodeSelector的要求(label selector的一种)是否一致;接下来再检查pod manifest中的scheduler.alpha.kubernetes.io/affinitylabel与node名字是否相吻合。

    podSelectorMatchesl作流程如下所示:

    ​ (1) 如果pod的NodeSelector属性(即pod.Spec.NodeSelector)不为空,则解析工作节点对象的元数据,提取labels属性,应用NodeSelecto树工作节点的labels进行匹配,如果匹配不成功,则表明该node不适合调度。
    ​ (2) 获取pod的Spec中scheduler.alpha.kubernetes.io/affinitylabel对应的值NodeAffinity。NodeAffinity是一组亲和性调度规则,目前实现了其中两种,分别为RequiredDuringSchedulingIgnoredDuringExecution和PreferredDuringSchedulingIgnoredDuringExecution。其中,仅有前者在这里的检查中使用到了,意指在pod被调度时,选择的node必须符合这一规则的定义。这同样是通过label匹配与否进行判定的。

  • HostName

    ​ HostName评估的依据被定义在PodFitsHost中,即如果待调度的pod指定了pod.Spec.Host的值为hostname,则将它调度到主机名为指定hostname的工作节点上运行,这个策略非常简单。

  • MaxEBSVoIumeCount

    ​ MaxEBSVoIumeCount检查node上即将被挂载的AWS EBS Volume是否超过了默认限制39。

  • MaxGCEPDVoIumeCount

    ​ MaxGCEPDVoIumeCount检查node上即将被挂载的GCE Persistent Disk是否超过了默认限制16。

Priorities

  • LeastRequestedPriority

    ​ LeastRequestedPriority的计算原则是尽量将pod调度到资源占用比较小的工作节点上,这样能够尽可能地实现Kubernetes集群工作节点上pod资源均衡分配。
    ​ 具体计算分数的方法可以用如下公式描述:

    cpu((capacity-sum(requested))10 / capacity)+memory((capacity-sum(requested))10/capacity)/2
    

    ​ 其中,requested cpu和requested memory是被调度的pod所需申请的资源总量加上正在被检查的工作节点上所有运行的pod所申请的资源总量,而capacity则是正在检查的工作节点目前可用的容量。

  • BalancedResourceAllocation

    ​ BalancedResourceAllocation,即在调度时偏好CPU和内存利用率相近的节点,具体的计算公式如下:

    score=10abs(cpuFraction-memoryFraction)*10
    

    ​ 我们采用类似于LeastRequestedPriority中的计算方式,分别求出节点上CPU和内存的已分配量,以及待调度pod所需要的CPU和内存值,将对应的资源相加并分别除以节点上的资源总量,求出占用率,再参考公式求出分数。

  • SelectorSpreadPriority

    ​ SelectorSpreadPriority的设计来源于对集群中rc和service的高可用以及流量分布均衡的要求,其基本理念在于要求对相同service/RC(包括replication controller和replicaSet)的pod在节点及zone上尽量分散,对应实现在CalculateSpreadPriority中,评分流程如下:

    ​ (1)对给定的待调度pod,查询该pod所在的namespace下对应的service。由于一个pod对应的service的数目是没有限制的(可能为0个,1个或多个),如果与该pod匹配的service数目不为0,则此处会返回所有匹配的service列表,否则返回错误标识没有找到匹配的service。

    ​ (2)对给定的待调度pod,查询该pod所在的namespace下对应的ReplicationController。同样地,当匹配的ReplicationController数目不为。时,返回匹配列表,否则返回错误。

    (3)对给定的待调度pod,查询该pod所在的namespace下对应的ReplicaSet。当匹配的ReplicaSet数目不为0时,返回匹配列表,否则返回错误。

    ​ (4)对于上述返回的与pod有相同label selector的service, ReplicationController和ReplicaSet,将其整合在一起,并且计算与待调度pod处于namespace下各个node上具有同样label selector的pod数目,并将所有node中相同label的pod数量最多的值记为maxCountByNodeName。
    ​ (5)同样地,我们再针对zone进行类似的计算,将所有zone中相同label的pod数目最多的值即为maxCountByZone。当然,有一些集群中的工作节点并没有zone这一特征,在这种情况下,无需在后续步骤中考虑zone因素的影响。
    ​ (6)运用简单的打分策略对各个工作节点进行打分,将该节点上相同label的pod与maxCount-ByNodeName及CountByZone进行投射比对,得到一个010分间的分数,具体计算过程如下:

    在这里插入图片描述

  • ServiceSpreadingPriority

    ​ ServiceSpreadingPriority可以认为是上述介绍的SelectorSpreadPriority的前身,在早期的设计中,该函数仅考虑了使节点上属于同一个service的后端pod尽量少。我们不难发现,这个功能点已经被SelectorSpreadPriority所覆盖,出于兼容低版本v1.0的考虑,现在仍然将其保留在系统中,而在注册函数时,将传人参数的RC列表设定为空即可。

  • NodeAffinityPriority

    ​ NodeAffinityPriority是一个新的特征,允许用户在pod manifest中指定pod的工作节点亲和性,对应的annotation为scheduler.alpha.kubernetes.io/affinity。
    ​ node亲和性本质上是一些调度规则,目前实现了其中两种,其一为强规则requiredDuringSchedulingIgnoredDuringExecution,若某个工作节点不满足该字段的要求,则待调度的pod一定不会被调度到该工作节点上;其二为弱规则preferredDuringSchedulingIgnoredDuringExecution,即说明pod偏好的工作节点,但是调度器仍然可能将该pod调度到不满足这一字段的工作节点上。注意,这两条规则都只考虑到调度发生时,而不考虑具体的运行过程。也就是说,一旦pod被成功调度到某个工作节点上,在它运行的生命周期内,即使工作节点不再满足该字段的要求,调度器也不会将pod从该工作节点上删除。在将来,还可能会有其他新的规则,如requiredDuringSchedulingRequiredDuringExecution等。目前在实际的调度算法中使用到的仅有弱规则preferredDuringSchedulingIgnoredDuringExecution。对于亲和性的具体表现形式,系统实现了多种支持方式,包括In, NotIn, Exists, DoesNotExist, Gt和Lt。
    ​ 在实际的检查过程中,首先将pod的node亲和性信息抽取出来,并与各个工作节点的node selector逐一比较,得到结果存放在一个以NodeName为key值的map中。一旦出现匹配,则对应的value值加1。最后将各个工作节点的得分投影到0一10之间。

  • EqualPriority

    ​ EqualPriority对应的实现函数是EqualPriority,它的计算原则是平等对待NodeLister中的每一个工作节点。与其他计算函数相比,EqualPriority函数的工作流程简单很多,即遍历NodeLister中所有备选的工作节点,将每个工作节点的优先级(score)均置为1。

  • ImageLocalityPriority

    ​ ImageLocalityPriority根据主机上已经存在的且将会被待调度pod使用到的镜像(大小)进行打分。在检查过程中,遍历pod.Spec.Containers项,对各个node分别检查是否存在对应的镜像,并且将存在镜像的大小和累加作为评分依据。存在镜像和越大的工作节点对应的得分越高。

scheduler的启动与运行

​ scheduler组件的启动程序放在plugin/and/kube-scheduler目录下,负责进行调度工作的核心进程为scheduler server,它的结构相对来说比较简单,主要的属性如表所示:

在这里插入图片描述

​ 在程序入口的main函数中,首先完成对SchedulerServer的初始化工作,这是一个涵盖了要运行调度器所需要的参数的结构体,并且调用Run函数来运行一个真正的调度器。Run函数完成的事情如下:

​ (1) 收集scheduler产生的事件信息并构建事件对象,然后向APIServer发送这些对象,最终由APIServer调用etcd客户端接口将这些事件进行持久化。event来源非常广泛,除了scheduler外,它的来源还包括kubelet, pod, Docker容器、Docker镜像、pod Volume和宿主机等。
​ (2) 创建一个http server,默认情况下绑定到IP地址Address(见表8-12)上并监听10251端口。在启用对scheduler的profiling功能时,该server上会被注册3条路由规则(/debug/pprof/,/debug/pprof/profile和/debug/pprof/symbol),可以通过Web端对scheduler的运行状态进行辅助性检测和debug。
​ (3) 根据配置信息创建调度器并启动SchedulerServer。在启动调度器之前,需要进行一些初始化操作,这些初始化操作的结果将作为调度器的配置信息传入,如下所示:

  1. 客户端对象client,用于与APIServer通信。

  2. 用于缓存待调度pod对象的队列podQueue。

  3. 存储所有已经调度完毕的Pod的链表ScheduledPodLister。

  4. 存储已调度的所有pod对象的链表podLister,其中包括已经调度完毕的以及完成了调度决策但可能还没有被运行起来的pod。

  5. 存储所有node对象的链表NodeLister。

  6. 存储所有PersistentVolumes的链表PVLister。

  7. 存储所有PersistentVolumeClaims的链表PVCLister。

  8. 存储所有service对象的链表ServiceLister。

  9. 存储所有控制器的链表ControllerLister。

  10. 存储所有ReplicaSet的链表ReplicaSetLister。

  11. 用于关闭所有reflectors的channel, StopEverything。

  12. 用于操作ScheduledPodLister池的控制器scheduledPodPopulator,负责在完成调度的pod被更新时进行相应的操作。

  13. 用于提前更新pod在系统中被调度的状态,使得调度器能够提前感知的Modeler。

  14. 调度器的名字SchedulerName。

    (4) 注册metrics规则,用于检测调度器工作的性能,包括调度延迟时间、binding延迟时间等。

controller manager

​ Kubernetes controller manager行在集群的master节点上,是基于pod API上的一个独立服务,它管理着Kubernetes集群中的各种控制器,包括读者已经熟知的replication controller和node controller。相比之下,APIServer负责接收用户的请求,并完成集群内资源的“增删改”,而controller manager系统中扮演的角色是在一旁默默地管控这些资源,确保它们永远保持在用户所预期的状态。

Contorller Manager启动过程

Contorller Manager启动过程大致分为以下几个步骤:

  • (1) 根据用户传入的参数以及默认参数创建kubeconfig和kubeClient。前者包含了controller manager工作中需要使用的配置信息,如同步endpoint, rc, node等资源的周期等;后者是用于与APIServer行交互的客户端。

  • (2) 创建并运行一个http server,对外暴露/debug/pprof/、/debug/pprof/profile, /debug/pprof/symbol和/metrics,用作进行辅助debug和收集metric数据之用。

  • (3) 按顺序创建以下几个控制管理器:服务端点控制器、副本管理控制器、垃圾回收控制器、节点控制器、服务控制器、路由控制器、资源配额控制器、namespace控制器,horizontal控制器、daemon sets制器、job控制器、deployment控制器、replicaSet控制器、persistent volume控制器(可细分为persistent volume claim binder , persistent volume recycler及persistent volume provision controller ), service account控制器,再根据预先设定的时间间隔运行。特别地,垃圾回收控制器、路由控制器仅在用户启用相关功能时才会被创建,而horizontal控制器、daemon set控制器、job控制器、deployment控制器、replicaSet控制器仅在extensions/vlbetal的API版本中会被创建。

    ​ controller manager控制pod、工作节点等资源正常运行的本质,就是靠这些controller定时对pod、工作节点等资源进行检查,然后判断这些资源的实际运行状态是否与用户对它们的期望一致,若不一致,则通知APIServer进行具体的“增删改”操作。理解controller工作的关键就在于理解每个检查周期内,每种资源对象的实际状态从哪里来,期望状态又从哪里来。接下来,我们以服务端点控制器、副本管理控制器、垃圾回收控制器、节点控制器和资源配额控制器为例,分析这些controller的具体工作方式。

服务端点控制器(endpoint controller)

​ 要想了解endpoint controller的工作原理,首先要从它的数据结构开始说起。

在这里插入图片描述

​ 当用户在Kubernetes中创建一个包含label selector的service对象时,系统会随之创建一个对应的endpoint对象,该对象即保存了所有匹配service的label selector端pod的IP地址和端口。可以预见,endpoint controller为endpoint对象的维护者,需要在service或者pod的期待状态或实际状态发生变化时向APIServe泼送请求,调整系统中endpoin时象的状态。

​ 顺着这条思路,可以发现endpoint controller维护了两个缓存池,其中serviceStore用于存储service, podStore用于存储pod,并且使用controller的reflector机制实现两个缓存与etcd内数据的同步。具体而言,就是当controller监听到来自etcd的service或pod的增加、更新或者删除事件时,对serviceStore或podStore做出相应变更,并且将该service或者该 pod对应的service加入到queue中。也就是说,queue是一个存储了变更service的队列。endpoint controller通过多个goroutine来同时处理service队列中的状态更新,goroutine的数量由controller manager的ConcurrentEndpointSyncs参数指定,默认为5个,不同goroutine相互之间不会相互干扰。

​ 每个goroutine的工作可以分为如下几个步骤:

​ (1) 从service队列中取出当前处理的service,在serviceStore中查找该service对象。若该对象已不存在,则删掉其对应的所有endpoint;否则进入步骤(2)。

​ (2) 构建与该service对应的endpoint的期望状态。根据service.Spec.Selector,从podStore获取该service对应的后端pod对象列表。对于每一个pod,将以下信息组织为一个新的EndpointSubset对象:pod.Status.PodIP, pod.Spec.Hostname, service.spec中定义的端口名、端口号、端口协议、和pod的资源版本号(ResourceVersion,同样作为endpoint对象的资源版本号),并且将所有EndpointSubset对象组成一个slice subset,这是期望的endpoint状态。

​ (3) 使用service名作为检索键值,调用APIServer的API获取当前系统中存在的endpoint对象列表currentEndpoints,即endpoint的实际状态。如果找不到对应的endpoint,则将一个新的Endpoint对象赋值给currentEndpoints,此时它的ResourceVersion为0。将步骤(2)中endpoint期望状态与实际endpoint对象列表进行比较,包括两者的pod.beta.kubernetes.io/hostname的annotation, subset(包含端口号、pod IP地址等信息),以及service的label与目前endpoint的label,如果发现不同,则调用APIServer的API进行endpoint的创建或者更新。如何判断需要进行的是创建还是更新呢?这就与ResourceVersion分不开了。如果ResourceVersion为0,说明需要创建一个新的endpoint,否则,则是对旧的endpoint的更新。

副本管理控制器(replication controller)

​ replication controller负责保证rc管理的pod的期望副本数与实际运行的pod数量匹配。可以预见,replication controller需要在rc或者pod的期待状态发生变化时向APIServer发送请求,调整系统中endpoint对象的状态。同样地,先来通过数据结构大致了解一下它的工作模式。

在这里插入图片描述

​ 它在本地维护了两个缓存池rcStore和podStore,分别同于同步rc与pod在etcd中的数据,同样调用controller的reflector机制进行list和watch更新。一旦发现有rc的创建、更新或者删除事件,都将在本地rcStore中进行更新,并且将该rc对象加人到待更新队列queue中。

​ 对于监听到pod的事件,则相对较为复杂。对于创建和更新pod,都要检查pod是否实际上已经处于被删除的状态(通过其DeletionTimestamp的标记),如果是则触发删除pod事件;对于创建与删除pod,还需要在expectations中写入相应的变更;expectations是replication controller用于记住每个rc期望看到的pod数的TTL cache,为每个rc维护了两个原子计数器(分别为add和del,用于追踪pod的创建或者删除)。对于pod的创建事件,add数目减少1,说明该rc需要期待被创建的pod数目减少了1个;类似地,对于删除事件,则是del的数目减少1。所以说,如果add的数目和del的数目都小于或等于0,我们就认为该rc的期望已经被满足了(即对应的Fulfilled方法返回为true值)。读者们也许会好奇,expectations中add和del的初始值为多少呢?事实上,在replication controller创建时,它们都被初始化为0,直到TTL超时或者期望满足时,该rc才会被加入到sync队列中,此时重新为该rc设置add和del值。最后,不管是哪种事件,都要将pod对应的rc加入queue队列。

​ replication controller的同步工作将处理rc队列queue,对系统中rc中副本数的期望状态及pod的实际状态进行对比,并启用了多个goroutine对其进行同步工作,每个goroutine的工作流程大致如下:

​ (1) 从rc队列中取出当前处理的rc名,通过rcStore获得该rc对象。如果该rc不存在,则从expectations中将该rc删除;如果查询时返回的是其他错误,则重新将该rc入队;这两种情况均不再进行后续步骤。

​ (2) 检查该rc的expectations是否被满足或者TTL超时,如果是,说明该rc需要被同步,在步骤(2)执行结束后将进入步骤((3),否则进入步骤(4)。调用APIServer的API获取该rc对应的pod列表,并且筛选出其中处于活跃状态的pod(即.status.phase不是Succeeded, Failed以及尚未进人被删除阶段)。

​ (3) 调整rc中的副本数。将(2)步骤中获得的活跃pod列表与rc的.spec.replicas字段相减得到diff,如果diff小于0,说明rc仍然需要更多的副本,设置expectations中的add值为diff,并且调用APIServer的API发起pod的创建请求,创建pod完毕后还需要将expectations的add相应减少1。如果diff大于0,说明rc的副本数过多,需要清除pod,将expectations中的del设为diff值,并且调用APIServer的API发起pod的删除请求,删除pod后还需要将expectations的del相应减少1。实际上因为工程的需要,引人了一个burstReplicas,默认为500,限制diff数目小于或等于该值。
​ (4) 最后,调用APIServer的API更新rc的status.replicas。
可以看到,Controller的运作过程依然遵循了旁路控制的原则,真正操作资源的工作是交给APIServer去做的。

垃圾回收控制器(gc controller)

​ 在用户启动pod的垃圾回收功能时,该控制器会被创建。所谓回收pod,是指将系统中处于终止状态的pod删除。注意:kubelet也执行的垃圾回收,但是针对的是容器和镜像的回收,而此处针对的是pod。在Kubernetes的设计中两者并非紧密关联,因此它们的回收流程是分开执行的。
​ gc controller维护了一个缓存池podStore,用于存储终止状态(即podPhase不是Pending,Running, Unknown三者)的pod,并使用reflecto峡用list和watch机制监听APIServer对podStore进行更新。
​ 要执行垃圾回收,首先会考察podStore中的pod数量是否已经到达触发垃圾回收的阈值。如果没有到达,不进行任何操作;否则,将所有pod按照创建时间进行排序,最先创建的pod将被优先回收。当然,删除pod的实际操作也是通过调用APIServer的API实现。

节点控制器(node controller)

​ node controller是主要用于检查Kubernetes的工作节点是否可用的控制器,它会定期检查所有在运行的工作节点上的kubelet进程来获取这些工作节点信息,如果kubelet在规定时间内没有推送该工作节点的状态,则将其NodeCondition为Ready的状态置成Unknown,并写人etcd中。
​ 在介绍node controller的具体职责之前,先明确一下工作节点在Kubernetes的表示方式。

  • 工作节点的描述方式

    ​ Kubernetes将工作节点也看作资源对象的一种,用户可以像pod那样,通过资源配置文件或kubectl命令行工具来创建一个node资源对象。当然,真正物理层面的工作节点(物理机或虚拟机)并不是由Kubernetes创建的,创建node资源对象只是为了抽象并维护工作节点的相关信息,并对工作节点是否可用进行持续的追踪。

    ​ Kubernetes主要维护工作节点对象的两个属性—spec和status,分别被用来描述一个工作节点的期望状态和当前状态。其中,期望状态由一个json资源配置文件构成,描述了一个工作节点的具体信息,而当前状态信息则包含如下一系列节点相关信息:

    1. Node Addresses:工作节点的主机地址信息,通常以slice(数组)的形式存在。如果工作节点是由IaaS平台创建的虚拟机,那么它的主机地址通常可以通过调用IaaS API来获取。Addresses的种类可能是Hostname, ExternalIP或InternalIP中的一种。经常被使用到的是后两者,并且通过能否从集群外部访问到进行区分。
    2. Node Phase:即工作节点的生命周期,它也由Kubernetes controller manager管理。工作节点的生命周期可以分为3个阶段:Pending, Running和下erminated。刚创建的工作节点处于Pending状态,直到它被Kubernetes发现并通过检查。检查通过后(譬如工作节点上的服务进程都在运行),它会被标记为Running状态。工作节点生命周期结束称为Terminated状态,处于Terminated状态的工作节点不会接收任何调度请求,且本来在其上运行的pod也都会被移除。一个工作节点处于Running状态是可调度pod的必要而非充分条件。如果一个工作节点要成为一个调度候选节点,它还需要满足被称为Node Condition的条件。
    3. Node Condition:描述Running状态下工作节点的细分状况,也就是说,一个Running的工作节点,并不一定可以接收pod,还要观察它是不是满足一些列细分要求。可用的Condition值包括如:NodeReady和NodeOutOfDisk,前者意味着工作节点上的kubele进程处于健康状态,且已经准备好接收pod了;后者表示该工作节点上的可用磁盘空间不足,导致无法接收新的pod。
    4. Node Capacity与Node Allocatable:分别标识工作节点上的资源总量及当前可供调度的资源余量,涉及的资源通常包括CPU、内存及Volume大小。
    5. Node Info:一些工作节点相关的信息,如内核版本、runtime版本(如docker ), kubelet版本等,这些信息经由kubelet收集。
    6. Images:工作节点上存在的容器镜像列表。
    7. Daemon Endpoints:工作节点上运行的kubelet监听的端口。
  • 工作节点管理机制

    ​ 与pod和service不同的是,工作节点并不是真正由Kubernetes创建的,它是要么由IaaS平台(譬如GCE)创建,要么就是用户管理的物理机或者虚拟机。这意味着,当Kubernetes创建一个node时,它只是创建了一个工作节点的“描述”。因此在工作节点被创建之后,Kubernetes必须检查该工作节点是否合法。以下资源配置文件描述了一个工作节点的具体信息,可以通过该文件创建一个node对象。
    在这里插入图片描述

    ​ 一旦用户创建节点的请求被成功处理,Kubernetes会立即在内部创建一个node对象,再根据metadata.name去检查该工作节点的健康状况,这一字段是该节点在集群内全局唯一的标志。

    ​ 工作节点的动态维护过程是依靠node controller(节点控制器)来完成的,它是Kubernetes controller manager下属的一个控制器。简单地说,controller manager中一直运行着一个循环,负责集群内各个工作节点的同步及健康检查。这个循环周期由传人参数node-monitor-period控制。
    ​ 在每个循环周期内,node controller不断地检测当前Kubernetes已知的每台工作节点是否正常工作,而如果一个之前已经失败的工作节点在这个检测循环中变成了“可以工作”,那么node controller就把这个机器添加为工作节点中的一员;反之node controller则会把一个已有的工作点删除掉。需要注意的是,被删除的只是etcd中的minion对象,Kubernetes总是有办法知道当前整个物理环境下有哪些机器是可以作为工作节点的,并且不断地检查这个机器池。

  • node controller检查工作节点的循环

    ​ node controller维护了3个缓存podStore, nodes士ore, daemonSetStore,分别存储pod, node,daemonSet资源,同时有3个相应的controllerpodController,nodeController,daemonSetController
    来应用list/watch机制同步etcd中相应资源的状态。有趣的是,它们对于监控到的资源变化非常地不积极。

    ​ podControlle:只响应pod创建和更新事件,此时将检查该pod是否处于终止状态或者没有被成功调度到一个正常运行的工作节点上,如果是的话,则调用APIServer的API将其强行删除。而nodeController和daemonSetController则对这些变化不做任何操作。

    ​ node controller的主要职责是负责监控由kubelet发送过来的工作节点的运行状态,这个监控间隔是5秒钟。它将其维护的已知工作节点列表记录在knownNodeSet中,并由kubelet推送的信息判断其是否准备好接收pod的调度(即处于Ready状态),如果工作节点的不Ready状态超过了一定时限,还会调用APIServer的API将其上运行的pod删除。此外,工作节点是否处于OutOfDisk状态,也同样被关心。在这一工作流程中,也会处理新工作节点的注册和旧工作节点的删除。

    ​ node controller会每隔30秒进行一次孤儿pod的清除。所谓的孤儿进程,是指podStore中缓存的pod中被bind到一个不再处于nodeStore中的工作节点的pod。

    ​ daemonSetStore缓存用来做什么呢?实际上,它将在删除工作节点上的pod时用作判断,如果被删除的pod是被daemonSet管理的,那么将会跳过该pod,不进行删除工作。

    ​ node controller和其他controller最大的不同在于,事实上的工作节点资源并不由Kubernetes系统产生和销毁,而是依靠底层的物理机器资源或者云服务提供商的IaaS平台。etcd里存放的node资源只是一种说明它是否正常工作的描述性资源,而它是否能够提供服务的信息则由kubelet来提供。

资源配额控制器(resource quota controller)

​ 集群资源配额一般以一个namespace为单位进行配置,它的期望值(即集群管理员指定的配额大小)由集群管理员静态设置,而它的实际使用值会在集群运行过程中随着资源的动态增删而不断变化,resource quota controller用于追踪集群资源配额的实际使用量,每隔一resource-quota-sync-period时间间隔就会执行一次检查,如果发现使用量发生了变化,它就会调用APIServer的API在etcd中进行使用量的动态更新。它支持的资源包括pod/service/replication controller/persistent volume/secret和configMap/cpu/memory,当然还有resource quota本身。

​ 为了完成resource quota的同步工作,resource quota controller维护一个队列,所有需要同步的resource quota都将入队。

​ 首先,对创建、删除以及有.spec.hard更新resource quota,将其加人队列中。注意,这些变更事件是采用了list/watch机制从APIServer监听获得的,并且将缓存在rqIndexer里。

​ 其次,每隔一段时间(默认为5分钟),会进行一个full resync,此时所有的resource quota会全部被加入到队列中。

​ 另外,对于其他支持的资源(pod, serivce, replication controller, persistent volume claim,secret, ConfigMap ),分别设置了对应的replenishmentController,同样使用了list/watch机制监听资源,并对这些资源的更新或删除做出响应,即将这些资源所在的namespace下对其进行了规定的resource quota人队。通俗地讲,即当某个resource quota对pod的数量进行了规定时,那么当同一个namespace下的pod发生了更新或删除时,将该resource quota入队。

​ 需要被同步的resource quota资源将被加人队列中后,将采用先入先出的方式进行处理,与其他的controller一样,负责处理的worker不止一个,而且它们是并发工作的。

​ 同步资源的处理函数可以归结为,使得resource quota的状态(status)与其期望值(spec)保持一致。如果出现了以下情况中的任意一种,都将调用APIServer的API对resource quota进行更新。

  • ​resource quota的.spec.hard与.status.hard不同。
  • resource quota的.status.hard或.status.used为空,意味着这是第一次进行同步工作。
  • resource quota的.status.Used与controller实际观察到的资源使用量不同,实际观察到的资源使用量是通过读取etcd中其他支持资源(如pod)的累加值求和得到的。

kubelet

​ kubelet组件是Kubernetes集群工作节点上最重要的组件进程,它负责管理和维护在这台主机上运行着的所有容器。本质上,它的工作可以归结为使得pod的运行状态(status)与它的期望值(spec)一致。目前,kubelet支持docker和rkt两种容器;而社区也在尝试使用C/S架构来支持更多container runtime与Kubernetes的结合。

kubelet的启动过程

  • (1) kubelet需要启动的主要进程是KubeletServer,它所需加载的重要属性包括kubelet本身的属性、接入的runtime容器所需的基础信息以及定义kubelet与整个集群进行交互所需的信息。

  • (2) 进行如下一系列的初始化工作。

    1. 选取APIServerList的第一个APIServer,创建一个APIServer的客户端。
    2. 如果上一步骤执行成功,则再创建一个APIServer的客户端用于向APIServer发送event对象。
    3. 初始化cloud provider。当然,如果集群的kubelet组件并没有运行在cloud provider上,该步骤将跳过。
    4. 创建并启动cAdvisor服务进程,返回一个cAdvisor的http客户端,IP和Port分别是localhost和CAdvisorPort的值。如果CAdvisorPort设置为0,将不启用cadvisor。
    5. 创建ContainerManager,为Docker daemon, kubelet等进程创建cgroups,并确保它们运行时使用的资源在限额之内。
    6. 对kubelet进程应用OOMScoreAdj值,即向/proc/self/oom_score_adj文件中写人OOMScoreAdj的值(默认值为-999 )。 OOMScoreAdj是用于描述在该进程发生内存溢出时被强行终止的可能性,分数越高,进程越有可能被杀死;其合法范围是[-1000, 1000]。换句话说,这里希望kubelet是最不容易被杀死的进程(之一)。
    7. 配置kubelet支持的pod配置方式,包括文件、url以及APIServer,支持多种方式一起使用。
  • (3) 初始化工作完成后,实例化一个真正的kubelet进程。重点值得关注的有以下几点:

    1. 创建工作节点本地的service和node的cache,并且使用list/watch机制持续对其进行更新。
    2. 创建DiskSpaceManager,用以与cadvisor配合进行工作节点的磁盘管理,这与kubelet是否接受新的pod在该工作节点上运行有密切关系。
    3. 创建ContainerRefManager,用以记录每个container及其对应的引用的映射关系,主要用于在pod更新或者删除时进行事件的记录。
    4. 创建VolumeManager,用以记录每个pod及其挂载的volume的映射关系。
    5. 创建OOMWatcher,用以从cadvisor中获取系统的内存溢出(Out Of Memory , OOM)事件,并对其进行记录。
    6. 初始化kubelet网络插件,可以指定传入一个文件夹中的plugin作为kubelet的网络插件。
    7. 创建LivenessManager,用以维护容器及其对应的probe结果的映射关系,用以进行pod的健康检查。
    8. 创建podCache来缓存pod的本地状态。
    9. 创建PodManager,用以存储和管理对pod的访问。值得注意的是,kubelet支持3种更新pod的方式,其中通过文件和url创建的pod是不能自动被APIServer感知的,称其为static pod。为了监控这些pod的状态,kubelet会为每个static pod在相同的namespace下创建一个同名的mirror pod,用以反应static pod的更新状态。
    10. 配置hairpin NAT。
    11. 创建container runtime,支持docker和rkt。
    12. 创建PLEG ( pod lifecycle event generator )。为了严密监控容器运行情况,kubelet在过去采用了为每个pod启动一个goroutine来进行周期性轮询的方法,即使在pod的spec没有变化的情况下依旧如此。这种做法会消耗大量的CPU资源,在性能上不尽如人意。为了改变这个现状,Kubernetes在v 1.2.0中引人了PLEG,专门进行pod变化的监控,避免了并发的pod worker来进行轮询工作。
    13. 创建镜像垃圾回收对象containerGC。
    14. 创建imageManager理容器镜像的生命周期,处理镜像的垃圾回收工作。
    15. 创建statusManager,用以向APIServer同步pod实际状态的更新。
    16. 创建probeManager,用作pod健康检查的探针。
    17. 初始化volume插件。
    18. 创建RuntimeCache,用以缓存pod列表。
    19. 创建reasonCache,用以缓存每个容器对应的最新的失败原因信息。
    20. 创建podWorker。每个pod将对应一个podWorker用以同步pod状态信息。

    ​ kubelet启动完成后通过事件收集器向APIServer发送一个kubelet已经启动的event,表明集群新加人了一个新的工作节点,kubelet将这一过程称为BirthCry,即“出生的啼哭”。并且开始进行容器和镜像的垃圾回收,对应的时间间隔分别为1分钟和5分钟。

  • (4) 根据Runonce的值选择运行仅一次kubelet进程或在后台持续运行kubelet进程,如果Runonce为true,则kubelet根据容器配置文件的内容创建pod后就退出;否则,将以goroutine的方式持续运行kubelet。

  • 另外,默认启用kubelet Server的功能,它将根据admin的配置创建HTTP Server或HTTPS Server,监听10250端口。同时,创建一个HTTP Server监听10255端口,用于heapster向kubelet收集统计信息。

kubelet与cAdvisor的交互

cAdvisor主要负责收集工作节点上的容器信息及宿主机信息,下面将一一进行介绍:

  • 容器信息

    ​ 获取容器信息的URL形如:/api/{api version}/containers/<absolute container name>。绝对容器名(absolute containere)与URL的对应关系如表所示。

    在这里插入图片描述

    ​ 绝对容器名/下包含整个宿主机上所有容器(包括Docker容器)的资源信息,而绝对容器名/docker下才包含所有Docker容器的资源信息。如果想获取特定Docker容器的资源信息,绝对容器名字段需要填入/docker/{container ID}。

  • 宿主机信息

    ​ 类似地,还可以访问URL: /api/{api version}/machine来获取宿主机的资源信息。要获取当前宿主机的资源信息。

kubelet垃圾回收机制

​ 垃圾回收机制主要涵盖两个方面:容器回收和镜像回收。此处以docker这种容器runtime为例进行说明。

  • Docker容器的垃圾回收

    Docker容器回收策略主要涉及3个因素,如表所示:

在这里插入图片描述

(1) 获取所有可以被kubelet垃圾回收的容器。

​ 调用一次Docker客户端API获取工作节点上所有由kubelet创建的容器信息,形成一个容器列表,这些容器可能处于不同的生命周期状态,包括正在运行的和已经停止运行的。注意,需要通过命名规则来判断容器是否由kubelet创建并维护,如果忽略了这一点可能会因为擅自删除某些容器而惹恼用户。
​ 遍历该列表,过滤出所有可回收的容器。所谓可回收的容器必须同时满足两个条件:已经停止运行;创建时间距离现在达到预设的报废时间MinAge。
​ 过滤出所有符合条件的可回收容器后,kubelet会将这些容器以所属的pod及容器名对为单位放到一个集合(evictUnits)中,并根据pod创建时间的早晚进行排序,创建时间越早的pod对应的容器越排在前面。注意,在创建evictUnits的过程中,需要解析容器及其对应的pod名字,解析失败的容器称为unidentifiedContainers。

(2) 根据垃圾回收策略回收镜像。

​ 首先,删除unidentifiedContainers以及被删除的pod对应的容器。这部分容器的删除不需要考虑回收策略中MaxPerPodContainer和MaxContainers。
​ 如果podMaxPerpodContainer的值大于等于0,则遍历evictUnits中所有的pod,如果某个pod内的可回收容器数量大于MaxPerpodContainer,则删除多出的容器及其日志存储目录,其中创建时间较早的容器优先被删除。

​ 如果MaxContainers的值大于等于0且evictUnits中的容器总数也大于MaxContainers,则执行以下两步:

  • 先逐一删除pod中的容器,直到每个pod内的可回收容器数=MaxContainers/evictUnits的大小,如果删除之后某个pod内的容器数<1,则置为1,目的是为每个pod尽量至少保留一个可回收容器。

  • 如果此时可回收容器的总数还是大于MaxContainers,则按创建时间的先后顺序删除容器,较早创建的容器优先被删除。

  • Docker镜像的垃圾回收

    Docke橇像回收策略主要涉及3个因素,如表所示:

    在这里插入图片描述

    在Kubernetes中,Docker镜像的垃圾回收步骤如下所示:
    (1) 首先,调用cadvisor客户端API获取工作节点的文件系统信息,包括文件系统所在磁盘设备、挂载点、磁盘空间总容量(capacity)、磁盘空间使用量(usage)和等。如果capacity为0,返回错误,并记录下InvalidDiskCapacity的事件。
    (2) 如果磁盘空间使用率百分比(usage*1oo/capacity)大于或等于预设的使用率上限HighThresholdPercent,则触发镜像的垃圾回收服务来释放磁盘空间,否则本轮检测结束,不进行任何回收工作。至于具体回收多少磁盘空间,使用以下公式计算:

    amountToFree := usage-(int64(im.policy.LowThresholdPercent)*capacity/100)
    

    其实就是释放超出Low下hresholdPercent的那部分磁盘空间。

    那么kubelet会选择删除哪些镜像来释放磁盘空间呢?

    ​ 首先,获取镜像信息。参考当时的时间(Time.Now())kubelet会调用Docker客户端查询工作节点上所有的Docker镜像和容器,获取每个Docker镜像是否正被容器使用、占用的磁盘空间大小等信息,生成一个系统当前存在的镜像列表imageRecords,该列表中记录着每个镜像的最早被检测到的时间、最后使用时间(如果正被使用则使用当前时间值)和镜像大小;删除imageRecords中不存在的镜像的记录。

    ​ 然后,根据镜像最后使用时间的大小进行排序,时间戳值越小即最后使用时间越早的镜像越排在前面。如果最后使用时间相同,则按照最早被检测到的时间排序,时间戳越小排在越前面。

    ​ 最后,删除镜像。遍历imageRecords中的所有镜像,如果该镜像的最后使用时间小于执行第一步时的时间戳,且该镜像的存在时间大于MinAge,则删除该镜像,并且将删除Docker镜像计入释放的磁盘空间值,如果释放的空间总量大于等于前面公式计算得到的amountToFree值,则本轮镜像回收工作结束。否则,则记录一条失败事件,说明释放的空间未达到预期。

kubelet如何同步工作节点状态

​ 首先,kubelet调用APIServer API向etcd获取包含当前工作节点状态信息的node对象,查询的键值就是kubelet所在工作节点的主机名。

​ 然后,调用cAdvisor客户端API获取当前工作节点的宿主机信息,更新前面步骤获取到的node对象。

这些宿主机信息包括以下几点:

  • 工作节点IP地址。
  • 工作节点的机器信息,包括内核版本、操作系统版本、docker版本、kubelet监听的端口、
  • 工作节点上现有的容器镜像。
  • 工作节点的磁盘使用情况—即是否有out of disk事件。
  • 工作节点是否Ready。在node对象的状态字段更新工作节点状态,并且更新时间戳,则node controller就可以凭这些信息是否及时来判定一个工作节点是否健康。
  • 工作节点是否可以被调度podo

最后,kubelet再次调用APIServer API将上述更新持久化到etcd里。

kube-proxy

​ Kubernetes基于service、endpoint等概念为用户提供了一种服务发现和反向代理服务,而kube-proxy就是这种服务的底层实现机制。kube-proxy支持TCP和UDP连接转发,默认情况下基于Round Robin算法将客户端流量转发到与service对应的一组后端pod。在服务发现的实现上,Kube-proxy使用etcd的watch机制,监控集群中service和endpoint对象数据的动态变化,并且维护一个从service到endpoint的映射关系,从而保证了后端pod的IP变化不会对访问者造成影响。另外kube-proxy还支持session affinity(即会话保持或粘滞会话)。

​ 下面我们以iptables模式对kube-proxy进行解读。

kube-proxy的启动过程

​ (1) 新建一个ProxyServer,包括两个功能性的结构的创建,负责流量转发的proxier和负责负载均衡的endpointsHandler。

​ (2) 运行ProxyServer。如果启用了健康检查服务功能,则运行kube-proxy的HTTP健康检查服务器,监听HealthzPort。同时,同样像kubelet一样发出birthCry(即记录一条已经创建完毕并开始运行kube-proxy的事件),并且开启同步工作。

proxier

​ 前面已经介绍过,kube-proxy中工作的主要服务是proxier,而LoadBalancer只负责执行负载均衡算法来选择某个pod。默认情况下,proxier绑定在BindAddress上运行,并需要根据etcd上service对象的数据变化实时更新宿主机的防火墙规则链。由于每个工作节点上都有一个kube-proxy在工作,所以无论在哪个节点上访问service的virtual IP比如11.1.1.88,都可以被转发到任意一个被代理pod上。可见,由proxier负责的维护service和iptables规则尤为重要。这个过程通过OnServiceUpdate方法实现,该方法的参数就是从etcd中获取的变更service对象列表,下面将分别分析userspace和iptables模式下的proxier的工作流程。

  • userspace模式

    ​ (1) 遍历期望service对象列表,检查每个servie对象是否合法。维护了一个activeServices,用于记录service对象是否活跃。

    ​ 对于用户指定不为该service对象设置cluster IP的情况,则跳过后续检查。否则,在activeServices中标记该service处于活跃状态。由于可能存在多端口service,因此对Service对象的每个port,都检查该socket连接是否存在以及新旧连接是否相同;如果协议、cluster IP及其端口、nodePort, externalIPs, loadBalancerStatus以及sessionAffinityType中的任意一个不相同,则判定为新旧连接不相同。如果service与期望一致,则跳过后续检查。否则,则proxier在本地创建或者更新该service实例。如果该service存在,进行更新操作,即首先将旧的service关闭并停止,并创建新的service实例。否则,则直接进行创建工作。

    ​ 删除proxier维护的service状态信息表(serviceMap)中且不在。ctiveServices记录里的service。

    ​ (2) 删除service实例的关键在于在宿主机上关闭通向旧的service的通道。对任何一个Kubernetes service(包括两个系统service)实例,kube-proxy都在其运行的宿主机上维护两条流量通道,分别对应于两条iptables链---------KUBE-PORTALS-CONTAINER和KUBE-PORTALS一HOST。所以,这一步proxier就必须删除iptables的nat表中以上两个链上的与该service相关的所有规则。

    ​ (3) 新建一个service实例。首先,根据service的协议(TCP/UDP)在本机上为其分配一个指定协议的端口。接着,启动一个goroutine监听该随机端口上的数据,并建立一条从上述端口到service endpoint的TCP/UDP连接。连接成功建立后,填充该service实例的各属性值并在service状态信息表中插人该service实例。然后,开始为这个service配置iptables,即根据该service实例的入口IP地址(包括私有和公有IP地址)、入口端口、proxier监听的IP地址、随机端口等信息,使用iptables在KUBE-PORTALS-CONTAINER和KUBE-PORTALS-HOST链上添加相应的IP数据包转发规则。最后,以service id(由service的namespace, service名称和service端口名组成)为key值,调用LB接口在本地添加一条记录Service实例与service endpoint的映射关系。

  • iptables模式

    ​ iptables式下的proxier只负责在发现存在变更时更新iptables规则,而不再为每个service打开一个本地端口,所有流量转发到pod的工作将交由iptables来完成。OnServiceUpdate的具体工作步骤如下:

    (1) 遍历期望service对象列表,检查每个service对象是否合法,并更新其维护的serviceMap,使其与期望列表保持同步(包括创建新的service、更新过时的service以及删除不再存在的service)。
    (2) 更新iptables规则。注意,这个步骤通过一个名为syncProxyRules的方法完成,在这个方法中涉及了service及endpoint两部分更新对于iptables规则的调整。处于代码完整性和逻辑严密性的考虑,此处将两部分内容合并到此处进行讲解。具体步骤如下:

    1. 确保filter和NAT表中”KUBE-SERVICES”链(chain)的存在,若不存在,则为其创建。

      iptables -t filter -N KUBE-SERVICESiptables -t nat -N KUBE-SERVICES
      
    2. 确保filter表和NAT表中”KUBE-SERV工CES”规则(rule)的存在,若不存在,则为其创建。

      iptables -I OUTPUT -t filter -m comment --comment "kubernetes service portals" -j KUBE-SERVICESiptables -I OUTPUT -t nat -m comment --comment "kubernetes service portals" -j KOBE-SERVICESiptables -I PREROUTING -t nat -m comment --comment "kubernetes service portals" -j KUBE-SERVICES
      
    3. 确保nat表中”KUBE-POSTROUTING”链的存在,若不存在,则为其创建。

      iptables -t nat -N KUBE-POSTROUTING
      
    4. 确保nat.中”KOBE-POSTROUTING”规则的存在,若不存在,则为其创建。

      iptables -I POSTROUTING -t nat -m comment --comment "kubernetes postrouting rules" -j KUBE-POSTROUTING
      
    5. 保存当前filter,将以冒号开头的那些行(即链)存人existingFilterChains中,这是一个以iptables规则Target为键、链为值的map.

      iptables-save -t filter
      
    6. 保存当前nat表,将以冒号开头的那些行(即链)存入existingNATChains中,这同样是一个以iptables规则Target为键、链为值的map。

      iptables-save -t nat
      
    7. 将existingFilterChains中的‘'KUBE-SERVICES”链写人filterChains(一个以*filter为开头的buffer)中。*

    8. 将existingNATChains中”KUBE-SERVICES"、“KUBE-NODEPORTS”、“KUBE-POSTROUTING”、"KUBE-MARK-MASO”链写入natChains(一个以*nat为开头的buffer)中。

    9. 在natRules(一个buffer)中写人如下数据。分别用于之后创建’‘KUBE-POSTROUTING’’和"KUBE-MARK-MASO"规则。

      A KUBE-POSTROUTING -m comment "kubenetes service traffic requiring SNAT" -m mark --mark ${masqueradeMark} -j MASQUERADE
      A KUBE-MARK-MASQ -j MARK --set-xmark ${masquerademark}
      
    10. 遍历proxier维护的serviceMap结构(保存着最新的service对象),为每个service执行如下操作:

    • 首先获得该service对应的iptables链,命名形式为‘'KUBE-SVC-{hash值}”(如”KUBE-SVC-OKIBPPLEBEZLXS53")。

    • 在existingNATChains中查找其是否存在,如果存在,则直接将该链写入natChains,否则在natChains写人一条新链(如:KUBE-SVC-OKIBPPLEBEZLXS53 -[0:0])。

    • 在activeNATChains(一个以链名为键的map)中标记该链为活跃状态。

    • 加人clusterIP对应的iptables规则。根据proxier参数MasqueradeAll的不同(该参数用于决定是否对所有请求都进行源地址转换),在natRules中写入形如如下两条规则中的一条,前一条对应参数为true的情况。

      -A KUBE-SERVICES -m comment --comment "${svcName} cluster IP" -m ${protocol} -p ${protocol) -d ${cluster-ip}/32 --dport ${port} -j ${masqueradeMark} 
      
      -A KUBE-SERVICES -m comment --comment "${svcName} cluster IP" -m ${protocol} -p ${protocol} -d ${cluster-ip}/32 --dport ${port} -j KUBE-SVC-{hash值}
      
    • 处理externalIPs,在natRules中添加如下iptables规则。注意,如果该externalIPs是一个本地IP,则还需要将其对应的port打开。

      -A KUBE-SERVICES -m comment --comment "${svcName} external IP" -m ${protocol} -p ${protocol} -d ${external-ip}/32 --dport ${port} -j ${masqueradeMark} 
      
      -A KUBE-SERVICES -m comment --comment "${svcName}
      external IP" -m ${protocol} -p ${protocol} -d ${external-ip}/32 -dport ${port} -m physdev ! --physdev-is-in -m addrtype ! --src-type LOCAL -j KUBE-SVC-{hash值}
      
      -A KUBE-SERVICES -m comment --comment "${svcName} external IP" -m ${protocol} -p ${protocol} -d ${external-ip}/32 --dport ${port} -m addrtype --dst-type LOCAL -j KUBE-SVC-{hash值}
      
    • 处理loadBalancer ingress,在natRules中添加如下iptables规则。

      -A KUBE-SERVICES -m comment "${svcName} loadbalancer IP" -m ${protocol} -p ${protocol} -d ${ingress-ip}/32 --dport ${port} -j ${masqueradeMark}
      
      -A KUBE-SERVICES -m comment --comment "${svcName} loadbalancer IP" -m ${protocol} -p ${protocol} -d ${ingress-ip}/32 --dport ${port} -j KUBE-SVC-{hash值}
      
    • 处理nodePort。首先要在本地打开一个端口,然后在natRules添加如下iptables规则。

      -A KUBE_NODEPORTS -m comment --commetn "${svcName}" -m ${protocol} -p ${protocol} -d ${ingress-ip}/32 --dport ${port} -j ${masqueradeMark}
      
      -A KUBE-NODEPORTS -m comment --comment "${svcName}" -m ${protocol} -p ${protocol} -d ${ingress-ip}/32 --dport ${port} -j KUBE-SVC-{hash值}
      
    • 如果一个service没有可用的后端endpoint,那么需要拒绝对其的请求。在filterRules中添加如下iptables规则。

      -A KUBE_SERVICES -m comment --comment "${svcName} has no endpoints" -m ${protocol} -p ${protocol} -d ${cluster-ip}/32 --dport ${port} -j REJECT
      

      至此,所有与service相关的iptables规则就已经全部创建完毕了。接下来,将为endpoint创建链和iptables规则。注意,下面的步骤12仍然处于步骤10中的循环里,即遍历service中。

    1. 遍历proxier维护的endpointsMap结构(以service为键,对应的endpoint列表为值的map),为每个endpoint执行如下操作:

      • 获得每个endpoint对应的iptables链,命名形式为”KUBE-SEP-{hash值}”(如KUBE-SEP-XL4YDER4UGY502IL)。
      • 在existingNATChains中查找该链是否存在,如果存在,则直接将该链写人natChains,否则在natChains写人一条新链(如:KUBE-SEP-XL4YDER4UGY502IL -[0:0])。
      • 在activeNATChains中将该链标记为活跃状态。
    2. 首先考虑session affinity规则。为启用了该功能的service在natRules中加人如下iptables规则。

      -A KUBE-SVC-{hash值} -m comment --comment ${svcName} -m recent --name KUBE-SEP-{hash值} --rcheck --seconds${stickyMaxAgeSeconds} --reap -j KUBE-SEP-{hash值}
      
    3. 接下来采用load balance规则,将一个service的流量分散到各个endpoint上。

      • 对于除了最后一个endpoint的其他endpoint,在natRules中加人如下规则。可以看到,这里出现了一个随机分配的机制,每条规则被选中的概率是1/(该service对应的endpoint数目-1)。

        -A KUBE-SVC-{hash值} -m comment --comment ${svcName} -m statistic --mode random --probability 1.0/(${endpoint-number}-1) -j KUBE-SEP-{hash值}
        
      • 对于最后一个endpoint,在natRules中加入如下规则,说明在此前各条均没有匹配到iptables规则的情况下,则一定从这个endpoint来接收访问该service的请求。

        -A KUBE-SVC-{hash值} -m comment --comment ${svcName} -j KUBE-SEP-{hash值}
        
      • 创建导向endpoint的iptables规则,在natRules中加人如下iptables规则,进行源地址解析。

        -A KUBE-SEP-{hash值} -m comment --comment ${svcName} -s ${endpoint(pod)-ip} -j ${masqueradeMark}
        
      • 进行目的地址解析。在natRules中加人如下iptables规则。如果该service有session affnity规则,加入第一条iptables规则,否则加人第二条。

        -A KUBE-SEP-{hash值} -m comment --comment ${svcName} -m recent --name KUBE-SEP-{hash值} --set -m ${protocol} -p ${protocol} -j DNAT --to-destination ${endpoints(pod)-ip}
        
        -A KUBE-SEP-{hash值} -m comment --comment ${svcName} -m ${protocol} -p ${protocol} -j DNAT --to-destination ${endpoint(pod)-ip}
        

        至此,所有导向endpoint的iptables规则也基本创建完毕了。

    4. 清除existingNATChains中不处于活跃状态的service和endpoint对应的链。并且在natChains写入这些链,同时在natRules写入-X ${chain},使得可以安全地删除这些链。

    5. 在natRules中写入最后一条iptables规则,用于访问”KUBE-SERVICES”的流量接入到"KUBE-NODEPORTS"。

      -A KUBE-SERVICES -m comment --comment "kubernetes service nodeports;NOTE: this must be the last rule in this chain" -m addrtype --dst-type LOCAL -j KUBE-NODEPORTS
      
    6. 最后,为filterRules和natRules写人COMMIT,并且将其拼接起来,并通过iptables-restore将其导入到iptables中,完成根据service和endpoint的更新而同步iptables规则的任务。

    7. 处理不需要再占用端口的释放。

    8. 删除nat表中旧的源地址转换的iptables规则。

      iptables -t nat -D POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4d415351 -j KUBE-MARK-MASQ
      

endpointsHandler

​ endpointHandler在选择后端时默认采用Round Robin算法,同时需要兼顾session affinity等要求。

  • userspace模式

    ​ 前面已经介绍过,当访问请求经过iptables转发至proxier之后,选择一个pod的工作就需要交给endpointsHandler。 userspace模式下的endpointsHandler本质上是一个loadBalancer ( LB),它不仅能够按照策略选择出一个service endpoint(后端pod ),还需要能实时更新并维护service对应的endpoint实例信息。这两个过程分别对应loadBalancer的两个处理逻辑,即NextEndpoint和OnEndpointsUpdate。下面将逐一进行分析。

    • NextEndpoint

      ​ NextEndpoint方法核心调度算法是Round-Robin,每次一个请求到达,它的目的地都应该“下一个pod"。但是在LoadBalancer中,这个Round-Robin算法还能够同时考虑“Session Affinity”的因素,即如果用户指定这个service需要考虑会话亲密性,那么对于一个给定的客户端,NextEndpoint会一直返回它上一次访问到的那个pod直至会话过期。这个具体的工作流程如下所示。

      ​ (1) 根据请求中提供的service id(由namespace, service名和service端口号组成),查找该service代理的pod端点列表(一个ip:port形式的字符串链表)、当前的endpoint的索引值和该service的Session Affinity(SA)属性等。
      ​ (2) Session Affinity有两种类型:None和ClientIP,如果SA的类型是ClientIP,则来自同一个客户端IP的请求在一段时间内都将重定向到同一个后端pod,这样也就简洁做到了访问的会话粘性(Session Sticky ), SA的最长保活时间决定了这个时间段的长度,默认值是180分钟;如果SA的类型为空(None),则不进行任何会话记录。

      ​ (3) 假如这个service不需要SA功能,或者上述SA已经超时了,那么IoadBalance啥直接将当前的endpoint索引值++1,再对endpoint列表长度取余作为下一个可用endpoint的索引值。

      ​ (4) 当然,如果是由于SA超时引起的步骤(3), LoadBalancer还会为步骤(3)中最终被访问的那个pod建立Session Affinity实例并设置时间戳,这样下次这个ClientIP来的请求就一定会继续落在这个pod上。

    • OnEndpointsUpdate

      ​ 知道了LoadBalancer如何选择一个“合适”的pod,再来看一下它如何保证它所知道的被代理pod列表总是最“准确”的。

      ​ 由于所有被代理pod的变化最后都会反映到etcd里面对应的pod数据上,所以存储在etcd中的pod对象总可以认为是用户的期望值,代表了endpoint的“理想世界”,而LoadBalancer内存中的endpoint对象则反映了service对象与实际后端pod的“现实世界”。因此,OnEndpointsUpdate方法的作用就是用“理想世界”的endpoint对象同步“现实世界”的endpoint,这个同步的过程就是一旦etcd中的endpoint信息发生变化,那么LoadBalancer就会把endpoint列表(理想世界)加载进来,然后通过对比注册新添的endpoint到自己的service信息中,或者删除那些已经不存在的endpoint ,同时更新service Affnity数据。

  • iptables模式

    ​ 正如上文所述,iptables式下的endpointsHandle体质上由proxier担任。它不再处理具体的选
    取service后端endpoint的工作,而只负责跟进endpoint对应的iptables规则。

    • OnEndpointsUpdate方法

      ​ 接收到etcd中en即oint对象的更新列表后,更新其维护的endpointsMap,包括更新、创建和删除其中的service和endpoint对应记录。
      ​ 其后的关键步骤syncProxyRules已经在上面展开,此处不再赘述。

核心组件协作流程

​ 至此,Kubernetes中主要的组件我们都有了大致的了解。接下来,我们梳理一下在Kubernetes的全局视图下,当执行一些指令时这些组件之间是如何协作的,这样的流程解析对于读者将来对Kubernetes进行调试、排错和二次开发都是非常有帮助的。

创建pod

​ 如图所示,当客户端发起一个创建pod的请求后,kubectl向APIServer的/pods端点发送一个HTTP POST请求,请求的内容即客户端提供的pod资源配置文件。

在这里插入图片描述

​ APIServer收到该REST API请求后会进行一系列的验证操作,包括用户认证、授权和资源配额控制等。验证通过后,APIServer调用etcd的存储接口在后台数据库中创建一个pod对象。

​ scheduler使用APIServer的API,定期从etcd获取/监测系统中可用的工作节点列表和待调度pod,并使用调度策略为pod选择一个运行的工作节点,这个过程也就是绑定(bind)。

​ 绑定成功后,scheduler会调用APIServer的API在etcd中创建一个 binding对象,描述在一个工作节点上绑定运行的所有pod信息。同时kubelet会监听APIServer上pod的更新,如果发现有pod更新信息,则会自动在podWorker的同步周期中更新对应的pod。

​ 这正是Kubernetes实现中“一切皆资源”的体现,即所有实体对象,消息等都是作为etcd里保存起来的一种资源来对待,其他所有组件间协作都通过基于APIServer的数据交换,组件间一种松耦合的状态。

创建replication controller

​ 如图所示,当客户端发起一个创建replication controller的请求后,kubectl向APIServer的/controllers端点发送一个HTTP POST请求,请求的内容即客户端提供的replication controller资源配置文件。

在这里插入图片描述

​ 与创建pod类似,APIServer收到该REST API请求后会进行一系列的验证操作。验证通过后,APIServer调用etcd的存储接口在后台数据库中创建一个replication controller对象。
​ controller manager会定期调用APIServer的API获取期望replication controller对象列表。再遍历期望RC对象列表,对每个RC,调用APIServer的API获取对应的pod集的实际状态信息。然后,同步replication controller的pod期望值与pod的实际状态值,创建指定副本数的pod。

创建service

​ 如图所示,当客户端发起一个创建service的请求后,kubectl向APIServer的/services端点发送一个HTTP POST请求,请求的内容即客户端提供的service资源配置文件。
​ 同样,APIServer收到该REST API请求后会进行一系列的验证操作。验证通过后,APIServer调用etcd的存储接口在后台数据库中创建一个service对象。

在这里插入图片描述

​ kube-proxy会定期调用APIServer的API获取期望service对象列表,然后再遍历期望service对象列表。对每个service,调用APIServer的API获取对应的pod集的信息,并从pod信息列表中提取pod IP和容器端口号封装成endpoint对象,然后调用APIServer的API在etcd中创建该对象。

  • userspace kube-proxy

    ​ 对每个新建的service, kube-proxy会为其在本地分配一个随机端口号,并相应地创建一个ProxySocket,随后使用iptablesl具在宿主机上建立一条从ServiceProxy到ProxySocket的链路。同时,kube-prxoy后台启动一个协程监听ProxySocket上的数据并根据endpoint实例的信息(例如IP,port和session affinity属性等)将来自客户端的请求转发给相应的service后端pod。

  • iptables kube-proxy

    ​ 对于每个新建的service,kube-proxy会为其创建对应的iptables。来自客户端的请求将由内核态iptables负责转发给service后端pod完成。

    ​ 最后,kube-proxy会定期调用APIServer的API获取期望service和endpoint列表并与本地的service和endpoint实例同步。

猜你喜欢

转载自blog.csdn.net/fy_long/article/details/88569842