无服务器架构下的运维

前言

在介绍运维之前,大家先来快速了解一下无服务器(serverless)的概念。由于笔者的实战经验是在AWS平台上,本文中出现的无服务器均指使用AWS Lambda构建的serverless应用。Serverless的特点是用户无需预配置或管理服务器,只需要部署功能代码,服务会在需要的时候执行代码并自动伸缩,从每天几个请求到每秒数千个请求,轻松地实现FaaS(Function as a Service)。如下图所示:

(图片来自网络)

在传统的应用中,开发团队除了需要编写功能代码,还要监控实时负载,并相应地对应用进行伸缩,还要处理一些因非功能性故障导致的停机(硬盘、内存等)。而无服务器架构则将开发团队从服务器维护的工作中解放出来,继而能更专注在功能代码上(图中的Function)。在实际的项目里,开发者只需将功能代码打包上传到AWS Lambda,再进行少量配置(环境变量,触发条件,内存,超时时间等)即可将应用/服务上线。

以上是无服务器架构的基本概念。接下来,笔者将从日志,指标,监控及报警,灾备这四个维度来介绍无服务器架构下的运维。

日志

默认情况下,应用运行时产生的日志会保存在应用服务器本机,在需要查看日志的时候,需要运维人员远程登录到这台服务器获取日志信息。这种方式操作起来稍显繁琐,而且当应用服务器的数量增多后,由于需要先找出产生错误信息的那台服务器,会严重降低查找日志的效率。

一种解决办法是ELK(ElasticSearch, Logstash, Kibana),这三个开源工具各司其职,Logstash负责日志的推送和转换,ElasticSearch作为数据库与搜索引擎,Kibana作为图形界面。好处是搭建容易,良好的伸缩性,以及免费。但带来的额外成本是,独立出来的日志服务也需要做好全方位的监控(应用状态,硬盘,网络等),避免因为基础服务的问题导致系统全面故障。

AWS无服务器架构中的日志是一个开箱即用的服务,所有日志自动采集到AWS CloudWatch Logs中,只要根据服务名称找到对应的日志组,即可进行查询搜索,不需要任何配置,也没有任何维护成本。

指标

通常情况下,运维工作会包含采集线上应用的运行指标,来反映应用的健康状况,故障率,性能,访问量,访问频率等。这里以一个使用Spring Boot构建的API服务来举例,Spring Boot中的Actuator扮演了采集指标的角色。默认配置下,对于每个API,Actuator会自动采集以下几个指标:

  • uri,例如/api/person/{id}
  • method,例如GET或POST
  • status,例如200或500

当然我们可以通过实现一些接口来扩展/自定义采集指标,这里就不展开了。有了指标数据,还需要对应的报表或仪表盘工具,以便更好地查询和展示,可以选择像Prometheus,Grafana这样的工具。

那么AWS无服务器架构是否提供了类似的指标采集呢?答案是肯定的,AWS CloudWatch Metrics自动采集了Lambda function的以下四个指标:

  • Invocations(实际调用量)
  • Errors
  • Duration(执行时间)
  • Throttles(超过并行限制而被阻止的调用的数量)

Invocations和Errors取一段时间的总数,结合二者可以得出应用的错误率,如下

Duration则通过取平均数来反映一段时间的性能表现,在笔者的项目中Lambda function的耗时主要集中在SQL的查询上,这个数字可以相应地反映技术人员对查询优化的效果。当然,在实际情况中,这些检验都可以在预发布环境下进行,这个例子只是为了方便理解。

在笔者目前的项目中,Throttle并未被使用到,默认的并发限制是1000/秒,而用量最大的Lambda function的调用频率也不过每分钟150次,距离超限差得很远,不过这一数据对于并发高的应用有很重要的意义。

除了开箱即用的几个指标以外,还可以结合CloudWatch metrics的API,在相应的功能代码中埋点,定制化采集指标。例如,对于一个Lambda function,代码里三个子task,默认提供的Duration只能反映总体的运行效率,如果需要统计每个task的消耗,就需要用到AWS CloudWatch metrics API。

监控&报警

监控的意义在于全面了解应用的资源使用率,性能和运行情况,这些数据可以用来帮助团队及时作出调整,保证应用程序顺畅运行。这通常包括CPU使用率,数据传输,磁盘使用等。在突发状况导致系统不可用的时候,团队的响应速度,往往取决于监控和报警的及时性,全面性和准确度。如果能在对历史数据的分析之上对监控系统进行合理的配置,团队甚至能预测不好的事情将要发生,提前做好防范,未雨绸缪。

同上,这里还是以一个Spring Boot应用为例,在上一小节指标数据的采集中提到过Actuator,事实上Actuator除了可以记录上面提到的指标,还可以用来收集监控数据。这里我们只需要设置一个Spring Boot Admin应用,给需要进行监控的应用加上Spring Boot Admin client配置,监控数据就会通过Actuator暴露的API传递给Spring Boot Admin。

报警功能一般则要根据实际情况自行实现。Spring Boot Admin中实现了对Pagerduty,Slack等第三方工具的集成,如果只是需要简单的邮件提醒,实现起来也不复杂,这里就不展开了。

随着云上基础设施的普及,上面提到的监控和报警早已是各个平台的标准配置,根本轮不到开发者去操心如何实现及维护,运营团队可以把更多的精力放在配置优化的工作中去。

AWS默认提供了非常完备的监控数据,也允许自定义监控dashboard,通过把一系列重要的指标添加到创建好的dashboard中,应用的运行状况一目了然。

前面已经提到过,在出现错误,或性能底下时,根据某些关键指标的变动情况发送警告通知非常必要。笔者所在的项目的做法是使用AWS CloudWatch和AWS SNS提供的告警通知功能,只需要先选择指标然后设定触发阈值和检查间隔时间即可,AWS SNS支持HTTP、SMS、Email等多种订阅方式。下图展示了如何设定当某个Lambda在过去5分钟内发生了5次以上错误的时候发送通知。

灾难备份&恢复

在系统镜像,构建工具还有容器技术越来越普及的今天,灾难备份的意义很大程度上是为了有效保护重要数据。通常的做法是设定一些定期任务,将数据传输到远端的灾备中心,从物理上抵御不可抗灾难。如果数据量过大,出现网络传输效率跟不上的情况,可以参考AWS用卡车拉数据的解决办法。

