SoundCloud:我们最终是如何使用微服务的?

微服务是近期的热点。

当我在SoundCloud工作时,负责从一个巨大的Ruby on Rails应用程序里迁移到众多的微服务上。我已经多次讲述这个过程的技术问题了,在演讲里,也在SoundCloud的工程师博客里写了一系列文章。这些是工程师们最感兴趣的话题,但是最近我才意识到从来没有向大家解释过我们最终使用微服务之前做了什么尝试。

我很抱歉可能会让一些技术人员失望,但是我们迁移到微服务更多的是跟生产力相关,而不是单纯的技术因素。下文会详细解释。

注意:本文有很多修正之处,为了使其更容易理解,将相当混乱的一系列事件简化成了线性的时间链。不过,我相信这很好得展示了在SoundCloud最初几年所做的工作。

Next项目

当我最初加入公司的时候,手头最重要的项目内部称之为v2。该项目对我们的网站做彻底的改版,发布时的商标名称为The Next SoundCloud。

一开始我加入了后端团队,App团队。我们负责巨大的Ruby on Rails应用程序。那时候还不称其为遗留系统,而称之为mothership。App团队负责Rails应用相关的所有事情,包括旧的用户接口。Next是一个单页面的JavaScript web应用程序,我们遵照当时的标准实践,将其构建为公开API的常规客户端,用Rails monolith实现的。

App和Web这两个团队是完全隔离的 -- 甚至在Berlin距离挺远的不同的大厦办公。我们几乎只在所有人都参加的大会上才能看到彼此,主要的沟通工具是问题跟踪系统和IRC。如果你问任何一个团队的任何人我们的开发流程时如何工作的,他们可能会这么回答:

  1. 有人想到了某个功能,随后写了很多描述,并且画了些模拟图。
  2. 设计师优化了用户体验。
  3. 编写代码。
  4. 一些测试之后,将应用部署。

但有时候这个过程会遇到一些障碍。工程师和设计师都抱怨他们加班过多,但同时产品经理和合作伙伴则抱怨他们永远无法按时得到想要的东西。

作为一个小型消费者企业,我们非常需要能够确保吸引尽可能多的合作伙伴(就是那些Apple和Google在发布产品时在一页PPT里列出的合作伙伴),因为这些意味着免费的宣传和增长。我们也需要在圣诞节前发布Next的内测版本,否则连续的假日就会将我们的所有计划推延到新年的第二季度,因为我们不想在新网站上线之前推出任何新功能。要想能够登上Keynote的PPT,并且确保我们不会浪费整个季度,我们必须开始追逐截止日期。

就是这个时候,我们决定尝试并且理解我们组织增长的流程到底是什么状态。

流程hacking

在加入SoundCloud之前,我当了好几年的咨询师,这些年里我学到的最有用的工具之一就是创建Value Stream Map的理念。我不想详细讲这个技术的为什么和如何做,但是如果你对下文讲述的流程感兴趣的话,至少已经知道应该搜索什么关键字了。

将不同工程师的非正式采访融合在一起,并且从我们多个自动化系统里收集数据,能够画出实际流程的图,和我们认为的流程作对比。我无法展示实际的文档,但是实际图和如下虚构的图差不多:

SoundCloud:我们最终是如何使用微服务的?

实际工作量类似:

  1. 有人想到一个功能。他们随后编写一个轻量级的spec,有一些模拟屏幕截图,并且保存到Google Drive文档里。
  2. spec一直也就是个文档,直到有人有时间真得实现它。
  3. 非常小的设计团队得到spec,并且为其设计用户体验。随后会变成Web团队管理的Trello白板上的一个待办事项。
  4. 这一待办事项会在Trello白板上待上一段时间,最少是一个两周的迭代之后,工程师才有时间查看它。
  5. 工程师可能开始基于这一项工作。在使用伪造/静态数据将设计转化成合适的基于浏览器的体验之后,工程师会记录下要想使这个功能能够工作的话,Rails API所需的改动。这会进入到Pivotal Tracker,App团队所选择的工具。
  6. 这项待办事项会一直呆在Pivotal里,直到App团队有人有时间查看它,通常又需要另一个两周的迭代。
  7. App团队成员可能为API能够工作而开始编写代码,集成测试和任何所需的其他东西。随后他们会更新Trello issue,让Web团队知道他们这部分工作已经完成了。
  8. 更新过的Trello待办事项会在backlog里待上一段时间,等待Web团队的工程师来完成他们之前开始的工作。
  9. Web团队开发人员让他们的客户端代码匹配上后台实现,并且发出可以部署的信号。
  10. 因为部署风险很大,很慢并且很困难,App团队会等待好几个功能都进入主分支之后才一起部署到生产环境。这意味着功能可能会在源码控制系统里待好几天,并且你的功能很可能会因为完全不相关的代码而被回滚。
  11. 某一天,这个功能终于部署到生产系统了。

