本文是一篇演讲整理。Joe Williams 在演讲中介绍了 GitHub 负载均衡器(GLB)的架构。
GitHub 在 HAProxy 之上构建了一个弹性的自定义解决方案,以智能地路由来自各种客户端(包括 Git、SSH 和 MySQL)的请求。GLB 分为两大部分:GLB 导向器和 GLB 代理,后者是基于 HAProxy 构建的。HAProxy 有许多优点,包括负载平衡、高级运行状况检查和可观察性等。有了与 Consul 的紧密集成,可以进行实时配置更改。部署使用一个 GitHub 流,并包含一个扩展的 CI 流程,并通过 Slack 上的 ChatOps 管理所有金丝雀部署。
演讲内容
我是 Joe,在 GitHub 工作,今天我要讲的是 GLB。我们在 2015 年前后开始构建 GLB,并于 2016 年投入生产。它是由我本人和 GitHub 的另一位工程师 Theo Julienne 共同构建的。我们构建 GLB 是为了替代一组不可扩展的 HAProxy 主机,后者是单体架构,难以测试,非常令人困惑而且有些可怕。这套基础设施是在我加入 GitHub 之前很久就建立的,但我的工作就是要替换掉它。
在那个时候,我们在构建 GLB 时要遵循一些设计原则,以缓解之前系统存在的许多问题。其中一条原则是:我们想要的系统应该运行在货架硬件上。之前的讨论中我们谈到了 F5 的故事,我们不想重蹈 F5 的复辙。
我们想要的是能够水平扩展,支持高可用性,并且不会在任何时候中断 TCP 会话的系统。我们想要支持连接清空(connection draining),可以在生产环境中轻松加入或撤出主机,而又不会破坏 TCP 连接的系统。我们想要的系统应该是以服务为单位的。GitHub 上有很多不一样的服务。GitHub.com 是一项重要的服务,但我们还有很多内部服务,希望将它们隔离到各自的 HAProxy 实例中。
我们需要的系统应该可以像在 GitHub 上随处可见的代码一样迭代,并且存储在 Git 仓库中。我们需要在每一层上都可以做测试,这样就能确保每个组件都在正常工作。我们已经扩展到了全球范围内的多个数据中心上,所以希望构建出为多个 PoP 和数据中心设计的产品。最后,我们想要一种能够抵御 DoS 攻击的系统,因为不幸的是这种攻击对于 GitHub 来说是非常普遍的。
首先,我要深入谈一谈 GitHub 的请求路径,并介绍一些我们用来管理它的开源工具;然后,我将深入探讨 GLB 的两大组成部分,分别是基于 DPDK 的导向器(Director)和基于 HAProxy 的代理。
上图是 GitHub 上请求路径的高级概览。由于我们对内部和外部请求都使用 GLB,因此这套流程基本上适用于进出 GitHub 的所有请求和服务(包括 MySQL 之类的服务)。接下来介绍两大组件,也就是 GLB 导向器和 GLB 代理。
先从客户端开始。GitHub 上有很多不同种类的客户端和协议。如果你看一下自己的 Git 仓库配置,就会发现它是 GitHub.com。所以这意味着我必须弄清楚哪些客户端基于 HTTP,哪些客户端基于 Git,哪些是 SSH,之类的各种事情。如果我能回到过去并与 GitHub 的创建者交流,让他们把所有 Git 流量都放在 git.github.com 上,我肯定会这么干的;但我必须弄清楚端口 443 上都在跑什么,诸如此类。因此,我们必须在 HAProxy 配置中做很多疯狂的事情来解决所有问题。
我们有诸如 Git、SSH 和 MySQL 之类的客户端,还有 GLB 的内部和外部版本,用来路由所有流量。另外 Git 有一点很特别,那就是它不支持重定向或重试之类的功能。如果你在拉取 Git 的过程中曾经按过 Control+C,你肯定知道它会从头重新开始;因此 GLB 最重要的任务之一,就是我们要避免任何种类的连接重置或类似的事情,以免干掉 Git 的拉取和推送。
有一样功能我们是没法支持的,那就是 TCP Anycast。我们的设计完全是围绕在 GeoDNS 之上构建事物,并使用 DNS 而非 Anycast 来管理我们的流量。我们跨多个提供商和机构来管理 DNS,并使用开源项目 OctoDNS 来完成所有这些工作。这是我与 GitHub 的另一位工程师 Ross McFarland 一起开发的项目。
在网络的边缘,我们使用 ECMP 和客户端发出请求,而 ECMP 则将这些客户端分片到我们的 GLB 导向器层上。GLB 导向器是一个基于 DPDK 的 L4 代理。它使用直接代理返回(应用通用 UDP 封装),对客户端和 L7 代理完全透明。
从导向器这里,请求被转发到 HAProxy 上,所有这些操作都主要使用 Consul 和 Consul-Template 以及称为 Kube Service Exporter 的工具来管理,后者是我们在 Kubernetes 和 HAProxy 之间的一种桥梁。我们完全不使用任何入口控制器。我们直接与 kube 节点上的 Nodeports 对话,而 Kube Service Exporter 简化了整个过程,而且它也是完全开源的。
下面我们来深入研究 GLB 本身,首先关注导向器,然后是代理的具体内容。
首先,我们使用等价多路径(Equal-Cost Multi-Path,ECMP)对跨多个服务器的单个 IP 进行负载均衡,就像前面提到的一样,它基本上就是为跨多个机器的特定 IP 哈希一个客户端。我们将其称为跨多个机器“拉伸 IP”。
之前我们将 GLB 的设计分为 L4 代理 /L7 代理。这种设计的一个不错的特性是,一般来说,L4 代理不会经常更改,而 L7 代理则每小时或每天都会更改一次;因此我们在更新这些配置时可以减轻一些风险。
我们在导向器中采取了很多谨慎措施,以尽可能减少状态。GLB 导向器完全没有像 IPVS 那样的状态共享。我们不会在节点之间进行任何类型的 TCP 连接多播共享或类似操作。我们要做的基本上就是建立一个转发表,该转发表在每台主机上都是一样的,并且和 ECMP 有些相似;我们将客户端哈希到这个转发表上,然后将它们的请求路由到后端中的特定代理。
每个客户端都使用 zip 哈希对它们的源 IP 和源端口进行哈希处理,然后把这些哈希基于称为集合哈希(rendezvous hash)的工具分配给 L4 代理。集合哈希有一个很好的属性,它可以在更改转发表时仅重置 1/n 个连接。它们被分配给特定的主机。这样,为了尽可能不断开 TCP 连接,我们只断开了与发生故障的主机连接的客户端的连接。作出这些更改的另一种方式是,在每个导向器主机上都有一个运行状况健康检查程序。它们不断检查代理主机,进行更改并更新这个转发表。
这个哈希表也是协调代理主机维护的一种方式。我们在导向器内部有一个状态机,我们可以转发它,也可以通过它处理每个代理主机,图上就是一个示例。这让我们可以将代理主机拉入和退出生产环境,还能清空主机在将它们填充回去,而不会破坏这些连接。这种设计的唯一缺点是,我们必须使状态表在所有导向器之间保持同步,这只在我们要更新状态表以做维护等情况下才会成为问题。这种情况下代理主机就会失败,因为我们处于一种怪异的状态,其中流量以一种奇怪的方式被路由。所幸这种事情很少见,我们没见过那么多故障。
为了在发生故障时减少连接重置的状况,我们引入了一种称为“故障转移第二次机会”的操作,我认为这也是 GLB 导向器的一种简洁且新颖的设计:为了允许最近失败的主机完成 TCP 流,我们有一个名为 glb-redirect 的 IP 表模块;在代理主机上,该模块检查我们在每个数据包中发送给代理主机的额外元数据,从而将这些数据包从活动主机重定向到最近发生故障的主机上。这样,如果发生故障的主机还能处理请求,它将继续处理下去。这种工作机制基本上是这样的,如果主服务器因为数据包不是 SYN 包,或者因为包与已经建立的流对应而无法理解这个包,则 glb-redirect 将接收该包,并将其转发到转发表中现有的“失败主机”上。这里的元数据由 GLB 导向器注入,接下来会介绍细节。
为了提供这种元数据,我们使用通用 UDP 封装(Generic UDP Encapsulation)。我们在 GLB 设计阶段的早期就决定使用直接服务器或直接代理返回。这种设计有很多影响,这里讲一些重点的部分。首先,它简化了导向器的设计,因为我们只处理一个方向的数据包流。这也让导向器对客户和 L7 代理都是透明的。因此我们不必为 X-Forwarded-For 之类的事情烦恼。我们还可以向代理层的每个数据包添加额外的元数据,以实现 “second-chance flow”。
我们使用称为通用 UDP 封装的相对较新的内核功能来执行这种操作。我们将元数据添加到这个 GLB 私有数据域中。在引入通用 UDP 封装之前,我们使用了内核中称为 Foo-over-UDP 的某种功能,我认为这两种事物都是来自谷歌的。基本上,结果就是我们将所有目的地为 L7 代理的 TCP 会话都包装在一个特殊的 UDP 包中,然后在代理端解包,就好像 HAProxy 直接从客户端看到它一样。那么导向器的内容就讲的差不多了,下面开始谈代理的事情。
我们很好地构建了我们的代理,或者一般来说是 GLB,但更具体地说,我们的代理层基本上是在服务集群中。上面的树形图提供了高层次的配置概览。
这种做法导致的结果是,我们通常会在其作业或服务上拆分 HA 配置,但是我们有许多配置恰好是多租户的。“服务”一词有很多定义,但是我们在 GLB 中给服务下的定义是,服务是单个 HAProxy 配置文件,它可以侦听任意数量的端口或 IP 之类的内容。像我之前提到的那样,其中一些是特定于协议的。我们有 Git 配置;我们有 SSH 配置;我们有 HTTP、SMTP 和 MySQL。
然后,在我们看来“集群”基本上就是这些 HAProxy 服务或配置的集合,它们被分配给特定区域或数据中心,然后我们为它们命名。在树图中可以看到,我们在一个集群中有一个区域和一个数据中心,而该集群由一堆服务组成。
这种设计落地到现实世界,结果就是我们最终在 CI 中构建了所有 HAProxy 配置。这些是预构建的配置文件打包工件。部署的所有内容最后都成为了一种甚至还没见到服务器时就已经预先构建好的配置。
我们曾经使用 Puppet 来部署这些配置,然后我们决定搞一些打包的配置工件出来。这些配置包与 HAProxy 本身分开部署,而 HAProxy 实际上是由 Puppet 管理的。如你在 GitHub 的屏幕截图中所见,我们为 GLB 本身提供了 48 个配置包,你看过其中某个的话就会知道,它几乎就是用 Jenkins 构建的矩阵。也就是说,这里的 48 个构建代表了我们拥有的每个数据中心、集群和站点组合配置。
接下来,就像在做 CI 作业的时候那样,我们会运行大量测试。我在 GitHub 的前六个月工作,就是为 GitHub 的上一代负载均衡器编写 500 个集成测试。因为我们对它的工作机制不了解,也不了解配置文件的生成方式,所以为了迁移到新系统,我们必须采用某种方式编写测试,然后确保新配置能够继续工作。在 CI 作业期间,我们有一个测试套件,其在我们的测试环境中配置一个具有真实 IP 和实时后端的完整 HAProxy GLB 堆栈。然后我们使用数百个针对性的集成测试,这些测试用上了 curl、Git、OpenSSL 和我们可以想到的所有客户端,以对所有这些 HAProxy 实例运行测试,并让被正确终止和路由的请求失效。
通过这种开发流程,我们就可以有效地使用 HAProxy 配置进行测试驱动的开发工作,我认为这是非常简洁的。在截图中你可以看到一些测试示例;这些测试完成度达到了 100%,过去的五年一直表现出色。
现在,我们希望在 CI 中部署我们的配置。CI 通过后,我们可以将其部署到 GLB 集群中 HAProxy…的一台或多台主机中。部署都是特定的 Git 分支。就像开源项目一样,我们使用了 GitHub Flow、PR 等等功能。我们通常从“无操作”部署开始,这样就能预览将要做出的更改,并对比当前配置和新配置之间的区别。如果结果看起来是正确的,则我们会继续进行金丝雀部署。金丝雀部署通常是在单个节点上进行的,然后我们可以观察这个节点的客户流量,并确保一切都能顺利工作。接下来,我们可以部署到整个集群,并将更改合并到主集群中;这些工作都是通过 Slack 完成的。
一旦有一台代理主机部署了配置,基本上实时更新就开始了。而这一切都是由 Consul-Template、Consul 和 Kube Service Exporter 编排的。对于 Kubernetes,我们使用自己的开源项目 Kube Service Exporter 来管理服务和 Consul。基本上,Kube Exporter 是在 Kubernetes 中运行并导出 kube 服务数据的服务。Kube 服务不同于 GLB 服务,前者与 Kubernetes API 对话,拉回 Kubernetes 服务信息元数据并将其转储到 Consul 中。
然后,我们从 Consul 中获取这份数据,并使用 Consul-Template 根据该数据构建 HAProxy 配置。就像我提过的那样,我们绝不使用 Kubernetes 入口控制器。我们直接与每个 kube 节点上的 Nodeport 对话。在最初部署 Kubernetes 的过程中我们发现,至少对于我们来说,Kubernetes 入口控制器是一种实际上并不需要的间接访问机制,因为我们已经拥有了如此强大的负载平衡基础架构,并且已经解决了 DNS 之类的问题。
一旦部署了一个配置,我们可能就需要进行维护工作了。上图是服务器后端线路的示例。为了进行维护工作,我们在每台 GLB 代理主机上都使用了一个小型服务,称为 Agent Checker。Agent Checker 知道如何对话,知道如何处理 HAProxy 中的 agent-check 和 agent-send。Agent Checker 将有关后端及其当前状态的元数据存储在本地配置文件中,然后由 Consul 和 Consul-Template 管理。因此,我们可以在整个集群中作出更改。下图是我们使用 ChatOps 拉入服务器,和拉出服务器以备维护的具体方式。
现在客户流量上来了,我们显然会想要监控这些流量。我们几乎记录了能想到的所有内容,并将它们全部转储到 Splunk 中,以便对数据切片和切块。我们还做了一些事情,例如使用 HAProxy 映射将 IP 地址映射到国家和自治系统编号上,以便我们追踪性能并提交报告。
我们还为每个请求设置了唯一的请求 ID,然后确保 GLB 后面的服务在整个流程中都包含该标头,以便我们跟踪整个堆栈中的请求。这里有一些 Splunk 的屏幕截图。最上面的是按国家 / 地区划分的客户端连接时间,最下面的是按自治系统划分的客户端连接时间。我必须加进去自 Splunk 的服务器指标,因为它确实很大。同样,我们在导向器和 HAProxy 里也会跟踪许多指标。
目前我们将所有这些数据都转储到 DataDog 中。我们可以将这些数据切片和切块,我们还有一个自定义的 DataDog 插件,该插件为我们要切片和切块的元数据添加了一堆标签。因此在这里,我们可以按数据中心、群集、服务主机之类的标准来对 GLB 群集分类。GitHub 上的所有团队都使用这些仪表板来监视 GLB,及其背后服务的运行状况和性能,并对任何类型的问题发出警报。
GLB 就讲到这里吧。正如我所提到的,它自 2016 年以来一直处于生产环境,并且几乎处理了 GitHub 内部和外部每个服务的所有请求。
评论前必须登录!
注册