真正需要用到灾难备份的情况在笔者有限的经历中还没有发生过,但是如果不未雨绸缪,真正发生时的后果将难以设想。笔者项目中用到的AWS RDS默认启用了以7天为周期的自动备份,这个配置可以手动调整也可以将配置写入构建基础设施的脚本中去。 如果灾难真的发生,光有数据备份是不够的,还需要能够快速重建应用运行时的基础设施。笔者所在的团队(下文简称团队)分别使用了AWS CloudFormationServerless framework,CloudFormation用来重建数据库、网络等基础设施,Serverless framework用来重建Lambda function,在重建数据库的时候,通过持续集成流水线,以环境变量的方式传入最近一次数据备份快照的Id,15分钟以内即可重建一套产品环境。

总结

笔者所在的团队是10个人左右的配置,采用结对编程的方式,3对pair,包含web端、业务层、数据层。从产品原型确定到第一次上线(MVP)耗时30天,每周至少发布一次新版本,story的平均交付时间(cycle time,从需求确定到上线)为8天。这样的速度也许不能算快,但是如果没有Serverless架构在运维端提供的支持,我们想要在交付速度上有更高的突破会困难得多。

最后来谈一下成本,俗话说抛开商业化谈技术都是耍流氓,大部分人看到一个强大易用的工具都会下意识里觉得开销会很大。实际上并不是这样,我们做了一个粗算,选用双核CPU,8G内存的M4型服务器,开销是$72每月。dev,staging,prod三个环境都用同样的配置就是$216每月,而实际上Lambda每个月的开销包含所有环境在$20左右,需要注意的是Lambda的计费是根据使用量来的,我们的API访问大约在150万每月的量级。可以预见到当访问达到一定数量的时候Lambda的开销会和使用服务器的方案持平甚至更大,但是在量小的时候优势明显。

得益于强大的AWS生态,利用Lambda构建的无服务器应用经过少量甚至无需任何配置,即可以极低的价格获得完整的运维功能和体验。与自己利用开源工具进行搭建的方式相比,研发团队可以从繁琐的运维工作——特别是基础工程搭建——中解脱出来,更加专注于产品本身,极大的提高软件交付速度,可用性、可靠性和可扩展性也相当有保障。换来的代价是更高的迁移成本,某些功能的不可定制化可能成为瓶颈,以及对底层实现原理的屏蔽也可能对开发者的学习和成长有影响。


更多精彩洞见,请关注微信公众号:思特沃克

Share

你的微服务敢独立交付么?

最近经常在项目或是社区里听到大家谈论微服务架构,但谈论的焦点更多集中在微服务拆分,分布式架构,微服务门槛,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服务也是兼容的。

至此,当验证了A的新版本1.1无论是作为调用端还是被调用端都与产品环境上的其他服务契约满足后,我们就认为A1.1与B2.0和C3.0集成是没有问题的,也就代表A1.1可以被放心地部署到产品环境中,替代现在的1.0版本。

这块稍微有些复杂,用文字也很难讲的特别清楚,如果大家对我上边讲的内容感兴趣,但又没有完全理解。请大家移步去看一下我2017年9月份在北京CDConf(持续交付大会)上针对这个主题做的一次分享,讲的应该比写的更清楚一些。在那个分享的最后,也详细介绍了一些我们在这个方案实施过程中碰到的一些问题:例如对于契约变更,并发提交,多环境支持的解决方案,感兴趣的也可以拖到最后看一下。

《契约测试-微服务持续交付的金钥匙》(CDConf 北京 2017)

最后,敲黑板划重点

  • 微服务架构下的独立部署(交付)很重要,但往往容易被忽视,没有被引起足够重视。
  • 为了实现微服务的独立持续交付,我们要向“后”看,不要向“前”看,即关注当前变更服务与部署环境中其他服务的兼容性而不是关注当前变更服务与其他服务最新版本的兼容性。
  • 用契约测试来替代E2E测试,降低测试成本,提高测试覆盖,尽早测试。并通过不断地完善契约管理,保障微服务架构质量和避免微服务架构腐化僵化。

更多精彩洞见,请关注微信公众号:思特沃克

Share

微服务下使用GraphQL构建BFF

微服务架构,这个在几年前还算比较前卫的技术在如今遍地开花。得益于开源社区的支持,我们可以轻松地利用 Spring Cloud 以及 Docker 容器化快速搭建一个微服务架构的原型。不管是成熟的互联网公司、创业公司还是个人开发者,对于微服务架构的接纳程度都相当高,微服务架构的广泛应用也自然促进了技术本身更好的发展以及更多的实践。本文将结合项目实践,剖析在微服务的背景下,如何通过前后端分离的方式开发移动应用。

对于微服务本身,我们可以参考 Martin Fowler 对 Microservice 的阐述。简单说来,微服务是一种架构风格。通过对特定业务领域的分析与建模,将复杂的应用分解成小而专一、耦合度低并且高度自治的一组服务。微服务中的每个服务都是很小的应用,这些应用服务相互独立并且可部署。微服务通过对复杂应用的拆分,达到简化应用的目的,而这些耦合度较低的服务则通过 API 形式进行通信,所以服务之间对外暴露的都是 API,不管是对资源的获取还是修改。

微服务架构的这种理念,和前后端分离的理念不谋而合,前端应用控制自己所有的 UI 层面的逻辑,而数据层面则通过对微服务系统的 API 调用完成。以 JSP (Java Server Pages) 为代表的前后端交互方式也逐渐退出历史舞台。前后端分离的迅速发展也得益于前端 Web 框架 (Angular, React 等) 的不断涌现,单页面应用(Single Page Application)迅速成为了一种前端开发标准范式。加之移动互联网的发展,不管是 Mobile Native 开发方式,还是 React Native / PhoneGap 之流代表的 Hybrid 应用开发方式,前后端分离让 Web 和移动应用成为了客户端。客户端只需要通过 API 进行资源的查询以及修改即可。

BFF 概况及演进

Backend for Frontends(以下简称BFF) 顾名思义,是为前端而存在的后端(服务)中间层。即传统的前后端分离应用中,前端应用直接调用后端服务,后端服务再根据相关的业务逻辑进行数据的增删查改等。那么引用了 BFF 之后,前端应用将直接和 BFF 通信,BFF 再和后端进行 API 通信,所以本质上来说,BFF 更像是一种“中间层”服务。下图看到没有BFF以及加入BFF的前后端项目上的主要区别。

1. 没有BFF 的前后端架构

