你的微服务敢独立交付么?
最近经常在项目或是社区里听到大家谈论微服务架构,但谈论的焦点更多集中在微服务拆分,分布式架构,微服务门槛,DevOps配套设施等话题上。
但是在我眼里,真正能称之为微服务架构的少之又少。原因也很简单,我所见到的很多所谓的微服务架构项目,大多都没有做到微服务架构的一个基本要求:服务的独立部署(交付)。
这里的独立部署和自动化部署还不是一个概念,服务的自动化部署相对简单,已有大量的工具可以帮助我们做到。但是这里所谈的独立部署,我认为关键和难点并不在于“部署”,而在于“独立”。
如果失去了服务独立部署(交付)的能力,一个微服务架构的威力将大打折扣,我们的系统虽然在物理上被拆分成了多个小的服务,但是如果从最终交付的角度来看,仍然是以一个整体存在的,就像单体应用一样,存在诸多的问题。
为什么服务的独立交付并不简单?
那为什么不能让每一个服务都独立部署到产品环境呢?问题的答案是:不是不能,而是不敢!
为了表达清楚,让我们来看个例子吧。
像下图一样,我现在就是那个程序员帅哥(本色出演),突然有一天心血来潮,动手开发了一个网上商城。代码Push到Github并通过CI构建持续交付流水线,最终自动化部署到云端产品环境,供用户访问使用。
随着用户和访问量的增加,需求和功能也越来越多,系统也变得越发复杂。
从网上了解到最近有个叫微服务的架构非常火爆,我也赶了回时髦,当然也觉得这种架构确实可以帮助我解决现在的一些问题。
经过对系统的分析,我将商城的后台部分拆分出了3个服务,为了简单我们就称之为ABC三个服务。
我们假设一个比较极端的情况,三个服务相互调用(先不考虑这样是否合理),每个服务通过自己的持续交付流水线独立部署到产品环境。当前产品环境的各个服务的版本是:
A:1.0、B:2.0、C:3.0
一切都非常完美是不是?看!我们已经做到了服务的独立部署!So easy~
当然,事情肯定不会那么简单。
问题出现在当我对A服务做了一次新的提交之后,A服务的最新版本升级到了1.1。不幸的是,这个新的版本意外的破坏了A与B之间的契约,错误的调用了B的接口,导致出现了错误。
虽然我的A服务和B服务都有比较完备的UT(单元测试),但因为UT无法发现服务之间的集成是否被破坏,所以只有UT作为质量保障的A服务持续交付流水线也自然没有能力发现AB服务集成被破坏的这个问题。最终导致存在问题的A1.1版本被部署到了产品环境,产品环境出现了严重的Bug。
请问在座的同学,碰到这样的情况,你会如何处理?
“加集成测试啊!”
这位同学说的极是,我这么聪明自然也想到了这一点,不就是要测集成吗?UT干不了就加集成测试不就成了。
为了统一语言,毕竟对于各种测试的叫法太容易引起混淆,参考Martin Fowler在《微服务测试策略》中的定义,我们在本文中将这种测试多服务集成的测试统一称作端到端测试(End-to-End tests,简称E2E测试)。
添加了E2E测试之后,我的交付流水线就变成了下面这个样子。
因为有了E2E测试的存在,问题迎刃而解,当A服务的新版本破坏了与B服务的集成时,E2E测试就会及时诊断出来,并阻止A服务的最新版本向产品环境流动,保证产品环境不被破坏。
这样看似没有什么问题,通过添加E2E测试,解决了服务间集成的验证问题,但在不知不觉中,我们也失去了微服务架构的那个重要的特性:“服务的独立交付”。
怎么讲?别急,我们再往下看。
假设A服务的修复过程中,B和C服务也提交了新的代码,我们假设这两个提交是没有问题的,但因为A服务的1.1版本导致E2E测试挂掉的问题还没有被修复,所以B和C的新版本也被E2E测试拦了下来,此时的E2E测试就像是一个亮起红灯的路口,阻塞了所有服务通往产品环境的通道。
所以说,随着集中E2E测试的添加,质量被保障的同时,我们的“微服务架构”也已悄然失去了服务独立交付的能力,杀敌一千自损八百,损失惨重!
这并不是我假想的场景,在我自己经历的几个真实项目中,这个问题都在一直困扰着我们。带来了各种各样的衍生问题,例如E2E测试长时间失败,无人修复,修复难度大,服务交付堵塞,为了保持交付通路畅通还不得不引入同样存在很大副作用的CodeFrezze机制和提交Token机制等。
可以看到,虽然我们能够在代码库,在部署结构上,甚至在组织上进行服务化拆分,但就因为这最后一个交付的十里路口,最后这一个红绿灯,让所有的服务又纠缠在了一起,所有的服务化拆分形同虚设,最终我们得到的也只是一个看起来像微服务架构的单体应用而已。
拆除红绿灯,各行其道,收复失地!
那,如何才能将这个“红绿灯”拆除,让服务可以在有质量保障的前提下还可以做到独立交付呢?这就是本文要解决的问题,让我们继续往下看。
我的解决方法其实也很简单:Inline E2E tests。
即并不添加新的集中的Pipeline做E2E测试,而是为每一个服务的Pipeline都添加一个相同的E2E测试的Stage,就相当于将E2E测试Inline到每个服务各自的部署流水线中,如下图所示。
其实Inline E2E测试还不是最关键的,最关键的变化点就是假设A服务有了新的提交,运行到A服务自己Pipeline的E2E测试的时候,此时的E2E测试并不是像之前一样获取B和C服务的最新代码库版本做集成验证,而获取当前产品环境上的B和C服务的已部署当前版本做集成验证。
例如,如图所示A服务的版本从1.0升级到了1.1,当前产品环境的B和C的版本是2.0和3.0。在执行A服务Pipeline上的E2E测试时,验证出A1.1和B2.0集成存在问题,测试变红,Pipeline挂掉,从而阻断了A服务的1.1版本部署到产品环境,保证了产品环境不会被A的1.1版本破坏。
同样,假设A还没有被修复之前,B也有了新的提交,产生了一个新的版本B2.1,这时在B服务Pipeline上的E2E测试并不获取当前A服务的代码库最新版本1.1做集成测试,而是获取产品环境上的当前版本A1.0版本做集成测试。我们假设B2.1和A1.0之间的集成没有问题,测试通过,所以B的2.1版本就被成功的交付到了产品环境,而此时产品环境的A服务的版本仍是1.0。
看!服务之间的阻塞被神奇的解决了,服务再也不会被堵在一个统一的十字路口,而是各行其道,A的车道出了事故,是A的问题,应该由A来承担后果和解决问题,不应该影响到其他服务,其他服务依然可以持续的交付到产品环境。
向前看是持续集成,向后看是持续交付!
看到这里可能有些小伙伴会感到有些失望。咋呼半天,不就是将E2E测试整到每个服务的Pipeline里,再把获取版本从最新代码改成产品环境么?有啥厉害的。
但是,在我看来,这个看似简单的变化,意义却是重大的:它揭示了“持续集成”和“持续交付”的一个主要区别。
“持续集成”和”持续交付”,这两个概念相信大家一定都不陌生,在软件领域也被提了不少年头了,不算什么新概念新技术。但对于这两个概念,我们经常一起提及,也经常混淆,搞不清楚两者的区别到底是什么,可能认为持续交付只不过是持续集成的演进版,新瓶装旧酒而已。
但其实它们却有着本质的区别。
“持续集成”关注的是各个集成单元之前最新版本的集成问题,即是不是某个集成单元的最新版本破坏了系统整体的集成,我管这种视角叫:向“前”看。
而“持续交付”关注的应该不是集成单元最新版本之间的集成问题,而是某个集成单元的最新版本是否可以(能和敢)部署到产品环境。换句话说就是维持产品环境的其他服务不变,只将当前集成单元的最新版本部署到产品环境,产品是否依然可用,不被破坏。所以在“持续交付”的视角下,应该关注的是当前集成单元与产品环境上的其他服务的版本是否兼容,我管这种视角叫:向“后”看。
向前看是持续集成,向后看才是持续交付,如果前后都不看那就是在裸奔。
但是肯定早有同学在心里疑惑,将E2E测试下放到每一个服务自己的Pipeline中,靠谱么?是不是太重了?根据测试金字塔,E2E测试应该是属于靠近金字塔顶端的测试种类,无论从数量和覆盖范围应该也都不会太多,怎么能靠它来保障服务之间的所有集成点和契约呢?
主角登场-契约测试
细心的同学肯定已经发现上面最后一张图中,我已经悄悄的把E2E测试变为了CT,即Contract Test,契约测试。
契约测试也是这两年伴随微服务架构的兴起,经常被提及的一种比较新的测试类型。在测试金字塔中,他的位置介于E2E和Component Tests(可以理解成单个服务的API测试)之间。
简单的理解,契约测试就是一种可以用类似于单元测试的技术验证两两服务之间集成的测试技术。它相比于更低层次的单元测试的优势是可以测集成(两两服务之间),相比于更高层次的E2E测试的优势是实现方式上又类似于单元测试,更轻量,跑的更快,覆盖的范围也自然可以更广更细。
使用契约测试替换掉E2E测试之后,整个架构也会变得更复杂一些,目前契约测试的框架也有很多,如大家常常提到的Pact或是SpringContracts等等。这里我先以Pact为例予以说明,其他框架实现上可能有些差别,但是思路是一致的。
A服务调用B服务的一个API,我们就称为A和B之间存在了一个契约,即B应该按照这个契约提供一个满足契约要求的API,而A也应该按照这个契约约定的方式来调用B的这个API。在这个过程中A作为调用方,我们称之为Consumer端。B作为被调用方,我们称之为Provider端。
如果A和B都履行契约,按照契约定义的约定调用和被调用,我们就可以认为集成不会有问题。但无论是B擅自修改了API破坏了契约,还是A擅自修改了调用API的方式破坏了契约,都会导致契约被破坏,反应到测试上就是契约测试会失败,反应到产品上就是功能被破坏,出现Bug。
每个契约,例如A->B,都会有Consumer端和Provider端生成的两个产出物:分别是a-b.consumer.json.1.1(由Consumer端生成的契约文件,所以版本也是Consumer端A的版本号)和a-b.provider.jar.2.0(由Provider端生成的契约验证测试包,他由Provider端生成,所以版本是B的版本)。这个jar包其实就是一组测试,他的输入是a-b.consumer.json,产出则是测试的结果,也就是契约的验证结果:成功或是失败。
可以把A服务产出的契约文件a-b.consumer.json.1.1想象成一把钥匙,把B服务产出的Provider端的测试a-b.provider.jar.2.0想象成一把锁。那契约测试的执行过程就像是用这把钥匙试着去打开这把锁:如果可以打开,我们认为这A1.1->B2.0的契约是满足的,反之契约就是被破坏了。
值得注意的一点就是,契约测试不像E2E测试,它是有方向的,所以我们看到a-b和b-a是两个不同的契约。
所以,只有当A1.1->B2.0和B2.0->A1.1双向的契约都被验证通过后,我们才能认为A1.1版本和B2.0版本的集成是没有问题的。
用契约测试替换E2E测试
回到前面的例子上,假设我们已经构建了ABC三个服务两两之间的契约测试。此时,A服务有了新的提交升级到了1.1版本,那我们如何才能通过契约测试来验证A1.1版本能否交付到产品环境呢?
答案就是只要通过A的1.1版本的最新代码,生成所有A作为Consumer端的契约文件(a-b.consumer.json.1.1和a-c.consumer.json.1.1),用这两把“钥匙”去试着开(作为输入执行Provider端测试)产品环境对应的两把“锁”(a-b.provider.jar.2.0和a-c.provider.jar.3.0)。
如果都可以打开(测试通过)的话,就证明A的新版本1.1作为Consumer端与产品环境的B和C服务是兼容的。
等等,别着急,还没完……
因为我们还需要考虑A作为Provider的情况,做法还是通过A的1.1版本的最新代码生成A版本作为Provider端的契约测试(b-a.provider.jar.1.1和c-a.provider.jar.1.1),拿着这两把“新锁”,然后试着用产品环境上的两把“钥匙”(b-a.consumer.json.2.0和c-a.consumer.json3.0)去开。
如果也都可以打开(测试通过)的话,就证明A的新版本1.1作为Provider端与产品环境的B和C服务也是兼容的。