微服务全链路灰度发布

前言

现大数据公有云服务已经上生产,为保证后续公有云的稳定性,鲁棒性,安全性,兼容性等,需提供大数据公有云微服务的全链路灰度发布思路,注意是微服务全链路灰度!!!全链路灰度发布在业内已经是非常成熟的技术了,暂时没有什么技术难点,主要是开发规划落实和推动,这里记录下思考过程。

为何要灰度发布?

业务线公有云产品对外的目的就是为了赚钱,没赚钱那公有云产品没任何意义,所以影响赚钱的因素我们都应该考虑处理掉。业内有一个指标叫SLA(Service-Level Agreement):服务等级协议是服务提供者对客户一个服务承诺,评估一个产品是否可用的方法。一般有四个SLA指标,可用性、准确性、系统容量和延迟。SLA指标降低了,那产品不可用了就影响算钱了,产品总是不可用了客户就要换产品了也影响赚钱。 因此保证系统的SLA指标是重中之重。而可灰度和可监控是保证SLA的重要因素。因此我们要做业务线产品公有云微服务线的灰度发布,且必须得做。

有那些版本发布方法

企业微信截图_17320096543284.png

一些说明
业内常见的版本发布方法有全量发布,滚动发布,蓝绿发布,灰度发布(金丝雀发布)。大数据公有云产品主要使用灰度发布方案实现大数据公有云服务的生产微感发布。同时简单介绍下其他的发布思路。

全量发布

企业微信截图_17320096905104.png

一些说明
全量发布主要适用于简单小型的内部服务,直接将所有的微服务和老版本升级到最新。属有损发布。
优点:成本低,运维简单粗暴。适用于小型内部业务服务。缺点:版本切换用户感知大,升级过程中服务无法访问,无法支持类似于公有云的对外客户服务。

滚动发布

企业微信截图_17320097333181.png

一些说明
滚动发布每次只升级一个或多个服务,如果业务观察无问题,再重复这个过程,直到全部服务升级到新版本。属有损发布。不过相对于全量发布,用户感知变小,业务方可以手动保证服务最低限度对外服务。同样只适用于内部的业务服务。
优点:运维成本较低,只有在新老版本切换的时间点才会客户有感。缺点:版本回滚麻烦。版本切换用户感知较大,需要人工去停止业务的流量,且难判断停止的老版本节点是否还有流量。发布和回滚时间长,同样有很大的升级风险。

蓝绿发布

企业微信截图_17320097777558.png

一些说明
蓝绿部署老集群绿版本不停机,部署一套完整的蓝集群新版本,用户可以将所有流量全部迁移到蓝集群新版本中,也可以通过流量染色实现流量在蓝绿集群中切换。蓝绿版本之间物理或者逻辑隔离,没有子请求交互。属无损发布。一般公有云单组件有状态服务产品,比如国内某些厂商的存储服务就使用蓝绿部署的升级,不仅可以做到版本升级,还可以做到集群迁移,扩缩容等功能。
优点:版本切换用户基本无感,蓝绿版本可以做到流量快速微感切换回滚。运维版本发布方便。缺点:成本较高,需要部署一套完整的集群,如果是涉及到底层的有状态存储服务,还需要做到数据的迁移和同步对齐。同时对于小版本和bugfix不友好,无法支持DevOps快速的迭代开发。

灰度发布(金丝雀发布)

企业微信截图_17320098323502.png

