资源模型与资源管理

资源模型

  • Pod是最小调度单位
  • 所有跟调度和资源管理相关的属性都是Pod对象的字段
  • 最重要的就是CPU和内存配置
    • CPU资源
      • 是可压缩资源 compressibleResource,特点是资源不足时,Pod只会饥饿,不会退出
      • 设置CPU的单位是CPU的个数,比如cpu=1,当然具体1个CPU在宿主机上如何解释,是1个CPU核心还是1个vCPU,还是1个CPU的超线程Hyperthread,完全取决于宿主机CPU的实现方式,k8s只能保证Pod能够使用1个CPU的计算能力
      • 还可将CPU限额设置为分数,比如500m,就是500millicpu,即0.5个CPU
      • 推荐使用500m,这是内部通用的CPU表示方式
    • 内存资源
      • 是不可压缩资源incompressibleResource,特点是资源不足时,Pod会OOM而被内核杀掉
      • 单位是bytes,支持使用Ei、Pi、Ti、Gi、Mi、Ki(E、P、T、G、M、K)的方式作为bytes的值
      • 1Mi = 1024 1024 ; 1M = 1000 1000 ```yaml

apiVersion: v1 kind: Pod metadata: name: frontend spec: containers:

  • name: db image: mysql env:
    • name: MYSQL_ROOT_PASSWORD value: “password” resources: requests: memory: “64Mi” cpu: “250m” limits: memory: “128Mi” cpu: “500m”
  • name: wp image: wordpress resources: requests:
    1. memory: "64Mi"
    2. cpu: "250m"
    limits:
    memory: "128Mi"
    cpu: "500m"
    
    ```
  • QoS模型
    • Guaranteed
      • pod里的每一个容器都同时设置了requests和limits,并且两者相等
      • pod仅设置了limits没有设置requests的时候,k8s会自动为它设置与limits相同的requests值
      • pod创建后,它的qosClass字段就被设置为Guaranteed
      • DaemonSet的Pod应该都设置为此类型,否则一旦被驱逐,又在当前宿主机重建,失去回收的意义 ```yaml

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"
    

    ```

    • Burstable
      • pod不满足Guaranteed条件,但至少有一个容器设置了requests ```yaml

apiVersion: v1 kind: Pod metadata: name: qos-demo-2 namespace: qos-example spec: containers:

  • name: qos-demo-2-ctr image: nginx resources: limits

    memory: "200Mi"
    

    requests:

    memory: "100Mi"
    

    ```

    • BestEffort
      • pod即没有设置requests,也没有设置limits ```yaml

apiVersion: v1 kind: Pod metadata: name: qos-demo-3 namespace: qos-example spec: containers:

  • name: qos-demo-3-ctr image: nginx ```
  • QoS模型作用
    • 当宿主机资源紧张时,kubelet对Pod进行Eviction(驱逐)时需要
    • k8s设置的Eviction的默认阈值,也可自定义配置 ```

      默认阈值

      memory.available<100Mi nodefs.available<10% nodefs.inodesFree<5% imagefs.available<15%

自定义配置

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两种模式
      - Soft
         - 允许为Eviction过程设置一段“优雅时间”
      - Hard
         - Eviction在阈值到达后立刻开始
   - 当宿主机的Eviction阈值达到后,就会进入MemoryPressure或DiskPressure状态,防止新的Pod调度进来
   - 当Eviction发生的时候,kubelet就会根据QoS挑选Pod进行驱逐
      - 首先驱逐BestEffort类别的Pod
      - 其次驱逐Burstable类别,并且发生“饥饿”的资源使用量已经超出requests的Pod
      - 最后驱逐Guaranteed类别,并且只在资源使用量超过limits限制或宿主机处于MemoryPressure状态时才会驱逐Guaranteed类别的Pod
   - 当然对于同类别的Pod,还会有优先级进一步排序
<a name="rOJnS"></a>
## 资源管理

- requests
   - 在kube-scheduler调度时,按照requests值进行计算,通过cpu.shares完成CPU时间按比例分配
   - requests.cpu=250m相当于cgroups的cpu.shares的值设置为250 / 1000 * 1024
   - 没有设置requests.cpu时,cpu.shares的值默认为1024,即requests.cpu默认为1000m
- limits
   - 在设置cgroups限制时,kubelet会按照limits值进行设置
   - 指定了limits.cpu=500m后相当于cgroups的cpu.cfs_quota_us的值设置为 500 /1000 * 1024
   - cpu.cfs_period_us的值始终是100ms,这样就设置了这个容器只能用到 CPU的 50% 
   - 指定了limits.memory=128Mi后相当于cgroups的memory.limit_in_bytes设置 128 * 1024 * 1024
- cpuset
   - 使用cpuset把容器绑定到某个CPU和核上,避免使用cpushare共享的CPU
   - 绑定后大大减少cs,性能会得到显著提升
   - 生产环境部署在线应用类型的Pod时非常常用的方式
   - 实现方式
      - Pod必须是Guaranteed的QoS类型
      - 将Pod的CPU资源的requests和limits设置为同一个相等的整数值,pod就会独占对应数量的CPU核
```yaml

