从Nginx到特使的Dropbox迁移

2020-07-31 03:32:10

在这篇博文中,我们将讨论旧的基于Nginx的交通基础设施,它的痛点,以及我们通过迁移到特使所获得的好处。我们将在许多软件工程和运营维度上将Nginx与特使进行比较。我们还将简要介绍迁移过程、其当前状态以及在迁移过程中遇到的一些问题。

当我们将大部分Dropbox流量转移到特使时,我们必须无缝迁移一个已经处理数千万个开放连接、每秒数百万个请求和TB带宽的系统。这实际上使我们成为世界上最大的特使用户之一。

免责声明:尽管我们试图保持客观,但这些比较中有相当一部分是针对Dropbox和我们的软件开发工作方式的:押注于Bazel、GRPC和C++/Golang。

还要注意的是,我们将介绍Nginx的开源版本,而不是其具有附加功能的商业版本。

我们的Nginx配置主要是静态的,使用Python2、JJIA2和YAML的组合呈现。对它的任何更改都需要完全重新部署。所有动态部分,如上游管理和统计导出器,都是用Lua编写的。任何足够复杂的逻辑都被移到下一个代理层,用GO编写。

我们的帖子“Dropbox流量基础设施:边缘网络”中有一节介绍了我们遗留的基于Nginx的基础设施。

Nginx在近十年的时间里为我们提供了很好的服务。但它并不适应我们当前的开发最佳实践:

我们的内部和(私有)外部API正逐渐从REST迁移到GRPC,这需要来自代理的各种代码转换功能。

对第三方模块的依赖增加影响了稳定性、性能和后续升级的成本。

Nginx部署和进程管理与其他服务有很大不同。它在很大程度上依赖于其他系统的配置:syslog、logrotate等,而不是完全独立于基本系统。

有了这一切,10年来我们第一次开始寻找Nginx的潜在替代品。

正如我们经常提到的,在内部,我们严重依赖基于Golang的名为Bandaid的代理。它与Dropbox基础设施进行了很好的集成,因为它可以访问内部Golang库的庞大生态系统:监控、服务发现、速率限制等。我们曾考虑从Nginx迁移到Bandaid,但有几个问题阻碍了我们这样做:

Golang比C/C++更耗费资源。低资源使用率对我们在Edge上特别重要,因为我们不能轻松地“自动扩展”我们在那里的部署。CPU开销主要来自GC、HTTP解析器和TLS,后者的优化程度不如Nginx/特使使用的BoringSSL。

在像我们这样的高连接服务中,“每请求Goroutine”模型和GC开销极大地增加了内存需求。

Bandaid在Dropbox之外没有社区,这意味着我们只能依靠自己进行功能开发。

考虑到这一切,我们决定开始将我们的交通基础设施迁移到特使。

让我们逐一看看主要的发展和运营层面,看看为什么我们认为特使是我们更好的选择,以及从Nginx到特使我们得到了什么。

Nginx的架构是事件驱动的、多进程的。它支持SO_REUSEPORT、EPOLLEXCLUSIVE和Worker-to-CPU固定。虽然它是基于事件循环的,但它不是完全非阻塞的吗?这意味着一些操作,如打开文件或访问/错误日志记录,可能会导致事件循环停止(即使在启用aio、aio_write和线程池的情况下也是如此)。这会导致尾部延迟增加,从而可能导致旋转磁盘驱动器上的数秒延迟。

特使具有类似的事件驱动体系结构,只是它使用线程而不是进程。它还支持SO_REUSEPORT(带有BPF过滤器支持),并依赖libeent实现事件循环(换句话说,没有EPOLLEXCLUSIVE这样的EPOLL(2)特性。)。特使在事件循环中没有任何阻塞IO操作。即使是日志记录也是以非阻塞的方式实现的,因此不会导致停滞。

从理论上看,Nginx和特使应该有类似的性能特征。但是希望不是我们的策略,所以我们的第一步是针对类似调优的Nginx和特使设置运行一组不同的工作负载测试。

如果您对性能调优感兴趣,我们将在“优化Web服务器以实现高吞吐量和低延迟”中介绍我们的标准调优准则。它涉及从选择硬件到操作系统可调参数,再到库选择和Web服务器配置的方方面面。

我们的测试结果显示,在我们的大多数测试工作负载下,Nginx和特使的性能相似:高每秒请求数(RPS)、高带宽和混合的低延迟/高带宽GRPC代理。

