AppsFlyer将API网关服务从Clojure迁移到Golang(28)

发布于2019-04-21 20:34:16

本文要点

AppsFlyer是一个领先的移动归因和市场分析平台,每天处理近700亿次HTTP请求(大约每分钟处理5000万次请求),使用微服务架构风格构建。系统的入口点是一个被称为API网关的关键任务(非微型)服务,它封装了所有前端服务。本质上,就是将客户流量通过单点路由到我们的后端服务,这极大地简化了客户端的身份验证和授权,但同时也可能导致单点故障。

本文探讨了工程团队为什么以及如何从基于Clojure的API网关实现迁移到基于Go的实现。

API网关的技术债务日益增加

我们之前已经讨论过技术债务是如何产生的,并且这种情况经常发生,就像我们的API网关服务那样。

最初,AppsFlyer的服务是一个Python单体,它需要一个身份验证和授权解决方案作为这个单体的一部分。随着时间的推移,流量和复杂性不断增加,我们迁移到了微服务架构。因此,我们需要创建一个统一的API网关解决方案,它将作为我们的身份验证和授权提供者。

我们稍微做了下准备,就开始用Clojure编写,跳过了设计阶段,以概念验证模式构建服务。我们公司是EMEA最大的Clojure工场之一,因此,在很多情况下,Clojure都是默认选择的语言,无需考虑手头的具体项目。虽然这有利于提高速度,符合“把事情做完”的心态,但对于项目的长期维护来说就不那么理想了。随着流量的增长,我们很快意识到——新推出的API网关的代码太复杂了,需要不断地重构以支持所需的吞吐量。

我们最终到了需要做出抉择的时候,服务太不稳定了,我们意识到,我们需要完全重写这个项目——要么用Clojure(但是要有更好的设计),要么探索其他语言选项。通过这次迭代,我们决定抛弃我们的认知偏见,不再回到我们的Clojure舒适区,而是做恰当的设计工作来构建我们需要的服务,而不仅仅是重新构建我们已经拥有的服务。

我们最终选择了Golang作为这个Clojure API网关服务的比较基准,这也额外带来了语言多样性的好处,而且,掌握额外的语法有益于我们的代码技艺心态。

我们理解了向堆栈中添加另一种编程语言的另一面。我们是CI/CD思想的忠实信徒,我们引入了一种新的语言,它不是基于JVM(与Clojure完全不同)的,这有其运维成本,但是我们能够在短时间内解决这个问题。

当然,掌握一门新语言也有学习曲线,而且需要确保代码长期来看足够优秀健壮,在使用特定的语言实际编写第一个项目,并看一下它在生产中的表现之前,这很难知道。

我将简要说明下为什么我们选择Go来提供这个特定的服务(仅供参考)。作为一种过程式异步语言,Go为我们提供了函数式编程(我们已经在内部使用)、面向对象功能和更好的扩展。它是一种类型化语言,使得维护变得更加容易,而且不需要重新发明轮子。尤其是对于我们打算重写的服务,它有一个得天独厚的优势,就是内置了一个经过实践证明的反向代理。作为同步语言,Clojure非常适合多线程和并发,而事实证明,这个特定的服务有很大的I/O开销。

评估我们的选项

我们知道,为了能够恰当地评估不同语言的适应性,我们需要进行几个方面的检查——每种语言的性能以及对于手头的具体任务所带来的特定好处。要测量性能,我们知道,我们需要在尽可能接近生产的环境中对Clojure和Go进行适当的基准测试。

为此,我们开始做压力测试,我们把NGINX(经过Lua增强)作为一个选项,同时使用了Golang和Clojure。Go比Clojure提供了更高的吞吐量。

以下是有关测试的基本统计数据:

代理方案 每秒请求数 每秒事务量 总请求数 总事务量 错误请求 平均延迟
直连 190 72 MB 34500 12.8 GB ~ 400 (drop:200) 4.41 Sec
NGINX 185 73 MB 33486 12.7 GB ~ 300 (drop:37) 7.95 Sec
Clojure(基本Http-Kit 实现) 190 72 MB 34412 12.8 GB ~ 100 (drop:600) 8.48 Sec
Golang(原生反向代理&http层) 185 73 MB 33443 12.7 GB ~ 200 (drop: 0) 5.42 Sec

除了上述这些结果之外,从语法的角度来看,Go也提供了额外的好处,作为一种类型化语言,它容易更新和迭代,而且更容易通过现有的包和库进行扩展(许多东西不用从头开始写),从功能的角度来看,其内核内置的反向代理组件是一个重要的好处。

我们放弃使用Clojure重写服务,避免走捷径去复制过时的代码,践行类型化思维方式。

在设计阶段,首先列出服务需要提供的功能,在确定好基本概念之后,我们研究了在将我们的生产用户迁移到新服务时的向后兼容性问题和潜在陷阱。一旦我们能够确定我们已经覆盖了所有的主要部分,我们就开始为项目分配架构师和开发人员,并开始相关工作。

从概念到交付

我们很惊讶,这么快就完成了项目的一部分编码,大约只需要两个月的工作。因为这是我们第一次将Go引入内部,我们在进行项目这部分的编码时很小心。对于每个功能,我们做了两次迭代,以确保我们做对了,而且,我们还做了大量的代码评审。这是因为我们知道这段代码必须精雕细琢,干净利落,因为这将作为后续其他Go项目的一个基础。

