持续交付 Travis CI : 最小的分布式系统

laofo · 2013年12月03日 · 2 次阅读

@ 金斌_jinbin 翻译 http://www.paperplanes.de/2013/10/18/the-smallest-distributed-system.html

Travis CI 一开始仅仅是个想法,在当时甚至还有些理想化。在这个项目启动之前,开源社区还没有一个可用的持续集成系统。

随着作为开源协作平台的 Github 越来越被人认可,Github 也非常需要可以持续对贡献代码进行测试的服务,来保证一个开源项目始终处于稳定健康的状态。

Travis CI 开始于 2011 年初,而且很快得到了一些试用客户。到了 2011 年夏天,我们每天进行 700 次构建。所有这些构建都是在一台构建服务器上进行的。Travis CI 跟 Github 完美集成,目前 Github 还是 Travis CI 的主要平台。

Travis CI 在持续集成领域并没有惊天动地的大动作,但它的确重新定义了一些原有的概念,并增加了一些新的想法。其中一个就是你可以在你的测试运行过程中,接近实时的看这个项目的构建日志流。

最重要的一点,Travis CI 允许你通过源码里的文件 (.travis.yml) 来对构建过程进行配置,而不是复杂的用户界面。

[attach] 2222[/attach] Travis CI 一开始的架构很简单。通过 Web 组件可以让项目和它的构建过程可见,同时,只要一个新的 commit 提交到了项目,Travis CI 就可以接收到来自 Github 的消息,从而触发构建。

另外一个叫做 hub 的组件,是负责处理新的提交,将他们转化成一次构建,并且处理构建任务运行和结束时产生的结果数据。

这两个组件都是跟 PostgreSQL 数据库打交道。

第三部分就是用来控制构建任务本身的线程集合,它们可以用来在虚拟机实例上执行一系列的命令。

[attach] 2223[/attach]

本质上,hub 会显得比其他部分稍微复杂一些。当 hub 处理构建日志时,它需要与 RabbitMQ 进行消息传递。日志会以 chunks 流的形式从控制构建任务的线程中得到。

Hub 更新数据库中的日志和构建结果信息,并且 hub 推送他们到 Pusher。通过 Pusher,Travis CI 可以在构建开始或结束的时候更新用户界面。

这样的架构一直维持到了 2012 年,当时我们每天进行 7000 个构建任务。我们欣喜的看到 Travis CI 在开源社区越来越广泛的使用,并且开始支持 11 种语言,包括 PHP,Python,Perl,Java 和 Erlang。

随着越来越多的使用,Travis CI 越来越像是一个开源项目的必备服务了。但是不幸的是,这个系统从一开始构建的时候就没有考虑过监控。

[attach] 2224[/attach] 过去,总是来自社区的用户通知我们系统没有正常运行,构建任务遇到异常,或是任务信息没有被处理好。

那可真是令人尴尬。我们的第一个挑战就是给系统增加监控,数据指标和日志,让 Travis CI 从一个业务爱好的项目转变为一个重要的商业平台。我们准备发布 Travis CI 的正式生产版本。

被用户告知系统没有正常运行直到今天仍然是我最大的噩梦,我们不得不努力工作建设好数据监控,以使系统能够在出现问题的一开始就及时通知。

如果没有任何数据记录或者良好的日志,我们根本不可能去搞清我们这个小分布式系统到底发生了什么。无论是从哪个方面看,Travis CI 都已经是一个分布式系统了。

加入监控指标和日志是一次循序渐进的学习过程,但是最终,它们让我们可以了解这个系统正在做什么,无论是通过图表还是日志。

这对我们而言是一个巨大的提升。可见性对于运行一个分布式系统是非常重要的。

当你写一个系统时,考虑好如何监控它。

做好监控会有助于你的系统更好的在生产环境运行,而不仅仅是通过测试。

关键是,更多的监控不仅仅是让你可以对系统更了解,你也会发现那些你以前未曾想到或见到的问题。系统更高的可见性带来更多的责任感。现在我们需要去面对这样的事实:我们对系统的错误有了更多的了解,所以我们必须更有效的工作来减少这些错误所带来的影响。 [attach] 2225[/attach]

