微服务的低风险演变

继续来深入探讨!在之前的文章(第一部分)中,我们为本篇文章建立了一个上下文环境(以便于讨论)。一个基本原则是,当微服务被引入到现有架构中时,不能也不应该破坏当前的请求流程(request flows)。“单体应用(monolish)”程序依然能带来很多商业价值(因此仍将在新的时代被使用,编者注),我们只能在迭代和扩展时,尽可能地减少其负面影响,这过程中就有一个经常被忽略的事实:当我们开始探索如何从单体应用过渡到微服务时,会遇到一些我们不愿意碰到的难题,但显然我们不能视而不见。如果你还没读过这段内容,我建议你再回去看看第一部分。同时也可以参考什么时候不要做微服务[0]。

关注推特上的(@christianposta)或访问http://blog.christianposta.com,以获取最新更新和讨论。

在此前的第一部分,想解决的问题有:

  • 如何可以有效可靠地生成微服务。以及如何建立一个持续交付的系统。
  • 如何能够对服务和单体应用等对象进行测试。
  • 如何在新的微服务中能安全地引入任何变更,包含灰度上线、金丝雀测试等等
  • 如何将流量路由到新的服务中去,以保证启用/终止任何新的特性或更改都不会出现问题
  • 如何面对许多棘手的数据集成挑战

一、技术层面

以下这些技术在我们的实践过程中将具备一定的指导作用:

• 开发人员服务框架(Spring Boot [1],WildFly [2],WildFly Swarm [3])

• API设计(APICur.io [4])

• 数据框架(Spring Boot Teiid [5],Debezium.io [6])

• 集成工具(Apache Camel [7])

• Service Mesh(Istio Service Mesh [8])

• 数据库迁移工具(Liquibase [9])

• 灰度上线/特性标记框架(FF4J [10])

• 部署/CI-CD平台(Kubernetes [11]/OpenShift [12])

• Kubernetes开发工具(Fabric8.io [13])

• 测试工具(Arquillian [14],Pact [15]/Arquillian Algeron [16],Hoverfly [17],Spring-Boot Test [18],RestAssured [19],Arquillian Cube [20])

我使用的是http://developers.redhat.com上的TicketMonster教程,显示从单体应用到微服务的演变,如果感兴趣的话可以关注,你还可以在github上找到相关的代码和文档(文档还在编写中):https://github.com/ticket-monster-msa/monolith

让我们一步步地读完第一部分 [21],具体来看看每一步应该怎么实施。中间还会引入上一部分中出现的一些注意事项,并在当前背景下再讨论一遍。

二、了解单体式应用

回顾下注意事项:

  • 单体式应用(代码和数据库模型)很难变更
  • 变更需要整体重新部署和团队间高度的协调
  • 需要进行大量测试来做回归分析
  • 需要一个全自动的部署方式

可以的话,尽可能为单体应用安排大量的测试,哪怕不是一直有效。随着演变的开始,无论是添加新功能还是替换现有功能,我们都需要清楚了解任何更改可能产生的影响。Michael Feathers 在他《重构遗留代码》[22]的书中,将“遗留代码(legacy code)”定义为没有被测试所覆盖的代码。像JUnit和Arquillian这样的工具就很能帮到大忙。使用Arquillian,可以任意选择远程方法调用的接口的颗粒大小(fine grain or coarse grain),然后打包应用程序,不过仍需要用适当的模拟等方式,来运行打算被测试的一部分程序。例如,在单体应用(TicketMonster)中,我们可以定义一个微部署(micro-deployment),用来将原有的数据库替换为内存数据库,并预加载一些样例数据。Arquillian适用于Spring Boot应用、Java EE等。在本例中,我们将测试一个Java EE的单体架构:

public static WebArchive deployment() {
  return ShrinkWrap
    .create(WebArchive.class, "test.war")
    .addPackage(Resources.class.getPackage())
    .addAsResource("META-INF/test-persistence.xml", "META-INF/persistence.xml")
    .addAsResource("import.sql")
    .addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml")
    // Deploy our test datasource
    .addAsWebInfResource("test-ds.xml");
}

更有意思的是,嵌入在运行环境中的测试可以用来验证内部工作的所有组件。例如,在上面的一个测试中,我们可以将BookingService注入到测试中,并直接运行:

@RunWith(Arquillian.class)
public class BookingServiceTest {

    @Deployment
    public static WebArchive deployment() {
        return RESTDeployment.deployment();
    }

    @Inject
    private BookingService bookingService;

    @Inject
    private ShowService showService;

