集群分配给多个用户使用时,需要使用配额以限制用户的资源使用,包括 CPU 核数、内存大小、GPU 卡数等,以防止资源被某些用户耗尽,造成不公平的资源分配。
大多数情况下,集群原生的 ResourceQuota
机制可以很好地解决问题。但随着集群规模扩大,以及任务类型的增多,我们对配额管理的规则需要进行调整:
ResourceQuota
针对单集群设计,但实际上,开发/生产中经常使用 多集群 环境。deployment
、mpijob
等 高级资源对象 进行提交,我们希望在高级资源对象的 提交阶段 就能对配额进行判断。但 ResourceQuota
计算资源请求时以 pod
为粒度,从而无法满足此需求。基于以上问题,我们需要自行进行配额管理。而 Kubernetes 提供了动态准入的机制,允许我们编写自定义的插件,以实现请求的准入。我们的配额管理方案,就以此入手。
进入 K8s 集群的请求,被 API server 接收后,会经过如下几个顺序执行的阶段:
请求在上述前四个阶段都会被相应处理,并且依次被判断是否允许通过。各个阶段都通过后,才能够被持久化,即存入到 etcd 数据库中,从而变为一次成功的请求。其中,在 准入控制(变更) 阶段,mutating admission webhook
会被调用,可以修改请求中的内容。而在 准入控制(验证) 阶段,validating admission webhook
会被调用,可以校验请求内容是否符合某些要求,从而决定是否允许或拒绝该请求。而这些 webhook
支持扩展,可以被独立地开发和部署到集群中。
虽然,在 准入控制(变更) 阶段,webhook
也可以检查和拒绝请求,但其被调用的次序无法保证,无法限制其它 webhook
对请求的资源进行修改。因此,我们部署用于配额校验的 validating admission webhook
,配置于 准入控制(验证) 阶段调用,进行请求资源的检查,就可以实现资源配额管理的目的。
在 K8s 集群中使用自定义的 validating admission webhook
需要部署:
ValidatingWebhookConfiguration
配置(需要集群启用 ValidatingAdmissionWebhook) ,用于定义要对何种资源对象(pod
, deployment
, mpijob
等)进行校验,并提供用于实际处理校验的服务回调地址。推荐使用在集群内配置 Service
的方式来提供校验服务的地址。ValidatingWebhookConfiguration
配置的地址可访问即可。单集群环境中,将校验服务以 deployment
的方式在集群中部署。多集群环境中,可以选择:
deloyment
的方式部署于一个或多个集群中,但要注意保证服务到各个集群网络连通。需要注意的是,不论是单集群还是多集群的环境中,处理校验的服务都需要进行资源监控,这一般由单点实现。因此都需要 进行选主。
validating admission webhook
以验证请求deployment
和 mpijob
等不同资源类型准入deployment
和 mpijob
等,以维护当前资源使用以用户创建 deployment
资源为例:
deployment
资源,定义中需要包含指定了应用组信息的 annotation
,比如 ti.cloud.tencent.com/group-id: 1
,表示申请使用应用组 1
中的资源(如果没有带有应用组信息,则根据具体场景,直接拒绝,或者提交到默认的应用组,比如应用组 0
等)。ValidatingWebhookConfiguration
,因此在准入控制的验证阶段,会请求集群中部署的 validating admission webhook
的 API,使用 K8s 规定的结构体AdmissionReviewRequest
作为请求,期待 AdmissionReviewResponse
结构体作为返回。deployment
资源的 admission 的逻辑,根据改请求的动作是 CREATE 或 UPDATE 来计算出此次请求需要新申请或者释放的资源。deployment
的 spec.template.spec.containers[*].resources.requests
字段中提取要申请的资源,比如为 cpu: 2
和 memory: 1Gi
,以 apply 表示。1
的配额信息,比如 cpu: 10
和 memory: 20Gi
,以 quota 表示。连同上述获取的 apply,向 resource usage manager 申请资源。deployment
的资源使用情况,并维护在 store 中。Store 可以使用本地内存,从而无外部依赖。或者使用 Redis
作为存储介质,方便服务水平扩展。1
当前已经占用的资源情况,比如 cpu: 8
和 memory: 16Gi
,以 usage 表示。检查发现 apply + usage <= quota 则认为没有超过配额,请求通过,并最终返回给 API server。以上就是实现资源配额检查的基本流程。有一些细节值得补充说明:
deployment
、mpijob
等,都需要实现相应的 admission 以及 informer 。deployment
有 apps/v1
、apps/v1beta1
等,需要根据集群的实际情况兼容处理。pod
的字段是否变化,来判断是否需要重建当前已有的 pod
实例,以正确计算资源申请的数目。cpu
等,如果还需要自定义的资源类型配额控制,比如 GPU 类型等,需要在资源请求约定好相应的 annotations
,比如 ti.cloud.tencent.com/gpu-type: V100
由于并发资源请求的存在:
在上述步骤 7 中,Resource usage manager 校验配额时,需要查询应用组当前的资源占用情况,即应用组的 usage 值。此 usage 值由 informers 负责更新和维护,但由于从资源请求被 validating admission webhook
通过,到 informer 能够观察到,存在时间差。这个过程中,可能仍有资源请求,那么 usage 值就是不准确的了。因此,usage 需要能够被在资源请求后即时更新。
并且对 usage 的更新需要进行并发控制,举个例子:
2
的 quota 为 cpu: 10
,usage 为 cpu: 8
deployment1
和 deployment2
申请使用应用组 2
,它们的 apply 同为 cpu: 2
deployment1
, 计算 apply + usage = cpu: 10
,未超过 quota 值,因此 deployment1
的请求允许通过。cpu: 10
deployment2
,由于 usage 被更新为 cpu: 10
,则算出 apply + usage = cpu: 12
,超过了 quota 的值,因此不允许通过该请求。上述过程中,容易发现 usage 是关键的 共享 变量,需要顺序查询和更新。若 deployment1
和 deployment2
不加控制地同时使用 usage 为 cpu: 8
,就会导致 deployment1
和 deployment2
请求都被通过,从而实际超出了配额限制。这样,用户可能占用 超过 配额规定的资源。
可行的解决办法:
由于资源竞争的问题,我们要求 usage 需要能够被在资源请求后即时更新,但这也带来新的问题。在 4. 准入控制(验证) 阶段之后,请求的资源对象会进入 5. 持久化 阶段,这个过程中也可能出现异常(比如其他的 webhook
又拒绝了该请求,或者集群断电,etcd 故障等)导致任务没有实际提交成功到集群数据库。在这种情况下,我们在 验证 阶段,已经增加了 usage 的值,就把没有实际占用配额的任务算作占用了配额。这样,用户可能占用 不足 配额规定的资源。
为了解决这个问题,后台服务会定时全局更新每个应用组的 usage 值。这样,如果出现了 验证 阶段增加了 usage 值,但任务实际提交到数据库失败的情况,在全局更新的时候,usage 值最终会重新更新为那个时刻应用组在集群内资源使用的准确值。
但在极少数情况下,全局更新会在这种时刻发生:某最终会成功存入 etcd 持久化 的资源对象创建请求,已经通过webhook
验证,但尚未完成 持久化 的时刻。这种时刻的存在,导致全局更新依然会带来用户占用 超过 配额的问题。
比如,在之前的例子中,deployment1
更新了 usage 值之后,恰巧发生了全局更新。此时deployment1
的信息恰好尚未存入 etcd,所以全局更新会把 usage 重新更新为旧值,这样会导致dployment2
也能被通过,从而超过了配额限制。
但通常,从 验证 到 持久化 的时间很短。低频 的全局更新情况下,此种情况 几乎不会发生。后续,如果有进一步的需求,可以采用更复杂的方案来规避这个问题。
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)
这里 rq
为 ResourceQuota
对象对应 controller 的自引用。这个 Controller 运行 Run
循环,持续地控制所有 ResourceQuota
对象。循环中,不间断定时调用 enqueueAll
,即把所有的 ResourceQuota
压入队列中,修改其 Used
值,进行全局更新。
【腾讯云原生】云说新品、云研新术、云游新活、云赏资讯,扫码关注同名公众号,及时获取更多干货!!