【k8s调度】梳理调度相关知识与device plugin

资源

  • CPU —— 可压缩资源 —— 资源不足时,只会”饥饿“,不会退出
  • 内存 —— 不可压缩资源 —— 资源不足时,会 OOM 被内核杀死

请求与限制

  • 调度时候, kube-scheduler 按照 Requests 的值进行计算
  • 真正设置 Cgroups 限制时,kubelet 会按照 limit 的值进行设置

Qos 模型

  • 作用:根据 Pod 内 container 的 requests 和 limits 的不同配置,分配不同 qosClass 级别,
  • 该 qosClass 主要作用是:当宿主机资源紧张的时候,kubelet 对 Pod 进行 Eviction(即资源回收)时需要用到的。
  • 具体地说,当 Kubernetes 所管理的宿主机上不可压缩资源短缺时,就有可能触发 Eviction。比如,可用内存(memory.available)、可用的宿主机磁盘空间(nodefs.available),以及容器运行时镜像存储空间(imagefs.available)等等。
apiVersion: v1
kind: Pod
metadata:
  name: qos-demo
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-ctr
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "700m"
      requests:
        memory: "200Mi"
        cpu: "700m"
Guaranteed
  • 同时设置 requests 和 limits,且 requests 和 limit 的值相等

  • 仅设置 limits,没有设置 requests 时,Kubernetes 会自动为 Pod 设置与 limits 相同的 requests,也属于 Guaranteed

  • Pod 创建后,其 qosClass 字段就会被自动设置为 Guaranteed

Burstable
  • 当 Pod 不满足 Guaranteed 的条件,但至少有一个 Container 设置了 requests。那么这个 Pod 就会被划分到 Burstable 类别
BestEffort
  • 一个 Pod 既没有设置 requests,也没有设置 limits,那么它的 QoS 类别就是 BestEffort
驱逐的配置
kubelet --eviction-hard=imagefs.available<10%,memory.available<500Mi,nodefs.available<5%,nodefs.inodesFree<5% --eviction-soft=imagefs.available<30%,nodefs.available<10% --eviction-soft-grace-period=imagefs.available=2m,nodefs.available=2m --eviction-max-pod-grace-period=600
  • 可以看到 Eviction 在 Kubernetes 里其实分为 Soft 和 Hard 两种模式。
    • 其中,Soft Eviction 允许你为 Eviction 过程设置一段“优雅时间”,比如上面例子里的 imagefs.available=2m,就意味着当 imagefs 不足的阈值达到 2 分钟之后,kubelet 才会开始 Eviction 的过程。
    • 而 Hard Eviction 模式下,Eviction 过程就会在阈值达到之后立刻开始。
  • Kubernetes 计算 Eviction 阈值的数据来源,主要依赖于从 Cgroups 读取到的值,以及使用 cAdvisor 监控到的数据。
  • 当宿主机的 Eviction 阈值达到后,就会进入 MemoryPressure 或者 DiskPressure 状态(修改 node Condition 的状态,同时给 node 打上 Taint 污点),从而避免新的 Pod 被调度到这台宿主机上。
  • 而当 Eviction 发生的时候,kubelet 具体会挑选哪些 Pod 进行删除操作,就需要参考这些 Pod 的 QoS 类别了。
    • 首当其冲的,自然是 BestEffort 类别的 Pod。其次,是属于 Burstable 类别、并且发生“饥饿”的资源使用量已经超出了 requests 的 Pod。最后,才是 Guaranteed 类别。
    • 并且,Kubernetes 会保证只有当 Guaranteed 类别的 Pod 的资源使用量超过了其 limits 的限制,或者宿主机本身正处于 Memory Pressure 状态时,Guaranteed 的 Pod 才可能被选中进行 Eviction 操作。当然,对于同 QoS 类别的 Pod 来说,Kubernetes 还会根据 Pod 的优先级来进行进一步地排序和选择。
