「译」How We Release So Frequently

这篇文章是Sky Betting & Gaming网站的Principle Engineer Tom Hudson发在他们blog上的技术文章,介绍他们是如何做到每天发布多次,以及这样做的好处。Sky Betting & Gaming是一家英国博彩网站,运行过程中涉及到大量的资金流转。对数据迁移以及资金安全等方面,都有较高要求。我们不一定要像他们一样每天发布多次,但其中的思想是值得学习和探讨的,比如数据库迁移和git的feature分支流程等。因此粗译出来分享给大家,欢迎指正。

How We Release So Frequently

原文链接:http://engineering.skybettingandgaming.com/2016/02/02/how-we-release-so-frequently/

在Sky Betting & Gaming,我们每天会发布数次新版本的代码,同时并不暂停我们的服务。我们的生产环境体量很大,而且种类繁多。但我只想关注在我们那个类似LAMP的技术栈上如何发布(最新的)软件。说它“类似”是因为,虽然很多年前我们使用的是传统的LAMP栈。但随着需求增加,我们相应地,也增加了很多其他技术,比如redis,Node.js,MongoDB等。不过,它的核心部件,仍然是一些跑着Apache和mod_php的服务器,它们会跟各种数据库和API会话。

我们的发布流程如下:

  • 构建发布包,并基于它进行测试;
  • 拷贝这个发布包到一个NFS的共享服务器上去;
  • 进行数据库迁移;
  • 自动切换Apache docroot至新的包;

只需点击Jenkins UI上的一个按钮,就可以在10分钟内完成上述所有动作。而这10分钟里,大部分都用在测试上了。我们有大量测试:unit tests, integration tests和full stack tests。每种测试都有各自的价值,所以每一种我们都有很多(用例)。如果串行执行,需要跑一个多小时。因此,我们将之分成小类,在十多个Jenkins slaves上并行执行。

这些听起来都没什么新意,确实是因为也没什么新奇的东西。速度方面还有很多提升空间(Jenkins slaves数量会一直够吗?)。当然,更快的构建过程也可以让我们更频繁地发布。但这些,并不是故事的全部。

我们之所以能如此频繁地发布,主要原因是约定

仅前向迁移

我们不回滚数据库的迁移。技术上讲,我们可以。但至少四年了,我们都没有这个需求。这是因为我们做的每次数据库迁移,新生成的schema都可以兼容代码的前一个版本和新版本。如果我们不得不回滚我们的代码(有时确实需要这么干),前一个版本的代码也可以完美地使用数据库新的schema.

实现它的并不是什么黑魔法,纯粹基于约定。以删除一列为例。我们如何发布这个改动?很简单:

  • 发布一版不使用那列数据的代码;确保它是稳定的,不会被回滚;
  • 发布第二版,包含将该列移除的数据库迁移;

就是这么简单。移除一张表也是相同的方法:先发布一个不使用它的版本,接着在下一个版本里移除这张表。

也不是所有更新都这么简单。如果我们想重命名一列,我们需要:

  • 发布第一版。先添加新列,列名为新的名字。相应地更新代码,写数据库的地方需要两列都写,读数据库的地方只用读老的那一列;
  • 执行一个批处理job,将老的那一列数据都复制到新的这一列。为了避免过多的locking,每次只复制一行;
  • 接着发布第二版,读取和写入都是到新的这一列;
  • 最后发布第三版,彻底移除老的那一列;

三次独立的发布和一个批处理job,对于重命名一列来说,工作貌似有累赘。但我们并不经常重命名列,而且这么做让我们在发布回滚时更有自信(,所以也可以接受)。

新代码 != 新功能

除非是一次性能上显著的提升,用户不应注意到代码的发布。对用户来说,看到一个新功能出现,开始使用,几分钟后因为发布回滚(可能因为不相关的原因),功能又消失了,这绝对是一次糟糕的体验。

我们每次新功能的发布,最初都处于隐藏状态,随时可以用一个“功能开关”触发开启。功能开关可以基于每个session来开启,这样就可以在用户看到它之前,在线上环境完整地测试。这种方式有几个流弊的优点:

  • 我们不需要只是因为某个新功能无法正常工作,就回滚整次发布(可能包含多个改动);
  • 我们可以在拥有完全一致的硬软件以及所需数据的环境里,完整地测试新功能;
  • 我们可以逐步向用户发布新功能;

最后这一点非常重要。如果我们不能保证一个新功能可以承受所有用户同时使用的压力时,可以先向少部分用户发布这个功能,然后监控它的性能。

我们将每个新的session分配一个1到100之间的随机值,将它存在一个long-lived cookie里。接着用这个值来将所有sessions归到不同的分组里。比如,假设我们有:

  • 1-10: 有风险的新功能组;
  • 11-100: 可控组;

这样,10%左右的sessions就会得到有风险的新功能,剩下的将没有任何变化。如果一切OK,我们就会将范围扩大到50%的session会得到这个新功能。最后,当我们对所有事情都非常有信心时,就可以推广到100%了。

(这里有个问题,)对一个已存在的session来说,只露出部分新功能不是很和谐。所以我们用了另一个小技巧来消弭这种不和谐。在session开始时,不仅使用long-lived cookie保存随机值,我们也会用一个session cookie来将用户”粘到”定义好的分组里。

如果session是全新的,那么我们将:

  • 分配一个随机值,并存在一个long-lived cookie;
  • 检查这个随机值归属于哪些分组;
  • 将那些分组信息保存在一个session cookie里;

在这个session里,所有后续的request,我们都将持续使用那些分组的规则。即使后来我们改变了新功能生效范围,导致那个随机值已经不属于之前的那些分组。一旦这个用户的session过期了,这个session cookie就会消失;因此所有以后的requests都会基于这个随机值的long-lived cookie来执行。除了不需要再分配新的随机值,这些使用随机值的long-lived cookie的requests,都会遵循前述相同的规则;

这种方式的好处是,用户在一个session过程中,不会看到部分露出的新功能。而在他们后来新的session里,就会看到完整露出的新功能。

小型发布

通过快速构建,大量测试,低风险的数据库迁移,以及和代码发布解耦合的功能更新,不仅能帮助我们快速地发布代码,也提供了一个非常有用的反馈环路:发布得越频繁,每次发布就可以越小。更小的发布带来更少的风险,也让我们能更频繁地发布代码。频繁的发布并不一定意味着小的发布–它依然需要一点约定。

我们的开发通常是在git的feature分支上进行的,技术上讲,没什么能防范我们制造生存周期过长的feature 分支(这样的分支将导致大型,并随之高风险的发布)。这时需要的规范是,不要等到所有(功能)都完成了再发布,而应该在没有破坏(任何其他功能)时就发布。这种做法最初可能会让人觉得不自然,但长期来看确实会减少开发人员的“发布焦虑”问题。

当你开始更频繁地发布代码,随之而来的另一件事就是,你将更清晰地感受到发布流程里的小痛点。大部分人可能可以忍受一个月才执行一次的手动操作,但没多少人能忍受每天进行数次手动操作。那些之前你想暂且搁置一边的小问题,(在频繁发布过程中)将变成大烦恼,但这也是好事:大问题就会得到更高的优先级并会被修复。

这看上去很明显,但更频繁地做某些事,确实是更好的改进它的一种方式。