对于客户来说,一项云端协作服务必须可靠、响应迅速。
自公司成立以来,某海外大型科技公司一直在持续扩展规模。如今,这家公司服务着全球各个时区超过 7 亿注册用户,每秒至少处理 30 万次请求。对于一家初创公司来说运行良好的系统,并不一定能很好地支撑这样的规模。因此,团队需要为内部系统设计一种新的架构模型,同时找到一种不会影响用户体验的迁移方式。
在这篇文章中,我们将介绍:为什么以及如何将一个大型 Python 单体应用逐步演进为内部托管平台。这个平台在提供面向服务架构,也就是 SOA 大部分优势的同时,尽可能降低了传统服务所有权带来的运维成本。

为什么说单体架构应该是一种有意识的选择?
这家公司的大多数软件开发人员都会参与服务器端后端代码开发,而所有服务器端开发都发生在服务器端单体仓库中。
团队主要使用 Python 进行服务器端产品开发。这个 Python 单体服务已经拥有超过 300 万行代码。
它确实有效。但随着业务增长,团队逐渐意识到,单体架构也开始成为瓶颈。
开发人员每天都要面对单体架构带来的各种意外后果。他们编写的每一行代码,无论是否愿意,都会成为共享代码。他们无法选择哪些代码应该共享,哪些代码最好只保留在某个具体端点中。
同样,在生产环境中,无论各个端点的稳定性、重要性或所有权如何,它们的命运都与其他所有端点紧密绑定在一起。
2020 年,团队启动了一个项目,目标是拆分单体架构,并将其演进为一个类似无服务器体验的托管平台。这样做的目标,是减少代码混乱,让服务及其背后的工程团队从彼此之间的强绑定关系中解放出来。
为此,团队必须同时在架构和运维层面进行创新。例如,在架构层面,团队标准化使用 gRPC,并通过代理实现 gRPC 到 HTTP 的转码;在运维层面,团队引入了自动扩缩容和金丝雀测试等能力。
这篇文章总结了这次单体架构演进过程中的关键理念和经验。
原单体服务:大型 Python 单体架构
截至当时,这家公司的内部服务拓扑可以被看作一种“太阳系”模型:许多产品功能仍由单体应用提供,而一些平台级组件,如身份认证、元数据存储、文件系统和同步系统,已经被拆分为独立服务。
在服务器端仓库中,大约一半的提交都会修改大型 Python Web 单体应用。