Cpuset 特性(独占)
  • 在使用容器的时候,你可以通过设置 cpuset 把容器绑定到某个 CPU 的核上,而不是像 cpushare 那样共享 CPU 的计算能力。
    • 这种情况下,由于操作系统在 CPU 之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升。
    • 事实上,cpuset 方式,是生产环境里部署在线应用类型的 Pod 时,非常常用的一种方式。
  • 可是,这样的需求在 Kubernetes 里又该如何实现呢?其实非常简单。
    • 首先,你的 Pod 必须是 Guaranteed 的 QoS 类型;
    • 然后,你只需要将 Pod 的 CPU 资源的 requests 和 limits 设置为同一个相等的整数值即可
spec:
  containers:
  - name: nginx
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "2"
      requests:
        memory: "200Mi"
        cpu: "2"

调度

调度过程
  1. 从集群所有的节点中,根据调度算法挑选出所有可以运行该 Pod 的节点;
  2. 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。

在具体的调度流程中,默认调度器会首先调用一组叫作 Predicate 的调度算法,来检查每个 Node。然后,再调用一组叫作 Priority 的调度算法,来给上一步得到的结果里的每个 Node 打分。最终的调度结果,就是得分最高的那个 Node。

调度成功的标志:调度器对一个 Pod 调度成功,实际上就是将它的 spec.nodeName 字段填上调度结果的节点名字。

  • Kubernetes 的调度器的核心,实际上就是两个相互独立的控制循环
    在这里插入图片描述
  1. 第一个控制循环,我们可以称之为 Informer Path。它的主要目的,是启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象的变化。

    • 比如,当一个待调度 Pod(即:它的 nodeName 字段是空的)被创建出来之后,调度器就会通过 Pod Informer 的 Handler,将这个待调度 Pod 添加进调度队列。
    • 默认情况下,Kubernetes 的调度队列是一个 PriorityQueue(优先级队列),并且当某些集群信息发生变化的时候,调度器还会对调度队列里的内容进行一些特殊操作。这里的设计,主要是出于调度优先级和抢占的考虑,
    • Kubernetes 的默认调度器还要负责对调度器缓存(即:scheduler cache)进行更新。事实上,Kubernetes 调度部分进行性能优化的一个最根本原则,就是尽最大可能将集群信息 Cache 化,以便从根本上提高 Predicate 和 Priority 调度算法的执行效率
  2. 第二个控制循环,是调度器负责 Pod 调度的主循环,我们可以称之为 Scheduling Path。

    • **过滤:**Scheduling Path 的主要逻辑,就是不断地从调度队列里出队一个 Pod。然后,调用 Predicates 算法进行“过滤”。这一步“过滤”得到的一组 Node,就是所有可以运行这个 Pod 的宿主机列表。当然,Predicates 算法需要的 Node 信息,都是从 Scheduler Cache 里直接拿到的,这是调度器保证算法执行效率的主要手段之一。
    • **打分:**接下来,调度器就会再调用 Priorities 算法为上述列表里的 Node 打分,分数从 0 到 10。得分最高的 Node,就会作为这次调度的结果。
    • **预绑定:**调度算法执行完成后,**调度器就需要将 Pod 对象的 nodeName 字段的值,修改为上述 Node 的名字。**这个步骤在 Kubernetes 里面被称作 Bind。
    • 假设(更新 Cache 缓存):但是,为了不在关键调度路径里远程访问 APIServer,Kubernetes 的默认调度器在 **Bind 阶段,只会更新 Scheduler Cache 里的 Pod 和 Node 的信息。**这种基于“乐观”假设的 API 对象更新方式,在 Kubernetes 里被称作 Assume。
    • **真正绑定(更新 apiserver etcd):Assume 之后,调度器才会创建一个 Goroutine 来异步地向 APIServer 发起更新 Pod 的请求,来真正完成 Bind 操作。**如果这次异步的 Bind 过程失败了,其实也没有太大关系,等 Scheduler Cache 同步之后一切就会恢复正常。
    • **运行前kubelet验证:**当然,正是由于上述 Kubernetes 调度器的“乐观”绑定的设计,当一个新的 Pod 完成调度需要在某个节点上运行起来之前,该节点上的 kubelet 还会通过一个叫作 Admit 的操作来再次验证该 Pod 是否确实能够运行在该节点上。这一步 Admit 操作,实际上就是把一组叫作 GeneralPredicates 的、最基本的调度算法,比如:“资源是否可用”“端口是否冲突”等再执行一遍,作为 kubelet 端的二次确认。
    • 无锁化:除了上述的“Cache 化”和“乐观绑定”,Kubernetes 默认调度器还有一个重要的设计,那就是“无锁化”。
      • 在 Scheduling Path 上,调度器会启动多个 Goroutine 以节点为粒度并发执行 Predicates 算法,从而提高这一阶段的执行效率。而与之类似的,Priorities 算法也会以 MapReduce 的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。
      • Kubernetes 调度器只有对调度队列和 Scheduler Cache 进行操作时,才需要加锁。而这两部分操作,都不在 Scheduling Path 的算法执行路径上。
      • Kubernetes 调度器的上述设计思想,也是在集群规模不断增长的演进过程中逐步实现的。尤其是 “Cache 化”,这个变化其实是最近几年 Kubernetes 调度器性能得以提升的一个关键演化
