分享

代码实战:从单体式应用到微服务的低风险演变

 xujin3 2018-06-10

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

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

  • 如何可以有效可靠地生成微服务。以及如何建立一个持续交付的系统。

  • 如何能够对服务和单体应用等对象进行测试。

  • 如何在新的微服务中能安全地引入任何变更,包含灰度上线、金丝雀测试等等

  • 如何将流量路由到新的服务中去,以保证启用 / 终止任何新的特性或更改都不会出现问题如何面对许多棘手的数据集成挑战

技术层面

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

  • 开发人员服务框架(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. 上的 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/v1beta1kind: Ingressmetadata:  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/v1alpha2kind: RouteRulemetadata:  name: tm-ui-defaultspec:  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/v1alpha2kind: RouteRulemetadata:  name: tm-ui-v2-dark-launchspec:  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/v1alpha2kind: RouteRulemetadata:  name: tm-ui-v2-1pct-canaryspec:  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:///#/editor。

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

@Testpublic 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]。

期望得到更多优质技术干货,欢迎扫描群助手小波波二维码,与近万名技术人一起在 eaworld 社群参与定期微课、视频分享、探讨关于微服务、DevOps 实践等技术内容。入群暗号:1225

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多