微服务是否适合小团队是个见仁见智的问题。但小团队并不代表出品的一定是小产品,当业务变得越来越复杂,如何使用微服务分而治之就成为一个不得不面对的问题。
因为微服务是对整个团队的考验,从开发到交付,每一步都充满了挑战。经过 1 年多的探索和实践,本着将 DevOps 落实到产品中的愿景,一步步建设出适合我们的微服务平台。
要不要微服务
我们的产品是 Linkflow,企业运营人员使用的客户数据平台(CDP)。产品的一个重要部分类似企业版的“捷径",让运营人员可以像搭乐高积木一样创建企业的自动化流程,无需编程即可让数据流动起来。
从这一点上,我们的业务特点就是聚少成多,把一个个服务连接起来就成了数据的海洋。
理念上跟微服务一致,一个个独立的小服务最终实现大功能。当然我们一开始也没有使用微服务,当业务还未成型就开始考虑架构,那么就是“过度设计"。
另一方面需要考虑的因素就是“人",有没有经历过微服务项目的人,团队是否有 DevOps 文化等等,综合考量是否需要微服务化。
微服务的好处是什么?
- 相比于单体应用,每个服务的复杂度会下降,特别是数据层面(数据表关系)更清晰,不会一个应用上百张表,新员工上手快。
- 对于稳定的核心业务可以单独成为一个服务,降低该服务的发布频率,也减少测试人员压力。
- 可以将不同密集型的服务搭配着放到物理机上,或者单独对某个服务进行扩容,实现硬件资源的充分利用。
- 部署灵活,在私有化项目中,如果客户有不需要的业务,那么对应的微服务就不需要部署,节省硬件成本,就像上文提到的乐高积木理念。
微服务有什么挑战?
- 一旦设计不合理,交叉调用,相互依赖频繁,就会出现牵一发动全身的局面。想象单个应用内 Service 层依赖复杂的场面就明白了。
- 项目多了,轮子需求也会变多,需要有人专注公共代码的开发。
- 开发过程的质量需要通过持续集成(CI)严格把控,提高自动化测试的比例,因为往往一个接口改动会涉及多个项目,光靠人工测试很难覆盖所有情况。
- 发布过程会变得复杂,因为微服务要发挥全部能力需要容器化的加持,容器编排就是***的挑战。
- 线上运维,当系统出现问题需要快速定位到某个机器节点或具体服务,监控和链路日志分析都必不可少。
下面详细说说我们是怎么应对这些挑战的。
开发过程的挑战
持续集成
通过 CI 将开发过程规范化,串联自动化测试和人工 Review。
我们使用 Gerrit 作为代码&分支管理工具,在流程管理上遵循 GitLab 的工作流模型:
- 开发人员提交代码至 Gerrit 的 Magic 分支。
- 代码 Review 人员 Review 代码并给出评分。
- 对应 Repo 的 Jenkins job 监听分支上的变动,触发 Build job。经过 IT 和 Sonar 的静态代码检查给出评分。
- Review 和 Verify 皆通过之后,相应 Repo 的负责人将代码 Merge 到真实分支上。
- 若有一项不通过,代码修改后重复过程。
- Gerrit 将代码实时同步备份至两个远程仓库中。
集成测试
一般来说代码自动执行的都是单元测试(Unit Test),即不依赖任何资源(数据库,消息队列)和其他服务,只测试本系统的代码逻辑。
但这种测试需要 Mock 的部分非常多,一是写起来复杂,二是代码重构起来跟着改的测试用例也非常多,显得不够敏捷。而且一旦要求开发团队要达到某个覆盖率,就会出现很多造假的情况。
所以我们选择主要针对 API 进行测试,即针对 Controller 层的测试。另外对于一些公共组件如分布式锁,Json 序列化模块也会有对应的测试代码覆盖。
测试代码在运行时会采用一个随机端口拉起项目,并通过 HTTP Client 对本地 API 发起请求,测试只会对外部服务做 Mock,数据库的读写,消息队列的消费等都是真实操作,相当于把 Jmeter 的事情在 Java 层面完成一部分。
Spring Boot 项目可以很容易的启动这样一个测试环境,代码如下:
测试过程的 HTTP Client 推荐使用 io.rest-assured:rest-assured 支持 JsonPath,十分好用。
测试时需要注意的一个点是测试数据的构造和清理。构造又分为 Schema 的创建和测试数据的创建:
- Schema 由 Flyway 处理,在启用测试环境前先删除所有表,再进行表的创建。
- 测试数据可以通过 @Sql 读取一个 SQL 文件进行创建,在一个用例结束后再清除这些数据。
顺带说一下,基于 Flyway 的 Schema Upgrade 功能我们封成了独立的项目,每个微服务都有自己的 Upgrade 项目。
好处:一是支持 command-line 模式,可以细粒度的控制升级版本;二是也可以支持分库分表以后的 Schema 操作。Upgrade项目也会被制作成 Docker image 提交到 Docker hub。
测试在每次提交代码后都会执行,Jenkins 监听 Gerrit 的提交,通过 docker run -rm {upgrade 项目的 image}先执行一次 Schema Upgrade,然后 Gradle test 执行测试。
最终会生成测试报告和覆盖率报告,覆盖率报告采用 JaCoCo 的 Gradle 插件生成,如下图:
这里多提一点,除了集成测试,服务之间的接口要保证兼容,实际上还需要一种 consumer-driven testing tool。
就是说接口消费端先写接口测试用例,然后发布到一个公共区域,接口提供方发布接口时也会执行这个公共区域的用例,一旦测试失败,表示接口出现了不兼容的情况。
比较推荐大家使用 Pact 或是 Spring Cloud Contact。我们目前的契约基于“人的信任”,毕竟服务端开发者还不多,所以没有必要使用这样一套工具。
集成测试的同时还会进行静态代码检查,我们用的是 Sonar,当所有检查通过后 Jenkins 会 +1 分,再由 Reviewer 进行代码 Review。
自动化测试
单独拿自动化测试出来说,就是因为它是质量保证的非常重要的一环,上文能在 CI 中执行的测试都是针对单个微服务的。
那么当所有服务(包括前端页面)都在一起工作的时候是否会出现问题,就需要一个更接近线上的环境来进行测试了。
在自动化测试环节,我们结合 Docker 提高一定的工作效率并提高测试运行时环境的一致性以及可移植性。
在准备好基础的 Pyhton 镜像以及 Webdriver(Selenium)之后,我们的自动化测试工作主要由以下主要步骤组成:
- 测试人员在本地调试测试代码并提交至 Gerrit。
- Jenkins 进行测试运行时环境的镜像制作,主要将引用的各种组件和库打包进一个 Python 的基础镜像。
- 通过 Jenkins 定时或手动触发,调用环境部署的 Job 将专用的自动化测试环境更新,然后拉取自动化测试代码启动一次性的自动化测试运行时环境的 Docker 容器,将代码和测试报告的路径镜像至容器内。
- 自动化测试过程将在容器内进行。
- 测试完成之后,不必手动清理产生的各种多余内容,直接在 Jenkins 上查看发布出来的测试结果与趋势。
关于部分性能测试的执行,我们同样也将其集成到 Jenkins 中,在可以直观的通过一些结果数值来观察版本性能变化情况的回归测试和基础场景,将会很大程度的提高效率,便捷的观察趋势:
- 测试人员在本地调试测试代码并提交至 Gerrit。
- 通过 Jenkins 定时或手动触发,调用环境部署的 Job 将专用的性能测试环境更新以及可能的 Mock Server 更新。
- 拉取***的性能测试代码,通过 Jenkins 的性能测试插件来调用测试脚本。
- 测试完成之后,直接在 Jenkins 上查看通过插件发布出来的测试结果与趋势。
发布过程的挑战
上面提到微服务一定需要结合容器化才能发挥全部优势,容器化就意味着线上有一套容器编排平台。我们目前采用是 Redhat 的 OpenShift。
所以发布过程较原来只是启动 Jar 包相比要复杂的多,需要结合容器编排平台的特点找到合适的方法。
镜像准备
公司开发基于 GitLab 的工作流程,Git 分支为 Master,Pre-production和 Prodution 三个分支,同时生产版本发布都打上对应的 Tag。
每个项目代码里面都包含 Dockerfile 与 Jenkinsfile,通过 Jenkins 的多分支 Pipeline 来打包 Docker 镜像并推送到 Harbor 私库上。
Docker 镜像的命令方式为:项目名/分支名:git_commit_id,如 funnel/production:4ee0b052fd8bd3c4f253b5c2777657424fccfbc9。
Tag 版本的 Docker 镜像命名为:项目名 /release:tag 名,如 funnel/release:18.10.R1。
在 Jenkins 中执行 build docker image job 时会在每次 Pull 代码之后调用 Harbor 的 API 来判断此版本的 Docker image 是否已经存在,如果存在就不执行后续编译打包的 Stage。
在 Jenkins 的发布任务中会调用打包 Job,避免了重复打包镜像,这样就大大的加快了发布速度。
数据库 Schema 升级
数据库的升级用的是 Flyway,打包成 Docker 镜像后,在 OpenShift 中创建 Job 去执行数据库升级。
Job 可以用最简单的命令行的方式去创建:
脚本升级任务也集成在 Jenkins 中。
容器发布
OpenShift 有个特别概念叫 DeploymentConfig,原生 Kubernetes Deployment 与之相似,但 OpenShift 的 DeploymentConfig 功能更多。
DeploymentConfig 关联了一个叫做 ImageStreamTag 的东西,而这个 ImagesStreamTag 和实际的镜像地址做关联,当 ImageStreamTag 关联的镜像地址发生了变更,就会触发相应的 DeploymentConfig 重新部署。
我们发布是使用了 Jenkins+OpenShift 插件,只需要将项目对应的 ImageStreamTag 指向到新生成的镜像上,就触发了部署。
如果是服务升级,已经有容器在运行怎么实现平滑替换而不影响业务呢?
配置 Pod 的健康检查,Health Check 只配置了 ReadinessProbe,没有用 LivenessProbe。
因为 LivenessProbe 在健康检查失败之后,会将故障的 Pod 直接干掉,故障现场没有保留,不利于问题的排查定位。而 ReadinessProbe 只会将故障的 Pod 从 Service 中踢除,不接受流量。
使用了 ReadinessProbe 后,可以实现滚动升级不中断业务,只有当 Pod 健康检查成功之后,关联的 Service 才会转发流量请求给新升级的 Pod,并销毁旧的 Pod。
线上运维的挑战
服务间调用
Spring Cloud 使用 Eruka 接受服务注册请求,并在内存中维护服务列表。
当一个服务作为客户端发起跨服务调用时,会先获取服务提供者列表,再通过某种负载均衡算法取得具体的服务提供者地址(IP + Port),即所谓的客户端服务发现。在本地开发环境中我们使用这种方式。
由于 OpenShift 天然就提供服务端服务发现,即 Service 模块,客户端无需关注服务发现具体细节,只需知道服务的域名就可以发起调用。
由于我们有 Node.js 应用,在实现 Eureka 的注册和去注册的过程中都遇到过一些问题,不能达到生产级别。
所以决定直接使用 Service 方式替换掉 Eureka,也为以后采用 Service Mesh 做好铺垫。
具体的做法是,配置环境变量:
- EUREKA_CLIENT_ENABLED=false,RIBBON_EUREKA_ENABLED=false
并将服务列表如:
- FOO_RIBBON_LISTOFSERVERS: '[http://foo:8080](http://foo:8080/)'
写进 ConfigMap 中,以 envFrom: configMapRef 方式获取环境变量列表。
如果一个服务需要暴露到外部怎么办,比如暴露前端的 HTML 文件或者服务端的 Gateway。
OpenShift 内置的 HAProxy Router,相当于 Kubernetes 的 Ingress,直接在 OpenShift 的 Web 界面里面就可以很方便的配置。
我们将前端的资源也作为一个 Pod 并有对应的 Service,当请求进入 HAProxy 符合规则就会转发到 UI 所在的 Service。
Router 支持 A/B test 等功能,唯一的遗憾是还不支持 URL Rewrite。
对于需要 URL Rewrite 的场景怎么办?那么就直接将 Nginx 也作为一个服务,再做一层转发。流程变成 Router → Nginx Pod → 具体提供服务的 Pod。
链路跟踪
开源的全链路跟踪很多,比如 Spring Cloud Sleuth + Zipkin,国内有美团的 CAT 等等。
其目的就是当一个请求经过多个服务时,可以通过一个固定值获取整条请求链路的行为日志,基于此可以再进行耗时分析等,衍生出一些性能诊断的功能。
不过对于我们而言,首要目的就是 Trouble Shooting,出了问题需要快速定位异常出现在什么服务,整个请求的链路是怎样的。
为了让解决方案轻量,我们在日志中打印 RequestId 以及 TraceId 来标记链路。
RequestId 在 Gateway 生成表示唯一一次请求,TraceId 相当于二级路径,一开始与 RequestId 一样,但进入线程池或者消息队列后,TraceId 会增加标记来标识唯一条路径。
举个例子,当一次请求向 MQ 发送一个消息,那么这个消息可能会被多个消费者消费,此时每个消费线程都会自己生成一个 TraceId 来标记消费链路。加入 TraceId 的目的就是为了避免只用 RequestId 过滤出太多日志。
实现上,通过 ThreadLocal 存放 APIRequestContext 串联单服务内的所有调用。
当跨服务调用时,将 APIRequestContext 信息转化为 HTTP Header,被调用方获取到 HTTP Header 后再次构建 APIRequestContext 放入 ThreadLocal,重复循环保证 RequestId 和 TraceId 不丢失即可。
如果进入 MQ,那么 APIRequestContext 信息转化为 Message Header 即可(基于 RabbitMQ 实现)。
当日志汇总到日志系统后,如果出现问题,只需要捕获发生异常的 RequestId 或是 TraceId 即可进行问题定位。
经过一年来的使用,基本可以满足绝大多数 Trouble Shooting 的场景,一般半小时内即可定位到具体业务。
容器监控
容器化前监控用的是 Telegraf 探针,容器化后用的是 Prometheus,直接安装了 OpenShift 自带的 cluster-monitoring-operator。
自带的监控项目已经比较全面,包括 Node,Pod 资源的监控,在新增 Node 后也会自动添加进来。
Java 项目也添加了 Prometheus 的监控端点,只是可惜 cluster-monitoring-operator 提供的配置是只读的,后期将研究怎么将 Java 的 JVM 监控这些整合进来。
总结
开源软件是对中小团队的一种福音,无论是 Spring Cloud 还是 Kubernetes 都大大降低了团队在基础设施建设上的时间成本。
当然其中有更多的话题,比如服务升降级,限流熔断,分布式任务调度,灰度发布,功能开关等等都需要更多时间来探讨。
对于小团队,要根据自身情况选择微服务的技术方案,不可一味追新,适合自己的才是***的。