过滤策略
  1. 第一种类型,叫作 GeneralPredicates

    • 顾名思义,这一组过滤规则,负责的是最基础的调度策略。比如,PodFitsResources 计算的就是宿主机的 CPU 和内存资源等是否够用。
    • PodFitsResources 检查的只是 Pod 的 requests 字段
      • Kubernetes 的调度器并没有为 GPU 等硬件资源定义具体的资源类型,而是统一用一种名叫 Extended Resource 的、Key-Value 格式的扩展字段来描述的
      • Pod 通过alpha.kubernetes.io/nvidia-gpu=2这样的定义方式,声明使用了两个 NVIDIA 类型的 GPU
      • 而在 PodFitsResources 里面,调度器其实并不知道这个字段 Key 的含义是 GPU,而是直接使用后面的 Value 进行计算。
      • 当然,在 Node 的 Capacity 字段里,你也得相应地加上这台宿主机上 GPU 的总数,比如:alpha.kubernetes.io/nvidia-gpu=4。这些流程,我在后面讲解 Device Plugin 的时候会详细介绍。
    • PodFitsHost 检查的是,宿主机的名字是否跟 Pod 的 spec.nodeName 一致
    • PodFitsHostPorts 检查的是,Pod 申请的宿主机端口(spec.nodePort)是不是跟已经被使用的端口有冲突
    • PodMatchNodeSelector 检查的是,Pod 的 nodeSelector 或者 nodeAffinity 指定的节点,是否与待考察节点匹配,等等
    • GeneralPredicates,正是 Kubernetes 考察一个 Pod 能不能运行在一个 Node 上最基本的过滤条件。所以,GeneralPredicates 也会被其他组件(比如 kubelet)直接调用。
      • 上面已经提到过,kubelet 在启动 Pod 前,会执行一个 Admit 操作来进行二次确认。这里二次确认的规则,就是执行一遍 GeneralPredicates。
  2. 第二种类型,是与 Volume 相关的过滤规则。

    • 这一组过滤规则,负责的是跟容器持久化 Volume 相关的调度策略。
    • NoDiskConflict 检查的条件,是多个 Pod 声明挂载的持久化 Volume 是否有冲突
      • 比如,AWS EBS 类型的 Volume,是不允许被两个 Pod 同时使用的。所以,当一个名叫 A 的 EBS Volume 已经被挂载在了某个节点上时,另一个同样声明使用这个 A Volume 的 Pod,就不能被调度到这个节点上了。
    • MaxPDVolumeCountPredicate 检查的条件,则是一个节点上某种类型的持久化 Volume 是不是已经超过了一定数目,如果是的话,那么声明使用该类型持久化 Volume 的 Pod 就不能再调度到这个节点了
    • VolumeZonePredicate,则是检查持久化 Volume 的 Zone(高可用域)标签,是否与待考察节点的 Zone 标签相匹配
    • VolumeBindingPredicate 的规则。它负责检查的,是该 Pod 对应的 PV 的 nodeAffinity 字段,是否跟某个节点的标签相匹配
  3. 第三种类型,是宿主机相关的过滤规则

    • 这一组规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件。
    • PodToleratesNodeTaints,负责检查的就是我们前面经常用到的 Node 的“污点”机制。只有当 Pod 的 Toleration 字段与 Node 的 Taint 字段能够匹配的时候,这个 Pod 才能被调度到该节点上。
    • NodeMemoryPressurePredicate,检查的是当前节点的内存是不是已经不够充足,如果是的话,那么待调度 Pod 就不能被调度到该节点上。
  4. 第四种类型,是 Pod 相关的过滤规则

    • 这一组规则,跟 GeneralPredicates 大多数是重合的。
    • 而比较特殊的,是 PodAffinityPredicate。这个规则的作用,是检查待调度 Pod 与 Node 上的已有 Pod 之间的亲密(affinity)和反亲密(anti-affinity)关系。比如下面这个例子:
  • 并行执行各个调度插件,每个插件对所有 node 进行过滤,结果是进行 merge 合并
  • 在实际的执行过程中,调度器里关于集群和 Pod 的信息都已经缓存化,所以这些算法的执行过程还是比较快的。
    • 此外,对于比较复杂的调度算法来说,比如 PodAffinityPredicate,它们在计算的时候不只关注待调度 Pod 和待考察 Node,还需要关注整个集群的信息,比如,遍历所有节点,读取它们的 Labels。这时候,Kubernetes 调度器会在为每个待调度 Pod 执行该调度算法之前,先将算法需要的集群信息初步计算一遍,然后缓存起来。这样,在真正执行该算法的时候,调度器只需要读取缓存信息进行计算即可,从而避免了为每个 Node 计算 Predicates 的时候反复获取和计算整个集群的信息。

