微服务运维难维护?数据基础架构公司Segment宣布放弃微服务构架

很多人可能已经知道微服务已成为明日黄花,它曾经作为最佳实践为Segment公司起到很大作用,但是并不适合所有场所。
简单说,微服务是将后台业务拆分成很多各自功能独立的面向服务软件架构,其模块化、减少测试压力、功能组合、开发团队自治等优点广为人知。与之对应的是单体式架构,即用单个服务为测试部署扩展提供所有功能模块。
2017年早些时候,Segment产品开发遇到了问题。如果在每个部门继续采用微服务,不但不会加速开发过程,反而会落入复杂的泥潭。这种架构的优势反而变成了负担。最终,团队发现需要三个全职工程师才能确保这套系统运转,这种无法承受的负担必须改变。这篇博文就是回顾如何将产品和团队需求更好嵌入开发过程的回顾。

微服务的功过

Segment客户数据架构每秒录入成千上万个时间转发到被我们称为服务端目标的代理商API。有上百种这样的目标,比如Google Analytics,Optimizely或者客户自己的Web钩子。
多年前,产品刚发布时,架构很简单。API录入时间,将他们转发到消息队列。本例中的事件是一个由Web或者移动应用产生的JSON对象,其中包含用户和行为的信息。例如:

{
"type": "identify",
"traits": {
"name": "Alex Noonan",
"email": "anoonan@segment.com",
"company": "Segment",
"title": "Software Engineer"
},
"userId": "97980cfea0067"
}

队列中的时间被处理,客户端配置决定事件会转发到哪个目标端,事件会按照顺序转发到目标端API;这种架构对开发者很有用,他们只需要将事件发送到单一服务端,也就是Segment的API(Segment会接下来出来事件发送到哪个具体目标端),而不需要做其他复杂整合。
如果某个请求失败,一般会过一阵再发送事件请求。某些失败是可以恢复的,但是有些却不能。可重试的错误包括例如HTTP 500s,过快连接和超时等错误;不可重试错误包括无效加密信息或者请求信息不全等状况。
也就是说,单一队列会包含发往所有目标端的最新事件和重试过几次的事件,毫无疑问会引起排头阻塞问题。本例中,如果一个目标端变慢或者失效,重试会阻塞整个队列,从而拖慢所有目标端反应。
假设目标端X出现故障,每个请求都返回超时。请求会产生大量回滚日志,失败请求也会放回重试队列。因为系统架构会根据负载压力弹性扩展,突然增加的队列深度会超过扩展能力从而导致响应减慢。不能及时发送消息,导致等待时间增加对客户是不可接受的。
为了解决排头阻塞问题,我们为每个目标创建了独立的服务和队列。新架构配置了一个额外路由器,负责接收事件并将它们发送到选中的目标端。如果某个目标端出问题,只有与之相关的队列受影响。这种微服务风格的架构将目标端隔离,解决了我们的问题。

各自独立Repos的场景

每个目标端API使用不同请求格式,获得客制化代码,翻译成符合格式的事件。例如payload模块中traits.dob需要目标端X发送生日信息,目标端X的代码如下:

const traits = {}
traits.dob = segmentEvent.birthday

Segment这种方式已经被广为接受。然而,根据目标端API不同这种转换非常复杂。例如,对老式或者无序伸展的目标端,需要更多手工配置XML的压力。
原来,当目标端被分成若干独立服务时,所有代码都存放在一个repo中。如果某个测试失败,就会波及所有代码池相关的应用,需要将每个目标端的代码池分解为各自独立的,随之代码转换也就很自然了。这种分离是的目标端测试和部署也很容易展开。
扩展微服务和Repos

随着业务发展,有50多个新目标端,也就有了50多个新代码池。为了更有效维护代码,创建了共享库,例如HTTP响应库,所有目标端都可以使用它。
例如,某事件需要用户名,event.name()可以从任何目标端代码调用。共享库检查请求字段是否正确和存在,如果不存在,则自动查找first name, firstName,first_name,FirstName等,同样会对last name进行同样操作,然后组合成full name返回。

  1. Identify.prototype.name = function() {

  2. var name = this.proxy('traits.name');

  3. if (typeof name === 'string') {

  4. return trim(name)

  5. }

  6. var firstName = this.firstName();

  7. var lastName = this.lastName();

  8. if (firstName && lastName) {

  9. return trim(firstName + ' ' + lastName)

  10. }

  11. }