这些步骤里很可能会有很多的来回,因为大家需要进一步说明或者又有了更好的想法。不过暂时先忽略这些。

总之,一个功能需要花费两个月才能上线。更为糟糕的是:这个过程超过一半的时间都花在等待上,比如,一些Work In Progress的项目等待某个工程师来完成。

像上文所说的map这样的工具使得更容易发现上述流程的诡异之处。我们只看图就能想到的是必须为monolith采用release train的方式,而不是等到有足够多的功能才一起部署,需要开始每天都进行部署,而不用管有多少功能进入了主分支。虽然这和持续部署还相差很远,但是已经可以帮助改进一点我们的开发周期了:

SoundCloud:我们最终是如何使用微服务的?

低处的水果是行动的最大驱动力,但是我们的例子里最主要的问题显然是前端和后端开发团队之间的来回。

在我们将工作分为Web和App团队时,其实就已经将后端开发人员和实际的产品完全隔离开了。他们会感到沮丧,觉得自己对于产品完全没有发言权。他们会觉得自己“只需要做像素抄写员所告知的工作”。在一个比供应链需要更多有经验的开发人员的市场里,这么对待团队似乎不是很好的做法。

但是如今需要关注的问题是花在开发上的47天里,只有11天真的在干事情。剩余的时间都在队列里浪费了,基本都是等待时间。

有一种说法认为浪费多少时间取决于等待新迭代的时间,但是即使改成迭代更短的流程,比如Kanban的variation,并没能帮助多少。

我们随后决定做一些有争议的事情:将后端和前端开发人员配对,他们在某个功能完成之前对其负有完全的责任。我们只有8个后端工程师,11个前端工程师,所以该策略的争论主要是因为需要前端开发人员尽可能早得完成大量工作,从而能让后端开发人员在每个功能上只需花费尽可能少的时间。这样策略的启动靠的是直觉,但是流程映射向我们展示了这样的策略其实产生了反作用。即使不算足前端和后端开发人员的来回讨论的时间,在东西实际上线之前,仍然有太长的等待时间。

我们决定首先单对尝试,随后再扩大到其他人。新流程类似于:

SoundCloud:我们最终是如何使用微服务的?

每个人终于能将更多的时间花在每个功能的开发上。这其实不相关,因为他们工作的同时,能够在更少的时间内完成端到端编码。值得注意的是,即使后台开发人员之前就和其他App团队的人比较远,但是在改动进入Rail应用的主分支之前要求强制的代码审核(也就是:Pull Request)流程。

时间减少了很多,我们决定在流程的其他步骤里尝试并完成相同的事情。我们让设计人员、产品经理以及前端开发人员在某个功能的范围内紧密工作,周期时间更为缩短了:

SoundCloud:我们最终是如何使用微服务的?

的确有相当可观的时间缩减。更短的工作流,让我们能够更容易得在截止日期之前发布Next的第一个版本。我们持续以不同领域配对的方式来进行迭代,最终导致功能团队构造SoundCloud。但是这是以后要讲的主题,这篇文章关注于这个长的Pull Request队列里有什么东西。

从mothership到遗留系统

让我最为兴奋的一件事情是所加入的SoundCloud有着浓厚的工程师文化。大部分听上去和我用在ThoughtWorks项目里的方式类似,但是有一个方面是全新的:强制的代码审核。

