亚马逊AWS官方博客

深度解析 Amazon ECS 集群上的 Auto Scaling 机制

Original URL:https://aws.amazon.com/cn/blogs/containers/deep-dive-on-amazon-ecs-cluster-auto-scaling/

 

内容摘要

长久以来,保证ECS集群内的各EC2实例根据实际需求进行数量扩展、持续适应任务与服务的运行状况,一直是个颇具挑战的难题。ECS集群很难始终根据即时需求进行横向扩展,而且稍有不慎,扩展操作就有可能给集群的可用性造成影响。有时候,客户会使用Lambda函数、自定义指标以及其他的硬处理方法应对这类挑战,但没有任何一种方法能够保证全面有效。当然,把EC2实例上的任务转移到Fargate当中确实可以消除这种集群扩展需求,但相当一部分客户其实并没有意愿或者能力通过Fargate处理所有工作负载。

ECS Cluster Auto Scaling (CAS)是一项面向ECS的全新功能,专门用于管理EC2 Auto Scaling Groups (ASG)的伸缩工作。使用CAS,您可以配置由ECS负责ASG的自动伸缩,将您的精力集中在任务运行本身。ECS能够保证ASG根据需求即时调整自身容量,不再需要额外的人为干预。CAS以ECS容量提供程序(ECS Capacity Provider)为基础,后者负责提供ECS集群与所使用ASG之间的联接。每个ASG与单一容量提供程序相关联——每个容量提供程序只能对应一个ASG,但我们可以将多个容量提供程序关联到同一个ECS集群。每个容量提供程序都将管理与之关联的ASG的对应伸缩任务,整体集群也由此获得全面的容量自动伸缩能力。

我们之所以要推出CAS,一大核心目标就是在不干扰用户正常操作的前提下实现ECS集群伸缩。但很多朋友可能仍然对CAS的幕后运作方式抱有好奇心。在今天的文章中,我们将深度解析CAS的实际工作方式。

设计目标

根据来自客户的反馈意见,我们为CAS确定了三项核心设计目标。

设计目标1:只要ASG没有充足的容量运行客户尝试执行的任务,CAS就应及时对ASG进行横向扩展(添加更多实例)。

设计目标2:只要不会导致任何任务中断(除后台守护程序以外),CAS就应进行收缩(移除实例)。

设计目标3:客户应保持对ASG的完全控制能力,包括设置容量的最小与最大值,使用其他伸缩策略以及配置实例类型等。

在后文中,我们将逐一介绍CAS如何在设计层面满足这三项基本目标。

核心伸缩逻辑

CAS的核心作用,是保证ASG中运行的实例数量始终“正确”,即,足以满足分配给该ASG的任务的处理需求,其中包括正在运行的任务、以及客户随时可能在该实例上添加的新任务。我们将这一“正确”实例数字设定为M,并将ASG中当前正在运行的实例数量设定为N。在后文中,我们将频繁使用M与N来指代这两项数值,请大家牢记二者的含义。目前,我们还没有解释我们如何知道M应该是多少,但是出于讨论的目的,我们假设M是您所需要的数量。在建立起这一基本假设之后,如果N=M,则不需要进行容量增加,同时也不可能进行容量缩减。另一方面,如果N<M,则代表当前正在运行的实例少于需求实例,因此需要进行扩展。最后,如果N>M,则代表可以进行容量收缩(但不一定必要),这是因为实际拥有的实例数量已经超过了运行所有ECS任务所需要的数量。在后文中我们还将以N与M为基础定义一项新的CloudWach指标,名为CapacityProviderReservation。对于给定的N与M,该指标的定义非常简单:

简而言之,这项指标代表的是ASG的需求实例数量与实际实例数量之间的比值,且以百分比形式表示。如后文所述,CAS会使用这项指标来控制ASG的容量伸缩幅度。在以上公式中,数字M代表的是CAS能够控制的部分;反过来,M又由客户要求运行的任务(包括正在运行以及等待运行的任务)所驱动。因此,M的计算方式,将成为决定CAS容量伸缩方式的关键。

