用Rust替代Java重写DNS解析器


我们重写BlueCat Edge核心的 DNS 解析器的经验可以证明:Rust可以成为编写网络应用程序和服务器的不错选择。

BlueCat Edge是一个智能DNS解析器和缓存层,允许你创建自定义规则(策略),规定流量应如何处理,以及流量应转发给谁(命名空间)。同时,我们收集所有通过Edge的DNS流量的日志,并通过UI或API使其可被发现。简而言之,Edge让您对您的DNS流量有前所未有的控制和可视性。
Edge的主要逻辑存在于命名空间和策略中。命名空间是一组 DNS 解析器(例如 [1.1.1.1, 8.8.8.8])和一个域的集合,以确定何时应使用该命名空间进行匹配。或者,你可以指定一个默认的命名空间,使每个查询都能匹配。Edge的部署可以有多个命名空间,每个命名空间只有在传入的DNS消息与它配置的域列表中的一个域相匹配时才会被选择。

这种逻辑被编入我们最初的Edge DNS解析器(Java网关)的Java实现中,尽管该服务是函数性的,但它有两个主要问题需要继续解决:可维护性和性能。
我们可以不顾这些挑战,尝试改进Java网关,或者我们可以重写软件以满足我们的要求。
重写软件是一项重要的工作,决不能轻率行事。这个过程需要大量的思考、研究和计划;即使如此,也不能保证重写后的实现会超过现有代码的能力。

为了决定是改进还是重写我们的实现,我们比较了我们用Java网关的情况和我们想用Edge的DNS解析器服务的情况。

Java gateway的代码很复杂。该版本的实际代码只有不到65000行。文档是有限的,在许多情况下,原作者早已不在了。
从性能上看,该服务在其默认配置下可以处理大约每秒4000次DNS查询(QPS);我们的目标是每个物理核心10000次QPS。
此外,增加额外的CPU资源并不能使QPS有效扩展。
当然,Java能够在CPU资源的作用下,实现令人尖叫的快速和良好的扩展,但这些优化需要非常仔细的编码,许多开发人员在编写和维护方面有困难。
考虑到Java网关代码库的状况和我们可用的开发人员资源,改进我们现有的实现不是一个可行的选择。

我们的性能要求使我们考虑使用C或C++,但内存管理的开销有可能引入我们在内存安全的Java中工作时从未遇到过的问题。考虑到我们对内存安全的渴望,促使我们更深入地研究使用Rust--一种最初来自Mozilla的编程语言,现在由其自己的基金会独立领导,用于编写具有C语言性能的安全系统代码--来进行重写。

用Rust重写
我们估计,编写习惯性Rust将使我们能够实现我们的性能和可扩展性目标,而不需要将大量的开发资源用于优化。此外,在没有垃圾收集器的情况下管理内存可以减少尾部延迟,使性能更可预测。性能对于这个应用程序来说是至关重要的,因为处理过程是即时发生的。因此,我们在计算策略或命名空间规则时,每花一毫秒都会给用户的DNS查询增加延迟。

使用Rust的另一个好处是它的工具。Rust编译器提供了有用的、可读的错误信息,cargo也是一个福音:cargo测试 "只是工作"。Cargo很容易实现 "工作区 "式的设置,即在一个产生二进制文件的工作区中,项目被分为许多板块。这种设置有助于减少编译时间和组织代码。即使如此,新构建的编译时间也不是很好,可能需要一两分钟。在Mac上,如果在docker中构建,情况可能会更糟。在开发过程中使用rust-analyzer进行快速反馈有助于缓解其中的一些问题。

虽然我们最初的开发团队有不同的计算机科学背景和经验,但不是每个人都熟悉Rust。最终,尽管如此,使用Rust的理由足以让我们承诺在重写Java gateway的过程中使用它。我们很快就发现,在团队中有一个熟悉Rust的人,对于让大家加快进度和保持势头至关重要。在一个新的语言上开始一个新的项目,如果没有人指导,就会导致在学习常见的习语、生态系统和库的状态方面的缓慢上升。从开始到第一次发布,我们能够在一年内写出一个功能完整的Rust实现。

