当前位置: 首页 > 科技观察

一个小团队的微服务落地实践可以参考

时间:2023-03-13 11:53:22 科技观察

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