货拉拉技术

[降本提效]如何可靠的大规模使用AWS竞价实例

作者简介:陈宗舒。先后从事过通讯软件、分布式系统、云计算等开发,在2016年之后一直从事容器相关工作。目前在货拉拉负责国内容器化相关架构和海外容器架构优化

1、前言

什么? 虚拟机只要1折起?我不允许我的小伙伴还不知道“竞价实例”这种类型的虚拟机!

现在互联网行业已经慢慢从“跑马圈地”粗放的爆发式增长,开始过渡到精细化运营的阶段。所以成本,更加成为了一个被关心的问题

降低云资源的成本,技术上无外乎2种方式: 用更少的资源干相同的事情或用相同的资源干更多的事情。于是竞价实例这种类型的虚拟机资源开始有更多公司开始尝试使用

在AWS,竞价实例就称为Spot

2、Spot竞价实例简介

• 一个AWS的老数据:2016年平均每周的EC2 Spot Instance的资源使用量大于2012一整年全区域所有EC2的使用量

• 携程目前在AWS的海外业务90%在Spot上

• 汇量科技70%使用Spot,每年节约140w$

• AutoDesk使用Spot节约50%

2.1、竞价实例

所谓的竞价实例,就是一种低价的抢占式ECS实例,我们出一个价格,云厂商把没卖出去的闲置资源便宜卖给我们,一但按需实例的需求量上来了,或者别人出价更高,可以回收我们的资源

对于云厂商来说,区域机房的总容量是有上限的,资源保障的优先级为 预留实例 > 按需实例 > 竞价实例

•预留实例:客户先付钱,预定需要多少资源,可以走批发价格,即使没使用那么多量也要付钱

•按需实例:字面意思,你有需要的时候就买

•竞价实例:客户竞价的抢占式实例,可能随时被回收

申请资源的时候,云厂商不保证一定能申请到某种类型的ECS实例,即使你申请到了,也不保证你能使用多久,可能随时被回收,这对任务的调度和整体服务的可用性带来很大的挑战。

竞价实例有四个特点:

1.便宜是真便宜。成本可以是按需的10%-30%

2.不是人人都能用好。

因为竞价实例随时会被回收,所以使用姿势要正确,具体见2.3.2一小节

1.不是你想要啥就有,不是你想用的时候就能用。

竞价实例的某种类型,在需要购买的时候,云厂商不保证你能买到

1.或迟或早,最终一定会被抢走。按照我们的实际体验,即使是不常用的机型,过几周也会发生回收事件

2.2、Spot发展史

Spot实例是AWS的竞价实例的叫法。竞价实例,各家云厂商原来有不同的叫法

Plain Text
AWS:         EC2 Spot Instances
Google Cloud:Preemptible VMs
阿里云:       竞价实例
Azure:       LowPriority VMs
腾讯云:       竞价实例
华为云:       竞价计费型实例

但是慢慢也都接受了Spot的叫法(按需实例AWS叫On-Demand实例)

AWS的Spot实例历史已经超过10年了,中间计费模式也调整过几次,以便让Spot回收更加平滑,现在的计费方式是每隔1小时重新定价一次。

Image

2.3、怎么用好Spot?

2.3.1、Spot和On-Demand对比

上面有说OD(On-Demand)是按需实例,Spot和On-Demand对比如下:

指标

Spot实例

On-Demand实例

优点

便宜

稳定

启动

需要指定类型来购买,容量够可以立刻启动

默认的类型

可用容量

对特定机型,容量/水位不定,可能会申请不到

正常情况能申请到

每小时价格

因供需而异。

保持不变。

中断告警

当实例处于高中断风险时发送警告信号。

无,主动终止或休眠实例。

中断场景

当容量不再可用、价格超过预算的最大费率或对 Spot实例的需求增加时,可由 AWS EC2 中断。

在主动终止或休眠实例之前保持不间断。

2.3.2、使用姿势

首先说明的就是,Spot和k8s/容器是最佳搭档。因为Spot实例会经常回收,如果你手动操作资源调度,任务调度,那就几乎是不可能完成的任务了,这么短基本没法完成任务关闭/转移/部署到新资源上这一系列动作。如果没有正确地处理业务的关闭和退出,则有可能造成数据的丢失。