这个单体应用是公司历史最悠久的服务之一,由早期创始团队在 2007 年创建。它长期以来很好地支撑了公司的发展。但随着工程团队多年持续推出新功能,代码库自然增长,也带来了严峻挑战。
单体应用为什么会变得复杂混乱?
这个单体应用最初采用的是小型开源项目中常见的简单模式:库、模型、控制器。它没有集中治理机制,也没有足够的防护措施来保证代码库的长期可持续性。
多年以后,这个单体应用逐渐演变成公司内部最混乱、最复杂的代码库之一。//monolith/controllers/ … //monolith/model/ … //monolith/lib/ …
原单体应用的代码结构。
由于代码库由多个团队共同维护,没有哪个团队对整体代码质量拥有明确责任。例如,为了解决某个产品功能的开发瓶颈,团队可能会引入循环导入,而不是重构代码。
这种做法在短期内可以加快功能发布速度,但会显著降低代码库可维护性,导致问题不断累积。
单体架构如何影响代码推送节奏?
团队每天都会将这个单体应用部署到生产环境,为所有用户提供服务。
然而,由于数百名开发人员都在维护同一套代码库,每天至少出现一个严重 bug 的概率相当高。这会导致整个单体应用需要回滚或选择性部署,也让开发人员的代码推送节奏变得不规律、不可靠。
一些常见的软件交付最佳实践都指出,快速且一致的部署,是提升开发人员效率的关键。而团队在这方面远未达到理想状态。
不稳定的代码推送节奏,会给开发过程带来不必要的不确定性。
例如,如果一位开发人员计划在某一天发布产品,他很难确定自己的代码应该在前一天、前两天,还是更早之前提交到代码仓库。因为另一位开发人员的代码可能会在发布当天导致某个完全无关的组件出现严重错误,从而需要回滚整个集群,而这与他的代码毫无关系。
单体架构带来了哪些基础设施债务?
由于系统由数百万行代码组成,基础设施改进需要更长时间,甚至有些改进根本无法实现。例如,仅在非关键路由上分阶段部署新版本 HTTP 框架或 Python 代码,已经变得不可能。
此外,原单体应用使用的是一个过时的 Python 框架。这个框架在公司大多数其他服务中都没有使用,外部系统中也很少见。与此同时,内部基础设施已经发展到广泛使用 gRPC 等行业标准开源系统。
但原单体应用仍然停留在一个已弃用的旧框架上。毫不意外,这个框架性能较差,而且由于一些晦涩难懂的 bug,维护起来非常棘手。例如,这个旧框架只支持 HTTP/1.0,而现代库已经普遍将 HTTP/1.1 作为最低版本。
此外,内部基础设施中开发或集成的各种能力,例如统一指标和链路追踪,都必须以非常笨拙的方式重新实现,才能在基于不同内部框架构建的单体应用上运行。
过去几年,团队启动过多个工作流来应对这些问题。并不是所有尝试都取得了成功,但即使是后来被放弃的方案,也为当前解决方案铺平了道路。
SOA 服务化:运营独立服务的成本
团队曾经尝试拆分单体应用,作为推进面向服务架构,也就是 SOA 计划的一部分。
SOA 的目标,是为各项产品功能建立更清晰的抽象和关注点分离。这正是团队希望在单体应用中解决的问题。
当时的执行计划很简单:让团队能够轻松地在生产环境中运行独立服务,然后逐步将单体应用的各个部分拆分成独立服务。
SOA 项目有两个主要里程碑。
第一,让在单体应用之外构建服务变得可行且简单。具体包括:
- 将身份管理等核心功能从单体架构中提取出来,并通过 RPC 暴露能力,从而允许在单体应用之外构建新功能;
- 建立最佳实践和生产就绪流程,使多个承载客户流量的新服务能够平稳、可扩展地上线。
第二,将单体应用拆分成由不同团队拥有和运营的更小服务。
SOA 的实施过程漫长而艰难。经过一年半时间,团队终于完成了第一个里程碑。然而,第一个里程碑的执行经验,也暴露了第二个里程碑中的问题。
随着越来越多团队和服务被引入客户流量的关键路径,团队发现,维持高可靠性标准变得越来越困难。当服务层级继续向上延伸,远离核心功能,并要求产品团队运行服务时,这个问题只会变得更加严重。
为什么说没有一种架构方案适合所有问题?
基于这一认识,团队重新评估了问题。
团队发现,产品功能可以大致分为两类:
- 大型复杂系统,例如围绕文件共享的所有逻辑;
- 小型、独立的功能,例如首页。
举例来说,“共享”服务包含围绕访问控制、速率限制和配额的有状态逻辑。另一方面,首页只是元数据存储和文件系统服务之上的一个相对简单的封装。它不会频繁变化,日常运维负担和故障模式也非常有限。
事实上,这项服务提供的大多数路由,在运维问题上都有一些共同点,例如外部流量意外激增,或底层服务中断。
这让团队得出了一个重要结论:
小型、独立的功能并不需要作为独立服务来运行。这正是团队开发内部托管平台的原因。
对于产品团队来说,为一些小型、简单的功能做容量规划、设置复杂告警机制,以及配置多地部署,都是不必要的开销。团队真正需要的是一个可以编写逻辑的地方。当用户访问特定路由时,这些逻辑能够自动运行;当路由错误过多时,团队能够收到基本的自动告警。
他们提交到代码仓库的代码,应该能够持续、快速且稳定地部署。
大部分产品功能都属于这一类别。因此,内部托管平台应该针对这类场景进行优化。
与此同时,大型组件仍应继续作为独立服务存在,托管平台可以与它们和谐共存。大型系统可以由规模更大的团队运营,这些团队能够可持续地管理系统健康状况。他们也应该自行管理推送计划,并设置专门的告警和验证机制。
内部托管平台:从单体架构到服务化的混合式方法
鉴于原单体应用存在根本性的可持续性问题,同时团队也认识到,将单体应用拆成大量小服务并不是解决所有问题的正确方式,于是推出了一个面向独立功能场景的内部托管平台。
这个平台采用混合式架构。它为产品开发人员提供类似无服务器系统的使用界面和体验,同时在后台由自动配置的服务提供支撑。
换句话说,这个平台的目标,是在尽可能降低服务运维成本的同时,提供 SOA 的大部分优势。
这个平台采用“托管”模式。这意味着,在平台中编写代码的开发人员,只需要编写接口和端点实现。平台会自动创建生产集群来服务这些端点。平台团队则负责向这些集群推送代码并进行监控。
这与开发人员为单体应用贡献代码时的体验形成了鲜明对比。

