使用 Admission Webhook 机制实现多集群资源配额控制

1 要解决的问题

集群分配给多个用户使用时,需要使用配额以限制用户的资源使用,包括 CPU 核数、内存大小、GPU 卡数等,以防止资源被某些用户耗尽,造成不公平的资源分配。

大多数情况下,集群原生的 ResourceQuota 机制可以很好地解决问题。但随着集群规模扩大,以及任务类型的增多,我们对配额管理的规则需要进行调整:

  • ResourceQuota 针对单集群设计,但实际上,开发/生产中经常使用 多集群 环境。
  • 集群大多数任务通过比如deploymentmpijob高级资源对象 进行提交,我们希望在高级资源对象的 提交阶段 就能对配额进行判断。但 ResourceQuota 计算资源请求时以 pod 为粒度,从而无法满足此需求。

基于以上问题,我们需要自行进行配额管理。而 Kubernetes 提供了动态准入的机制,允许我们编写自定义的插件,以实现请求的准入。我们的配额管理方案,就以此入手。

2 集群动态准入原理

进入 K8s 集群的请求,被 API server 接收后,会经过如下几个顺序执行的阶段:

  1. 认证/鉴权
  2. 准入控制(变更)
  3. 格式验证
  4. 准入控制(验证)
  5. 持久化

请求在上述前四个阶段都会被相应处理,并且依次被判断是否允许通过。各个阶段都通过后,才能够被持久化,即存入到 etcd 数据库中,从而变为一次成功的请求。其中,在 准入控制(变更) 阶段,mutating admission webhook 会被调用,可以修改请求中的内容。而在 准入控制(验证) 阶段,validating admission webhook 会被调用,可以校验请求内容是否符合某些要求,从而决定是否允许或拒绝该请求。而这些 webhook 支持扩展,可以被独立地开发和部署到集群中。

虽然,在 准入控制(变更) 阶段,webhook也可以检查和拒绝请求,但其被调用的次序无法保证,无法限制其它 webhook 对请求的资源进行修改。因此,我们部署用于配额校验的 validating admission webhook,配置于 准入控制(验证) 阶段调用,进行请求资源的检查,就可以实现资源配额管理的目的。

3 方案

3.1 如何在集群中部署校验服务

在 K8s 集群中使用自定义的 validating admission webhook 需要部署:

  1. ValidatingWebhookConfiguration 配置(需要集群启用 ValidatingAdmissionWebhook) ,用于定义要对何种资源对象(pod, deployment, mpijob 等)进行校验,并提供用于实际处理校验的服务回调地址。推荐使用在集群内配置 Service 的方式来提供校验服务的地址。
  2. 实际处理校验的服务,通过在 ValidatingWebhookConfiguration 配置的地址可访问即可。

单集群环境中,将校验服务以 deployment 的方式在集群中部署。多集群环境中,可以选择:

  1. 使用 virtual kubelet,cluster federation 等方案将多集群合并为单集群,从而退化为采用单集群方案部署。
  2. 将校验服务以 deloyment 的方式部署于一个或多个集群中,但要注意保证服务到各个集群网络连通。

需要注意的是,不论是单集群还是多集群的环境中,处理校验的服务都需要进行资源监控,这一般由单点实现。因此都需要 进行选主。

3.2 如何实现校验服务

3.2.1 校验服务架构设计

3.2.1.1 基本组件构成

  • API server:集群请求入口,调用 validating admission webhook 以验证请求
  • API:准入服务接口,使用集群约定的 AdmissionReview 数据结构作为请求和返回
  • Quota usage service:请求资源使用量接口
  • Admissions:准入服务实现,包括 deploymentmpijob 等不同资源类型准入
  • Resource validator:对资源请求进行配额校验
  • Quota adapter:对接外部配额服务供 validator 查询
  • Resource usage manager:资源使用管理器,维护资源使用情况,实现配额判断
  • Informers:通过 K8s 提供的 watch 机制监控集群中资源,包括 deploymentmpijob 等,以维护当前资源使用
  • Store:存放资源使用数据,可以对接服务本地内存实现,或者对接 Redis 服务实现
3.2.1.2 资源配额判断的基本流程