为了确定M,我们首先需要明确到底存在多少客户打算在当前实例上运行、但又暂时无法运行的任务。为此,我们调整了原有ECS任务生命周期。以往,任务是否运行只取决于是否存在可用容量。现在,无法在实例上获得充足资源的任务将暂时处于置备状态。例如,如果大家调用RunTask API,但该任务由于资源不足而无法在实例上运行(可能是因为实例上没有足够的内存、vCPU、端口、ENI及/或GPU),那么与以往立即返回失败不同,现在任务将进入置备状态(但请注意,只有在容量提供程序中启用了托管扩展选项时,任务才会进入置备状态;如果未开启,找不到充足容量的任务将与以往一样直接失败)。随着新实例的加入,置备状态中的任务将被分配给这些新的实例,从而减少置备状态下任务的数量。在某些情况下,大家可以将置备状态任务理解成一种队列;随着新资源的加入,队列中的任务也将逐渐减少。

目前,每个集群能够支持最多100个任务处于置备状态,置备任务会等待所需的资源10到30分钟,在这之后如果仍未获得必要的实例资源,则任务状态将转为“停止”、不再进行任何运行尝试。

在这样的新型任务生命周期模式之下,CAS要如何确定所需的实例数M?总体来看,其中的基本逻辑非常简单:

  • 如果每个实例都至少运行有一项任务(不包括守护程序服务任务),且不存在处于置备状态的任务,则M=N。(这里之所以要排除守护程序服务任务,是因为我们不希望由守护程序服务任务驱动规模伸缩活动。每个实例都应运行有守护程序服务任务,否则可能导致集群陷入无限横向扩展的循环)。图一所示,为一个简单示例。

图一:此ASG中包含3个实例(紫色方框,N=3),每个实例中都运行有非守护程序任务(绿色方框)。不存在置备任务。这里不需要添加更多实例,但如果不中断现有任务,集群也不会终止任何实例,因此M=N=3

  • 如果至少存在1个处于置备状态的任务,则M>N。我们将在后文中具体描述这类情况下的M计算方法。图二所示,为一个简单示例。

图二:现有实例(N=3)已经没有更多空间执行图上方的3项置备任务。在这种情况下,需要添加更多实例才能让置备任务得以运行,因此M>3;但要确定M的正确值,还需要更多工作。

  • 如果至少有一个实例未运行任何任务(守护程序服务任务除外),而且不存在任何置备状态任务,则M<N。更具体地讲,M=运行至少一个任务的实例数(这里之所以要排除守护程序服务任务,是因为每个实例都应运行有守护程序服务任务。如果包含守护程序服务,则集群永远不会执行容量缩减)。图三所示,为一个简单示例。

图三:绿色方框代表的是非守护程序任务,蓝色方框代表的则是守护程序任务。很明显,最右侧的实例中只运行有守护程序任务,因此可以在不中断任何非守护程序任务的前提下关闭第三个实例,意味着此时的M=2。

让我们进一步考虑,如果至少有一项任务处于置备状态,又该如何计算M值。我们知道M应该比N大,但具体要大多少?理想情况下,CAS应该为M计算出一个最佳值——即该值绝不大于运行所有置备任务所需要的实例数量。遗憾的是,这个目标在大多数情况下根本没办法实现——不同的任务有着不同的资源需求、位置约束以及部署策略,而ASG本身也可能包含配备着不同vCPU、内存及其他可用资源容量的多种实例类型。这里就不聊太多具体的优化考量了,其中会涉及到大量数学层面的知识;总而言之,我们不可能在实际场景中结合任务与实例的所有情况计算出最佳的M值。