内部托管平台的设计目标是什么?
在设计这个内部托管平台时,团队主要考虑了五个理想结果。
1. 改进代码结构
原单体应用缺乏真正的代码共享抽象,导致代码高度耦合。高度耦合的代码不仅难以理解和重构,在修改时也更容易引入 bug。
团队希望引入一种结构,降低代码耦合度,让新代码更容易阅读和修改。
2. 独立且一致的推送体验
当一切运行正常时,原单体应用的推送体验其实非常好。产品开发人员只需要专注于提交代码,代码会自动推送到生产环境。
然而,前面提到的推送隔离不足,导致这种体验并不稳定。
团队希望创建一个平台,让团队不会因为无关代码中的错误而导致推送受阻,并为未来团队自行推送代码奠定基础。
3. 尽可能减少运维工作
目标是在保留单体应用运维优势的同时,提供一定程度的服务灵活性。
为此,团队设置了自动容量管理、自动告警、自动金丝雀测试和自动推送流程,确保产品开发人员能够顺利从单体架构迁移到托管平台。
4. 统一基础设施
团队希望将所有服务统一到 gRPC 这类标准开源组件上,不再重复造轮子。
5. 提供隔离性
某些功能,例如首页,比其他功能更重要。团队希望能够独立运行这些功能。这样一来,一个功能的过载或错误,就不会波及单体应用的其他部分。
团队也评估过使用现成解决方案来运行这个平台。但为了降低迁移风险并控制工程成本,最终决定继续使用公司其他团队也在使用的同一套部署编排平台来托管服务。
不过,团队决定移除一些自定义组件,例如自研请求代理,并用满足需求的开源系统来替代它们。
技术设计:如何从 Python 单体应用拆分到托管平台?
这个项目包含以下几个关键步骤。
组件化
团队需要将代码库按功能拆分成组件,以防止未来继续混乱增长。
具体来说,需要:
- 强制每个组件只能有一个所有者,这样非所有者无法随意向组件中添加新功能;
- 鼓励减少共享库的使用,增加通过 RPC 进行代码共享的方式。
编排
团队需要在部署编排平台中,以少于 50 行样板代码,自动将每个组件配置为服务。
同时,还要:
- 配置代理,将特定路由请求发送到正确服务,而不是简单地把所有请求都发送到单体应用节点;
- 配置服务之间使用 gRPC 而不是 HTTP 进行通信。
运维化
团队需要为每个组件自动配置每日运行并推送到生产环境的部署流水线。
同时,还要:
- 为每条推送流水线设置自动告警和自动回归分析,以便出现问题时自动暂停并回滚;
- 根据流量情况,通过每个组件的自动扩缩容器自动分配额外主机,以扩展容量。
下面我们详细介绍这些内容。
如何通过组件化拆分单体应用?
内部托管平台通过“路由组”对路由进行逻辑分组。
平台引入了一种逻辑上原子化的路由分组方式。例如,首页路由组包含用于构建首页的所有路由;导航路由组包含网站导航栏使用的所有路由。
为了部署这个平台,团队与产品团队合作,为原单体应用中的每个路由分配对应的路由组。最终,团队为超过 5000 个路由配置了 200 多个路由组。
路由组是拆分单体应用的关键工具。//platform/home/ … //platform/nav/ … //platform/<some other route_group>/ …
平台代码结构按路由组组织。
每个路由组在代码库中都有一个私有目录。路由组的所有者拥有该目录的完整所有权,可以自由组织目录内容,其他人无法从中导入代码。
这种代码结构,从本质上打破了原单体应用的代码单体格局。它要求每个端点都位于私有目录中,并将代码共享变成一种明确选择,而不是贡献单体代码时的意外结果。
将路由组代码组织到目录路径后,团队就可以自动生成通常伴随生产服务出现的生产配置。
服务器端代码使用构建系统进行构建。团队通过构建系统中的“可见性规则”来阻止不合理导入。这个功能允许库所有者控制哪些代码可以使用他们的库。
打破代码库中的循环依赖
为了拆解代码库中的循环依赖,团队必须打破大部分 Python 导入循环。
这项工作耗费了数年时间,期间编写了大量脚本,也进行了大量繁琐的清理和重构。随后,团队通过构建系统的可见性规则机制,防止回归和新的导入循环再次出现。
如何通过编排实现独立集群?