在传统的前后端设计中,通常是 App 或者 Web 端直接访问后端服务,后台微服务之间相互调用,然后返回最终的结果给前端消费。对于客户端(特别是移动端)来说,过多的 HTTP 请求是很昂贵的,所以开发过程中,为了尽量减少请求的次数,前端一般会倾向于把有关联的数据通过一个 API 获取。在微服务模式下,意味着有时为了迎合客户端的需求,服务器常会做一些与UI有关的逻辑处理

2. 加入了BFF 的前后端架构

加入了BFF的前后端架构中,最大的区别就是前端(Mobile, Web) 不再直接访问后端微服务,而是通过 BFF 层进行访问。并且每种客户端都会有一个BFF服务。从微服务的角度来看,有了 BFF 之后,微服务之间的相互调用更少了。这是因为一些UI的逻辑在 BFF 层进行了处理。

BFF 和 API Gateway

从上文对 BFF 的了解来看,BFF 既然是前后端访问的中间层服务,那么 BFF 和 API Gateway 有什么区别呢?我们首先来看下 API Gateway 常见的实现方式。(API Gateway 的设计方式可能很多,这里只列举如下三种)

1. API Gateway 的第一种实现:一个 API Gateway 对所有客户端提供同一种 API

单个 API Gateway 实例,为多种客户端提供同一种API服务,这种情况下,API Gateway 不对客户端类型做区分。即所有 /api/users的处理都是一致的,API Gateway 不做任何的区分。如下图所示:

2. API Gateway 的第二种实现:一个 API Gateway 对每种客户端提供分别的 API

单个 API Gateway 实例,为多种客户端提供各自不同的API。比如对于 users 列表资源的访问,web 端和 App 端分别通过 /services/mobile/api/users, /services/web/api/users服务。API Gateway 根据不同的 API 判定来自于哪个客户端,然后分别进行处理,返回不同客户端所需的资源。

3. API Gateway 的第三种实现:多个 API Gateway 分别对每种客户端提供分别的 API

在这种实现下,针对每种类型的客户端,都会有一个单独的 API Gateway 响应其 API 请求。所以说 BFF 其实是 API Gateway 的其中一种实现模式。

GraphQL 与 REST

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

GraphQL 作为一种 API 查询语句,于2015年被 Facebook 推出,主要是为了替代传统的 REST 模式,那么对于 GraphQL 和 REST 究竟有哪些异同点呢?我们可以通过下面的例子进行理解。

按照 REST 的设计标准来看,所有的访问都是基于对资源的访问(增删查改)。如果对系统中 users 资源的访问,REST 可能通过下面的方式访问:

Request:

GET http://localhost/api/users

Response:

[
  {
    "id": 1,
    "name": "abc",
    "avatar": "http://cdn.image.com/image_avatar1"
  },
  ...
]
  • 对于同样的请求如果用 GraphQL 来访问,过程如下:

Request:

POST http://localhost/graphql

Body:

query {users { id, name, avatar } }

Response:

{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "abc",
        "avatar": "http://cdn.image.com/image_avatar1"
      },
      ...
    ]
  }
}

关于 GraphQL 更详细的用法,我们可以通过查看文档以及其他文章更加详细的去了解。相比于 REST 风格,GraphQL 具有如下特性:

1. 定义数据模型:按需获取

GraphQL 在服务器实现端,需要定义不同的数据模型。前端的所有访问,最终都是通过 GraphQL 后端定义的数据模型来进行映射和解析。并且这种基于模型的定义,能够做到按需索取。比如对上文 /users 资源的获取,如果客户端只关心 user.id, user.name 信息。那么在客户端调用的时候,query 中只需要传入 users {id \n name}即可。后台定义模型,客户端只需要获取自己关心的数据即可。

2. 数据分层

查询一组users数据,可能需要获取 user.friends, user.friends.addr 等信息,所以针对 users 的本次查询,实际上分别涉及到对 user, frind, addr 三类数据。GraphQL 对分层数据的查询,大大减少了客户端请求次数。因为在 REST 模式下,可能意味着每次获取 user 数据之后,需要再次发送 API 去请求 friends 接口。而 GraphQL 通过数据分层,能够让客户端通过一个 API获取所有需要的数据。这也就是 GraphQL(图查询语句 Graph Query Language)名称的由来。

{
  user(id:1001) { // 第一层
    name,
    friends { // 第二层
      name,
      addr { // 第三层
        country,
        city
      }
    }
  }
}

3. 强类型

const Meeting = new GraphQLObjectType({
  name: 'Meeting',
  fields: () => ({
    meetingId: {type: new GraphQLNonNull(GraphQLString)},
    meetingStatus: {type: new GraphQLNonNull(GraphQLString), defaultValue: ''}
  })
})

GraphQL 的类型系统定义了包括 Int, Float, String, Boolean, ID, Object, List, Non-Null 等数据类型。所以在开发过程中,利用强大的强类型检查,能够大大节省开发的时间,同时也很方便前后端进行调试。

4. 协议而非存储

GraphQL 本身并不直接提供后端存储的能力,它不绑定任何的数据库或者存储引擎。它利用已有的代码和技术进行数据源的管理。比如作为在 BFF 层使用 GraphQL, 这一层的 BFF 并不需要任何的数据库或者存储媒介。GraphQL 只是解析客户端请求,知道客户端的“意图”之后,再通过对微服务API的访问获取到数据,对数据进行一系列的组装或者过滤。

5. 无须版本化

const PhotoType = new GraphQLObjectType({
  name: 'Photo',
  fields: () => ({
    photoId: {type: new GraphQLNonNull(GraphQLID)},
    file: {
      type: new GraphQLNonNull(FileType),
      deprecationReason: 'FileModel should be removed after offline app code merged.',
      resolve: (parent) => {
        return parent.file
      }
    },
    fileId: {type: new GraphQLNonNull(GraphQLID)}
  })
})

GraphQL 服务端能够通过添加 deprecationReason,自动将某个字段标注为弃用状态。并且基于 GraphQL 高度的可扩展性,如果不需要某个数据,那么只需要使用新的字段或者结构即可,老的弃用字段给老的客户端提供服务,所有新的客户端使用新的字段获取相关信息。并且考虑到所有的 graphql 请求,都是按照 POST /graphql 发送请求,所以在 GraphQL 中是无须进行版本化的。

GraphQL 和 REST

对于 GraphQL 和 REST 之间的对比,主要有如下不同:

1. 数据获取:REST 缺乏可扩展性, GraphQL 能够按需获取。GraphQL API 调用时,payload 是可以扩展的;

2. API 调用:REST 针对每种资源的操作都是一个 endpoint, GraphQL 只需要一个 endpoint( /graphql), 只是 post body 不一样;

