Kubernetes API 的主要工作之一是将容器调度到机器集群中的工人节点。这个任务是由 Kubernetes 集群中的专用二进制来完成的,称为 Kubernetes 调度器。本章介绍了调度器的运行方式,如何扩展它,甚至可以被其他调度器替换或增强。Kubernetes 可以处理各种各样的工作负载,从无状态的 Web 服务到有状态的应用程序、大数据批处理作业或 GPU 上的机器学习。确保所有这些截然不同的应用能够在同一个集群上和谐运行的关键在于作业调度的应用,它确保每个容器被放置到最适合它的工作节点上。

调度概览

当一个 Pod 第一次被创建时,它一般没有 nodeName 字段。nodeName 表示 Pod 应该在哪个节点上执行。Kubernetes 调度器会不断扫描 API Server(通过 Watch 请求),寻找没有 nodeName 的Pod,这些 Pod 是符合调度条件的。然后调度器为 Pod 选择一个合适的节点,并用调度器选择的 nodeName 更新 Pod 定义。nodeName 设置好后,运行在该节点上的 kubelet 会被通知 Pod 的存在(同样是通过 Watch 请求),并开始在该节点上实际执行该 Pod。

:::info 如果你想跳过调度器,你总是可以自己设置 Pod 的 nodeName。这样直接将一个 Pod 调度到一个特定的节点上。事实上,这就是 DaemonSet 控制器如何将单个 Pod 调度到集群中的每个节点上。然而,一般情况下,应该避免直接调度,因为它往往会使你的应用程序更脆弱,集群效率更低。在一般的使用情况下,你应该相信调度器会做出正确的决定,就像你相信操作系统在单机上启动你的程序时会找到一个核心来执行一样。 :::

调度过程

当调度器发现一个 Pod 没有被分配到一个节点时,它需要决定将 Pod 调度到哪个节点。Pod 的正确节点是由一些不同的因素决定的,其中一些因素由用户提供,一些因素由调度器计算。一般来说,调度器正试图优化各种不同的标准,以找到最适合特定 Pod 的节点。

断言

当决定如何调度 Pod 时,调度器使用两个通用概念来做决定。第一个是断言。简单地说,断言表示一个 Pod 是否适合在一个特定的节点上。断言是硬约束,如果被违反,会导致 Pod 在该节点上无法正常运行(或根本无法运行)。这种约束的一个例子是 Pod 请求的内存量。如果该内存在节点上不可用,Pod 就不能获得它所需要的所有内存,约束就被违反了 — 断言失败。断言的另一个例子是用户指定的节点选择器标签查询。在这种情况下,用户要求 Pod 只能在节点标签所指示的特定机器上运行。如果一个节点没有所需的标签,则断言失败。

优先级

断言表示的情况要么是真要么是假 — Pod要么适合,要么不适合 — 但调度器还有一个额外的通用接口,用于确定一个节点对另一个节点的偏好。这些偏好用优先级或优先级函数来表示。优先级函数的作用是对将 Pod 调度到特定节点上的相对价值进行评分。与断言相反,优先级函数并不表明被调度到节点上的 Pod 是否可行 — 假定 Pod 可以在节点上成功执行,相反,优先级函数试图判断将 Pod 调度到该特定节点的相对价值。

举个例子,优先级函数会对镜像已经被拉动的节点进行加权。因此,容器的启动速度会比没有镜像的节点快,而且必须拉动镜像,从而延迟 Pod 的启动。一个重要的优先级函数是扩散(spreading)函数。这个函数负责优先处理属于同一 Kubernetes 服务成员的 Pod 不存在的节点。它是用来确保可靠性的,因为它减少了机器故障使某个 Service 中所有容器失效的机会。

最终,所有的各种判定值混合在一起,以实现节点的最终优先级分数,这个分数用于确定 Pod 被安排在哪里。

高层算法

对于每一个需要调度的 Pod,都会运行调度算法。从高层来看,算法是这样的:

  1. schedule(pod): string
  2. nodes := getAllHealthyNodes()
  3. viableNodes := []
  4. for node in nodes:
  5. for predicate in predicates:
  6. if predicate(node, pod):
  7. viableNodes.append(node)
  8. scoredNodes := PriorityQueue<score, Node[]>
  9. priorities := GetPriorityFunctions()
  10. for node in viableNodes:
  11. score = CalculateCombinedPriority(node, pod, priorities)
  12. scoredNodes[score].push(node)
  13. bestScore := scoredNodes.top().score
  14. selectedNodes := []
  15. while scoredNodes.top().score == bestScore:
  16. selectedNodes.append(scoredNodes.pop())
  17. node := selectAtRandom(selectedNodes)
  18. return node.Name

你可以在 Kubernetes 的 GitHub 页面上找到实际代码