spec:
  containers:
  - name: nginx
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "2"
      requests:
        memory: "200Mi"
        cpu: "2"

默认调度器

defaultScheduler

  • kube-scheduler
  • 主要职责就是为一个新创建的Pod,寻找一个最合适的节点Node
    • 从集群所有节点中,根据调度算法挑选出所有可以运行该Pod的Node
    • 从第一步的结果中,再根据调度算法挑选一个最合适的节点作为最终结果
  • 调度过程
    • 首先调用Predicate的调度算法筛选Node
    • 再调用Priority的调度算法打分
    • 最终的调度结果就是得分最高的那个Node
  • 调度成功的标志就是将它的spec.nodeName字段填上调度结果的Node名字
  • 工作原理
    • 核心是两个相互独立的控制循环
      • InformerPath
        • 启动一系列Informer,watch Etcd中的Pod、Node、Service等对象的变化
        • 当一个待调度的Pod被创建出来后,调度器就会通过Pod Informer的Handler 将这个Pod添加调度队列
        • 默认情况下调度队列是一个PriorityQueue(优先级队列)
        • 默认调度器还要负责对调度器缓存 SchedulerCache 进行更新,Cache保证算法效率,但也要更新
      • SchedulingPath(调度器负责调度的主循环)
        • 不断从调度队列出队一个Pod
        • 调用Predicates算法进行过滤,得到一组可以运行的Node列表
        • 调用Priority算法进行打分,从0到10,得分最高的Node作为结果
        • 把结果写入Pod.nodeName字段完成Bind
        • 为了不在关键调度路径里远程访问APIServer,默认调度器在Bind阶段只会更新SchedulerCache里的Pod和Node信息,这种基于乐观假设的API更新方式被称作 Assume
        • Assume后调度器才会创建一个goroutine来异步向APIServer更新Pod完成真正的Bind,如果失败了也没关系,等SchedulerCache同步之后一切就会恢复正常
        • 基于乐观绑定的Assume的设计,一个新的Pod在某个节点上运行起来之前,该节点的kubelet还会通过Admit的操作来再次验证该Pod确实能够运行在该节点
        • Admit操作就是把一组GeneralPredicates的、最基本的调度算法(资源是否可用,端口是否冲突)等再执行一遍,作为kubelet端的二次确认
    • 除了Cache化和乐观绑定Assume,默认调度器还有一个重要的设计就是无锁化
      • predicates算法阶段会以节点为粒度并发执行
      • priorities算法会以MapReduce方式并行计算然后汇总
      • 所有并发的路径上,调度器都会避免设置任何全局的竞争资源,从而避免使用锁
      • 只有在对调度队列和SchedulerCache进行操作时才会需要锁

调度工作原理图.jpg

可扩展性设计

  • SchedulerFramework在生命周期的各个关键点上,为用户暴露出可进行扩展和实现的接口,从而实现自定义
  • 可插拔式的逻辑都是标准的Go语言插件机制(Go plugin机制),需要在编译时把插件编译进去
  • 实现自定义的kube-scheduler

默认调度器设计.jpg

默认调度策略