一些说明
灰度发布也交金丝雀发布,是一种是一种渐进式的软件发布方式,它允许将新功能或更新逐步给一部分用户试用,而不是一次性全部替换。属无损发布。灰度发布也可以分为单链路发布和全链路发布,单链路发布理解为特殊的蓝绿部署,流量可以按比例分割,如老集群80%,灰度集群20%。当灰度集群稳定了后,逐步将老集群的所有流量迁移到新集群中。单链路方案有一些缺陷,比如bugfix和小版本快速迭代,只需要更新一个或者多个服务,而不是全部。因此便有了全链路发布,全链路发布的思路可以允许运维开发人员只发布部分应用。适合复杂大型对外微服务。
优点:全链路发布对客户基本无感,运维版本发布非常方便,且保存了稳定的老版本回滚快速,风险低。同时支持线上灰度测试,生产治理可控。缺点:成本较高,包括开发和资源成本,开发和运维流程需要严格约束。系统架构稍微复杂了,需更精细管理和维护,提高了管理和监控成本。

小结

上文简单介绍了全量发布,滚动发布,蓝绿部署,灰度发布4种发布思路。每一种发布思路都有适合的场景,对于当前公有云的微服务体系而言,全链路灰度发布是最好的选择。可以做到客户基本无感,回滚方便,低风险,运维方便。下面着重介绍下全链路发布方案的思路。

灰度发布有什么好处?

企业微信截图_1732009913139.png

一些说明
灰度发布的好处有:用户无感,提高系统稳定性,大大大减少了运维成本,发版不影响服务赚钱,白天也可以发布啦,用户和业务灰度测试,无缝回滚,提高公有云产品SLA, 快速体验新产品idea,新版本影响面风险可控,迭代反馈循环,新版本影响面风险可控。下面重点说明下以下几项
1,用户无感:因为灰度发布会同时存在一个或多个版本,版本在快速切换的时候,不影响产品的正常使用。
2,大大大减少了运维成本:传统的发布方法需要再凌晨做全量的配置文件,数据库,微服务等发布操作,工作复杂且高风险,灰度发布存在网关流量控制,版本发布不用一股脑全部替换,可以先一个一个完成配置,数据库,灰度服务发布后,然后使用apisix快速切换无感切换流量即可。
3,提高系统稳定性:灰度发布因为不会全量替换老版本,可以一个一个服务灰度切换,因此系统不会由于大量操作而导致奔溃而影响这个系统的稳定。
4,提高公有云产品SLA:因为灰度发布不会影响系统对外服务,保证了全年大数据公有云产品对外的服务时间。
5,白天也可以发布啦:传统的发布方法因为会影响客户使用,由于发布工作量大,风险发,因此一般在晚上发布。而由于灰度发布的流量可控性,白天也可以发布服务,不会影响线上用户的使用。
6,用户和业务灰度测试:灰度发布可以控制部分流量到灰度版本,因此可以使部分客户作尝鲜新的功能的同时也可以作用户测试,测试人员也可以使用指定的账号做线上灰度测试。线下测试版本,架构,配置,数据,依赖,负载等维度不同难以全面覆盖线上场景,灰度测试能弥补这种情况,使产品更加的完善完整正确。
7,无缝回滚:灰度发布会存在多个版本,如果在用户和业务灰度测试过程中发现bug,可以在网关层快速切换流量。
所以让我们快速开始实现灰度发布吧~

灰度发布的一些方法

由于我们需要全链路灰度发布,因此基本上可以套模型,模型为:注册中心+API服务网关+微服务RPC负载均衡+微服务框架流量透传。只有满足这个模型才可以做到全链路灰度发布。

企业微信截图_17320099981957.png

一些说明


企业微信截图_1732010907913.png

灰度发布实现

由上文已确定使用APISIX+NACOS+SpringCloud+RpcLoadblance+流量透传方式实现全链路灰度发布,下面记录下详细的实现细节。

全链路灰度的必要因素

企业微信截图_1732010078527.png

