在 2014 年 6 月 Google 开源了 Kubernetes 后,经过这几年的发展,已逐渐成为容器编排领域的事实标准, 可以称之为云原生时代的操作系统,它使得基础设施维护变得异常简单。在云原生时代,微服务依赖于 Kubernetes 的优势在哪,微服务的生命周期基于 Kubernetes 该如何实践呢?本文整理自石墨文档架构负责人彭友顺在 Gopher China Meetup 西安站的主题演讲《石墨文档基于 Kubernetes 的 Go 微服务实践(上篇)》。下篇会在近期整理出来,敬请期待。
架构演进
互联网的 WEB 架构演进可以分为三个阶段:单体应用时期、垂直应用时期、微服务时期。
单体应用时期一般处于一个公司的创业初期,他的好处就是运维简单、开发快速、能够快速适应业务需求变化。但是当业务发展到一定程度后,会发现许多业务会存在一些莫名奇妙的耦合,例如你修改了一个支付模块的函数,结果登录功能挂了。为了避免这种耦合,会将一些功能模块做一个垂直拆分,进行业务隔离,彼此之间功能相互不影响。但是在业务发展过程中,会发现垂直应用架构有许多相同的功能,需要重复开发或者复制粘贴代码。所以要解决以上复用功能的问题,我们可以将同一个业务领域内功能抽出来作为一个单独的服务,服务之间使用 RPC 进行远程调用,这就是我们常所说的微服务架构。
总的来说,我们可以将这三个阶段总结为以下几点。单体应用架构快速、简单,但耦合性强;垂直应用架构隔离性、稳定性好,但复制粘贴代码会比较多;微服务架构可以说是兼顾了垂直应用架构的隔离性、稳定性,并且有很强的复用性能力。可以说微服务架构是公司发展壮大后,演进到某种阶段的必然趋势。
但微服务真的那么美好吗?我们可以看到一个单体架构和微服务架构的对比图。在左图我们可以看到一个业务可以通过 Nginx 服务器 数据库就能实现业务需求。但是在右图微服务架构中,我们完成一个业务需要引入大量的组件,比如在中间这一块我们会引入 DNS、HPA、ConfigMap 等、下面部分引入了存储组件 Redis、MySQL、Mongo 等。以前单体应用时期我们可能直接上机器看日志或上机器上查看资源负载监控,但是到了微服务阶段,应用太多了,肯定不能这么去操作,这个时候我们就需要引入 ELK、Prometheus、Grafana、Jaeger 等各种基础设施,来更方便地对我们的服务进行观测。
微服务的组件增多、架构复杂,使得我们运维变得更加复杂。对于大厂而言,人多维护起来肯定没什么太大问题,可以自建完整的基础设施,但对于小厂而言,研发资源有限,想自建会相当困难。
不过微服务的基础设施维护困难的问题在 Kubernetes 出现后逐渐出现了转机。在 2014 年 6 月 Google 开源了 Kubernetes 后,经过这几年的发展,已逐渐成为容器编排领域的事实标准。同时 Kubernetes 已俨然成为云原生时代的超级操作系统,它使得基础设施维护变得异常简单。
在传统模式下,我们不仅需要关注应用开发阶段存在的问题,同时还需要关心应用的测试、编译、部署、观测等问题,例如程序是使用 systemd、supervisor 启动、还是写 bash 脚本启动?日志是如何记录、如何采集、如何滚动?我们如何对服务进行观测?Metrics 指标如何采集?采集后的指标如何展示?服务如何实现健康检查、存活检查?服务如何滚动更新?如何对流量进行治理,比如实现金丝雀发布、流量镜像?诸如此类的问题。我们业务代码没写几行,全在考虑和权衡基础设施问题。然而使用 Kubernetes 后,可以发现大部分问题都已经被 Kubernetes 或周边的生态工具解决了,我们仅仅只需要关心上层的应用开发和维护 Kubernetes 集群即可。
Kubernetes 在微服务中的作用就如同建高楼的地基,做了很多基础工作,统一了大量的基础设施标准,以前我们要实现服务的启动、配置、日志采集、探活等功能需要写很多中间件,现在我们只需要写写 yaml 文件,就可以享受这些基础设施的能力。运维更加简单这个也显而易见,例如在以前出现流量高峰时研发提工单后增加副本数,运维处理工单,人肉扩缩容,现在我们可以根据实际应用的负载能力,合理的配置好副本 CPU、Mem 等资源及 HPA 规则,在流量高峰时由 Kubernetes 自动扩容、流量低谷时自动缩容,省去了大量人工操作。
同时在框架层面,传统模式下基础设施组件很多都是自研的,基本上没有太多标准可言,框架需要做各种 switch case 对这种基础设施组件的适配,并且框架经常会为因为基础设施的改变,做一些不兼容的升级。现在只需要适配 Kubernetes 即可,大大简化微服务的框架难度和开发成本。
微服务的生命周期
刚才我们讲到 Kubernetes 的优势非常明显,在这里会描述下我们自己研发的微服务框架 Ego 怎么和 Kubernetes 结合起来的一些有趣实践。
我们将微服务的生命周期分为以下 6 个阶段:开发、测试、部署、启动、调用、治理。
2.1 开发阶段
在开发阶段我们最关注三个问题:如何配置、如何对接,如何调试。
2.1.1 配置驱动
大家在使用开源组件的时候,其实会发现每个开源组件的配置、调用方式、debug 方式、记录日志方式都不一样,导致我们需要不停去查看组件的示例、文档、源码,才能使用好这个组件。我们只想开发一个功能,却需要关心这么多底层实现细节,这对我们而言是一个很大的心智负担。
所以我们将配置、调用方式做了统一。可以看到上图我们所有组件的地址都叫 addr,然后在下图中我们调用 redis、gRPC、MySQL 的时候,只需要基于组件的配置 Key path 去 Load 对应的组件配置,通过 build 方法就可以构造一个组件实例。可以看到调用方式完全相同,就算你不懂这个组件,你只要初始化好了,就可以根据编辑器代码提示,调用这个组件里的 API,大大简化我们的开发流程。
2.1.2 配置补齐
配置补齐这个功能,是源于我们在最开始使用一些组件库的时候,很容易遗漏配置,例如使用gRPC
的客户端,未设置连接错误、导致我们在阻塞模式下连接不上的时候,没有报正确的错误提示;或者在使用 Redis、MySQL 没有超时配置,导致线上的调用出现问题,产生雪崩效应。这些都是因为我们对组件的不熟悉,才会遗漏配置。框架要做的是在用户不配置的情况下,默认补齐这些配置,并给出一个最佳实践配置,让业务方的服务更加稳定、高效。
2.1.3 配置工具
我们编写完配置后,需要将配置发布到测试环境,我们将配置中心 IDE 化,能够非常方便的编写配置,通过鼠标右键,就可以插入资源引用,鼠标悬停可以看到对应的配置信息。通过配置中心,使我们在对比配置版本,发布,回滚,可以更加方便。
2.1.4 对接 -Proto 管理
我们内部系统全部统一采用gRPC
协议和protobuf
编解码。统一的好处在于不需要在做任何协议、编解码转换,这样就可以使我们所有业务采用同一个protobuf
仓库,基于 CI/CD 工具实现许多自动化功能。
我们要求所有服务提供者提前在独立的路径下定义好接口和错误码的 protobuf 文件,然后提交到 GitLab,我们通过 GitLab CI 的 check 阶段对变更的 protobuf 文件做 format、lint、breaking 检查。然后在 build 阶段,会基于 protobuf 文件中的注释自动产生文档,并推送至内部的微服务管理系统接口平台中,还会根据 protobuf 文件自动构建 Go/PHP/Node/Java 等多种语言的桩代码和错误码,并推送到指定对应的中心化仓库。
推送到仓库后,我们就可以通过各语言的包管理工具拉取客户端、服务端的 gRPC 和错误码的依赖,不需要口头约定对接数据的定义,也不需要通过 IM 工具传递对接数据的定义文件,极大的简化了对接成本。
2.1.5 对接 - 错误码管理
有了以上比较好的 protobuf 生成流程后,我们可以进一步简化业务错误状态码的对接工作。而我们采用了以下方式:
- Generate:
- 编写 protobuf error 的插件,生成我们想要的 error 代码。
- 根据 go 官方要求,实现 errors 的 interface,他的好处在于可以区分是我们自定义的 error 类型,方便断言。
- 根据注解的 code 信,在错误码中生成对应的 grpc status code,业务方使用的时候少写一行代码。
- 确保错误码唯一,后续在 API 层响应用户数据确保唯一错误码,例如: 下单失败 (xxx)。
- errors 里设置 with message,with metadata,携带更多的错误信息。
- Check:
- gRPC 的 error 可以理解为远程 error,他是在另一个服务返回的,所以每次 error 在客户端是反序列化,new 出来的。是无法通过 errors.Is 判断其根因。
- 我们通过工具将 gRPC 的错误码注册到一起,然后客户端通过 FromError 方法,从注册的错误码中,根据 Reason 的唯一性,取出对应的错误码,这个时候我们可以使用 errors.Is 来判断根因。
- 最后做到 errors.Is 的判断: errors.Is(eerrors.FromError(err), UserErrNotFound())。
2.1.6 对接 - 调试
对接中调试的第一步是阅读文档,我们之前通过 protobuf 的 ci 工具里的 lint,可以强制让我们写好注释,这可以帮助我们生成非常详细的文档。
基于 gRPC Reflection 方法,服务端获得了暴露自身已注册的元数据能力,第三方可以通过 Reflection 接口获取服务端的 Service、Message 定义等数据。结合 Kubernetes API,用户选择集群、应用、Pod 后,可直接在线进行 gRPC 接口测试。同时我们可以对测试用例进行存档,方便其他人来调试该接口。
2.1.7 Debug- 调试信息
我们大部分的时候都是对接各种组件 API,如果我们能够展示各种组件例如 gRPC、HTTP、MySQL、Redis、Kafka 的调试信息,我们就能够快速的 debug。在这里我们定义了一种规范,我们将配置名、请求 URL、请求参数、响应数据、耗时时间、执行行号称为 Debug 的六元组信息。
将这个 Debug 的六元组信息打印出来,如下图所示。我们就可以看到我们的响应情况,数据结构是否正确,是否有错误。
2.1.8 Debug- 定位错误
Debug 里面有个最重要的一点能够快速定位错误问题,所以我们在实践的过程中,会遵循 Fail Fast 理念。将框架中影响功能的核心错误全部设置为 panic,让程序尽快的报错,并且将错误做好高亮,在错误信息里显示 Panic 的错误码,组件、配置名、错误信息,尽快定位错误根因。这个图里面就是我们的错误示例,他会高亮的显示出来,你的配置可能不存在,这个时候业务方在配置文件中需要找到server.grpc
这个配置,设置一下即可。
2.2 测试阶段
2.2.1 测试类型
开发完成后,我们会进入到测试阶段。我们测试可以分为四种方式:单元测试、接口测试、性能测试、集成测试。
我们会通过 docker-compose 跑本地的一些单元测试,使用 GitLab CI 跑提交代码的单元测试。我们接口测试则使用上文所述接口平台里的测试用例集。性能测试主要是分两种,一类是 benchmark 使用 GitLab ci。另一类是全链路压测就使用平台工具。集成测试目前还做的不够好,之前是用 GitLab ci 去拉取镜像,通过 dind(Docker in Docker)跑整个流程,但之前我们没有拓扑图,所以需要人肉配置 yaml,非常繁琐,目前我们正在结合配置中心的依赖拓扑图,准备用 jekins 完成集成测试。
在这里我主要介绍下单元测试。
2.2.2 工具生成测试用例
单元测试优势大家都应该很清楚,能够通过单测代码保证代码质量。但单测缺点其实也非常明显,如果每个地方都写单测,会消耗大家大量的精力。
所以我们首先定义了一个规范,业务代码里面不要出现基础组件代码,所有组件代码下层到框架里做单元测试。业务代码里只允许有 CRUD 的业务逻辑,可以大大简化我们的测试用例数量。同时我们的业务代码做好 gRPC,HTTP 服务接口级别的单元测试,可以更加简单、高效。
然后我们可以通过开发 protobuf 工具的插件,拿到 gRPC 服务的描述信息,通过他结合我们的框架,使用指令自动生成测试代码用例。在这里我们框架使用了 gRPC 中的测试 bufconn 构造一个 listener,这样就可以在测试中不关心 gRPC 服务的 ip port。
以下是我们通过工具生成的单元测试代码,我们业务人员只需要在红框内填写好对应的断言内容,就可以完成一个接口的单测。
2.2.3 简单高效做单元测试
目前单元测试大部分的玩法,都是在做解除依赖,例如以下的一些方式:
- 面向接口编程
- 依赖注入、控制反转
- 使用 Mock
不可否认,以上的方法确实可以使代码变得更加优雅,更加方便测试。但是实现了以上的代码,会让我们的代码变得更加复杂、增加更多的开发工作量,下班更晚。如果我们不方便解除依赖,我们是否可以让基础设施将所有依赖构建起来。基础设施能做的事情,就不要让研发用代码去实现。
以下举我们一个实际场景的 MySQL 单元测试例子。我们可以通过 docker-compose.yml,构建一个 mysql。然后通过 Ego 的应用执行 job。
- 创建数据库的表./app --job=install
- 初始化数据库表中的数据 ./app --job=intialize
- 执行 go test ./...
可以看到我们可以每次都在干净的环境里,构建起服务的依赖项目,跑完全部的测试用例。详细 example 请看 https://github.com/gotomicro/go-engineering。
2.3 部署阶段
2.3.1 注入信息
编译是微服务的重要环节。我们可以在编译阶段通过-ldflags
指令注入必要的信息,例如应用名称、应用版本号、框架版本号、编译机器 Host Name、编译时间。该编译脚本可以参考 https://github.com/gotomicro/ego/blob/master/scripts/build/gobuild.sh:
go build -o bin/hello -ldflags -X "github.com/gotomicro/ego/core/eapp.appName=hello -X github.com/gotomicro/ego/core/eapp.buildVersion=cbf03b73304d7349d3d681d3abd42a90b8ba72b0-dirty -X github.com/gotomicro/ego/core/eapp.buildAppVersion=cbf03b73304d7349d3d681d3abd42a90b8ba72b0-dirty -X github.com/gotomicro/ego/core/eapp.buildStatus=Modified -X github.com/gotomicro/ego/core/eapp.buildTag=v0.6.3-2-gcbf03b7 -X github.com/gotomicro/ego/core/eapp.buildUser=`whoami` -X github.com/gotomicro/ego/core/eapp.buildHost=`hostname -f` -X github.com/gotomicro/ego/core/eapp.buildTime=`date %Y-%m-%d--%T`"
通过该方式注入后,编译完成后,我们可以使用./hello --version ,查看该服务的基本情况,如下图所示。
2.3.2 版本信息
微服务还有一个比较重要的就是能够知道你的应用当前线上跑的是哪个框架版本。我们在程序运行时,使用 go 里面的 debug 包,读取到依赖版本信息,匹配到我们的框架,得到这个版本。
然后我们就可以在 prometheus 中或者二进制中看到我们框架的版本,如果框架某个版本真有什么大 bug,可以查询线上运行版本,然后找到对应的应用,让他们升级。
2.3.3 发布版本
发布配置版本,我们在没有 Kubernetes 的时候,不得不做个 agent,从远端 ETCD 读取配置,然后将文件放入到物理机里,非常的繁琐。而使用 Kubernetes 发布配置,就会非常简单。我们会在数据库记录配置版本信息,然后调用 Kubernetes API,将配置写入到 config map 里,然后再将配置挂载到应用里。
发布微服务应用版本,因为有了 Kubernetes 就更加简单,我们只需要发布系统调用一下 deployment.yml 就能实现,应用的拉取镜像、启动服务、探活、滚动更新等功能。
2.4 启动阶段
2.4.1 启动参数
EGO
内置很多环境变量,这样可以很方便的通过基础设施将公司内部规范的一些数据预设在Kubernetes
环境变量内,业务方就可以简化很多启动参数,在dockerfile
里启动项变为非常简单的命令行:CMD ["sh", "-c", "./${APP}"]
2.4.2 加载配置
我们通过 Kubernetes configmap 挂载到应用 pod,通过框架 watch 该配置。在这里要提醒一点,Kubernetes 的配置是软链模式,框架要想要监听该配置,必须使用 filepath.EvalSymlinks(fp.path) 计算出真正的路径。然后我们就可以通过配置中心更改配置,通过 configmap 传递到我们的框架内部,实现配置的实时更新。
2.4.3 探活
首先我们探活的概念。
- livenessProbe:如果检查失败,将杀死容器,根据 Pod 的 restartPolicy 来操作
- readinessProbe:如果检查失败,Kubernetes 会把 Pod 从 service endpoints 中剔除
转换成我们常见的研发人话就是,liveness 通常是你服务 panic 了,进程没了,检测 ip port 不存在了,这个时候 Kubernetes 会杀掉你的容器。而 readinessProbe 则是你服务可能因为负载问题不响应了,但是 ip port 还是可以连上的,这个时候 Kubernetes 会将你从 service endpoints 中剔除。
所以我们 liveness Probe 设置一个 tcp 检测 ip port 即可,readness 我们需要根据 HTTP,gRPC 设置不同的探活策略。
当我们确保服务接口是 readness,这个时候流量就会导入进来。然后在结合我们的滚动更新,我们服务可以很优雅的启动起来。(liveness、readness 必须同时设置,而且策略必须有差异,否则会带来一些问题)
2.5 调用阶段
我们在使用 Kubernetes 的时候,初期也使用最简单的 dns 服务发现,他的好处就是简单方便,gRPC 中直接内置。但是在实际的使用过程中,发现 gRPC DNS Resolver 还是存在一些问题。
gRPC DNS Resolver 使用了 rn 的 channel 传递事件。当客户端发现连接有异常,都会执行 ResolveNow,触发客户端更新服务端副本的列表。但是当 K8S 增加服务端副本时,客户端连接是无法及时感知的。
因为 gRPC DNS Resolver 存在的问题,我们自己实现了 Kubernetes API Resolver。我们根据 Kubernetes 的 API,watch 服务的 endpoints 方式,实现服务发现。
我们再来梳理下微服务在 Kubernetes 的注册与发现的流程,首先我们服务启动会,探针会通过 ip port 检测我们的端口查看我们是否是活的,如果是活的就说明我们的 pod 已经跑起来了,然后会通过探针访问我们 gRPC 服务的 health 接口,如果是可用的,这个时候 Kubernetes 会将我们这个服务的 pod ip 注册到 service endpoints,流量就会随之导入进来。然后我们的客户端会通过 Kubernetes API Watch 到 service endpoints 的节点变化,然后将该节点添加到它自己的服务列表里,然后它就可以通过 Balancer 调用服务节点,完成 RPC 调用。
由于篇幅较多,以上介绍了微服务生命周期的一部分,下期我们在介绍微服务治理中的监控、日志、链路、限流熔断、报警、微服务管理等内容。以下是 ego 架构图和研发生命周期的全景图。
资料链接
- Ego 框架:https://github.com/gotomicro/ego
- PPT:https://github.com/gopherchina/meetup/blob/master/XiAn/20210911/石墨文档Go在K8S上微服务的实践-彭友顺.pdf
- 文档:https://ego.gocn.vip
- 编译:https://ego.gocn.vip/micro/chapter1/build.html
- 链路:https://ego.gocn.vip/micro/chapter2/trace.html
- 限流:https://ego.gocn.vip/frame/client/sentinel.html
- 日志:https://ego.gocn.vip/frame/core/logger.html
- docker-compose 单元测试,protobuf 统一错误码:https://github.com/gotomicro/go-engineering
- proto 错误码插件:https://github.com/gotomicro/ego/tree/master/cmd/protoc-gen-go-errors
- proto 单元测试插件:https://github.com/gotomicro/ego/tree/master/cmd/protoc-gen-go-test