优先级与抢占

优先级
  • Kubernetes 里,优先级和抢占机制是在 1.10 版本后才逐步可用的。要使用这个机制,你首先需要在 Kubernetes 里提交一个 PriorityClass 的定义,如下所示:

    • apiVersion: scheduling.k8s.io/v1beta1
      kind: PriorityClass
      metadata:
        name: high-priority
      value: 1000000
      globalDefault: false
      description: "This priority class should be used for high priority service pods only."
      
  • Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过 1000000000(10 亿,1 billion),并且值越大代表优先级越高。而超出 10 亿的值,其实是被 Kubernetes 保留下来分配给系统 Pod 使用的。显然,这样做的目的,就是保证系统 Pod 不会被用户抢占掉。

  • 而一旦上述 YAML 文件里的 globalDefault 被设置为 true 的话,那就意味着这个 PriorityClass 的值会成为系统的默认值。而如果这个值是 false,就表示我们只希望声明使用该 PriorityClass 的 Pod 拥有值为 1000000 的优先级,而对于没有声明 PriorityClass 的 Pod 来说,它们的优先级就是 0。

  • 在创建了 PriorityClass 对象之后,Pod 就可以声明使用它了,如下所示:

    • apiVersion: v1
      kind: Pod
      metadata:
        name: nginx
        labels:
          env: test
      spec:
        containers:
        - name: nginx
          image: nginx
          imagePullPolicy: IfNotPresent
        priorityClassName: high-priority
      
    • 可以看到,这个 Pod 通过 priorityClassName 字段,声明了要使用名叫 high-priority 的 PriorityClass。当这个 Pod 被提交给 Kubernetes 之后,Kubernetes 的 PriorityAdmissionController 就会自动将这个 Pod 的 spec.priority 字段设置为 1000000。

  • 前面的文章中曾为你介绍过,调度器里维护着一个调度队列。所以,当 Pod 拥有了优先级之后,高优先级的 Pod 就可能会比低优先级的 Pod 提前出队,从而尽早完成调度过程。这个过程,就是“优先级”这个概念在 Kubernetes 里的主要体现。

