"留意你所演进的方向是重要的,这样你才能持续不断向有用的方向发展。"
在 Reddit 我们仍然不断地部署代码。每个工程师都会编写代码,再让其他人审查这份代码,合并代码之后再定期把代码推到生产环境。这种情形每周经常会发生 200 次而且每次部署从开始到结束都不会超过 10 分钟。
支持所有这些的系统在这些年不断演进。让我们看看在这段时间它是如何改变的(包括没有改变的部分)。
故事最开始的地方:一致和可重复的部署(2007-2010)
现在系统起源于一个叫做 push 的 Perl 脚本。在 Reddit 的历史上,写这个脚本的时候和现在是大相径庭。当时 Reddit 只有一群小会议室就可以容纳的工程师队伍。Reddit 也没有部署在 AWS。站点运行在固定数目的服务器上,如果要增加站点处理能力就需要手动添加机器,整个站点是由一个叫做 r2 的大型单体 Python 应用组成。
一直到现在都没有改变的事是请求在负载均衡器上会被分类并被分配到其它独立应用服务器特殊的 "请求池" 中。比如说,listing 和 comment 页面是在不同的请求池里处理。虽然任何给定的 r2 进程都可以处理任何类型的请求,单个池与其他池的请求高峰是隔离的,并且当它们有不同的依赖关系时,每个池的失败也是隔离的。
push 工具在代码里有一个硬编码的服务器列表并且它是围绕单个应用部署的过程所构建的。它将会遍历所有的应用服务器,使用 SSH 登录到那台机器,运行一系列预设的命令来通过 git 更新服务器上的代码副本,然后重启所有的应用进程。实际上过程如下(大量简化,不是真实的代码):
# build the static files and put them on the static server
`make -C /home/reddit/reddit static`
`rsync /home/reddit/reddit/static public:/var/www/`
# iterate through the app servers and update their copy
# of the code, restarting once done.
foreach $h (@hostlist) {
`git push $h:/home/reddit/reddit master`
`ssh $h make -C /home/reddit/reddit`
`ssh $h /bin/restart-reddit.sh`
}
整个部署过程是顺序的。它一个接一个的在服务器上完成它的工作。就像听起来那么简单,这实际上是一件很棒的事情:它允许一定形式的金丝雀部署。如果你部署了少数服务器的时候一个新的异常突然出现,这时你知道引入了一个 bug 就可以马上中断(Ctrl-C)部署并且回滚之前已经部署的服务器,这样就不会影响全部的请求。因为部署的简单性,我们可以很轻易的在生产环境尝试新事物并且在它不工作的情况下也可以很轻松的还原到之前状态。这也意味着在同一时间内只执行一次部署是很有必要的,这可以保证新的错误是源自于你的部署而不是其他人的部署,从而可以很简单的知道何时以及哪里需要回滚。
这些对于确保确保一致和可重复的部署都非常重要。它运行的很快。一切都很美好。
一大批新的人(2011)
然后我们雇佣了一批工程师,成长到有六个全职工程师的队伍,现在这个队伍适合进入一个更大点的会议室。我们开始觉得在部署中需要有更好的协调性,特别是当有个人是在家里工作的情况。我们修改了 push 工具让它通过一个 IRC 聊天机器人来声明部署是什么时候开始和结束的。这个机器人就存在 IRC 中并声明部署的事项。部署的过程看起来和以前一样,只是现在由系统为我们做这个工作并通知每一个人你正在做什么。
这是我们在部署工作流中第一次使用聊天机器人。在这段时间里,有很多管理部署系统的会话都源自于聊天机器人,但是因为我们使用的是第三方的 IRC 服务器,所以我们在生产环境的控制中并不能完全信任这个聊天室,所以它仍然只是单向的信息流。
在站点流量增加的同时,我们也保证了相应基础设施的增长。我们偶尔需要启动新的一系列新的应用服务器并把它们放到服务中。这仍然是非常手工化的过程,包括更新 push 代码中的主机列表。
当我们需要给服务器扩容时,我们经常会一次增加数个服务器来增大一个池。其结果是,顺序地遍历服务器列表会快速地接触同一个池中的多个服务器,而不是不同池中的服务器。
我们使用 uWSGI 来管理工作进程,当我们通知这个应用重启时,它将会关闭已经存在的进程并且生成新的进程。这个新的进程需要一段时间才能准备好处理请求,并且我们是在同一时间内处理一个池,这将会影响池处理请求的能力。所以我们把部署速度限制到可以保证安全的速度。当服务器数量增多时,部署的时间也会变长。
一个重构的部署工具(2012)
我们对部署工具进行了一次改革,改成使用 Python 编写,让人困惑的是它仍然被叫做 push。这个新版本有一些主要的提升。
首先,它是从 DNS 中获取它的主机列表而不像之前那样硬编码到代码中。这让我们可以在更新主机列表时不用担心忘记更新部署工具 — 一个基本的服务发现系统。
为了处理顺序重启的问题,我们在部署前打乱了主机列表的顺序。因为它把所有服务器池的部署顺序都打乱了,这让我们可以在更快的速度下安全的切换版本,从而部署地更快。
这个最初的实现只是每次都随机的打乱顺序,但是这样做的话很难快速的回滚代码,因为你不会每次都部署到和之前一样的前几台机器上。所以我们修改了打乱的策略使用了种子(译者注:即随机数生成器的种子),当你需要回滚的时候,这个种子可以第二次重新使用。
另一个小而重要的变化是始终部署指定版本的代码。先前版本的部署工具会在给定的主机上更新 master 分支,但是如果因为有人不小心推了代码导致 master 分支在部署中改变了呢?通过部署特定的 git 版本而不是分支名,我们可以确保部署在生产环境的任何地方代码都是同样的版本。
最后,新工具区分了它的代码(主要关注主机列表和用 ssh 登录到这些主机)和被运行的命令。它仍然非常偏向于满足处理 r2 的需求,但是它有了一个多样的原型 API。这让 r2 可以控制自己的部署步骤,从而更简单把代码的改变推到构建和发布流。例如,以下是可能在单个服务器上运行的指令。确切的命令并没有显示出来但是这个命令序列仍然是特定于 r2 的工作流。
sudo /opt/reddit/deploy.py fetch reddit
sudo /opt/reddit/deploy.py deploy reddit f3bbbd66a6
sudo /opt/reddit/deploy.py fetch-names
sudo /opt/reddit/deploy.py restart all
那个叫做 fetch-names 的命令是只针对 r2 处理的。
自动伸缩器(2013)
然后我们决定开始使用云端的设施和自动伸缩(这是另一篇博客文章的主题)。这让我们在网站不怎么忙时省下一大笔钱,遭遇到预料不及的请求量时自动增加设施。
之前所做的自动从 DNS 获取主机列表的功能使这个变成了一个很自然的过渡。主机列表的更改频率比以前更加频繁,但是这对于工具来说并没有什么不同。这个一开始只是为了提高生活质量的东西现在成为了自动伸缩的必要部分。
然而,自动伸缩确实带来了一些有趣的边界条件。天下没有免费的午餐,如果在部署进行的期间启动了新的服务器,那会发生什么?我们必须确保所有新启动的服务器都能切换到新的代码(如果有的话)。如果服务器在部署中途退出了怎么办?这个工具必须做得更聪明,从而可以检测服务器何时可以合法地被移除,而不是成为部署过程中的一个应该被提醒的问题。
意外的,这段时间我们也因为各种各样的原因从 uWSGI 切换到 Gunicorn。对于部署来说,这并没有真正的区别。
事情仍在继续。
太多服务器了(2014)
随着时间推移,需要处理峰值流量的服务器不断增长。这意味着部署所花的时间越来越长。在最坏的情况下,一个普通的部署会花掉将近一个小时。这看起来不对啊。
我们重写了部署工具来并行处理主机。这个新版本叫做 rollingpin。老的部署工具所花的大量时间都是初始化 ssh 连接并且等待命令完成,所以在可允许的安全数量并行化部署可以加快部署。这马上又把部署的时间降低到了 5 分钟。
为了减少同时重新启动多台服务器的影响,部署工具的随机打乱程序也变得越来越智能。它不会随便的打乱服务器列表,而是[通过最大限度的分割每个池的服务来交错的部署服务器]((https://github.com/reddit/rollingpin/blob/master/rollingpin/utils.py#L94-L110)。更加显著的减少了部署对网站的影响。
新工具最重要的变化是部署工具和每个服务器上的工具之前的 API定义的更加清晰并且和 r2 的需求解耦。这最初是为了让源代码更加易读,但不久之后变的非常有用。下面是一个部署示例,高亮显示的命令是被远程执行的 API。
太多人了(2015)
突然,似乎有太多人在同一时间在 r2 上工作了。这很棒但也意味着更多的部署。维持在同一时间只部署一次代码慢慢变得更加困难,个别的工程师必须先口头上协调好他们发布代码的顺序。为了解决这个问题,我们向聊天机器人添加了协调部署队列的功能。工程师将先申请申请将部署锁并且将其部署或放入等待队列。这有助于维护部署的顺序并且让人在等待锁解开的时候可以休息一下。
在团队增长中增加的另一个重要功能是集中化追踪部署。我们修改了部署工具来将部署过程中的指标发送到 Graphite,这样就可以简单地将指标的变化和部署相关联。
第二次(太多)服务了(也是 2015)
恍如隔世,我们有第二个服务要上线了。这个网站新的移动版要上线。这是一个完全不同的技术栈,而且它有自己的服务器和构建过程。这是部署工具解耦 API 的第一次实战测试。通过增加在每个项目不同位置增加构建步骤的能力,新服务成功启动了而且我们能够在同一个系统下管理这两个服务。
太多服务了(2016)
在下一年的开发过程中,我们看到了 Reddit 团队的爆炸式增长。我们从这两个服务增长到十几个服务并且从两个团队增长为几打。我们的主要服务要么都是建立在我们的后端服务框架 Baseplate 上,要么就是类似于移动网络的 node 应用。这个部署的基础设施在所有的服务中都很常见,而且因为 rollingpin 并不关心它部署的是什么,越来越多的服务可以更快的上线。这就可以很轻松地用我们熟悉的工具来部署新的服务。
安全的网络(2017)
随着专用于单体应用的服务器数量增加,部署的时间也增长了。我们希望通过提高同时部署的并行数量来解决这个问题,但是这样做会导致过多同时重新启动的应用服务器。这样我们可用服务器的容量就会不足,导致不足以处理接受的请求,使其它的应用服务器过载。
Gunicorn 的主进程使用的是和 uWSGI 相同的模式,它将会同时重启所有的工作进程。在新的工作进程启动阶段,你都不能处理任何请求。我们单体应用的工作进程启动时间为 10-30 秒,这意味着在这段时间内,我们将无法处理任何请求。为了解决这个问题,我们用 Stripe 的 worker 管理器 Einhorn 取代了 gunicorn 的主进程,但是仍然保存 gunicorn 的 HTTP 堆栈和 WSGI 容器。用 Einhorn 来重启工作进程的方式是:先产生一个新的工作进程,等到这个新的进程声明已经准备好处理请求之后关闭旧的工作进程,重复前面的步骤直到全部服务器都升级好。这样创建了一个安全的网络可以让我们在部署期间仍能保证服务器的处理能力。
这个新模式引入了另一个问题。如前所述,一个工作进程可能需要长达 30 秒的时间来替换和启动。这意味着如果你的代码有一个 bug,它将不会立刻显露出来而且你继续会在很多服务器上做版本变更。为了防止这种情况,我们引入了一个部署方式,部署过程会阻塞一直到工作进程已经完成重启,之后才会在另一台服务器开始部署。这是通过简单的定时查询 Einhorn 状态,一直到所有的新工作进程都准备好。为了保持部署的速度,我们只是增加了并行量,至少现在看这样做是安全的。
这个新的机制让我们可以并行地部署更多的服务器,无视因为安全而等待的额外时间,对于将近 800 台服务器部署的时间降至 7 分钟。
忆古思今
这个部署的基础设施是多年来逐步提升的结果,而不是任何单一专门的开发过程。历史中的问题和每一步的权衡无论是在现在的系统还是过去任何时候的都看得到。这种演进的方式有利有弊:在任何时间我们所需要付出的努力都会更少,但是在这个过程中我们可能会遇到死胡同。重要的是要关注你正在演进的地方,这样你才能不断朝着有用的方向前进。
未来
Reddit 的基础设施需要支持团队的扩大和新项目的构建。现在 Reddit 这家公司的发展速度比历史上的任何时候都要快,而且我们正在开发比以前更大,更有趣的项目。我们今天遇到的大问题有两个方面:首先要在保持生产环境基础设施安全的情况下提高工程师的自主权,还要逐步建立一个可以让工程师可以放心地快速部署的安全网络。