在这个托管平台中,每个路由组都是一个独立集群。这带来了三个重要优势。
第一,默认隔离。行为异常的路由只会影响同一个路由组中的其他路由,而这些路由通常属于同一个团队。
第二,独立推送。每个路由组都可以单独推送,让产品开发人员能够掌控自己代码推送的一致性。
第三,一致性。每个路由组的外观和行为都与公司其他内部服务一致。因此,基础设施团队提供的各种工具,例如定期性能分析,也都适用于其他团队的路由组。
为什么要统一到 gRPC 服务栈?
部署这个平台的目标之一,是统一服务基础设施。
团队选择采用 gRPC 作为标准。gRPC 是内部广泛使用的工具。为了继续处理 HTTP 流量,团队使用了代理和负载均衡器中内置的 gRPC-HTTP 转码能力。

为了简化向 gRPC 的迁移,团队编写了一个适配器。这个适配器可以接收现有端点,并将其转换为 gRPC 所需的接口,同时设置端点所需的旧版内存状态。
这让团队能够自动化完成大部分迁移代码变更。此外,它也有助于在迁移过程中保持端点同时兼容原单体应用和新平台,从而让团队能够安全地在不同实现之间迁移流量。
如何实现托管式运维体验?
内部托管平台的关键优势在于它提供了托管式体验。
开发者可以专注于编写功能,而不必担心生产环境中运行服务的诸多运维细节,同时又能保留独立服务的大部分优势,例如隔离性。
显而易见的代价是,现在 200 多个集群的运维工作都由一个团队承担。因此,作为项目的一部分,平台团队开发了几个工具,帮助有效管理这些集群。
自动化金丝雀分析如何降低发布风险?
原单体应用,以及扩展后的内部托管平台,都是无状态的。因此,系统故障最常见的引入方式之一就是代码变更。
如果能够确保推送防护措施足够严密,就可以消除绝大多数故障场景。

