本文由 客路 KLOOK 基础架构组高级开发工程师韩金明在 Gopher Meetup 深圳站的演讲整理而成,主要介绍 KLOOK 微服务治理框架的落地实践,关注公众号回复KLOOK可得ppt。内容包含:
-
自研配置和注册中心的实现;
- RPC 落地过程中的实践经验。
No.1
KLOOK 微服务治理实践
简单介绍一下,KLOOK 为旅行者提供简单便捷的自由行玩乐预定,包括全球景点门票、一日游、特色活动、美食、交通及 Wi-Fi。KLOOK 微服治理包括很多东西,此次主要介绍支撑了稳定的服务治理平台的自研配置中心和注册中心的实现,以及 RPC 落地过程中的实践经验。
KLOOK 2015 年开始使用 Go 语言,我们非常了解 Go 语言的特性,喜欢 Go 的简洁、高效、健壮;代码没有特别的黑科技,易于理解、可控,很棒的生产力,所以我们用 Go 实现了配置中心和注册中心,它支持我们稳定的服务治理平台。另外,我们升级基础框架过程中也落地了 RPC,其中有一些值得一讲的落地实践。
No.2
配置中心
2.1 配置中心&注册中心 - 背景
最开始,我们用业界常用的 Consul 实现服务配置和服务注册与发现。Consul 作为服务配置中心解决了什么问题呢?假设你有几十上百个微服务实例部署在不同的服务器,每个实例都有相关的配置文件,那么查看或修改配置的时候需要登录机器去操作。用 Consul 作为服务配置中心的话,服务启动时从 Consul 中同步配置,通过配置 watcher 回调触发服务配置热更新。这样你可以在一个地方去查看或修改服务配置并生效,而不用一个个去登录机器去查看或修改配置文件。
那么服务注册与发现有什么用呢?服务启动时,将服务信息如服务名、IP 端口号、健康检查路径、HTTP 路由前缀等相关信息注册到 Consul 中,服务关闭时从Consul 中反注册,Consul 会定期调服务健康检查接口进行健康检查,Fabio 就可以从 Consul 中获取到路由前缀和健康的健康的服务实例的 IP 端口号的对应信息,发现新服务实例,作为动态路由的 HTTP 负载均衡器。
2.2 配置中心 - 背景
Consul 很好用,当时我们用版本 0.8.1,出现了一次集群不可用: 经常出现无法获取及更新配置。一看 Consul 日志全是 snapshot 出错,查 GitHub 发现有一个 Issue 描述和我们的很像,但又不敢随便升级。踩了这么个坑之后,我们陷入深深思考,服务直连 Consul,Consul 的维护后面会是一个问题,从长远来考虑,需要实现一个更稳定更可控的配置同步方案。
2.3 配置中心 - 实现
首先充分理解适合我们应用场景的配置中心应该具备怎样的功能?当时服务用 Consul 都在运行,要能从 Consul 中平滑地迁移。第二我们更关注可用性,不能出现配置中心不可用出了问题不好解决的情况。其次,可以有更好的配置管理体验。
配置中心的架构如图, 服务启动时,会调配置中心的接口,配置中心会从 DB 中获取指定前缀的KV列表,然后服务使用配置中心的 SDK 反序列化到配置对象的字段中。配置更新通常来自于服务治理平台,在界面上进行更新操作,其调配置中心的接口,配置更新后发送审计邮件,如果是热更新的话,则回调给相关的服务实例,服务收到回调后会重新拉取配置。
怎么从 Consul 中平滑迁移的呢?服务一开始是直接连Consul的,第一版的配置中心相当于是一个Consul的中间件,把服务直接连Consul同步配置改为调配置中心的接口,配置中心再与Consul交互。将所有服务同步配置的代码逻辑改为经过配置中心之后,将Consul的所有配置项kv同步到DB中,然后将配置项kv读写切换到DB,这样就完成了从Consul平滑迁移。这样一个配置中心是高可用的,主要体现在: 配置存储使用MySQL,一般规模下MySQL RDS的高可用方案是非常成熟的,配置中心自身可以在多个可用区部署多个节点达到高可用。
那么怎么更好地进行配置管理呢?我们在服务治理平台上面写了这样一个用于配置查看及修改的界面,界面上的service root config、instance id、config section、config key,这几部分编码成了真正的key,value就是配置值。config key与配置结构体标签StructTag一一对应,其中利用了Go EmbedStruct匿名结构体的特性达到分类的效果。
有这样一个平铺查看修改界面之后就可以很方便地实现key/value查找功能,比如查找包含某个关键字key,包括某个关键字的value,可以很好地实现权限控制功能,可以控制每个有权限的人可以查看及修改配置的服务,可以实现修改记录审计功能,实现comment的功能。
实现配置热更新时,我们综合比较了回调、消息队列和长连接三种方式,最后选择了回调方式。框架内统一注册内部的HTTP接口,接收回调处理配置更新。相比消息队列方式,能知道更新的结果。相比长连接方式,减少了连接管理的复杂度及管理连接的开销。
2.4 配置中心 - 实践经验
通过自研配置中心,获得了一个运行稳定的配置中心,很好地支撑了服务治理的相关功能,也获得了一些实践经验。
首先,运行稳定,简单可控很重要,所谓可控就是指能很好地清楚其内部行为和机制,必要时可以去修改其内部机制。配置中心服务的实现代码很简单,我们自己实现的完全清楚其内部行为和机制。
第二,中间件模式扩展性很好,配置中心的很多功能比如加密以及审计都是基于自研了这样一个中间件的基础上扩展的,基于此,无论kv存储使用DB还是Consul还是Etcd,都可以,取决于哪个更好维护。使用一个资源的时候,一般要进行封装,以便后期能更好地加功能以及更换底层实现,当封装的代码多了,也就该进化成中间件服务了。
第三是服务命名规范很关键,服务命名统一,配置中心、注册中心、日志系统、监控系统、限流系统等等一系列的基础组件和中间件都将会受益,这个受益体现在基础组件和中间件的SDK能快速平滑地实现功能,并且好维护。
这里有个利用了Go语言导入包的特性和init函数执行特性的技巧: 可以在服务代码中放置一个统一风格的服务名常量,在init函数中设置到公共库中,这样公共库的其它SDK就可以取到当前的标准服务名。我们知道Go里面的init函数是根据导入包的依赖关系按顺序执行的,init函数通常用于包的轻量初始化,可以限制只设置一次服务名,达到禁止跨服务引用的效果。
No.3
注册中心
3.1 注册中心 - 背景
自研配置中心后我们去掉了Consul,同时也把负责HTTP负载均衡的Fabio切换到了AWS ELB,我们花费了一些运维成本获得了比较稳定的HTTP负载均衡功能。这也带来了两个问题,服务的健康状态怎么去及时获取呢?RPC的服务注册与实现怎么实现呢?
3.2 注册中心 - 实现
所以我们自研了注册中心,注册中心的工作示意图是这样的,服务启动时通过调用一个gRPC接口,将自身信息比如服务名、IP端口号、健康检查路径等信息注册到注册中心,接收到原始请求的注册中心节点会将其写入到DB,然后依靠健壮的RPC调用广播至Peer节点,其中利用了Go channel串行处理的特性来保证服务注册的更新一致性。服务注册后,注册中心会每隔五秒对服务实例进行健康检查,当服务实例返回200的时候表示服务健康,当服务实例返回501的时候表示服务实例正在下线或者服务实例正在关闭,当检查报错的时候如connection refuse/timeout时,表示这个服务实例已经不健康了。
注册中心的核心就是实现一个watcher,当服务健康状态变更的时候能感知到。那么怎么用GO实现一个watcher呢?watcher可以看作是一个可以往里面发送数据的channel,用map作为watcher管理器去管理这样的多个channel,然后可以开个协程去感知ctx的Done信号,ctx cancel了之后将这个watcher从watcher管理器中移除掉。当服务的健康状态变更的时候就可以往这些watcher发送相关信息,watcher感知到后重新去获取相关服务健康的IP端口号列表,这是服务内的watcher,gRPC stream双向流特性使得能够很好实现服务之间的watcher,那么注册中心可以作为一个grpclb server,客户端使用grpclb进行服务发现与负载均衡。
3.3 注册中心 - 实践经验
自研注册中心的收益比预期的要大。
首先,服务注册时可以加一个连续两次注册的规则,可以发现服务的意外重启,找到难以发现的fatal或者没有recover的panic,当我们服务出现这种情况的时候,就会收到一个这样的报警,表明这个服务可能发生了crash。
那么Go服务什么情况下会出现crash呢?开协程,协程里面没有recover而做了引发panic操作比如空指针、slice下标越界、除0,或者是一些没办法recover的底层错误,比如map并发读写、stack overflow、out of memory以及调用runtime.throw,有这样一个机制就能够很好地发现一些常规方式不好监控到的意外重启crash的问题。
第二个是服务注册可以加一个简单的注册频率检查规则,能及时发现服务启动异常被进程管理工具拉起出现无限重启的情况。
第三个是服务自身可以拿到更多有利于服务治理的metadata注册到自研注册中心,展示在服务治理平台,如git_hash版本、startup_time启动时间、cmd_args命令行参数、timezone时区、user_group用户组、work_dir 工作目录,其中git_hash是编译时用ldflags注入的。
第四个是为服务部署提供健康百分比检查,可以拦截到导致全部实例不健康的操作。通常我们服务会部署多个实例以便能更好进行平滑地重启和发布,在发布过程中,假如代码有问题,三个实例中已经有两个最新部署的实例不健康了,注册中心能够及时感知到服务健康状态变更,再结合部署的检查逻辑,能拦截掉最后一个实例的部署,避免服务不可用的情况发生。
No.4
gRPC落地
4.1 gRPC落地 - 一些关键的事情
4.1.1 思路-端口多路复用cmux
我们升级基础框架过程中,也落地了gRPC,落地过程中我们做了很多有意思的事情,使从写HTTP代码到写RPC代码迁移方便。首先是思路,第一个是端口多路复用cmux,我们在研究Etcd的时候看到了cmux,一个端口同时serve HTTP server和gRPC server。cmux的原理是在accept时判断,对于长连接应用的开销很小。端口复用解决了落地gRPC如果要两个端口带来的一系列运维上面的以及服务注册的问题。
4.1.2 思路-grpclb
第二是grpclb,grpclb的思想非常简洁巧妙,使用gRPC来进行gRPC的负载均衡,它的核心是通过这样一个双向流方法,客户端在首次连接服务端的时候,先去跟grpclbserver进行交互,告诉它要调用的服务名,然后grpclb server找到这个服务名对应健康的IP端口号列表返回给客户端,客户端进行客户端侧的负载均衡。这就是为什么说gRPC虽然没有服务治理的功能但是有很好的服务治理的接口,这样注册中心无论是用Consul还是Etcd还是自研,都能很好实现这样一个的grpclb接口。最近grpc-go也正在将grpclb升级到使用envoy的xDS API中。
4.1.3 封装-业务errorCode处理
下一个是封装。gRPC的statusCode和HTTP的statusCode一样,不太适合承载复杂的业务错误码,但它提供了StatusDetail的功能,允许你以protobuf message的形式去附加更多的状态信息,所以我们基于此进行封装。这是我们封装的一个server handler返回的error返回成一个RPCError的包内工具函数,考虑了各种常用的error类型,对于业务方使用者来讲,只需要在handler中return一个实现GetCode和GetMessage的error类型就可以,如果你不关注code,则直接返回error类型就可以了,大大减轻了业务方处理错误的复杂度。
4.1.4 封装-自定义pb类型
下一个是自定义pb类型,我们知道pb只有基本的数据类型,对于时间类型支持是不够好的,虽然有well-know类型google.protobuf.timestamp,但是它是一个绝对的时间值不包含时区,不能跟time.Time完全对应起来的,因为time.Time是包括时区的。时区对我们业务是非常重要的,所以我们包装了time.Time类型,实现ProtoMessage的相关方法,使得可以支持pb序列化与反序列化使用time.RFC3339格式来序列化与反序列化。这里很好地体现了Go语言所谓面向接口编程的特性,自定义pb类型只需要实现相关的几个方法,就能以自定义的方式来序列化与反序列化,我们用这种思路也将其我们常用的Decimal类型增加了protobuf的支持以便业务方能很好地迁移。
4.1.5 工具-struct2proto
第三个方面是工具。对于之前使用HTTP的时候写的大量的结构体,如果要转成gRPC的话,意味着你要把之前的结构体转化成proto message。这是一个枯燥的重复工作,容易出错,出错后比较麻烦,所以我们写了个工具struct2proto,利用Go自带AST Parse的特性去解析AST,可以把指定package内的所有结构体和type int的枚举值转换为protobuf message,当然其中也会处理自定义类型。对于生成的代码, 可以很好地利用Go1.9引入的type alias语法,将旧的结构体名指向新的结构体。
4.1.6 工具-protoc-gen-krpc
protoc-gen-krpc这是我们落地gRPC的一个重点工具,我们知道,protobuf提供了插件机制,允许你拿到proto元信息,写插件定义自己生成代码的逻辑。我们基于此定制了自己的生成代码工具,这种方式定制的控制力很强,它能达到怎样的功能呢?第一个功能, 它能更好地调试RPC方法,生成的RegisterXXX的方法其实会调用我们自己封装的包的方法之后,再调用gRPC包里面的方法,这样就能拿到gRPC方法的元数据,以便框架上对于dev环境注册HTTP Endpoint使得能以POST JSON的方式调用RPC方法,非常方便。相比其它方式,grpcurl需要提供import的proto文件,显然用起来是比较麻烦的,gRPC生态越来越好了,最近出现了像BloomRPC这样的类似Postman的GUI工具,在非命令行环境是很好用的。
可能有人会问为什么要改名呢?如果不改名,可能有的人用的是修改版的生成代码工具,有的人用的还是原版生成代码工具,那么对于同一份proto文件使用同一个生成代码的命令指令产生的代码却不一样不兼容,这显然是难以接受的,所以改了名字有利于维护。
第二个功能,可以加入方便的自定义 CallOption,比如我们调用一个 RPC 方法时,带上 krpc.CallOptionLogAll,就会在这一次 RPC 方法调用的时候,把请求、响应、metadata 都打印日志。在性能要求不高的场景下,打印请求响应是很实用的功能。CallOption 还包括 logRequest 打印请求,logResponse 打印响应,logMetadata 打印 meta,EnableTracing 启用 opentacing,Timeout 超时时间, extraMetadata 额外的元信息.
第三,对于需要扩大吞吐量的情况可以很方便支持多个连接。我们知道,grpc.ClientConn 是一个连接管理器,对于每个 backend target 默认内部只会建立一条TCP连接,grpc transport 在一个TCP连接上用http2 stream的特性进行多路复用,性能很好,大多数情况一个连接就够了。我们在自己测试的过程中发现,当提高一点点连接数的话,吞吐量会有一些提升,所以对于有的需要更高的吞吐量的场景,需要多个连接去支持,如果要去管理多个 grpc.ClientConn 的话,显然是有点麻烦的,我们通过生成代码的插件做一些调整,就能很好地去支持多个连接: 简单配置一下,可以支持对于某一个服务使用多个连接。
4.2 gRPC落地 - 实践经验
4.2.1 实践经验
我们落地 gRPC 过程中也遇到了一些问题,其中有一些问题也是在深入研究了 grpc-go 的特性之后才得以解决的,第一个问题, 如何解决 grpc steam 服务负载不均衡的问题呢?首先你要部署三个节点,因为你如果部署两个,更新时,最后更新那个,可能很长时间内都不会收到请求。如果你部署三个,起码可以保证你有两个负载是比较均衡的。第三个怎么负载均衡呢?可以很好地利用 keepalive 里面的参数,keepalive 里面有个 maxConnAge 参数,这个参数是表示连接最大的生命时长,到了这个时长之后,这个连接就处于 Draining 状态,不会再建立新的 stream,然后等待已有的 stream 关闭了之后,连接就可以进行安全地被关闭掉。stream handler 可以设置一个比 maxConnAge 稍微大一点的超时时间,过了 maxConnAge 时长,再稍微过一点时间,stream handler 超时进行优雅关闭。stream 客户端通常会进行重试,客户端重试的时候就会运用一次负载均衡算法重新选择一次服务端节点,这样就达到了过了 maxConnAge 时长之后负载均衡的效果。
第二个问题是 stream 方法如何优雅退出呢?这也是 grpc-go issue 里面经常被问到的一个问题,其实可以很好利用 Go closed channel pattern 通知的特性,在调用 (grpc.Server).GracefulStop 前去 close 一个 channel,然后你的 stream 方法的 handler 去select 这个 channel,这样服务就能感受到服务正在关闭,stream handler 接收到这个channel closed的信号之后可以做一些优雅关闭的清理动作,(grpc.Server).GracefulStop 会 Block 正在进行的 stream 结束。
第三个,我们在封装过程中大量使用了 Functional Options,这种模式很优雅,很适合 GO 封装友好的 API,GO 语言大师 Dave 在2014年的 GopherCon 上讲了这个主题,并且写了一篇详细的博客。Functional Options 的核心思想在于定义一个配置项结构体,设置一个变量作为默认配置,Functional option 就是这样的一个函数: 它的函数签名是接收这个配置项指针,函数体是修改其中的某几个字段。那么多个 option 函数就按顺序应用到这个配置对象中,你可以在包里面去预定义一些自己想要的一些 option 函数,我们生成的 RPC 代码的函数签名最后一个参数是可变参数 CallOption,调用方在使用的时候就能很方便去附加多个自定义的 CallOption。
4.2.2 落地gRPC后的效果
我们落地 gRPC 之后,相比于 HTTP 方式,大大减少了调用方的代码量,提高开发效率和运行效率。gRPC 的 transport failover 是很有趣的特性: 在 HTTP 方式下,假如负载均衡器后有两个服务实例,在调用的时候其中一个 server 突然挂了,那么后面几个请求会出现 502 的情况,用 gRPC 的话,连接是有状态的,这个连接一旦断开,transport 会 reset,重新选择一个健康的 backend target,而不是出现 502。gRPC 的 transport failover 也得调用更加健壮,所以我们大量在基础设施和业务上使用 gRPC。
No.5
核心总结
最后进行核心总结,自研配置中心的核心在于配置项和 KV 之间的编码设计,我们也体会到了中间件模式带来的扩展性,体会到了统一的服务名带来的收益。自研注册中心的核心是去实现一个 Watcher,注册中心也提升了服务监控的维度。gRPC 的落地核心在于利于落地的封装以及相应生态工具链的建设。当中涉及到很多 GO 语言的特性,比如配置中心的KV跟配置结构体字段使用的 StructTag 一一对应,利用反射来将一个个 KV 反序列化到配置对象字段中,利用 EmbedStruct 的特性来达到配置项分类的效果,统一服务名的过程中利用了 init 函数执行的特性和导入包的特性去向公共库中注册统一风格的服务名,注册中心中利用了 Go channel 来串行处理,利用 Go channel 的特性来实现 Watcher,使用 Go build flag 向注册中心注册 git_hash,自定义PB类型很好地体现了 Go 的 Interface 的特性面向接口编程,利用 closed channel 的特性来感知服务正在关闭实现优雅关闭的机制,利用了 Go 自带的 AST 去将原来的结构体转化成 proto 文件,然后利用 TypeAlias 的语法将原来的结构体迁到新的结构体,FunctionalOptions 是利用 Go 的函数作为一等公民可以当做参数传递的特性的综合应用。
用好这些特性,生产力就来了。
Q&A
提问:我想知道这个架构是在 K8s 的基础上部署的吗?
韩金明:不是,这一套架构是在普通的机器方式部署的,当然我们现在已经在进行容器化了。
提问:刚刚听到你说一个节点崩溃的时候,你那个注册中心是可以捕获到的,我想请问一下你们对于无限递归导致的崩溃是怎么捕获的呢?就是业务人员写了一个无限递归导致了程序崩溃。
韩金明:这种情况下由于函数使用的栈是有最大限制的,出现这种情况,这个程序就会崩溃。
提问:这种类型的崩溃你们是怎么捕获到或者感知到呢?
韩金明:服务在正常启动的时候是会往注册中心注册一下,然后服务关闭的时候会进行一次反注册。假如出现了一个服务连续出现两次注册,那就说明它上一次反注册没成功, 也就是崩溃过。
提问:刚刚说到你们会把 crash 的信息上报给注册中心?
韩金明:我们知道那些服务节点有出现 crash,通常我们是用服务进程管理工具管理进程,它会收集服务的 stderr,通常来讲服务崩溃了之后,stderr就会有相关的信息。
提问:但是据我所知,由于无限递归导致的崩溃是不会输出到stderr里面去的,那这个你们是怎么捕获到这些错误信息呢?
韩金明:无限递归的时候,由于Go里面的调用栈是有最大限制的,这个时候你出现无限递归情况下一定会突破那个上限,就会出现stack overflow,它是一个不可recover的panic,导致进程崩溃,stderr有panic日志,崩溃后这个服务就没有反注册成功,启动后会再进行一次服务注册,这个时候出现了连续两次服务注册我们就能判断出服务节点出现了意外重启的情况,这个时候我们去看相关的stderr log就能看得到崩溃的原因。
提问:我想问一下你们那个注册中心是怎么实现高可用的呢?
韩金明:一方面我们存储使用MySQL,一般规模的MySQL RDS高可用方案是很成熟的,另一方面注册中心可以在多个可用区部署多个节点,每个节点都有同样的功能: 当服务注册的时候接到请求的注册中心节点会先写到DB,然后通过RPC调用的方式来顺序去广播到peer节点,所以每个节点都有相同的功能,都可以提供注册。
提问:我有两个问题,一个就是我想问一下你们注册中心存不存数据库密码,如果存的话是怎么存呢?另外一个就是你的gRPC stream服务负载不均衡这个问题为什么不考虑一下K8S负载均衡这一块呢?
韩金明:先回答第一个问题,就像利用gRPC来实现gRPC的负载均衡,我们的注册中心节点也是可以注册自己,配置中心自己的配置也是可以同步到配置中心的,我们的服务相关密码这些敏感信息是会在服务治理平台上进行隐藏,这些密码信息只有能登到相关服务所在机器权限的人才有可能看到数据库密码,我们数据库也会去设置很严格的安全组,只有在安全组内的机器才有权限连接到相关的库。
第二个,对于一个长连接stream应用来讲,它是一个有状态的,所以你不能stream里的第一个请求发到这个节点,第二个请求就马上把这个连接断开去发到另一个节点上去。我对K8S不是很了解,但是我想对于一个长连接有状态调用的负载均衡,达到负载均衡的过程也是需要有那么一点点的时间差。这个问题很好,如果有兴趣的话我们到茶歇处再讨论讨论。
提问:我从刚开始就没有很清晰的问题就是你们为什么要自研注册中心呢?你们自研注册中心的方式是完全抛弃Consul或者Etcd吗?
韩金明:现在是完全抛弃,中间有一段时间是过渡的。
提问:注册中心有两部分,一个是存储,还有一个是数据同步,我了解的是Etcd同步的DB是BoltDB,一个高速度的数据库,那你觉得Mysql比BoltDB优势在哪里呢?相当于它不是单机的,可能多个实例都在用Mysql,那这种情况下比直接在本机部署我觉得可能会慢一点。
韩金明:可以考虑一下服务注册这个过程的频率在现实中是怎么样的,对于我们公司的应用场景,只会在启动和关闭的时候进行服务注册与反注册,健康检查结果也是限制频率采样写入的。性能跟Etcd肯定是不能比的,Etcd是10万/秒的写入性能,一般MySQL是达不到这个性能的。另外我们能去自研注册中心也是因为我们没有用到选举的功能,我们目前的应用里面没有涉及到关于选举的场景,后面如果有场景涉及到的话,我觉得Etcd还是很好用的,取决于你要解决什么样的问题。
有疑问加站长微信联系(非本文作者)