我们在Rust方面的经验
一个项目的重要基准是它是否容易加入新的开发人员。旧的Java网关代码库是很脆弱的,做一些小的功能改动往往需要花费数周的时间。
相比之下,我们让刚接触Rust的员工在一两个星期内就能上手并为corten贡献大量的新功能。
尽管如此,新加入Rust的开发人员也不是没有挑战的。
与C语言相比,Rust是一种更复杂的语言,因为它对内存管理有独特的方法。虽然习惯于Rust的借贷检查器--验证数据的寿命和所有权--最初可能会令人沮丧,但它极大地提高了代码的安全性,并带来长期的回报。
因此,我们没有观察到corten的 "segfault "崩溃,这证明了Rust编译器的代码验证的可靠性。此外,用sum和product类型表达业务逻辑感觉很直观,而且Rust的严格编译器定期通知重构,使事情顺利进行。

Corten还受益于Rust的 "无畏的并发性",我们在项目中广泛使用了tokio。
tokio原被用来从UDP和TCP中读取信息,并为每个传入的消息生成一个任务,支持在单个TCP流中进行消息的管道化。所有与给定消息相关的数据都由该任务完全拥有,所以它可以自由地改变其内容。
此外,tokio支持轻量级的任务生成,这使得一个盒子可以运行成千上万,甚至数十万的任务。
tokio-util的编解码模块可以帮助实现Stream,但是我们在corten成立之初无法使用它,因为来自tokio-util的UdpFramed需要一个完全拥有的UdpSocket,但是我们在实践中最经常需要一个Arc<UdpSocket>(之前在tokio 0.2中是ReadHalf)。这导致我们为tokio-util贡献了一个PR,改变了这种行为。
自发布以来,async-stream已经出现,以涵盖更多的情况,使用async/await语法轻松创建流。


成品
总的来说,Corten在几个方面比我们原来的Java网关实现有了改进。
Corten只有大约15000行,但通过整合几个较小的微服务,设法比Java gateway的功能更丰富。
此外,Corten在性能和可扩展性方面大大超过了我们的目标。
说实话,Rust并不是造成所有差异的唯一原因,因为你对某件事情的第二次尝试总是会吸收第一次的经验教训。
然而,应该注意的是,我们并没有试图对Rust版本进行大量的优化。
至于内存的使用,在没有域名列表的默认配置下,corten只消耗了十几MB。
一般来说,内存使用的最大决定因素是每个命名空间/政策所附的域名列表有多大,而这些列表可能相当长。然而,在启动后出现了一个值得注意的注意事项。

Rust默认使用系统分配器来管理内存:
在Linux上,这个分配器是为速度而调整的,如果它认为进程很快就会需要内存,它就不会轻易地把内存释放给操作系统。这种行为导致了与corten的意外互动,corten将域以压缩的trie形式保存在内存中,可以在操作过程中更新或重新下载和反序列化,然后替换。这些列表可以有几百MB的大小,包含了数百万个域,我们注意到随着时间的推移,内存会被消耗到某个上限。在几次更新之后,经常可以看到corten消耗接近2GB的内存,而这些内存在新开始时只需要200-300MB。

我们通过用jemalloc代替分配器并启用background_thread配置参数来解决这个问题,以牺牲几个百分点的性能为代价,更容易将内存释放回操作系统。
与Rust不同的是,JVM有一个垃圾收集器来处理压缩和释放内存,尽管对于高性能的Java应用来说,必须调整你的GC是很常见的。Java在分配域列表时可能使用了较少的内存,但如果你想走优化之路,Rust对类型的大小给出了更多的底层控制。这是Rust的一个共同主题:如果你需要更多的控制,它是以增加复杂性为代价的。

虽然 Rust 生态系统并不完美,而且升级可能具有挑战性,但这个项目取得了成功,我们希望在未来用 Rust 做更多的事情。在此过程中,我们能够为 tokio 和 trust-dns 库做出贡献。我们还最终一起破解了一些东西以满足我们的集成测试需求,但这些细节可能值得他们自己的博客文章。如果您有兴趣使用 Rust 编写网络应用程序,请随时查看我们的另一个项目dhcproto,一个 DHCP 解析器/编码器,并考虑加入我们的 BlueCat!