本文已发表一年以上。较旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已失效。
为 Envoy v2 构建 Kubernetes Edge (Ingress) 控制平面
Kubernetes 已成为基于容器的微服务应用的实际(de facto)运行时,但仅靠这个编排框架并不能提供运行分布式系统所需的所有基础设施。微服务通常通过 Layer 7 协议(如 HTTP、gRPC 或 WebSockets)进行通信,因此,在此层进行路由决策、操作协议元数据和进行观察的能力至关重要。然而,传统的负载均衡器和边缘代理主要专注于 L3/4 流量。Envoy Proxy 正是在这里发挥作用。
Envoy Proxy 由 Lyft 工程团队从头开始设计为一个通用数据平面,适用于当今分布式、以 L7 为中心的世界,广泛支持 L7 协议,提供用于管理其配置的实时 API,一流的可观测性,以及在小内存占用下的高性能。然而,Envoy 庞大的功能集和操作灵活性也使得其配置非常复杂——从其丰富但冗长的控制平面语法中可以看出这一点。
借助开源的 Ambassador API Gateway,我们希望解决创建新控制平面的挑战,该控制平面专注于将 Envoy 作为面向前方的边缘代理部署到 Kubernetes 集群中的用例,其方式要符合 Kubernetes 运维人员的习惯。在本文中,我们将详细介绍 Ambassador 设计的两个主要迭代版本,以及我们如何将 Ambassador 与 Kubernetes 集成。
2019 年前的 Ambassador:Envoy v1 API、Jinja 模板文件和热重启
Ambassador 本身作为一个 Kubernetes 服务部署在容器内,并使用添加到 Kubernetes Service 中的注解作为其核心配置模型。这种方法使应用开发者能够将路由管理作为 Kubernetes Service 定义的一部分。我们明确决定采用这种方式,是因为当前的Ingress API 规范存在局限性,并且我们喜欢扩展 Kubernetes Service 的简洁性,而不是引入另一种自定义资源类型。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 也存在显著的挑战。首先,尽管热重启对于我们大多数客户的用例是有效的,但它速度不快,一些客户(特别是那些有大型应用部署的客户)发现它限制了他们更改配置的频率。热重启还可能导致连接断开,尤其是像 WebSockets 或 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、速率限制和 gRPC 认证 API 等功能。其次,由于 Envoy 配置日益复杂(特别是在处理大型应用部署时),我们还希望对其进行更健壮的语义验证。
初步阶段
我们首先重构了 Ambassador 的内部结构,使其更像一个多阶段编译器。类层次结构被设计得更能紧密反映 Ambassador 配置资源、IR 和 Envoy 配置资源之间的关注点分离。Ambassador 的核心部分也进行了重新设计,以方便来自 Datawire 以外社区的贡献。我们决定采用这种方法有几个原因。首先,Envoy Proxy 是一个发展非常快速的项目,我们意识到需要一种方法,即使 Envoy 配置发生看似微小的变化,也不会导致 Ambassador 内部需要数天进行重新工程。此外,我们还希望能够提供配置的语义验证。
随着我们开始更紧密地与 Envoy v2 合作,一个测试挑战很快被发现。随着 Ambassador 支持越来越多的功能,在处理不常见但完全有效的功能组合时,Ambassador 的处理出现了越来越多的错误。这催生了一个新的测试需求,意味着 Ambassador 的测试套件需要进行重写,以自动管理多种功能组合,而不是依赖人工单独编写每个测试。此外,我们希望测试套件能够快速运行,以最大限度地提高工程效率。
因此,作为 Ambassador 重构的一部分,我们引入了 Kubernetes 验收测试 (KAT) 框架。KAT 是一个可扩展的测试框架,它可以:
- 将一系列服务(以及 Ambassador)部署到 Kubernetes 集群
- 对部署的 API 运行一系列验证查询
- 对这些查询结果执行一系列断言
KAT 旨在提高性能——它会在前期批量设置测试,然后使用高性能客户端异步运行第 3 步中的所有查询。KAT 中的流量驱动器使用 Telepresence 在本地运行,这使得调试问题更加容易。
将 Golang 引入 Ambassador 技术栈
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 中的 mutating 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。
- 有时需要重新设计测试框架(test harness)以推动软件向前发展。
- 重新设计测试框架的真正成本通常在于将旧测试移植到新的框架实现中。
- 为边缘代理用例设计(并实现)有效的控制平面一直是一项挑战,来自 Kubernetes、Envoy 和 Ambassador 开源社区的反馈非常有用。
将 Ambassador 迁移到 Envoy v2 配置和 ADS API 是一段漫长而艰难的旅程,需要大量的架构和设计讨论以及大量的编码,但早期反馈结果是积极的。Ambassador 0.50 现已可用,您可以进行测试运行,并在我们的 Slack 频道 或 Twitter 上与社区分享您的反馈。