“逃离”单体,GitHub的微服务架构实践
GitHub 创建于 2008 年,其宗旨是为开发人员托管和分享代码提供便利。GitHub 的创建者也是开源贡献者,他们在 Ruby 社区非常有影响力。正因为如此,GitHub 的架构深深地扎根于 Ruby on Rails。
在公司的整个发展历程中,我们雇佣了世界上最好的 Ruby 开发人员,帮助我们扩展和优化代码库。如今,我们的平台上已经有超过 5000 万名开发人员,每年有超过 8000 万个 pull 请求合并,全球各大洲有超过 1 亿个代码存储库。
如你所见,这个单体架构已经带我们走得很远。一个演进了 12 年的代码库,每天要协调多次部署。我们有一个规模很大的平台,每天处理 10 亿次 API 调用,我们还提供了一个高性能的用户界面,专注于完成这项工作。
在过去 18 个月中,GitHub 内部经历了快速增长。我们已经有超过 2000 名员工,为代码库做贡献的工程师数量已经是以前的两倍多。这种增长既包括自身的逐步发展,也包括收购,如 Semmle、npm、Dependabot 和 Pull Panda。
此外,GitHub 是一个高度分散的团队,在疫情发生前,我们就有超过 70% 的员工是在旧金山总部以外的地方办公。GitHub 的员工和承包商要跨六大洲展开协作,他们工作的时区各不相同。我们有 1000 多名内部开发人员,他们有各种各样的开发技能,涉及到许多不同的技术。
显然,我们需要从根本上重新考虑下 GitHub 的软件开发工作。让每个人在参与开发之前都学习 Ruby,让所有人都在同一个单体代码库上进行开发,不再是扩展 GitHub 最高效、最优化的方法。根据康威定律,任何组织设计的系统,其结构都是对组织沟通结构的复制。
反之亦然,单体架构会导致更大规模的涉众会议,更复杂的决策过程,因为交织的逻辑和共享的数据会影响所有团队。
因此我们就想,是不是该从 Ruby on Rails 单体迁出,转向一种微服务架构了?如果是这样的话,我们该如何进行?单体架构和微服务架构各有所长。
在单体环境中,配置并运行应用程序更简单,不用考虑复杂的依赖关系,拉取所有必要的依赖项。新建一个 Hubber,只需几个小时就可以在本机上配置好 GitHub 并运行起来。在单体架构中,代码在有些情况下会更简洁。例如,不用添加超时处理逻辑,也不用考虑如何优雅地处理由网络延迟和中断所导致的失败。
此外,由于所有人都工作在同一个技术栈上,大家对代码库都很熟悉,所以可以方便地将开发人员和团队调去开发单体的其他特性,有利于实现特性的全局最优。考虑到 GitHub 在过去 18 个月中的增长情况,微服务环境的一部分优点吸引了我们。
例如,建立具有系统级所有权的特性团队,通过清晰定义的 API 契约确立职责边界。在遵循 API 契约的前提下,团队有充分的自由选择最适合自己的技术栈。代码库更小意味着阅读更容易、启动速度更快、问题排查更简单。开发人员不用为了提高生产力去理解一整个庞大的代码库的内部运行机制。最重要的是,服务现在可以根据各自的需求单独扩展。
在开始迁移 GitHub 之前,我们花了一些时间考虑为什么要这样做,以及这样做的目标是什么。对我们来说,这是文化上的巨大转变,需要做大量的工作。我们得想好,到底要解决什么问题和痛点。
在 GitHub,这样做可以让超过一半的开发人员(在过去的 18 个月中加入)在单体代码库之外富有成效地开展工作。我们的目标是赋能而非替代。
为此,我们得接受这样一个现实,GitHub 未来的特性将基于一个单体 - 微服务混合的环境。也就是说,对于我们来说,维护和改进现有的单体代码库仍然很重要。有一个很好的例子是,我们最近升级到了 Ruby2.7。感兴趣的话,可以从 GitHub 官方博客上了解我们做了什么,以及我们总体上如何改进系统。
良好的架构始于模块化。拆分单体的第一步是考虑基于特性功能分割代码和数据。这个过程可以在真正在微服务环境中拆分之前在单体中完成。使代码库易于管理,通常都是一种良好的架构实践。确保每个服务都有自己的数据,并且能够控制对这些数据的访问,而且只能通过明确定义的 API 契约访问。
我看到,在很多情况下,人们会首先抽出代码逻辑,但仍然使用单体的共享数据库。这往往会导致分布式单体,这是最糟糕的单体,同时也是最糟糕的分布式。没有获得任何好处(比如,单独快速地向生产环境中部署一组特性),却还要应对微服务的复杂性。
正确地拆分数据是从单体架构转向微服务的基础。这里将稍微详细地介绍下 GitHub 的做法。
首先,我们在现有的数据库模式中识别功能边界,并按照这些边界将实际的数据库表分组。例如,我们将所有存储库相关的表分到一起,所有用户相关的分到一起,所有项目相关的分到一起。我们将生成的功能分组称为模式域,并记录在 YAML 定义文件中。现在,这个文件就成了事实来源。在数据库模式中添加或删除表,都要更新这个文件。我们通过一种静态分析测试方法来提醒开发人员,在修改数据库模式时,要更新这个文件。
接下来,对于每个模式域,我们找了一个分区键。这是一个共享字段,将一个功能组中的所有信息联系在一起。例如,存储库模式域(其中包含所有与存储库相关的数据,如问题、pull 请求、评审意见)使用存储库 ID 作为分区键。最终,创建数据库模式功能组帮助我们将数据拆分到微服务架构所需的不同服务器和集群上。
对于当前的跨域查询,我们做了修复,以防数据拆分对产品造成破坏。在 GitHub,我们在单体中实现了一个查询监视器来帮助我们检测,并在发现跨域查询时发出告警信息。我们会根据域边界,把这些查询拆分并重写成多个,并在应用程序层实现必要的连接。在划分完功能组后,我们开始通过一个类似的过程,进一步将数据分片到相应的租户组。
GitHub 有超过 5000 万用户和 1 亿个存储库,在这样的规模下,功能组可能会变得非常大。这时,分区键就派上用场了。例如,一种简单的方法是根据数值范围将不同的用户分配到不同的数据存储。更常见的可能是根据每个数据集的特性(如区域和大小)所做的逻辑分组。Tenantizing 是一个很好的方法,可以将数据存储故障的爆炸半径限制在客户的一个子集里,而不是一下子影响到所有人。
我们已经花了很多时间讨论数据拆分的重要性。现在,我们换个话题,介绍下从单体中抽取服务的基础工作。一定要记住,依赖方向只能从单体内到单体外,不能反过来,否则,我们最终会得到一个分布式单体。也就是说,当从单体中抽取服务时,要从核心服务入手,然后逐步到特性层面。
接下来,找出开发人员在单体环境中开发时所使用的助力工具。随着时间的推移构建一些共享工具以方便单体开发,这是很常见的。例如,我们的特性标识,可以让单体开发者安心地将新特性从测试环境转到生产环境,因为在这个过程中,他们可以通过这个标识控制谁能看到这些特性。将助力工具转移出来,让开发人员在单体之外也可以使用这些工具。
最后,在新服务上线运行后,务必要删除旧的代码路径。通过工具来识别谁在调用这个服务,并规划好如何将流量全部导向新服务,这样你就不用老是为两套代码提供支持了。在 GitHub,我们使用一个名为 Scientist 的工具帮我们处理这种上线,我们可以用它并排运行和比较新旧代码路径。
在 GitHub,我们决定首先抽取的核心服务是身份验证和授权。身份验证相当复杂,因为所有东西都依赖于它。网站和 Git 操作之间有一大堆的共享逻辑。也就是说,如果 github.com 宕掉了,那么 Git 系统就无法访问了,即使是使用命令行窗口,也无法执行像 pull、push 这样的 Git 操作。这就是为什么把这些基础部分抽取出来如此重要,那可以让主要功能脱离单体而运行。
对于我们来说,身份验证已经很简单,因为我们已经在单体外部将它重写为一个镜像服务。当前的 Rails 应用程序(即我们的单体)使用 Twirp(这是一个 gRPC 风格的服务到服务通信框架)和它通信,依赖方向是由内到外。
监控、CI/CD、容器化都不是什么新概念,但为了支持从单体到微服务的转型,节省时间,加速向微服务的过渡,运营要做必要的改变。在修改这些工作流时,要时刻记着微服务的特性。与为一个大型单体运行单个高度定制化的管道相比,为众多小型的、独立运行的、基于不同技术栈的服务提供运营支持存在很大的差别。将监控从功能调用指标升级为网络指标和契约接口。推动实现自动化程度更高、更可靠的 CI/CD 管道,并使其可以在服务之间共享。使用容器化技术支持各种语言和技术栈。创建工作流模板以实现重用。
例如,在 GitHub,我们创建了一个自助服务运行时平台,可以用于微服务的打包交付。其目的是大幅减轻每个团队创建微服务时的运营负担。它提供了现成的 Kubernetes 模板,可自由使用的 Ingress 负载均衡设置。它可以将日志自动提取到 Splunk,并集成了我们内部的部署流程。这样,任何团队想要试验或上线一个新的微服务都会更容易。
到目前为止,我们主要讨论的还是结构性变化,以及从单体成功过渡到微服务架构所需要的基础工作。此后,任何新特性都应该创建成单体外的一个微服务。
下一步,找一些简单的小特性从单体中迁移出来,例如,那些没有复杂依赖和共享逻辑的特性。在 GitHub,我们是从 webhook 推送和语法高亮开始的。我们希望在迁移更多更大的单体功能之前,找出常见的模式和两种架构之间的差别。我们是根据产品和业务价值来确定微服务的大小。
我们通过查找经常一起更改和部署的代码和数据,来确定耦合度较高的特性或功能,并以此为基础,自然地划分成可以独立于其他部分单独迭代和部署的分组。此外,专注于产品和业务价值,还有助于组织内跨工程团队、产品和设计开展紧密合作。请注意,拆分得太小往往会增加不必要的复杂度和开销。例如,需要维护单独的部署密钥,更多的服务台职责,以及由于缺少知识共享而导致的单点故障。
从单体转向微服务是重大的模式转变。在这个过程中,不管是软件开发流程,还是实际的代码库,都会发生很大的变化。在最后一部分内容中,我们将快速了解下服务之间的通信以及失败机制(designing for failure),这两个都是微服务开发中非常重要的概念。
服务之间的通信方式有两种:同步和异步。使用同步通信,客户端在发送请求后会等待服务器的响应。使用异步通信, 客户端在发送请求后不会等待响应,每条消息都可以由多个接收者处理。在 GitHub,我们使用 Twirp 实现单体与单体外部核心服务(如授权)之间的同步通信。
然而,随着越来越多的服务移到单体之外,同步通信开始变得非常低效。而且,那还导致了服务之间的紧耦合,背离了迁移到微服务架构的初衷。更好的做法是创建一个共享的事件管道,协调多个生产者和消费者之间的消息。在 SendGrid,我们使用的就是这种架构。
由于服务不再是运行在一台服务器上,所以考虑网络通信中的延迟和故障非常重要。对于大部分暂时的网络问题,使用一种简单的重试机制,定义好重试频率和最大重试次数,就足够了。可以考虑使用指数退避让重试逻辑变得更加智能。例如,随着重试次数的增加延长等待时间,而不是间隔同样的时间,从而缓解那些因为过载而无法响应的服务器的压力。作为一种自我保护和自愈机制,还可以在服务之间增加断路器。例如,在多次尝试失败之后,断路器会打开,在服务恢复之前,不再允许额外的请求进入。为服务设置超时时间,这样服务就不会一直等待外部服务的响应。设法实现优雅的失败,可以向用户展示友好的提示信息,或者恢复到缓存中上一个已知的良好状态。关注用户体验,做对企业有益的事。
本文前 4 部分主要介绍了在开启从单体到微服务的旅程之前应该了解的基础内容。关注迁移原因。考虑模块化和数据拆分。从核心服务和共享资源入手,做必要的运营调整。做好这些准备,整个组织的微服务转型之旅就会更加令人愉快。接下来,我们讨论了从哪里入手,以及如何将微服务与产品和业务价值联系起来。最后,我们介绍了微服务的两个关键概念:服务之间的通信和构建弹性系统。