本文介绍了GitHub如何从单一架构迁移到微服务架构,并详细解释了一些最佳实践。
旅程开启
GitHub成立于2008年,其宗旨是为开发者托管和共享代码提供便利。GitHub的创始人也是开源贡献者,他们在Ruby社区非常有影响力。这就是为什么GitHub的架构深深植根于Ruby on Rails。在整个公司的发展过程中,我们聘请了世界上最好的Ruby开发人员来帮助我们扩展和优化代码库。如今,我们的平台上有5000多万开发人员,每年有8000多万个拉取请求被合并,各大洲有超过1亿个代码存储库。
正如你所看到的,这种整体式架构已经让我们走了很长一段路。一个有12年历史的代码库每天需要协调多个部署。我们有一个每天处理10亿次API调用的大型平台,我们还提供了一个高性能的用户界面来专注于这项任务。
内部快速增长
在过去的18个月里,GitHub经历了快速的增长。我们有2000多名员工,为代码库做出贡献的工程师数量增加了一倍多。这种增长既包括自身的逐步发展,也包括收购,如Semmle、npm、Dependabot和Pull Panda。此外,GitHub是一个高度分散的团队。疫情爆发前,我们70%以上的员工都在旧金山总部外工作。GitHub的员工和承包商需要跨越六大洲进行协作,他们在不同的时区工作。我们有1000多名内部开发人员,他们拥有各种开发技能,涉及许多不同的技术。
显然,我们需要从根本上重新考虑GitHub的软件开发。让每个人在参与开发之前先学习Ruby,让每个人都在同一个单一的代码基础上开发,这已经不是扩展GitHub最高效、最优化的方式了。根据康威定律,组织设计的任何系统的结构都是组织沟通结构的副本。
相反,单一的架构将导致更大的利益相关者会议和更复杂的决策过程,因为交织的逻辑和共享的数据将影响所有团队。
单体 vs. 微服务
所以我们想,我们是否应该脱离Ruby on Rails,转向微服务架构?如果是,我们应该如何进行?单一架构和微服务架构各有优势。在单一环境中,配置和运行应用程序更简单,无需考虑复杂的依赖关系,也无需提取所有必要的依赖关系。创建一个新的Hubber,GitHub只需几个小时就可以在这台机器上配置和运行。在单片架构中,代码在某些情况下更简单。例如,不需要添加超时处理逻辑,也不需要考虑如何优雅地处理由网络延迟和中断引起的故障。
此外,由于大家都在同一个技术栈中工作,对代码库也比较熟悉,所以方便将开发人员和团队转移到单个单元的其他特性上进行开发,有利于实现特性的全局优化。考虑到GitHub过去18个月的增长,微服务环境的一些优势吸引了我们。
例如,建立了一个拥有系统级所有权的功能团队,并通过明确定义的API契约建立了责任边界。在遵循API合同的前提下,团队有充分的自由选择最合适的技术栈。更小的代码库意味着更容易阅读、更快启动和更容易排除故障。开发人员不必为了提高生产率而了解庞大代码库的内部运行机制。最重要的是,服务现在可以根据自己的需求单独扩展。
务实——以赋能为出发点
:吉图迁移开始 b 之前,我们花了一些时间考虑为什么要这样做,以及这样做的目标是什么。对我们来说,这是文化上的巨大转变,需要做大量的工作。我们得想好,到底要解决什么问题和痛点。在 GitHub,这样做可以让超过一半的开发人员(在过去的 18 个月中加入)在单体代码库之外富有成效地开展工作。我们的目标是赋能而非替代。
为此,我们得接受这样一个现实,GitHub 未来的特性将基于一个单体-微服务混合的环境。也就是说,对于我们来说,维护和改进现有的单体代码库仍然很重要。有一个很好的例子是,我们最近升级到了 Ruby2.7。感兴趣的话,可以从 GitHub 官方博客上了解我们做了什么,以及我们总体上如何改进系统。
良好的架构始于模块化
良好的架构始于模块化。拆分单体的第一步是考虑基于特性功能分割代码和数据。这个过程可以在真正在微服务环境中拆分之前在单体中完成。使代码库易于管理,通常都是一种良好的架构实践。确保每个服务都有自己的数据,并且能够控制对这些数据的访问,而且只能通过明确定义的 API 契约访问。
我看到,在很多情况下,人们会首先抽出代码逻辑,但仍然使用单体的共享数据库。这往往会导致分布式单体,这是最糟糕的单体,同时也是最糟糕的分布式。没有获得任何好处(比如,单独快速地向生产环境中部署一组特性),却还要应对微服务的复杂性。
数据拆分
正确地拆分数据是从单体架构转向微服务的基础。这里将稍微详细地介绍下 GitHub 的做法。
首先,我们在现有的数据库模式中识别功能边界,并按照这些边界将实际的数据库表分组。例如,我们将所有存储库相关的表分到一起,所有用户相关的分到一起,所有项目相关的分到一起。我们将生成的功能分组称为模式域,并记录在 YAML 定义文件中。现在,这个文件就成了事实来源。在数据库模式中添加或删除表,都要更新这个文件。我们通过一种静态分析测试方法来提醒开发人员,在修改数据库模式时,要更新这个文件。
接下来,对于每个模式域,我们找了一个分区键。这是一个共享字段,将一个功能组中的所有信息联系在一起。例如,存储库模式域(其中包含所有与存储库相关的数据,如问题、pull 请求、评审意见)使用存储库 ID 作为分区键。最终,创建数据库模式功能组帮助我们将数据拆分到微服务架构所需的不同服务器和集群上。
对于当前的跨域查询,我们做了修复,以防数据拆分对产品造成破坏。在 GitHub,我们在单体中实现了一个查询监视器来帮助我们检测,并在发现跨域查询时发出告警信息。我们会根据域边界,把这些查询拆分并重写成多个,并在应用程序层实现必要的连接。在划分完功能组后,我们开始通过一个类似的过程,进一步将数据分片到相应的租户组。
GitHub 有超过 5000 万用户和 1 亿个存储库,在这样的规模下,功能组可能会变得非常大。这时,分区键就派上用场了。例如,一种简单的方法是根据数值范围将不同的用户分配到不同的数据存储。更常见的可能是根据每个数据集的特性(如区域和大小)所做的逻辑分组。Tenantizing 是一个很好的方法,可以将数据存储故障的爆炸半径限制在客户的一个子集里,而不是一下子影响到所有人。
从核心服务和共享资源入手
我们已经花了很多时间讨论数据拆分的重要性。现在,我们换个话题,介绍下从单体中抽取服务的基础工作。一定要记住,依赖方向只能从单体内到单体外,不能反过来,否则,我们最终会得到一个分布式单体。也就是说,当从单体中抽取服务时,要从核心服务入手,然后逐步到特性层面。
接下来,找出开发人员在单体环境中开发时所使用的助力工具。随着时间的推移构建一些共享工具以方便单体开发,这是很常见的。例如,我们的特性标识,可以让单体开发者安心地将新特性从测试环境转到生产环境,因为在这个过程中,他们可以通过这个标识控制谁能看到这些特性。将助力工具转移出来,让开发人员在单体之外也可以使用这些工具。
最后,在新服务上线运行后,务必要删除旧的代码路径。通过工具来识别谁在调用这个服务,并规划好如何将流量全部导向新服务,这样你就不用老是为两套代码提供支持了。在 GitHub,我们使用一个名为 Scientist 的工具帮我们处理这种上线,我们可以用它并排运行和比较新旧代码路径。
AuthN/AuthZ 抽取
在 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 部分主要介绍了在开启从单体到微服务的旅程之前应该了解的基础内容。关注迁移原因。考虑模块化和数据拆分。从核心服务和共享资源入手,做必要的运营调整。做好这些准备,整个组织的微服务转型之旅就会更加令人愉快。接下来,我们讨论了从哪里入手,以及如何将微服务与产品和业务价值联系起来。最后,我们介绍了微服务的两个关键概念:服务之间的通信和构建弹性系统。
关于作者
Sha Ma 是 GitHub 软件工程部门的副总裁,负责核心平台和生态系统。在加入 GitHub 之前,她是 SendGrid 工程部门的副总裁,是 2017 年将公司上市的领导团队的一员。