尽管这是我们引入的第一个Go项目,但我们有机会好好掌握这门语言及其核心功能,因为我们必须弥补Clojure在与栈中其他部分如Redis(用户登录计数器的持久状态,预防DDoS和bot病毒)和Kafka(我们管理着一个域事件CQRS,其中一个是成功或不成功的登录)进行通信时所使用的库的不足,这需要我们在Go中创建类似的库。Clojure这种语言需要我们的应用程序编译为Java字节码,并且,作为一个先决条件,需要在我们作为部署目标的机器上运行JVM,而Go是一种静态编译语言,不需要在一台机器上运行一个VM。

为了实现开箱即用的功能,如CPU和内存使用情况指标、业务逻辑计数器等等——我们基本上需要从头开始编写整个栈,这使我们可以更快地深入到Golang的错综复杂之处。

在大约两个月之后,我们做好了基本的迁移准备,覆盖了基本的功能和测试。我们开始在父节点组(域组)中以可控的方式将服务迭代地迁移到新的API网关,基本上是一次金丝雀发布。

在这个过程的最初几个周里,我们决定以可控的方式推出第一批的几个服务,这样,我们就可以发现生产中的错误和缺陷,并且有时间在推出我们所有的服务之前修复它们。在从最初的API解决方案迁移时,由于速度过快而导致了错误,并最终导致了低质量交付,我们希望吸取教训。

一旦我们觉得自己已经准备好并修复了所有的缺陷,我们就开始了所有服务的迁移计划。这包括每个服务的迁移指南PDF,其中包括迁移到新服务所需的具体步骤,这一举措的好处,以及根据具体的栈和依赖的不同确定执行迁移的最优方法。

为了以循序渐进的方式推出一个新的反向代理,我们使用了一个应用程序负载均衡器(ALB)基于一组预定义的URL来进行流量路由,这些URL是我们想要通过新旧API网关暴露的服务。

这为我们如何以最小的努力和风险路由流量提供了一种非常可控的方法。我们把我们的时间用在了测试每个要迁移的服务以及与其他负责用户服务的团队的协作上。我们花了六个月的时间,但我们设法使大约40个微服务在零停机的情况下迁移到了新的API网关。

结果

最终的结果是,我们从25个运行Clojure代码的实例(c4 xlarge)——能够处理60个并发请求——减少到两个运行Go代码的实例(c3.2xlarge),大约能够支持一分钟5000个并发请求,这是一个巨大的改进。对于我们下一阶段的增长,新的架构设计也是一个足够健壮的解决方案,它给我们提供了一个功能强大的服务,得益于其过程式方法,可以承受业务复杂性的大规模增长,同时,它也为我们处理大规模业务的工具箱添加了一门新语言。

让我们通过例子看一下Clojure和Go中的反向代理解决方案。

Clojure中:

;; Creating a connection manager


(let [cm (clj-http.conn-mgr/make-reusable-conn-manager {:timeout 1 :threads 20 :default-per-route 10})])


;; Creating a proxy server using cm (connection manager)
 (client/request {:method	:get
                           :url	(service/service-uri service-spec uri-match)
                           :headers	(dissoc (into {} (:headers req)) “content-length”)
                           :body	(when-let [len (get-in req [:headers “content-length”])]
                                                     (bs/to-byte-array (:body req)))
                           :follow-redirects   false
                           :throw-exceptions   false
                           :connection-manager cm
                           :as	:stream}))

Golang中:

func NewProxy(spec *serviceSpec.ServiceSpec, director func(*http.Request), respDirector func(*http.Response) error, dialTimeout, dialKAlive, transTLSHTimeout, transRHTimeout time.Duration) *MultiReverseProxy {
	return &MultiReverseProxy{
		proxy: &httputil.ReverseProxy{
			Director:       director, //Request director function
			ModifyResponse: respDirector,
			Transport: &http.Transport{
				Dial: (&net.Dialer{
					Timeout:   dialTimeout, //limits the time spent establishing a TCP connection (if a new one is needed).
					KeepAlive: dialKAlive,  //limits idle keep a live connection.
				}).Dial,
				TLSHandshakeTimeout:   transTLSHTimeout, //limits the time spent performing the TLS handshake.
				ResponseHeaderTimeout: transRHTimeout,   //limits the time spent reading the headers of the response.
			},
		},

请注意,Golang的许多特性都是为了更好地管理连接池以及将反向代理功能集成到其核心类中。

总结

事实是,使用类型化语言构建使我们能够借助Golang的库支持和社区的力量更轻松地插入各种功能并引入新技术。对我们来说,从性能和吞吐量的角度来看,最重要的部分是改善我们的客户体验。新部署的解决方案能够支持比现在多得多的流量——随着我们的流量和请求以10倍的规模增长,从前瞻性的角度来看,这是非常重要的。

关于作者

Asaf Yonay是AppsFlyer研发组经理,非常乐于接受管理和技术挑战,在其中加入人的元素,把它们变成成功的故事。Asaf坚信,可以通过定义流程帮助研发团队成长和发展,而又没有速度损失,他始终在迎接全栈的挑战——相信这可以让经理进化为领导者。他一直在初创企业中承担不同的角色,包括技术支持、QA和各种研发角色,使用Clojure、Golang、Node.js和Python构建可伸缩的、可靠的系统来支撑React和Angular服务,并使用Kafka、Aerospike和Neo4J处理大规模或复杂的业务逻辑状态。

查看英文原文:Rewriting an API Gateway Service from Clojure to Golang: AppsFlyer Experience Report