    @Test
    @InSequence(1)
    public void testCreateBookings() {
        BookingRequest br = createBookingRequest(1l, 0, new int[]{4, 1}, new int[]{1,1}, new int[]{3,1});
        bookingService.createBooking(br);

        BookingRequest br2 = createBookingRequest(2l, 1, new int[]{6,1}, new int[]{8,2}, new int[]{10,2});
        bookingService.createBooking(br2);

        BookingRequest br3 = createBookingRequest(3l, 0, new int[]{4,1}, new int[]{2,1});
        bookingService.createBooking(br3);
    }

完整的示例请参阅TicketMonster单体应用模块[23]中的BookingServiceTest。

测试的问题解决了,那么部署呢?

Kubernetes已成为容器化服务或应用程序的实际部署平台。Kubernetes处理诸如健康度检查、扩展、重启、负载平衡等事项。对于Java开发人员来说,像fabric8-maven-plugin[24]这样的工具甚至都可以用来自动构建容器或docker镜像,并生成任意部署资源文件。OpenShift[25]是Red Hat的Kubernetes的产品化版本,其中增加了开发人员的功能,包括CI/CD pipelines等。

无论是微服务、单体应用还是其他平台(比如能够处理持续的工作负载,即数据库等),Kubernetes/OpenShift都是一个适用于应用程序/服务的部署平台。通过Arquillian,容器和OpenShift pipelines,可以持续地将变更引入生产环境。顺便来看一下openshift.io[26],它将开发经验与自动CI/CD pipelines、SCM集成、Eclipse Che[27]开发人员工作区、库扫描等结合在一起。

目前,生产负载指向单体应用。如果我们翻到它的主页,我们会看到这样的内容:

接下来,让我们开始做一些改变…

三、提取用户界面UI

回顾下注意事项:

  • 一开始,先不要变更单体式应用;只需将UI复制粘贴到单独的组件即可
  • 在UI和单体式应用间需要有一个合适的远程API—但并非所有情况下都需要
  • 增加一个安全层
  • 需要用某种方法以受控的方式将流量路由或分离到新的UI或单体式应用,以支持灰度上线(dark launch)/金丝雀测试(canary)/滚动发布(rolling release)[28]

如果我们看下TicketMonster UI v1 [29]代码,就会发现它非常简单。静态HTML/JS/CSS组件已经被移到它自己的Web服务器,还被打包到一个容器中。通过这种方式,我们可以在单体应用之外对它进行单独部署,并独立更改或更新版本。这个UI项目仍然需要与单体应用对话来执行它的功能,所以应该是公开一个REST接口,让UI可以与之交互。对于一些单体应用来说,这说起来容易做起来难。如果你想从遗留代码中打包出来一个不错的REST API,又遇到了挑战,我强烈推荐你看看Apache Camel,尤其是它的REST DSL。

比较有意思的是,实际上单体应用并没有被改变。它的代码没有变动,同时新UI也部署完成。如果查看Kubernetes,我们会看到两个单独的部署对象和两个单独的pod:一个用于单体架构,另一个用于UI。

即使tm-ui-v1用户界面部署完了,也没有任何流量进入这个新的TicketMonster UI组件。为了简单起见,即使这个部署并没有承载生产流量,而是ticket-monster这个单体应用在承担所有流量,我们仍然可以把它当作一个简单的灰度上线。相关的UI端口仍旧可以访问:

接下来,用kubectl cli 工具从本地端口转发到特定的pod(端口80上的tm-ui-v1-3105082891-gh31x),并将其映射到本地端口8080。现在,如果导航到http://localhost:8080,应该得到一个新版本UI(注意突出显示的文本部分,表明这是一个不同的UI,但它直接指向单体应用)

如果我们这个新版本还算满意,就可以开始将流量引入进来。为此,我们将使用Istio service mesh [30]。Istio是用于管理由入口点和服务代理组成的网格控制层(control plane)。我已经写了一些关于像Envoy这样的数据层[31]以及service mesh[32]的文章。我个人强烈建议看看Istio的全部功能。接下来的几段内容,我们会围绕整个项目的全过程来依次展开讨论Istio的各项功能。如果控制层和数据层之间的区分让你困惑,请查看Matt Klein[33]撰写的博客。

我们将从使用Istio Ingress Controller[34]开始。该组件允许使用Kubernetes Ingress规范来控制流量进入Kubernetes集群。一旦安装了Istio,我们可以这样创建一个入口资源,将流量指向Ticket Monster UI的Kubernetes服务,tm-ui:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: tm-gateway
  annotations:
    kubernetes.io/ingress.class: "istio"
spec:
  backend:
    serviceName: tm-ui
    servicePort: 80

一旦有了入口,就可以开始应用Istio路由规则[35]。例如,有一个规则,“任何时候有人试图与在Kubernetes中运行的tm-ui服务对话,将它们指向服务的第一版本v1”:

apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
  name: tm-ui-default
spec:
  destination:
    name: tm-ui
  precedence: 1
  route:
  - labels:
      version: v1

如此,我们能够更好地控制进入集群甚至深入集群内部的流量。在这个步骤的最后,我们会将所有的流量都转到tm-ui-v1部署。

四、从单体架构移除UI

回顾下注意事项

