Dropbox 是如何从 Nginx 迁移到 Envoy


【编者的话】这篇文章里讲述了 Dropbox 如何将他们的接入层从 Nginx 迁移到了 Envoy,就两个软件之间基于多个维度进行了对比,并介绍了他们的迁移流程、迁移后的现状以及这个过程中遇到的一些问题。

在这篇文章里,我们将会介绍 Dropbox 之前曾经使用过的一套基于 Nginx 的流量基础设施,它的一些痛点,以及迁移到 Envoy 之后带来的一些好处。我们将会针对 Nginx 和 Envoy 就多个软件工程及运维方面进行比较。我们还将简单地介绍一下我们的迁移流程,迁移后的现状,以及在这个过程中遇到的一些问题。

在我们大部分的 Dropbox 流量转到 Envoy 后,我们还必须无缝地将一个已经处理了数千万个建立的连接,每秒数百万个请求,并拥有数个 TB 带宽的复杂系统迁移到上面。这实际上已经让我们成为了世界上最大的 Envoy 用户之一。

免责声明:尽管我们试图保持客观性,但是本文中有很多对比是针对 Dropbox 以及我们软件开发的方式进行的:我们的技术栈选型是 Bazel,gRPC 和 C++/Golang。

另外请注意,下文提到的 Nginx 指的是其开源版本,而不是具有附加功能的商业版本。

旧的一套基于 Nginx 的流量基础设施

我们的 Nginx 配置绝大部分都是静态的,然后通过结合 Python2、Jinja2 以及 YAML 等渲染。任意一处变动都需要一次完整的重新部署才能生效。所有动态的部分,比如 upstream 管理以及 stats exporter,这些是用 Lua 编写的。更复杂的逻辑放到了用 Go 编写的,下面一层的代理层

相关文章《Dropbox 的流量基础设施:边缘网络》,这里面有一节介绍到了我们旧的这套基于 Nginx 的基础设施

Nginx 在我们的环境里良好运行了近十年。但是它已经无法再适应我们现在的开发最佳实践:
  • 我们内部和(私有)外部的 API 逐渐从 REST 迁移到 gRPC,这需要各种代理层面的转码功能支持;
  • Protocol buffer 已经成为服务定义和配置的事实标准;
  • 所有软件,无论使用哪种语言,均是通过 Bazel 构建和测试;
  • 我们的工程师开始重度参与开源社区里一些重要的基础设施项目。


此外,在运维方面,Nginx 的维护成本非常高:
  • 配置生成逻辑太过灵活,而且拆分到了 YAML、Jinja2 和 Python等多处;
  • 监控是一个 Lua 脚本、日志解析以及基于系统的基础监控的组合;
  • 越来越依赖于第三方模块,而这会带来稳定性、性能以及后续升级成本等多方面的影响;
  • Nginx 的部署及流程管理同其他服务完全不同。它完全依赖于其他一些系统的配置:syslog、logrotate 等,并没有和基础系统完全分离。


正是出于上述种种原因,10 年来我们首次开始寻找 Nginx 的潜在替代产品。

为什么不用 Bandaid?