以用户创建 deployment 资源为例:

  1. 用户创建 deployment 资源,定义中需要包含指定了应用组信息的 annotation,比如 ti.cloud.tencent.com/group-id: 1,表示申请使用应用组 1 中的资源(如果没有带有应用组信息,则根据具体场景,直接拒绝,或者提交到默认的应用组,比如应用组 0 等)。
  2. 请求由 API server 收取,由于在集群中正确配置了 ValidatingWebhookConfiguration,因此在准入控制的验证阶段,会请求集群中部署的 validating admission webhookAPI,使用 K8s 规定的结构体AdmissionReviewRequest 作为请求,期待 AdmissionReviewResponse 结构体作为返回。
  3. 配额校验服务收到请求后,会进入负责处理 deployment 资源的 admission 的逻辑,根据改请求的动作是 CREATE 或 UPDATE 来计算出此次请求需要新申请或者释放的资源。
  4. deploymentspec.template.spec.containers[*].resources.requests 字段中提取要申请的资源,比如为 cpu: 2memory: 1Gi,以 apply 表示。
  5. Resource validator 查找 quota adapter 获取应用组 1 的配额信息,比如 cpu: 10memory: 20Gi ,以 quota 表示。连同上述获取的 apply,向 resource usage manager 申请资源。
  6. Resource usage manager 一直在通过 informer 监控获取 deployment 的资源使用情况,并维护在 store 中。Store 可以使用本地内存,从而无外部依赖。或者使用 Redis 作为存储介质,方便服务水平扩展。
  7. Resource usage manager 收到 resource validator 的请求时,可以通过 store 查到应用组 1 当前已经占用的资源情况,比如 cpu: 8memory: 16Gi,以 usage 表示。检查发现 apply + usage <= quota 则认为没有超过配额,请求通过,并最终返回给 API server

以上就是实现资源配额检查的基本流程。有一些细节值得补充说明:

  • 校验服务的接口 API 必须采用 https 暴露服务。
  • 针对不用的资源类型,比如 deploymentmpijob 等,都需要实现相应的 admission 以及 informer
  • 每个资源类型可能有不同的版本,比如 deploymentapps/v1apps/v1beta1 等,需要根据集群的实际情况兼容处理。
  • 收到 UPDATE 请求时,需要根据资源类型中 pod 的字段是否变化,来判断是否需要重建当前已有的 pod 实例,以正确计算资源申请的数目。
  • 除了 K8s 自带的资源类型,比如 cpu 等,如果还需要自定义的资源类型配额控制,比如 GPU 类型等,需要在资源请求约定好相应的 annotations,比如 ti.cloud.tencent.com/gpu-type: V100
  • resource usage manager 进行使用量、申请量和配额的判断过程中,可能会出现 资源竞争、配额通过校验但实际 资源创建失败 等问题。接下来我们会对这两个问题进行解释。

3.2.2 关于资源申请竞争

由于并发资源请求的存在:

  1. usage 需要能够被在资源请求后即时更新
  2. usage 的更新需要进行并发控制

在上述步骤 7 中,Resource usage manager 校验配额时,需要查询应用组当前的资源占用情况,即应用组的 usage 值。此 usage 值由 informers 负责更新和维护,但由于从资源请求被 validating admission webhook 通过,到 informer 能够观察到,存在时间差。这个过程中,可能仍有资源请求,那么 usage 值就是不准确的了。因此,usage 需要能够被在资源请求后即时更新。

并且对 usage 的更新需要进行并发控制,举个例子:

  1. 应用组 2quotacpu: 10usagecpu: 8
  2. 进入两个请求 deployment1deployment2 申请使用应用组 2,它们的 apply 同为 cpu: 2
  3. 需要首先判断 deployment1, 计算 apply + usage = cpu: 10,未超过 quota 值,因此 deployment1 的请求允许通过。
  4. usage 被更新为 cpu: 10
  5. 再去判断 deployment2,由于 usage 被更新为 cpu: 10,则算出 apply + usage = cpu: 12,超过了 quota 的值,因此不允许通过该请求。

上述过程中,容易发现 usage 是关键的 共享 变量,需要顺序查询和更新。若 deployment1deployment2 不加控制地同时使用 usagecpu: 8,就会导致 deployment1deployment2 请求都被通过,从而实际超出了配额限制。这样,用户可能占用 超过 配额规定的资源。