3. 复杂数据请求:REST 对于嵌套的复杂数据需要多次调用,GraphQL 一次调用, 减少网络开销;

4. 错误码处理:REST 能够精确返回HTTP错误码,GraphQL 统一返回200,对错误信息进行包装;

5. 版本号:REST通过 v1/v2 实现,GraphQL 通过 Schema 扩展实现;

微服务 + GraphQL + BFF 实践

在微服务下基于 GraphQL 构建 BFF,我们在项目中已经开始了相关的实践。在我们项目对应的业务场景下,微服务后台有近 10 个微服务,客户端包括针对不同角色的4个 App 以及一个 Web 端。对于每种类型的 App,都有一个 BFF 与之对应。每种 BFF 只服务于这个 App。BFF 解析到客户端请求之后,会通过 BFF 端的服务发现,去对应的微服务后台通过 CQRS 的方式进行数据查询或修改。

1. BFF 端技术栈

我们使用 GraphQL-express 框架构建项目的 BFF 端,然后通过 Docker 进行部署。BFF 和微服务后台之间,还是通过 registrator 和 Consul 进行服务注册和发现。

  addRoutes () {
    this.express.use('/graphql', this.resolveFromRequestScopeAndHandle('GraphqlHandler'))
    this.serviceNames.forEach(serviceName => {
      this.express.use(`/api/${serviceName}`, this.routers.apiProxy.createRouter(serviceName))
    })
  }

在 BFF 的路由设置中,对于客户端的处理,主要有 /graphql/api/${serviceName}两部分。/graphql 处理的是所有 GraphQL 查询请求,同时我们在 BFF 端增加了 /api/${serviceName} 进行 API 透传,对于一些没有必要进行 GraphQL 封装的请求,可以直接通过透传访问到相关的微服务中。

2. 整体技术架构

整体来看,我们的前后端架构图如下,三个 App 客户端分别使用 GraphQL 的形式请求对应的 BFF。BFF 层再通过 Consul 服务发现和后端通信。

关于系统中的鉴权问题

用户登录后,App 直接访问 KeyCloak 服务获取到 id_token,然后通过 id_token 透传访问 auth-api 服务获取到 access_token, access_token 以 JWT (Json Web Token) 的形式放置到后续 http 请求的头信息中。

在我们这个系统中 BFF 层并不做鉴权服务,所有的鉴权过程全部由各自的微服务模块负责。BFF 只提供中转的功能。BFF 是否需要集成鉴权认证,主要看各系统自己的设计,并不是一个标准的实践。

3. GraphQL + BFF 实践

通过如下几个方面,可以思考基于 GraphQL 的 BFF 的一些更好的特质:

GraphQL 和 BFF 对业务点的关注

从业务上来看,PM App(使用者:物业经理)关注的是property,物业经理管理着一批房屋,所以需要知道所有房屋概况,对于每个房屋需要知道有没有对应的维修申请。所以 PM App BFF 在定义数据结构是,maintemamceRequestsproperty 的子属性。

同样类似的数据,Supplier App(使用者:房屋维修供应商)关注的是 maintenanceRequest(维修工单),所以在 Supplier App 获取的数据里,我们的主体是maintenanceRequest。维修供应商关注的是 workOrder.maintenanceRequest

所以不同的客户端,因为存在着不同的使用场景,所以对于同样的数据却有着不同的关注点。BFF is pary of Application。从这个角度来看,BFF 中定义的数据结构,就是客户端所真正关心的。BFF 就是为客户端而生,是客户端的一部分。需要说明的是,对于“业务的关注”并不是说,BFF会处理所有的业务逻辑,业务逻辑还是应该由微服务关心,BFF 关注的是客户端需要什么。

GraphQL 对版本化的支持

假设 BFF 端已经发布到生产环境,提供了 inspection 相关的 tenantslandlords 的查询。现在需要将图一的结构变更为图二的结构,但是为了不影响老用户的 API 访问,这时候我们的 BFF API 必须进行兼容。如果在 REST 中,可能会增加 api/v2/inspections进行 API 升级。但是在 BFF 中,为了向前兼容,我们可以使用图三的结构。这时候老的 APP 使用黄色区域的数据结构,而新的 APP 则使用蓝色区域定义的结构。

GraphQL Mutation 与 CQRS

mutation {
  area {
    create (input: {
      areaId:"111", 
      name:"test", 
    })
  }
}

如果你详细阅读了 GraphQL 的文档,可以发现 GraphQL 对 querymutation 进行了分离。所有的查询应该使用 query { ...},相应的 mutaition 需要使用 mutation { ... }。虽然看起来像是一个convention,但是 GraphQL 的这种设计和后端 API 的 读写职责分离(Command Query Responsibility Segregation)不谋而合。而实际上我们使用的时候也遵从这个规范。所以的 mutation 都会调用后台的 API,而后端的 API 对于资源的修改也是通过 SpringBoot EventListener 实现的 CQRS 模式。

如何做好测试

在引入了 BFF 的项目,我们的测试仍然使用金字塔原理,只是在客户端和后台之间,需要添加对 BFF 的测试。

  • Client 的 integration-test 关心的是 App 访问 BFF 的连通性,App 中所有访问 BFF 的请求都需要进行测试;
  • BFF 的 integration-test 测试的是 BFF 到微服务 API 的连通性,BFF 中依赖的所有 API 都应该有集成测试的保障;
  • API 的 integration-test 关注的是这个服务对外暴露的所有 API,通常测试所有的 Controller 中的 API;

结语

微服务下基于 GraphQL 构建 BFF 并不是银弹,也并不一定适合所有的项目,比如当你使用 GraphQL 之后,你可能得面临多次查询性能问题等,但这不妨碍它成为一个不错的尝试。你也的确看到 Facebook 早已经使用 GraphQL,而且 Github 也开放了 GraphQL 的API。而 BFF, 其实很多团队也都已经在实践了,在微服务下等特殊场景下,GraphQL + BFF 也许可以给你的项目带来惊喜。

参考资料

【注】部分图片来自网络


更多精彩洞见,请关注微信公众号:思特沃克

Share

Serverless的微服务架构案例

本文首发于GitChat《Serverless 风格微服务的持续交付(上):架构案例》,部分内容已做修改。文章聊天实录请见:“顾宇:构建Serverless 风格微服务实战解析(上)

一次微服务架构的奇遇

2016年12月初,当时我正在以一名 DevOps 咨询师的身份参与某客户的 DevOps 转型项目。这个项目是提升该部门在 AWS (Amazon Web Services)云计算平台上的 DevOps 能力。