  • 从单体式应用中移除UI组件
  • 需要对单体式应用进行最小的变更(弃用/删除/禁用UI)
  • 不停机的前提下,再次使用受控的路由/整流方法来引入这种变更

这一步相当直接,通过删除静态UI组件来更新单体应用(删除的部分已经转移到了tm-ui-v1部署)。既然应用程序已经被释放成为一个单体应用的服务,以供UI,API或者其他一些程序调用,那么也可以对这个部署进行一些API层级的更改。而如果想对API进行一些更改,就需要部署一个新版本的UI。此处我们部署了backend-v1服务以及一个新的UI tm-ui-v2,可以利用后端服务中的这个新API。

来看看在Kubernetes集群中的部署情况:

此时,ticket-monster和tm-ui-v1正接收实时流量。backend-v1和指向它的UI–tm-ui-v2则没有流量负载。需要注意的一点是,backend-v1部署与ticket-monster部署共享数据库,但各自有略微不同的外向API(outward facing API)。

现在,新的backend-v1和tm-ui-v2组件已经部署到生产环境中。现在是时候把注意力放在一个简单而又重要的事实上:生产环境部署发生了改变,但是它们还没有发布。在turblabs.io [36]一些优秀的博客更详细地阐述了这一点[37]。现在,我们有机会部署一个非正式的灰度发布。也许我们希望这个部署慢慢来,首先面向内部用户,或者先对某个特定区域内,特定设备的部分用户进行部署等等。

既然已经有了Istio,接下来看看它能做些什么。我们只想为内部用户做一个灰度发布。我们可以用各种方式来识别内部用户,诸如headers、IP等等,在本例中,如果HTTP header带有 x-dark-launch: v2 这样的文本内容,则该请求将会被路由到新的backend-v1和tm -ui-v2服务中。以下是istio路由规则的样子:

apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
  name: tm-ui-v2-dark-launch
spec:
  destination:
    name: tm-ui
  precedence: 10
  match:
    request:
      headers:
        x-dark-launch:
          exact: "v2"
  route:
  - labels:
      version: v2

任意用户身份登录主页时,应该可以看到当前的部署(即指向ticket-monster单体应用的tm-ui-v1):

现在,如果改变浏览器中的消息头(例如使用Firefox的修改消息头工具或其他类似工具),我们应该被路由到已灰度上线的服务(指向backend-v1的tm-ui-v2):

然后点击“开始”开始修改消息头并刷新页面:

现在,我们已经被重定向到服务的灰度发布版本。由此,可以通过做一个金丝雀发布(这里也许引1%的实时流量到新部署),来向客户群发布,同时,如果没有负面效果的话,那么就缓慢增加流量负载(5%、10%、50%等)。以下是Istio路由规则的一个例子,其将v2流量以1%进行金丝雀发布:

 apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
  name: tm-ui-v2-1pct-canary
spec:
  destination:
    name: tm-ui
  precedence: 20
  route:
  - labels:
      version: v1
    weight: 99
  - labels:
      version: v2
    weight: 1

能“看到”或“观察”这个版本的影响是至关重要的,稍后我们会进一步讨论。另外请注意,这种金丝雀发布方式目前正在架构外围完成,但是也可以通过istio控制内部服务间通讯/交互时采用金丝雀的方式。在接下来的几个步骤中,我们将开始看到。

五、引入新服务

回顾下注意事项