可以说,很难进行良好的性能测试。Nginx有关于性能测试的指导方针,但这些指导方针并未编入法典。特使也有基准测试的指导方针,甚至在特使-Perf项目下还有一些工具,但遗憾的是,后者看起来没有得到维护。

我们求助于使用我们的内部测试工具。它被称为“绿巨人”是因为它以破坏我们的服务而声名狼藉。

Nginx表现出较高的长尾潜伏期。这主要是由于事件循环在繁重的I/O下停止,特别是在与SO_REUSEPORT一起使用时,因为在这种情况下,可以代表当前阻塞的工作进程接受连接。

在没有统计信息收集的情况下,nginx的性能与特使是一致的,但是我们的lua stats收集使nginx在高RPS测试中的速度降低了3倍。考虑到我们对lua_share_dict的依赖,这是意料之中的,它是使用互斥体在所有工作进程之间同步的。

我们确实理解我们的统计数据收集效率有多低。我们考虑在用户空间中实现类似于FreeBSD的计数器(9):CPU固定,每个工作进程的无锁计数器,带有一个获取例程,循环遍历所有工作进程,汇总各自的统计数据。但我们放弃了这个想法,因为如果我们想要检测Nginx内部(例如,所有错误情况),这将意味着支持一个巨大的补丁,这将使后续的升级成为真正的地狱。

因为特使没有受到这两个问题的影响,在迁移到它之后,我们能够释放高达60%的以前由Nginx独占的服务器。

可观察性是任何产品最基本的操作需求,尤其是对代理这样的基础设施而言。更重要的是,在迁移期间,监控系统可以检测到任何问题,而不是沮丧的用户报告。

活动连接:291服务器接受已处理的请求16630948 16630948 31070465读取:6写入:179WAITING:106。

这肯定是不够的,所以我们添加了一个简单的LOG_BY_LUA处理程序,它根据Lua中提供的头部和变量(状态代码、大小、缓存命中率等)添加每个请求的统计信息。下面是一个简单的统计信息发出函数的示例:

Function_M.cache_hit_stats(Stat)if_var.upstream_cache_status THEN_var.upstream_cache_status==";HIT";THEN STAT:ADD(";upstream_cache_Hit";)ELSE STAT:ADD(";;upstream_cache_misse";)end end

除了每个请求的Lua统计信息之外,我们还有一个非常脆弱的error.log解析器,它负责上游、http、lua和TLS错误分类。

最重要的是,我们有一个单独的导出器来收集Nginx内部状态:自上次重新加载以来的时间、工作进程数、RSS/VMS大小、TLS证书期限等。

典型的特使设置为我们提供了数千个描述代理流量和服务器内部状态的不同指标(以普罗米修斯格式):

各种内部/运行时统计信息,从基本版本信息和正常运行时间到内存分配器统计信息和过时的功能使用计数器。

特使的管理界面需要特殊的大喊答题功能。它不仅通过/certs、/cluster和/config_dump端点提供额外的结构化统计信息,而且还具有非常重要的操作功能:

能够通过/记录动态更改错误记录。这使我们能够在几分钟内解决相当模糊的问题。

/Runtime_Modify端点允许我们在不推送新配置的情况下更改一组配置参数,这些参数可以用于功能选通等。

除了统计信息,特使还支持可插拔的跟踪提供程序。这不仅对拥有多个负载平衡层的流量团队很有用,而且对希望从边缘到应用服务器端到端跟踪请求延迟的应用程序开发人员也很有用。

从技术上讲,Nginx还支持通过第三方OpenTracing集成进行跟踪,但并未进行大量开发。

最后但并非最不重要的一点是,特使能够通过GRPC流式传输访问日志。这从我们的流量团队中消除了支持syslog到hive网桥的负担。此外,它更容易(也更安全!)。在Dropbox生产中启动通用GRPC服务,而不是添加自定义TCP/UDP侦听器。

与其他配置一样,特使的访问登录配置也是通过GRPC管理服务,即访问日志服务(ALS)进行的。管理服务是将特使数据平面与生产中的各种服务集成的标准方式。这就引出了我们的下一个话题。

Nginx的集成方法最好用“Unix-ish”来描述。配置非常静态。它严重依赖文件(例如配置文件本身、TLS证书和票证、允许列表/阻止列表等)。和众所周知的行业协议(通过HTTP记录到系统日志和授权子请求)。这种简单性和向后兼容性对于小型设置来说是件好事,因为Nginx可以通过几个shell脚本轻松实现自动化。但随着系统规模的扩大,可测试性和标准化变得更加重要。