Travis CI : 最小的分布式系统 (二) [attach] 2226[/attach] 大约 1 年之前,我们发现当时的架构有些不合理了。尤其是 Hub,它上面承担了太多的任务。Hub 要接收新的处理请求,处理并推动构建日志,它要同步用户信息到 Github,它要通知用户构建是否成功。它跟一大群外部 API 打交道,全部都是在一个进程中处理。

Hub 需要继续演化,但它却不太可能自由扩展。Hub 只能以单进程的方式运行,也因此成为我们最有可能发生的单点错误。

Github API 是一个有趣的例子。我们是 Github API 的重度用户,依靠这些 API 我们的构建任务才能执行。无论是获取构建配置信息,更新构建状态,还是同步用户数据,都离不开这些 API。

回顾历史,当这些 API 中的某一个不可用,hub 就会停止当天正在处理的任务,而转移到下一个任务上。所以,当 Github API 不可用时,我们的很多构建都会失败。

我们对这些 API 赋予了很多信任,当然现在也一样,但是说到底,这些是我们不能掌控的资源。这些资源不是我们自己来维护,而是由另外的一个团队,在另外的网络系统中,有他们自身的弱点。

我们过去没有这样想。过去我们总是把这些资源当做我们可以信赖的朋友,以为他们随时都会响应我们的请求。

我们错了。

一年之前,这些 API 无声无息的对某个功能做了修改。这个一个虽然没有文档说明,但是我们非常依赖的功能。这个功能就是这么消无声息的被修改了,于是导致了我们这边的问题。

结果,我们的系统完全乱套了。原因很简单,我们把 Github API 当做了自己的朋友,我们耐心的等待这些 API 回应我们的请求。每一次新的提交,我们都等了很长的时间,每次都有几分钟。

我们的超时设置太宽松了。因为这个原因,当对 Github API 的请求最终超时时,我们的系统也已经发生错误。那天晚上我们花了很长的时间处理这个问题。

即便是小问题,当某个时刻凑到一块了,也能够破坏一个系统。

[attach] 2227[/attach] 我们开始隔离这些 API 请求,设置更短的超时时间。为了保证我们不会因为 Github 方面的中断而导致构建失败,我们同样加了重试机制。为了保证我们能够更好的处理外部的异常,我们的每一次重试都会依次延长过期时间。

你应该接受那些在你控制之外的外部 API 随时可能失败的现实。如果你不能将这些失败完全隔离,你就有必要考虑如果去处理他们。

如何去处理每一个单点错误场景是基于商业上的考虑。我们可以承受一个构建出异常吗?当然,这不是世界末日。如果因为外部系统的问题,我们能够让数百个构建出现异常嘛?我们不能,因为无论什么原因,这些构建异常够影响到了我们的客户。

Travis CI 最初是一个好心的家伙。它总是很乐观地认为每一件事情总会正确的工作。

很不幸,那不是事实。每一件事在任何时刻都可能导致混乱,但是我们的代码却从来没考虑过这一点。我们做过很多努力,现在我们仍然在努力,去改变这种情况,去提高我们的代码处理外部 API 或者系统内部异常的能力。

[attach] 2228[/attach]

回到我们的系统,hub 承担的任务很容易导致异常,所以我们将其分割成很多的小应用。每个小应用都有其自身的目的和承担的任务。

做好任务隔离,这样我们就能更轻松的扩展系统。大部分任务都是直接的从上到下运行的。

现在我们有了三个进程;处理新的提交,处理构建通知,和处理构建日志。

突然之间,我们有了新的问题。

虽然我们的应用已经分割开了,但是他们都依赖一个叫做 travis-core 的核心。核心包括数量很多的 Travis CI 所有部分的商业逻辑。这可真是一个 big ball of mud。

[attach] 2229[/attach]

对核心的依赖意味着核心代码的改动可能影响到所有应用。我们的应用是按照各自的任务进行划分,但是我们的代码不是。

我们现在还在为最早的架构设计而支付学费。如果你增加功能,或是修改代码,对公用部分的一点点改变都可能带来问题。

为了保证所有应用的代码都可以正常工作,当 travis-core 做了修改,我们需要部署所有应用去验证。

