ㅤㅤㅤ
ㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ(生活有时会迫使我们弯曲,但在弯曲的轨迹上,我们也能找到属于自己的旅程。 即便离开了我钟爱的技术领域,我仍然在新的旋律中发现着人生的节奏。- 史蒂夫·乔布斯)
ㅤㅤㅤ
ㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤ
团队灰度设计背景
因为前段时间有些迷茫,再加上入职了新公司,所以三个月的时间没有写博客,以下是当时整个技术团队灰度架构设计的背景,我也尽可能完整详细的进行记录,让大家更了解灰度架构设计的前后因果
设计和开发灰度系统其实已经是21年的事情了,以下是大概的时间线
- 在21年三四月份第一次提出灰度架构,作为研发年度绩效kpi
- 另一个是服务重构,因为部分服务的代码十分陈旧,可以追溯到2012年,旧的代码维护起来十分困难,最新的特性,语言版本,工具包等都不能使用,并且很多技术都已不再维护,很多相关资料和文档也都缺失
- 在21年年中时组织架构发生了变化,来了新的研发总经理。并且年中时系统又接连出了不少线上事故。比如云厂商数据库故障影响了公司业务长达几个小时,运维操作数据库导致故障,发版导致的线上问题,新功能造成的线上问题等
- 于是在新领导的推动下,灰度系统的日程提前了,由他牵头,组织各部门研发经理来定制方案
- 在定制方案之前,有其他部门的研发领导提出要先对部分服务做压力测试
- 我们产品的整体服务主要是使用nodejs12-14,http框架使用nestjs,数据库使用mongodb3.x-4.x,redis,消息队列使用kafka,nsq,服务承载使用k8s容器化架构,日志使用的kibana
- 需要压测的服务部署由运维同学按照研发的要求单独部署了一个k8s命名空间,每一个服务单独一个pod,每一个pod配置是2cpu,4g内存,单进程
- 测试中也发现了不少问题,比如nestjs的依赖循环,REQUEST注解,链路追踪id(cls-rtracer)等造成的内存泄露和性能问题
- 在做了优化之后,就开始使用jmeter进行测试,使用两种测试方案
- 提供最简单的http服务和一个数据库查询,最高qps达到1000+
- 提供普通业务的http服务,内部有简单的数据库增删改查或请求其他服务,qps普遍在200-300
- 以上是大致的流程,关于更多细节,因为时间关系记不太清楚了
- 做完压力测试,用了接近一个月的时间调研和定制整体系统的灰度方案
- 在这期间,提出压力测试的研发领导又提出了使用java进行灰度系统开发的提议,他列出了使用java的优势,虽然io性能和nodejs相当,但生态比nodejs要强很多,比如在大型架构中需要用到的动态路由,负载均衡,降级,熔断等,nodejs虽然在这方面也有解决方案,但没有java成熟。所以最终在大家的商讨下,决定用使用java语言,springboot框架来进行灰度网关开发
- 虽然公司的部分服务也有用java开发的,比如各种导入导出等对性能要求高的服务
- 澄清一下,这里说的性能主要是cpu性能,因为每次导入导出都需要使用新的线程来执行,nodejs虽然有多进程,并且node10版本之后也有工作线程,但相比较java的多线程来说缺失处于劣势,最终才决定灰度网关使用java的springboot框架开发
- 这里还要提一下,于公来说,在灰度网关开发上使用java的springboot的论点确实让人无法反驳,对公司来说是利大于弊的,但于私来说,这也是有利于其他部门的事情,为了争取更多的部门福利,其他部门想在整个技术团队中脱颖而出,一直想成立技术架构组,但都没有得到批准,所以这次压测,他们也是做了很多准备,这也为后来服务重构全部使用java埋下了伏笔,让他们在接下来的时间里有足够的话语权,各种人员福利也更倾向于他的部门
- 其实这也是研发团队日常的勾心斗角之一 😃
- 虽然公司的部分服务也有用java开发的,比如各种导入导出等对性能要求高的服务
- 但因为研发资源主要还在产品需求上,所以每个部门需要抽调1-2个主力来开发灰度系统
- 设计到的部门有,一个前端部门,三个后端部门,一个测试部门,一个运维部门,一共六个人左右加入灰度系统小组中
- 实际开始开发灰度系统的时间是十月份,整体架构开发和对接完毕的时间是在十二月底
- 灰度系统的测试时间是2022年一月份,整体测试用了一个月左右,在二月份完成测试并上线第一版
灰度发布的目的
作为一个SAAS服务提供的产品,除了产品的功能层面以外,客户最关心的是产品的稳定性。 而由于SAAS软件的快速迭代的特性,线上系统在不停的进行升级,要完全防止每次迭代bug引起的故障本身是一件比较难的事情。那我们能做的事情就是降低出现问题的影响:一个是降低线上系统问题影响的客户群体,一个是降低线上系统问题的故障时间。 灰度发布就是达成这个目标的手段之一。
灰度的定义
从效果层面上看到的灰度,就是不同客户可以使用我们不同的软件版本。 这样系统升级的时候,可以从不同的客户群体开始升级,等该用户群体运行一段时间以后(也就是新的系统经过线上的某些客户验证后没有问题,理论上后续出问题的概率就比较小,如果这部分客户出问题,也只影响这部分客户,不影响其它客户),再逐步升级到后续其它的客户。例如升级的客户群分别是 内部客户(3天)→ 小客户(7天) → VIP客户(7天) → VVIP客户(7天)这样一个步骤。
- 这其实是比较完整的方案,但后来实施时因为基于现状,资源,客户量的考虑,小客户在灰度环境三天没问题后,在后面的1-2天内就把剩余客户逐渐也都迁移进去了
灰度的总体设计方案
设计图
为了隐私问题,我没有直接把原公司的设计图拿出来,而是以呼叫中心架构为背景重新设计了一张
设计图概述
该设计图涵盖了21年上线时的所有内容,部分细节虽然被忽略,但整体设计就是如此的,其中还有一些缺陷因为资源等问题就没有完善,不过对于当时的架构来说已经能够满足了。
灰度实现设计细则
灰度环境管理系统
灰度环境的控制有一个灰度环境管理系统,前端使用vuejs,后端使用java开发,它只有一个正式环境,它主要有以下作用
- 管理用户和灰度分组之间的关系
- 管理灰度分组
- 控制灰度分组所属环境
灰度的范围
以下对"非用户使用的系统"称为"inner_system"
核心业务能灰度的全部灰度,包括各业务服务,服务的k8s配置文件,服务的cicd配置文件,mongodb数据库,redis数据库,elasticsearch缓存数据库,kafka/nsq消息队列,文件存储,apollo配置中心,kibana日志系统等,inner_system可以不灰度,比如租户系统,日志系统,内部使用系统等,直接和正式环境通信,或者同时兼容两个环境,但需要保证inner_system不能影响到主业务,否则也需要进行灰度设计
k8s
整个产品的服务集群都是托管在k8s集群中的,涉及到前端部门一个,后端部门三个,运维部门一个,因为采用了微服务架构设计,最终涉及到的业务服务达到40-50个,有些是需要灰度的,有些是不需要灰度的,最终统计后,需要灰度的业务服务仍然接近40个
为了支持灰度环境,需要先评估现有正式环境集群的资源配置,比如多少带宽,内存,硬盘,cpu等,然后因为灰度环境的服务比正式环境要少一些,所以额外申请了正式环境百分之70的机器资源, 假如正式环境占用资源100g,那么灰度环境就需要70g的资源。然后添加gray命名空间用来部署灰度服务,再把需要灰度的服务整理出来,将k8s相关的的helm,chart,deployment,hpa,ingress,service,环境变量等配置文件再添加一份灰度的配置,用于支持灰度服务的k8s配置
CICD
CICD持续部署,目前用的是gitlab的runner,通过本地服务的.gitlab-ci.yml配置规则,提交的分支匹配指定的规则,或提交指定的标签来触发部署流程
ci部署的流程以打标签为例,开发环境的是dev.开头,测试环境的是release.开头,正式环境是v.开头,这里以正式环境为例
- 提交v.*规则git标签,在配置的代码监听配置下,有代码改动的服务开始触发部署流程
- git runner收到信号后,开始执行ci流程
- 根据script配置规则,使用gitlab环境变量的用户名和密码登陆docker
- 执行docker build,根据当前服务的Dockerfile配置打包服务
- dockerfile流程如下
- 阶段一
- 加载node镜像
- 创建工作空间
- 复制本地文件到docker镜像内
- 执行npm run build编译
- 阶段二
- 定义工作目录,将编译后的代码和配置复制到当前目录下
- 定义端口号3000
- 使用pm2启动node服务
- 将打包好的docker镜像推送至私有的harbor镜像存储仓库中
至此ci阶段已经完成,因为cd阶段配置的是manual,手动部署,所以还需要人工点击deploy完成部署
cd阶段的配置流程同样以正式环境为例
- 进入helm文件夹
- 使用helm执行升级流程,使用配置文件中的镜像仓储地址,根据上下文变量,替换对应pod配置文件的镜像id,触发k8s pod的更新,达到服务更新的墓地
整个cicd的流程就是如此,对于灰度来说,我们单独添加了一个.gitlab-ci-gray.yml文件配置灰度服务的cicd规则,和.gitlab-ci.yml不同的是,添加了.gray的标签触发规则,使用了新的命名空间-gray和pod配置文件
apollo
apollo配置中心管理了产品下所有服务的环境配置,包括服务间通信地址,数据库,消息队列,业务常量,环境变量等
对于灰度来说,所有服务都需要将正式环境服务配置复制一份,变量名都不需要更改,在正式环境相同应用的下,创建新的集群配置,比如命名为v-gray,然后直接在启动时根据环境变量读取不同的配置即可
包括一些公共配置也是一样的,都需要复制一份到灰度集群配置中
file
如果是用户上传的文件,对于文件自身来说是不区分环境的,且系统中的文件命名都采用类似uuid的唯一性id,所以不存在环境隔离相关的问题
但对于系统内的文件,一些公共配置文件,对环境隔离有要求的,就需要复制一份出来作为灰度文件处理,后续的同步也需要人工处理
nfs
有些旧的业务采用的是本地文件存储的,后来更新后为了减少业务入侵就采用了k8s的nfs文件挂载系统架构
旧业务本地文件存储命名也都是唯一的id,并且都是用户导入导出留下的临时文件,对环境隔离没有要求,所以nfs就不需要做灰度的处理了
log
日志系统使用的是kibana,内部使用elasticsearch存储和分析日志,因为添加了灰度环境,所以灰度服务的日志也要同步到elastisearch中,日志空间别名直接使用服务的名称进行区分,比如xx-gray就可以,没有其他额外的灰度操作
mongodb
-
方案一
只对数据有灰度要求的表,进行灰度数据结构设计,数据结构设计无法满足的,视情况找最佳解决方案。比如定时器任务表,添加env字段标记所属环境,各自环境的定时器只读取属于自己环境的任务即可,比如数据初始化类操作的,做好存在即更新的操作即,等等。其他情况在后续业务发展中,需要做好数据结构的向前兼容等灰度处理,避免出现因环境不一致导致的异常问题。 -
方案二
对mongodb整个集群或库,或表进行灰度,但最终该方案被弃选
不对mongodb整个表,整个库或者整个集群做灰度的原因
- 成本
- 维护成本
- 额外的部署工作,还需要添加监控,日志等一系列集群所需的组件
- apollo分布式集群配置中心额外的维护工作
- 为了保证用户的数据一致性,每次产品迭代都需要考虑环境数据源动态切换,数据一致性同步相关的问题
- 需要对现有代码进行更改和调试,对旧代码造成较大的入侵
- 在后续mongodb集群,服务代码日常维护,迭代,脚本执行都有额外的工作量
- 产品迭代周期加长,日常bug排查链路加长
- 运营成本
- 额外的数据库成本
- 额外的服务器成本
- 额外的开发,运维成本
- 维护成本
- 价值
- 回归到数据库灰度层面,让我们思考一个问题,为什么需要对数据库做灰度
- 灰度和正式环境的相同的表数据结构不一致,导致线上业务功能异常
- 灰度和正式环境的相同的表数据结构不一致,导致线上数据被入侵,加大了数据回滚和排查难度
- 灰度环境不稳定,比如灰度环境因人为,代码或不可抗力问题造成mongodb集群流量,连接数,内存,cpu等激增,直接影响到线上数据库,从而间接影响到正式环境的用户使用
- 综上所述,主要是数据一致性和环境格隔离的问题
- 数据一致性的另一个解决方案
- 提高技术团队代码质量,使用强类型的编程语言,比如TypeScript或者Java等,做好代码检查和审查工作,降低问题出现频率
- 做好业务和技术文档,比如保留好业务和数据的数据结构文档,产品迭代过程中,做好旧数据的兼容
- 对有环境要求的数据做好"存在即更新"的机制,避免因重复执行造成的数据或业务问题
- 数据不一致即便不做灰度也会有类似的问题,所以对旧业务做好数据兼容至关重要
- 环境隔离的另一个解决方案
- 针对多环境提前做好测试方案,提前发现问题
- 对有灰度要求的业务数据结构添加环境标识,各自环境只处理属于自己环境的业务数据,这样就不会造成数据污染,或者说减少了数据污染的可能性
- 加强代码审查力度,做好服务降级,熔断就能降低因数据库或服务器负载激增造成的影响范围
- 数据一致性的另一个解决方案
- 很明显,另一个方案成本更低,执行起来更容易,后续维护也更简单
- 回归到数据库灰度层面,让我们思考一个问题,为什么需要对数据库做灰度
最终,我们内部经过多次讨论,针对现有的业务和架构情况,对数据库灰度,我们使用了方案一
redis
和mongodb灰度方案类似,也是基于成本,维护和便捷性方面的考虑,最终采用了方案一。
elasticsearch
es索引缓存在目前的架构中不需要灰度,因为存储的数据结构都比较通用,单一,比如呼叫中心系统中的通话记录,客户信息,工单信息等,并且涉及到es缓存管理的业务并不多,所以没有做灰度。即便需要灰度,也可以参考数据库的灰度方案
queue
因为消息队列的异步特性,导致它无法区分事件来源的所属环境,以通话账单事件为例,灰度环境发送了一个账单事件,因为没有做环境隔离,所以这个事件可能被灰度,也可能被正式环境所消费,如果这个事件被灰度或正式环境都兼容,那么一切正常。但可惜的是,消息队列是我们日常业务中最常使用的数据传递组件之一,假如某天灰度环境发布了新功能,账单事件的数据结构没有做向前兼容,如果该事件被灰度环境消费,那么没有问题,但如果被旧的正式环境消费,那么可能会出现数据不一致,业务异常等问题,将直接影响到用户的使用,造成企业损失
综上所述,消息队列需要进行灰度环境隔离,可以使用集群,topic或事件级别的方式进行隔离。
- 方案一
参考数据库的灰度方案,对灰度和正式环境的消费主题做灰度设计。消息队列的灰度分为两种级别,一个是消费事件,但涉及更改的消费事件多到上百种,对原业务代码侵入性太高,并且还涉及到多部门协作,考虑到成本和维护问题,最终采用消费者主题灰度级别,只需要再复制一套消费者主题,添加后缀标识,就能够完成环境隔离
假如有通话账单消费者主题topic_bill_call,设计为灰度就是的topic_bill_call_gray和topic_bill_call,这里直接将旧topic_bill_call作为正式环境的topic,就可以避免对正式环境做更改,降低了影响范围,然后灰度环境的使用topic_bill_call_gray
首先需要将消息队列的topic进行统一管理,用于环境隔离
先将代码中topic的常量,字符串,环境变量都迁移到配置中心apollo集群配置中
在apollo配置中心,消息队列所在的集群配置都需要复制一份后缀为_gray的topic的变量,用于灰度环境获取
再添加服务的启动环境变量,根据不同环境,启动时从apollo获取不同的集群配置并转换为业务识别的topic
然后在实际生产或消费消息时,各服务原代码不需要做更改,只需要在启动时做一些topic变量的转换即可
但还有一个问题,每一个产品下都有自己的研发部门,并且产品之间有些业务场景需要做数据通信,但并不是每一个产品都需要灰度环境等环境隔离架构,如何在其他部门没有灰度环境的情况下做好架构兼容?
假如有以下场景,多部门间通过同一个消息队列topic进行通信,比如通话账单,租户状态等,如何做到其他部门的业务无感知,不受任何影响呢?
那么这个问题其实就是如何让其他部门和以前一样,使用一个topic来执行业务,也就是同时分发和接收灰度和正式环境的消息,下面以两个场景进行举例,并列出解决方案,一个是租户系统A,一个是呼叫系统B,A系统只有一个正式环境,呼叫系统有灰度和正式两个环境
每当有租户在A系统注册成功时,都会向B系统同步该租户的注册状态,进行一系列初始化操作。
在之前,因为双方都只有一个系统,那么是一对一的关系,但现在B有两个环境,变成了一对多,可是对A来说只能对接一个环境。
为了减少B系统对A系统造成的影响,也为了让A系统对接一个更为稳定的环境,所以决定让A系统继续对接原来B系统的正式环境,包括原环境的消息队列,http地址等,这样无论B系统怎么改造,A系统也不会受到影响。如果A系统有更改,比如topic事件,接口数据结构等,B系统也要和之前一样同步升级。最终就是A系统一个topic或服务的事件分别由B系统的两个环境来消费处理。
每当有坐席在B系统完成一通电话时,都需要给A系统发送一个账单事件,用于A系统计算账单。
因为现在B有两个环境,在之前,B系统和A系统是一对一关系,但现在B系统多了一个灰度环境,为了保证灰度环境的账单事件能同步,B系统两个环境账单业务的topic或http地址保持不变,也就是B系统两个环境生产的账单事件由A系统一个topic或服务来消费处理。
虽然方案一能解决环境隔离问题,但对旧代码存在较大的入侵,需要将原代码中的所有topic迁移至apollo配置中心统一管理,并且因为对topic做了别名,所以为保证灰度环境代码能够正常运行,需要在启动时对topic别名进行配置和转换。对这些的更改,涉及到众多的服务,先不说研发,就测试而言,需要从0对整个系统进行测试,链路过长,成本过高
- 方案二
针对方案一的问题,主要是对现有代码的入侵过大,导致额外的研发和测试成本,那么针对该问题,出现了消息队列灰度集群方案
消费队列灰度集群配置和原正式集群相同,因为在灰度环境的主要是中小型用户,资源占用方面为原集群的百分之70甚至更低
和方案一不同的是,灰度集群服务使用新的地址和端口连接消息队列集群,所以需要先在apollo配置中心添加灰度集群配置,里面包含了消息队列,数据库等相关连接配置
配置好apollo后,还需要配置各服务的启动环境变量,在灰度部署的文件中,将消息队列变量替换为灰度的,那么在启动时,就可以读取灰度环境的连接配置了,因为连接的是新的灰度集群,原topic等命名都不用更改,那么业务代码不需要更改,开发和测试的成本就更低了
方案二虽然额外部署了一套消息队列灰度集群,增加了硬件消耗,但极大的减少了开发和测试成本,降低了代码侵入性,保障了系统的稳定性。再加上研发资源吃紧,时间比较紧,所以我们最终选择了方案二,以增加硬件成本的方式来提高整个系统的高可用性
业务服务(呼叫中心系统)
经过统计,需要灰度的服务一共高达40个,百分之70是nodejs服务,百分之30是java和python服务,在做好以上组件的各项灰度配置,并和需要灰度的40个服务的进行配置集成后,更多的是需要对灰度和正式环境进行全量测试,整个系统的0-1测试(但后来因测试测试资源有限,加上时间不够,只对核心功能做了全量测试)
非用户使用系统的灰度
以下对"非用户使用的系统"称为"inner_system"
inner_system虽然不需要灰度,但它们都与现有核心业务服务有交互,比如http或消息队列通信,或者共用了某个数据库表,或者共用了某个路径的文件存储,又或者共用了某个apollo集群配置等,为了保持现有的系统正常运行,也为了不影响核心业务,所以需要将inner_system中各服务和核心业务的交互罗列出来,逐一排查和确认
以下分别用三个系统进行举例
租户系统
- 每当有租户注册时,http/mq通知呼叫系统执行初始化业务
该场景上面已经提到过,因为对于租户系统来说并不关心用户所属的环境,它只负责消息的送达。所以为了保证通信稳定,通信的地址和消息队列topic保持不变,继续只对接呼叫系统的正式环境,后续该功能的更新,也要先从正式环境升级并并同步到灰度系统中
导出系统
- 呼叫系统账单导出是一个由用户触发,在租户系统运行的定时任务,由呼叫中心系统插入到数据库表,再由租户系统筛选执行导出,但呼叫中心系统插入的任务是双环境的,为了保证双环境任务能够被租户系统正常处理,原插入任务的数据结构要保持不变,后续即便有变更,也要和租户系统研发团队进行确认,避免因环境不一致导致业务异常
灰度环境管理系统
- 共用了呼叫系统apollo部分配置和数据库集群,使用了相同的mongodb和redis集群,因为是新系统,所以在业务上能用新表就用新表,尽量不影响原业务表结构。如果必须用原业务表的(比如基于性能等考虑的),对表字段只做新增,不做修改,遵循开放封闭原则
呼叫系统(核心业务)
- 每当有坐席产生账单时,都会发送消息队列事件给租户系统
因为租户系统只有一个,但呼叫系统的业务是双环境,所以是多对一的关系,为了保证两个环境的消息送达,原http地址和topic均保持不变,同一个业务的数据结构双环境必须保持一致,做好向前兼容,尽量遵循开放封闭原则
灰度网关
只有一个正式环境的时候,只有一个客户端,且客户端和服务端通信依赖的是nginx配置文件,里面配置了各个服务的路由转发,包括业务,在线,售后等系统。
但现在有两个环境,两个客户端,所以就还需要一个灰度网关作为请求入口,根据用户不同的环境,渲染不同环境的静态资源,请求不同环境的后端接口。
灰度网关的设计,可以参照上面的灰度架构设计图。
灰度分组
因为系统架构采用的是k8s,可以使用命名空间进行环境隔离和分发,当然也可以直接在业务中对环境进行过滤,自己指定转发路径
用户的账号根据体量分为三个等级,小用户,中型用户,大型用户,并且三类用户分别对应三个分组,但还有一个默认分组,指向正式环境,存放了新开户或没有被分组的用户
产品部会安排人员定期更新用户和分组的关系,比如每周,每个月。
用户和分组的关系是固定的,升级前,先将小用户组切到灰度环境,升级后的三天内观察小用户组的使用情况,如果没有问题,则陆续将中大型用户组迁移到灰度环境,三个组在灰度环境都没问题后,再升级最新代码到正式环境
遗留问题
- 进行中的请求,事件,通知等无法做灰度处理,进行此时业务正在执行,再做灰度处理就过于麻烦了,所以允许此类概率低的错误产生
- 系统架构目前没有服务熔断,服务降级等措施,但目前系统还没有到达这个级别,所以没有做
最后
以上是整个灰度架构设计的全部流程,但还有诸多细节方面没有提到,从设计,开发,联调,测试,上线整个阶段遇到了许许多多的问题,但也都一一迎刃而解了
上面只提到了一些相对比较核心和关键的问题,其背后还有特别多的业务细节问题,但因为和业务绑定程度较高,所以这里就不阐述了,但归根结底都属于"环境隔离"问题,解决的思路都和上述的场景十分类似