那时是2011年,所有的创业公司都在尝试复制GitHub的模型,Zach Holman的GitHub如何使用GitHub来构建GitHub演讲里对此讲述得非常清楚。在很多年使用和倡导基于trunk的开发模式后,我迫不及待得想看到SoundCloud这样的公司以及GitHub能够使用这一与众不同的方式。

那时候,所有App团队的工程师都坐在一个桌子周围,共享相同的任务backlog,并且互相非常亲密。monolith的代码基已经很古老,成熟并且无聊了。我们在整个代码基里都遵守相同的原则和模式,代码提交都是基于现有设计,没有什么意外情况。这使得Pull Request流程几乎是一种仪式,大家花不到一个小时的时间审查提交的内容。

因为越来越多的人离开了这个紧密的组织,在开发Next功能时去和Web团队的配对,正式的沟通渠道被破坏了。有问题的部署变得越来越频繁,是由一些问题上的误解所导致的,这些问题包括什么正在部署或者一些功能如何设计等。因为这通常是人为导致的,在很多这样的问题之后,我们认为方案必须要能够在合并改动上强制实行一种更为严格的流程。从此以后,在将改动放到主分支并且最终部署之前,所有改变必须被第二个工程师“正式得”批准。

如上图所示,这导致了改动上线之前Pull Request的长时间等待。要想解决这个问题,我们检查的第一步是确保每个人每天最少花费一个小时来审核团队外部进来的Pull Request -- 比如,从Next项目上工作的人。这并没能更快得减少队列的长度,最终我们意识到一些小的Pull Request被很多人审核,而一些大的Pull Request(Next的Pull Request通常很大)却无人问津,直到产品经理发怒为止。大改动的审核会花费很多时间,基于我们的Rails代码的特点,也非常有风险。大家都像躲开瘟疫一样避开这些大型Pull Request。

我们一致决定从事Next功能开发的开发人员需要将其工作分解到更小、更好管理的Pull Request。这也和每个Pull Request都需要快速审核和合并的理念契合。但是同时将单一功能分解为多个小型Pull Request也会导致审核人员看不到整体情况:有时候一系列看上去都挺好的代码提交实际上隐藏着危险的架构错误。我们确定更好的用户故事的需求,但是对员工进行培训可能会花费一些时间,为了业务需要,我们需要一个短期就能见效的方案。

最终应用了书上的古老技巧:结对。看出来了么,我们的需求是代码必须被另一个开发人员审核。使用结对编程之后,我们一直都在进行实时的审核,这意味着每个代码提交都能自动+1。大多数人都喜欢结对,不喜欢的人可以选择保持单独工作,但是只在和Next项目不相关的任务上。

我们开始尝试了几对,但是一件有意思的事情阻止了我们。我们发现monolithic的代码基太大了,以至于没有一个人了解所有的代码。大家围绕应用程序的子模块建立了自己的专业领域。当一对人选择了某个待办事项,这对人可能发现自己没有该部分代码的足够知识,因此他们不得不要么等待该领域的专家有空闲,要么和熟悉该领域的人重新配对,要么选择另外的任务,通常是低优先级的任务。这两种选择都很糟糕。

这都成了公司的一个笑话,“这里的所有东西都很有趣,像做游戏一样,直到你不得不学习monolith的时候。”

monolith无法减少的复杂度

要想从所花费的时间里节省出这8天,我们需要后退一步,问问自己,为什么一开始需要这所有的Pull Request。随着对自己的流程理解的深入,我们的想法随之改变:

  1. 我们为什么需要Pull Request?因为我们知道,基于多年的经验,大家通常会犯很低级的错误,这样的改动上线后可能会导致整个平台崩溃几个小时。
  2. 为什么大家这么频繁得犯错?因为代码基太复杂了。很难记住所有事情。
  3. 为什么代码基这么复杂?因为SoundCloud从一个非常简单的网站起步,但是随着时间的演进,它发展成了一个大型平台。我们拥有很多功能,各式各样不同的客户应用程序、不同类型的用户、同步和异步的工作流以及大型规模。代码基实现并且反应了如今复杂平台的很多组件。
  4. 为什么我们需要单个代码基来实现很多组件?因为范围经济。mothership有着很好的部署流程和工具,架构经历了尖峰性能和DDOS的实际考验,也很容易水平扩展等等。如果构建新系统,我们将必须为新系统构建所有的这一切。
  5. 为什么我们在多个,或者小型系统里得不到规模经济?嗯。。。