自助服务的应用系统基于 Ruby on Rails 框架开发,前端部分采用 AngularJS 1.0,但是没有采用前后端分离的设计,页面代码仍然是通过 ERB 组合而成。移动端则采用 Cordova 开发。为了降低开发难度和工作量, 移动端的应用内容实际上是把 AngularJS 所生成的 Web 页面通过响应式样式的方式嵌入到移动端。但因为经常超时,所以这款 APP 体验并不好。

整套 Rails 应用部署在 AWS 上,并且通过网关和内部业务系统隔离。BOSS 系统采用 SOAP 对外暴露服务,并由另外一个部门负责。因此,云上的应用所做的业务是给用户展现一个使用友好的界面,并通过数据的转化和内部 BOSS 系统进行交互。系统架构如下图所示:

应用的交互流程如下

  1. 浏览器或者移动端通过域名(由 AWS Route 53托管)转向 CDN(采用 AWS Cloudfront)。
  2. CDN 根据请求的内容类别进行区分,静态文件(图片,JS,CSS 样式等),会转向 AWS S3 存储。动态请求会直接发给负载均衡器 (AWS Elastic Load Balancer)。
  3. 负载均衡器会根据各 EC2 计算实例的负载状态将请求转发到不同的实例上的 Ruby On Rails 应用上。每一个应用都是一个典型的 MVC Web 应用。
  4. EC2 上的应用会将一部分数据存储在关系型数据服务(AWS RDS,Relational Database Service)上,一部分存储在本地文件里。经过应用的处理,转换成 SOAP 请求通过 网关发送给 BOSS 系统处理。BOSS 系统处理完成后会返回对应的消息。

根据业务的需要,一部分数据会采用 AWS ElastiCache 的 Redis 服务作为缓存以优化业务响应速度。

团队痛点

这个应用经历了多年的开发,前后已经更换过很多技术人员。但是没有人对这个应用代码库有完整的的认识。因此,我们对整个团队和产品进行了一次痛点总结:

组织结构方面

运维团队成为瓶颈,60 个人左右的开发团队只有 4 名 Ops 支持。运维团队除了日常的事务以外,还要给开发团队提供各种支持。很多资源的使用权限被限制在这个团队里,就导致各种问题的解决进度进一步拖延。

随着业务的增长,需要基础设施代码库提供各种各样的能力。然而 Ops 团队的任何更改都会导致所有的开发团队停下手头的进度去修复更新所带来的各种问题。

应用架构方面

应用架构并没有达到前后端分离的效果,仍然需要同一个工程师编写前后端代码。这样的技术栈对于对于开发人员的要求很高,然而市场上缺乏合适的 RoR 工程师,导致维护成本进一步上升。经过了三个月,仍然很难招聘到合适的工程师。

多个团队在一个代码库上工作,新旧功能之间存在各种依赖点。加上 Ruby 的语言特性,使得代码中存在很多隐含的依赖点和类/方法覆盖,导致了开发进度缓慢。我们一共有 4 个团队在一个代码库上工作,3个团队在开发新的功能。1 个团队需要修复 Bug 和清理技术债,这一切都要同时进行。

技术债方面

代码库中有大量的重复 Cucumber 自动化测试,但是缺乏正确的并行测试策略,导致自动化测试会随机失败,持续集成服务器 (Jenkins)的 slave 节点本地难以创建,导致失败原因更加难以查找。如果走运的话,从提交代码到新的版本发布至少需要 45 分钟。如果不走运的话,两三天都无法完成一次成功的构建,真是依靠人品构建。

基础设施即代码(Infrastructure as Code)建立在一个混合的遗留的 Ruby 代码库上。这个代码库用来封装一些类似于 Packer 和 AWS CLI 这样的命令行工具,包含一些 CloudFormation 的转化能力。由于缺乏长期的规划和编码规范,加之人员变动十分频繁,使得代码库难以维护。

此外,基础设施代码库作为一个 gem 和应用程序代码库耦合在一起,运维团队有唯一的维护权限。因此很多基础设施上的问题开发团队无法解决,也不愿解决。

我参与过很多 Ruby 技术栈遗留系统的维护。在经历了这些 Ruby 项目之后,我发现 Ruby 是一个开发起来很爽但是维护起来很痛苦的技术栈。大部分的维护更改是由于 Ruby 的版本 和 Gem 的版本更新导致的。此外,由于 Ruby 比较灵活,人们都有自己的想法和使用习惯,因此代码库很难维护。

虽然团队已经有比较好的持续交付流程,但是 Ops 能力缺乏和应用架构带来的局限阻碍了整个产品的前进。因此,当务之急是能够通过 DevOps 提升团队的 Ops 能力,缓解 Ops 资源不足,削弱 DevOps 矛盾。

DevOps 组织转型中一般有两种方法:一种方法是提升 Dev 的 Ops 能力,另一种方法是降低 Ops 工作门槛。在时间资源很紧张的情况下,通过技术的改进,降低 Ops 的门槛是短期内收益最大的方法。

微服务触发点:并购带来的业务功能合并

在我加入项目之后,客户收购了另外一家业务相关的企业。因此原有的系统要同时承载两个业务。恰巧有个订单查询的业务需要让当前的团队完成这样一个需求:通过现有的订单查询功能同时查询两个系统的业务订单。

这个需求看起来很简单,只需要在现有系统中增加一个数据源,然后把输入的订单号进行转化就可以。但由于存在上述的痛点,完成这样一个简单的功能的代价是十分高昂的。几乎 70% 的工作量都和功能开发本身没有关系。

在开发的项目上进行 DevOps 转型就像在行进的汽车上换车轮,一不留心就会让所有团队停止工作。因此我建议通过设立并行的新团队来同时完成新功能的开发和 DevOps 转型的试点。

这是一个功能拆分和新功能拆分需求,刚好订单查询是原系统中一个比较独立和成熟的功能。为了避免影响原有各功能开发的进度。我们决定采用微服务架构来完成这个功能。

构建微服务的架构的策略

我们并不想重蹈之前应用架构的覆辙,我们要做到前后端分离。使得比较小的开发团队可以并行开发,只要协商好了接口之间的契约(Contract),未来开发完成之后会很好集成。

这让我想起了 Chris Richardson 提出了三种微服务架构策略,分别是:停止挖坑,前后端分离和提取微服务。

停止挖坑的意思是说:如果发现自己掉坑里,马上停止。