既然无法准确得出M的最佳值,那么CAS就得做出一个较为可靠的估算性结论。如果ASG中已经存在实例,而且假设我们的ASG为单可用区加单一实例类型的简单组合,那么CAS可以估算需要添加的最佳实例数量的下限,并将结果赋值给M。换句话说,我们至少需要M个实例才能运行当前置备任务。以此为基础,CAS对M值的计算过程如下:

  1. 对所有置备任务进行分组,保证每个组都拥有相同的资源需求。
  2. 获取ASG中各新添加实例的具体类型与资源属性。
  3. 对于资源需求量相同的各个组,如果需要使用binpack部署策略,则计算所需的总实例数(部署策略不能更改所需实例数的下限,只能更改这些实例上的具体任务分布方式)。此项计算应考虑到任务与实例的vCPU、内存、ENI、端口以及GPU资源量以及任务部署约束。但除了 distinctInstance以外,我们不建议您使用任何放置约束条件。
  4. 取第3步计算中得出的最大值,作为各任务组中的M值。
  5. 最后,要求M>=N + minimumScalingStepSize且M <= N + maximumScalingStepSize。( 这两项参数在容量提供程序的配置中进行定义。)

这种算法得出的M,通常为所需实例数的下限;有时候结果也正好就是所需实例的最佳值——例如,如果所有置备任务的资源需求都相同,且ASG中只使用同一类型的实例,任务又没有任何放置约束,那么此算法得出的就是正确的实例需求量(前提是符合第5步中设定的M值范围)。

如果事实证明算出的M值不足以运行所有置备任务,上面的计算也不会白费。结合后文中的演示,ASG的目标容量上限为100,它会逐步扩展到M个实例。部分置备任务将被放置在新的实例上,但由于M实例数量仍然不足,因此置备队列中仍存在部分待运行任务。这时,集群会启动新的容量扩展操作,重新计算M值,整个过程反复进行,直到所有置备任务都被消化完成。在理想情况下,我们希望整个过程能够一次性完成,因为每一轮重新计算都需要消耗额外的时间。如果CAS能够一次性扩展至正确大小,ASG的规模扩展周期也将显著缩短。但即使达不到这样完美的效果,集群本身终将扩展至正确的大小。

如果我们的ASG中包含多种实例类型,或者跨越多个可用区,又该如何处理?在这种情况下,上述算法不一定能够给出M值下限,因此CAS会选择更简单的方法:M = N + minimumScalingStepSize。虽然效率不高,但最终仍能将集群扩展为正确的大小。

AWS Auto Scaling与容量伸缩指标

在CAS确定了M值之后,我们为什么不直接对ASG的所需容量进行直接指定(即强制将N更新为N=M)?因为如此这样处理,我们就无法满足第3条设计目标。直接设置所需容量,意味着覆盖掉其他所有的伸缩策略,且全部伸缩决定权都将由CAS所掌握。为了实现三大基本设计目标,CAS在实例终止保护之外,还需要与AWS Auto Scaling相配合。具体来讲,当我们通过ASG容量提供程序启用托管伸缩与托管终止保护时,ECS会执行以下操作:

  1. 为ASG创建一项容量伸缩计划。
  2. 创建一项目标跟踪伸缩策略,并将其添加至容量伸缩计划当中。容量伸缩计划将通过由ECS面向各ASG容量提供程序发布的新CloudWatch指标CapacityProviderReservation实现托管容量伸缩(大家也可以同时引入其他扩展策略,将其添加至容量伸缩计划当中,甚至可以选择使用EC2预测性容量伸缩功能)。
  3. 定期(每分钟一次)发布CapacityProviderReservation指标。
  4. 管理实例终止保护,防止运行有非守护程序任务的实例因ASG伸缩操作而意外中断。

使用CapacityProviderReservation指标的意义,在于控制ASG当中的实例数量,同时保证其他容量伸缩策略也能在ASG中同时起效。换句话说,如果我们不使用任何其他容量伸缩策略,则所需的ASG计数应为M(即CAS初步确定的实例数量)。前文曾经提到,N是ASG当中已经启动且正在运行的实例数量。为了将M与N转换为与目标跟踪伸缩策略相兼容的指标,我们需要遵循“指标值必须与Auto Scaling组中的实例数量等比例增加或减少”这一基本要求。以此为基础,我们的CapacityProviderReservation计算公式(如前所述)为

