抖音集团的业务类型具备多元化的特点,根据业务对实时性要求的区别,我们可以将这些业务划分为在线业务和离线业务两个业务体系,其中:
在线业务体系通常服务于终端用户,包含 Web 服务,算法服务,有状态服务,视频编解码、FaaS 服务等,这些服务通常对 RPC 调用延迟比较敏感,对实时性要求高。
离线业务体系包含临时查询、定时报表、模型训练、数据分析等作业,这些服务的特点是它们可以承受一定程度的排队或等待,在合理时间得到合理结果即可。
对于大部分的在线服务来说,业务的访问量具备明显波峰波谷的潮汐变化。以抖音为例,绝大部分用户会在晚高峰时段使用抖音,这样就会导致抖音相关服务的整体流量都上涨到一个比较高的水平。而到了凌晨,因为用户使用抖音的次数和频率下降,该时段业务访问的流量会出现比较明显的波谷。
在线服务访问量的变化也导致了这些服务资源使用量的变化。下图展示了抖音集团内部在线业务的天级 CPU 使用情况。
在线业务的天级 CPU 利用率
可以看到,在线业务凌晨时段的 CPU 使用量只有晚高峰时段的 20% - 30%。这种潮汐现象对头部服务而言会更加明显,它们波峰波谷间利用率的差距可能高达到 40%。但是为了保证在线业务在高峰时段的稳定性,在业务部署时,业务方必须按照峰时的流量来预估申请资源,这部分资源在谷时就会被闲置下来,造成严重的资源浪费。
在抖音集团的在线业务体系中,无状态服务如 Web 类微服务有着很高的资源保有量。而 Kubernetes 原生就提供了 HPA 的概念,可以根据 workload 的实际资源使用情况来扩缩无状态服务的实例数。如果我们可以通过弹性伸缩,在业务处于低谷时,通过回收业务副本数的方式来回收这部分资源,然后在业务处于峰值时,重新恢复业务的副本数,那么我们就能够在保证服务的 SLA 几乎不受影响的前提下,回收大量在线低谷时段的弹性资源。
在通过弹性伸缩回收了在线低谷时的资源后,下一步需要做的事情就是将这部分弹性资源进行二次的分配和利用,否则集群整体的利用率并没有因为服务弹性伸缩而得到本质的提升。但是在凌晨时段,几乎所有在线业务都会面临使用频次减少的情况,所以无法通过扩容在线的方式来利用这部分资源。而抖音集团也有很多离线的任务同样需要资源进行调度,例如视频转码和模型训练等,这些任务对资源的需求相对来说没有特定的时间约束,所以天然能够利用闲置资源。在这样的背景下,我们就开启了通过弹性伸缩来实现在离线业务的混部,即分时弹性混部。
弹性伸缩
大部分的在线服务都属于无状态服务,可以直接通过水平扩展来增加副本数。抖音集团内部并没有使用原生的 Deployment 描述在线的无状态服务,也没有使用社区原生的 HPA 体系,而是在上面构建了一层 HPAGroup 用于控制多个 Deployment 支持小流量或者 AB 发布,同时也方便我们在原生能力上针对某些场景做能力增强。整体架构如下图所示。
从图中可以看到,Agent 负责采集业务各种数据,包括业务指标如 QPS 、P99 延迟等,以及系统维度指标如 Load、CPU 利用率等。这些数据最终会由两个接收方进行消费,一方面它会通过中心式采集的组件进入到实时数据的存储系统,另一方面它会通过一个消息队列进入离线算法模型中。
中心式的 Controller 负责消费这两种数据,并在这些数据的基础上决定当前的扩缩容行为。因此扩缩容行为是由 Controller 调整 HPAGroup 的 replica 数,最终进入到 K8s 调度体系中产生 Pod,完成最终的调度。
在弹性伸缩的流程中最重要的就是实时性和稳定性,对于在线业务来说,我们需要保证它们被缩容的实例在流量上涨之后能够被迅速地扩容,同时需要保证有足够的资源可以让在线业务完成扩容,否则就有可能造成单实例流量过高影响服务的 SLA,严重时可能会导致服务不可用,甚至影响到整个请求调用链路。所以我们需要底层系统的配合来提供一整套的机制进行保证,主要包括几个方面:
监控体系:需要一套集群维度的监控体系,为弹性伸缩提供稳定实时的利用率数据。
Quota 体系:需要一套 Quota 系统保证业务在伸缩的过程中,集群整体的资源量是可控的,不能出现在波谷时将服务的副本数缩容后,它所对应的 Quota 被别的服务占用且无法归还的情况。
监控体系
从上文中描述的弹性伸缩过程可以看出,控制面强依赖于从监控系统中获取服务的实时资源利用率情况,需要尽可能避免利用率采集出错或者延迟太高,导致服务在需要扩容时扩不上去的问题。抖音集团在实际生产中没有采用 K8s 原生的 Metrics Server,主要是基于以下的考虑——
首先, Metrics Server 只能代理实时数据,不存储历史数据。如果希望在弹性伸缩中根据历史数据做一些更平滑的策略,基于原生 Metrics Server 无法很好的实现。
其次,由于抖音集团的弹性伸缩能力可以基于多集群的联邦,所以需要在联邦层上得到服务资源使用情况的汇聚数据。
最后,不是只有弹性伸缩依赖监控系统,业务也需要实时通过内部的监控管理系统查询业务的实时数据和历史数据,所以监控系统内还需要将数据同步到内部的离线分析系统。
为了更好地支持这些特殊逻辑,研发团队借鉴 Metrics Server 实现了一套监控系统,主要包括以下的组件,其中 Metrics Agent 负责提供单机上 Pod 聚合数据,并以标准的 Prometheus 格式对外暴露数据;Collector 通过轮询收集每台机器上的数据,然后写入到 Metrics Store 中;Metrics Store 是一个基于内存的数据库。
另外在提高整个数据采集链路的高可用和高性能方面,我们也做了一定的优化,例如对所有组件都采用多实例 stand by 的方式部署。Collector 支持根据 Node 做分片采集,Metrics Store 支持根据 Metrics 分片存储;Custom Metrics APIServer 从 Store 中读写数据时采用 quorum 机制保证数据的准确性;Store 自身支持故障恢复和数据同步;每个 Store 实例都在内存中以 podName 为 key 构建 B 树以提高查询效率,并对数据内容进行压缩来降低存储压力。同时 Store 还支持一些控制面计算逻辑的下发,例如直接返回服务的平均利用率等信息,由于抖音集团的部分服务可能规模非常庞大,单 deployment 副本数可以达到 5000+,所以计算逻辑放在 metrics 系统中实现能够大大降级数据传输的量和延迟。目前这套监控系统单次查询延迟在 30ms 左右,单次采集延迟在 60s 左右,基本能够满足弹性伸缩的需求。
Quota 体系
Kubernetes 原生提供了简单的 Quota 实现,但是由于抖音集团内部的服务有特定的组织形式,组织内部存在着比较复杂的嵌套关系,套用原生的 Quota 系统会非常难以维护,同时也无法对计算类服务所需求的定制化资源进行更好地支持。
我们从零开始构建了自己的 Quota 计量体系,具体来说,服务大致会按所属的业务划分到不同组中,我们使用 CRD 对象来记录各个组中所有服务的总体资源可用量和使用量的信息,然后通过旁路的 Controller 不断轮询更新对象的内容。当业务方对服务副本数进行修改时,APIServer 的请求处理链中会通过 validation webhook 对该服务的资源进行校验和准入控制。尤其需要说明的是,当服务开启弹性伸缩后,Quota 系统将通过扩容的实例数上限进行资源的预留,从而保证资源弹性伸缩过程中资源量可控。
伸缩策略
目前我们支持根据 CPU、内存、GPU 等多个资源维度进行弹性伸缩,另外我们还补充了一些新的特性,这里有两个重点特性值得一提:
支持根据时间段设置不同的配置、支持设置服务级别的对利用率小幅波动的容忍度、支持单步扩缩容的步长。关于这个配置的背景主要是因为一些算法相关的服务在启动和退出时需要进行数据的同步操作,如果单次扩缩容实例数较多,可能会对底层的存储组件造成较大的瞬时压力,所以需要将一次较大的扩缩容行为拆分为多次较小扩缩容行为来做一个缓冲,使得服务副本数的变化更加平滑。
使用每个服务小时级别的历史数据作为保底的策略,以应对监控系统异常的情况。这里我们还是利用了服务天级的利用率比较稳定的特性,在监控系统出现问题导致无法获取监控数据时,控制面可以使用该服务昨天相同时段的利用率数据来作为指导扩缩容的兜底策略。
分时弹性混部实践
系统架构资源出让与回收
在开启了大规模弹性伸缩后,在线业务就具备了生产和使用弹性资源的能力。正如背景介绍中提到的,为了能够充分地使用弹性资源,我们采取的方案是和离线作业进行混合部署。
最开始开展混部项目的时候,我们的底层隔离能力还并不十分完善,如果将在线和离线业务同时摆放在一台节点上运行,容易出现在离线业务之间的互相影响,导致在线业务的 SLA 受损。
所以我们早期采取的是 “0/1” 的方式进行混部。具体来说,就是把在线业务波谷时产生的弹性资源折合成同等资源规模的整机出让给离线业务使用,使得同一时段不会同时有在线业务和离线业务运行在同一台机器上;当在线波峰来临时对弹性资源进行回收。
为了实现这个逻辑,我们引入了集群部署水位的概念(见下图)。
初始情况下,在线服务没有进行弹性伸缩,集群中整体的资源部署处于满载状态。当在线服务的波谷来临后,几乎所有服务都会因为弹性缩容而导致副本数降低。从整体上看,集群里节点上的 Pod 会变得非常稀疏,集群整体资源部署水位也随之降低。
当集群的部署水位低于设置的阈值后,控制面会通过一定规则选取部分在线节点,将该节点上的 Pod 驱逐到别的节点上,并标记该节点不可调度,最后将离线服务调度到该节点上实现资源的出借。
同理,当在线服务的波峰来临后,会发生一个逆向的控制过程,控制面在通知并确保离线任务撤离后,重新将节点设置为在线可调度状态,实现资源的回收。在实际操作中,集群的水位阈值通常会定义在 90% 的水平上,这就意味着集群在常态下会始终维持在一个接近满载的部署状态中,从而能够最大限度地提升资源利用率。
分时弹性混部的控制面最主要的职责就是控制节点的动态出让和回收,即节点的动态伸缩。可以用一个状态机来描述节点的转移状态,其中 Online 和 Offline 分别表示节点处于在线使用和离线使用的状态。而当节点被选择进行出让和回收时,会分别先进入到 OnlineToOffline 或 OfflineToOnline 的中间状态。在此状态下在离线都无法调度新的任务到节点上来,控制面会负责清理残留的在线的 Pod 和离线任务,并执行用户所配置的 hook 函数,只有当这些工作都处理完成后,才会真正完成节点的出让和回收逻辑。
离线业务稳定性保证
弹性资源最大的特点是它整体的资源供应量不确定,当在线服务出现抖动时,我们需要优先保证在线服务的稳定性,极端情况下需要通过杀死离线业务来为在线服务腾挪资源。而离线任务通常运行的时间长,频繁杀死和重跑任务对离线业务来说也会造成较大的影响。因此如何在不稳定的资源供应基础上保证离线业务的稳定性也十分重要。
资源不稳定性主要来自以下两个方面:
弹性资源的供应量是不稳定的。弹性资源的供应量受制于在线业务的扩缩容情况,这会导致整体的资源的总量、资源供应的时间,甚至每一天资源所对应的具体的机器环境是不一样的。因此我们没有办法让离线业务针对弹性资源做一些提前的资源规划,同时当在线业务发生任何抖动时,我们会随时进行资源回收,这对整个训练作业的体验并不好。
弹性资源的需求量是不稳定的。离线作业存在 min/max 语义,例如在一个 PS-Worker 离线训练中,Worker 的数量其实是不确定的,离线业务整体的资源描述也并非确定值。同时我们还需要解决一个问题,即在提高单个作业的训练速度和满足更多训练作业之寻求平衡。
那么在抖音集团内部,研发团队如何解决上述问题?
在资源供应方面:我们在执行缩容操作的过程中,引入了 deletion cost 机制定义实例缩容的优先级。比如我们可以尽可能地缩容整机,甚至尽可能地保证这些缩容出来的资源处于同一个 Pod 或者使用了同质的 GPU ,从而减少资源碎片的问题。
在资源分配方面:对于一些离线业务例如离线训练来说,因为作业在调度和非调度的过程中,可能会执行很多次 checkpoint dump 和 reload 操作,这个操作过程需要从 HDFS 上实现完整模型的上传和下载,非常耗时。如果我们运行更多的作业,虽然在一定程度上可以优化用户的体验,但是弹性资源在归还给在线业务时会触发多次无效的 checkpoint 操作占据大量时间,从而降低资源的利用效率。因此对于离线训练业务,我们更倾向于提高单个作业的加速比,而不是运行更多的作业。
在资源回收方面:为了解决资源回收的过程中无脑地杀死离线业务的问题,研发团队构建了弹性资源的优先级,基于优先级实现资源回收。目前的弹性资源大概分为三级,如下图所示。以 PS-Worker 架构的离线分布式训练为例,PS 作业可能会处于一个 High 的优先级;能够满足基本运行的 Min 的 Worker 处于中优的优先级;为了进行弹性加测的 Worker 处于 low 的优先级。这样做的好处是当我们在线进行资源回收时,我们可以定制一些调度器的抢占策略,使得在线服务总是倾向于去抢占低优先级的作业资源。除此之外,我们在分时弹性混部的控制面引入提前通知的机制,在提前通知的时间窗口内,不会再调度新的离线任务,同时尽可能保证那些已经被调度的任务顺利跑完,从而将任务杀死率维持在一个可接受的范围内。
Priority Mapping
案例分析
视频编解码使用弹性资源
场景一是视频编解码服务和在线服务进行并池。我们基于 Kubernetes 生态系统构建了一套适用于视频计算的解决方案,支持视频相关任务的调度、运行与结果回调全流程。简化的架构如下图所示:
其中:
我们定义了一种 GroupCRD 的自定义资源,用来管控一类具有完全相同的任务执行环境(镜像、资源规格、环境变量等)的 Pod。
Task scheduler 负责从上游接收用户的函数任务,并将任务分配到正确的 Pod 上执行,并完成任务执行结果的回调。
每个 pod 都是执行视频编解码任务的 executor,它从任务调度模块获取到需要执行的函数任务,完成任务执行之后发起任务执行的回调用户过程。
Scaler 可以根据 pod 状态、任务状态、资源状态等信息调整 GroupCRD 中的 Pod 副本数,实现弹性伸缩
在这个场景中,视频编解码任务具备了两个特点:
任务之间相对独立,可以利用有弹性能力的业务架构来快速扩容。
每个任务的处理时间短,一般在分钟级以内,任务的杀死成本低。
这也使得视频编解码任务可以更平滑地接入弹性资源。
Ring AllReduce 使用弹性资源
场景二是 NLP 和在线推理服务进行资源并池。通常来说, NLP 场景更适合使用 Ring AllReduce 的训练方案。我们可以在一个 GPU 的显存里完整地加载所有的模型参数,通过 Ring AllReduce 更加合理地使用整体带宽,从而达到较高的加速比。
框架说明:在具体实现中,研发团队基于社区的 horovod 和 et-operator 实现了 Ring AllReduce 框架弹性。从上图可以看到,框架引入了一个中心式 Launcher 负责 Worker 之间通讯环建立、Worker 健康状态检查、异常处理等逻辑。
该场景中的弹性思路是:将 Launcher 和满足基本训练需要的 Worker 运行在稳定资源上,同时将用于加速的弹性 Worker 运行在弹性资源上。在线推理模型通常来说比较大,一个推理模型的实例可能会占据一个整机,因此这种做法的好处是:缩容一个在线实例等于缩容一台机器,从而供给完整的机器给 Ring AllReduce 的 Worker 运行,规避单机硬件资源的隔离问题。
目前整套框架的弹性加速比可以达到 1:8 水平,达到了对弹性资源充分利用的效果。
PS-Worker 使用弹性资源
场景三是 PS-Worker 和推广搜核心服务共用 CPU 和 GPU 资源的情形。通常来说,CTV/CVR 的训练模型非常稀疏,而且特征维度非常庞大,所以单机基本没有办法装上所有的特征参数,因此这类训练模式基本上只能使用 PS-Worker 架构。抖音集团内部对 CTV/CVR 这种训练模型的需求十分大,为了更好地支持这种训练任务,研发团队自研了一套 PS-Worker 框架进行异步训练。
与传统 PS-Worker 不同的是,自研框架中的 Worker 被拆分为 Sparse 部分和 Dense 部分。其中 Dense 部分主要负责稠密模型的训练,它能在一个完整的 GPU 卡上加载所有模型参数,从而实现更好的加速效果;而 Sparse 和 PS 通常运行在廉价的 CPU 上。
同时, PS、Worker、在线三种服务会可能同时运行在一台机器上,共享部分单机资源,需要我们提供一些隔离机制减少互相干扰。例如,我们采用双网卡方案,在单机上进行分流,在交换机侧通过流量优先级打标的方式保证在线稳定性;在 NUMA 分配策略上通过微拓扑感知的能力,针对不同的角色定制 NUMA 分配逻辑,例如 PS 与 Worker 不共享 NUMA,Worker 可以共享 NUMA。
另外,PS-Worker 的弹性粒度是作业整体维度,不可避免会造成比较大的资源碎片,为了解决该问题,我们引入了视频编解码或者 Spark 的一些 batch 类作业来填充资源碎片。
目前,这套框架在抖音集团内部得到了广泛使用,每天可以出让大概 300 万核心乘以 7 小时的资源。
总结
对于由在线业务潮汐现象而导致的资源浪费,我们可以通过分时弹性混部,即将缩容在线实例而获得的资源以弹性资源的形式,折合成整机出让给离线业务的方式来实现资源效能提升。分时弹性混部的优点在于对基础设施能力要求较低。缺点一方面在于出让的粒度为整机,容易形成资源碎片;另一方面在于在离线业务对于潮汐过程都有一定程度的感知。
总体来说,分时弹性混部比较适合基础设施能力建设尚处于早期的用户,在现有环境中快速上量,实现资源效能提升。
相关开源项目:github.com/kubewharf/katalyst-core
相关服务咨询:
扫码咨询
点击此处链接,体验字节跳动同款云原生技术!
评论