怎样才能提高研发效率?是依赖于各自独立的本地开发测试环境,还是依赖完整的端到端测试?Lyft 的这一系列文章介绍了其开发环境的历史和发展,帮助我们思考如何打造一套适合大规模微服务的高效研发环境。本系列共 4 篇文章,这是第 2 篇。原文:Scaling productivity on microservices at Lyft (Part 2): Optimizing for fast local development[1]
本系列介绍的是 Lyft 在面对越来越多的开发人员和服务时,如何高效扩展开发实践,本文是第二篇。
- 第一部分:开发和测试环境的历史
- 第二部分:优化快速本地开发(本文)
- 第三部分:利用覆盖机制在预发环境中扩展服务网格
- 第四部分:基于自动验收测试的部署门禁
本文将专注于我们如何将优秀的开发体验带到笔记本电脑上,从而实现超快的迭代。
缺少内部开发循环
开发人员在更改代码时可能会将流程分解为内部开发循环和外部开发循环。内部开发循环是快速迭代的循环,即进行代码更改并测试其是否有效。理想情况下,开发人员会多次执行内部开发循环以使保证开发的特性工作,大部分时间应该花在编辑代码上,然后在 10 秒内快速运行测试。外部开发循环通常包括将代码更改同步到远程 git 分支,在 CI 上运行测试,并在部署更改之前进行代码检查。外部开发循环通常需要至少 10 分钟的时间,理想情况下只需要少量的时间来处理代码评审和注释。
正如我们在前一篇文章中提到的,执行内部开发循环需要将代码更改同步到开发者自己运行的一个名为 Onebox 的远程虚拟机环境中。这些环境是出了名的变化无常,启动时间很长,需要经常重建,用户总是因为内部开发循环经常被这些环境问题所阻碍而感到沮丧。环境调整和同步代码变更使得这个过程看起来更像外部开发循环,开发人员通常会回到真正的外部开发循环,并使用 CI 为每个迭代运行测试。
每次运行一个服务
所以我们开始构建一个简单快速的内部开发循环。需要进行的核心转变是从 Onebox(许多服务)的完全集成环境,转向只运行一个服务及其测试的隔离环境。这种新的隔离环境将在开发人员的笔记本电脑上运行,这又回到了上图所示的内部开发循环,在这个循环中,用户只是简单的编辑代码并运行测试,中间没有额外的步骤。我们努力使绝大多数测试独立于单个服务。我们还创建了在笔记本电脑上启动单个服务并向其发送测试请求的能力。
我们决定在 MacOS 上直接运行服务代码,而不使用容器或 VM。从以前的经验中,我们了解到在容器中运行代码并不是一种自由的抽象,虽然设置执行环境变得更容易了,但会导致用户困惑,并在容器网络或文件系统安装出现问题时带来额外的调试挑战。与在容器中运行相比,本机运行也能得到更好的 IDE 支持。我们仍然在某些情况下使用容器,比如运行只能在 Linux 上运行的数据存储或服务。
设置笔记本电脑环境
在 MacOS 上本地运行代码的最大代价是必须在每个开发人员的笔记本电脑上配置和维护环境。为了克服这个问题,我们投资了一些工具,让 Lyft 的新开发人员能够很快启动和运行。
后端服务是用 Python 和 Go 编写的(少数例外),前端服务是用 Node 编写的,每个服务都有自己的 Github 库和依赖集。
Python
对于 Python,我们为每个服务构建一个虚拟环境(也称为 venv)。我们开发了一个工具来帮助创建和管理 venv。该工具分发特定的受支持的 Python 版本,并进行一些操作系统设置,例如通过 Homebrew 安装共享库,以及设置 SSL 以使用正确的证书,并且强制使用内部 PyPi 仓库。
当用户运行命令构建 venv 时,它将会:
- 查看元数据,为该服务选择正确的 Python 版本
- 创建一个新的 venv
- 通过 pip 安装定义在 requirements.txt 中的依赖项
一旦 venv 被构建,必须被激活(添加到 $PATH 中)。每次用户进入服务目录时,我们使用 aactivator[2]来自动激活 venv,并在他们离开时失效。创建的 venv 是不可变的(pip install 被禁用)。当对 requirements.txt 进行更改时,将构建一个新的 venv。这确保 venv 中的依赖项与 requirements.txt 文件和将要部署的内容精确匹配。之前完全构建的 venvs 会被缓存,所以如果用户将更改恢复到 requirements.txt,它将使用之前构建的版本。它还支持创建可变的 venv,以便在不触发完全重新构建的情况下轻松尝试新的依赖项。我们还支持为内部 Python 库创建 venv,以便轻松的在本地运行测试。
Go
对于 Go 来说,设置非常简单。用户安装 Go 运行时,设置一些环境变量(例如用于下载依赖关系的代理),然后可以使用 go run 或 go test。多亏了超棒的 Go modules[3]工具链,每次运行这些命令时,都可以自动下载并链接所有依赖项。
Node
对于 Node,我们使用围绕 nodeenv[4]的自定义包装器,根据元数据为服务下载并安装正确的 node 和 npm,防止用户需要手动安装 nvm[5]之类的版本管理器,并在运行不同的服务时切换到正确的 Node 版本。
除了少数服务以外,开发人员都可以使用上面描述的环境在笔记本电脑上直接运行服务代码。一些服务依赖于仅在 Linux 上支持的库,对于这个非常小的子集,开发人员可以轻松的下载由 CI 系统构建的 Docker 镜像,挂载本地代码目录,从而为服务运行测试。虽然这个过程更麻烦,但仍然可以实现快速迭代。
运行服务
对于开发人员来说,能够快速迭代一个完全运行的服务是很重要的,所以我们努力在笔记本电脑上启用用例。我们创建工具来协调启动服务、发送测试请求以及代理由该服务发出的任何请求。为了确保数据隔离,服务使用的数据存储每次都在本地启动,并带有新数据。在启动时运行脚本来创建表并插入测试所需的任何数据。团队负责维护这个测试数据集,以允许对其特性进行适当的测试。
下面是运行服务所需步骤示例:
- 运行环境检查(例如,工具安装正确,签出必要的 git 仓库,确保端口空闲)
- 激活虚拟环境(Python 和 Node 服务)
- 启动数据存储(例如 dynamodb, elasticsearch, postgres)
- 启动代理应用程序(稍后详细介绍)
- 运行数据存储填充脚本
- 运行服务
让开发人员手动运行所有这些操作非常繁琐且容易出错,因此我们需要工具来编排这些检查,并使用声明性配置管理必要的流程,为此我们决定使用 Tilt[6]。虽然 Tilt 经常用于测试 Kubernetes 集群中的代码,但我们目前使用它来进行纯粹的本地工作流管理。每个服务都有一个 Tiltfile[7],指定启动服务之前必须运行的步骤。Tiltfile 是用 Starlark 编写的,是 Bazel 使用的一种 Python 方言,为服务所有者提供了很大的灵活性。我们提供了常用的函数(例如 ensure_venv(), launch_dynamodb())),所以服务 Tiltfiles 主要由这些预定义的函数调用组成。
为了启动服务,用户在终端上执行 tilt up,Tilt 将解析 Tiltfile 并创建一个内部执行计划,按照 Tiltfile 中指定的顺序运行所有检查和处理。Tilt 有一个本地网页应用,可以显示所有正在运行的东西的状态。用户可以点击 web 应用中的选项卡来显示每个进程的日志输出。这允许用户跟踪正在运行的进程状态,并使用日志调试任何错误。
一旦服务开始运行,当用户在 IDE 中编辑代码时,它将自动重新加载。这是缩短内部循环的一大优势,因为用户甚至不需要触发任何操作来重新加载服务。
处理对其他服务的请求
Lyft 由一个很大的服务网络组成,几乎任何服务都会调用这个网络中至少一个其他服务。有两种主要的方法来处理本地服务发出的请求:
- 构造并返回一个模拟的响应
- 将请求转发到另一个真实环境中
我们使用自己开发的内部工具,以非常灵活的方式支持这两种方式。
几年前,Lyft 开发了一款代理应用,作为一种帮助移动应用开发者将开发工作流程与后端服务团队分离开来的工具。它是一个 Electron 应用程序,在移动应用程序对预发环境 API 的调用之间充当代理。开发人员将移动应用程序连接到代理服务器,为每个用户提供一个唯一的 URL。默认情况下,代理将把所有请求转发到预发环境。用户可以选择覆盖特定的调用并返回完全模拟的数据,或者在预发环境的响应中改变某个字段。这使得手机开发者能够在后端 API 仍处于开发阶段时测试应用的变化。设置是这样的:
与 charlesproxy[8]等其他工具相比,这个代理应用带来的最大优势是与 Lyft 的接口定义语言(IDL[9])深度集成,IDL 是通过 protocol buffers[10]实现的。在 IDL 中,我们为后端服务端点指定请求和响应结构。在代理应用中,用户通过一个 Typescript 代码编辑器(使用 VSCode 中的 Monaco Editor[11])来编写响应,从而与 IDL 集成并为用户提供类似于 IDE 的体验,可以进行类型检查,并自动补全模拟数据响应的结构。代码接口还允许复杂的交互,比如将字段从请求设置为响应。它还显示了通过代理的所有请求的可读请求和响应体,使用户能够很好的可视化来自移动应用程序的所有请求。
当我们开发本地运行后端服务的工具时,代理应用程序非常适合处理本地服务向其他服务发出的请求。我们重用了代理功能,将请求转发给预发环境,或者根据用户的意图返回模拟数据。设置过程是这样的:
向本地服务发出请求
接下来,我们需要一个能够让用户直接编写并向本地运行的服务发送请求的工具。这在 Devbox/Onebox 时代并不常见,因为大多数测试请求都来自移动客户端,所以我们必须想出新的解决方案。构造 API 请求的工具有很多,比如 curl[12]或 Postman[13]。但我们需要在 Lyft 支持几种 RPC 传输格式,包括 GRPC、JSON(基于 HTTP)和 protobuf(基于 HTTP)。没有任何现有工具可以无缝处理这些不同的格式、利用我们的定义以及轻松的组合请求。
代理程序对我们来说是最好的选择,我们添加了帮助用户使用 Typescript 代码编辑器编写请求并按下按钮将请求发送到本地服务的能力,再次利用了与 IDL 的集成来提供 URL 路径以及实现了请求体字段的自动补完功能。
结果
自从我们在今年早些时候向整个公司推出这个工具以来,反馈一直非常积极。开发人员喜欢在笔记本电脑和 IDE 上运行测试,而不需要任何远程环境。创建一个新的 Onebox 环境通常需要大约一个小时,但现在笔记本电脑环境总是可以运行测试,使用 Tilt 在本地启动服务只需要几分钟。
因为开发者花了更多的时间来测试自己的服务,我们还观察到他们的行为发生了转变。在本地运行服务时,用户直接向服务 API 发送请求,而不是通过移动应用程序和公共 API 进行测试。这增加了开发人员对服务 API 的熟悉程度,并减少了出错时的调试范围。
虽然成本并不是这个项目的主要驱动因素,但由于不需要为每个开发人员配置支持 Onebox 的强大的 AWS 实例,因此最终节省了可观的开销。让用户在自己的笔记本电脑上独立运行服务意味着真正减少所需的总计算资源。
未来的工作
上面所描述的是工具的第一次迭代。关于下一步该怎么做,我们有很多令人兴奋的想法。以下是其中的一些:
支持苹果芯片
我们很高兴即将开始向开发人员交付带有 M1 芯片的新款 Macbook Pro。早期的基准测试显示,即使是在仿真环境下,这些机器仍然提供了开箱即用的巨大的性能提升!那些确保我们将所有东西都在本地原生运行的额外工作将帮助我们获取性能提升的好处。
将请求从预发环境中的服务路由到本地服务
目前,用户必须直接调用本地运行的服务,而无法调用客户端 API,并在路由到本地服务之前,让请求通过预发服务。我们计划很快启用这个功能,这将允许用户测试完整的端到端用户流(如运行一款手机应用),以测试后端服务的新功能,同时仍然拥有本地开发的快速内部开发循环。
改进发送请求 UI
代理应用程序中用于编写请求的代码接口非常灵活和强大,但对于新手用户来说,正确构造请求仍然具有挑战。我们希望为这个用例创建一个更像 Postman[13]的定制 UI,同时保持代码接口的强大功能。我们还计划创建一个 API 平台,用户可以很容易的发现并体验 Lyft 的任何服务。
远程开发环境
在像 Github Codespaces 这样的完全远程开发环境中,已经有了一些令人兴奋的发展。随着这些解决方案的成熟,我们肯定会密切关注,看看它们是否适合我们的用例。
本系列的下一篇文章将展示如何安全的将 PR 中的代码部署到预发环境中并对其进行测试。
References:[1] Scaling productivity on microservices at Lyft (Part 2): Optimizing for fast local development: https://eng.lyft.com/scaling-productivity-on-microservices-at-lyft-part-2-optimizing-for-fast-local-development-9f27a98b47ee
[2] aactivator: https://github.com/Yelp/aactivator
[3] Using Go Modules: https://go.dev/blog/using-go-modules
[4] Node.js virtual environment: https://github.com/ekalinin/nodeenv
[5] Node Version Manager: https://github.com/nvm-sh/nvm
[6] Tilt: https://tilt.dev/
[7] Writing Your First Tiltfile: https://docs.tilt.dev/tiltfile_authoring.html
[8] Charles Web Debugging Proxy: https://www.charlesproxy.com/
[9] IDL: https://en.wikipedia.org/wiki/IDL_(programming_language)
[10] Protocol Buffers: https://developers.google.com/protocol-buffers
[11] Monaco Editor: https://microsoft.github.io/monaco-editor/index.html
[12] curl: https://curl.se/
[13] Postman: https://www.postman.com/