特使在如何将流量数据平面与其控制平面集成,从而与基础设施的其余部分集成的问题上更加固执己见。它通过提供通常称为XDS的稳定API来鼓励使用Protobufs和GRPC。特使通过查询这些XDS服务中的一个或多个来发现其动态资源。

如今,XDSAPI的发展已经超越了特使:通用数据数据平面API(Universal D ata Plane API,UDDA)有着“成为L4/L7负载均衡器事实上的标准”的雄心勃勃的目标。

根据我们的经验,这一雄心壮志实现得很好。我们已经将Open Request Cost Aggregation(ORCA)用于内部负载测试,并且正在考虑将UDDA用于我们的非特使负载均衡器,例如我们基于Katran的eBPF/XDP Layer-4负载均衡器。

这对Dropbox尤其有利,因为所有服务都已经通过基于GRPC的API进行了内部交互。我们已经实现了我们自己版本的XDS控制平面,它将特使与我们的配置管理、服务发现、秘密管理和路由信息集成在一起。

有关Dropbox RPC的更多信息,请阅读“Courier:Dropbox迁移到GRPC”。在这里,我们详细描述了如何将服务发现、秘密管理、统计、跟踪、断路等与GRPC集成在一起。

以下是一些可用的XDS服务、它们的Nginx替代方案,以及我们如何使用它们的示例:

如上所述,访问日志服务(ALS)允许我们动态配置访问日志目的地、编码和格式。想象一下Nginx的log_format和access_log的动态版本。

端点发现服务(EDS)提供有关群集成员的信息。这类似于Nginx配置中的上游块的服务器条目的动态更新列表(例如,对于可能是BALANLER_BY_LUA_BLOCK的Lua)。在我们的示例中,我们将此代理到我们的内部服务发现。

秘密发现服务(SDS)提供各种与TLS相关的信息,涵盖各种SSL_*指令(以及相应的SSL_*_BY_LUA_BLOCK)。我们将此接口调整为适用于我们的秘密分发服务。

运行时发现服务(RTDS)正在提供运行时标志。我们在Nginx中对此功能的实现相当繁琐,这是基于检查是否存在来自Lua的各种文件。此方法可能很快在各个服务器之间变得不一致。特使的默认实现也是基于文件系统的,但是我们将RTDS XDS API指向分布式配置存储。这样,我们可以一次控制整个集群(通过具有类似sysctl的界面的工具),并且不同服务器之间不会意外出现不一致。

路由发现服务(RDS)将路由映射到虚拟主机,并允许对报头和过滤器进行额外配置。在Nginx术语中,它们类似于具有set_header/proxy_set_header和proxy_pass的动态位置块。在较低的代理层上,我们直接从服务定义配置自动生成这些。

对于特使与现有生产系统集成的示例,下面是如何将特使与自定义服务发现集成的规范示例。还有几个开源的特使控制平面实现,比如Istio和不太复杂的Go-Control-Plane。

我们自主开发的特使控制平面实现了越来越多的XDS API。它在生产中部署为普通的GRPC服务,并充当我们的基础架构构建块的适配器。它通过一组通用的Golang库来与内部服务对话,并通过一个稳定的XDSAPI将它们公开给特使。整个过程不涉及任何文件系统调用、信号、cron、logrotate、syslog、日志解析器等。

Nginx具有简单的人类可读配置的不可否认的优势。但是,随着配置变得更加复杂,并开始生成代码,这一胜利就会落空。

如上所述,我们的Nginx配置是通过混合使用Python2、Jinsa2和YAML生成的。你们中的一些人可能已经在erb、pug、text::template甚至m4中看到过或者甚至编写过它的变体:

{%FOR SERVERS%}SERVER{{%FOR ERROR_PAGE在服务器中。ERROR_PAGE%}ERROR_PAGE{{ERROR_PAGE{{ERROR_PAGE_STATUS|Join(';';)}{{Error_Page.file}};{%endfor%}...{%for service.routes.route%}{%if route.regex或route.prefix或route.exact_path%}位置{%if route.regex%}~{{route.regex}}{%Elif route.exact_path%}={{route.exact_path}}{%Else%}{{route.prefix}}{%endif%}{{%if route.brotli_level%}。{%endif%}...。

我们的Nginx配置生成方法有一个很大的问题:所有涉及到配置生成的语言都允许替换和/或逻辑。YAML有锚,JJIA2有循环/if/宏,当然Python是图灵完成的。如果没有干净的数据模型,复杂性会迅速蔓延到这三个领域。

配置格式没有声明性描述。如果我们想要以编程方式生成和验证配置,我们需要自己发明它。

从C代码的角度来看,语法上有效的配置可能仍然是无效的。例如,一些与缓冲区相关的变量具有值限制、对齐限制以及与其他变量的相互依赖关系。要从语义上验证配置,我们需要通过nginx-t运行它。

另一方面,特使有一个统一的配置数据模型:它的所有配置都在协议缓冲区中定义。这不仅解决了数据建模问题,还将键入信息添加到配置值。鉴于Protobuf是Dropbox生产中的一等公民,并且是描述/配置服务的通用方式,这使得集成变得容易得多。

我们新的特使配置生成器是基于protocol bufs和Python3的。所有数据建模都在Proto文件中完成,而所有逻辑都在Python中完成。这里有一个例子:

从dropbox.proto.envoy.extensions.filters.http.gzip.v3.gzip_pb2导入Gzip从dropbox.proto.envoy.extensions.filters.http.compressor.v3.compressor_pb2导入压缩器def DEFAULT_GZIP_CONFIG(COMPRESSION_LEVEL:GZIP.CompressionLevel.Enum=Gzip.CompressionLevel.DEFAULT)->;Gzip:返回Gzip(#特使的默认值为6(Z_DEFAULT)。COMPRESSION_LEVEL=COMPRESSION_LEVEL,#特使的默认值为4k(12位)。Nginx使用32k(MAX_WBITS,15位)。WINDOW_BITS=UInt32Value(值=12),#特使的默认值为5。nginx使用8(MAX_MEM_LEVEL-1)。Memory_Level=UInt32Value(值=5),压缩器=压缩器(Content_Length=UInt32Value(值=1024时),REMOVE_ACCEPT_ENCODING_HEADER=TRUE,content_type=default_compressible_mime_types(),),)。

注意该代码中的Python3类型注释!,再加上mypy-protocol buf协议插件,它们在配置生成器中提供端到端类型。能够检查它们的IDE将立即突出显示键入不匹配。

在某些情况下,类型检查的协议可能在逻辑上是无效的。在上面的示例中,gzip Window_bits只能取9到15之间的值。借助proc-gen-valify协议插件可以很容易地定义这种限制:

最后,使用正式定义的配置模型的一个隐含好处是,它有机地导致文档与配置定义并置。下面是来自gzip.proto的一个示例:

//取值范围为1~9,控制zlib使用的内存大小。更高的值。//占用内存较多,但速度较快,压缩效果较好。默认值为5.google.protocol buf.UInt32Value MEMORY_LEVEL=1[(validate.ules).uint32={lte:9 gte:1}];

如果您正在考虑在生产系统中使用协议Buf,但又担心缺少无模式表示,这里有一篇由特使核心开发人员Harvey Tuch撰写的很好的文章,介绍了如何使用google.Protobuf.Struct和google.Protobuf.Any解决这个问题:“Dynamic Extenability and Protocol Buffers”。

将Nginx扩展到使用标准配置所能实现的范围之外,通常需要编写一个C模块。Nginx的开发指南对可用的构建块进行了坚实的介绍。也就是说,这种方法是相对重量级的。在实践中,需要相当资深的软件工程师才能安全地编写Nginx模块。

就模块开发人员可用的基础设施而言,他们可以期待哈希表/队列/RB树、(非RAII)内存管理和请求处理所有阶段的挂钩等基本容器。还有一些外部库,如pcre、zlib、openssl,当然还有libc。

为了实现更轻量级的功能扩展,Nginx提供了Perl和Javascript接口。遗憾的是,两者的能力都相当有限,主要局限于请求处理的内容阶段。

社区最常用的扩展方式是基于第三方l ua-nginx模块和各种OpenResty库。这种方法几乎可以挂接到请求处理的任何阶段。我们使用LOG_BY_LUA进行统计数据收集,使用BALANCER_BY_LUA进行动态后端重新配置。

理论上,Nginx提供了用C++开发模块的能力。实际上,它对所有原语都缺乏适当的C++接口/包装器,因此不值得这样做。尽管如此,还是有一些社区尝试这样做。不过,这些产品还远远没有准备好投入生产。

特使的主要扩展机制是通过C++插件。这个过程没有像Nginx案例中记录得那么好,但它更简单。部分原因是:

干净且注释良好的界面。C++类充当自然扩展和文档点。例如,签出HTTP筛选器接口。

C++14语言和标准库。从模板和lambda函数等基本语言功能,到类型安全容器。

.