第五个问题需要长篇大论来回答。我们自己的经验和同事的调研显示可能有两种方案:

  • (A)为什么我们在多个或者小型系统里得不到规模经济?问题不是我们不能,而是这样做并不会比将所有东西放到一个代码基里更为有效。相反,我们应该围绕monolith以及开发人员可用性来构建更好的工具和测试。这也是Facebook和Etsy采用的方式。
  • (B)为什么我们在多个或者小型系统里得不到规模经济?我们可以。我们需要做一些实验来找到所需的工具和支持。当然,也取决于构建了多少单独的系统,我们也需要思考规模经济,但这是Netflix、Twitter等构建系统的方式。

每种方案都有各自的支持者,并没有哪一种明显对或者错。最大的问号是每个方案需要多少代价。金钱和资源不是问题,但是我们没有足够的人或时间来研究任何颠覆性的事情。我们需要一种能够增量实现的策略,而且从一开始就能带来价值。

我们从另外的角度审视拥有的东西。我们一直用非常简单的格式来看待后台系统:

SoundCloud:我们最终是如何使用微服务的?

这样的思路必然会将整个大盒子实现为一个单一的巨大代码基。虽然我们在自我反省中发现了这一点,但是实际事情并不像上图那么明显。

实际上,如果你打开这个大黑箱,会意识到我们的系统更像如下图像所示:

SoundCloud:我们最终是如何使用微服务的?

我们没有单一的网站,我们有的是拥有多个组件的平台。每个组件都有自己的拥有者和stakeholder,以及独立的生命周期。

比如,subscriptions模块构建了一次,只在支付网关要求我们在流程里改变什么的时候才需要改动。另一方面,notifications以及其他模块,和增长以及留存率相关的,则会因为我们这个年轻的创业公司努力发展更多的用户和内容而每天都有改动。

它们还有不同服务级别的预期。一个小时没有收到通知不会让任何人抓狂,但是回放模块五分钟的中断就会让我们很受伤。

如果尝试(A)方案,结论是要想使得monolith工作的唯一方式就是让这些组件显式化,不仅仅在代码上,而且要从部署架构上。

在代码级别,需要确保单个功能的改动能够在相对隔离的地方开发,而不要求改动其他组件的代码。需要确保该改动不会引入bug或者改变系统里其他不相关部分的运行时行为。这是业界一直存在的问题,我们知道必须要让隐式组件显式化,并且确保充分了解了哪个模块依赖于哪个模块。

我们讨论了使用Rails引擎和各种工具来实现,类似如下:

SoundCloud:我们最终是如何使用微服务的?

在部署方面,需要确保某个功能能够单独部署。将某个模块的改动推送到生产环境不要求非相关模块的重新部署,并且如果这样的部署失败,导致生产环境被破坏,那么被影响的唯一功能就是有改动发生的功能。

要想实现这样的系统,我们考虑仍旧将相同的artifact部署到所有服务器上,但是使用负载均衡器来确保一组服务器只负责单一功能,将这个功能的问题和其他服务器上的功能隔离开:

SoundCloud:我们最终是如何使用微服务的?

要完成这些工作并不简单。即使上述方案并不要求从一直使用的技术堆栈和工具隔离出去,这些改动还是会带来了问题和风险。

但是即使一切都很顺利,我们也知道monolith的现有代码无论如何都需要重构。这些代码在过去几年里一直带来很多问题,到处都有欠下的技术账。除了我们自己制造的麻烦,还需要从Rail 2.x升级到3,这本身也是个巨大的迁移工作。

这些问题都让我们重新思考(B)方案。我们认为它类似于:

SoundCloud:我们最终是如何使用微服务的?

但是至少我们能够从开始的第一天就从这个方案受益。任何我们构建的新东西都会是一个全新的项目,就不会再受Pull Request的困扰了。

