Stripe 单体仓库的开发者环境
原文由 Nelson Elhage 于 发布,订阅该博客
我在 Stripe 工作了大约七年,从 2012 年到 2019 年。在这期间,我使用并参与构建了多代 Stripe 的开发者环境——也就是工程师日常编写和测试代码所用的工具。我认为 Stripe 在设计和打造这套开发者体验方面做得相当不错,离开之后,我也发现自己经常向朋友和同事介绍这套环境的一些特性。
本文试图按我的记忆记录下这套环境的主要特点。我也会尽量回顾做出这些选择时的背景、约束和动因;虽然我认为在当时的情境下这些都是不错的选择,但它们深受业务和技术背景的影响,其他团队很可能需要做出不同的取舍。
需要先说明几点:已经过去了将近五年,我肯定记错了一些具体细节,尽管我对整体图景还是有把握的。我也确信 Stripe 至今仍在持续演进,本文并不代表 Stripe 当下的开发者体验。
另外,虽然我参与过本文所述的许多组件,但我并不想、也无法独占功劳。这里的一切都是在多年时间里逐步演进而来的,凝聚了众多优秀工程师的贡献,无论是在构想愿景还是在具体实现上。
Stripe 的背景
在我所讨论的那个时期,Stripe 的绝大部分代码都是用 Ruby 编写的,并且存放在一个大型的单体仓库中。这套代码支撑着多个服务,彼此之间大量共享代码。Stripe 当然也有越来越多单体仓库之外的、采用其他语言的服务,但总体而言,它们对业务的核心程度要低一些——最典型的是,Stripe API 几乎完全就是单体仓库里的一个 Ruby 服务。本文讨论的工具和体验,主要是为支持单体仓库内的开发而设计和构建的。对其他服务和语言的支持,即便有,也只是顺带的。
Stripe 的这套工具由先后多支团队和个人构建和维护,但我将统称其作者和负责人为“开发者生产力”团队(简称“devprod”),这也是我在职最后几年该团队的名称。
对工具的投入
在我加入后不久,Stripe 就较早地设立了专门负责内部工具和生产力的团队编制——也就是后来打造这套工具的团队,但在最后三四年里,这支团队规模大幅扩张,真正成熟起来。团队成员一直都是非常优秀的工程师,其中包括一些资历很深的个人贡献者。比起后文将要介绍的任何技术选型,我认为这支团队的存在,以及它对开发者工具稳定性和可靠性的投入(稍晚一些,等到团队规模足够大、能够应付完紧急问题并有余力去规划和投入之后),才是 Stripe 开发者体验取得成功的关键驱动因素。技术选型和工程实现固然重要,但必须有足够的人手和持续的维护投入作支撑,才能真正奏效。
环境的这种可靠性和稳定性,尤其是在团队和代码库不断增长和演进的过程中,将是一个反复出现的主题。再好的工具,也难以弥补“时不时就要花一整天来调试开发环境”的损耗,因此把这一点做好,其重要性很容易超过其他几乎所有决策。许多设计选择的目的,都是为了让 devprod 团队能够更轻松、更一致地支持和监控这套工具,实现问题的集中化调试和解决,而不是把负担推给每一位工程师个体。
开发者环境的架构
开发代码在云端运行
对于一个开发者环境而言,有一个决定性问题:开发中的代码是在开发者本地的笔记本电脑上运行,还是需要在集中供应的环境中的实例或容器上远程运行?
多年来,Stripe 工程师两种方式都在用,具体取决于个人偏好,以及在不同时期、不同环境下何种方式能跑通的零散决策和细节。当开发者生产力团队决定投入打造一套唯一推荐的环境时,我们最终选择了在 Stripe 云环境(生产环境之外)中为每位开发者提供独立实例(“devbox”)的方案。在开发过程中,代码都在 devbox 上运行,无论是执行 minitest 测试,还是进行交互式测试和试验。
这些开发者实例由 Stripe 标准的配置管理工具来创建,并且是临时的——一条命令就能销毁你的实例并创建一个新的(系统会保留一定数量的热备实例,因此这一操作通常非常快)。一个注册中心会记录每位工程师当前激活的是哪个实例,供各类工具使用。所有 devbox 都可以通过 ssh 被所有工程师访问,这也让协作和环境问题的调试变得更加容易。
这样的设计意味着,大多数环境配置问题都可以由工具团队或服务负责人集中解决,开发者基本无需操心如何更新自己的开发环境以跟上变化。
引入新依赖
Stripe 在持续为 API 或其他服务新增和重构依赖;例如,在 API 中实现限流就需要一个 Redis 集群。在开发代码跑在笔记本电脑上的模式下,这意味着每台电脑都得安装并可能需要配置 Redis。在开发者电脑上更新配置是一件很棘手的事,往往只能靠工程师之间口口相传:“嘿,我刚拉了 master,现在报了个奇怪的错误——有人知道怎么修吗?”然后同事发来一条对应的配置命令。另一种做法是,把一条安装 Redis 的 brew 命令悄悄塞进某个开发者会定期运行的脚本里,指望以此解决问题,但这种方式也很脆弱,还会时不时拖慢一些工具的运行。
采用 devbox 模式后,负责添加 Redis 的团队本来就要在生产环境中配置它,他们只需添加相应的 Puppet 配置,就能确保 Redis 同样在 devbox 上安装并运行。如果有用户在这条新路径上遇到问题,该团队或 devprod 的成员可以直接 ssh 到他的 devbox 上调试问题,然后直接更新 Puppet 或 Ruby 源码,防止该问题影响到其他用户。
在我在 Stripe 任职的大部分时间里,新功能和基础设施变更一直在不断带来新的依赖,或对现有依赖的各种配置变更;统一采用 devbox 既减轻了这些团队的工作负担,也大幅降低了基础设施变更破坏他人开发者体验的频率,这两点都极具价值。
编辑器与源码管理
在运行开发中的新代码之前,你首先需要通过源码管理获取代码并进行编辑。
在 Stripe,尽管代码在云端运行,git 检出和编辑器却都保留在本地的开发者笔记本电脑上。Stripe 之所以最终采用这种方式,有多方面的原因,包括:
支持多种编辑器和 IDE 环境
有些编辑器(如
emacs或vim)可以较好地通过 SSH 会话运行,有些(如 VS Code 或配合tramp的 emacs)支持用本地界面操作远程文件系统,但很多编辑器并不支持。将代码保留在笔记本电脑上,Stripe 得以让开发者继续使用自己喜欢的任何编辑器。延迟
Stripe 的开发者遍布全球,但在当时尚未在美国以外维护大规模的基础设施。在 200 毫秒以上的网络延迟下编辑远端代码是非常痛苦的。虽然可以在全球部署 devbox,但出于诸多原因会变得相当复杂。
源码的持久性与文件系统性能
将源码的权威副本保留在笔记本电脑上、而不是放在 devbox 上,使得执行环境更容易被视为临时、易抛弃的。这一特性反过来又非常有助于保持环境更新、防止随时间产生漂移。
源码本可以存放在与 devbox 分离的网络存储上(例如 EFS 或 EBS 卷),但除了复杂度成本外,这些方案的延迟都相对较高,而源码操作往往依赖(至少是)快速的文件元数据查询;在没有经过大量艰难调优和优化的情况下,基于 NFS 或 EBS 的
git操作往往会慢得让人难以忍受。
自动同步
然而,将代码放在笔记本电脑上、却在云端执行,又带来了新的挑战:编辑后的代码如何从笔记本电脑同步到 devbox 上?
早在我加入之前,Stripe 就有了一个“sync”脚本,它将文件监听器与 rsync 粘合在一起:监控本地检出的变更并将其复制到 devbox 上。过去,开发者需要在单独的终端或 tmux 窗口中以临时的方式手动启动和看护这个脚本。
后来,开发者生产力团队接管了这个工具并投入大量精力进行打磨,主要目标是增加“精细度”,让它对其他开发者而言无缝、近乎无感。
值得一提的是,他们让同步变得隐式进行,无需任何配置或人工干预:他们与 IT 团队合作,将同步脚本作为 launchd 服务安装到每台开发者电脑上,并利用前文提到的注册中心自动发现对应的 devbox。
他们还在可靠性和对错误及网络问题的自愈能力上投入了大量精力。其中一项“内部”但意义重大的改进是迁移到 watchman 来进行文件监听:在我们的测试中,它是我们找到的迄今最稳健的文件监听器,极大地降低了更新遗漏或监听器“卡死”的频率。我们还得以利用它的一些更高级的特性,稍后会谈到。
总体而言,这些投入取得了显著成效:大多数开发者都能将代码同步视为理所当然的“既定事实”,很少需要去操心或调试它。
同步的一个缺点是,它让编写“操作代码的代码”变得更加困难,例如自动化迁移工具,甚至只是 linter 或代码生成工具(Stripe 最终依赖了若干需要签入代码库的代码生成组件,原因各异)。这在我任职期间始终是一个小痛点;我们通过混合多种策略来应对:
- 在开发者笔记本电脑上运行,并处理随之而来的环境挑战
- 在 devbox 上运行,然后再以某种方式将生成的文件“同步回来”。我们有一个小型协议,可以在“sync-back”包装器中运行脚本,让它们请求将文件拷回笔记本电脑,但这种方式仍然有些笨拙、不够人性化,且偶尔不可靠。
devbox 上的 HTTP 服务
Stripe 的大量代码都在 HTTP 服务内部执行,其中最主要的就是 Stripe API。开发者生产力团队构建了一系列工具来支持这些服务的开发,包括:
- DNS 会将形如
*.$username.stripe-dev.com的主机名解析到该用户当前的 devbox - 每个 devbox 上都运行着一个前端服务,负责终止 SSL、基于客户端证书处理认证/鉴权,并将服务名映射到对应的本地实例
- Stripe 的每个服务都被静态分配了一个本地端口,供前端用于路由流量。
- 例如,API 服务可能被分配到 3000 端口,因此前端会将
api.nelhage.stripe-dev.com转发到localhost:3000
- 一个独立的代理服务在每个端口上监听,并按需启动后端的 Ruby 服务。
- 因此,在首次请求
localhost:3000时,API 服务会被启动,随后保持运行以直接处理后续请求。
- 因此,在首次请求
- 这些服务在 devbox 上使用了 Stripe 的 autoloader,因此只在需要时才加载 Ruby 代码,从而实现快速启动。
- 该 autoloader 还会跟踪每个服务加载了哪些源文件,并监听文件系统变更;如果磁盘上某个已加载的文件发生变化,任何使用该文件的服务都会自动重启以载入变更。
这套基础设施的最终效果是,开发者修改代码后,几乎可以立即——无需任何手动重启——通过一个固定、可在 Stripe 内部共享的 URL 访问到运行着新代码的服务副本。即使重新创建一个新的 devbox,这个 URL 依然保持不变。此外,尽管该功能几乎适用于所有内部服务,每个 devbox 却只会运行该开发者实际在用的那些服务,从而降低了 CPU 和内存开销。
pay 命令
Devprod 还构建并维护了一个名为 pay 的命令行工具,它为一系列 devbox 功能和工作流提供了统一入口。
Devbox 本来就可以通过 ssh 访问(封装为 pay ssh),但 pay 还对最常见的使用模式进行了封装:
pay test ...会在 devbox 上执行minitest测试(底层通过ssh),并将输出和退出状态回传到本地。pay curl封装了curl,提供了一个辅助工具来对 devbox 上的服务执行手动的curl命令,在对 API 端点进行临时手动测试时经常很有用。pay typecheck封装了 Sorbet,用于在 devbox 上对代码进行类型检查
同步屏障
除了提供便利之外,这些工具还提供了一个关键特性:与源码同步流程的集成。
会操作源码的 pay 命令会与同步进程通信,确保在远程执行代码前,同步已经适当地“追赶”完成。这一特性对同步的透明性是一项关键改进:过去,一个常见的“坑”是编辑文件后在同步完成前就启动测试,于是实际上是用旧版本跑测试,却误以为自己在测试新代码。这常常让人误以为改动没有生效,进而导致毫无头绪的排查和极大的挫败感。
通过从笔记本电脑发起工作流,pay 子命令能够保证它们看到的文件系统状态与编辑器一致,而通过与同步机制协作,它们又能确保 devbox 看到的状态“不早于”该状态。
对这种“同步屏障”最直接的实现方式是让(例如)pay test 触发一次新的同步,这会给工作流增加令人烦躁的延迟,即便你根本没有改动任何文件。通过利用 watchman 的 clock 特性,我们得以做得更好,避免不必要的往返。在工程师编辑文件并运行测试的常见情况下,事件的时间线大致如下:
- 用户编辑并保存文件
pay sync被watchman唤醒。它记录下文件系统时钟,并启动一次rsync- 用户启动
pay test命令 pay test请求pay sync等待同步追赶完成- 作为响应,
pay sync会再次检查文件系统时钟 - 由于文件系统自上次保存后未再发生变化,该时间戳与当前正在进行的
rsync所关联的时间戳一致 pay sync因而知道只需等待这次已经在途的rsync命令完成,然后再通知pay test
使用 watchman 的时钟让这一机制对事件的乱序具有鲁棒性;无论 pay test 的调用发生在同步之前、期间还是之后,我们都能获得正确的行为。
这种 pay sync 协调还为观察同步进程的健康状况提供了一个有用的钩子。开发者的笔记本电脑毕竟是笔记本电脑,会经常离线又重新上线。因此,同步失败,甚至长时间的同步滞后,都可能是正常现象,这使得从同步脚本中收集有意义的错误统计变得困难。然而,如果用户运行了一个需要在 devbox 上执行的 pay 命令,这就很好地表明用户期望同步是健康的。因此,如果对同步进程的同步屏障调用失败或超时,那正是向 Stripe 集中式异常追踪系统上报错误的合适时机。这类上报反过来让 devprod 得以监控同步系统和用户体验的整体健康状况。
LSP 与编辑器工具
如前所述,Stripe 工程师可以使用自己选择的任何编辑器,但到 2019 年,开发者生产力团队决定重点投入 VS Code。他们将其定为首选编辑器,并专门为该环境投入工具建设。
其中一部分工作是开发内部插件,带来一些虽小但有意义的体验改进,例如支持对光标所在的具体测试运行 pay test。
然而,更实质性的改进随着 Sorbet——Stripe 的 Ruby 类型检查器——的成熟及其 LSP 服务端实现的出现而到来(LSP 即 Language Server Protocol,它定义了一套接口,让服务端能够以可被多种编辑器消费的方式提供诸如“跳转到定义”或自动补全等语言感知功能)。
Stripe 将 VS Code 配置为通过 ssh 在 devbox 上运行 Sorbet LSP 服务端,像本地 LSP 服务端一样通过 stdin 和 stdout 进行通信。这样做让 Sorbet 得以利用 devbox 上充足的内存(LSP 服务端在大型代码库上通常都很耗内存,Sorbet 也不例外),并运行在更便于 Sorbet 团队监控、调试和测试的 Linux 环境中。
总体而言,这种方式效果相当不错;LSP 服务端本身就需要容忍一定的延迟,因此额外的网络跳转和文件同步带来的延迟大多不会造成问题。例如,LSP 协议本身就被设计为能够处理(极为常见的)用户在编辑器中做了修改但尚未保存到磁盘的情况,此时编辑器会直接将相关改动发送给服务端。这一机制实际上也自动为文件同步的延迟提供了一定的容错性。
回顾与反思
在我看来,本文所述的这套工具相当精巧,运行得也相当不错,为 Stripe 的许多工程师创造了高效、富有成效的开发体验。在此,我想回顾一下我认为塑造了这套工具的、Stripe 组织和技术栈的一些细节;如果你在其他组织从事开发者工具方面的工作,这些方面很可能值得考虑。
组织规模
本文所述的工具是在 Stripe 工程团队从几百人增长到上千人的这段时间里开发的。在此期间,devprod 从不足一个全职人力(FTE)发展到大约十几人的团队。本文描述的许多工具都是在接近后者规模、devprod 已有 3 人以上时构建的。
这种规模——devprod 的规模,进而是整个组织的规模,使其能够负担得起在工具上投入 10 个全职人力——是我们做出这些选择的一个重要因素。我描述了大量相当复杂的定制化工具;我们需要足够多的工程师来构建和维护它们,也需要足够多的“客户”工程师来让这份投入物有所值。我想再次强调维护和可靠性:把文件监听器和 rsync 拼凑起来以实现从笔记本电脑同步文件并不难——第一个版本在 2012 年就已存在,最初的投入不到一天。真正的神奇之处不在于有这样一个工具,而在于它足够可靠,并且在团队增长和需求变化的过程中始终保持可靠,以至于作为用户,你根本无需去操心它。
此外,Stripe 当时也在快速增长。在一个快速增长的组织中,拥有一个开箱即用、让新工程师几乎无需痛苦配置的开发者环境要重要得多。在一个相对稳定的组织里,工程师可以学会工具的各种 quirks 以及绕过它们的方法,这大多是一次性的成本。而在一个快速增长的组织中,这些成本会不断由每一位新人和负责培训他们的工程师来承担,因此稳定性和无缝体验所带来的回报会更高。
这一特性也是“devbox”模式的重要动因之一。在笔记本电脑上进行本地开发有其优势,但要集中管理环境或帮助用户调试要困难得多,因此几乎不可避免地需要开发者个体具备更多的专业知识和投入。转向集中供应、临时性的 devbox,让我们得以将大部分维护工作集中化,并能将“拉取 master 并重建你的 devbox”作为一刀切的首要排障建议,它能解决大多数问题。
代码库
我提到过,这套基础设施支撑的是一个大型单体仓库,其架构和模式在时间和代码库维度上都相对一致。拥有这样一个稳定的目标,创造了杠杆点,使得开发者生产力团队能够打造共享工具(如 autoloader 和 devbox 服务管理器),与环境和代码库进行相当深入的集成,并提供对用户友好的功能。在一个包含更多样语言、运行时和模式的环境中,为某一种技术栈做如此深度的定制就没有意义了,基础设施团队需要构建更通用的、将用户代码更多视为不透明黑盒的工具。
此外,出于各种历史、业务和技术原因,Stripe 的代码库在许多方面都耦合得相当紧密;对于我们开发者生产力团队而言,很明显它不会轻易或迅速地被拆解(例如拆分为更多微服务)。我们确实在持续投入工具和模式以提升单体仓库内的模块化和抽象能力,但对 Ruby 单体仓库整体结构“黏性”的确信,也印证了我们在专用工具上进行大规模投入的合理性。
需要指出的是,即便在 Stripe 内部,这一选择也曾引发一定争议,并非总是一致通过。尽管大部分代码和业务价值都集中在 Ruby 单体仓库中,Stripe 仍然有大量其他代码库在运行基础设施组件或专用流水线的部分环节;这些代码库通常从工具和基础设施团队那里获得的支持较少,这也成为反复出现摩擦的根源。
单体仓库 specifically 采用 Ruby 编写这一事实,也在细节上塑造了我们的许多决策。例如,一种源码与产物更解耦的编译型语言,可能会把我们推向其他方向。此外,据我们所知,Stripe 的单体仓库是当时现存最大的 Ruby 代码库,这意味着我们常常只能靠自己,现有的工具在这样那样的方面都难以支撑我们的规模。
结语
随着工程组织的壮大,维持开发者生产力是一件困难的事。随着组织和代码库的增长,人均生产力在一定程度上出现下降几乎是不可避免的,尽管这种影响几乎无法量化。
挑战出现在组织的各个层面,其社会性和组织性往往不亚于技术性。我绝不会声称自己掌握所有答案,也不会说 Stripe 为这一挑战的所有方面都找到了最优甚至“持续足够好”的解决方案。然而,我确实认为到 2019 年左右,Stripe 在开发者工具上的投入已经足够多,以至于在许多方面,相比更早的、规模更小的阶段,中位数开发者体验反而得到了改善,而本文正试图传达支撑这种体验的基本选择和基础设施。
最后:开发体验当然只是故事的一部分——代码和功能的完整生命周期还会继续延伸到 CI 和代码评审,并最终通过部署进入生产环境,在那里被进一步观察、调试和演进。要讲述那些系统,至少还需要再写一篇同样篇幅的文章。
随机一篇博客
评论
登录后参与讨论