:::tips 调度器的基本操作如下:首先,调度器获取当前所有已知的健康节点的列表。然后,对于每个断言,调度器会针对被调度的节点和 Pod 进行评估。如果该节点是可行的(Pod 可以在其上运行),则该节点被添加到可能的节点列表中进行调度。接下来,所有的优先级函数都会针对 Pod 和节点的组合运行。结果被推送到一个优先级队列中,按得分排序,得分最好的节点在队列的顶端。然后,所有得分相同的节点都会被从优先级队列中弹出,并放入最终的列表中。它们被认为是完全相同的,并以循环赛的方式选择其中一个节点,然后返回作为 Pod 应该被安排的节点。使用循环赛代替随机选择,以保证 Pod 在相同节点之间的均匀分布。 :::

冲突

因为在 Pod被调度(时间 T_1)和容器实际执行(时间 T_N)之间有滞后时间,调度决定可能会变得无效,因为在调度和执行之间的时间间隔内有其他操作。

在某些情况下,这可能意味着选择了一个稍不理想的节点,而本来可以分配一个更好的节点。这可能是由于 Pod 在时间 T_1 之后,但在时间 T_N 之前终止,或者集群的其他变化造成的。一般来说,这些软约束冲突并不是那么重要,它们在总体上是正常化的。因此,这些冲突会被 Kubernetes 忽略。调度决策只有在单一时刻才是最优的,随着时间的流逝和集群的变化,它们总是可以变得更糟。

:::info Kubernetes 社区正在进行一些工作来改善这种情况。一个 Kubernetes-descheduler 项目,如果在 Kubernetes 集群中运行,会扫描其中被确定为明显不理想的 Pod。如果发现了这样的 Pod,descheduler 就会把 Pod 从当前节点驱逐出去。因此,Kubernetes 调度器会重新调度该 Pod,就像刚刚创建的一样。 :::

当集群的改变违反了调度器的硬约束时,就会发生一种更重要的冲突,例如,调度器决定将 Pod 放在节点 N 上。例如,想象一下,调度器决定将 Pod P 放在节点 N 上,想象 Pod P 需要两个核来运行,而节点 N 正好有两个核的空余容量,在 T_1 时,调度器已经确定节点N有足够的容量来运行 Pod P。在 T_1 的时候,调度器已经确定节点 N 有足够的容量来运行 Pod P。然而,在调度器在代码中做出决定之后,在决定被写回 Pod 之前,一个新的 DaemonSet 被创建了。这个 DaemonSe t创建了一个不同的 Pod,运行在每个节点上,包括节点 N,它消耗了一个核心的容量。现在节点 N只有一个空闲核心,却被要求运行需要两个核心的 Pod P。鉴于节点 N 的新状态,这是不可能的,但调度决策已经做出。

当节点注意到它已被要求运行一个不再通过 Pod 和节点的断言的 Pod 时,Pod 被标记为失败。如果 Pod 已经被 ReplicaSet 创建,那么这个失败的 Pod 就不能算作 ReplicaSet 的活动成员,因此,将创建一个新的 Pod,并将其调度到适合它的不同节点上。这种失败的行为很重要,因为这意味着不能指望 Kubernetes 可靠地运行独立的 Pod。你应该始终通过 ReplicaSet 或 Deployment 来运行 Pod(即使是单体)。

用标签、亲和力、污点和容忍度控制调度

当然,有些时候,你希望对 Kubernetes 执行的调度决策进行更精细的控制。你可以通过添加自己的谓词和优先级来实现,但这是一项相当繁重的任务。幸运的是,Kubernetes 为你提供了许多工具来定制调度 — 而无需在你自己的代码中实现任何东西。

节点选择器

请记住,Kubernetes 中的每个对象都有一组相关的标签。标签为 Kubernetes 对象提供识别元数据,标签选择器通常用于动态识别各种操作的 API 对象集。例如,标签和标签选择器用于识别为 Kubernetes 负载均衡器后面的流量服务的 Pod 的集合。

:::tips 标签选择器还可用于识别 Kubernetes 集群中应用于调度特定 Pod 的节点子集。默认情况下,集群中的所有节点都是潜在的调度候选者,但通过在 Pod 或 PodTemplate 中填写 spec.nodeSelector 字段,初始节点集可以减少到一个子集。 :::

举个例子,考虑将工作负载调度到一台拥有高性能存储的机器上的任务,比如 NVMe 支持的 SSD。这种存储(至少在撰写本文时)非常昂贵,因此可能不是每台机器都有。因此,每一台拥有这种存储的机器都会被赋予一个额外的标签,比如:

kind: Node
metadata:
  - labels:
      nvme-ssd: true
 ...

要创建一个总是被调度到带有 NVMe SSD 的机器上的 Pod,你可以设置 Pod 的 nodeSelector 来匹配节点上的标签:

kind: Pod
spec:
  nodeSelector:
    nvme-ssd: true
...

Kubernetes 有一个默认的断言,要求每个节点匹配 nodeSelector 标签查询,如果它存在的话。因此,每个带有 nvme-ssd 标签的 Pod 总是会被调度到一个具有相应硬件的节点上。

:::tips 正如前面冲突一节中提到的,节点选择器只在调度时进行评估。如果节点被主动添加和删除,当容器执行时,它的节点选择器可能不再匹配它运行的节点。 :::