团队通过一个简单的金丝雀分析服务来实现故障检查自动化。这个服务与业界一些常见金丝雀分析系统类似。
每个服务都包含三个部署:金丝雀部署、对照部署和生产部署。其中,金丝雀部署和对照部署只会接收一小部分随机流量。
在推送过程中,金丝雀部署会使用最新版本代码重启。对照部署会使用旧版本代码重启,但会与金丝雀部署同时重启,以确保二者从相同起点开始运行。
团队会自动比较金丝雀部署和对照部署的指标,例如 CPU 利用率和路由可用性,查找金丝雀相对于对照部署可能出现退化的指标。
如果推送成功,金丝雀部署的表现应等于或优于对照部署,推送会被允许继续。如果推送失败,系统会自动停止推送并通知所有者。
除了金丝雀测试之外,团队还设置了告警机制,在整个流程中进行检查,包括单个集群的金丝雀测试、对照部署和生产推送之间的各个环节。
这样一来,如果出现任何问题,系统都可以自动暂停并回滚推送流水线。
错误仍然会发生,错误变更也可能悄然进入系统。这时,平台的默认隔离机制就会发挥作用。出错代码只会影响其所在集群,并且可以单独回滚,而不会阻塞组织内其他集群的代码推送。
自动扩缩容如何减少容量规划负担?
集群策略会产生大量小型集群。虽然这有利于隔离,但也会显著降低每个集群应对流量增长的余量。
在单体架构中,原单体应用是一个大型共享集群,某个路由的 RPS 小幅增长很容易被共享集群吸收。但当每个路由组都是独立服务时,某个路由流量突然增长 10 倍,就会更难应对。
为 200 多个集群进行容量规划,会严重影响团队效率。因此,团队构建了一个自动扩缩容系统。
这个自动扩缩容器会实时监控每个集群的利用率,并自动分配机器,确保每个集群的可用容量始终保持在 40% 以上。
这让团队能够应对流量增长,同时也省去了手工容量规划的麻烦。
自动扩缩容系统会从代理的负载报告服务中读取指标,并使用请求队列长度来决定集群大小。这个话题本身也值得单独写一篇文章。
单体架构演进如何落地执行?
垫脚石,而不是里程碑
此前多次改进原单体应用的尝试,都因代码库规模庞大且复杂而失败。
这一次,即使团队无法完全用内部托管平台取代原单体应用,也希望能持续为产品开发人员带来价值。
因此,项目执行计划采用的是循序渐进的方式,而不是只盯着最终里程碑。这样,即使项目下一阶段由于某种原因失败,每个增量步骤也依然能提供足够价值。
举几个例子:
团队首先加快了原单体应用中的测试框架速度,因为团队知道,在测试中使用新平台的服务栈可能会导致测试时间倒退。
从原单体应用迁移到内部托管平台时,团队面临一个限制:必须显著提升内存效率并减少 OOM 终止。因为迁移后,团队可以在每台主机上部署更多进程,从而降低资源消耗。团队选择专注于提升原单体应用的内存效率,而不是把这些改进与新平台部署强绑定在一起。
团队设计了一个负载测试,用于验证平台最小可行版本是否能够处理原单体应用的流量。在另一个项目中,团队复用了这个负载测试,验证原单体应用在新硬件上的性能。
团队尽可能将工作流简化功能移植回原单体应用。例如,将新平台中的一些工作流改进,移植到了原单体应用的 Web 工作流中。
根据协议不同,原单体应用的开发工作流分为三类:Web、API 和内部 gRPC。团队首先将新平台的重点放在内部 gRPC 上,以降低新服务栈风险,避免一开始就引入 gRPC-HTTP 转码等风险较高的部分。反过来,这也让团队有机会改进内部 gRPC 工作流,而不必同时承担新平台中其他高风险部分的复杂性。
大规模架构迁移会遇到哪些挑战?
如此大规模的迁移,遇到各种挑战也是意料之中的事。这些问题本身足以单独写成一篇文章。这里仅总结其中一些最有意思的问题。
第一,原有 HTTP 服务栈包含一些古怪、出乎意料且难以复现的行为。为了避免回归问题,团队必须将这些行为移植到新系统中。团队通过多种方式推进这项工作:阅读原始源代码,在必要时复用原有库函数,依赖现有集成测试,并设计一套关键测试,逐字节比较新旧系统输出,从而安全完成迁移。
第二,虽然拆分原单体应用在生产环境中取得了成功,但在集成测试框架中启动 200 多个 Python 进程并不现实。因此,团队决定在本地开发和测试场景中,将这些进程合并回单体应用。此外,团队还与构建规则深度集成,使合并操作在后台自动完成,开发人员可以像引用普通服务一样引用路由组。
第三,在生产环境中拆分原单体应用,打破了许多不容易察觉的假设,而这些假设很难通过测试发现。例如,一些基础设施服务为了进行访问控制,将原单体应用的身份信息硬编码在代码中。为了最大限度减少故障,团队制定了细致的分阶段迁移计划,明确每个阶段的风险,并在新系统部署过程中持续监控各项指标。
第四,原单体应用的工程工作流随着单体架构自然演进而不断增长,最终导致工程师需要查询大量上下文信息,才能完成一些看似简单的工作。为了确保新平台优先解决主要工程痛点,团队邀请关键产品开发人员作为设计合作伙伴,并经过多轮迭代,制定了一份能够同时解决产品和基础设施需求的路线图。对于类似的大型研发变革项目,除了架构设计本身,团队还需要管理目标、需求、任务、测试、发布和知识沉淀之间的关系。PingCode 这类智能化研发管理工具,可以帮助研发团队把目标制定、需求评审、开发测试、发布上线和 Wiki 经验沉淀串联起来,让架构演进过程中的协作、追踪和复盘更加清晰。
当前状态
内部托管平台目前已经承担了原单体应用 25% 以上的流量。团队也已经通过测试验证了剩余迁移工作。
团队正按计划继续推进,并将在不久的将来弃用原单体应用。
结论:从单体架构到托管平台,关键不是盲目拆分
这项历时数年的工作带来的最重要经验是:在项目早期阶段,进行周密的代码组织至关重要。
否则,技术债务和代码复杂性会迅速累积。拆除导入循环,并将原单体应用分解为基于功能的目录,可能是整个项目中最具战略意义的部分。因为这不仅防止了新代码继续加剧问题,也让代码本身更容易理解。
通过部署托管平台,团队以一种更审慎的方式拆分了 Python 单体应用。
团队也认识到,单体架构有许多优势,而盲目地将单体拆成服务,会增加工程团队的运维负担。
开发者并不真正关心单体架构和服务之间的区别。他们更关心的是,如何用最低的开销为客户交付最终价值。
因此,能够消除容量规划等繁琐运维工作,同时提供快速发布等高度灵活性的托管平台,代表着未来的发展方向。整个行业也正在朝着这样的方向前进。
文章包含AI辅助创作:Python 单体应用如何演进为内部托管平台:从单体架构到服务化实践,发布者:shang,转载请注明出处:https://worktile.com/kb/p/3973485
微信扫一扫
支付宝扫一扫