原先的单体应用对我们来说就是一个焦油坑,因此我们要停止在原来的代码库上继续工作。并且为新应用单独创建一个代码库。所以,我们拆分策略模式如下所示:

在我们的架构里,实现新的需求就要变动老的应用。我们的想法是:

  1. 构建出新的业务页面,生成微服务契约。
  2. 根据 API 契约构建出新的微服务。
  3. 部署 Web 前端到 S3 上,采用 S3 的 Static Web Hosting (静态 Web 服务) 发布。
  4. 部署后端微服务上线,并采用临时的域名和 CDN 加载点进行测试。
  5. 通过更新 CDN 把原应用的流量导向新的微服务。
  6. 删除旧的服务代码。

我们原本要在原有的应用上增加一个 API 用来访问以前应用的逻辑。但想想这实际上也是一种挖坑。在评估了业务的复杂性之后。我们发现这个功能如果全新开发只需要 2人2周(一个人月)的时间,这仅仅占我们预估工作量的20%不到。因此我们放弃了对遗留代码动工的念头。最终通过微服务直接访问后台系统,而不需要通过原有的应用。

在我们拆微服务的部分十分简单。对于后端来说说只需要修改 CDN 覆盖原先的访问源(Origin)以及保存在 route.rb 里的原功能访问点,就可以完成微服务的集成。

构建出新的业务页面,生成微服务契约

结合上面的应用痛点和思路,在构建微服务的技术选型时我们确定了以下方向:

  1. 前端框架要具备很好的 Responsive 扩展。
  2. 采用 Swagger 来描述 API 需要具备的行为。
  3. 过消费者驱动进行契约测试驱动微服务后端开发。
  4. 前端代码库和后端代码库分开。
  5. 前端代码框架要对持续交付友好。

因此我们选择了 React 作为前端技术栈并且用 yarn 管理依赖和任务。另外一个原因是我们能够通过 React-native 为未来构建新的应用做好准备。此外,我们引入了 AWS SDK 的 nodejs 版本。用编写一些常见的诸如构建、部署、配置等 AWS 相关的操作。并且通过 swagger 描述后端 API 的行为。这样,后端只需要满足这个 API 规范,就很容易做前后端集成。

部署前端部分到 S3 上

由于 AWS S3 服务自带 Static Web Hosting (静态页面服务) 功能,这就大大减少了我们构建基础环境所花费的时间。如果你还想着用 Nginx 和 Apache 作为静态内容的 Web 服务器,那么你还不够 CloudNative。

虽然AWS S3 服务曾经发生过故障,但 SLA 也比我们自己构建的 EC2 实例处理静态内容要强得多。此外还有以下优点:

  1. 拥有独立的 URL,很容易做很多 301 和 302 的重定向和改写操作。
  2. 和 CDN(CloudFront)集成很好。
  3. 很容易和持续集成工具集成。
  4. 最大的优点:比 EC2 便宜。

根据 API 契约构建出新的微服务

在构建微服务的最初,我们当时有两个选择:

采用 Sinatra (一个用来构建 API 的 Ruby gem) 构建一个微服务 ,这样可以复用原先 Rails 代码库的很多组件。换句话说,只需要 copy 一些代码,放到一个单独的代码库里,就可以完成功能。但也同样会面临之前 Ruby 技术栈带来的种种问题。

采用 Spring Boot 构建一个微服务,Java 作为成熟工程语言目前还是最好的选择,社区和实践都非常成熟。可以复用后台很多用来做 SOAP 处理的 JAR 包。另一方面是解决了 Ruby 技术栈带来的问题。

然而,这两个方案的都有一个共同的问题:需要通过 ruby 语言编写的基础设施工具构建一套运行微服务的基础设施。而这个基础设施的搭建,前前后后估计得需要至少 1个月,这还是在运维团队有人帮助的情况下的乐观估计。

所以,要找到一种降低环境构建和运维团队阻塞的方式避开传统的 EC2 搭建应用的方式。

这,只有 Lambda 可以做到!

基于上面的种种考量,我们选择了 Amazon API Gateway + Lambda 的组合。而 Amazon API Gateway + Lambda 还有额外好处:

  1. 支持用 Swagger 规范配置 API Gateway。也就是说,你只要导入前端的 Swagger 规范,就可以生成 API Gateway。
  2. 可以用数据构建 Mock API,这样就可以很大程度上实现消费者驱动契约开发。
  3. 通过 Amazon API Gateway 的 Stage 功能,我们无需构建 QA 环境,UAT 环境和 Staging 环境。只需要指定不同的 Stage,就可以完成对应的切换。
  4. Lambda 的发布生效时间很短,反馈很快。原先用 CloudFormation 构建的 API 基础设施需要至少 15 分钟,而 Lambda 的生效只需要短短几秒钟。
  5. Lambda 的编写很方便,可以采用在线的方式。虽然在线 IDE 并不很好用,但是真的也写不了几行代码。
  6. Lambda 自动根据请求自扩展,无需考虑负载均衡。

虽然有这么多优点,但不能忽略了关键性的问题:AWS Lambda 不一定适合你的应用场景!

很多对同步和强一致性的业务需求是无法满足的。所以,AWS Lambda 更适合能够异步处理的业务场景。此外,AWS Lambda 对消耗存储空间和 CPU 很多的场景支持不是很好,例如 AI 和 大数据。(PS: AWS 已经有专门的 AI 和大数据服务了,所以不需要和自己过不去)

对于我们的应用场景而言,上文中的 Ruby On Rails 应用中的主要功能(至少60% 以上)实际上只是一个数据转换适配器:把前端输入的数据进行加工,转换成对应的 SOAP 调用。因此,对于这样一个简单的场景而言,Amazon API Gateway + Lambda 完全满足需求!

部署后端微服务

选择了Amazon API Gateway + Lambda 后,后端的微服务部署看起来很简单:

  • 更新 Lambda 函数。
  • 更新 API 规范,并要求 API 绑定对应 Lambda 函数处理请求。

但是,这却不是很容易的一件事。我们将在下一篇文章《Serverless 风格微服务的持续交付》中对这方面踩过的坑详细介绍。

把原应用的请求导向新的微服务

这时候在 CDN 上给新的微服务配置 API Gateway 作为一个新的源(Origin),覆盖原先写在 route.rb 和 nginx.conf 里的 API 访问规则就可以了。CDN 会拦截访问请求,使得请求在 nginx 处理之前就会把对应的请求转发到 API Gateway。