任务并不仅仅意味着你必须从代码的角度将其分隔。任务本身也同样需要物理分隔。

复杂的依赖影响了部署,同样,它也影响了你交付新代码、新功能的能力。

我们慢慢的将代码的依赖变小,真正的从代码隔离开每个应用间的任务。幸运的是,代码本身已经有很好的隔离程度了,所以这个过程显得容易多了。

有一个应用需要特别关注,因为它是我们做扩展最大的挑战。

Travis CI : 最小的分布式系统 (三) [attach] 2230[/attach] 日志的作用有两个:当构建日志的数据块通过消息队列进来时,更新数据库对应行,然后推送它到 Pusher 用于实时的用户界面更新。

日志块以流的形式在同一个时间从不同的进程中进来,然后被一个进程处理。这个进程每秒最高可处理 100 个消息。

[attach] 2231[/attach] 一般情况下这样处理日志流的方式也相当 OK,但是这也意味着我们很难处理某些时刻突然增长的日志消息,因此这个唯一的进程对于我们系统的扩展会成为一个很大的障碍。

问题在于,进程是按照这些消息到达消息队列的先后顺序来进行处理的,而 Travis CI 中的所有事情都依赖于这些消息。

更新数据库里的一条日志流意味更新包含所有日志的一行数据。更新用户界面的日志当然意味着在 DOM 树上附加一个新的结点。

为了解决这个棘手的问题,我们需要改很多代码。

但是首先,我们需要理清楚什么才是一个更好的解决方案,好的解决方案应该是能够让我们很方便的扩展日志处理的部分。

我们决定让处理的顺序作为消息本身的一个属性,而不是隐含的依赖于它们被放进消息队列的顺序。 [attach] 2232[/attach]

这个想法是受到 Leslie Lamport 于 1978 年发表的一篇论文《Time, Clocks, and the Ordering of Events in a Distributed System》的启发。

在这篇论文中,Lamport 描述了在分布式系统中,使用递增计数器来保留事件发生的顺序的方法。当一个消息被发送,发送者会在消息被接收者接收到之前增加计数器的值。

我们可以简化那个想法,因为在我们的场景中一个日志块只能来自一个发送者。进程只要不断增加计数器的值,就可以让之后的日志收集工作变得简单。

剩下的工作就是根据计数器的值来对日志块进行排列了。

困难之处在于,这样设计之后等同于允许向数据库写入小的日志块,这些小日志块只有在对应任务结束后才会写入到完整的日志中。

但是这会直接影响到用户界面。我们不得不面对消息以无序的方式到来。这个变化的确影响的范围大了些,但它反过来简化了很多部分的代码。

从表面看,这个改动似乎无关紧要。但是依赖于你本不需要依赖的顺序会带来更多潜在的复杂性。

我们现在不用依赖于信息是如何传送的,因为现在我们可以在任何时间得到他们的顺序。

我们修改了不少代码,因为那些代码做了一个假设,任何信息都是顺序过来的,而这个假设是完全错误的。在分布式系统中,事件可以以任何顺序在任何时间到达。我们只需要确保之后我们可以将这些片段重新组装回去。

你可以从我们的博客获取这个问题更详细的说明。

到了 2013 年,我们每天已经在运行 45000 次构建。我们还是在为早先的设计付出着代价,但是我们也在慢慢的改进设计。 [attach] 2233[/attach] 我们现在还有一个麻烦。系统所有的组件还是在共享同一个数据库。如果数据库出现问题,自然的所有组件都会出现问题。这个故障上周我们刚刚遇见一次。

这同样意味着日志写入的数量 (目前可以达到每秒 300 次) 影响到了我们 API 的性能,当用户浏览我们的用户界面时可能会慢一点。

另外,当我们从构建任务的数量上考虑时,我们的下一个挑战就是如何去扩展我们的数据容量。

Travis CI 在 500 台构建服务器上运行,这已经不能再算是一个小的分布式系统了。我们现在正着手解决的问题还是从一个相当小的维度来考虑的,但即便在那个维度上,你也能够遇到很多有趣的挑战。根据我们的经验,简单直接的解决方案总是比那些更复杂的要好。

需要 登录 后方可回复。