本文发表于一年多前。旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已变得不正确。

为 Envoy v2 构建 Kubernetes 边缘(Ingress)控制平面

Kubernetes 已成为基于容器的微服务应用程序的事实运行时,但这个编排框架本身并未提供运行分布式系统所需的所有基础设施。微服务通常通过 HTTP、gRPC 或 WebSocket 等第 7 层协议进行通信,因此在此层进行路由决策、操作协议元数据和进行观察的能力至关重要。然而,传统的负载均衡器和边缘代理主要关注 L3/4 流量。这就是 Envoy Proxy 发挥作用的地方。

Envoy 代理由 Lyft 工程团队从头开始设计,作为适用于当今分布式、以 L7 为中心的世界的通用数据平面,它广泛支持 L7 协议,提供用于管理其配置的实时 API,一流的可观测性,以及在小内存占用下的高性能。然而,Envoy 庞大的功能集和操作灵活性也使其配置高度复杂——从其丰富但冗长的控制平面语法中可见一斑。

通过开源的 Ambassador API 网关,我们希望解决创建新控制平面的挑战,该控制平面专注于在 Kubernetes 集群中以符合 Kubernetes 运营商习惯的方式将 Envoy 部署为面向前方的边缘代理。在本文中,我们将介绍 Ambassador 设计的两次重大迭代,以及我们如何将 Ambassador 与 Kubernetes 集成。

Ambassador 2019 年之前:Envoy v1 API、Jinja 模板文件和热重启

Ambassador 本身作为 Kubernetes 服务部署在一个容器中,并使用添加到 Kubernetes Services 的注解作为其核心配置模型。这种方法使应用程序开发人员能够将路由作为 Kubernetes 服务定义的一部分进行管理。我们明确决定采用此路线,原因在于当前的 Ingress API 规范存在限制,并且我们喜欢扩展 Kubernetes 服务的简单性,而不是引入另一种自定义资源类型。Ambassador 注解的一个示例如下所示:

kind: Service
apiVersion: v1
metadata:
  name: my-service
  annotations:
    getambassador.io/config: |
      ---
        apiVersion: ambassador/v0
        kind:  Mapping
        name:  my_service_mapping
        prefix: /my-service/
        service: my-service
spec:
  selector:
    app: MyApp
  ports:
  - protocol: TCP
    port: 80
    targetPort: 9376

将这个简单的 Ambassador 注解配置转换为有效的 Envoy v1 配置并非易事。Ambassador 的配置并非基于与 Envoy 配置相同的概念模型——我们有意聚合和简化操作和配置。因此,在概念集之间进行转换涉及到 Ambassador 内部大量的逻辑。

在 Ambassador 的第一次迭代中,我们创建了一个基于 Python 的服务,它监视 Kubernetes API 以了解 Service 对象的更改。当检测到新的或更新的 Ambassador 注解时,这些注解从 Ambassador 语法转换为中间表示 (IR),该 IR 包含了我们的核心配置模型和概念。接下来,Ambassador 将此 IR 转换为一个代表性的 Envoy 配置,该配置作为文件保存在与正在运行的 Ambassador k8s Service 关联的 Pod 中。然后,Ambassador“热重启”在 Ambassador Pod 中运行的 Envoy 进程,这触发了新配置的加载。

最初的实现带来了许多好处。所涉及的机制非常简单,Ambassador 配置到 Envoy 配置的转换是可靠的,并且基于文件的 Envoy 热重启集成是值得信赖的。

然而,这个版本的 Ambassador 也存在显著的挑战。首先,尽管热重启对于我们大多数客户的用例是有效的,但它并不快,一些客户(特别是那些拥有庞大应用程序部署的客户)发现它限制了他们更改配置的频率。热重启还会导致连接中断,特别是像 WebSocket 或 gRPC 流这样的长连接。

然而,更重要的是,IR 的第一个实现虽然允许快速原型设计,但它过于原始,以至于事实证明很难进行实质性更改。虽然这从一开始就是一个痛点,但随着 Envoy 转向 Envoy v2 API,这成为了一个关键问题。很明显,v2 API 将为 Ambassador 带来许多好处——正如 Matt Klein 在他的博客文章《通用数据平面 API》中所述——包括访问新功能和解决上述连接中断问题,但也很明显,现有的 IR 实现无法实现这一飞跃。

Ambassador >= v0.50:Envoy v2 API (ADS)、使用 KAT 进行测试和 Golang

在与 Ambassador 社区协商后,Datawire 团队于 2018 年对 Ambassador 的内部结构进行了重新设计。这主要由两个关键目标驱动。首先,我们希望集成 Envoy 的 v2 配置格式,这将支持 SNI 等功能,SNI速率限制gRPC 身份验证 API。其次,由于 Envoy 配置日益复杂(尤其是在大规模应用程序部署中操作时),我们还希望对 Envoy 配置进行更健壮的语义验证。

初期阶段