抢占
  • 而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。这个过程,就是“抢占”这个概念在 Kubernetes 里的主要体现。

    • 接下来会把待调度的高优先级 Pod 称为“抢占者”(Preemptor)。
  • 当上述抢占过程发生时,抢占者并不会立刻被调度到被抢占的 Node 上。事实上,调度器只会将抢占者的 spec.nominatedNodeName 字段,设置为被抢占的 Node 的名字。**然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。**这当然也就意味着,即使在下一个调度周期,调度器也不会保证抢占者一定会运行在被抢占的节点上。

    • 原因就是:调度器只会通过标准的 DELETE API 来删除被抢占的 Pod,所以,这些 Pod 必然是有一定的“优雅退出”时间(默认是 30s)的。而在这段时间里,其他的节点也是有可能变成可调度的,或者直接有新的节点被添加到这个集群中来。所以,鉴于优雅退出期间,集群的可调度性可能会发生的变化,把抢占者交给下一个调度周期再处理,是一个非常合理的选择。
    • 简单来说:就是抢占,需要删除低优先级 Pod,而在删除低优先级 Pod 这段时间内,集群可能会有变化(新增节点之类),或者有可能该高优先级 Pod 会被更高优先级 Pod 抢占了 Node
    • 而在抢占者等待被调度的过程中,如果有其他更高优先级的 Pod 也要抢占同一个节点,那么调度器就会清空原抢占者的 spec.nominatedNodeName 字段,从而允许更高优先级的抢占者执行抢占,并且,这也就使得原抢占者本身,也有机会去重新抢占其他节点。这些,都是设置 nominatedNodeName 字段的主要目的。
  • 接下来,我就为你详细讲述一下这其中的原理

    • 抢占发生的原因,一定是一个高优先级的 Pod 调度失败。这一次,我们还是称这个 Pod 为“抢占者”,称被抢占的 Pod 为“牺牲者”(victims)。

    • 而 Kubernetes 调度器实现抢占算法的一个最重要的设计,就是在调度队列的实现里,使用了两个不同的队列。

      • 第一个队列,叫作 activeQ。凡是在 activeQ 里的 Pod,都是下一个调度周期需要调度的对象。所以,当你在 Kubernetes 集群里新创建一个 Pod 的时候,调度器会将这个 Pod 入队到 activeQ 里面。而我在前面提到过的、调度器不断从队列里出队(Pop)一个 Pod 进行调度,实际上都是从 activeQ 里出队的。
      • 第二个队列,叫作 unschedulableQ,专门用来存放调度失败的 Pod。而这里的一个关键点就在于,当一个 unschedulableQ 里的 Pod 被更新之后,调度器会自动把这个 Pod 移动到 activeQ 里,从而给这些调度失败的 Pod “重新做人”的机会。
    • 回到我们的抢占者调度失败这个时间点上来

      • 调度失败之后,抢占者就会被放进 unschedulableQ 里面。

      • 然后,这次失败事件就会触发调度器为抢占者寻找牺牲者的流程。

      • 第一步,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点。这是因为有很多 Predicates 的失败是不能通过抢占来解决的。比如,PodFitsHost 算法(负责的是,检查 Pod 的 nodeSelector 与 Node 的名字是否匹配),这种情况下,除非 Node 的名字发生变化,否则你即使删除再多的 Pod,抢占者也不可能调度成功。

      • 第二步,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程。

        • 这里的抢占过程很容易理解。调度器会检查缓存副本里的每一个节点,然后从该节点上最低优先级的 Pod 开始,逐一“删除”这些 Pod。而每删除一个低优先级 Pod,调度器都会检查一下抢占者是否能够运行在该 Node 上。一旦可以运行,调度器就记录下这个 Node 的名字和被删除 Pod 的列表,这就是一次抢占过程的结果了。
        • 当遍历完所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果。而这一步的判断原则,就是尽量减少抢占对整个系统的影响。比如,需要抢占的 Pod 越少越好,需要抢占的 Pod 的优先级越低越好,等等
        • 在得到了最佳的抢占结果之后,这个结果里的 Node,就是即将被抢占的 Node;被删除的 Pod 列表,就是牺牲者。所以接下来,调度器就可以真正开始抢占的操作了,这个过程,可以分为三步。
      • 找到牺牲者后,进行真正的抢占

        • 第一步,调度器会检查牺牲者列表,清理这些 Pod 所携带的 nominatedNodeName 字段。
        • 第二步,调度器会把抢占者的 nominatedNodeName,设置为被抢占的 Node 的名字。
        • 第三步,调度器会开启一个 Goroutine,同步地删除牺牲者。
        • 而第二步对抢占者 Pod 的更新操作,就会触发到我前面提到的“重新做人”的流程,从而让抢占者而第二步对抢占者 Pod 的更新操作,就会触发到我前面提到的“重新做人”的流程,从而让抢占者在下一个调度周期重新进入调度流程。
        • 所以接下来,调度器就会通过正常的调度流程把抢占者调度成功。这也是为什么,我前面会说调度器并不保证抢占的结果:在这个正常的调度流程里,是一切皆有可能的。
      • 不过,对于任意一个待调度 Pod 来说,因为有上述抢占者的存在,它的调度过程,其实是有一些特殊情况需要特殊处理的。

        • 具体来说,在为某一对 Pod 和 Node 执行 Predicates 算法的时候,如果待检查的 Node 是一个即将被抢占的节点,即:调度队列里有 nominatedNodeName 字段值是该 Node 名字的 Pod 存在(可以称之为:“潜在的抢占者”)。那么,调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍。

        • 第一遍, 调度器会假设上述“潜在的抢占者”已经运行在这个节点上,然后执行 Predicates 算法;

        • 第二遍, 调度器会正常执行 Predicates 算法,即:不考虑任何“潜在的抢占者”。

        • 而只有这两遍 Predicates 算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定(bind)的。

        • 不难想到,这里需要执行第一遍 Predicates 算法的原因,是由于 InterPodAntiAffinity 规则的存在。

          • 由于 InterPodAntiAffinity 规则关心待考察节点上所有 Pod 之间的互斥关系,所以我们在执行调度算法时必须考虑,如果抢占者已经存在于待考察 Node 上时,待调度 Pod 还能不能调度成功。

          • 当然,这也就意味着,我们在这一步只需要考虑那些优先级等于或者大于待调度 Pod 的抢占者。毕竟对于其他较低优先级 Pod 来说,待调度 Pod 总是可以通过抢占运行在待考察 Node 上。(只需要考虑优先级高于当前 Pod 的抢占者,优先级低于自己的不需要考虑,因为自己先抢占)

        • 而我们需要执行第二遍 Predicates 算法的原因,则是因为“潜在的抢占者”最后不一定会运行在待考察的 Node 上。关于这一点,我在前面已经讲解过了:Kubernetes 调度器并不保证抢占者一定会运行在当初选定的被抢占的 Node 上。