当然现在也有很多第三方供应商,提供了EC2上任务的调度功能,但是基本上都要接管你的AWS账号,而且传统应用可能也要做改造。

这个时候,k8s的自动调度功能,和Spot就显得非常契合了。

Spot使用的通用最佳姿势

1.混合使用Spot、按需实例和预留实例。

在按需实例中保存关键数据,或者运行数据库等关键服务

2.避免在Spot上运行不能中断的任务,而运行对错误容忍度高和使用灵活的应用

比如大数据,容器化的工作任务,高性能计算HPC,无状态的web服务器,渲染、CI/CD和其他测试和开发工作负载。

3.把需要比较长时间的大型工作任务拆分成大量小的、异步的短时间工作任务

减少被中断的可能性

4. 充分利用Spot的价格浮动特性

在适当的时间购买可被抢占实例,降低计算成本,并在整体成本下降的前提下,提升业务在该时间周期内的吞吐量。比如在晚上或周末这种非高峰时段运行大型Spot集群。

5.支持断点续算的智能调度模式

6.合理使用云厂商提供的工具

AWS的Spot Instance Advisor 可以帮用户确定中断可能性最低的池,提供与按需费率相比可节省的成本信息。在选择实例时,用户可以权衡应用程序对中断的容错能力和自身的成本节省目标。中断率越低,Spot 实例的运行时间可能就越长。

3、Spot机制讲解

Spot的特殊之处,就在于可能随时被回收,所以搞清楚Spot的回收机制和流程,对于用好Spot的是非常必要的,这一节就来介绍下Spot的回收机制、信号和流程

Spot回收流程,后面的具体判断机制是黑盒,咨询过AWS的SA也没有得到具体的答案。只有大致条件

Spot实例中断的可能原因:

•价格 – 目前Spot价格高于我们以前购买的价格

•容量 – 如果没有足够的未使用的EC2实例来满足Spot实例的需求,Amazon EC2会中断Spot实例。 Amazon EC2确定实例中断的顺序。

•约束 – 如果您的请求包含约束,例如启动组或可用区组,那么,当不再满足约束条件时,这些Spot实例将作为一个组终止。

Spot中断时候,会通过信号通知节点,然后托管节点组会自己处理,下面讲讲关键信号和流程

3.1、关键信号

这里主要托管节点组的Capacity Rebalancer讲一下Spot在回收时期的两个主要信号和生命周期

•RBR:Rebalance Recommendation

当Spot 实例处于中断风险较高时发送的信号,一般会提前10分钟发出,如果RBR信号处理打开,则这时候会开始替换风险高的节点,但是RBR信号不保证一定发出

•ITN:Instance Termination Notification

Spot 实例中断信号,一般会提前2分钟发出,收到此信号,会立马驱逐所有Pod,然后2分钟之后关闭Spot实例

3.2、节点回收流程

简单总结整个流程就是:

AWS回收节点 -> 节点组收到RBR信号 -> 新建节点 -> 驱逐将要关闭节点的Pod -> 关闭节点

Image

加上ITN信号的整体流程如下图

•AWS根据自己的模型预测,准备回收某种风险高类型的Spot节点,然后提前10min发出RBR信号

•托管节点组收到RBR信号之后,「1.1」步会先启动1个新的节点

•如果「1.1」成功,然后会执行「1.2」驱逐回收节点上的Pod

•「1.2」驱逐成功之后,会「1.3」回收节点

•如果RBR信号丢失或者上面RBR处理流程中某些步骤失败,确实要回收节点,AWS会提前2min发出ITN信号

•如果托管节点组收到ITN信号,会执行「2.1」驱逐Pod操作

•如果「2.1」在2分钟之内完成,开始「2.2」执行回收节点

•如果「2.1」驱逐动作发生2分钟之后没驱逐Pod完成,就会强制回收节点

Image

4、货拉拉海外 Spot大规模落地实践

先分享一个数据

货拉拉海外的测试环境90%使用SPOT,每月节省ECS成本57.38%;

预发布环境90%使用SPOT,每月节省ECS成本62.6%;

4.1 、挑战

SPOT优势是价格,带来的挑战主要来自两方面

•来自AWS Spot特性:

○资源层面:SPOT本身回收的频率和在新节点扩容的时候,是否有足够的资源分配到新节点

○事件处理层面:AWS在回收资源时候,信号的不确定性质。RBR不一定能发出,但是ITN信号只会提前2分钟,这个时间服务能否完全优雅退出,流量摘干净,这些都是需要注意的问题

•应用韧性的挑战:

○应用是否能优雅关闭

○应用在副本减少的情况下,能否正常对外提供服务

○应用启动时间不能太长

Image

4.3、系统架构

整体架构如下

Image

•针对应用可用性方面:

○在部署层面,创建专门的Spot节点组,然后把符合准入规则的应用通过策略调度到Spot节点组,Spot节点组不允许非Spot应用调度到该节点组里面,防止一些系统应用或者核心应用被频繁回收导致系统异常

○为保证节点基础依赖可用性,自研“node-status Controller”,保证Spot节点上依赖的基础daemonset正常了,才允许该节点被调度

○为了解决在Spot节点重启过程中Pod的堆叠,提高应用高可用性,使用“Rescheduler Controller”在低峰期二次打散应用的Pod

•针对Spot资源特性的挑战:

○在可观测性层面,开发了一个“notify handler”去收集并展示Spot生命周期的各种数据,比如覆盖率,各种机型的RBR信号,服务的Pod打散情况,Pending时间等等,为下一步决策提供数据。比如如果某个AZ的某种机型回收率过高,可能把这种机型从Spot节点组里面去掉

○在集群可用性方面,使用OD 节点组来兜底,如果遇到Spot节点扩容不出来的时候,最后扩容OD节点,来保证应用不因为资源问题长时间处于Pending状态。

下面将对具体规则和落地策略做一一详细阐述

4.3、准入规则

根据Spot的特点和容器应用的一些特性,我们定制了货拉拉 Spot准入的一些准入门槛,来保证服务在迁移到Spot的时候,不会有太大问题

•系统服务比如CoreDNS等、

•有状态服务

•副本数大于1

•能够在2分钟内优雅关闭(指标确定)

•启动时间在2分钟内

•能够http检测自身健康状态

•支持优雅缩容(缩容时不会报5XX错误)

4.4、具体策略

落地方案围绕这两方面:

1. 减少Spot中断概率,以及减少Spot回收对应用的影响;

2. 应用可用韧性设计

根据实际Spot落地需要考虑到问题,来设置对应的策略(左侧是问题,右侧绿色是对应策略)

Image

4.4.1 节点组设计

节点组多个AZ

因为云厂商在每个AZ的热度和使用率不同,如果节点组跨AZ设计,ASG会自动选择资源最丰富的 AZ,这样就降低了Spot回收的几率

配置多个节点组、多个机型

可以选择多种机型,这样就如果当某种类型资源不足,申请不到新节点的时候,会选择其他节点组,不会最后扩不出新节点

而且ASG会自动给我们选择当前水位最低的机型来创建实例,可以降低回收率

自研组件收集Spot回收信息

我们自己开发了一个组件从AWS的信息中心eventBridge去接收RBR和ITN信号,并上传到Prometheus里面,这样我们可以从监控面板感知到Spot的某些AZ和类型回收率,可以调整节点组配置

4.4.2 CA及节点扩容设计

CA配置,使用OD节点组来兜底

创建一个configmap,名字要固定,叫 cluster-autoscaler-priority-expander ,调整了CA扩容的节点组权重,优先扩容Spot节点组,但是当Spot节点组都不能扩容的时候,会扩容OD节点组

YAML
apiVersion: v1
kind: ConfigMap
metadata:
 name: cluster-autoscaler-priority-expander
 namespace: kube-system
data:
 priorities: |-
   20:
   - spot-.*
   10:
   - .*

修改ca的deployment的启动配置,在启动参数增加最后2项,减小了CA扩容节点组轮询的等待时间

SQL
kubectl -n kube-system edit deploy cluster-autoscaler

    spec:
      containers:
      - command:
        - ./cluster-autoscaler
        - --v=4
        - --stderrthreshold=info
        - --cloud-provider=aws
        - --skip-nodes-with-local-storage=false
        - --expander=least-waste
        - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/Cluster-PRD01
        - --balance-similar-node-groups
        - --skip-nodes-with-system-pods=false
        - --expander=priority
        - --max-node-provision-time=5m0s