当然,如果你想做灰度发布的话,就不能按上面这种方式搞了。CloudFront 和 ELB 负载均衡 并不具备带权转发功能。因此你需要通过 nginx 配置,按访问权重把 API Gateway 作为一个 upstream 里的一个 Server 就可以。

删除旧的服务代码

不要留着无用的遗留代码!

不要留着无用的遗留代码!

不要留着无用的遗留代码!

重要且最容易被忽略的事情要说三遍。斩草要除根,虽然我们可以保持代码不动。但是清理不再使用的遗留代码和自动化测试可以为其它团队减少很多不必要的工作量。

最终的架构

经过6个人两个月的开发(原计划8个人3个月),我们的 Serverless 微服务最终落地了。当然这中间有 60% 的时间是在探索全新的技术栈。如果熟练的话,估计 4 个人一个月就可以完成工作。

最后的架构如下图所示:

在上图中,请求仍然是先通过域名到 CDN (CloudFront),然后:

  1. CDN 根据请求点的不同,把页面请求转发至 S3 ,把 API 请求转发到 API Gateway。
  2. 前端的内容通过蓝绿部署被放到了不同的 S3 Bucket 里面,只需要改变 CDN 设置就可以完成对应内容的部署。虽然对于部署来说蓝绿 Bucket 乍看有一点多余,但这是为了能够在生产环境下做集成在线测试准备的。这样可以使环境不一致尽可能少。
  3. API Gateway 有自己作用的 VPC,很好的实现了网络级别的隔离。
  4. 通过 API Gateway 转发的 API 请求分成了三类,每一类都可以根据请求状况自扩展。
  5. 身份验证类:第一次访问会请求 ElastCache(Redis),如果 Token 失效或者不存在,则重新走一遍用户验证流程。
  6. 数据请求类:数据请求类会通过 Lambda 访问由其他团队开发的 Java 微服务,这类微服务是后台系统唯一的访问点。
  7. 操作审计类:请求会记录到 DynamoDB (一种时间序列数据库)中,用来跟踪异步请求的各种日志。
  8. API Gateway 自己有一些缓存,可以加速 API 的访问。
  9. 消息返回后,再有三类不同的请求的结果统一通过 API Gateway 返回给客户端。

Serverless 风格微服务架构的优点

由于没有 EC2 设施初始化的时间,我们减少了至少一个月的工作量,分别是:

  1. 初始化网络配置的时间。
  2. 构建 EC2 配置的时间。
  3. 构建反向代理和前端静态内容服务器的时间。
  4. 构建后端 API 应用基础设施的时间。
  5. 构建负载均衡的时间。
  6. 把上述内容用 Ruby 进行基础设施即代码化的时间。

如果要把 API Gateway 算作是基础设施初始化的时间来看。第一次初始化 API Gateway 用了一天,以后 API Gateway 结合持续交付流程每次修改仅仅需要几分钟,Serverless 风格的微服务大大降低了基础设施配置和运维门槛。

此外,对于团队来说,Amazon API Gateway + Lambda 的微服务还带来其它好处:

  1. 开发效率高,原先至少 45 分钟的开发反馈周期缩短为 5 分钟以内。
  2. 无关的代码量少,需要维护的代码量少。除了专注业务本身。上游和 API Gateway 的集成以及下游和后端服务的集成代码量很少。
  3. 应用维护成本低。代码仅仅几十行,且都为函数式,很容易测试。避免了代码库内部复杂性的增加。

此外,我们做了 Java 和 NodeJs 比较。在开发同样的功能下,NodeJS 的开发效率更高,原因是 Java 要把请求的 json 转化为对象,也要把返回的 json 转化为对象,而不像 nodejs 直接处理 json。此外, Java 需要引入一些其它 JAR 包作为依赖。在 AWS 场景下开发同样一个函数式微服务,nodejs 有 4 倍于 java 的开发效率提升。

最后

Serverless 风格的微服务虽然大大减少了开发工作量以及基础设施的开发维护工作量。但也带来了新的挑战:

  1. 大量函数的管理。
  2. SIT,UAT 环境的管理。
  3. 持续交付流水线的配置。
  4. 面对基础设施集成带来的测试。

这让我们重新思考了 Serverless 架构的微服务如何更好的进行持续交付。


更多精彩洞见,请关注微信公众号:思特沃克

Share

微网关与服务啮合

技术雷达:现在越来越多的大型组织在向更加自组织的团队结构转型,这些团队拥有并运营自己的微服务,但他们如何在不依赖集中式托管的基础架构下,确保服务之间必要的一致性与兼容性呢?为了确保服务之间的有效协作,即使是自组织的微服务也需要与一些组织标准对齐。服务啮合(SERVICE MESH)在服务发现、安全、跟踪、监控与故障处理方面提供了一致性,且不需要像API网关或ESB这样的共享资产。服务啮合的一个典型实现包含轻量级反向代理进程,这些进程可能伴随每个服务进程一起被部署在单独的容器中。反向代理会和服务注册表、身份提供者和日志聚合器等进行通信。通过该代理的共享实现(而非共享的运行时实例),我们可以获得服务的互操作性和可观测性。一段时间以来,我们一直主张去中心化的微服务管理方法,也很高兴看到服务啮合这种一致性模式的出现。随着linkerd和Istio等开源项目的成熟,服务啮合的实现将更加容易。

新瓶旧酒还是厚积薄发?

对于持续关注云服务架构设计最新发展趋势的同学来说,刚过去的2017年,最火的词莫过于CNCF了,这是一个专注于“云原生”(Cloud Native)的基金会,由众多战斗在云服务一线的公司(包括了谷歌、AWS、阿里云等等)组建而来,旨在推进云原生的架构标准化与最佳实践的普及。

而在最近一期的技术雷达中,“云原生”(Cloud Native)和 “微服务”(Microservices)也引出了许多相关的技术,随着 Kubernetes、Docker 等一众容器管理工具的普及,我们也看到在容器的内部,微服务的架构设计也发生着一些变化,其中“服务啮合”(Service Mesh)就成为了大家关注的热点。

那么这些变化到底是新瓶旧酒,还是厚积薄发?我们不妨先从一个更耳熟能详的架构——“网关”(Gateway)谈起。

网关(Gateway)的作用

作为微服务工具链中的元老,“网关”(Gateway) 的引入为微服务API提供统一的入口和平台,不同的服务可以得到一致的管理。使用网关的架构可以减少企业大量的重复开发。甚至有一些通用的逻辑也可以使用网关来承载(如Zuul、Enovy、OpenResty等)。