一些说明
如果要实现全链路灰度发布,那么在技术层面需要支持如下几点
1,API对外网关和RPC组件能负载均衡以及路由:如果API网关和RPC组件无法做到负载均衡和路由,那么无法将请求流量分发给灰度节点。
2,流量的标记信息和染色信息能全链路透传:因为要做到全链路灰度,如果无法将流量的标记信息和染色信息透传到整个链路,那将无法做到整个链路的灰度。
3,微服务节点能标记分组和区分版本:如果微服务节点无法标记,那么rpc路由将无法识别下游的节点是否为灰度节点,也就无法做到灰度发布。
4,请求流量能染色:流量如果无法做到染色,那当前节点将无法获得下游的灰度节点。
5,请求流量有版本和灰度标记信息:请求流量又灰度标记和版本信息才能作为灰度判断的参数。

全链路灰度技术细节

企业微信截图_17320101286349.png

RpcLoadblance和流量透传在业内有多种思路,主要有SDK,Javaagent(Sermant),以及自己编码(推荐)。我们这里为了避免又引入其他组件,直接修改ddaf框架新增 MyFeignRequestInterceptor(流量透传) 和 GrayNacosLoadBalancer(灰度路由和负载) 工具。

流量透传实现

public class MyFeignRequestInterceptor implements RequestInterceptor {
 
    private static final Logger LOGGER = LoggerFactory.getLogger(MyFeignRequestInterceptor.class);
 
    @Override
    public void apply(RequestTemplate requestTemplate) {
        Map<String, String> headers = getHeaders();
        headers.forEach(requestTemplate::header);
    }
 
    private Map<String, String> getHeaders() {
        HttpServletRequest request =
                ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        Map<String, String> map = new LinkedHashMap<>();
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String key = headerNames.nextElement();
            String value = request.getHeader(key);
            map.put(key, value);
        }
        LOGGER.info("request Header:{}",map);
        return map;
    }
}

RPC LoadBalancer实现

public class GrayNacosLoadBalancer implements ReactorServiceInstanceLoadBalancer {
 
    private static final Logger LOGGER = LoggerFactory.getLogger(GrayNacosLoadBalancer.class);
 
    /**
     * 当前服务的名称
     */
    private final String serviceId;
 
    /**
     * 负载均衡上游服务实例
     */
    private ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
 
    /**
     * nacos配置信息
     */
    private final NacosDiscoveryProperties nacosDiscoveryProperties;
 
 
    public GrayNacosLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider,
                                 String serviceId, NacosDiscoveryProperties nacosDiscoveryProperties) {
        this.serviceId = serviceId;
        this.serviceInstanceListSupplierProvider = serviceInstanceListSupplierProvider;
        this.nacosDiscoveryProperties = nacosDiscoveryProperties;
    }
 
    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        ServiceInstanceListSupplier supplier =
                serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new);
        return supplier.get().next().mapNotNull(serviceInstances -> getInstanceResponse(serviceInstances, request));
    }
 
    private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> serviceInstances, Request request) {
        if (serviceInstances.isEmpty()) {
            LOGGER.warn("No servers available for service: " + this.serviceId);
            return new EmptyResponse();
        }
 
        try {
            String clusterName = this.nacosDiscoveryProperties.getClusterName();
            List<ServiceInstance> instancesToChoose = serviceInstances;
            if (StringUtils.isNotBlank(clusterName)) {
                List<ServiceInstance> sameClusterInstances = serviceInstances.stream().filter(serviceInstance -> {
                    String cluster = serviceInstance.getMetadata().get("nacos.cluster");
                    return StringUtils.equals(cluster, clusterName);
                }).collect(Collectors.toList());
 
                //--是否为灰度请求
                if (isGrayRequest(request)) {
                    //--获得灰度节点
                    sameClusterInstances =
                            sameClusterInstances.stream().filter(s -> s.getMetadata().get(SysConstants.KEY_GRAY_TAG) != null
                                    && s.getMetadata().get(SysConstants.KEY_GRAY_TAG).equals(SysConstants.VAL_GRAY_TAG)).collect(Collectors.toList());
                } else {
                    //--排除灰度节点
                    sameClusterInstances = sameClusterInstances.stream().
                            filter(s -> s.getMetadata().get(SysConstants.KEY_GRAY_TAG) == null ||
                                    !s.getMetadata().get(SysConstants.KEY_GRAY_TAG).equals(SysConstants.VAL_GRAY_TAG))
                            .collect(Collectors.toList());
                }
                if (!CollectionUtils.isEmpty(sameClusterInstances)) {
                    instancesToChoose = sameClusterInstances;
                }
            } else {
                LOGGER.warn("A cross-cluster call occurs,name = {}, clusterName = {}, instance = {}", serviceId,
                        clusterName, serviceInstances);
            }
 
            ServiceInstance instance = NacosBalancer.getHostByRandomWeight3(instancesToChoose);
            return new DefaultResponse(instance);
        } catch (Exception e) {
            LOGGER.warn("GrayNacosLoadBalancer error", e);
            return null;
        }
    }
 
    private boolean isGrayRequest(Request request) {
        RequestDataContext dataContext = (RequestDataContext) request.getContext();
        HttpHeaders headers = dataContext.getClientRequest().getHeaders();
        return headers.get(SysConstants.KEY_GRAY_TAG) != null && Objects.requireNonNull(headers.get(SysConstants.KEY_GRAY_TAG)).get(0).equals(SysConstants.VAL_GRAY_TAG);
    }
}