节点亲和性

节点选择器提供了一种简单的方法来保证一个 Pod 落在一个特定的节点上,但它们缺乏灵活性。特别是,它们不能表示更复杂的逻辑表达式(例如,“标签 foo 不是 A 就是 B。”),也不能表示反亲和性(”标签 foo 是 A,但标签 bar 不是 C。”)。最后,节点选择器是谓词 — 它们指定的是需求,而不是偏好。

从 Kubernetes 1.2 开始,通过 Pod 规范中的亲和力结构将亲和力的概念加入到节点选择中。亲和力是一个比较复杂的结构,难以理解,但如果你想表达更复杂的调度策略,它的灵活性就会大大增加。

考虑一下刚才提到的例子,在这个例子中,一个 Pod 应该调度到一个节点上,这个节点的标签 foo 的值要么是 A,要么是 B:

kind: Pod
...
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          # foo == A or B
          - key: foo
            operator: In
            values:
            - A
            - B
...

为了显示反亲和性,考虑策略标签 foo 的值为A,而标签 bar 不等于C,这用一个类似的,虽然稍微复杂一点的规范来表达。

kind: Pod
...
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          # foo == A
          - key: foo
            operator: In
            values:
            - A
          # bar != C
          - key: bar
            operator: NotIn
            values:
            - C
...

:::info 这两个例子包括操作符 InNotIn。Kubernetes 还允许 Exists(只要求标签键存在而不考虑值)和 NotExists(要求标签不存在)。还有 Gt 和 Lt 运算符,分别实现了大于等于和小于等于。如果您使用 Gt 或 Lt 运算符,则期望值数组由单个整数组成,并且您的节点标签应当是积分。 :::

到目前为止,我们已经看到节点亲和力提供了一种更复杂的选择节点的方式,但我们仍然只表达了一个断言。这是由于 requiredDuringSchedulingIgnoredDuringExecution,它是对节点亲和力行为的一个长篇大论但准确的描述。标签表达必须在调度时匹配,但在 Pod 执行时可能不匹配。

如果你想表达一个节点的优先级而不是需求(或者在需求之外),你可以使用 preferredDuringSchedulingIgnoredDuringExecution。例如,使用我们前面的例子,我们要求 foo 要么是 A 要么是 B,让我们也表达一个调度到标记为 A 的节点上的优先级。优先级结构中的权重项允许我们调整它相对于其他优先级的重要程度。

kind: Pod
...
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          # foo == A or B
          - key: foo
            operator: In
            values:
            - A
            - B
      preferredDuringSchedulingIgnoredDuringExecution:
        preference:
        - weight: 1
          matchExpressions:
          # foo == A
          - key: foo
            operator: In
            values:
            - A
...

节点亲和力目前是一个测试版功能。在 Kubernetes 1.4 及以后的版本中,也引入了 Pod Affinity,其语法类似(用 Pod 代替 Node)。Pod 亲和性允许你用特定的标签来表达与其他 Pod 一起调度或远离其他 Pod 的要求或偏好。

污点和容忍

节点和 Pod 亲和力允许你指定 Pod 的偏好,以便将 Pod 调度(或不调度)到特定的节点集或特定的 Pod 集附近。然而,这需要用户在创建容器时进行操作,以实现正确的调度行为。有时,作为一个集群的管理员,你可能想在不要求用户改变行为的情况下影响调度。

例如,考虑一个异构的 Kubernetes 集群。你可能有混合的硬件类型 — 有些使用旧的 1G 处理器,有些使用新的 3G 处理器。一般来说,除非有特别要求,否则你不希望你的用户将他们的工作排到旧的处理器上。你可以通过节点抗亲和力来实现这一点,因为它要求每个用户明确地将抗亲和力添加到他们的老机器的 Pod 中。

正是这种用例激发了节点污点的开发。节点污点就像它的声音一样。当一个污点被应用到一个节点上时,该节点被认为是污点,将被默认排除在调度之外。任何被污染的节点都会在调度时未能通过断言检查。

然而,考虑一个想要访问 1G 机器的用户。他们的工作并不是时间上的关键,而且 1G 的机器成本较低,因为需求量小得多。为了达到这个目的,用户通过为特定污点添加一个容忍度来选择进入 1G 机器。这个容忍度使得调度断言能够通过,从而允许节点调度到污点机上。需要注意的是,虽然对污点的容忍使 Pod 能够在污点机上运行,但并不要求 Pod 在污点机上运行。事实上,所有的优先级都会像以前一样运行,因此,集群中的所有机器都可以执行。强制 Pod 在特定机器上运行是前面描述的节点选择器或亲和性的用例。

总结

Kubernetes 的核心功能之一是能够接受用户执行容器的请求,并将该容器调度到合适的机器上。对于集群管理员来说,调度器的操作 — 以及教会用户如何很好地使用它 — 对于构建一个可靠的集群是至关重要的,你可以推动集群达到高利用率和高效率。