怎样才能提高研发效率?是依赖于各自独立的本地开发测试环境,还是依赖完整的端到端测试?Lyft 的这一系列文章介绍了其开发环境的历史和发展,帮助我们思考如何打造一套适合大规模微服务的高效研发环境。本系列共 4 篇文章,这是第 1 篇。原文:Scaling productivity on microservices at Lyft (Part 1)[1]
2018 年底,Lyft 工程团队完成了将最初的 PHP 单体拆分为 Python 和 Go 微服务的工作,在接下来的几年里,微服务在很大程度上成功的帮助团队独立运行和发布服务。微服务所带来的关注点分离使我们能够更快试验和交付特性(可以每天部署数百次),并且提供了足够的灵活性,可以在合适的地方采用不同的编程语言,还可以根据服务的关键程度采用更严格或更宽松的需求,等等。然而,随着工程师、服务、测试数量的增加,开发工具很难跟上微服务的爆炸式增长,拖累了生产率的增长。
本系列分为四部分,将介绍 Lyft 工程团队从 100 名工程师和少量服务发展到 1000 多名工程师以及数百项服务的过程中所使用的开发环境。我们将讨论导致我们放弃这些环境的规模化挑战,以及从主要基于大量集成测试(通常接近端到端)的测试方法,转变为以独立测试组件为中心的本地优先方法。
- 第一部分:开发和测试环境的历史(本文)
- 第二部分:优化快速本地开发
- 第三部分:利用覆盖机制在预发环境中扩展服务网格
- 第四部分:基于自动验收测试的部署门禁
开发和测试环境的历史
我们在综合开发环境的第一个重大投资始于 2015 年,当时我们有 100 名工程师,几乎所有的开发工作都集中在一个单体 PHP 系统上,在某些用例中开始出现一些微服务(比如司机登录)。
由于预计到需要服务的工程师和服务的数量将会持续增长,因此有必要采用容器化方案。我们计划构建一个基于 docker 的容器编排环境(当时 docker 还处于起步阶段),首先服务于开发人员的测试工作,然后再扩展到生产环境,在生产环境中,多租户工作负载的成本更低、扩展速度更快,我们将因此受益。
利用 Devbox 进行本地开发
Devbox 是 Lyft 的即开即用开发环境,于 2016 年初发布,很快就被大多数工程师所采用。Devbox 的工作方式是代表用户管理一个本地虚拟机,这样工程师就不必安装或更新依赖包、配置 runit[2]启动服务、添加共享文件夹,等等。VM 运行后,只需一个命令和几分钟就可以获取最新版本的镜像、创建/初始化数据库、启动 envoy proxy sidecar[3],以及在开始发送请求前所需的一切依赖。
与之前相比,这次升级非常棒,我们手动为每个开发人员及其负责的服务提供了一个 EC2 实例,这使得设置和保持更新非常繁琐。我们第一次有了一种一致的、可重复的、简单的方法来完成跨多个服务的开发。
利用 Onebox 进行远程开发
很快就出现了新的需求,需要能够与其他工程师或团队(如设计团队)共享的、可长期维持的环境,因此我们打造了 Onebox。Onebox 本质上是一个 EC2 实例上的 Devbox,它有许多吸引用户放弃 Devbox 的优点。我们将其部署在 r3.4xlarge 实例上,拥有 16 个 vCPU 和 122G 内存,比工程师随身携带的 MacBook Pro 要强大得多。Onebox 可以运行更多的服务,下载容器镜像更快(因为基于 AWS),更不用说还可以避免 VirtualBox 让笔记本电脑的风扇声音大的就像喷气发动机。
我们有两种不同的开发环境,每种环境都能够运行多个服务
集成测试
除了单元测试以外,Onebox 的云基础设施也很适合在 CI 上运行集成测试。服务可以简单的在manifest.yaml
文件中定义需要的依赖项,一个临时的 Onebox 会启动这些服务,并对每一个 pull request 执行测试。许多服务,特别是靠近移动客户端的服务组合,会需要构建大型集成测试套件来应对异常的服务失效,并且每次事故分析通常都会以添加新的集成测试结束。有了如此灵活和强大的测试功能,单元测试逐渐退居次要地位。
name: api type: service groups: - name: integration members: - driver_onboarding - users tests: - name: integration group: integration
定义要在 CI 中运行的集成测试的服务示例
预发环境(Staging environment)
Lyft 的预发环境与生产环境几乎相同(除了使用更少的资源,也没有生产数据),所有服务都是和生产环境交付一致的过程部署的。尽管不是开发环境,但因为预发环境在端到端测试中扮演着越来越重要的角色,因此同样值得讨论。
在 2017 年初 Devbox 和 Onebox 发布后不久,我们还解决了另一类不断增长的问题:负载测试。那些会造成拼车需求流量激增的事件(比如新年和万圣节),会暴露我们系统的瓶颈,并且往往会导致宕机。为了解决这些问题,我们构建了一个框架来模拟大规模流量。该框架针对我们的生产环境,协调数以万计具有不同配置的模拟用户(例如,模拟洛杉矶的一名经常取消订单的司机),并将 Lyft 视为黑匣子。
作为阶段性测试仿真框架本身的副产品,我们意识到生成的流量对于一般的端到端测试也是有价值的。在预发环境中不断测试公共接口可以为真正的部署提供很好的信号。例如,如果部署破坏了让乘客下车的接口,部署的发起者几乎立即就能看到错误日志和警报。模拟还会持续生成用户、车辆、支付等最新数据,减少了开发过程中必须进行的手动测试的设置时间。随着负载测试的努力,预发环境变得比以往任何时候都更加现实和有用,团队将 PR 分支部署在那里,从而可以获得真实数据的一致的反馈,这已经成为一种普遍现象。
新的问题
快进到 2020 年(在将 Devbox 和 Onebox 作为容器化开发环境引入 4 年后),尽管我们尽了最大的努力,但“Lyft-in-a-box”风格的环境仍然难以跟上。使用这些环境的工程师增加了十倍,现在有数百个微服务为更复杂的业务提供支撑。虽然在依赖关系较小的服务上开发仍然相当高效,但大多数开发都是在已经构建了巨大依赖关系树的服务上进行的,这使得在 CI 上启动环境或运行测试非常缓慢。
虽然这些环境和测试功能非常强大和方便,但却达到了弊大于利的程度。我们构建了一个为测试少量服务而优化的系统,当服务的数量从 5 个增加到 50 个,从 50 个增加到 100 个,甚至更多的时候,我们没有重新评估我们的策略。这不仅需要大量的服务来进行维护和扩展,而且还会因为迫使开发人员不断的从整个系统的角度而不是从一个组件的角度来考虑而降低开发人员的生产力。
让我们更详细的看看这个问题的一些细节:
扩展性问题
由于涉及的资源数量庞大,且与类似于生产环境的环境存在分歧,Onebox 环境的扩展变得很不现实。例如,在数百个环境中运行相同的可观察性工具是不可行的。当出现问题时,很难找出确切的原因(运行的 70 个服务中哪个可能有问题?),人们倾向于在放弃并在预发测试之前按几次“reset”按钮。
另一方面,预发环境既容易缩放,又能更忠实的反映生产环境。它提供了同样的日志记录、跟踪和度量功能来帮助调试。部署到共享的预发环境的主要缺点是:(1)实验更改可能会破坏他人的使用环境,(2)每次只能有一个服务做出一次变更才能够有效进行测试,(3)由于需要同步代码和热加载,需要花费更多的时间(分钟)来构建和部署。
维护困难
由于上述伸缩性的挑战,维护和优化这些环境花费了大量时间,导致技术落后。生产环境和预发环境已经用 Kubernetes 进行容器编排,同时切换到更小的单进程容器镜像。开发使用了捆绑了 sidecars 和其他基础设施组件(指标、日志等)的更重的多进程镜像,使得构建和下载镜像的速度更慢。
每周都有一些变更会造成问题,这些变更不会影响预发或生产环境,但会影响开发环境。由于大多数开发者需要运行大多数服务,一个服务的问题会造成很大的影响。一些团队已经将他们所有的端到端测试转移到预发阶段,使得他们的服务在开发过程中变得越来越弱,进一步加剧了这种问题。
问题所有权不清
在开发环境中,问题的所有权是不清楚的。谁应该负责修复引起问题的特定服务?是启动这个 Onebox 的人、服务的负责人还是开发者基础设施团队?在实践中,这常常落在开发者基础设施团队的头上,但他们无法诊断和解决与应用程序相关的问题(例如,配置变更导致应用程序在启动时崩溃)。
臃肿的测试
笨重的集成测试套件已经成为生产力的一大消耗。长达一小时的测试套件随处可见,运行在复杂的分片基础设施上,通过自动重试来弥补不稳定的环境造成的问题。造成这一问题有两个主要的驱动因素,依赖关系的膨胀和测试本身。由于依赖的传递性,依赖的服务会在服务所有者没有注意到的情况下逐渐增加,从而消耗掉测试时间。测试套件本身也在稳步增长,尽管我们会在出现问题时添加测试,但很少会因为假设现有的测试有作用而被删除。
那么,为什么我们要为合并一个 PR 而花费几小时的等待时间呢?当然是因为可以在 bug 进入生产环境之前捕获它们!但通过在实践中进一步检验,这一理论并不成立。对我们开发得最活跃的一些服务的集成测试进行分析发现,80%或更多的测试要么是不必要的(例如,过时的或现有单元测试的副本),要么可以重写,从而可以在不依赖外部的情况下以较短的时间运行。当测试失败时,大多数都是误报,而这将耗费数小时的调试时间,其余测试通常会在通过预发或金丝雀环境并造成生产问题之前被捕获。
# 2013 (monolith), duration: 1 minute def test_driver_approval(): """ Requires: - api """ user = get_user() approve_driver(user) assert user.is_approved # ------------------------------------------------------------ # # 2015 (mostly monolithic, a few services), duration: 3 minutes def test_driver_approval(): """ Requires: - api (monolith) - users - mongodb - driver_onboarding - mongodb - redis """ user = user_service.create_user() user = driver_onboarding_service.approve_driver(user) assert user.is_approved # ------------------------------------------------------------ # # 2018 (post-decomp, microservices), duration: 20 minutes def test_driver_approval__california(): """ Requires: - users - redis - experimentation - fraud - dynamodb - messaging - mongodb - driver_onboarding - messaging - email - experimentation - dmv_checks - vehicles - payments """ user = user_service.create_user() user = driver_onboarding_service.approve_driver(user) assert user.is_approved def test_driver_approval__newyork(): # ... def test_driver_approval__montreal(): # ...
随着我们继续分离出新的微服务,集成测试变得越来越笨拙。
改变过程
在大约一年前开始将我们的开发环境迁移到 Kubernetes 之后,工程资源的变化成为了我们缩小并重新审视发展方向的催化剂。维护基础设施以支持随需应变的环境变得过于昂贵,而且随着时间的推移会变得越来越糟。要解决这种情况,我们需要对开发和测试微服务的方式进行更彻底的改变,是时候用对由数百个微服务组成的系统具有可持续性的替代方案来取代在 CI 上的 Devbox、Onebox 和集成测试了。
仔细观察开发人员是如何使用现有环境的,我们确定了三个关键的工作流(在下图中用紫色表示),这三个工作流对维护非常重要,并且需要进行投资:
- 本地开发: 对于任一给定服务,运行单元测试或启动 web 服务器并发送请求都应该非常简单快捷。
- 手动端到端测试: 测试特定变更在更大的系统中如何执行是许多工程师依赖的关键工作流程。我们希望扩展预发测试,使开发人员可以更容易、更安全的独立进行测试。
- 自动端到端测试: 尽管我们过度依赖于这种测试,但如果没有自动化的端到端测试提供的信心,我们无法继续每天交付数百次变更。我们将保留一小部分有价值的测试作为验收测试,在部署到生产环境时运行。
本系列后续文章将深入研究这三个领域,我们将讨论相关问题、如何处理以及学到了什么。
References:
[1] Scaling productivity on microservices at Lyft (Part 1): https://eng.lyft.com/scaling-productivity-on-microservices-at-lyft-part-1-a2f5d9a77813
[2] runit - a UNIX init scheme with service supervision: http://smarden.org/runit/
[3] Envoy Proxy: https://www.envoyproxy.io/