背景
抢占式实例是什么
抢占式实例是阿里云提供的一种低价短期实例。其价格受阿里云ECS资源存量的影响,浮动不定,最低时只有包月实例价格的三分之一。阿里云只保证抢占式实例一小时的可用时间,一小时后随时可能被释放。
抢占式实例如何创建
创建抢占式时需要指定类型和最高价格。
举例来说,我们创建时指定类型ecs.c5.xlarge,最高价格0.27元。如果该实例当前价格低于我们的出价,假如是0.15元,则抢占式实例创建成功。一小时后实例价格随时可能变化,变化之前实例的运行费用为每小时0.15。如果价格变为0.25元,之后实例按每小时0.25元计费。直到价格高于我们出价,实例就会被释放。如果创建时实例价格高于我们的出价,抢占式实例不会创建创建,阿里云返回错误”LowerThanPublicPrice”。某些时候,一些类型可能没有抢占式实例资源可用,即出价再高也无法创建成功。
除了价格低廉,后文还会提到抢占式实例的其他优势。
CI环境试用抢占式实例
大规模应用之前,我们先在CI环境中试用了抢占式实例。CI环境的特点非常适合使用这种临时短期实例:
- 大部分实例只在测试、发布代码时才会工作,其余时间都是闲置的
- 大部分代码的测试和构建任务不需要很长时间,一个小时的生命周期完全足够
我们的运维机器人实时监控Jenkins任务队列,如果有正在等待资源的任务立即创建抢占式实例并加入CI集群。开发时有两个问题需要注意:
- 初始化实例
虽然很多步骤可以预置到ECS镜像中,但还是有些操作需要在实例启动后进行。最初我们使用ssh,待实例启动后登陆实例执行初始化脚本。实际应用后发现,实例启动后到ssh端口可用这段时间很不确定,有时实例启动后几秒钟ssh端口就通了,有时却长达一分钟。所以不得不在代码中重复尝试直到成功登陆。之后我们发现阿里云支持Cloud Init,可以在创建实例时指定实例启动后自动执行的脚本(CreateInstance接口的UserData字段)。使用Cloud Init相当于将阻塞的初始化任务变成异步非阻塞,应用程序的代码更简单,其可靠性经我们测试也非常高。 - 抢占式实例创建失败
受阿里云策略的影响,抢占式实例价格浮动不定,甚至可能无货。为了保证CI系统正常运转,如果抢占式实例创建失败需要继续尝试创建按量实例。
试用结果
最终我们的CI系统只保留了一台包月实例运行Jenkins Web UI和git server,构建各种服务的发布包和容器镜像的任务都运行在按需创建的实例上。产品发布高峰期CI集群中有十几台抢占式实例。实例加入集群的速度控制在五分钟内,在CI系统中这样的延迟是完全可以接受的。
小规模应用到生产环境
由于在CI环境中试用很顺利,创建成功率很高加入集群的流程也很稳定,我们进一步将抢占式应用到生产环境。
我们设置了集群节点数,由运维机器人保证集群内始终有足够的节点,如果不够则尝试创建抢占式实例。设置集群节点数量的好处是,如果抢占式实例应用顺利可以继续推进,只需要删除一台包月实例运维机器人就会自动补上一台抢占式实例加入集群。
和CI环境类似,生产环境中使用抢占式实例也包括创建实例和初始化实例两个步骤。不同于CI环境对实例的需求是短期的,生产环境中的实例会一直运行业务,如果创建到按量实例就很不划算。所以当抢占式实例应用到生产环境后我们作了一些尝试来提高创建成功率:
- 加入更多类型
每种类型的资源状态不同,一种类型当前无货可能其他类型还能够创建到。 - 实例分散在不同类型上
我们推测阿里云的资源池是以类型来划分的,所有类型资源同时波动的概率相对小些。尽量创建不同类型的抢占式实例,避免集群中全是同一种类型的实例,可以降低集群内所有抢占实例被同时释放的风险。实现上,实例类型是一个有序数组,初始顺序以价格从低到高排序,创建时从第一个类型开始尝试。如果一种类型创建成功,降低权重使其排序靠后,下次创建时优先使用其他类型。
扩大抢占式实例规模
随着生产环境中使用规模的增,抢占式实例负担了越来越多的业务服务,释放抢占式实例带来的风险也越来越大。
这个阶段我们的目标是,在实例被释放前创建新实例并妥善处理待释放实例上的服务。阿里云在释放实例前五分钟会将实例标注为待释放状态,运维机器人检测到有实例将被释放,执行以下动作:
- 立即创建等量实例
- 被释放实例移出白名单
当实例被移出白名单后,已有服务不受影响,之后新启动的服务将不会再分配到这台实例上。 - 迁移待释放实例上的服务
我们使用mesos+marathon运行服务,如果一个容器退出,marathon会新启动一个容器保证服务的节点数量不变。即使不做额外的操作这个模型也可以保证服务最终恢复到正常状态,但这样的波动对服务质量还是很有影响的。比如某个高流量的服务少了一个节点,其它节点流量会相应升高;某个服务一共两个节点且两个节点所在的实例全部被释放,那么该服务将短暂地处于无节点状态。抢占式实例的释放相对高频,频繁的波动必定会严重影响服务质量。因此在实例被释放前应该先迁移上面的服务。既然知道服务节点将会减少,我们就提前给服务增加节点。例如data-api
一共五个节点,其中两个节点所在的实例即将释放,则将data-api
服务扩展至七个节点。 - 关闭待释放实例
阿里云在释放实例前会使用”强制停止”停止实例,强制停止相当于断电,这会带来两个问题:
- 如果机器正在处理请求,断电会造成该请求被中断
- 断电会使mesos-slave与master失联,从而引起marathon上短暂的服务状态异
所以在阿里云释放实例之前,我们会主动停止服务并关闭实例,保证服务和集群状态正常。因为第3步增加了节点数量,这一步在停止服务时使用scale=true
参数将节点数调回到初始状态。
这4个步骤需要注意的是,执行第1步后不能立即执行后续步骤,因为新实例需要一段时间才能加入集群,如果立即将待释放实例移出白名单,第3步新增的节点可能没有足够的资源运行,第4步又将旧节点停止最终服务的节点就会变少。所以我们在第1步之后会等待3分钟,保证有足够的时间让新实例加入集群,并让第3步新增的节点在新实例上面运行起来。
实例级伸缩
我们之前的运维系统实现了服务级别的伸缩,运维机器人会根据服务的负载增加或减少服务节点。当时没有做实例级别的伸缩主要是因为阿里云的价格策略,按量实例的价格约是包月实例的三倍,如果频繁使用按量实例,成本上毫无优势。
抢占式实例价格低且按小时计价,非常适合用来实现实例级伸缩。如果集群内资源使用率过高,运维机器人创建抢占式实例,较低则尝试移除一台实例。
之前我们为集群设置了节点个数的限制,保证集群中始终有足够的实例。现在我们将节点个数改为了CPU核数,增加实例时也是按CPU数量增加。例如增加8个CPU,8个CPU可能是一台实例也可能是四台实例。当集群内多台实例将被释放时,运维机器人会计算其CPU总数,创建与之等量的实例,可能是多台也可能是一台。
关注CPU数量而非节点个数,屏蔽了不同类型的差异,简化内部实现。而且只关注计算资源本身也更符合mesos框架的思想。我们业务服务的内存CPU比值大约为二比一,而阿里云的绝大多数机器的内存CPU比值都大于二,所以使用CPU一个指标就足够,不用再考虑内存。
运维机器人的判断逻辑:
- 所有实例按资源使用率排序,若最低的实例超过了70%,增加8个CPU
- 如果最低两个实例的使用率之和低于20%,尝试删除一台实例
理论上,增加的阈值设置为70%,那么删除时只要保证最低两个实例之和低于70%即可,这样就不会出现删除实例之后又达到增加的阈值。但是我们的集群中还有很多定时任务,这些短任务随时有可能被启动占用集群资源。如果删除的阈值刚好是70%,有可能在删除后某些定时任务又被启动了,这时就会达到增加实例的触发条件。所以我们将删除的阈值设为20%,给这些定时任务留出空间,防止集群内实例伸缩抖动。 -
尝试移除实例的策略:
- 优先删除按量实例
- 优先删除低CPU实例,每台实例上都会运行一些基础服务,所以大实例的资源利用率更高
- 抢占式实例尽可能保留到整数小时。抢占式实例是按小时计费的,我们对其创建分钟数取模,大于50分钟时才正真删除
- 单个实例上是否有足够的资源
除了考虑资源使用率,还应该考虑单个实例上是否有足够的资源运行服务。某些情况下当所有大实例都不可用,创建到的抢占式实例都是小实例(2核或4核),如果只判断使用率就有可能出现所有实例的资源使用率都低于70%,但单个实例上没有足够的资源运行某些资源占用较多的服务。 - 给集群设置CPU下限与上限。
下限是为了防止集群在业务低谷缩得太小,从而不能应付突发流量;上限是防止某些极端情况,比如代码或数据库的问题导致负载升高,这时增加再多的节点也无济于事。
提升动态实例的创建效率
经过长时间的应用,我们集群中的抢占式实例已经占了大半,期间遇到过多次所有实例被同时释放。至此我们改变了思路,将抢占式实例被释放当成随时可能会发生的常态,我们能做的就是在收到释放通知后尽可能快的创建新实例。
创建抢占式实例的时间
在谈优化之前,先量化一下创建一台实例所需的时间。
- 调用阿里云创建接口
调用创建接口失败大概需要1秒,成功需要4秒。假设尝试了6种类型都失败,第7种类型成功,那么耗时为6 * 1 + 4,一共10秒。在没有任何预判随机选取类型依次尝试,平均需要尝试7次,这个概率大致符合实际情况。这也是后文量化数据的一个前提假设。 - 初始化动态实例
当创建接口调用成功,会返回实例的InstanceId。我们发现接口调用成功后使用该InstanceId立即去查询有可能会查不到,而且实例创建后会短暂地处于”Pending”状态,该状态无法做任何操作,所以创建成功后需要sleep 15秒。实例启动需要30秒左右。启动之后会设置数据库白名单、SLS机器组,加上mesos相关属性的一些设置,初始化实例一共需要50秒。
创建一台实例一共需要60秒。
实例类型尝试策略改进
如前所述,调用创建接口时即使失败也需要1秒左右,在可尝试类型很多的时候,大批量的失败调用也非常浪费时间。要提高创建效率,首先需要提高创建时的成功率。
- 只尝试有可能成功的类型
最初在代码里hardcode了几个常用的类型,可尝试类型很少,失败的概率也很高。后来导入了所有实例的类型依次尝试,不仅效率很低而且还触发了阿里云的接口调用频率的限制,可见也不能盲目加入过多类型。
最终的方案是使用阿里云的历史价格接口DescribeSpotPriceHistory,这个接口返回抢占式实例近期的价格变化。我们抓取所有最近价格低于出价的2核到12核类型,在创建时只尝试这些类型。虽然这个接口的数据并不是实时的,但也大幅提高了创建成功率。
- 失败后移除实例
之前为了防止创建相同类型的实例,一个类型创建成功后会降低其权重,避免下一次再创建该类型的实例。也就是说,我们宁愿多几次失败的尝试也不希望集群中出现相同类型的实例。但从多次抢占式实例全部被释放的情况来看,这种防御措施用处不大。后来我们改为只要某个类型失败则半小时内不再尝试该类型。这样可以大大减少(半小时内)二次创建时的失败次数。
并发创建模型的演进
创建一个实例需要一分钟,一次创建大量实例时,不得不使用并发模型。我们的并发模型经历了四次演进。
1. 按节点数创建的简单并行
最初按实例数量创建,创建5个则由5个worker并行创建。模型如图:
创建总时间为60秒。但这种使用实例个数作为创建指标的方案只适用于仅使用单一类型实例的小型测试集群。由于创建单一类型实例失败的概率很大,这样的方案无法应用于生产环境中。为了将多种实例类型统一起来,我们后面改为使用CPU数量作为创建指标。
2. 按最大CPU核数拆分子worker
改为按CPU数量创建后带来的问题:应该按多大的粒度(CPU个数)拆分子woker?假设需要创建48核,如果分为24子worker每个子woker创建一个2核的实例,顺利的话24个子worker一次并发就能成功,创建时间为60秒。但可能会遇到没有2核实例的情况。如果拆分的粒度太大,假设每个子worker创建24个核,由于我们设置最大的类型为12核,所以每个子worker至少会创建2次。总时间为2 * 60 = 120秒。
我们采取了一种折中的方案,采用最大CPU核数(12)拆分为4个子worker,理想情况下1次并发就能创建完成,耗时60秒。如果12核资源不可用,那么子worker会尝试其他其他资源,创建时间也会相应延长。
3. MapReduce模型
上一种模型的效率很大程度上取决于阿里云的资源池状态。例如一个子worker创建12核,尝试12核实例不成功,转而尝试8核实例,8核不成功再一次尝试4核、2核。最坏的情况,每个子worker都只能创建2核实例,需要串行6次才能将12核创建完成。总时间为6 * 60 = 360秒。
继续优化模型,上一种模型的问题在于,以最大CPU核数(12核)拆分子worker,但有可能当前所有12核实例都不可用,所以在子worker中又不得不继续串行创建。
理论上,我们将失败的类型标记为半小时之内不再尝试,那么可尝试的第一个类型应该就是最有可能创建成功的。我们以这个类型的CPU来拆分子worker,一次并行的成功率应该会很高。问题是,创建抢占式实例的频率远低于失败类型过期的时间(半小时),于是,在创建第一组实例时,所有类型会被认为是可用的,无法达到有效过滤的目的。
因此,我们引入了MapReduce模型,子worker只创建一个实例,成功后立即返回告知主worker,不再继续创建。主worker汇总每个子worker的创建结果,如果还需要创建,则再进行拆分。如果第一次拆分使用的CPU数量不可靠,第二次使用的CPU数量是一分钟前成功创建的,再次创建成功的概率就非常高了。
例如,第一次按12核拆分了4个子worker,每个子worker都创建了一个4核实例,reduce之后还剩32核需要创建。第二次拆分就会按4核拆分出8个子worker,这8个子worker创建4核实例的成功率非常高,几乎不会进行第三次拆分。
这个模型绝大多数情况都不会多过两次map,总时间为 60 + 55 = 115秒。第二次不会再遇到错误尝试,所以会快5秒。
4. 异步初始化
上一种模型还有改进的空间,实例创建后子worker需等到实例启动并加入集群才认为实例创建成功,大部分的时间是在等待实例初始化。
初始化步骤调试稳定后,几乎没有出过错。既然出错的概率很低,我们完全可以认为只要实例创建成功那么一定会加入集群运行业务,所以子worker不用等到实例初始化完毕才返回。只要实例创建成功,子worker立即fork一个新的worker初始化实例,然后返回通知主worker实例创建完毕。
这也是我们现在使用的最终方案,创建效率提高了将近一倍。两次map的初始化动作是并行的,总时间为65秒。
最终方案耗时65秒,实际线上运行的创建成功率100%,并且效率很接近第一种全并发的方案。
有了创建效率的保证,才能抵抗抢占式实例被释放的风险,在旧实例被释放前,新实例已经加入了集群接过之前运行在旧实例上的服务,旧实例释放后对服务没有丝毫影响。我们有时一天能遇到好几次实例全部被释放的情况,但运维机器人每次都能做到平滑过渡,业务层面无感知。
改进空间
当前使用的最终模型仍有继续优化的空间。
1. 主动替换实例
当所有抢占式实例都不可用,就会创建到按量实例。目前对按量实例的处理仅体现在删除上,删除时优先删除按量实例。
如果1:00创建了三台按量实例,2:00抢占式实例可用了,但系统可能分别在3:00、4:00、5:00才将三台按量实例删除。我们可以加入主动替换的模型,只要集群中存在按量实例,运维机器人就会周期性地尝试创建抢占式实例,创建成功则移除按量实例。
更进一步,除了主动替换按量实例,还可以主动用低价实例替换高价实例,以及用高配置实例替换低配实例。
因为创建按量实例的概率很低,所以这种改进暂时不是很有必要。
2,主动将服务压缩至一台机器
运维机器人在动态伸缩实例时,会判断每个实例的资源使用率。服务在每台实例上的分布没有人为干预,有可能出现两台实例上的资源使用率分别是40%,这种情况将两台实例的服务迁移到一台实例上,就能释放一台实例。
遇到的问题
在抢占式实例的应用过程中,有下面几个值得注意的问题。
1. 实例不存在成为常态,需要更大的错误容忍度
之前集群内节点几乎不会变化,引入抢占式实例后,集群内节点变动非常频繁,一个节点下一秒可能就被释放,任何对该节点的引用都会产生错误。系统各处需要妥善处理这里错误:
- zabbix host unreachable
实例只要被移除,zabbix便会出现unreachable错误,以往这种出现这种情况需要人为干预,现在需要将实例从zabbix配置中移除 - 服务发现不要求注册成功
之前部署一个nginx的后端服务,需要通知对应的nginx服务注册后端到upstream中,只有确认注册成功后后端服务才会通过健康检查。现在nginx服务所处的实例很有可能已经被释放了,所以我们在部署后端服务时,发送注册请求后不用再等待其返回。 - 使用nginx健康检查插件
nginx默认只支持被动的健康检查,即使某个backend不可用,实际的请求至少会错误一次。实例频繁被释放,nginx backend节点不可用的概率也会增大,使用nginx默认的健康检查就会影响服务质量。我们采用了淘宝的nginx健康检查插件nginx_upstream_check_module,支持主动模式,可以很好的解决backend不可用的问题。
2. 优先保证抢占式实例创建业务线的运行资源
在抢占式实例的应用初期,我们遇到过新实例没有创建起来,旧实例被释放后大量服务没有资源运行。其中包括我们的运维机器人和创建抢占式实例的一些运维服务,结果造成集群资源已经非常紧张了但是新实例无法被创建。
我们为抢占式实例相关的运维线服务分配了独占资源,以保证其运行资源不受影响。
3. docker pull直连OSS
大量新实例加入集群后,一时间会全量拉取很多image。由于我们的docker registry没有开启直连OSS,所有请求都会走registry中转,受registry所在ECS带宽限制,多节点同时拉取大量image速度非常慢。
阿里云有为docker registry提供一个官方的OSS driver,设置一个启动参数即可开启直连OSS。之前没有开启是因为我们有跨区拉取image的需要。我们的docker registry位于杭州区,但CI环境部署在上海区,无法访问杭州区的OSS,所以需要走docker registry中转。
我们最终在杭州区部署两个docker registry指向同一个OSS,一个registry开启直连OSS供产生环境使用,一个不直连OSS供CI环境使用。
4. 创建实例不指定InstanceName
最初我们在创建抢占式实例时会指定InstanceName,形如”service-1020074503”,用以标识实例的用途和创建时间,系统中多处使用InstanceName来判断实例的用途。结果时常出现创建两台甚至三台同名实例的情况,深入分析调查发现是因为阿里云创建接口超时SDK内部重试造成了连续发起了多个创建请求。
我们将系统改造为不依赖InstanceName,创建实例时不指定Instancename,由阿里云生成随机Instancename。
5. 阿里云接口限流
由于不知道阿里云创建接口的限流规则,我们曾导入过超过150种类型到尝试列表中,期望通过尝试更多机型以提高抢占式实例的创建成功率。实际工作时由于并发执行在一分钟调用了400多次创建接口,之后所有创建接口都返回错误。工单咨询得知是调用太频繁被阿里云限制访问了,阿里云要求每分钟不超过200次。
增值点:
1. 资源灵活伸缩
使用抢占式实例,我们实现了实例级别的伸缩。业务高峰期,集群会自动扩大规模;低谷期会收缩到最小。
2. 降低成本
抢占式实例成本低于包月实例,加上实例都是按需创建,成本进一步被降低。
- CI环境、测试环境
只有发布代码时才创建实例,平时不需要常备实例 - 生产环境:
实例级伸缩加持,业务低谷期只保有最小规模
抢占式实例的资源池每个区域不一样,我们的生产环境部署在杭州区,CI环境在上海区。经过半年的使用发现,上海区的资源相对充足,抢占式创建成功率非常高,创建成功后持有的时间相当长(最长到一个多星期),被同时释放的概率也很低。杭州区资源相对紧张,实例平均持有时间不到一天,被全部释放的概率也较高。即便如此,我们在杭州区创建按量实例的次数也很少。
3. 简化运维工作
对于包月实例有很多问题需要处理:
-
磁盘不足
- 日志目录太大,做log rotate
- docker目录太大,删除docker数据后重启docker
- 如果是某个还没有自动化的目录,就得开发、测试、部署一套新逻辑
- 负载过高
重启实例,问题依旧再深入调查,无果则提交工单申请迁移物理机 - ......
使用抢占式实例后,所有这些问题都不存在了。只要实例出现这些异常状态,运维机器人会将其删除,之后会根据情况自动创建新实例。这就好比长进程和短进程的区别,长进程运行时间长难免出现内存泄漏,短进程执行完毕就退出,下次再执行就是一个新的进程。又好比系统太复杂,运行时间长了总会出现各种奇怪的问题,与其花费时间精力调查不如直接重启来的经济。而删除实例再新建,则是更为彻底的重启,运行时是新的,文件系统是新的,网络配置是新的。
期望阿里云的支持
抢占式实例的总体效果符合甚至超出我们的预期,但仍有几个方面我们觉得阿里云可以做得更好。
1,优化接口响应时间
调用创建接口需要4秒左右,即使失败也需要1秒左右才能返回,在一个规模不算太大的集群里,这个时间还是可以接受的。
让人头痛的是,创建接口偶尔会超时,超时不严重会造成之前提到过的创建多台同名实例的问题。之所以说不严重,是因为SDK在重试的时候最后一次成功返回了,应用层认为这次调用是成功的,后续的流程可以继续走下去。12月1日晚八点半到九点半我们遇到了一次严重的超时,每一次调用的每一次重试都超时,在应用层看来创建接口全部失败,然而实例却被创建了。最终创建了100多台按量实例,达到了我们设置的上限才没有继续创建。这些按量实例因为没有被初始化无法运行业务,还白白占用了配额使运维机器人无法继续创建新的可用实例。
2,精确的询价接口
创建模型中相当一部分工作就是猜测当前什么类型可用。如果阿里云能提供一个精确的询价接口,返回当前每个类型的准确价格,不仅能提高创建效率还能大幅简化创建模型。
3,数据读写一致性
之前提到的因为超时导致创建多台同名实例的问题,在使用随机InstanceName之前,我们还尝试过这种workround方案:创建之后再查询,发现同名实例就重命名多余的实例。
例如,发现名为”service-1020074503”的实例有两台,将其中一台重命名为”service-1020074503-deprecated”,另一台实例继续走初始化流程。在测试过程中,我们发现重命名后立即使用”service-1020074503”去查询仍然有可能查询到”service-1020074503-deprecated”这台实例的信息,即短时间内系统中仍然存在两台重名实例。
另外我们在初始化实例时会等待15秒,其中一个原因是返回的InstanceId短时间内有可能查询不到。
我们猜测这些问题都是因为写操作同步到阿里云内部的所有数据节点需要一定时间,在这之前读到的可能是修改前的数据。
如果阿里云的API系统能实现对于同一个客户端的读写一致性的话,用户使用起来会方便不少。