我们决定试一试,并且最终将首个项目构建成服务,从monolith上隔离出来。该项目引入了多个大功能,并且重新思考了subscription模块,并且比预先计划提前了2组2个工程师完成。

体验很棒,我们决定为新东西的构建持续使用该架构。我们的第一个服务使用Clojure和JRuby构建,最终改为使用Scala和Finagle。

必须引用康威定律

从2013年起SoundCloud所构建的新东西几乎都是服务。不知道从什么时候起,我们开始使用“微服务”来指代这些服务,但是在一开始构建这种架构时并没有想到这一点(SoundCloud在2013年在邮件里第一次使用单词‘微服务’,2012年实现了第一个服务)。

使用新架构框架,我们能够将新功能所花费的时间减少到,虽然和最早的黄金时代比还有不少差距,但是对于一个在竞争异常激烈的音乐领域奋斗的公司而言已经足够好了:

SoundCloud:我们最终是如何使用微服务的?

到这里都很不错,但是这是针对新功能的。无论何时需要改进现有功能,这些都还在monolith里实现,我们还是不得不回到旧的周期里。更糟糕的是,很多人在这些新微服务上比巨大代码基上花费更多的时间,因此空闲的审核员数目降低,但是Pull Request队列持续增长。

每次一些大型改动出现的时候,我们都安排足够的时间来确保从monolith里提取出旧系统。虽然这从来没有发生过。大家仍然要么在旧代码里实现这些改动,要么创建了一些诡异的混合代码,改动在一个微服务里实现,微服务和巨大代码基耦合在一起。

这时,App“团队”更像是后台开发人员的资源池,他们会和Web团队,设计师以及产品经理配对,在某个功能上一起工作一段时间。大家会一直从一个功能跳到另一个功能,我们意识到我们并没有为系统的任何部分指定所有者或者自主权。任何人如果觉得不为某件事情负责,就都不会承担风险来研究这些历史代码。这正印证了古老的格言:所有人都承担责任就等于没有人承担责任。

我们考虑将资源池分解成小型团队,关注于特定领域。在花费很多时间尝试找到正确的编组方式之、后,发现我们还是无法达成共识。这很让人沮丧,有时候我只能将组分解成3-4人的小团队,几乎随机得指定他们的模块职能。

这些团队被告知他们对所负责的模块负全责。这意味着这些模块造成中断时会找到他们,同时他们也有自由去开发认为合理的变化。如果他们决定将某些东西保留在monolith里,他们自己决定就好了。他们是维护代码的人。

你可能会猜到,之后我们看到大量代码脱离大型代码基。Messages、stats以及新的iOS应用所需的大部分改进的功能都从主代码基里抽离了出来。

一切都很顺利,但是分解团队的半随机的方式是很大的问题:单一团队负责生态系统里几乎所有基础功能和对象,类似跟踪和用户元数据和社交图。该团队一直扮演救火队的角色,无暇顾及迁移模块到微服务上,因为这会带来更多风险和可能的中断。

这个问题最近才解决了。我们仍然让单一团队负责这些对象,但是现在的架构更加稳定,降低了需要救火的时间。最终让这些人能够有时间将项目本身从monolith里将模块抽离出来。

如今,SoundCloud还有monolith,但是它的重要性每天都在降低。它仍然在很多功能的关键路径上,但是由于strangler系统,它甚至不再是面向互联网的。我不确定最终是否会消失,其提供的一些功能很小并且很稳定,保持这样的状态是最经济的,但是我们计划一年时间里将monolith从任何关键路径上移除。

未来

正如本文一开始所述,这是我们微服务探索的简化版。

我在这个公司的最后12个月关注于我们想引入的范围经济。就算我一直重复使用“微服务”的字眼也并不代表什么,可以确定的是如果有人使用该词汇描述其架构,那么肯定有很多服务。随着企业的发展,他们需要留意每个服务的固定花费。

我的团队和我花费了很多时间思考如何利用约束,并且确保该架构的运维不会比monolith更昂贵和复杂。期望一些工作能够开源,因此一定要订阅工程师博客哦。我在以后的博客里会继续介绍更多内容。