全链路灰度结构

一些说明
0,路由规则:灰度流量如果有灰度节点则走灰度节点,没有灰度节点则走普通节点。而如果是正常流量则只能走普通节点。
1,外部流量通过APISIX网关,如果满足gray=true条件,则认定为灰度流量。
2,APISIX要能提供灰度流量分割能力,将灰度流量负载和路由到第一个微服务中,同时也支持下游所有服务节点的对外服务路由配置。
3,微服务之间的流量透传使用fegin拦截器实现,将所有的header信息透传到下游的所有节点中。
4,如果当前微服务节点接受到了灰度流量,那么在spring cloud loadbalancer中要先从nacos注册服务中获得有gray标签的节点,然后将这个灰度请求发往这个灰度节点中。
5,灰度流量到达灰度节点,灰度节点完成灰度版本的业务逻辑,同样按上一步的逻辑获得下游的节点类型。如果是灰度请求则负载发往下游的灰度节点,否则直接发完正常的节点。

Demo演示

0,基础环境准备

交付一套完整的K8S环境,基础服务中需要有Nacos,APISIX,Rancher等基础组件。


企业微信截图_17320103736802.png

1,微服务部署

Demo服务请求链

image.png

流量通过APISIX请求dwuser,dwuser在通过dworder查询订单信息。在k8s上分别给dwuser和dworder部署base和gray版本。

实际操作

a)创建2个服务(dwuser,dworder),并打包镜像。


企业微信截图_17320104529651.png

b)k8s部署微服务


企业微信截图_17320104823103.png

k8s交付信息

Namespace:microservices
# 服务名称,dwuser表示服务名,gray表示灰度版本,v1.0.1表示版本,普通版本名称为dworder-release-v1.0.0
Name:dwuser-gray-v1.0.1
 
# 镜像
Image:xxxx/dwuser:v1.0.0
 
# 环境变量
Environment Variables:
NACOS_USERNAME  NACOS_USERNAME 
NACOS_PASSWORD  NACOS_PASSWORD
LANG en_US.utf8
app.env dw_dev
 
# 注册到Nacos的服务的元数据-服务节点版本
spring.cloud.nacos.discovery.metadata.version v1.0.1
 
# 注册到Nacos的服务的元数据-服务是否为灰度节点
spring.cloud.nacos.discovery.metadata.gray true
 
spring.cloud.nacos.discovery.server-addr xxxx//xxxx:18848
JAVA_OPTS -Xms128M -Xmx128M -XX:AutoBoxCacheMax=20000 -XX:+AlwaysPreTouch -XX:-UseBiasedLocking -XX:+UseConcMarkSweepGC -XX:MaxTenuringThreshold=5 -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCDateStamps

主要注意的配置项为spring.cloud.nacos.discovery.metadata.version和spring.cloud.nacos.discovery.metadata.gray。

c)查看服务是否注册成功


企业微信截图_17320105849885.png

d)配置APISIX服务路由


企业微信截图_17320106159117.png

测试请求

正常请求

# 只有gray=true的才是灰度流量,其他的是正常流程
curl --request GET \
  --url xxxx://xxxx:xxxx/dwuser/v1/index/getUserInfo \
  --header 'Content-Type: application/json' \
  --header 'gray: true' \
  --header 'userid: 100' \
  --header 'version: 1'
企业微信截图_17320106732082.png

可以看到version返回的字段均为base,表示访问base版本的信息。

灰度请求

# 如果gray=false,路由到灰度的节点
curl --request GET \
  --url xxxx://xxxx:xxxx/dwuser/v1/index/getUserInfo \
  --header 'Content-Type: application/json' \
  --header 'gray: false' \
  --header 'userid: 100' \
  --header 'version: 1'
企业微信截图_17320107229864.png

可以看到version返回的字段均为gray,表示访问gray版本的信息。

总结

1,全链路灰度发布使用APISIX+NACOS+SpringCloud+GrayNacosLoadBalancer(RPC负载均衡和路由)+FeignRequestInterceptor(流量透传)思路能在业务线公有云的场景下能落地实现。
2,如果需要做到完整的灰度发布,需要将网关换成APISIX。方便流量分割,染色,流量监控反馈等。
3,灰度发布需要后续大数据产品更精细化的管理和维护。
4,当前灰度发布方案只能针对微服务全链路的场景,如果需要存储池支持,则需要做影子库或影子表,不过如果租户隔离做的OK,问题不大。
5,注意对外第一个网关也需要具备流量切割和负载均衡能力。

一些问题

1,数据库等有状态的服务和组件该如何做到全链路灰度?
答:本文一开始就说了,主要针对的是无状态微服务的全链路灰度发布,如果是数据库比如MySQL这种有状态存储服务,业内主要提供的是影子表或者影子库的思路,也就是创建一个新表,灰度完后再将数据同步到原表或者源库中。而其他的有状态的组件也需要做独立的灰度支持,实现成本较高。

2,灰度的服务和版本建议留多久的时间?
答:不行就是不行,行就是行。线上服务灰度的版本不建议超过1周,在灰度测试一段时间OK的情况下,尽快将灰度表示去掉作为普通节点使用,避免后续多个gray版本混乱了。

3,如果开发人员没有把新版本标识位灰度就直接上线了会怎么样?
答:如果新版本没有加上灰度标识,当前的灰度计算规则将直接把发布的版本作为普通的服务节点,所以说全链路灰度发布技术成熟,主要是开发规划落实和推动。

4,服务节点是否为灰度版本的计算规则改如何确定?
答:建议先不要太复杂,当前使用的是最简单的spring.cloud.nacos.discovery.metadata加一个gray=true的tag方式,后续如果业务需要可以按版本,按多tag,甚至使用表达式引擎来处理。

5,全链路灰度使用场景有那些?
答:a)大版本升级:所有版本或者2/3的服务要升级。建议使用单链路蓝绿方式,使用全新的nacos/k8s新namespace。b)小版本或者bugfix,使用同一个namespace,然后使用流量染色的方式验证版本。c)灰度测试,同样可以对uid做染色,然后也可以使用一个灰度数据库,线上验证灰度流量。d)A/B测试流量分割,可以使用traffic-split方式做流量切割给少部分客户做功能试用,和灰度测试场景差不多。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,163评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,301评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,089评论 0 352
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,093评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,110评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,079评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,005评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,840评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,278评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,497评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,394评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,980评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,628评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,649评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,548评论 2 352

推荐阅读更多精彩内容