你真的在做Cl吗
我猜你很有可能正在组织内使用持续集成。如果没有的话,你应该开始这么做,因为这个
关键实践允许我们更快速、更容易地修改代码。如果没有持续集成,向微服务架构进行转
型就会非常痛苦。即便如此,很多宣称自己在做CI的团队并没有真正在做。他们认为使
用了 ci工具就算是采用了 ci这个实践,事实上,只有工具是远远不够的。
我很喜欢jez Humble用来测试别人是否真正理解CI的三个问题。
•你是否每天签入代码到主线?
你应该保证代码能够与已有代码进行集成。如果你的代码和其他人的代码没被频繁地放 在一起,那么将来的集成就会非常困难。即使你只使用生命周期很短的分支来管理这些 修改,也要尽可能频繁地把代码检入到单个主线分支中。
•你是否有一组测试来验证修改?
如果没有测试,我们只能知道集成后没有语法错误,但无法知道系统的行为是否已经被 破坏。没有对代码行为进行验证的CI不是真正的CI。
•当构建失败后,团队是否把修复CI当作第一优先级的事情来做?
绿色的构建意味着,我们的修改已经安全地和已有代码集成在了一起。红色的构建意味 着,最后一次修改很可能有问题,这时只能提交修复构建的代码。如果你允许别人在构 建失败时提交更多的修改,用于修复构建的时间就会大大增加。我见过在一个团队中构 建失败持续了好几天,最后花了很长时间才修复这个构建。
6.2把持续集成映射到微服务
当持续集成遇上微服务时,需要考虑如何把CI的构建和每个微服务映射起来。前面我已
经提过很多次,每个服务应该能够独立于其他服务进行部署。所以如何在微服务、CI构建
及源代码三者之间,建立起合适的映射呢?
如果从最简单的做法开始,我们可以先把所有东西放在一起。如图6-1所示,现在我们有
一个巨大的代码库,其中包括所有的代码,并且只有一个构建。向该代码库任何一次的代
6.3构建流水线和持续交付
在早些年使用持续集成时,我们意识到了把一个构建分成多个阶段是很有价值的。比方说 在测试中可能有很多运行很快、涉及范围很小的测试;还有一些比较耗时、涉及范围较大 的测武,这些测试通常数量也比较少。如果所有测试一起运行的话,有可能一个快速测试 已经失败了,但是因为需要等待那些耗时测试的完成,所以还是无法得到快速反馈。而且 如果快速测试失败了,再接着运行剩下的耗时测试也是不合理的!解决这个问题的一个方 案是,将构建分解成为多个阶段,从而得到我们熟知的构建流水线。在第一个阶段运行快 速测试,在第二个阶段运行耗时测试。
构建流水线可以很好地跟踪软件构建进度:每完成一个阶段,就离终点更近一步。流水线 也能够可视化本次构建物的软件质量。构建物会在整个构建的第一个环节生成,然后它会 被用在整个流水线中。随着构建物通过不同的阶段,我们越来越能确定该软件能够在生产 环境下正常工作。
CD (Continuous Delivery,持续交付)基于上述的这些概念,并在此之t有所发展。正如 Jez Humble和Dave Farley的同名著作中提到的,CD能够检查每次提交是否达到了部署到 生产环境的要求,并持续地把这些信息反馈给我们,它会把每次提交当成候选发布版本来 对待。
为了更好地理解这些概念,我们需要对从代码提交及部署到生产环境这个过程中,所需要 经历的流程进行建模,并知道哪些版本的软件是可发布的。在CD中,我们会把多阶段构 建流水线的概念进行扩展,从而覆盖软件通过的所有阶段,无论是手动的还是自动的。在 图6-4中,我们可以看到一个熟悉的示例流水线。
图6-4: —个使用构建流水线建模的标准发布流程
我们需要一个真正重视CD概念的工具来辅助它的实施。我看过很多人尝试对CI工具进行 扩展来做CD,大多数情况下会得到一个复杂的系统,而这个系统,也不可能比一开始就 为CD设计的工具好用。完全支持CD的工具能够定义和可视化这些流水线,并对发布到 生产环境的整个过程进行建模。当某个版本的代码经过流水线时,如果它通过了某个自动 验证的步骤,就会移动到下一阶段。有些阶段可能是手动的,举个例子,如果你有一个尹 动白勺UAT (User Acceptance Testing,用户验收测试)流程,那么也应该可以使用CD工具 来对其建模。应该可以在CD工具中看到下一个可用于部署到UAT环境的构建,并触发 部署流程,如果通过了手动检查,就可以将该阶段标记为成功,这样它就能够移动到下一 阶段了。
通过对整个软件上线过程进行建模,软件质量的可视化得到了极大改善,这可以大大减少 发布之间的间隔,因为可以在一个集中的地方看到构建和发布流程,这也是可以引入改进的一个焦点。
在微服务的世界,我们想要保证服务之间可以独立于彼此进行部署,所以每个服务都有自 己独立的C1。在流水线中,构建物会沿着上线方向进行移动。构建物的大小和形态可能会 有很大差别,后面会看到一些最常见的例子。
不可避免的例外
所有好的规则都需要考虑例外。“每个微服务一个构建”的方法,基本上在大多数情况下 都是合理的,那么是否有例外呢?当一个团队刚幵始启动一个新项目时,尤其是什么都没 有的情况下,你可能会花很多时间来识别出服务的边界。所以在你识别出稳定的领域之 前,可以把初始服务都放在一起。
在最开始的阶段,经常会发生跨服务边界的修改,所以时常会有些内容移入或者移出某个 服务。在这个阶段,把所有服务都放在一个单独的构建中,可以减轻跨服务修改所带来的 代价。
当然,在这个阶段你必须把所有服务打包发布,但这应该是一个过渡步骤。当服务的API 稳定之后,就可以开始把它们移动到各自的构建中。如果几周(或者几个月)之后,你的 服务边界还是不够稳定,那么再把它们合并回单块服务中(当然还可以在边界内部保持模 块性),然后花些时间去了解领域。这也是我们SnapCI团队的经验,在第3章讨论过这个 问题。
6.4平台特定的构建物
大多数技术栈都有相应的构建物类型,同时也有相关的工具来创建和安装这些构建物。 Ruby中有gem,Java中有JAR包和WAR包,Python中有egg。对某一种技术有经验的开 发人员,都会比较了解与这些构建物相关的技术,如果他们也知道如何创建就更好了。
但是从微服务部署的角度来看,在有些技术栈中只有构建物本身是不够的。虽然可以把 Java的JAR包做成可执行文件,并在其中运行一个嵌入式的HTTP进程,但对于类似于 Ruby和Python这样的应用程序来说,你需要使用一个运行在Apache或者Nginx中的进 程管理器。所以为了部署和启动这些构建物,需要安装和配置一些其他软件,然后再启动 这些构建物。类似于Puppet和Chef这样的自动化配置管理工具,就可以很好地解决这个 问题。
另一个问题是,不同技术栈生成的构建物各不相同,所以混合不同的构建物进行部署就会 很复杂。可以尝试从某人想要同时部署多个服务的角度来考虑,比如,某个开发或者测试人员想要测试一些功能,或者做一次生产环境的部署。现在想象一下,所要部署的服务使 用了三种完全不同的部署机制,比如Ruby的Gem、JAR包和Node.js的NPM包,你会有 什么感觉?
自动化可以对不同构建物的底层部署机制进行屏蔽。Chef、Puppet及Ansible都支持一些 通用技术栈的构建物部署。但有一些构建物的部署会非常简单。
6.5操作系统构建物
有一种方法可以避免多种技术栈下的构建物所带来的问题,那就是使用操作系统支持的构 建物。举个例子,对基于RedHat或者CentOS的系统来说,可以使用RPM;对Ubuntu来 说,可以使用deb包;对Windows来说,可以使用MSI。
使用OS特定构建物的好处是,在做部署时不需要考虑底层使用的是什么技术。只需要简 单使用内置的工具就可以完成软件的安装。这些操作系统工具也可以进行软件的卸载及查 询,甚至还可以把CI生成的构建物推送到软件包仓库中。OS包管理工具,可以帮你完成 很多原本需要使用Chef或者Puppet来完成的工作。举个例子,在我用过的所有Limix平 台上,你都可以定义软件包所依赖的其他软件包,然后OS就会自动帮你完成这些工具的 安装。
其缺点是,刚开始编写构建脚本的过程可能会比较困难。对于Linux来说,FPM包管理工 具(https://github.com/jordansissel/fpm7wiki)为创建Linux操作系统软件包提供了很好的抽 象,所以能自然地从基于tarball的部署过渡到基于0S的部署。在Windows的世界,这件 事情就有些棘手了。相比Linux能够提供的功能来说,类似MSI这样的原生打包系统缺 失了很多功能。NuGet软件包系统对此做出了一定的改善,至少它简化了开发库的依赖管 理。最近,Chocolatey NuGet扩展了这个想法,并提供了一个Windows上的软件包管理器 来简化部署工作,它提供的功能和Linux提供的非常接近了。这个方向肯定是正确的,但 是Windows惯用的风格是部署在IIS,这意味着,这种方法可能对一些Windows团队没有 吸引力。
当然这会产生另一个缺点,即如果你需要部署到多种操作系统的话,维护不同版本构建物 的开销就会很大。如果你创建的软件包是用来给别人进行安装的,那么就别无选择。但如 果软件是部署在你可控的机器上,那么我建议,尽量减少需要维护的操作系统的数量,最 好只维护一种。它可以大大减少不同机器之间可能存在的不同之处,并减小部署和维护的 工作量。
我见过很多团队使用基于OS的软件包管理工具,很好地简化了他们的部署流程,并且通 常不会产生那种又大又复杂的部署脚本。特别是如果你在Linux上工作,而且采用多种技 术栈来部署微服务,那么这种方法就很适合你。
6.6定制化镜像
使用类似Puppet、Chef及Ansible这些自动化配置管理工具的一个问题是,需要花费大量 时间在机器上运行这些脚本。考虑这样一个例子:对服务器进行配置,使其能够部署Java 应用程序。假设我的服务器在AWS上,使用的是标准的Ubimtu镜像。为了运行Java应 用程序,需要做的第一件事情是安装Oracle JVM。这个简单的过程可能就会花费五分钟, 其中一些时间用于启动机器上,剩下的则用于安装JVM。然后我们才能开始考虑把软件放 上去。
上面这个例子比较简单,实际情况下还需要安装其他常用软件。比如,可能需要使用 collectd来收集操作系统的状态,使用logstash来做日志的聚合,还可能需要安装nagios来 做监控(第8章会详细讨论这部分内容)。随着时间的推移,越来越多的东西被添加进来, 所以自动化配置环境所需的时间也会越来越长。
Puppet、Chef和Ansible这类的工具,能够很智能地避免重复安装已安装的软件。但不幸 的是,这并不意味着在已经存在的机器上运行这些脚本总会很快,因为仅仅是做这些检查 就会花费很多时间。同时,我们也想避免一台机器运行的时间过长,因为这会引起配置漂 移(后面会详细解释)。如果使用按需计算平台,那么可以每天(如果不是更频繁的话) 按需关闭和启动新的实例,所以这些声明式的配置管理工具的使用可能会受到限制。
随着时间的推移,看着同样的工具被一遍遍重复安装,也是一种煎熬。如果在CI上运行 这些脚本,那么也无法得到快速反馈。在进行部署时,服务停止的时间也会增加,因为你 在等待软件的安装。类似于蓝/绿部署(第7章会详细讲解)的模式,可以帮助你缓解这 个问题,因为它允许我们在老版本服务不下线的同时,去部署新版本的服务。
一种减少启动时间的方法是创建一个虚拟机镜像,其中包含一些常用的依赖,如图6-5所 示。我用过的所有虚拟化平台,都允许用户构建自己的镜像,而且现在的工具提供的便利 程度,也远远超越了多年前的那些工具。使用这种方法之后事情就变得简单一些了。现在 你可以把公共的工具安装在镜像上,然后在部署软件时,只需要根据该镜像创建一个实 例,之后在其之上安装最新的服务版本即可。
你只需要构建一次镜像,然后根据这些镜像启动虚拟机,不需要再花费时间来安装相应的 依赖,因为它们已经在镜像中安装好了,这样就可以节省很多时间。如果你的核心依赖没 有改变,那么新版本的服务就可以继续使用相同的基础镜像。
这个方法也有一些缺点。首先,构建镜像会花费大量的时间。这意味着,在开发环境中可 能需要使用其他替代部署方案,避免花费很长时间去创建一个二进制部署物。其次,产生 的镜像可能会很大。当你创建VMWare镜像时,这会是一个很大的问题。想象一下,在网 络上传送一个20GB的镜像文件是怎样一个场景。后面会介绍一种容器技术:Docker,它 可以避免上述的一些问题。
由于历史原因,构建不同平台上的镜像所需的工具链是不一样的。构建VMWare镜像的方 式就和构建AWS AMI的不同,更不用说我们还有Vagrant镜像、Rackspace镜像等。如果 你只使用一个平台,那么这就不是问题,但并不是所有的组织都这么走运。而且即使撇幵 这个因素,这个领域的工具通常也很难用,很难将其与其他做机器配置的工具结合在一起 使用。
Packer (http://www.packer.io/)可以用来简化这个创建过程。你可以选择自己喜欢的T具 (Chef、Ansible、Puppet或者其他)来从同一套配置中生成不同平台的镜像。该工具产生 之初就为 VMWare、AWS、Rackspcace 云、Digital Ocean 和 Vagrant 提供了 支持,而且我 也见到此方法在Linux和Windows平台上的成功运用。这意味着,你可以在生产环境使用 AWS来做部署,并使用Vagram镜像做本地幵发和测试,它们都源于同一套配置。
6.6.1将镜像作为构建物
现在已经做到了使用包含依赖的虚拟机镜像来加速反馈,那么为什么要止步干此呢?我们可以更进一步,把服务本身也包含在镜像中,这样就把镜像变成了构建物。现在当你启动 镜像时,服务就已经就绪了。Netflix就是因为这个快速启动的好处,把自己的服务内建在 了 AWS AMI 中。
就像使用OS特定软件包那样,可以认为这些VM镜像是对不同技术栈的一层抽象。我们 不需要关心运行在镜像中的服务,所使用的语言是Ruby还是Java,最终的构建物是gem 还是JAR包,我们唯一需要关心的就是它是否工作。然后把精力放在镜像创建和部署的自 动化上即可。这个简洁的方法有助于我们实现另一个部署概念:不可变服务器。
6.6.2不可变服务器
通过把配置都存到版本控制中,我们可以自动化重建服务,甚至重建整个环境。但是如果 部署完成后,有人登录到机器上修改了一些东西呢?这就会导致机器上的实际配置和源代 码管理中的配置不再一致,这个问题叫作配置漂移。
为了避免这个问题,可以禁止对任何运行的服务器做手动修改。相反,无论修改多么小, 都需要经过构建流水线来创建新的机器。事实上,即使不使用镜像,你也可以实现类似的 模式,但它是把镜像作为构建物的一个非常合理的扩展。你甚至可以在镜像的创建过程中 禁止SSH,以确保没有人能够登录到机器上做任何修改。
当然,在使用这个方法时,也需要考虑前面提到的周期时间这个因素。同时需要保证,机 器上的持久化数据也被保存到了其他地方5。尽管存在这些复杂性,但我看到很多团队使用 这种模式之后,部署过程变得更容易理解,环境问题也更容易定位。前面我已经说过,任 何能够简化工作的措施都值得尝试!
6.7环境
当软件在CD流水线的不同阶段之间移动时,它也会被部署到不同的环境中。如果考虑图 6-4中所示的构建流水线,其中起码存在4个环境:一个用来运行耗时测试,一个用来做 UAT,一个用来做性能测试,另一个用于生产环境。我们的微服务构建物从头到尾都是一 样的,但环境不同。至少它们的主机是隔离的,配置也不一样。而事实上情况往往会复杂 得多。举个例子,我们的生产环境可能会包括两个数据中心的多台主机,使用负载均衡来 管理,而测试环境可能会把所有的服务运行在一台机器上。这些环境之间的不同可能会引 起一些问题。
多年前我就因为这个问题吃过亏。在生产环境中,我们使用WebLogic的集群来部署一个 Java Web服务。这个WebLogic的集群会在不同的节点之间复制会话状态,这样,如果一个节点宕机了,其他节点还可以正常使用。但由于WebLogic的许可证过于昂贵,所以在 测试环境中只使用了一台机器,也就是非集群的配置。
在一次发布中这带来了非常严重的问题。为了能够在节点之间复制会话状态,应该对这些 会话数据做恰当的序列化。不幸的是,我们的一次提交破坏了这个功能,所以部署之后复 制会话的功能就出问题了。最后通过不懈的努力,终于在测试环境中也使用了集群设置。
不同环境中部署的服务是相同的,但是每个环境的用途却不一样。在我的开发机上,想要 快速部署该服务来运行测试或者做一些手工测试,此时相关的依赖很有可能都是假的;而 在生产环境中,需要把该服务部署到多台机器上并使用负载均衡来管理,甚至从持久性 (durability)的角度考虑,还需要把这些机器放在不同的数据中心去。
从笔记本到UAT,最终再到生产环境,我们希望前面的那些环境能不断地靠近生产环境, 这样就可以更快地捕获到由环境差异导致的问题。你需要持续地做权衡。有时候重建类生 产环境所消耗的时间和代价会让人望而却步,所以你必须做出妥协。比如说,把软件部署 到AWS上需要25分钟,而在本地的Vagrant实例中部署服务会快得多。
类生产环境和快速反馈之间的平衡不是一成不变的。要持续关注将来产生的那些bug和反 馈时间,然后按需去调节这个平衡。
管理单块系统的环境很具有挑战性,尤其是当你对那些很容易自动化的系统没有访问权的 时候。当你需要对每个微服务考虑多个环境时,事情会更加艰巨。后面会讲一些能够简化 这些工作的部署平台。
6.8服务配置
服务需要一些配置。理想情况下,这些配置的工作量应该很小,而且仅仅局限于环境间的 不同之处,比如用来连接数据库的用户名和密码。应该最小化环境间配置的差异。如果你 的配置修改了很多服务的基本行为,或者不同环境之间的配置差异很大,那么你可能就只 能在一套环境中发现某个特定的问题,这是极其痛苦的事情。
所以,如果存在不同环境之间的配置差异,应该如何在部署流程中对其进行处理呢? 一种 方法是对每个环境创建不同的构建物,并把配置内建在该构建物中。刚开始看这种方法好 像挺有道理。配置已经被内建了,只需要简单的部署,它应该就能够正常工作了,对吧? 其实这是有问题的。还记得持续交付的概念吗?我们想要创建一个构建物作为候选发布版 本,并使其沿着流水线向前移动,最终确认它能够被发布到生产环境。想象一下,我构 建 了一个 Customer-Service-Test 构建物和 Customer-Service-Prod 构建物。如果 Customer-Service-Test构建物通过了测试,但我真正要部署的构建物却是Customer-Service-Prod,又 要如何验证这个软件最终会真正运行在生产环境中呢?
还有一些其他的挑战。首先,创建这些构建物比较耗时。其次,你需要在构建的时候知道 存在哪些环境。你要如何处理敏感的配置数据?我可不想把生产环境的数据库密码提交到 源代码中,但是如果在创建这些构建物时需要的话,通常这也是难以避免的。
一个更好的方法是只创建一个构建物,并将配置单独管理。从形式上来说,这针对的可能 是每个环境的一个属性文件,或者是传入到安装过程中的一些参数。还有一个在应对大量 微服务时比较流行的方法是,使用专用系统来提供配置,第11章会详细讨论这个话题。