Device Plugin

gpu

以 NVIDIA 的 GPU 设备为例,上面的需求就意味着当用户的容器被创建之后,这个容器里必须出现如下两部分设备和目录:

  1. GPU 设备,比如 /dev/nvidia0;
  2. GPU 驱动目录,比如 /usr/local/nvidia/*。

其中,GPU 设备路径,正是该容器启动时的 Devices 参数;而驱动目录,则是该容器启动时的 Volume 参数。所以,在 Kubernetes 的 GPU 支持的实现里,kubelet 实际上就是将上述两部分内容,设置在了创建该容器的 CRI (Container Runtime Interface)参数里面。这样,等到该容器启动之后,对应的容器里就会出现 GPU 设备和驱动的路径了。

Kubernetes 的源码中,Pod 没有 gpu 的资源字段,因此pod requests 和 limits 中定义的 gpu 资源字段,在源码中是用 Extended Resource(ER)特殊字段来承接的

不过,Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段,而是使用了一种叫作 Extended Resource(ER)的特殊字段来负责传递 GPU 的信息。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: cuda-vector-add
spec:
  restartPolicy: OnFailure
  containers:
    - name: cuda-vector-add
      image: "k8s.gcr.io/cuda-vector-add:v0.1"
      resources:
        limits:
          nvidia.com/gpu: 1
  • 可以看到,在上述 Pod 的 limits 字段里,这个资源的名称是nvidia.com/gpu,它的值是 1。也就是说,这个 Pod 声明了自己要使用一个 NVIDIA 类型的 GPU。
  • 而在 kube-scheduler 里面,它其实并不关心这个字段的具体含义,只会在计算的时候,一律将调度器里保存的该类型资源的可用量,直接减去 Pod 声明的数值即可。所以说,Extended Resource,其实是 Kubernetes 为用户设置的一种对自定义资源的支持。
  • 当然,为了能够让调度器知道这个自定义类型的资源在每台宿主机上的可用量,宿主机节点本身,就必须能够向 API Server 汇报该类型资源的可用数量。在 Kubernetes 里,各种类型的资源可用量,其实是 Node 对象 Status 字段的内容,比如下面这个例子
apiVersion: v1
kind: Node
metadata:
  name: node-1
...
Status:
  Capacity:
   cpu:  2
   memory:  2049008Ki

而为了能够在上述 Status 字段里添加自定义资源的数据,你就必须使用 PATCH API 来对该 Node 对象进行更新,加上你的自定义资源的数量。这个 PATCH 操作,可以简单地使用 curl 命令来发起,如下所示:

# 启动 Kubernetes 的客户端 proxy,这样你就可以直接使用 curl 来跟 Kubernetes  的API Server 进行交互了
$ kubectl proxy

# 执行 PACTH 操作
$ curl --header "Content-Type: application/json-patch+json" \
--request PATCH \
--data '[{"op": "add", "path": "/status/capacity/nvidia.com/gpu", "value": "1"}]' \
http://localhost:8001/api/v1/nodes/<your-node-name>/status

PATCH 操作完成后,你就可以看到 Node 的 Status 变成了如下所示的内容:

apiVersion: v1
kind: Node
...
Status:
  Capacity:
   cpu:  2
   memory:  2049008Ki
   nvidia.com/gpu: 1  # 通过上面 patch 命令,为 node 手动增加的资源容量记录,表示该 node 有一个 gpu
  • 这样在调度器里,它就能够在缓存里记录下 node-1 上的nvidia.com/gpu类型的资源的数量是 1。

  • 当然,在 Kubernetes 的 GPU 支持方案里,你并不需要真正去做上述关于 Extended Resource 的这些操作。在 Kubernetes 中,对所有硬件加速设备进行管理的功能,都是由一种叫作 Device Plugin 的插件来负责的。这其中,当然也就包括了对该硬件的 Extended Resource 进行汇报的逻辑。

device plugin
  • Kubernetes 的 Device Plugin 机制,我可以用如下所示的一幅示意图来和你解释清楚。

在这里插入图片描述

  • 我们先从这幅示意图的右侧开始看起
  1. 首先,对于每一种硬件设备,都需要有它所对应的 Device Plugin 进行管理,这些 Device Plugin,都通过 gRPC 的方式,同 kubelet 连接起来。以 NVIDIA GPU 为例,它对应的插件叫作NVIDIA GPU device
  2. plugin。这个 Device Plugin 会通过一个叫作 ListAndWatch 的 API,定期向 kubelet 汇报该 Node 上 GPU 的列表。比如,在我们的例子里,一共有三个 GPU(GPU0、GPU1 和 GPU2)。这样,kubelet 在拿到这个列表之后,就可以直接在它向 APIServer 发送的心跳里,以 Extended Resource 的方式,加上这些 GPU 的数量,比如nvidia.com/gpu=3。所以说,用户在这里是不需要关心 GPU 信息向上的汇报流程的。
  3. 需要注意的是,ListAndWatch 向上汇报的信息,只有本机上 GPU 的 ID 列表,而不会有任何关于 GPU 设备本身的信息。而且 kubelet 在向 API Server 汇报的时候,只会汇报该 GPU 对应的 Extended Resource 的数量。当然,kubelet 本身,会将这个 GPU 的 ID 列表保存在自己的内存里,并通过 ListAndWatch API 定时更新。
  4. 而当一个 Pod 想要使用一个 GPU 的时候,它只需要像我在本文一开始给出的例子一样,在 Pod 的 limits 字段声明nvidia.com/gpu: 1。那么接下来,Kubernetes 的调度器就会从它的缓存里,寻找 GPU 数量满足条件的 Node,然后将缓存里的 GPU 数量减 1,完成 Pod 与 Node 的绑定。
  5. 这个调度成功后的 Pod 信息,自然就会被对应的 kubelet 拿来进行容器操作。而当 kubelet 发现这个 Pod 的容器请求一个 GPU 的时候,kubelet 就会从自己持有的 GPU 列表里,为这个容器分配一个 GPU。此时,kubelet 就会向本机的 Device Plugin 发起一个 Allocate() 请求。这个请求携带的参数,正是即将分配给该容器的设备 ID 列表。
  6. 当 Device Plugin 收到 Allocate 请求之后,它就会根据 kubelet 传递过来的设备 ID,从 Device Plugin 里找到这些设备对应的设备路径和驱动目录。当然,这些信息,正是 Device Plugin 周期性的从本机查询到的。比如,在 NVIDIA Device Plugin 的实现里,它会定期访问 nvidia-docker 插件,从而获取到本机的 GPU 信息。
  7. 而被分配 GPU 对应的设备路径和驱动目录信息被返回给 kubelet 之后,kubelet 就完成了为一个容器分配 GPU 的操作。接下来,kubelet 会把这些信息追加在创建该容器所对应的 CRI 请求当中。这样,当这个 CRI 请求发给 Docker 之后,Docker 为你创建出来的容器里,就会出现这个 GPU 设备,并把它所需要的驱动目录挂载进去。
  8. 至此,Kubernetes 为一个 Pod 分配一个 GPU 的流程就完成了

总结

  1. node 上的 gpu 设备,由 device plugin 管理,通过 LIstAndWatch API 向 kubelet 上报【gpu 设备列表】,如 GPU(GPU0、GPU1 和 GPU2)
  2. kubelet 拿到列表后,将以 Extended Resource 的方式加上【gpu设备数量】上报到 apiserver,如nvidia.com/gpu=3
  3. Pod 需要一个 gpu 时,如在 Pod 的 limits 字段声明nvidia.com/gpu: 1,Kubernetes 调度器会从【缓存】中,寻找 gpu 数量满足条件的 Node,之后将缓存里该 node 的 gpu 数量减1,完成 Pod 与 Node 的绑定
  4. Pod 调度成功后,kubelet 将会创建该 Pod 对应的容器,kubelet 会发现该Pod 的容器申请一个 gpu,因此会从自己持有的【gpu列表中取出一个 gpu】,之后kubelet【会拿着此分配gpu 的 id】向 device plugin 发起一个 Allocate() 请求
  5. 当 Device Plugin 收到 Allocate 请求之后,它就会根据 kubelet 传递过来的设备 ID,从 Device Plugin 里找到这些设备对应的设备路径和驱动目录。(当然,这些信息,正是 Device Plugin 周期性的从本机查询到的。比如,在 NVIDIA Device Plugin 的实现里,它会定期访问 nvidia-docker 插件,从而获取到本机的 GPU 信息。)
  6. 而被分配 GPU 对应的设备路径和驱动目录信息被返回给 kubelet 之后,kubelet 就完成了为一个容器分配 GPU 的操作。接下来,kubelet 会把这些信息追加在创建该容器所对应的 CRI 请求当中。这样,当这个 CRI 请求发给 Docker 之后,Docker 为你创建出来的容器里,就会出现这个 GPU 设备,并把它所需要的驱动目录挂载进去。

小结

  • kubelet 通过 device plugin 的 ListAndWatch 方法,维护 Node 本地 gpu 设备 ID 列表
  • kubelet 向 apiserver 上报的是本 Node 的 gpu 设备数量
  • kubelet 通过 Pod 申请的 gpu 数量,分配对应的【gpu id 列表】,之后拿着该列表利用 device plugin 的 Allocate 方法进行 gpu 设备的申请
  • device plugin 的 Allocate 方法,会返回【kubelet申请的gpu 设备的设备路径和驱动目录】给 kubelet,kubelet 拿着这些信息,追加到容器对应的 CRI 请求中,进行容器的创建,并将 gpu 设备和驱动挂载到容器中
  • 对于其他类型硬件来说,要想在 Kubernetes 所管理的容器里使用这些硬件的话,也需要遵循上述 Device Plugin 的流程来实现如下所示的 Allocate 和 ListAndWatch API:

  • 目前,Kubernetes 社区里已经实现了很多硬件插件,比如FPGA、SRIOV、RDMA等等。感兴趣的话,你可以点击这些链接来查看这些 Device Plugin 的实现。

  service DevicePlugin {
        // ListAndWatch returns a stream of List of Devices
        // Whenever a Device state change or a Device disappears, ListAndWatch
        // returns the new list
        rpc ListAndWatch(Empty) returns (stream ListAndWatchResponse) {}
        // Allocate is called during container creation so that the Device
        // Plugin can run device specific operations and instruct Kubelet
        // of the steps to make the Device available in the container
        rpc Allocate(AllocateRequest) returns (AllocateResponse) {}
  }
  • 13
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值