在某些特殊情况下,这个公式也可能并不适用。如果M与N均为0,则代表当前集群中不存在实例,也没有正在运行的任务或置备任务,这时我们定义CapacityProviderReservation =100。如果M>0且N=0,则表示没有实例、没有正在运行的任务,但至少存在一项置备任务,此时我们定义CapacityProviderReservation =200。(目标跟踪伸缩策略能够支持从零容量开始进行扩展的能力。在这种情况下,此策略会假定当前的容量为1而非0。)

下面再来回顾图一、图二与图三中的场景。

  • 在图一中,CapacityProviderReservation = 3/3 X 100 = 100。
  • 在图二中,我们假设M = 4,因此需要额外添加1个实例以运行3项置备任务。因此,CapacityProviderReservation = 4/3 X 100 = 133。
  • 在图三中, CapacityProviderReservation = 2/3 X 100 = 66。

目标跟踪伸缩策略

目标跟踪伸缩策略能够管理ASG的容量。给定一项指标,并为其指定一项目标值,则伸缩策略将自动增加/减少ASG中的实例数量,也就是随着指标的提升与降低对N值做出调整,从而尽可能令该指标接近或等于目标值。伸缩行为基于以下假设:“指标值必须与Auto Scaling组中的实例数量等比例增加或减少。”CapacityProviderReservation也正是以这一假设为基础设计而来。

假设CapacityProviderReservation的目标值为100,那么规模伸缩策略将上下调整ASG的大小(即N值),直至N=M。而最终的判断标准,就是方程式CapacityProviderReservation =目标值(或者表示为M/N x 100 =100),仅在N = M时为真。如果M发生变化,不论是由于尝试运行更多任务或关闭现有任务,伸缩策略都会通过调整N让其保持等于M。这时,从0开始扩展或者容量缩减为0也将成为可能:如果M=0,意味着当前集群上只运行有守护程序服务任务,则 N将可以被下调为0。同样的,如果N=0而M>0,则代表存在备置任务,但当前无实例正在运行,这时CapacityProviderReservation = 200,集群会上调N值以向ASG当中添加新的实例。

当目标值小于100时,ASG中会有空闲容量。例如,如果将目标值设置为50,则容量伸缩策略会尝试调整N,以使方程式M/N x 100 = 50为真。(需要注意的是,M只是CAS对于运行所有任务所需要的实例数量的估计,而非基于容量伸缩策略的目标值。)通过简单的代数计算,就可以得出N = 2 x M。换句话说,在目标值为50的情况下,伸缩策略会将N调整至CAS预计运行所有任务所需要的实例数量的2倍。这意味着集群中有一半实例不运行任何任务。这些实例将持续存在以供新加入的任何其他任务使用,而无需在添加任务时临时启动。当这些空闲实例开始运行任务后,规模伸缩策略会再次上调N值,保持方程式N = 2 x M为真。同样的,如果原本运行有任务的部分实例不再运行任务,则N值也会相应下调。

一般来说,目标值越小,ASG中的空闲容量就越大。例如,当目标值为10时,则表示规模伸缩策略会在可行范围内调整N值,保证无论当前运行有多少任务,ASG集群中永远有90%的实例不运行任何任务。请注意,如果使用的目标值小于100,则集群容量无法被缩减至零,因为这与维持空闲容量的目标相冲突。

关于目标跟踪伸缩策略的另一项要点,在于其无法始终保证指标完全等同于目标值。例如,如果目标值为75,且M = 10个实例,则M/N x 100不可能等于75,因为N必须是整数。为此,伸缩策略只能调整N以尽可能接近目标值,并在可能的情况下优先选择低于目标值的指标。

容量缩减与终止保护

当伸缩策略减小N时,其只能调整实例数量,却无法控制集群到底终止哪个实例。换言之,即使存在未运行任何任务的实例,ASG也可能默认关闭掉某个正在运行任务的实例——托管终止保护的作用正是为了应对这种场景。

图四:3个实例,其中2个在运行任务。M=2且N=3,因此CapacityProviderReservation =66。(需要注意的是,即使目前正在运行的4个任务完全可以在单一实例上运行,M仍然只基于当前正在运行任务的实例数值进行计算——不涉及任何通过任务分配优化减少实例数量的尝试)。如果目标容量为100,则ASG将去掉1个实例。