低优先级的占位应用提前去扩容新节点

正常如果收到RBR信号,会先扩容,但是有可能RBR信号会丢失或者可以手动关闭节点组对RBR信号的处理,如果真正要回收的时候,AWS会直接发ITN信号。收到ITN信号,ASG直接驱逐节点上面的Pod,这个时候可能资源不足于容纳该节点的所有Pod,会使得Pod长时间处于Pending,等待CA扩容

这个时候可以用一个低优先级占位Pause容器来提前扩容,真正的业务容器被驱逐的时候,可以踢到该占位容器

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: overprovisioning
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      run: overprovisioning
  template:
    metadata:
      labels:
        run: overprovisioning
    spec:
      priorityClassName: overprovisioning
      containers:
      - name: reserve-resources
        image: k8s.gcr.io/pause
        resources:
          requests:
            cpu: "200m"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: overprovisioning-autoscaler
  namespace: default
  labels:
    app: overprovisioning-autoscaler
spec:
  selector:
    matchLabels:
      app: overprovisioning-autoscaler
  replicas: 1
  template:
    metadata:
      labels:
        app: overprovisioning-autoscaler
    spec:
      containers:
        - image: k8s.gcr.io/cluster-proportional-autoscaler-amd64:1.1.2
          name: autoscaler
          command:
            - ./cluster-proportional-autoscaler
            - --namespace=default
            - --configmap=overprovisioning-autoscaler
            - --default-params={"linear":{"coresPerReplica":1}}
            - --target=deployment/overprovisioning
            - --logtostderr=true
            - --v=2
      serviceAccountName: cluster-proportional-autoscaler-service-account

特殊时期缩小SPOT节点组最大值

在特殊时节比如黑色星期五这种资源使用高峰期,或者观测到SPOT回收率太高的时候,可以缩小SPOT节点组最大值,让应用调度到OD节点组上去(目前亲和性都是软亲和,即满足最好,不满足也不会直接Pending)。

4.4.3 加强应用韧性设计

调度策略,准备部署到Spot的应用才调度到Spot节点

Spot节点组要设置污点,这样才不会让一些公共的核心应用比如CoreDNS、Istiod调度到Spot节点组上

使用软亲和性,使得如果Spot节点组扩容不出来,或者整个回退时候,应用能够调度到OD节点组上

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${app}
  namespace: ${namespace}
spec:
  template:
    spec:
      tolerations:
      - key: "node-type"
        operator: "Exists"
        effect: "NoSchedule"    
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 1
            preference:
              matchExpressions:
              - key: node-type
                operator: In
                values:
                - spot   

亲和性拓扑配置,打散同一应用的不同Pod到不同节点

节点不稳定,带来的可能就是应用的不稳定,可以根据应用等级使用亲和性或者拓扑配置把Pod打散不同AZ、不同节点、不同类型,这样当部分节点回收时候,不会导致全部Pod在重启中

YAML
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 30
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - ${appid}
              topologyKey: failure-domain.beta.kubernetes.io/zone
          - weight: 20
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - ${appid}
              topologyKey: beta.kubernetes.io/instance-type
          - weight: 10
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - ${appid}
              topologyKey: kubernetes.io/hostname      

PDB配置,保证驱逐时应用不可用Pod个数只有1个

PDB的设置可以让服务配置最多只允许一个应用里面多少Pod被驱逐或重启,这样可以保证一个应用的最大可用副本数。

YAML
apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
 name: appid
spec:
 minAvailable: 4
 selector:
   matchLabels:
     app: appid

Pod启动之前的依赖检查

现有的SOA架构Pod是去连接本节点的Consul Agent去注册发现服务,但是由于新节点启动的时候,Consul Agent和应用本身并没有先后顺序,应用健壮性又比较差,连不上Consul没什么进程不会退出而且不会重试,导致应用实际有问题。

临时方案是使用一个initContainer去检查Consul Agent状态,如果没成功则先不启动应用,最好应用调整自身逻辑;长期方案是自己写一个节点控制器,去检查新节点上的前置项状态,没通过之前把节点设置为not ready。

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${appid}
  namespace: ${namespace}