共享库使得新目标端创建加速,同样的概念,共享功能使得维护工作大为减轻。
然而,出现了新问题。测试和部署加快影响了共享库的部署。有时为了加速代码部署和测试,不同版本共享库会出现在不同目标端中,造成了共享库的不兼容,共享库带来的红利碰到了瓶颈。同时,微服务架构另外一个问题也开始显露出来。目标端数量不断快速增长(平均每月三个新目标端),带来更多代码池,更多队列和更多微服务,意味着更多的维护问题显现出来,迫使我们停下来反思整个架构。
打通微服务和Queues

第一项工作就是将超过140项服务整合为一个服务。管理这些服务是带来了非常大的工作负载,很多工程师夜里不得不被不断叫醒处理出现的问题。
然而,此时的架构对单一服务体系转向造成很大挑战。独立目标端队列,需要每个队列“工人”检查状态,给每个目标端添加了额外的复杂度。这个挑战催生了“Centrifuge”项目的诞生,它负责替代所有独立的队列,并将事件发送到单一的服务端。

转型Monorepo

因为只有一个服务,可以将目标端代码整合到单一代码池中。尽管实现过程会非常复杂和凌乱。
面对超过120种不同依赖关系,需要最终转变为一种依赖关系,过程中我们不断检查依赖版本,更新到最新版本。结果是大大减少了复杂性,也大大减少了维护人力和成本。
我们还需要解决高效快速运行测试的方法,也就是之前讨论过的场景。幸运的是,目标端测试都有类似的架构。而且验证逻辑也都相仿(执行HTTP请求,验证事件被发送到正确目标端)。之前独立目标端代码池的目标是将测试失败分离开,但是这种“优势”并不能减少失败测试,反而使我们掉入了无法预测的技术泥沼。
弹性测试

测试过程中失败的主要原因就是发往目标端的带外HTTP请求,比如过期认证信息这种无关问题不应该引起失败。而且某些目标端比其他要慢。要解决这些问题,我们创建了“Traffic Recorder”,它运行在yakbak之上,负责记录所有与测试负载相关的内容。当第一次运行测试时,会记录所有请求和响应到一个文件中。后续测试中,会从文件中的请求和响应会回放,而不是发往目标端。这些文件会在repo中登记以保证一致性。通过这种方法,测试变的非常弹性。
我还记得第一次运行整合了“Traffic Recorder”的测试时,瞬间就完成了对140多个目标端的工作,运转如飞,太不可思议了。

为什么单一模式

一旦目标端代码统一为一个代码池,他们就可以被整合为一个单一服务。开发者针对服务中的每个目标端的生产率都提高了。我们不需要部署共享库。
2016年,微服务架构方兴未艾时,通过共享库模式做了32个优化。而现在,我们每年都会有46个优化,甚至过去的六个月的优化比整个2016年都多。
改变也为运维带来好处。因为目标端都在一个服务中,计算力(CPU和内存)使用都得以优化,性能不再是一个问题,运维工程师也不再需要被半夜叫醒解决负载问题。
取舍

从微服务过渡到单一服务对业务效率有很大提升,然而仍然有一些取舍:

  • 容错。大家都运行在一个单体服务中,如果某个bug触发了灾难,会波及所有目标端。我们正在着手解决这个问题。
  • 低效的内存级缓存。之前微服务模式,每个目标端只处理相关访问,内存级缓存信息能够保持命中率;现在内存要处理超过3000个进程,命中率自然会降低。尽管可以用Redis解决这个问题,但已经属于另外一种弹性扩展技术了。目前我们接受这种架构改变带来的效率损失。
  • 升级版本问题。整合为一个服务解决了版本依赖的复杂性,但是也带来了版本升级对稳定服务的影响。这个问题需要用测试套件来解决。目前来看效果还比较理想。

结论

初始阶段我们选择了微服务,通过各自独立解决了性能问题。然而扩展问题并没有解决,尤其是测试部署阶段碰到的复杂性问题,开发团队在这里卡壳了。
转向单一服务模式使得开发效率大大提高,但是我们并没有对这种改变带来的问题掉以轻心。需要将测试套件整合到一个单一代码池,并且避免我们之前碰到的问题。
尽管单一服务也有问题,但是我们做了取舍,而且对这种改变带来的好处比较满意。
刚开始做取舍时,微服务确实对提高开发效率起到推动作用,但是我们的应用场景却证明,单一服务模式更适合。从微服务到单一服务改变要感谢Stephen Mathieson,Rick Branson,Achille Roussel,Tom Holmes,以及团队里的其他人。
特别感谢Rick Branson帮助修改这篇博文。