可行的解决办法:

  • 资源申请进入队列,由单点的服务依次消费和处理。
  • 将共享的变量 usage 所处的临界区上锁,在锁内查询和更新 usage 的值。

3.2.3 关于资源创建失败

由于资源竞争的问题,我们要求 usage 需要能够被在资源请求后即时更新,但这也带来新的问题。在 4. 准入控制(验证) 阶段之后,请求的资源对象会进入 5. 持久化 阶段,这个过程中也可能出现异常(比如其他的 webhook 又拒绝了该请求,或者集群断电,etcd 故障等)导致任务没有实际提交成功到集群数据库。在这种情况下,我们在 验证 阶段,已经增加了 usage 的值,就把没有实际占用配额的任务算作占用了配额。这样,用户可能占用 不足 配额规定的资源。

为了解决这个问题,后台服务会定时全局更新每个应用组的 usage 值。这样,如果出现了 验证 阶段增加了 usage 值,但任务实际提交到数据库失败的情况,在全局更新的时候,usage 值最终会重新更新为那个时刻应用组在集群内资源使用的准确值。

但在极少数情况下,全局更新会在这种时刻发生:某最终会成功存入 etcd 持久化 的资源对象创建请求,已经通过 webhook 验证,但尚未完成 持久化 的时刻。这种时刻的存在,导致全局更新依然会带来用户占用 超过 配额的问题。
比如,在之前的例子中,deployment1 更新了 usage 值之后,恰巧发生了全局更新。此时 deployment1 的信息恰好尚未存入 etcd,所以全局更新会把 usage 重新更新为旧值,这样会导致 dployment2 也能被通过,从而超过了配额限制。
但通常,从 验证持久化 的时间很短。低频 的全局更新情况下,此种情况 几乎不会发生。后续,如果有进一步的需求,可以采用更复杂的方案来规避这个问题。

3.2.3 原生 ResourceQuota 的工作方式

K8s 集群中原生的配额管理 ResourceQuota 针对上述 资源申请竞争资源创建失败 问题,采用了类似的解决方案:

即时更新解决申请竞争问题

检查完配额后,即时更新资源用量,K8s 系统自带的乐观锁保证并发的资源控制(详见 K8s 源码中 checkQuotas 的实现),解决资源竞争问题。

checkQuotas 中最相关的源码解读:

// now go through and try to issue updates.  Things get a little weird here:
// 1. check to see if the quota changed.  If not, skip.
// 2. if the quota changed and the update passes, be happy
// 3. if the quota changed and the update fails, add the original to a retry list
var updatedFailedQuotas []corev1.ResourceQuota
var lastErr error
for i := range quotas {
    newQuota := quotas[i]
    // if this quota didn't have its status changed, skip it
    if quota.Equals(originalQuotas[i].Status.Used, newQuota.Status.Used) {
        continue
    }
    if err := e.quotaAccessor.UpdateQuotaStatus(&newQuota); err != nil {
        updatedFailedQuotas = append(updatedFailedQuotas, newQuota)
        lastErr = err
    }
}

这里 quotas 是经过校验后的配额信息,其中 newQuota.Status.Used 字段则记录了该配额的资源使用情况。如果针对该配额的资源请求通过了,运行到这段代码时,Used 字段中已经被加上了新申请资源的量。随后,Equals 函数被调用,即如果 Used 字段未变,说明没有新的资源申请。否则,就会运行到 e.quotaAccessor.UpdateQuotaStatus,立刻去把 etcd 中的配额信息按照 newQuota.Status.Used 来更新。

定时全局更新解决创建失败问题

定时全局更新资源使用量(详见 K8s 源码中 Run 的实现),解决可能的资源创建失败问题 。

Run 中最相关的源码解读:

// the timer for how often we do a full recalculation across all quotas
go wait.Until(func() { rq.enqueueAll() }, rq.resyncPeriod(), stopCh)

这里 rqResourceQuota 对象对应 controller 的自引用。这个 Controller 运行 Run 循环,持续地控制所有 ResourceQuota 对象。循环中,不间断定时调用 enqueueAll,即把所有的 ResourceQuota 压入队列中,修改其 Used 值,进行全局更新。


4 参考

【腾讯云原生】云说新品、云研新术、云游新活、云赏资讯,扫码关注同名公众号,及时获取更多干货!!