Predicates

  • 可以理解为Filter
  • 按照调度策略,从当前集群的所有节点,过滤出符合条件的节点,都可以运行待调度的Pod
  • 具体执行时,调度器会同时启动16个goroutine,并发为所有node计算predicates,最后返回一个列表
  • 默认调度策略分四种
    • GeneralPredicates
      • 负责最基础的调度策略
      • PodFitsResources计算的就是宿主机CPU和内存是否够用,把除了CPU和内存之外的资源统一用名叫ExtendedResource的KV格式的扩展字段来描述
      • PodFitsHost检查宿主机的名字是否跟Pod的spec.nodeName一致
      • PodFitsHostPorts检查Pod申请的主机端口spec.nodePort是否有冲突
      • PodMatchNodeSelector检查的是Pod的nodeSelector或nodeAffinity指定的节点是否匹配待考察节点
      • GeneralPredicates也会被其他组件直接调用,就是因为它是最基本的过滤条件
      • Node在启动Pod前会进行Admit也会调用GeneralPredicates ```yaml

apiVersion: v1 kind: Pod metadata: name: extended-resource-demo spec: containers:

  • name: extended-resource-demo-ctr image: nginx resources: requests:

    alpha.kubernetes.io/nvidia-gpu: 2  # 声明使用2个NVIDIA类型的GPU
    

    limits:

    alpha.kubernetes.io/nvidia-gpu: 2
    

    ```

    • Volume相关的过滤规则
      • 跟容器持久化Volume相关的调度策略
      • NoDiskConflict检查的是多个Pod声明挂载的PV是否有冲突
      • MaxPDVolumeCountPredicate检查的是一个节点上某种类型的PV是不是已经超过了一定数目
      • VolumeZonePredicates检查PV的Zone标签是否与待考察节点的Zone标签匹配
      • VolumeBindingPredicate检查该Pod对应的PV的nodeAffinity是否跟节点标签匹配
    • 宿主机相关的过滤规则
      • 主要考察待调度Pod是否满足Node本身的某些条件
      • PodToleratesNodeTaints,只有toleration与node的Taint字段匹配时才能调度
      • NodeMemoryPressurePredicate检查当前节点的内存是否已经不够用
    • Pod相关的过滤规则
      • 与GeneralPredicates大多数是重合的
      • 比较特殊的是PodAffinityPredicate是检查待调度Pod与Node上的已有Pod之间的affinity和anti-affinity
      • PodAffinityPredicate是有作用域的,通过topologyKey指定 ```yaml

apiVersion: v1 kind: Pod metadata: name: with-pod-antiaffinity spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: # 调度时检查,已经运行的变化后忽略

  - weight: 100  
    podAffinityTerm:
      labelSelector:
        matchExpressions:
        - key: security 
          operator: In 
          values:
          - S2  # 不希望跟任何携带了security=S2的Pod存在于同一个Node上
      topologyKey: kubernetes.io/hostname # 作用域

containers:

  • name: with-pod-affinity image: docker.io/ocpqe/hello-pod ```

    Priorities

  • 完成了预选后,priorities就是为节点打分(0-10),得分最高的节点就是最后被Pod绑定的节点
  • 打分规则
    • LeastRequestedPriority,最常用的打分规则,实际上实在选择空闲资源最多的宿主机 ```yaml

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


   - BalancedResourceAllocation 选择调度完成后所有节点里各种资源分配最均衡的节点,避免一个节点上CPU被大量分配,Memory大量剩余
```yaml

score = 10 - variance(cpuFraction,memoryFraction,volumeFraction)*10

# 每种资源的Fraction = Pod请求的资源 / 节点上的可用资源
# variance算法计算两种资源Fraction之间的距离,最后选择资源Fraction差距最小的节点
  • ImageLocalityPriority策略,当待调度的Pod需要镜像很大,并且已经存在于某些Node上,那么这些Node得分就比较高,当然也会根据镜像的分布进行优化

    总结

  • 调度策略可以由kube-scheduler指定的配置文件或CM配置哪些开启,哪些关闭
  • 可为Priorities设置权重,控制调度器的调度行为
  • 调度器再执行调度算法之前可能先将集群的信息初步计算一遍,然后缓存起来,提高效率

    调度器的优先级与抢占机制

  • 优先级和抢占机制解决的是Pod调度失败时该怎么办的问题

    Priority优先级

  • PriorityClass对象

  • k8s规定 优先级是一个32bit的整数,最大值不超过10亿,并且值越大代表优先级越高
  • 超过10亿的值被保留下来分配给系统Pod使用,保证系统Pod不会被用户抢占掉
  • globalDefault
    • 被设置为true的话就意味着这个PriorityClass的值会成为系统的默认值
    • 被设置为false表示只希望使用该PriorityClass的Pod有此优先级
    • 没有声明使用PriorityClass的Pod默认的优先级就是0
  • 优先级的值会被设置的Pod.spec.priority字段
  • 高优先级的Pod在调度队列里会比低优先级的Pod提前出队,尽早完成调度过程 ```yaml

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.”

```yaml

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    env: test
spec:
  containers:
  - name: nginx
    image: nginx
    imagePullPolicy: IfNotPresent
  priorityClassName: high-priority