不论初心为何,这些网关们随着时光流转,功能也变得越来越丰富,网关可以负责解决不同服务的服务注册发现、负载均衡、配额流控、监控日志、缓存加速、配置分离、安全管控、跟踪审计等问题。这一系列的功能,我们可以大致分为两类:“数据面”和“控制面”。

数据面(Data Plane)负责在数据包粒度上进行筛选和处理

  • 路由转发
  • 负载均衡
  • 安全通信
  • 缓存加速
  • 认证鉴权
  • 日志审计
  • 健康检查
  • 熔断限流

控制面(Control Plane)负责在服务粒度上进行统筹和管理

  • 注册发现
  • 配置管控
  • 弹性伸缩
  • 统筹遥测
  • 容错自愈
  • 策略执行
  • 证书签发

这一系列的功能,就是网关面临的问题域。在了解问题域之后,让我们回归本篇的主题:继承了“网关”(Gateway)衣钵的“微网关”(MicroGateway)和“服务啮合”(Service Mesh),它们到底是什么?

什么是微网关?

随着微服务的普及,传统的中心化网关变得越来越厚重,由于与中心化节点通信,带来了大量网络、IO开销以及单点问题,往往无法满足我们对于实时性、高可用的要求。另外越来越多的自治化需求,与原有集权式微服务治理方法之间,也产生出许多冲突矛盾。因此,与微服务化相适应的,可以本地化、分布式部署的微网关(MicroGateway)也逐渐涌现出来。

什么是服务啮合?

服务啮合(Service Mesh)是一种为了保证“服务到服务”的安全、快速和可靠的通信而产生的基础架构层,区别于应用层、通信层的一种新的云原生上下文内的抽象层。如果你正在构建云原生的应用程序,在微服务拓扑结构日益复杂的今天,服务啮合层的提出,可以帮助开发者将服务的交互通信问题与微服务内部的业务问题隔离开来,专注于各自的领域。

演进中的微网关与服务啮合

当我们了解到微网关与服务啮合的作用之后,就可以一起来看一下微网关与服务啮合架构是如何一步步设计出来的。

低侵入性组件(Low-Invasive Component)

最初的服务间互访,常常由于业务尚不清晰,给标准化带来了障碍。因此我们常常见到一些由领域专家提供的低侵入性的组件,为服务的开发者提供抽象的规范,使其能轻松获得定制化能力。

组件可以更好地规范问题,并且尽可能地将组件封装为简单的接口,早期的服务发现常常通过该类方式实现,例如 Eureka 套件通过引入 Client 来获得完整的如报告、监控、熔断等能力。

我们在一些 IAM (Identity Access Management)的服务设计中采用了这种模式,为各个业务服务提供了一致的认证鉴权接口,由领域专家驱动,设计规范化的调用模式。由于该类组件尽可能设计为低侵入性的接口,因此微服务团队也可以更加便利地根据不同场景取舍是否使用该组件提供的功能,例如通过配置文件加 feature toogle 简单地在开发环境中关闭认证鉴权的功能,以加快开发进程。

反向代理(Reverse Proxy)

随着服务成熟度的提高,我们可以发现一些常见的非业务强相关的逻辑,可以从原有的服务中剥离出来,通过反向代理统一进行过滤处理。

反向代理,可以为微服务处理请求的前后环节增添通用逻辑,例如 apigee 提供的 API proxy 封装,通过反向代理模式为原有的服务添加 PreFlow、PostFlow,解决请求生命周期前后常见的问题,例如检查配额和记录调用频度,对 CORS 等 Http Header 的添加和消费,这些功能有些类似于传统的 Filter 模型,但是却可以独立部署。

反向代理可以提供更高的可用性,并帮助微服务开发者从这些常见细节中解脱出来。

侧车模式(Sidecar Pattern)

准确来说,侧车模式(Sidecar Pattern)本身并非微网关或者服务啮合技术独有,它只是一种特定的软件模块共生关系。侧车模式可以是一个反向代理,也可以作为一个服务存在。

作为反向代理使用的Sidecar进程可以过滤请求与返回内容,实现如安全通信、认证鉴权、服务端/客户端负载均衡、自动路由等功能。

作为服务使用的Sidecar进程可以为主服务提供额外能力,实现分布式缓存同步、配置文件拉取、日志搜集等功能。

侧车模式常见于分布式缓存和安全基础设施网关,通过与微服务进程共同启停的服务或容器,可以更方便地与微服务一并调度,享受微服务管理平台本身提供的服务发现、注册、配置、扩容能力。通过共享生命周期,在简单部署和灵活应用中寻找一个平衡

我们在微服务框架 Jhipster 提供的基础能力中,可以直接通过注解使用 Hazelcast 的分布式缓存,正是通过 Sidecar 模式实现的,拥有共生的分布式缓存实例后,可轻松实现服务接口的缓存,而分布式缓存自身的同步策略等问题都被封装在 Sidecar 进程中,无需开发者花费大量时间重新开发和调试。Sidecar也可以用于实现例如OAuth等安全相关的守护服务,帮助微服务处理业务界限外的专项问题。

原生的基础设施(Native Infrastructure)

服务啮合带来的最大的不同就是原生无感知,通过侧车模式部署的反向代理,与一些容器系统级的配置结合,更彻底地解决微服务在数据面与管理面的能力一致性问题。

服务啮合框架 Istio 提供了 Istio-Initializer 和 istioctl 工具,你可以在Kubernetes的容器整备过程中,注入所需的配置和 Envoy 容器,将Sidecar Proxy、Sidecar Service注册为容器集群中的原生服务,可以在享受弹性部署的同时,享受数据面和控制面协同提供的标准化能力。无痛地加入Istio提供的功能,如 iptables 代理转发、双向TLS认证、限流策略、日志收集等等。

图片来自:http://t.cn/RETWzGV

我们在设计服务啮合层时也可以考虑使用更原生的服务组织与部署策略,例如将微服务容器注册为系统服务、通过控制流对容器进行编排、尽可能与微服务共享生命周期和运行环境来提高可用性与性能等等。

从现在开始,拥抱微服务的云原生生态

既是新瓶旧酒,又是厚积薄发,云原生趋势下的微服务也在不断的演进,逐渐变成我们最初希望的“会呼吸”的模样。我们建议您考虑在一些适用的场景,尤其是微服务化的架构设计中,考虑使用微网关与服务啮合,并总结最佳实践与我们交流。

让我们一起期待云原生生态下的微服务,为数字化时代提供更多的想象力。


更多精彩洞见,请关注微信公众号:思特沃克

Share