spec:
  template:
    spec:
      initContainers:
      - name: check-consul
        image: xxxx/busybox:1
        env:
        - name: HOST_IP
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: status.hostIP
        command:
        - /bin/sh
        - -c
        - code=1; while [ $(code) -ne 0 ]; do sleep 1; curl http://$(host_IP):8500/v1/status/leader 2>/dev/null | grep -E '".+"'; code=$?; echo "return code is $(code)"; done;

PreStop探针,保证应用优雅关闭

无论使用SLB还是注册中心,在实际中,当Pod关闭去注销时,SLB或者注册中心的上游会有一点延时,这个时候可能还有流量没摘干净,导致上游抛异常,我们可以用preStop探针sleep一段时间

PowerShell
        lifecycle:
          preStop:
            exec:
              command:
              - sh
              - -c
              - sleep 15

应用预热

一些java服务,启动时间较长,需要做很多初始化动作(并且使用了懒加载),如果这时候有流量进来,会导致报错,我们增加了startup探针,来保证应用能够预热

Bash
        startupProbe:
          failureThreshold: 30
          exec:
            command:
            - sh
            - -c
            - code=`curl -m 10 -o /dev/null -s -w %{http_code} 127.0.0.1:${httpStartupProbePort}/${httpStartupProbeUrl}`;
              if [ $code -ge 200 -a $code -lt 400 ]; then sleep 6; else exit 1; fi
          initialDelaySeconds: 60
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 10

4.4.4 其他

集群内所有应用使用云商或者私有镜像仓库

Dockerhub现在限制了免费用户每天下载镜像的次数只50次,如果Spot节点频繁回收节点,而我们服务中有Dockerhub镜像,会导致我们频繁去Dockerhub拉镜像。所以我们的Pod的Sidecar中不能使用Dockerhub镜像仓库,不然可能导致因为限制下载不了镜像而Pod启动失败。

可以使用云商的镜像仓库或者自己搭建Harbor仓库

4、在新扩容节点的时候,上面依赖服务和应用之间启动顺序

应对策略:

•临时方案是使用一个initContainer去检查Consul Agent状态,如果没成功则先不启动应用,最好应用调整自身逻辑

•长期方案是自己写一个节点控制器,去检查新节点上的前置项状态,没通过之前把节点设置为not ready。

•检查所有公共服务和sidecar,使用云商的镜像仓库或者自己搭建镜像仓库

4.5、回滚策略

当应用迁移到Spot节点或者当AWS某个AZ的Spot大面积出现问题的时候,需要有机制或者功能能够回退到OD节点组

4.5.1、单应用常规回滚

由CICD平台提供一键回滚功能,然后把部署到Spot实例上面的应用迁移到On-Demand实例上

4.5.2、某类节点回收率过高

因为AWS不能动态调整节点组里面的实例类型,所以我们只能一个新建不包含该类节点的节点组,然后把原来的节点组设置为不可调度,然后驱逐之后等待缩容,最后删掉原节点组

4.5.3、SPOT整个回滚

如果遇到整个SPOT资源申请不到,或者有在不停回收新分配等问题。可以对全部SPOT进行回滚,

下面是一个操作流程和15个节点的模拟:(按照15个节点预计耗时33分钟左右)

1.OD节点组扩大,控制台设置节点组最值和SPOT节点组相当 ( 15个节点 2分钟 )

2.原节点组设置不可调度 ( 10秒 )

3.把SPOT节点组最大值调整为当前值 ( 1分钟 )

4.驱逐老的节点组 ( 1个节点 25秒 + 90秒sleep, 总共约28分钟,其中22分钟是sleep时间)

5.SPOT节点组最大值缩小1,并设置为不可调度。

6.新节点组最小值改为1

5、总结

SPOT技术本身是提高了稳定性风险,来换取成本的降低。我们能做的就是在保证稳定性的前提下来降低成本。

容器层面的解决方案是围绕着下面两方面进行:

•降低资源中断概率,提高资源响应速度

•减少中断时间,保证应用高可用

在应用层面也需要配合做一定的调整:

•比如优雅关闭、自身状态检测

•存储和计算分离等