Nginx是Envoy出现之前网络通信中间件领域非常有代表性的开源系统,功能强大,性能出色,扩展性很强,已经形成了强大的生态,成为HTTP流量管理领域事实上的标杆。Envoy作为后起之秀,虽然定位和目标上与Nginx有不少差异,但架构设计层面,Envoy和Nginx都有很多的可取之处。
下面会从功能定位、整体网络模型、连接处理、请求解析、插件机制等维度,对Envoy和Nginx进行详细剖析和比较,通过与Nginx功能和架构层面的全方位对比,大家也可以对Envoy的架构设计有更立体的认识。
1. 功能与定位
Nginx最核心的功能是Web服务器和反向代理服务器,Web服务器完成对HTTP请求协议的解析和以HTTP协议格式响应请求、缓存、日志处理这些基本Web服务器功能;反向代理服务器完成对请求的转发、负载均衡、鉴权、限流、缓存、日志处理等代理常用功能。
除了对Nginx协议的支持外,Nginx还支持普通的TCP、UDP反向代理功能,同时以stream方式支持通用的基于4层协议的反向代理,比如MySQL代理、Memcached代理等。
Envoy的目标比较远大,定位是透明接管微服务之间的通信流量,将通信和服务治理功能从微服务中解耦,通过Envoy可以方便地增加对自定义协议的支持。
概括起来,Nginx的关键词是Web服务器和反向代理,Envoy是透明接管流量,更加体现对流量的控制和掌控力。另外,从使用方式上看,微服务对Nginx是显式调用,通过Nginx完成负载均衡等相关功能,对Envoy是隐式调用,业务微服务不需要感知Envoy的存在,和使用Envoy使用相同的方式进行通信,只不过不再需要关注通信和链路治理的细节。
2. 网络模型
网络模型上,Nginx采用的是经典的多进程架构,由master进程和worker进程组成。其中,master进程负责对worker进程进行管理,具体包含监控worker进程的运行状态,根据外部输入的一些管理命令向worker进程发送处理信号以及worker进程退出时启动新的worker进程。worker进程负责处理各种网络事件,各个worker进程之间相互独立,一同竞争来自客户端的新的连接和请求,为了保证请求处理的高效,一个请求处理的全部过程在同一个worker进程中。worker进程的个数推荐配置为与当前环境的CPU核数相同。
自从Nginx诞生以来,一直使用上述经典的多进程架构。这种架构下,请求处理过程中如果遇到特别耗时的操作,比如磁盘访问、第三方服务同步访问等,会导致处理该请求的进程被夯住,不仅CPU资源没有得到充分利用,夯住时间比较长时不仅会影响当前请求,严重时会导致本进程的待处理请求大量超时。为了解决这种问题,Nginx从1.7.11版本开始引入了线程池的概念,如果遇到耗时特别长的逻辑,可以增加线程池配置,放到线程池中进行处理。线程池机制的引入对Nginx架构来说是个很好的补充,通过针对性地解决耗时特别长的一些阻塞场景,使得Nginx的性能达到一个新的高度。
和Nginx不同,Envoy采用了多线程的网络架构,Envoy一般会根据当前CPU核数创建相同个数的worker线程,所有worker线程同时对Envoy配置的监听器进行监听,接受新的连接,为每个新连接实例化相应的过滤器处理链,处理该连接上的所有请求。和Nginx类似,Envoy的每个请求的处理全流程都在同一个线程下进行。
从上面的分析看,Envoy和Nginx的网络处理方式大体类似。这两种方式都是全异步的编程模式,所有的操作都是异步进行,每个执行上下文使用一个单独的事件调度器,对该执行上下文的异步事件进行调度和触发,只是承载网络的执行上下文有差异,Nginx通过多进程的方式承载,Envoy使用的是多线程方式。
Nginx通过线程池的方式,从设计上解决了异步编程中的阻塞问题,但仍然没有从根本上解决这个问题,如果遇到设计或者代码层面没有注意到的问题场景,仍然会出现因为当前请求阻塞导致后续等待的请求得不到处理而超时的现象。由于都是全异步的编程模式,Envoy也会遇到同样的问题,不过Envoy开始尝试着进行解决,具体的解决方式是:为每个worker线程分别设置一个看门狗,并通过定时器定期更新本线程看门狗的最新更新时间,主线程会监控各个worker线程看门狗一段时间内是否有更新,如果超过一段时间没有更新,可以认为该线程的看门狗定时更新操作得不到执行的机会,从而推断出这个线程当前已经夯住,无法处理请求消息。Envoy通过这种机制可以检测出worker线程是否被长时间阻塞住,在此机制的基础上,后续可以增加相应的处理(比如将待处理请求移到其他线程,然后把该线程杀掉),可以从机制上解决工作线程被阻塞的问题。
3. 连接处理
Nginx通过worker_connections参数来控制每个worker能够建立的最大连接数,从Nginx网络模型可以看出,客户端连接到来时,所有空闲的进程都会去竞争这个新连接。这种竞争如果导致某个进程得到的新连接比较多,同时该进程的空闲连接也会很快用完,如果不进行控制,后续该进程获取新连接时会遇到没有空闲连接而丢弃,而有的进程有空闲连接却获取不到新连接。那么直接按照均等的方式将连接分配给各个进程是否可行呢?这种方式其实也是有问题的,不同连接上可能承载的请求QPS差异很大,可能会出现两个进程处理相同连接数,但一个特别忙另外一个特别闲的现象,因此为了保证各个工作进程都能够最大限度地提供自己的计算能力,需要对连接进行精细化管理,Nginx采取的方式是各工作进程根据自身的忙闲程度,动态调整获取新连接的时机,具体实现是:当本进程当前连接数达到最大worker_connections的7/8时,本worker进程不会去试图拿accept锁,也不会去处理新连接,这样其他worker进程就更有机会去处理监听句柄,建立新连接。而且,由于超时时间的设定,使得没有拿到锁的worker进程去拿锁的频率更高,通过这种方式,Nginx解决了worker进程之间的负载均衡问题。
Envoy也会遇到和Nginx类似的负载不均问题,Envoy当前发展很快,同时需要解决的问题很多,Envoy社区的人觉得这个问题当前的优先级还不够高,后续会根据具体情况对这个问题进行讨论和解决。
4. 插件机制
Nginx拥有强大的插件扩展能力,基于Nginx的插件扩展机制,业务可以非常方便地完成差异化和个性化定制,Nginx 插件通过模块的方式提供,具体来说,Nginx主要提供如下几种形式的插件扩展:
1)通过stream机制进行协议扩展,比如增加memcached协议代理和负载均衡等;
2)以Handler方式处理HTTP请求;
3)对HTTP请求和响应消息进行过滤,比如可以修改和定制消息内容等;
4)访问Upstream时的负载均衡,可以提供自定义的负载均衡机制。
对于最成熟的HTTP协议来说,Nginx把整个请求处理过程划分为多个阶段,当前一共有包含读取请求内容、请求地址改写等一共11个处理阶段,业务需要在某个阶段进行扩展和定制处理时,只需要挂载该阶段对应的回调函数,Nginx核心处理HTTP请求到这个阶段时,会回调之前注册的回调函数进行处理。
Nginx对模块的支持总体来说不算灵活,Nginx模块必须和Nginx自身源码一块编译,并且只能在编译期间选择当前支持的模块,不支持运行时进行模块动态选取和加载,大家一直以来吐槽比较多。为了解决这个问题,Nginx在1.9.11版本引入了模块动态加载支持,从此不再需要替换Nginx文件即可增加第三方模块扩展。Nginx也支持Lua扩展,利用Lua语言的简单易用和强大的协程机制,可以非常方便地实现很多扩展机制,并且性能也能够基本满足需求。
Envoy也提供了强大的插件扩展机制,当前使用最多的地方是监听过滤插件和网络处理过滤插件。和Nginx相比,Envoy网络插件定位在协议层面,以HTTP协议为例,Envoy并没有那么细粒度的插件扩展机制,如果想对Envoy的HTTP协议处理进行扩展,当前并没有提供特别多的扩展点。
Envoy的插件当前采用的是静态注册的方式,插件代码和Envoy代码一块进行编译,和Nginx不同,Envoy从最开始就支持插件的动态加载,Envoy通过独特的XDS API设计,可以随时对Envoy的XDS插件进行定制修改,Istio将修改后的XDS配置通过Grpc的方式推送给Envoy动态加载和生效。
此外,当前Envoy社区和Cilium社区一块探索利用,利用eBPF提供的用户态网络定制能力,对Envoy的流量进行精细化的管理和扩展定制。Cilium从1.3版本开始,引入了Envoy的Go扩展,通过Go扩展实现Filter插件向Envoy注册,主要实现的还是OnData()函数,当Envoy接收到流量时,就会调用插件的OnData函数进行处理。
Envoy在Lua扩展支持方面也进行了一些探索性的工作,当前已经试验性地支持使用Lua脚本对HTTP请求进行过滤和调整。Lua脚本HTTP过滤器当前仍处于实验阶段,不建议直接在生产环境中使用,后续待验证成熟后才能在生产环境使用。成熟后可以在更多的场景下通过Lua脚本机制增强Envoy的扩展性。