Preemption抢占机制

  • 当一个高优先级的Pod调度失败时,调度器的抢占能力就会被触发
  • 调度器就会试图从当前集群里寻找一个节点,使得当这个节点上一个或者多个低优先级Pod被删除后,待调度的高优先级的Pod可以被调度进来,实现抢占
  • 待调度的高优先级的Pod是抢占者 Preemptor
  • 调度器只会将抢占者的spec.nominatedNodeName字段设置为被抢占的Node的名字
  • 抢占者会重新进入下一个调度周期,在新的周期里决定是否要运行在被抢占的节点上
  • 调度器只会通过标准的DELETE API来删除被抢占的Pod,有一个优雅退出的时间,默认30s
  • 在抢占者等待被调度的过程中,如果有其他更高优先级的Pod也要抢占同一个节点,调度器会清空原抢占者的spec.nominatedNodeName字段,允许更高优先级的Preemptor执行抢占,本身也有机会抢占其他节点
  • 原理
    • 发生的原因一定是一个高优先级的Pod调度失败
    • 这个Pod就是抢占者Preemptor,被抢占的Pod是牺牲者 victims
    • 实现抢占算法的设计就是在调度队列里使用了两个不同的队列
      • activeQ 存放下一个调度周期需要调度的对象
      • unschedulableQ 专门存放调度失败的Pod
    • 调度失败后抢占者就被放进unschedulableQ里,这次失败事就会触发调度器为抢占者寻找牺牲者的流程
      • 调度器检查这次失败事件的原因,来确认是否可以通过抢占来解决
      • 如果抢占可以解决问题就把自己缓存的所有节点的信息复制一份,然后使用这个信息模拟抢占过程
      • 模拟抢占过程结束后找到最佳结果(尽量减少抢占对整个系统的影响)包括Node和Pod
      • 接下来进行抢占,分三步
        • 检查牺牲者列表,清理这些Pod携带的nominatedNodeName字段
        • 把抢占者的nominatedNodeName字段设置为被抢占Node的名字,这一步就会更新unschedulableQ里的Pod,而其更新后,调度器会自动把这个Pod移动到activeQ里,重新做人
        • 调度器开启一个goroutine,同步地删除牺牲者
  • 因为有抢占的存在,在一个正常的调度过程中,有特殊情况需要处理

    • 当为某一对Pod和Node执行Predicates算法时,如果此Node是被抢占的节点(调度队列里有nominatedNodeName字段值是该Node的的Pod)
    • 调度器会对这个Node执行两遍Predicates算法
      • 假设抢占者已经运行在此Node上,执行Predicates算法,避免InterPodAntiAffinity规则
      • 正常执行Predicates算法,不考虑抢占者,避免抢占者最终没被调度过来
    • 只有两遍Predicates算法都通过时,这个Node和Pod才能Bind

      GPU管理与DevicePlugin机制

      GPU管理

  • 硬件加速需求需要提供

    • GPU设备,比如/dev/nvidia0
    • GPU驱动,比如/usr/local/nvidia/*
  • CRI
    • 容器启动时可以指定Device参数来设置为GPU设备
    • 驱动目录可以通过Volume挂载进去
  • API对象
    • ExtendedResource,是k8s为用户设置的一种对自定义资源的支持 ```yaml

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

- Node对象
   - 必须向APIServer上报资源信息
   - 为了能在Status字段里添加自定义资源的数据,必须使用PATCH API对Node更新
   - PATCH API可以使用curl命令发起,如下所示

apiVersion: v1 kind: Node metadata: name: node-1 … Status: Capacity: cpu: 2 memory: 2049008Ki

```shell

# 启动 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

apiVersion: v1
kind: Node
...
Status:
  Capacity:
   cpu:  2
   memory:  2049008Ki
   nvidia.com/gpu: 1

DevicePlugin

  • 机制
    • 每一种硬件设备,都需要对应的DevicePlugin管理
    • DP通过gRPC方式同kubelet连接起来
    • DP通过ListAndWatch的API定期向kubelet汇报Device信息(GPU的ID列表)
    • kubelet拿到信息后向APIServer发送心跳(以ER的方式,加上对应的数量比如nvidia.com/gpu=3)
    • 当一个Pod想要使用一个GPU时,在Podlimits字段进行声明 nvidia.com/gpu=1
    • 调度器从缓存里寻找GPU数量满足条件的Node,然后将缓存里的GPU数量减1,完成Pod与Node绑定
    • 这个调度成功的Pod信息就会被对应的kubelet拿来进行容器操作,发现需要1个GPU时,kubelet就会从自己持有的GPU列表里分配一个GPU,kubelet向本机的DP发起一个Allocate(),参数就是设备ID列表
    • DP收到请求后找到对应的设备路径和驱动目录返回给kubelet
    • kubelet把这些信息追加在创建该容器所对应的CRI请求当中,这样这个CRI发给docker后,创建出来的容器就包含这个设备和对应的驱动目录

DevicePlugin.jpg