以图四中的示例为基础,指标值为66,目标值为100,ASG会按比例将N值从3缩减至2。如果不存在其他输入,则无法保证终止的实例上不存在任务。这样一来,本应被终止的第3个实例可能继续存在,本应继续运行的前2个实例却有可能被终止。为此,我们提供了一项选项,可以由ECS来动态的管理实例的终止保护(借此实现设计目标2)。在启用容量提供程序的前提下,ECS会防止集群终止任何运行有至少1个非守护程序任务的实例。需要注意的是,这项保护机制并不会阻止竞价实例的回收或者对实例的手动终止操作;其只能防止ASG在容量伸缩过程中错误终止某些实例。

图五:在托管终止保护的支持下,ECS能够防止ASG在伸缩操作当中终止运行有非守护程序任务的实例,从而避免当前运行任务的意外中断(设计目标2)。

容量伸缩实操

在对CAS进行了全面定义之后,接下来一起看看扩展与收缩的完整示例。

容量扩展


第一步。集群包含一个容量提供程序,ASG中包含3个实例(如上所示),且所有实例都在运行任务。启用了目标容量为100的托管容量伸缩,同时启用了托管终止保护选项。集群中只运行了一个任务定义,意味着全部任务都具有相同的资源要求。此时M = 3,N = 3,且CapacityProviderReservation = 100。

第二步。调用RunTask以额外添加9项任务。其中6项运行在现有实例之上,另外3项则处于置备状态。现在,M = 4,N = 3,CapacityProviderReservation = 133。指标变化如下图所示。


第三步。一旦指标超过预设的目标值100,则伸缩策略开始执行,将所需的ASG数量从N = 3上调至N = 4。由于ECS还没有进行任务分配,所以这时任务仍暂时处于置备状态。

第四步。ECS识别出可用的额外容量,并将置备任务放置在新的实例上。

第五步。指标更新后,由于M = 4而且N = 4,因此CapacityProviderReservation = 100。不再需要进一步的容量伸缩。

容量收缩


第一步。与容量扩展的第一步相同。集群包含一个容量提供程序,ASG中包含3个实例(如上所示),且所有实例都在运行任务。启用了目标容量为100的托管容量伸缩,同时启用了托管终止保护选项。集群中只运行了一个任务定义,意味着全部任务都具有相同的资源要求。此时M = 3,N = 3,且CapacityProviderReservation = 100。

第二步。停止其中一项任务(例如,由于服务的伸缩带来的任务停止)。现在,前2个实例中仍存在非守护程序任务,因此不会被终止;但第3个实例已经不符合保护标准。但由于尚未触发容量伸缩操作,因此3个实例仍在继续运行。

第三步。现在,其中1个实例上已经不存在任何非守护程序任务,因此伸缩指标被更新为:M = 2, N = 3, 因此CapacityProviderReservation = 66。

第四步。15分钟之后(即经过连续15次检测后,指标值仍然为66),ASG触发容量收缩。由于第3个实例不符合保护条件,因此会被终止。这也保证了容量收缩期间,现有任务不会受到任何影响。

第五步。现在,该实例已经被终止,指标再次更新:N = 2, M = 2, 因此CapacityProviderReservation = 100。不需要进行进一步容量伸缩。

总结

在本篇文章中,我们了解了ECS集群自动容量伸缩机制的设计目标,同时详细说明了CAS如何实现这些目标。CAS所代表的不只是几项新的API,同时也涵盖了ECS的一整套全新行为。建议大家随时关注本系列博文,深入了解集群容量伸缩的工作原理。

最后,对于CAS以及容量提供程序来说,还有更多故事值得挖掘。我们将发布更多深度解析文章,探讨ECS与容量提供程序的更多细节,同时积极扩展现有机制的功能丰富度。如果大家希望获得新的功能,或者对我们的发展路线图抱有兴趣,请访问GitHub上的AWS Containers路线图。感谢大家的关注!

 

本篇作者

Nick Coult

Amazon Elastic Container Service首席产品经理。