我们首先按照多遍编译器的思路重构了 Ambassador 的内部结构。类层次结构更紧密地反映了 Ambassador 配置资源、IR 和 Envoy 配置资源之间的关注点分离。Ambassador 的核心部分也经过重新设计,以促进 Datawire 之外社区的贡献。我们决定采用这种方法有几个原因。首先,Envoy Proxy 是一个发展非常快的项目,我们意识到我们需要一种方法,即看似微小的 Envoy 配置更改不会导致 Ambassador 内部进行数天的重新设计。此外,我们希望能够提供配置的语义验证。

当我们开始更密切地使用 Envoy v2 时,很快就发现了一个测试挑战。随着 Ambassador 支持的功能越来越多,Ambassador 在处理不常见但完全有效的功能组合时出现了越来越多的 bug。这促使我们提出了一个新的测试要求,即 Ambassador 的测试套件需要重新设计,以自动管理许多功能组合,而不是依赖人工逐个编写每个测试。此外,我们希望测试套件运行速度快,以最大限度地提高工程生产力。

因此,作为 Ambassador 重构的一部分,我们引入了 Kubernetes 验收测试 (KAT) 框架。KAT 是一个可扩展的测试框架,它:

  1. 在 Kubernetes 集群中部署一组服务(以及 Ambassador)
  2. 针对已启动的 API 运行一系列验证查询
  3. 对这些查询结果执行一系列断言

KAT 专为性能而设计——它预先批量设置测试,然后使用高性能客户端异步运行步骤 3 中的所有查询。KAT 中的流量驱动器使用 Telepresence 在本地运行,这使得调试问题变得更容易。

向 Ambassador 堆栈引入 Golang

随着 KAT 测试框架的到位,我们很快遇到了 Envoy v2 配置和热重启的一些问题,这为我们提供了机会,转而使用 Envoy 的聚合发现服务 (ADS) API,而不是热重启。这完全消除了配置更改时重启的需求,我们发现这可能导致高负载或长连接下的连接断开。

然而,当我们考虑转向 ADS 时,我们面临一个有趣的问题。ADS 并不像人们预期的那样简单:在向 Envoy 发送更新时存在明确的顺序依赖关系。Envoy 项目提供了排序逻辑的参考实现,但仅限于 Go 和 Java,而 Ambassador 主要使用 Python。我们有点痛苦,最终决定最简单的前进方式是接受我们世界的多语言性质,并在 Go 中实现我们的 ADS。

我们还发现,使用 KAT,我们的测试已经达到了 Python 在处理许多网络连接时性能受限的程度,因此我们也利用了 Go,主要用 Go 编写了 KAT 的查询和后端服务。毕竟,当你已经下定决心时,再多一个 Golang 依赖又何妨呢?

有了新的测试框架、生成有效 Envoy v2 配置的新 IR 以及 ADS,我们认为 Ambassador 0.50 的主要架构更改已完成。然而,我们又遇到了一个问题。在 Azure Kubernetes Service 上,Ambassador 注解的更改不再被检测到。

与响应迅速的 AKS 工程团队合作,我们找到了问题所在——即 AKS 中的 Kubernetes API 服务器通过代理链暴露,这要求客户端更新以了解如何使用 API 服务器的 FQDN 进行连接,FQDN 通过 AKS 中的变异 webhook 提供。不幸的是,官方的 Kubernetes Python 客户端不支持此功能,因此这是我们选择转向 Go 而不是 Python 的第三个地方。

这提出了一个有趣的问题:“为什么不放弃所有的 Python 代码,而只是完全用 Go 重写 Ambassador 呢?”这是一个合理的问题。重写的主要问题是 Ambassador 和 Envoy 在不同的概念层面运作,而不是简单地用不同的语法表达相同的概念。确保我们用新语言表达了概念桥梁并非易事,并且在没有非常出色的测试覆盖率的情况下不应轻易尝试。

目前,我们使用 Go 来覆盖非常特定、独立的功能,这些功能的正确性比我们验证完整的 Golang 重写更容易。未来,谁知道呢?但对于 0.50.0 版本,这种功能分离让我们既能利用 Golang 的优势,又能让我们对 0.50 版本中的所有更改保持更高的信心。

经验教训

在构建 Ambassador 0.50 的过程中,我们学到了很多。我们的一些主要收获:

  • Kubernetes 和 Envoy 是非常强大的框架,但它们也是发展极快的项目——有时,阅读源代码和与维护者交流是不可替代的(幸运的是,他们都非常乐于助人!)
  • Kubernetes/Envoy 生态系统中支持最好的库是用 Go 编写的。虽然我们喜欢 Python,但我们不得不采用 Go,这样我们就不必自己维护太多的组件。
  • 重新设计测试工具有时是推动软件向前发展的必要条件。
  • 重新设计测试工具的真正成本通常在于将旧测试移植到新的工具实现中。
  • 为边缘代理用例设计(和实现)一个有效的控制平面一直充满挑战,而来自 Kubernetes、Envoy 和 Ambassador 开源社区的反馈非常有用。

将 Ambassador 迁移到 Envoy v2 配置和 ADS API 是一段漫长而艰难的旅程,需要大量的架构和设计讨论以及大量的编码,但早期的反馈结果是积极的。Ambassador 0.50 现已发布,您可以试运行并向社区分享您的反馈意见,可以通过我们的 Slack 频道Twitter