作为技术,尤其是后端开发,从一开始工作就一直会和nginx打交道。它已经是现代互联网企业的事实网关标准了。即使各个公司都自研了各种不同的网关,但是大多数还是会在请求最前端部署一层nginx。它的稳定性、可靠性以及性能方面的口碑都是非常非常好的。在我刚开始工作那时,就特别崇拜nginx。在这个网络的时代,nginx作为网关,它涉及到了非常多有深度的后端技术。比如:
- 网络协议
- 事件驱动的异步编程
- 高度可扩展的架构设计
- 高可用设计(热加载热更新)
- 恰当的数据结构设计
- 极致的性能优化
在我看来,nginx就是一个技术的百科全书,除了存储相关的内容,大部分技术都可以在它里面找到运用,而且都是标杆级的。因此我也萌生了一个想法,就是要自己写一个和nginx一样好用的网关软件。这不是出于重复造轮子的目的,而是为了学习。因为我觉得只有当你有能力模仿,你才有能力去创新。
通过这些年不断地学习和使用,我渐渐地发现,实现一个nginx(完成核心功能、性能差距不太大)并不是一件难以想象的事情,虽然困难但是依然是可以做到的。由于nginx是一个模块化设计的软件,能够非常容易地扩展,随着这些年的发展,功能是非常丰富的。如果想要完整覆盖nginx的功能,没必要也没可能。同时,nginx经过多年的发展,除了核心技术(事件驱动)的优越性以外,还进行了很多细节的优化,这些优化也使得后来者很难达到相同甚至更高的性能。因此,我逐渐明白,与其说要实现一个nginx,不如说是领悟nginx的精髓,实现一个和nginx一样扩展性高,性能好的web server。
不过随着互联网的快速发展,网关技术也处于一个变革的前夕。由于微服务的出现,以前单体应用中的功能模块被拆分成多个微服务进行独立部署,这使得nginx的下游顿时变得多了起来。微服务的动态伸缩,使得服务发现变成了几乎所有企业都需要的基础设施。而nginx设计之初并没有考虑服务发现,好在由于nginx支持配置热更新,现在有很多方法可以在服务信息变化之后自动改写nginx.conf中upstream部分,从而达到支持服务发现的目的。但是这又引入了很多运维的问题。
另一方面,nginx主要是进行http服务代理,而很多企业越来越意识到http1.x协议的不足。它明文、无类型且性能差,虽然部分场景无法替代(浏览器),但是越来越多的公司已经开始在内部RPC转向thrift dubbo protobuf这类基于TCP的强类型且高性能的传输协议了。nginx设计之初并没有考虑多协议转换的问题,虽然这些年官方也慢慢支持了grpc,nginx的fork版本 tengine支持了dubbo,但是这让我们意识到一个问题:如何在架构设计时就考虑到多协议的支持?只有当web server能够支持多协议转换,它才能够承担企业内网流量转发的职责。
除了以上问题,nginx如果要作为企业内部流量转发器,还面临另一个问题,那就是运维和部署。nginx的常用场景的就是几台固定的机器,半自动化地利用一些工具去更新nginx.conf并reload。但是这也意味着,nginx是一个单点层,一旦出问题将使所有服务都不可用。在过去,除了让nginx更稳定以外没什么别的办法。但到了现在,随着k8s等技术的发展,部署服务和服务的依赖变得非常容易,因此去中心化的代理层变得越来越受欢迎,比如说像service mesh的sidecar。每个代理服务和业务服务部署到一个节点,通过统一的控制面板来修改不同节点上sidecar的行为。这样的好处是去中心化,即使有sidecar挂掉,也不影响整体可用性,同时每个sidecar并不需要承受太高的流量压力,并且最关键的是节约资源,不需要额外为转发层专门部署数量庞大的机器,一年下来这甚至能节约数百万。去中心化的这种sidecar模式,它最核心的复杂性就在运维和部署,而这些复杂性已经被k8s解决掉了。
sidecar除了作流量转发以外,还有个很重要的功能就是服务管控,包括但不限于:
- 限流
- 降级
- tracing
- 加解密
- 鉴权
- 服务发现
- 熔断
这些功能在nginx里基本上都有,即使官方没有支持,也能找到社区的开源模块或者利用openresty+lua来实现。这里也侧面体现出了nginx架构设计的优越性,能够容易地扩展,使得软件的生命力长盛不衰。由于以上服务管控的功能在现代任何一个互联网公司都几乎是必不可少的,因此在设计一个新的代理服务器时,一定要在设计阶段就把这些功能考虑进去。
总结一下:对于一个现代的流量代理服务器(这里我不再说它是web server了,因为web服务只是很少的一部分了),它需要满足以下的一些特点:
- 高度模块化可扩展的设计
- 利用异步实现高IO性能
- 更适合存储和结构化的配置文件(不需要像nginx那样human readable)
- 能支持xDS协议
- 无状态适合分布式部署
- 支持热升级配置热更新
- 支持多协议转换,提供方便的开发接口
- 内置服务管控
- 丰富的metrics且能方便对接prometheus等平台
当然,这里面的每个点做起来其实都不简单。比如模块化和可扩展性的设计,这个实际是每个系统都需要做的设计,然而也是看起来很虚的。因为绝大部分系统和软件的生命周期都很短,短到甚至MVP版本上线后就砍掉了,因此大部分人虽然口头上重视实际行动上藐视,很少真正去思考怎么去设计。
模块化和可扩展性本质上是类似的,如果模块拆分得比较恰当,每个模块的功能很正交且明确,那么在扩展时就能够很容易地对它们进行组合。增强和扩展单个模块,也可以让所有其它相关功能受益。模块其实就是宏观上的函数。而可扩展性,其实就是代码中的抽象。我们的代码核心流程应该建立在一系列的抽象之上,比如写日志,不应该先入为主地认为日志就是文件,而应该抽象来看,日志是把系统生成一些关键信息,通过某种设备记录下来。我们不应该假定设备就是文件系统,也可能是数据库,也可能是通过网络传输到另外的机器…
因此,只有面向抽象来设计整个系统,才能做到真的可扩展。除了抽象以外,核心流程里留出足够多的hook,也是可扩展性的关键。但是通过hook实现的扩展,只能是一些旁路功能,但是这些也很重要,比如给http请求加一些额外的header,修改header中的某些值,记录一些metric信息等等…
因此,光设计一项就需要花很多心思。如果系统设计得过于抽象也是不行的,因为那样就没有意义了。最极端抽象的系统就是一个空的main函数,它当然足够抽象,把系统抽象为一系列函数的组合,基于这种抽象,你当然可以从main函数扩展出任何你想要的功能。但是软件总是为了实现特定需求的,也就是说它不需要像main函数那么强的扩展性。太抽象意味着直接可以被复用的能力太少,也意味着扩展方需要花更多的精力去设计和开发。一个好的设计,一定是从先明确需求开始的,也就是上面我总结的那些需求点,从需求出发,根据实际需要,哪些是未来可能变化的,哪些是不变的。总的来说,依赖和功能的实现应该抽象的,而处理流程应该具象的。
除了设计以外,性能也是一个及其重要的因素。apache由于性能太差,现在都没有人关心它的设计是如何的,因为大家都不用它。对于流量代理软件来说,性能不单纯是一个指标,它应该属于功能的一部分。代理软件在请求链路中加了一层,为了保证整体耗时可控,一定需要让消耗在这一层的时间尽可能少甚至忽略不计。而代理请求是一个IO密集型任务,因此一定需要使用多路服用的技术。像nginx,使用了master+多worker的多进程架构,这样除了通过进程的隔离机制尽可能保证可用性外,还能够充分利用多核。同时每个进程内部的主要部分是一个基于epoll的异步IO,大大提高了性能。其实除了nginx这种方式以外,像以Golang为代表的基于单进程多线程的green thread,也能够解决这样的问题,每个green thread也是基于epoll的异步IO。
对于配置文件来说,这里和nginx有比较大的差别。nginx设计之初大部分公司还是人肉运维,因此它把易用性放在首位。一个好读易懂语法简单的但又富有表达力的配置文件,能够极大地减少用户配置nginx的复杂性,也能帮助他们更快上手。而到了现在,网关集群的规模已经是非常非常大了,人肉地挨个去配置nginx.conf几乎不可能了。同时,nginx作为接入层,承接了太多太多的外部流量,一个nginx.conf的location块已经多得数不清了,早已拆分从上百个xxx.conf,通过include来加载。这种规模的业务场景下,配置文件再也谈不上简单易读。这时候我们更需要的是类似配置中心这样的操作界面,通过界面的操作来控制网关的行为。配置的下发也都应该做成自动化,最终一致,可灰度可回滚的。因为我们都是在界面上填写表单,所以配置文件本身就不再需要满足human readable了,最终配置长什么样其实无所谓了,只要应用好解析就行。由于人工配置可能会有失误,对配置文件进行一些自动化校验也很关键,因此配置文件需要满足结构化,这样方便进行读取和检测。比如最常见的就是JSON格式。另一方面是要考虑配置文件的可扩展性,因为系统的每个功能都需要可配置,同时还得考虑按什么维度去配置,每个配置项是否支持特定条件才生效,特定条件怎么在配置中表达,先后顺序是什么…最后,一个很关键的问题是,配置平台一般来说还是挺复杂,包括前后端以及各种推送存储,如果能够对接开源平台就好了。那么如果对接Istio这类的控制平面,我们就需要支持xDS协议…
另一个问题也是非常重要的,就是热升级和配置热更新。配置的热更新几乎所有软件都是采用的reload方式,而不是直接在内存中更新配置对象。这么做最主要的原因是避免锁带来的开销。如果直接在内存中更新配置,相当于配置对象被改写,同时处理请求时又会读取配置。这就是最直接的data race。为了避免data race引起的各种问题,必然需要对配置对象加上一个读写锁,而锁又引入的额外的性能开销。使用reload方式,意味着在两次reload之间配置是不可变的,这就完全不需要考虑data race了。但是reload也有它的问题,比如如果要保证过程平滑,那么就需要做graceful reload,不能生硬地中断正在处理的请求。如果只是C/S模型的请求,请求由client发起,server只是被动响应那还简单,处理完请求不再读取client的数据就行了。但是如果是双向通信比如服务端推送或者websocket,这时处理起来就比较麻烦。我们得考虑平滑地迁移长链接。而且考虑到部署方式越来越偏向于k8s以pod为单位的容器化部署,我们的热升级不再是单纯的物理机上操作,需要考虑跨容器的平滑长链接迁移。这方面蚂蚁金服的mosn做了一些探索,也是值得学习的。
多协议转换也是在设计时需要着重花功夫思考的问题。如果要适应内网环境,那么对多协议的支持是必须要考虑的。一开始开发,肯定不可能覆盖所有的协议,所以需要设计出扩展的口子,为后续添加功能做准备。从传输层考虑,可以分为TCP和UDP两类请求。TCP是一定要支持的而且是重中之重,那么接下来考虑我们是否需要支持UDP的反响代理。由于最新的http3是基于quic,本质就是udp,那么未来我们是不是也需要能够反响代理http3请求?如果需要的话,那就得考虑udp。接下来需要考虑最重要的,就是如何抽象协议转换。协议转换简单的来说就是解析协议(decode),以及按照新协议进行编码(encode)。但解析时针对不同协议有不同的解析方式,应该怎么抽象?是定一个interface,不同协议各自实现interface然后由core代码来调度,还是不同协议完全自己实现协议解析?解析后的数据用什么数据结构表示?怎么在后续各个模块之间流转?这些都是设计时需要思考的问题。并且,网上几乎没有文章讲这些内容,大部分都需要自己去读nginx或者envoy的源码去悟。蚂蚁mosn自己总结了一个协议解析框架,本质上是对协议编解码的抽象过程进行了部分的具象化(这句话本身有点抽象),由于具象化,因此有了部分可复用的能力,能够降低协议接入的开发成本。
总结
实现一个类似nginx的更现代的流量转发服务器是一个看起来不复杂(知乎上各种嘴炮帝甚至都不屑),但其实内部的设计和实现都不是一件容易的事情,比单纯做个proxy难上很多很多。说了这么多,我也想立个flag:使用rust开发一个包含上述功能,并同时适合作为http反响代理/k8s ingress和service mesh sidecar的"现代nginx"。为什么要用rust?第一,性能好;第二,安全;第三,我学了很久但依然感觉不太熟的样子,得找个大项目实操一把