  • 我们要关注被抽取的服务的API设计或边界
  • 可能需要重写单体式应用中的某些内容
  • 在确定API后,将为该服务实施一个简单的脚手架或者place holder
  • 新的Orders服务将拥有自己的数据库
  • 新Orders服务目前不会承担任何流量

在这一步中,我们开始设计我们所设想的新订单服务的API,在做一些领域驱动设计练习时,我们常常需要确定一些边界(boundaries),新的API应该更多的与这种边界相一致。这里可以使用API建模工具来设计API,部署一个虚拟化的实施,并且随服务消费者的需求变化 一起迭代,而不是一开始花费大量的精力去构建,最后又发现需要不断修改。

在TicketMonster重构时,需要在单体应用中保留一个上文所说的API,以便在最初的服务拆分时尽可能轻松并且降低风险。无论是哪种情况,有两个给力的工具可以帮到我们:一个是网页式的API设计器,apicur.io[38],一个是测试/ API虚拟化工具,Hoverfly[39]。Hoverlfy是模拟API或捕获现有API流量的好工具,可以用来模拟mock端点。

如果我们正在构建一个新的API,或在使用领域驱动设计方法后,想看看API什么样,可以使用apicur.io工具建立一个Swagger/Open API的规范。

在TicketMonster这个例子中,我们通过在代理模式下启动hoverfly,并使用hoverfly捕获从应用程序到后端服务的流量。我们可以在浏览器设置中设置HTTP代理,从而通过hoverfly发送所有流量。这将把每个请求/响应对(request/response pair)的仿真存储在JSON文件中。这样我们就可以在Mock里使用这些请求/响应对,或者更进一步,用它们开始编写测试,以规范具体的实现代码中的一些行为。

对于所关注的请求或响应对(response pairs),我们可以生成一个JSON架构并用于测试中,参见https://jsonschema.net/#/editor

例如,结合使用Rest Assured和Hoverfly,可以调用hoverfly模拟,并确定该响应符合我们预期的JSON架构:

@Test
public void testRestEventsSimulation(){
    get("/rest/events").then().assertThat().body(matchesJsonSchemaInClasspath("json-schema/rest-events.json"));
}

在新的订单服务中,可以查看HoverflyTest.java [40]测试。有关测试Java微服务的更多信息,请查阅Manning这本给力的书,《测试Java微服务》[41],我的一些同事Alex Soto Bueno[42]、Jason Porter[43]和Andy Gumbrecht[44]也参与了这本书的撰写。

由于这篇博文已经很长了,我决定将最后的部分单独写成本主题的第三部分,其中将涉及在单体应用和微服务之间管理数据、服务消费的契约测试(consumer contract testing), 功能发布控制( feature flagging),甚至更复杂的istio路由等内容。本系列的第四部分将展示一个包含上述内容的实操Demo,使用负载仿真测试(load simulation tests)和故障注入(fault injections)。欢迎访问我的网站 [45]和关注我的Twitter [46]。

原文链接:http://blog.christianposta.com/microservices/low-risk-monolith-to-microservice-evolution-part-ii/

参考地址:

[0] http://blog.christianposta.com/microservices/when-not-to-do-microservices/

[1] https://projects.spring.io/spring-boot/

[2] http://wildfly.org/

[3] http://wildfly-swarm.io/

[4] http://www.apicur.io/

[5] https://github.com/teiid/teiid-spring-boot

[6] http://debezium.io/

[7] http://camel.apache.org/

[8] https://istio.io/

[9] http://www.liquibase.org/

[10] https://ff4j.org/

[11] https://kubernetes.io/

[12] https://www.openshift.org/

[13] https://fabric8.io/

[14] http://arquillian.org/

[15] https://github.com/pact-foundation/pact-specification

[16] http://arquillian.org/arquillian-algeron/

[17] https://hoverfly.io/

[18] https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-testing.html

[19] http://rest-assured.io/

[20] http://arquillian.org/arquillian-cube/

[21] http://blog.christianposta.com/microservices/low-risk-monolith-to-microservice-evolution/

[22] https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052

[23] https://github.com/ticket-monster-msa/monolith/blob/master/monolith/src/test/java/org/jboss/examples/ticketmonster/test/rest/BookingServiceTest.java

[24] https://maven.fabric8.io/

[25] https://www.openshift.com/

[26] https://openshift.io/

[27] https://www.eclipse.org/che/

[28] http://blog.christianposta.com/deploy/blue-green-deployments-a-b-testing-and-canary-releases/

[29] https://github.com/ticket-monster-msa/monolith/tree/master/tm-ui-v1

[30] https://istio.io/

[31] http://blog.christianposta.com/microservices/00-microservices-patterns-with-envoy-proxy-series/

[32] http://blog.christianposta.com/microservices/application-network-functions-with-esbs-api-management-and-now-service-mesh/

[33] https://medium.com/@mattklein123/service-mesh-data-plane-vs-control-plane-2774e720f7fc

[34] https://istio.io/docs/tasks/traffic-management/ingress.html

[35] https://istio.io/docs/reference/config/traffic-rules/routing-rules.html

[36] https://www.turbinelabs.io/

[37] https://blog.turbinelabs.io/deploy-not-equal-release-part-one-4724bc1e726b

[38] http://www.apicur.io/

[39] https://hoverfly.io/

[40] https://github.com/ticket-monster-msa/monolith/blob/master/orders-service/src/test/java/org/ticketmonster/orders/HoverflyTest.java

[41] https://www.manning.com/books/testing-java-microservices

[42] https://twitter.com/alexsotob

[43] https://twitter.com/lightguardjp

[44] https://twitter.com/andygeede?lang=en

[45] http://blog.christianposta.com/

[46] https://twitter.com/christianposta