正如我们上面提到的,在 Dropbox 内部,我们严重依赖一个 Golang 实现的代理(称为 Bandaid)。之所以它可以和 Dropbox 的整个基础设施很好地集成,原因是在于它可以访问到我们内部 Golang 库的广阔生态:监控、服务发现、限流等。我们考虑过从 Nginx 迁移到 Bandaid 的方案,但是这里面有一些问题阻碍了我们这样做:

  • Golang 比 C/C++ 占用更多的资源。资源的低使用率对于跑在边缘端的我们至关重要,因为我们无法轻易地“自动扩容”那里的部署。
    • CPU 开销主要来自 GC、HTTP 解析器和 TLS ,后者的优化程度低于 Nginx/Envoy 使用的 BoringSSL。
    • “每个请求一个 goroutine”的模型和 GC 开销大大增加了像我们这样高连接服务的内存需求。

  • Go 的 TLS 栈不支持 FIPS(译者注:见 go #11658)。
  • 除了 Dropbox,Bandaid 没有一个社区的支持,这意味着我们只能依靠我们自己来开发功能。


综合上述原因,我们决定将所有流量基础设施迁移到 Envoy。

全新的基于 Envoy 的流量基础设施

让我们逐一看看开发和运维几个主要的维度,从中了解为什么我们认为 Envoy 对我们来说是一个更好的选择,以及我们从 Nginx 迁移到 Envoy 之后获得了哪些收益。

性能

Nginx 的架构是一个事件驱动和多进程的模式。它支持 SO_REUSEPORTEPOLLEXCLUSIVE 以及 Worker 绑核。尽管它是基于事件循环的,但它并不是完全非阻塞的。这意味着某些操作(例如打开一个文件或记录 access/error 日志)可能会导致事件循环中止(即使启用了 aio,aio_write 和线程池),这样会使得尾部延迟增加,从而导致旋转磁盘驱动器上出现几秒钟的延迟。

Envoy 是一个类似的事件驱动式的架构,只是它使用的是线程而不是进程。它也同样支持 SO_REUSEPORT(自带一个 BPF 过滤器的支持),并且依赖 libevent 实现事件循环(换句话说,没有用到像 EPOLLEXCLUSIVE 这样的 epoll(2) 功能)。Envoy 在事件循环里并没有任何阻塞 IO 的操作。甚至日志记录也是以非阻塞方式实现的,因此它不会出现停顿的现象。

从理论上讲,Nginx 和 Envoy 应该具有相似的性能特征。但是“希望”不是我们的做事风格,因此我们的第一步便是针对经过类似调整的 Nginx 和 Envoy 设置运行各种工作负载测试。

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

我们的测试结果表明,在大多数测试工作负载下,Nginx 和 Envoy 拥有相近的性能表现:单秒高请求(RPS),高带宽以及混合的低延迟/高带宽的 gRPC 代理。

可以说,进行一个良好的性能测试非常困难。Nginx 有提供一个用于性能测试的准则,但是这些准则尚未形成规范。Envoy 也有提供一个基准测试指南,甚至在 envoy-perf 项目下也提供了一些工具,但遗憾的是后者似乎不怎么维护了。

我们转而使用内部的测试工具。之所以称其为“绿巨人(hulk)”,是因为它在粉碎我们的服务方面享有盛誉。

这么说吧,我们发现测试结果里有几处显著的差异:
  • Nginx 表现出更高的长尾延迟。这主要是在遭遇繁重的 I/O 时事件循环停滞所致,尤其是与 SO_REUSEPORT 一起使用的情况,因为在这种情况下,它可以代表当前被阻塞的 Worker 程序继续接受连接请求

  • 在没有开启统计数据收集的情况下,Nginx 的功能和 Envoy 相当,但是我们的 Lua 统计数据收集模块在高 RPS 测试中拖慢了 Nginx 3 倍。考虑到我们对 lua_shared_dict 的依赖,它在 Nginx 的各个 worker 之间是以一个互斥锁进行同步,性能方面的损失也是意料之中的情况。

    我们也知道统计数据收集模块效率很低下。我们有考虑过在用户空间里实现一个类似于 FreeBSD 的 counter(9) 的功能:绑定 CPU 核心,为每个 worker 分配一个无锁的计数器以及一个取值的例程,该例程会循环遍历所有 worker 并聚合汇总他们各自的统计信息。但是我们最终放弃了这个想法,因为如果我们想要监控 Nginx 内部状态(比如所有错误情况),那就意味着我们要维护一个巨大的补丁程序,这将使得后续升级变成一个真正的地狱。


由于 Envoy 不会受到这两个问题的困扰,因此在迁移到 Envoy 之后,我们可以释放多达 60% 的服务器资源(之前被 Nginx 独占)。

可监察性

可监察性是任何产品的最基本的运维需求,尤其是对于像代理这样的基础设施而言。在迁移期间,这一点尤为关键,这样一来任何问题都可以被监控系统检测到,而不用等到沮丧的用户报障时才发现。

非商业版本的 Nginx 自带一个“stub status”模块,提供了 7 个统计信息:
Active connections: 291 
server accepts handled requests
16630948 16630948 31070465 
Reading: 6 Writing: 179 Waiting: 106

这当然是不够的,因此我们添加了一个简单的 log_by_lua 处理程序,该处理程序根据 Lua 中可用的 HTTP header 和变量添加每个请求的统计信息:状态代码,大小,缓存命中率等。以下是一个简单的吐出统计数据的函数的例子:
function _M.cache_hit_stats(stat)
if _var.upstream_cache_status then
    if _var.upstream_cache_status == "HIT" then
        stat:add("upstream_cache_hit")
    else
        stat:add("upstream_cache_miss")
    end
end
end

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

在这些之上,我们有一个单独的 exporter ,用于收集 Nginx 的内部状态:自上次 reload 的时间,worker 数量,RSS/VMS 大小,TLS 证书使用期限等。

典型的 Envoy 配置为我们提供了数千种不同的指标(采用 Prometheus 格式),用于描述代理流量和服务器的内部状态:
$ curl -s http://localhost:3990/stats/prometheus | wc -l
14819

这里面囊括了五花八门的来自不同地方汇总的大量统计信息:
  • 每个集群/每个 upstream /每个 vhost 的 HTTP 统计信息,包括连接池信息和各种时序直方图;
  • 每个监听器的 TCP/HTTP/TLS 下游的连接统计信息;
  • 从基本版本信息和正常运行时间到内存分配统计以及弃用功能使用情况计数,各种内部/运行时状态的统计信息。


Envoy 的管理接口真心赞。它不仅通过 /certs/clusters/config_dump 端点提供额外的结构化的统计信息,而且还提供了非常重要的运维功能:
  • 通过 /logging 提供即时更改错误日志配置的能力。这让我们能够在几分钟内排查相当模糊的问题;
  • /cpuprofiler/heapprofiler/contention 在不可避免的性能排障期间肯定会有用武之地;
  • /runtime_modify 端点允许我们更改配置参数集而不用推送新的配置,而这些配置可用于功能开关等


除了统计数据外,Envoy 还支持可插拔的 tracing 实现。这不仅对拥有多个负载均衡层的接入层团队有用,对于希望从边缘到应用服务器端到端跟踪请求延迟的应用程序开发人员也很有用。

从技术上讲,Nginx 也支持通过第三方的 OpenTracing 集成进行跟踪,但是该功能开发尚不成熟。

最后,同等重要的是,Envoy 能够通过 gRPC 流式传输 access 日志。这减轻了我们接入层团队不得不支持打通 syslog 到 hive 的负担。此外,在 Dropbox 生产中启动通用 gRPC 服务比添加自定义的 TCP/UDP 监听器更容易(也更安全!)。

和其他所有操作类似,Envoy 里面 access 日志的配置是通过 gRPC 管理服务,即访问日志服务(ALS)设置。管理服务是将 Envoy 数据平面与生产中的各种服务集成的标准方式。这也是我们下一节要介绍的内容。

集成

Nginx 提供的集成方案,一个最佳描述便是 “Unix-ish”。配置是非常静态的。而且它严重依赖于文件(例如配置文件本身,TLS 证书和 Ticket 、白名单/黑名单等)以及一些知名的行业协议(记录日志到 syslog 以及通过 HTTP 发送认证子请求)。对于小型部署而言,这样做的简单性和向后兼容性是一件好事,因为我们可以通过编写几个 Shell 脚本轻松实现 Nginx 的自动化。但是随着系统规模的扩大,可测试性和标准化变得更加重要。

Envoy 对于是否应该将接入层数据平面和它的控制平面,以及因此也即是与其他基础设施集成在一起,持更加坚定的态度。它通过提供一个稳定的 API(通常称为 xDS ),鼓励用户使用 protobuf 和gRPC。Envoy 通过查询一个或多个这样的 xDS 服务来发现其动态资源。

如今,xDS API 的发展已然超越 Envoy:通用数据平面 API(UDPA)的宏伟目标是“成为事实上的 L4/L7 负载均衡器标准”。

根据我们的经验,这一雄心壮志是靠谱的。我们已经在内部负载测试中使用了开放请求成本汇总(ORCA),并且正在考虑将 UDPA 用于非 Envoy 的负载均衡,例如我们基于 Katran 的 eBPF/XDP 4层负载均衡代理

这对于 Dropbox 尤其适合,Dropbox 的所有服务都已经实现了在内部通过基于 gRPC 的 API 进行交互。我们已经实现了自主版本的 xDS 控制平面,它将 Envoy 同我们的配置管理、服务发现、私密信息管理以及路由信息集成在一起。

有关 Dropbox RPC 的更多信息,请阅读《Courier:Dropbox 转到 gRPC》。在这篇文章里,我们详细描述了如何将服务发现,私密信息管理,统计信息,跟踪,熔断等与 gRPC 集成在一起。

以下是一些可用的 xDS 服务,它们的 Nginx 替代品以及我们如何使用它们的一些示例:
  • 如上所述,访问日志服务(ALS)让我们可以动态地配置访问日志目标,编码和格式。试想一下 Nginx 的 log_formataccess_log 的动态版本;
  • 端点发现服务(EDS)提供相关集群成员的信息。这类似于 Nginx 配置中动态更新 upstream 里的 server 条目列表(例如,对于 Lua 来说便是 balancer_by_lua_block)。在这里,我们将其代理到内部的服务发现;
  • 私密信息发现服务(SDS)提供了各种与 TLS 相关的信息,这些信息将涵盖各种 ssl_ * 指令(以及相应的ssl_*_by_lua_block)。我们将这个接口调整为适用于我们场景的私密信息分发服务;
  • 运行时发现服务(RTDS)提供了运行时标志。我们在 Nginx 中实现这一功能的方式很不可靠,它是通过在 Lua 里检查各种文件是否存在来实现的。这种方法很快会在各个服务器之间变得不一致。 Envoy 的默认实现也是基于文件系统的,但是我们将 RTDS xDS API 指向了我们的分布式配置存储。这样一来,我们便可以一次控制整个集群(通过带有类似 sysctl 界面的工具),并且在不同服务器之间不会出现意外不一致的情况;

  • 路由发现服务(RDS)将路由映射到虚拟主机,并允许对 HTTP 头部和过滤器进行额外的配置。用 Nginx 术语来说,它们类似于带有 set_header/proxy_set_headerproxy_pass 的动态 location 配置。在更底下的代理层,我们直接在我们的服务定义配置中自动生成这些配置;

    关于 Envoy 如何与现有生产系统集成的示例,这里有一个将 Envoy 与自定义服务发现集成的一个经典例子。还有一些开源的 Envoy 控制面板实现,例如 Istio 和更少复杂度的 go-control-plane


我们自己的 Envoy 控制平面实现了越来越多的 xDS API。它在生产中以普通的 gRPC 服务的形式部署,并充当我们基础设施构建块的一个适配器。它通过一组通用的 Golang 库来实现与内部服务进行对话,并通过稳定的 xDS API 向 Envoy 公开它们。整个过程不涉及任何文件系统调用,信号量,cron,logrotate,syslog,日志解析器等。

配置

Nginx 在配置方面具有简单易读这样一项不可否认的优势。但是,随着配置变得越来越复杂,并且配置开始变成是代码生成式的,这项优势就没了。

正如上面所提到的,我们的 Nginx 配置是通过 Python2,Jinja2 和 YAML 的混合生成的。 你们中的一些人可能已经在 erb,pug,Text::Template 甚至 m4 里面看到或甚至编写了这样类似的变体:
{% for server in servers %}
server {
{% for error_page in server.error_pages %}
error_page {{ error_page.statuses|join(' ') }} {{ error_page.file }};
{% endfor %}
...
{% for route in service.routes %}
{% if route.regex or route.prefix or route.exact_path %}
location {% if route.regex %}~ {{route.regex}}{%
        elif route.exact_path %}= {{ route.exact_path }}{%
        else %}{{ route.prefix }}{% endif %} {
    {% if route.brotli_level %}
    brotli on;
    brotli_comp_level {{ route.brotli_level }};
    {% endif %}
    ...

我们的 Nginx 配置的生成方式有一个大问题:配置生成中涉及的所有语言都允许代入 和/或 逻辑。YAML 有 anchor,Jinja2 有 loop/ifs/macroses,然后,Python 当然是图灵完备的。如果没有一个干净的数据模型,复杂性会迅速扩散到这三者。

这个问题自然是可以解决的,但是有两个基本问题:
  • 这里没有关于配置格式的声明性描述。如果我们想通过编程的方式生成和验证配置,那么需要自己重新造轮子;
  • 语法上有效的配置,从 C 代码的角度来看仍然可能是无效的。例如,某些与缓冲区相关的变量具有值限制,对齐限制以及与其他变量的相互依赖性。为了从语义上验证配置,我们需要运行 nginx -t 来校验。


另一方面,Envoy 自带一套用于配置的统一数据模型:它的所有配置都放到 protobuffer 中定义。这不仅解决了数据建模问题,而且还将输入的信息添加到了配置值里。鉴于 protobuf 是Dropbox 生产环境里的头等公民,并且是描述/配置服务的通用方式,因此,集成就变得更加简单了。

我们针对 Envoy 设计的新的配置生成器是基于 protobuf 和 Python3 实现的。所有数据建模均在原始文件中完成,而所有逻辑均在 Python 中执行。下面是一个例子:
from dropbox.proto.envoy.extensions.filters.http.gzip.v3.gzip_pb2 import Gzip
from dropbox.proto.envoy.extensions.filters.http.compressor.v3.compressor_pb2 import Compressor

def default_gzip_config(
compression_level: Gzip.CompressionLevel.Enum = Gzip.CompressionLevel.DEFAULT,
) -> Gzip:
    return Gzip(
        # Envoy's default is 6 (Z_DEFAULT_COMPRESSION).
        compression_level=compression_level,
        # Envoy's default is 4k (12 bits). Nginx uses 32k (MAX_WBITS, 15 bits).
        window_bits=UInt32Value(value=12),
        # Envoy's default is 5. Nginx uses 8 (MAX_MEM_LEVEL - 1).
        memory_level=UInt32Value(value=5),
        compressor=Compressor(
            content_length=UInt32Value(value=1024),
            remove_accept_encoding_header=True,
            content_type=default_compressible_mime_types(),
        ),
    )

注意上述代码里的 Python3 类型注解!结合 mypy-protobuf protoc 插件,它们可以在 config 生成器里提供端到端的输入。如果你用的 IDE 支持自动检查的话,它将会立即高亮显示输入信息不匹配。

在某些情况下,经过类型检查的 protobuf 在逻辑上仍然可能是无效的。在上面的示例中,gzip window_bits 只能取 9 到 15 之间的值。这类限制可以在 protoc-gen-validate protoc 插件的帮助下轻松完成定义:
google.protobuf.UInt32Value window_bits = 9 [(validate.rules).uint32 = {lte: 15 gte: 9}];

最后,使用官方定义的配置模型的一个潜在好处是,它有机地引领了文档与配置定义并排配置。 以下是一个 gzip.proto 的示例
// Value from 1 to 9 that controls the amount of internal memory used by zlib. Higher values.           
// use more memory, but are faster and produce better compression results. The default value is 5.            
google.protobuf.UInt32Value memory_level = 1 [(validate.rules).uint32 = {lte: 9 gte: 1}];

对于那些考虑在生产系统中使用 protobuf 的人,如果你担心可能缺少无模式(schema-less)的表达方式的话,这里有一篇不错的文章,它出自 Envoy 核心开发人员 Harvey Tuch 之手,内容涉及到如何使用 google.protobuf.Structgoogle.protobuf.Any 变相解决这类问题:《动态可扩展性和 Protocol Buffer》。

可扩展性

要想让 Nginx 提供超出标准配置所提供的功能范畴之外的特性的话,通常需要编写一个 C 模块。 Nginx 的开发指南对于可用的构建块提供了详尽的介绍。这也就是说,这种方式是相对重量级的。实际上,要想安全地编写一个 Nginx 模块的话,需要一位相当资深的软件工程师。

关于可供模块开发人员选用的基础设施的话,他们可以期待一些像哈希表、队列、rb树这样的基础容器,(非RAII)内存管理、以及可用于所有请求处理阶段的钩子。另外,还有一些外部库,比如 pcre、zlib、openssl,当然,还有libc。

为了提供更轻量级的功能扩展,Nginx 提供了 PerlJavaScript 的接口。可悲的是,它们所提供的功能都相当有限,绝大部分都局限于请求处理的内容阶段。

社区采纳的最常用的扩展方式是基于第三方的 lua-nginx 模块和各种 Openresty 类库。这一方案几乎可以外挂到请求处理的任意阶段。我们使用 log_by_lua 进行统计信息的收集,然后使用 balancer_by_lua 进行动态地后端再配置。

从理论上讲,Nginx 提供了使用 C++ 开发模块的能力。然而实际上,对于所有原语,它都缺少适当的 C++ 接口/包装器,这样就显得不太值得了。尽管如此,仍有一些社区对此进行尝试。这些还远远没有达到生产就绪。

Envoy 的主要扩展机制是通过 C++ 插件。这个流程在文档方面不如 Nginx,但是它更为简单。部分原因是:
  • 整洁且拥有良好注释的界面。C++ 类充当自然的扩展和文档点。比如,看看 HTTP 过滤接口
  • C++14 语言和标准库。从 template 和 lambda 函数等这些基本的语言功能,到类型安全的容器和算法。通常,编写现代 C++14 与使用 Golang 并没有多大区别,或者有些人甚至会说 Python。

  • C++14 及其标准库以外的功能。由 abseil 库提供的,其中包括来自较新版本的 C++ 标准的直接替换,内置了静态的死锁检测和 debug 支持的互斥锁,以及额外的/更有效的容器,等等

    具体地,这里有一份 HTTP 过滤模块的经典例子


通过简单地实现 Envoy stats 接口,我们仅用200行代码就可以将 Envoy 与 Vortex2(我们的监视框架)集成在一起。

Envoy 还通过 moonjit 添加了对 Lua 的支持,moonjit 是一个对 Lua 5.2 做了诸多改进支持的 LuaJIT fork。与 Nginx 的第三方 Lua 集成相比,它所提供的功能和开放的钩子要少得多。由于在开发、测试和解释后代码的排障等诸多方面存在的额外复杂性成本,这使得 Lua 对 Envoy 的吸引力大大降低。专门从事 Lua 开发的公司可能会不同意这一观点,但是在我们的案例中,我们决定避免使用它,而只是将 C++ 用于 Envoy 的可​​扩展性。

Envoy 与其他 Web 服务器的区别就在于它提供了对 WebAssembly(WASM)的新兴支持 —— 一种快速的,可移植且安全的扩展机制。WASM 不能直接使用,但是可以作为任何通用编程语言的编译目标。Envoy 实现了一个适用于代理的 WebAssembly 规范(还包括相关的 RustC++ SDK),该规范描述了 WASM 代码和通用的 L4/L7 代理之间的边界。代理和扩展之间代码的分隔提供了一个安全的沙箱,而 WASM 低级凝练的二进制格式又为之提供了接近原生的效率。在此之上,在 Envoy 里,proxy-wasm 扩展是和 xDS 集成在一起的。这样便可以进行动态地更新,甚至可以进行潜在的 A/B 测试。

在 KubeCon’s 19 上,《通过 WebAssembly 扩展 Envoy》这个演讲很好地概述了 Envoy 里集成的 WASM 及其潜在用途。它还暗示已经达到了原生 C++ 代码性能水平的 60-70%。

使用 WASM,服务提供方可以安全有效地在其边缘环境运行客户的代码。客户获益的则是可移植性:他们的扩展可以在实现 proxy-wasm ABI 的任何云上运行。此外,它允许用户使用任意一种语言,只要它可以编译为 WebAssembly。这使得他们能够安全有效地使用更广泛的非 C++ 类库。

Istio 正在向 WebAssembly 的开发倾注大量资源:他们已经有了基于 WASM 的遥测扩展的实验版本以及用于共享扩展的 WebAssemblyHub 社区。你可以查阅《在代理中重新定义可扩展性 —— 为 Envoy 和 Istio 引入 WebAssembly》,这篇文章详细了解它。

当前,Dropbox 并未用到 WebAssembly。但是,等到 proxy-wasm 的 GO SDK 可用时,这一情况也许会发生变化。

构建和测试

默认情况下,Nginx 使用的是一套自定义的基于 shell 的配置系统和基于 make 的构建系统构建的。这是简单而优雅的,但是将其集成到 Bazel 构建的 monorepo 的话需要花费大量的精力才能获得增量、分布式、封闭和可重现构建这些所有优点。

Google 开源了他们用 Bazel 构建的 Nginx 版本,该版本由 Nginx,BoringSSL,PCRE,ZLIB 和 Brotli 库/模块组成。

在测试方面,Nginx 在一个单独的仓库里有一组 Perl 驱动的集成测试,没有任何单元测试。

鉴于我们对 Lua 的大量使用以及缺乏内置的单元测试框架,我们求助于使用模拟配置和基于 Python 的简单测试驱动程序进行测试:
class ProtocolCountersTest(NginxTestCase):
@classmethod
def setUpClass(cls):
    super(ProtocolCountersTest, cls).setUpClass()
    cls.nginx_a = cls.add_nginx(
        nginx_CONFIG_PATH, endpoint=["in"], upstream=["out"],
    )
    cls.start_nginxes()

@assert_delta(lambda d: d == 0, get_stat("request_protocol_http2"))
@assert_delta(lambda d: d == 1, get_stat("request_protocol_http1"))
def test_http(self):
    r = requests.get(self.nginx_a.endpoint["in"].url("/"))
    assert r.status_code == requests.codes.ok

最重要的是,我们通过预处理所有生成的配置(例如用 127/8 替换所有 IP 地址,切换到自签名 TLS 证书等)并在结果上运行 nginx -c 来验证所有语法的语法正确性。

在 Envoy 方面,其主流的构建系统已经是 Bazel。因此,将它和我们的 monorepo 集成起来并不繁琐:Bazel 支持轻松添加外部依赖项

我们还使用 copybara 脚本来同步 Envoy 和 udpa 的 protobuf。当你需要进行简单的转换而无需永远维护大型补丁集时,Copybara 十分方便。

通过 Envoy,我们可以灵活地选择使用一组预先编写好的 mock 来进行单元测试(基于 gtest/gmock ),也可以选择使用 Envoy 的集成测试框架,或者同时使用两者。至此,针对每一项细小的改动,我们不再需要依靠缓慢的端到端集成测试了。

gtest 是 Chromium 和 LLVM 等项目所采用的一套相当有名的单元测试框架。如果你想进一步了解 googletest,这里有两份不错的介绍资料:googletestgooglemock

开源的 Envoy 开发要求每次更改达到 100% 的单元测试覆盖率。它会通过 Azure CI 流水线针对每个 PR 自动触发测试。

使用 google/becnhmark 对性能敏感的代码进行微基准测试也是一种常见做法:
$ bazel run --compilation_mode=opt test/common/upstream:load_balancer_benchmark -- --benchmark_filter=".*LeastRequestLoadBalancerChooseHost.*"
BM_LeastRequestLoadBalancerChooseHost/100/1/1000000          848 ms          449 ms            2 mean_hits=10k relative_stddev_hits=0.0102051 stddev_hits=102.051
...

改用 Envoy 之后,我们开始完全依赖单元测试来进行内部模块开发:
TEST_F(CourierClientIdFilterTest, IdentityParsing) {
struct TestCase {
std::vector<std::string> uris;
Identity expected;
};
std::vector<TestCase> tests = {
{{"spiffe://prod.dropbox.com/service/foo"}, {"spiffe://prod.dropbox.com/service/foo", "foo"}},
{{"spiffe://prod.dropbox.com/user/boo"}, {"spiffe://prod.dropbox.com/user/boo", "user.boo"}},
{{"spiffe://prod.dropbox.com/host/strange"}, {"spiffe://prod.dropbox.com/host/strange", "host.strange"}},
{{"spiffe://corp.dropbox.com/user/bad-prefix"}, {"", ""}},
};
for (auto& test : tests) {
EXPECT_CALL(*ssl_, uriSanPeerCertificate()).WillOnce(testing::Return(test.uris));
EXPECT_EQ(GetIdentity(ssl_), test.expected);
}


实施亚秒级的往返测试在生产力方面会带来复合效果。它让我们能够把更多精力放在增加测试覆盖范围。由于可以在单元测试和集成测试之间进行自由选择,这使得我们能够在 Envoy 测试的覆盖范围、速度和成本之间获得一个平衡。

学习上手 Bazel 对我们开发人员来说是一次绝佳的经历。它的学习曲线非常陡峭,而且前期需要付出大量的投资,但是它却有很高的回报:增量式构建远程缓存分布式构建/测试等。

Bazel 很少讨论的好处之一是,它让我们能够查询甚至扩展依赖图谱。依赖关系图的编程接口,以及跨语言的通用构建系统,这是一项非常强大的功能。它可以用作代码提示器,代码生成,漏洞跟踪以及部署系统等这类服务的基础构建块。

安全性

Nginx 的代码面非常小,它的外部依赖很少。通常,对生成的二进制文件来说只能看到 3 个外部依赖项:zlib(或者更快的变体之一),一个 TLS 库以及 PCRE。Nginx 自研实现了所有相关的协议解析器、事件库,甚至还重新实现了某些 libc 函数。

在某些情况下,Nginx 被认为非常安全,以至于它被用作 OpenBSD 里默认的 Web 服务器。后来,两个开发社区发生了冲突,也因此有了 httpd。您可以在 BSDCon《介绍OpenBSD的新 httpd 》中了解此举背后的动机。

这种极简主义在实践中得到了回报。Nginx 在11年多的时间里仅报告了30个安全漏洞

另一方面,Envoy 的代码量更大,尤其是考虑到 C++ 代码比用于 Nginx 的基本 C 代码更加密集时,这一点更为明显。它还包含来自外部依赖项的数百万行代码。从事件通知到协议解析器的所有内容都推给了第三方库。这会增加攻击面并造成最终产出的可执行文件愈加膨胀。

为了解决这个问题,Envoy 高度依赖现代安全实践。它使用 AddressSanitizerThreadSanitizerMemorySanitizer。它的开发人员甚至超纲地采用了模糊测试

任何对于全球IT基础架构至关重要的开源项目都可以接入到 OSS-Fuzz,这是一个用于自动化模糊测试的免费平台。要了解更多信息,请参阅“OSS-Fuzz/架构”。

实际上,尽管如此,所有这些预防措施都不能完全抵消增加的代码所留下的痕迹。结果便是,在过去的两年里,Envoy 已经发布了 22 条安全公告

Envoy 的《安全发布策略》一文对此进行了详细描述,针对某些指定的漏洞还有详细的检查报告。Envoy 还是 Google 漏洞奖励计划(VRP)的成员之一。VRP 向所有安全研究人员开放,根据它们设计的规则,对发现和报告的漏洞提供奖励。

有关可以如何利用这些漏洞里面某些漏洞的实际案例,请参阅有关 CVE-2019–18801 的这篇文章:《利用 Envoy 的堆漏洞》。

为了应对不断增长的漏洞风险,我们使用了来自上游发行版供应商 Ubuntu 和 Debian 的最佳可执行文件来强化安全措施。我们为所有边缘环境曝光的可执行文件定义了特殊加固后的构建配置文件。它包括 ASLR,堆栈保护器以及符号表的加固:
build:hardened --force_pic
build:hardened --copt=-fstack-clash-protection
build:hardened --copt=-fstack-protector-strong
build:hardened --linkopt=-Wl,-z,relro,-z,now

在绝大多数环境里,fork 的 Web 服务器(如 Nginx)在堆栈保护器方面会存在问题。由于主进程和工作进程共享同一套堆栈来进行灰度,而在灰度验证失败时,工作进程会被杀死,因此,我们可以通过大约 1000 次尝试逐位对灰度的实例进行暴力破解。使用线程作为并发原语的 Envoy 不受此攻击的影响。

我们还希望尽可能的给第三方依赖的安全性加固。我们在 FIPS 模式下使用 BoringSSL,该模式包括启动自检和可执行文件的完整性检查。我们还考虑在某些边缘环境的灰度服务器上运行启用 ASAN 的可执行文件。

功能性

这是帖子中最主观的部分,请做好准备。

Nginx 最初是设计成一个 Web 服务器,致力于以最少的资源消耗来提供静态文件服务。它的功能性在这里是最重要的:静态文件服务,缓存(包括惊群保护)以及 range 缓存。

但是,作为代理的话,Nginx 缺乏现代基础架构所需的功能。它的后端没有 HTTP/2。尽管可以代理 gRPC 服务,但是它不支持连接多路复用。而且不支持 gRPC 转码。最重要的是,Nginx的“核心开放(open-core)”模型限制了可以纳入开源版本代理服务的功能集。结果便是,某些重要功能(如统计信息)在它的“社区”版本里是不可用的。

相比之下,Envoy 已经演变为一个 ingress/egress 代理,经常用于以 gRPC 为主要负载的环境。尽管它的 Web 服务功能还是相当初级的:没有文件服务,而且缓存功能仍然在开发中brotli或者预压缩这些功能也都不支持。针对这些场景,我们仍然提供了一个小的 fallback Nginx 实例,Envoy 会把它当成上游集群。

等到 Envoy 的 HTTP 缓存功能生产就绪时,我们便可以将大多数静态服务用例迁移过去,使用 S3 取代文件系统作为长期存储。想要了解关于 eCache 设计的更多信息,请参阅《eCache:一个用于 Envoy 的多后端 HTTP 缓存》。

Envoy 还提供了许多 gRPC 相关能力的原生支持:
  • gRPC 代理。这是一项基本功能,允许我们在应用程序里端到端地使用 gRPC(比如 Dropbox 桌面客户端)
  • HTTP/2 到后端。这项功能使得我们可以大大减少流量层之间的 TCP 连接数,从而减少内存消耗和 keepalive 的流量。
  • gRPC -> HTTP 的桥接(+反向代理)这些让我们能够使用现代的 gRPC 栈对外暴露旧版的 HTTP/1 应用程序服务。
  • gRPC-WEB。这项功能让我们即使在中间层(防火墙,IDS等)尚不支持 HTTP/2 的环境里也可以端对端地使用gRPC。
  • gRPC JSON 转码器。这让我们能够将所有入站流量(包括 Dropbox 公共 API )从 REST 转到 gRPC。


此外,Envoy 还可以用作一个出站代理。我们通过它统一了另外几个用例场景:
  • Egress 代理:自从 Envoy 添加了对 HTTP CONNECT 方法的支持以后,它便可以用作 Squid 代理的替代产品。我们已经开始用 Envoy 替换出站的 Squid 实例。这样做不仅极大地提高了可见性,而且还通过统一使用通用的数据平面和可观察性技术栈省掉了一揽子运维方面的糟心事(不再需要为了统计信息而去解析日志)
  • 第三方软件服务发现:我们并没有使用 Envoy 来构建服务网格,取而代之的是,我们依靠的是软件里的 Courier gRPC 库。但是,当遇到需要一次性花费最小的精力将开源服务与我们的服务发现连接的场景时,我们实际还是选择了 Envoy。比如,Envoy 在我们的分析栈里用作服务发现的辅助工具。Hadoop 可以动态地发现它的 name 及 journal 节点。 Superset 可以发现airflow,presto 以及 hive 的后端。Grafana 可以发现它的 MySQL 数据库。


社区

Nginx 的开发是相当中心化的。它的绝大部分开发活动都是核心团队内部进行的。nginx-devel 邮件列表上有一些外部活动,然后官方 Bug 跟踪上偶尔有一些与开发相关的讨论。

FreeNode 上有一个 #nginx 频道。我们可以随时加入到里面进行更多互动性质的社区对话。

Envoy 的开发则是开放和去中心化的:通过 GitHub issue/pull request,邮件列表社区会议进行协同。

Slack 上的社区活动也很活跃。你可以在这里收到邀请

开发风格和工程社区这些方面很难量化定性,因此,我们不妨一起来看一个开发 HTTP/3 的特定例子。

F5 最近发表了 Nginx QUIC 和 HTTP/3 实现。这块代码是干净的,没有任何外部依赖。但是开发过程本身并不透明。在此之前的半年,Cloudflare 提出了自己的 Nginx HTTP/3 实现。结果便是,该社区现在拥有两套单独的用于 Nginx 的 HTTP/3 实验版本。

再来看看 Envoy ,HTTP/3 的实现这块也正在开发中,它是基于 chromium 的 “quiche”(QUIC,HTTP等)库。该项目的进展可以通过 这个 GitHub issue 跟踪。在补丁开发完成前,设计文档便已经对外公布了。剩下的工作里,想要从社区参与中获益的部分会带有 “help wanted” 标签。

如你所见,后者显然更加开放透明,而且非常鼓励协作开发。对我们来说,这意味着我们能够从上游获得大量的 Envoy 相关的大大小小的变动 —— 包括从运维改进性能调优新的 gRPC 转码功能以及负载均衡功能的一些变动

迁移后的现状

我们已经让 Nginx 和 Envoy 并排运行了半年多,然后通过 DNS 逐步将流量从一个切换到另一个。到目前为止,我们已经成功将许多各式各样的工作负载迁移到了 Envoy:
  • Ingress 高吞吐量服务。Dropbox 桌面客户端的所有文件数据都是通过 Envoy 背后端到端 的 gRPC 提供服务。改用 Envoy 后由于在边缘环境的连接复用效果更好,我们还略微提高了用户的性能。
  • Ingress 高RPS服务。这是 Dropbox 桌面客户端的所有文件元数据。同样地,我们获得了端到端的 gRPC 带来的好处,而且去掉了连接池,这意味着我们不受每个连接一次请求的限制。
  • 通知和遥测服务。这里是用来处理所有实时通知的服务,因此这些服务器一般会有数百万个 HTTP 连接(每个活跃的客户端会有一个连接)。如今我们不再需要使用昂贵的长轮询方式来实现通知服务,取而代之的是,我们可以通过 streaming gRPC 来实现。
  • 兼有高吞吐量/高RPS的混合服务。API 流量(元数据和数据本身)。这使得我们开始考虑一些公共的 gRPC API。我们甚至可以直接在边缘环境对现有的基于 REST 的 API 进行转码。
  • Egress 高吞吐量代理。在我们的使用场景,Dropbox 和 AWS 的通信主要是 S3 这块。最终我们会在生产网络里下线所有的 Squid 代理,然后只留下一个 L4/L7 的数据平面。


迁移的最后一件事便是 www.dropbox.com 自己。在完成迁移后,我们便可以开始停用我们在边缘环境部署的 Nginx 服务。一个时代即将结束。

我们遇到的一些问题

当然,整个迁移的过程并非完美无瑕。不过至少没有导致任何明显的中断。整个迁移过程中最困难的部分是我们的 API 服务这块。大量不同的设备通过我们的公共 API 和 Dropbox 进行通信——从 curl/wget 驱动的 Shell 脚本,以及具有自定义 HTTP/1.0 堆栈的嵌入式设备,到每个可能访问到这里的 HTTP 库。 Nginx 是经过实践检验的行业事实标准。可以理解,大多数库都隐式地依赖于它的某些行为。除了我们的 api 用户在依赖的 Nginx 行为这块,在转到 Envoy 后遇到了一些行为不一致的地方以外,我们在使用 Envoy 和它提供的类库的过程中还发现了许多 bug。在社区的帮助下,所有这些问题都很快得到了解决并且反馈到了上游。

下面只是其中一些“异常的”/没有出现在 RFC 里的行为的摘要:
  • 合并 URL 路径里的斜杠。URL 规范化及斜杠合并在 Web 代理里是一项非常常见的功能。Nginx 默认启用了斜杠归一和斜线合并,但是 Envoy 不支持后者。我们向上游提交了一个补丁,添加了该项功能,并允许用户通过merge_slashes 选项选择性地加入。
  • 虚拟主机名字里的端口。Nginx 允许接收两种形式的 Host header:要么是 example.com,又或者是 example.com:port。我们有几个曾经依赖于这一行为的 API 用户。首先,我们通过在配置里复制虚拟主机(带和不带端口)来解决此问题,但是后来我们又在 Envoy 端添加了一个忽略匹配端口的选项:strip_matching_host_port
  • 传输编码区分大小写。出于某种不知名的原因,一小部分子集的 API 客户端使用了 Transfer-Encoding:Chunked(请注意大写的“C”)header。这在技术上是可行的,因为 RFC7230 声明了 Transfer-Encoding/TE header 不区分大小写。修复办法也很简单,然后也已经提交到了上游的 Envoy 。
  • 一个请求里同时带有 [Content-Length](https://github.com/envoyproxy/envoy/issues/11398)[Transfer-Encoding:chunked](https://github.com/envoyproxy/envoy/issues/11398) 这两个 header。有些请求在 Nginx 下面是好的,但是迁移到 Envoy 以后就炸了。 RFC7230 有点棘手,通常大家会觉得 Web 服务器的确应该在处理这些请求时报错,因为它们很可能是“走私的(smuggled)”。另一方面,可能又会有人说,代理应该去掉 Content-Length header 然后再转发该请求。对此,我们的做法是:我们扩展了 http-parse 然后让使用该库的用户来自主选择是否要支持这类请求,然后目前我们正在努力为 Envoy 自身引入这项功能的支持。


另外,值得一提的是,我们还遇到了一些常见的配置问题:
  • 熔断器配置错误。根据我们的经验,如果入站代理采用 Envoy 的话,尤其是工作在 HTTP/1 以及 HTTP/2 这样的混合环境里,那么熔断器的配置不当可能会在流量高峰或后端中断期间导致意外停机。如果不使用 Envoy 作为 mesh 代理,不妨考虑放松这块的配置。值得一提的是,在默认情况下,Envoy 的熔断器限制非常严格 ———— 特别要注意这一点!
  • 缓冲区。 Nginx 允许将磁盘用作请求的缓冲区。这在用户采用的是无法理解 chunked 传输编码的传统的 HTTP/1.0 后端的情况下很有用。Nginx 可以将它们先缓冲到磁盘上然后转换成带有 Content-Length 的请求。Envoy 有一个 Buffer 过滤器,但是由于无法将数据保存到磁盘,能够缓冲多少内容受限于我们机器的内存大小。


如果你正在考虑使用 Envoy 作为边缘代理的话,推荐阅读《配置 Envoy 作为边缘代理》这篇文章,应该会让你有所启发。它能够切实满足你对于基础设施最暴露在外的部分关于安全性和资源限制的要求。

下一步

  • HTTP/3 的黄金时代即将来临。几个主流浏览器均已加入了对 HTTP 3.0 的支持(目前由一个参数或者命令行选项来控制是否开启)。Envoy 也已经在进行实验性的支持。升级 Linux 内核支持 UDP 加速后,我们会在我们的边缘环境上进行试验。
  • 内部基于 xDS 的负载均衡以及异常值检测。目前,我们正在考虑使用负载上报服务(LRS)端点发现服务(EDS)的组合作为构建模块,以便为 Envoy 和 gRPC 创建一套通用的后备型、负载感知式的负载均衡。
  • 基于 WASM 的 Envoy 扩展。等到 Golang 的 proxy-wasm SDK 可用时,我们便可以开始在 Go 里面编写 Envoy 的扩展,这将使我们能够访问各种内部的 Golang 库。
  • 替换 Bandaid。将所有的 Dropbox 代理层统一成一个单个的数据平面听上去很有吸引力。为此,我们需要将许多 Bandaid 功能(尤其是在负载均衡方面)迁移到 Envoy。这还有很长的路要走,但这是我们目前的计划。
  • Envoy 移动端。最终,我们想看看能否将 Envoy 用于移动端的应用程序。站在接入层的角度,在所有的移动端平台上支持一套具有统一的监控和现代化功能(HTTP/3,gRPC,TLS1.3 等)的单个服务栈非常耀眼。


原文链接:How we migrated Dropbox from Nginx to Envoy(翻译:吴佳兴)

0 个评论

要回复文章请先登录注册