DevOps-2-0-工具集-全-
DevOps 2.0 工具集(全)
原文:
annas-archive.org/md5/e248f279147eee242afb743e2e4e63f4译者:飞龙
前言
我开始我的职业生涯时是一名开发者。在那些早期的日子里,我所知道的(并且认为我应该知道的)就是编写代码。我相信,一个优秀的软件设计师是一个擅长编写代码的人,通往精通这项技术的道路就是深入了解自己选择的编程语言的所有内容。后来,这种想法发生了变化,我开始对不同的编程语言产生兴趣。我从 Pascal 转到 Basic,再到 ASP。当 Java 和后来 .Net 出现时,我学习了面向对象编程的好处。Python、Perl、Bash、HTML、JavaScript、Scala。每一种编程语言都带来了新的东西,教会我如何以不同的方式思考,以及如何为当前任务选择合适的工具。每学会一门新语言,我就感觉离成为专家更近了一步。我当时唯一的目标就是成为一名资深程序员。随着时间的推移,这个愿望发生了变化。我学到,如果我想做好我的工作,就必须成为一名软件工匠。我必须学习的不仅仅是打代码。测试一度成了我的执念,而现在我认为它是开发过程中不可或缺的一部分。除非在非常特殊的情况下,我写的每一行代码都是基于测试驱动开发(TDD)的。它已经成为我工具带中不可或缺的一部分。我还学到,我必须与客户保持紧密联系,和他们并肩工作,共同定义要做的事情。所有这些,以及其他许多事情,最终让我走上了软件架构的道路。理解全局,努力将不同的部分融合成一个完整的系统,这个挑战我逐渐学会了喜欢。
在我多年来从事软件行业的过程中,没有任何工具、框架或实践比持续集成(CI)以及后来的持续交付(CD)更让我钦佩。这句话的真正含义,隐藏在 CI/CD 所涵盖的广泛范围中。最开始,我以为 CI/CD 就是我懂得使用Jenkins并能编写脚本。随着时间的推移,我越来越深入地了解,发现 CI/CD 涉及软件开发的几乎每个方面。这些知识的获得是有代价的。
我曾经(不止一次)未能成功创建与当时所用应用程序配合的 CI 流水线。尽管其他人认为结果是成功的,但现在我知道那是失败的,因为我采取的方法是错误的。没有做出架构决策,CI/CD 是无法完成的。测试、配置、环境、故障转移等方面也可以说类似。要创建成功的 CI/CD 实施,我们需要做很多改变,初看起来这些改变似乎与 CI/CD 没有直接关系。我们需要从一开始就应用一些模式和实践。我们必须考虑架构、测试、耦合、打包、容错等许多其他事情。CI/CD 要求我们影响几乎所有的软件开发方面。正是这种多样性让我爱上了它。通过实践 CI/CD,我们正在影响并改善软件开发生命周期的几乎每个方面。
要真正精通 CI/CD,我们需要远不止是操作方面的专家。DevOps 运动是一个重要的改进,它将传统操作与开发所能带来的优势结合在一起。但我认为这还不够。如果我们想要获得 CI/CD 带来的所有好处,我们需要了解并影响架构、测试、开发、操作,甚至客户谈判。即使作为 CI/CD 驱动力的 DevOps 这个名称也不合适,因为它不仅仅是关于开发和运维,更是与软件开发相关的一切。它还应包括架构师、测试人员,甚至是管理人员。与传统操作相比,DevOps 是一个巨大的改进,通过将其与开发相结合,DevOps 运动认识到,手动运行操作在当前的商业需求下已不可行,而且没有开发就没有自动化。我认为是时候通过扩大其范围来重新定义 DevOps 了。由于名称 DevOpsArchTestManageAndEverythingElse 太复杂,不仅难以记住,几乎无法发音,我选择 DevOps 2.0。它是下一代,应该放弃那种包罗万象的工具,转而使用设计来执行非常具体任务的较小工具。这是一个应该回到起点的转变,不仅要确保操作自动化,还要确保整个系统的设计可以自动化,快速、可扩展、容错、零停机、易于监控,等等。我们无法仅通过自动化手动过程和使用单一的全能工具来实现这一目标。我们需要深入挖掘,从技术层面和过程层面开始重构整个系统。
概述
本书介绍了不同的技术,帮助我们以更好的方式、更高效地架构软件,采用微服务,打包成不可变容器,经过测试并持续部署到通过配置管理工具自动配置的服务器上。这是关于快速、可靠和持续部署的内容,具有零停机和回滚能力。它讲述了如何扩展到任意数量的服务器,设计能够从硬件和软件故障中恢复的自愈系统,以及集中的日志记录和监控集群。
换句话说,本书涵盖了使用一些最新和最优秀的实践和工具来进行微服务开发和部署的整个生命周期。我们将使用 Docker、Kubernetes、Ansible、Ubuntu、Docker Swarm 和 Docker Compose、Consul、etcd、Registrator、confd、Jenkins 等工具。我们将深入实践,甚至更多工具。
最后,尽管本书有很多理论内容,但它是一本实践性很强的书。你不能仅仅通过在地铁上看它就完成它。你必须在电脑前阅读这本书,动手实践。最终,你可能会遇到困惑并需要帮助,或者你可能想要写书评或评论本书的内容。请在 Disqus 上的 The DevOps 2.0 Toolkit 频道发布你的想法。如果你更喜欢一对一的讨论,随时发送电子邮件到<viktor@farcic.com>,或者通过 HangOuts 与我联系,我会尽力提供帮助。
读者群体
本书面向那些对完整的微服务生命周期感兴趣的专业人士,结合了持续部署和容器技术。由于内容范围非常广泛,目标读者可能是架构师,他们希望了解如何围绕微服务设计自己的系统。也可能是DevOps,他们希望了解如何应用现代配置管理实践并持续部署打包在容器中的应用程序。它还适用于开发者,他们希望将过程掌控在自己手中,以及管理者,他们希望更好地理解从开始到结束交付软件的过程。我们将讨论如何扩展和监控系统。我们甚至会设计(并实现)能够从故障中恢复的自愈系统(无论是硬件故障还是软件故障)。我们将直接将应用程序持续部署到生产环境中,做到零停机,并随时能够回滚。
本书适合任何想要了解软件开发生命周期的人,从需求和设计开始,通过开发和测试,一直到部署和部署后的阶段。我们将创建这些过程,借鉴一些最大型公司开发的最佳实践。
第一章:DevOps 理想
从事小型绿地项目是一件很棒的事。我参与的最后一个项目是在 2015 年夏天,尽管它有一些问题,但整体上还是非常愉快的。与一套小而相对新的产品合作使我们能够选择我们喜欢的技术、实践和框架。我们是否应该使用微服务?当然,为什么不呢?我们是否应该尝试 Polymer 和 GoLang?没问题!没有那些束缚你的包袱是一种美妙的感觉。一个错误的决定可能让我们退步一周,但它不会危及到别人之前辛勤工作的几年时间。简单来说,我们不需要考虑并害怕遗留系统的影响。
我的职业生涯大多数时间并不是这样的。我有机会,或者说是一种诅咒,参与到大型遗留系统的工作中。我曾为一些公司工作,这些公司在我加入之前就已存在,并且无论是好是坏,都已经有了自己的系统。我必须在创新和改进的需求与现有业务必须不间断运作之间找到平衡。在所有那些年里,我不断地尝试寻找新的方法来改进这些系统。虽然这很痛心,但我不得不承认,许多尝试都是失败的。
我们将探讨这些失败,以便更好地理解推动我们在本书中讨论的进展的动机。
持续集成、持续交付与持续部署
发现持续集成(CI)以及后来出现的持续交付(CD)是我职业生涯中的一个关键点。这一切都显得合情合理。在那个时候,集成阶段可能持续几天、几周,甚至几个月。这是我们都害怕的时期。在不同团队为不同服务或应用程序工作几个月后,集成阶段的第一天就是地狱的代名词。如果我不懂,我可能会说但丁是一个开发者,并且在集成阶段写了《地狱篇》。
在令人畏惧的集成阶段的第一天,我们都会带着严肃的面孔走进办公室。只能听到低声耳语,集成工程师宣布整个系统已搭建好,游戏可以开始了。他启动系统,有时,结果是一个空白屏幕。几个月的独立工作再次证明是灾难性的。服务和应用程序无法集成,修复问题的漫长过程就此开始。在某些情况下,我们可能需要重新做几周的工作。提前定义的需求,像往常一样,会有不同的解释,这种差异在集成阶段尤为明显。
然后,极限编程(XP)实践诞生了,随之而来的是持续集成(CI)。今天,集成应该持续进行这个想法听起来似乎是显而易见的。嗯!当然,你不应该等到最后一刻才进行集成!但在当时的瀑布开发时代,这种做法并不如今天这样显而易见。我们实施了一个持续集成的流水线,并开始检查每一次提交,运行静态分析、单元测试、功能测试、打包、部署和集成测试。如果任何一个阶段失败,我们会放弃正在做的工作,并将修复流水线检测到的问题作为我们的首要任务。流水线本身非常快速。在有人提交代码到仓库后几分钟内,我们就会收到通知,若出现失败。后来,持续交付(CD)开始得到普及,我们可以确信每次通过整个流水线的提交都能部署到生产环境。我们甚至做得更好,不仅能验证每个构建是否准备好生产,而且可以应用持续部署,在不等待任何人(手动)确认的情况下,部署每个构建。所有这些最棒的一点是,一切都是全自动化的。
这是一个梦想成真。字面意思!它是一个梦想。它不是我们设法变成现实的东西。那是为什么呢?我们犯了错误。我们认为 CI/CD 是运维部门的任务(今天我们称之为DevOps)。我们以为我们可以创建一个围绕应用程序和服务的流程。我们认为 CI 工具和框架已经准备好。我们认为架构、测试、商业谈判以及其他任务是别人负责的事。我们错了。我错了。
今天我知道,成功的 CI/CD 意味着没有任何环节可以忽略。我们需要影响一切;从架构到测试,再到开发和运维,直到管理层和商业预期。但让我们再回到原点。那些失败中,究竟出了什么问题?
架构
尝试调整一个由许多人多年来开发的单体应用,缺乏测试、耦合紧密且技术陈旧,就像试图让一个八十岁的老太太看起来年轻一样。我们可以改善她的外貌,但我们所能做的也就是让她看起来稍微不那么老,而不是年轻。简单来说,某些系统已经太老,不值得进行现代化努力。我尝试过,很多次,结果从未如预期。 有时候,使其恢复年轻的努力是不划算的。另一方面,我无法去对银行之类的客户说:“我们将重写你们的整个系统。”重写一切的风险太大,而且由于其紧密耦合、老化和技术过时,改动其中的一部分也不值得投入太多精力。常见的做法是开始构建新系统,并在此期间维护旧系统,直到一切完成。这总是个灾难。完成这样一个项目可能需要好几年,而我们都知道长期规划的事情最终会怎么样。那甚至不是瀑布方法。那就像站在尼亚加拉大瀑布的底部,困惑自己为什么会被淋湿。即便是像更新 JDK 这种琐事,也变得异常艰难。而那时候,我会觉得自己很幸运。那么,面对例如用 Fortran 或 Cobol 编写的代码库,你会怎么做呢?
然后我听说了微服务。这对我来说简直是如同天籁之音。我们可以构建许多小的独立服务,由小团队维护,代码库可以很快理解,能够在不影响整个系统的情况下更换框架、编程语言或数据库,并且可以独立部署的想法实在太美好了,几乎不敢相信。我们终于可以开始从单体应用中剥离一些部分,而不至于让整个系统(遭受重大)风险。听起来太完美了,结果也确实如此。好处的背后也有缺点。部署和维护大量服务最终变成了一项沉重的负担。我们不得不做出妥协,开始标准化服务(扼杀创新),创建共享库(再次耦合),将它们分组部署(拖慢一切进度),诸如此类。换句话说,我们不得不去除微服务本应带来的好处。更不用提配置和它们在服务器内部制造的混乱了。这些是我尽量忘记的日子。我们已经有了足够多与单体系统相关的问题,微服务只是将它们放大了。这是一次失败。然而,我还没有准备好放弃。叫我一个受虐狂也罢。
我不得不一次解决一个问题,其中一个关键问题就是部署。
部署
你知道这个过程。先将一些工件(JAR、WAR、DLL,或者任何你编程语言的产物)组装起来,然后部署到已经被“污染”的服务器上... 我甚至无法把这句话说完,因为在许多情况下,我们甚至不知道服务器上都有什么内容。随着时间的推移,任何手动维护的服务器都会堆满各种东西。库、可执行文件、配置文件、捣蛋鬼和恶作剧者。它开始发展出自己的个性。又老又脾气差,速度快但不可靠,要求高等等。所有服务器唯一的共同点就是它们各不相同,没有人能确保在“预生产环境”中测试过的软件在部署到生产环境后会表现得一样。那简直就是买彩票。你可能会幸运,但大概率不会。希望是最后死的。
你可能会问,为什么在那些日子里我们没有使用虚拟机。嗯,这个问题有两个答案,它们取决于“那些日子”是指哪段时间。一个答案是,在那些日子里我们根本没有虚拟机,或者它们太新了,管理层对批准使用它们感到害怕。另一个答案是,后来我们确实使用了虚拟机,而那才是真正的改进。我们可以复制生产环境,并将其用作,比如说,测试环境。只不过,仍然需要做很多工作来更新配置、网络设置等等。
此外,我们依然不知道这些机器上积累了多少东西。我们只知道如何复制它们。这仍然没有解决配置在不同虚拟机之间不同的问题,另外,复制虽然短时间内看似与原始环境一致,但最终会偏离。部署时,改一些配置,bada bing,bada boom,你又回到了测试与生产环境不一致的问题。差异随着时间的推移而积累,除非你有一个可重复且可靠的自动化流程,而不是依赖人工干预。如果这样的东西存在,我们就能创建不可变的服务器。与其把应用程序部署到现有的服务器上,走上积累差异的老路,我们可以把创建新的虚拟机作为 CI/CD 流水线的一部分。于是,我们不再创建 JAR、WAR、DLL 等文件,而是开始创建虚拟机。每当有新的版本发布时,它会作为一个从头构建的完整服务器出现。通过这种方式,我们可以确保测试过的就是将要投入生产的版本。创建新的虚拟机,部署软件,进行测试,然后将生产路由器切换到新的虚拟机。这非常棒,除了它慢且资源消耗大。为每个服务创建单独的虚拟机有些过头。尽管如此,凭借耐心,不可变的服务器确实是一个好主意,但我们使用这种方法的方式以及支持它的工具并不够成熟。
编排
编排是关键。Puppet和Chef证明了它们是巨大的帮助。编程所有与服务器设置和部署相关的任务是一个巨大的改进。不仅设置服务器和部署软件所需的时间大大减少,而且我们终于可以实现一个更可靠的过程。让人类(也就是运维部门)手动执行这些任务,简直是灾难的配方。终于有一个圆满的结局?其实不然。你可能已经开始注意到一个模式。一旦一个改进完成,通常它会伴随一个高昂的代价。随着时间的推移,Puppet 和 Chef 的脚本和配置会变成一大堆****(我被告知不要使用某些词,所以请用你的想象力填充这些空白)。维护它们本身往往成为一场噩梦。不过,通过编排工具,我们能够大幅度减少创建不可变虚拟机的时间。总比什么都没有好。
部署流水线尽头的曙光
我可以一直描述我们所面临的问题。别误会我,所有这些举措都是改进措施,并且在软件历史上有其地位。但历史是过去的,我们生活在当下,试图展望未来。我们曾经面临的许多问题,甚至可以说是所有问题,现在已经得到了解决。Ansible 证明了编排不需要复杂的设置,也不需要难以维护。随着Docker的出现,容器逐渐取代了虚拟机,成为创建不可变部署的首选方式。新的操作系统正在出现,并完全拥抱容器作为第一类公民。服务发现工具为我们展示了新的前景。Swarm、Kubernetes 和 Mesos/DCOS正在开启一些几年前难以想象的领域。
微服务正在慢慢成为构建大型、易于维护并具有高度可扩展性系统的首选方式,这得益于像Docker、CoreOS、etcd、Consul、Fleet、Mesos、Rocket等工具。这个想法一直很棒,但我们以前并没有足够的工具来让它正常运作。现在我们有了!这并不意味着我们所有的问题都已经解决。它意味着,当我们解决一个问题时,难度就会提高,而新的问题也会随之而来。
我一开始抱怨过去。以后不会再这样了。本书是给那些不想活在过去而是活在当下的读者的。本书是关于为未来做准备的。本书是关于穿越镜子的旅程,关于进入新领域,关于从新的角度看待事物。
| 这是你最后的机会。之后就没有回头路了。你服下蓝色药丸——故事结束,你回到床上,信仰你想信的任何东西。你服下红色药丸——你将留在仙境,我会带你看看兔子洞有多深。 | ||
|---|---|---|
| --摩尔菲斯(黑客帝国) |
如果你选择了蓝色药丸,我希望你没有买这本书,而是通过阅读免费的样本来获取这些内容。没有任何不快。我们每个人都有不同的追求和目标。另一方面,如果你选择了红色药丸,你将会经历一场奇妙的旅程。这就像过山车一样,我们还未能预见到在这场旅程结束时将会有什么等着我们。
第二章:实现突破——持续部署、微服务和容器
初看之下,持续部署(CD)、微服务(MS)和容器似乎是三个不相关的主题。毕竟,DevOps运动并未规定微服务是持续部署所必需的,也没有要求将微服务打包成容器。然而,当这三者结合时,新的大门打开了,等待我们迈步而入。容器领域的最新发展和不可变部署的概念使我们能够克服微服务之前所面临的许多问题。另一方面,它们使我们能够获得灵活性和速度,而没有这些,持续部署将不可能实现,也无法在成本上做到高效。
在我们继续深入这个思路之前,我们将尝试正确地定义每一个术语。
持续集成
为了理解持续部署,我们首先需要定义它的前身——持续集成和持续交付。
项目开发的集成阶段往往是软件开发生命周期中最痛苦的阶段之一。我们会花费数周、数月甚至数年时间,在各自的团队中为不同的应用程序和服务而工作。每个团队都有他们自己的需求,并尽力满足它们。虽然定期验证每个应用程序和服务的独立性并不困难,但我们都害怕团队领导决定该是时候将它们集成到一个统一交付物中了。凭借从前期项目中积累的经验,我们知道集成将会遇到问题。我们知道,我们将发现问题、未满足的依赖关系、接口无法正确通信,而管理层将感到失望、沮丧和焦虑。通常情况下,这一阶段可能会花费数周甚至数月时间。最糟糕的是,在集成阶段发现的一个 bug 可能意味着我们需要回去重新做掉几天甚至几周的工作。如果有人问我对集成的看法,我会说这是我能体验到的最接近长期沮丧的感受。那时的情况不同,我们曾认为那是开发应用程序的“正确”方式。
从那时起,很多事情发生了变化。极限编程(XP)和其他敏捷方法变得广为人知,自动化测试变得频繁,持续集成开始占据重要地位。今天我们知道,当时我们开发软件的方式是错误的。自那时以来,行业已经走了很长一段路。
持续集成(CI)通常是指在开发环境中集成、构建和测试代码。它要求开发者频繁地将代码集成到共享代码库中。到底“频繁”是指多久,这可以有很多解释,取决于团队的大小、项目的规模以及我们投入的编程时间。在大多数情况下,这意味着开发者要么直接推送代码到共享代码库,要么将自己的代码合并到其中。无论是推送还是合并,这些操作在大多数情况下应该至少每天做几次。将代码推送到共享代码库本身还不够,我们还需要有一个管道,至少要检查代码并运行所有与该代码相关的测试,无论是直接还是间接的。管道执行的结果可以是红色或绿色。如果是前者,说明某些操作失败了;如果是后者,则说明所有操作都顺利执行。如果出现前者,最小的反应是通知提交代码的人。
持续集成管道应该在每次提交或推送时运行。与持续交付不同,持续集成没有明确的目标来定义该管道。仅仅说一个应用程序与其他应用程序集成并不能告诉我们它的生产准备情况。我们不知道还需要多少工作才能将代码交付到生产环境。我们真正追求的目标只是知道一次提交没有破坏任何现有的测试。尽管如此,正确实施持续集成(CI)仍然是一个巨大的进步。在许多情况下,这是一项非常难以实施的实践,但一旦大家都适应了,结果通常会令人印象深刻。
集成测试需要与实现代码一起提交,或者至少在其之前提交。为了获得最大的效益,我们应该采用测试驱动开发(TDD)的方式编写测试。这样,不仅测试可以与实现代码一起提交,而且我们知道这些测试是没有缺陷的,不管我们做什么,它们都不会通过测试。TDD 带来了许多其他好处,如果你还没有采用它,我强烈推荐你去使用。你可以参考technologyconversations.com/category/test-driven-development/ 这个博客。
测试并不是唯一的持续集成(CI)前提条件。最重要的一条规则是,当管道失败时,修复问题的优先级要高于任何其他任务。如果这个修复行动被推迟,管道的后续执行也会失败。人们会开始忽略失败通知,渐渐地,CI 过程会失去它的意义。我们越早修复在 CI 管道执行过程中发现的问题,就越好。如果立即采取纠正措施,那么问题的潜在原因仍然鲜明(毕竟,提交与失败通知之间的时间只有几分钟),修复问题应该是轻而易举的。
那么它是如何工作的呢?具体细节取决于工具、编程语言、项目以及许多其他因素。最常见的流程如下:
-
推送到代码仓库
-
静态分析
-
部署前测试
-
打包并部署到测试环境
-
部署后测试
推送到代码仓库
开发人员在单独的分支上开发功能。一旦他们觉得自己的工作稳定,所开发的分支就会与主干(或 trunk)合并。更高级的团队可能完全跳过功能分支,直接提交到主干。关键点是主干分支(或 trunk)需要经常接收提交(无论是通过合并还是直接推送)。如果几天或几周过去,变化会积累,使用持续集成的好处会减少。在这种情况下,没有快速反馈,因为与其他人的代码集成被推迟了。另一方面,CI 工具(我们稍后会讨论)正在监控代码库,每当检测到提交时,代码会被检出(或克隆),然后 CI 管道会运行。管道本身由一组自动化任务组成,这些任务可以并行或顺序执行。管道的结果要么是其中某个步骤的失败,要么是成功的提升。至少,失败应该导致某种形式的通知,发送给推送了导致管道失败的提交的开发人员。开发人员应该负责修复问题(毕竟,他最清楚如何修复几分钟前他自己造成的问题),并重新提交代码库,这反过来将触发管道的再次执行。这个开发人员应该把修复问题当作最优先任务,以便保持管道始终为“绿色”,并避免其他开发人员的提交导致失败。尽量将收到失败通知的人员数量保持在最低。整个从发现问题到修复问题的过程应该尽可能快。涉及人员越多,管理工作就越多,直到修复提交的时间就越长。另一方面,如果管道在所有任务中都成功运行,整个过程中生成的包将被提升到下一个阶段,并在大多数情况下交给测试人员进行手动验证。由于管道(几分钟)和手动测试(几小时或几天)之间的速度差异,并非每次管道执行都由 QA 执行:

图 2-1 – 持续集成过程
持续集成管道的第一步通常是静态分析。
静态分析
静态分析 是在不执行程序的情况下对计算机软件进行的分析。与其相反的是,执行程序时进行的分析被称为 动态分析。
静态分析的目标从突出可能的编码错误到确保遵循约定的格式不等。虽然使用静态分析的好处值得商榷,但实施它所需的努力非常小,因此没有真正的理由不使用它。
我不会提供工具的全面列表,因为它们因编程语言而异。CheckStyle 和 FindBugs 适用于 Java,JSLint 和 JSHint 适用于 JavaScript,PMD 适用于多种语言,这只是几个例子。
静态分析通常是流水线中的第一步,原因很简单,它的执行速度通常非常快,在大多数情况下比流水线中的任何其他步骤都要快。我们需要做的就是选择工具,并通常花一些时间设置我们希望它们使用的规则。从那以后,维护的成本几乎为零。由于执行此步骤不应该超过几秒钟,因此时间成本也微不足道:

图 2-2 – 持续集成流水线:静态分析
在设置好静态分析后,我们的流水线已经启动,可以进入部署前测试阶段。
部署前测试
与(可选的)静态分析不同,部署前测试应该是强制性的。我故意避免使用更具体的名称来描述这些测试,因为它们依赖于架构、编程语言和框架。一般来说,所有不需要将代码部署到服务器的测试类型都应该在这个阶段运行。单元测试总是属于这个类别,可能还会有其他几种测试。如果,例如,你可以在不部署代码的情况下执行功能测试,那么就现在执行它们。
部署前测试可能是持续集成流水线中最关键的阶段。虽然它不能提供我们所需要的所有确定性,也不能替代部署后测试,但在这个阶段运行的测试相对容易编写,执行速度非常快,而且它们通常能提供比其他类型的测试(例如集成测试和性能测试)更大的代码覆盖率:

图 2-3 – 持续集成流水线:部署前测试
打包与部署到测试环境
一旦我们完成了所有可以在不实际部署应用程序的情况下进行的验证,就该开始打包了。打包的方法取决于框架和编程语言。在 Java 环境中,我们会创建 JAR 或 WAR 文件,对于 JavaScript,我们会最小化代码,并可能将其发送到 CDN 服务器,等等。有些编程语言在此阶段不需要我们做任何事情,除了可能将所有文件压缩成 ZIP 或 TAR 文件,以便更方便地传输到服务器。一个可选的步骤,但在本书中是必须的,就是创建一个容器,其中不仅包含应用程序包,还包含应用程序可能需要的所有其他依赖项,比如库、运行时环境、应用服务器等。
一旦部署包创建完成,我们可以继续将其部署到测试环境中。根据服务器的容量,你可能需要将其部署到多个机器上,例如,一个机器专门用于性能测试,另一个机器则用于其他所有需要部署的测试:

图 2-4 – 持续集成流水线:打包和部署
部署后测试
一旦部署到测试环境中,我们就可以执行其余的测试;这些测试是那些无法在不部署应用程序或服务的情况下运行的测试,以及那些验证集成是否成功的测试。同样,能够在此阶段执行的测试类型取决于框架和编程语言,但作为一般规则,它们包括功能测试、集成测试和性能测试。
用于编写和运行这些测试的具体工具和技术将取决于许多方面。我个人的偏好是使用行为驱动开发进行所有功能测试,这些测试同时也作为验收标准,Gatling用于性能测试。
一旦部署后测试的执行成功完成,持续集成流水线通常也会完成。我们在打包和部署到测试环境过程中生成的包或工件,将等待进一步的验证,通常是手动验证。之后,流水线中的某个构建将被选中部署到生产环境。额外检查和部署到生产环境的方式和细节并不属于持续集成的范畴。每个通过整个流水线的构建都被视为已集成,准备好迎接接下来的任务:

图 2-5 – 持续集成流水线:部署后测试
在流水线中还可以做很多其他事情。这里展示的流水线是一个非常通用的示例,通常会因案例而异。例如,你可能会选择测量代码覆盖率,并在未达到某个百分比时失败。
我们现在不深入细节,而是尝试对这个过程进行总体概述,接下来我们将进入持续交付和部署。
持续交付与部署
连续交付流水线在大多数情况下与我们用于持续集成(CI)的流水线相同。主要区别在于我们对该过程的信心,以及在流水线执行后无需采取任何后续行动。持续集成假设在之后需要进行(大多是手动)验证,而成功实施连续交付(CD)流水线则意味着包或工件已经准备好进行生产部署。换句话说,每次成功运行的流水线都可以部署到生产环境中,毫无疑问。是否进行部署则更多取决于政治而非技术决策。市场部门可能希望等到某个特定日期,或者他们可能希望将一组功能一起发布。无论选择部署哪个版本以及何时部署,从技术角度来看,每个成功构建的代码都是完全完成的。持续集成与持续交付过程之间的唯一区别在于后者没有在包通过流水线之后进行的手动测试阶段。简而言之,流水线本身提供了足够的信心,认为无需手动操作。借助它,我们在技术上有能力部署每个已推广的构建。至于哪些构建将被部署到生产环境,这通常是基于业务或市场标准的决策,企业决定何时发布一组功能:

图 2-6 – 连续交付过程
请记住,我们在持续交付过程图中继续使用了CI 工具。之所以如此,是因为 CI 和 CD 工具之间没有任何实质性差异。这并不意味着没有被市场化的 CD 工具——其实有很多。然而,根据我的经验,这更像是一种市场营销手段,因为假设流程依赖于高度自动化,两者几乎是相同的。
关于流水线过程,持续集成和持续交付之间也没有实质性区别。两者都经历相同的阶段。真正的区别在于我们对该过程的信心。因此,持续交付过程没有手动质量保证(QA)阶段。决定哪个已推广的包将部署到生产环境是我们的任务。
持续部署流水线更进一步,自动部署每一个通过所有验证的构建。它是一个完全自动化的过程,始于代码库的提交,终于应用或服务被部署到生产环境。没有人工干预,没什么需要决定的,也没有什么需要做的,只需开始编写下一个功能,而你的工作成果正在向用户传递。当包被部署到 QA 服务器上,才会部署到生产环境时,部署后测试会进行两次(或根据部署到的服务器数量进行多次)。在这种情况下,我们可能选择运行不同子集的部署后测试。例如,我们可能在部署到 QA 服务器的软件上运行所有测试,而只在部署到生产环境后运行集成测试。根据部署后测试的结果,我们可能选择回滚或将发布版本开放给公众。当使用代理服务让新的发布对公众可见时,通常不需要回滚,因为新发布的应用程序在问题被检测到之前并未对外公开:

图 2-7 – 持续部署流水线
我们需要特别关注数据库(特别是关系型数据库),并确保从一个版本到另一个版本的更改是向后兼容的,并且能够在两个版本之间正常工作(至少在一段时间内)。
虽然持续集成欢迎但不一定要求在生产环境中测试已部署的软件,持续交付和部署则将生产环境中的(主要是集成)测试视为绝对必要,且在持续部署的情况下,这部分测试是完全自动化流水线的一部分。由于没有人工验证,我们需要尽可能确保部署到生产环境中的软件按预期工作。这并不意味着所有的自动化测试都需要重复进行。这意味着我们需要运行能证明部署软件与系统其余部分集成良好的测试。我们可能在其他环境中运行相同的集成测试,但这并不意味着由于某些差异,部署到生产环境的软件就能够继续与系统的其余部分“友好合作”。
在持续部署的背景下,另一个非常有用的技术是功能切换。由于每个构建都会部署到生产环境,我们可以使用功能切换来暂时禁用某些功能。例如,我们可能已经完全开发了登录界面,但没有注册功能。如果没有注册功能,向访客展示一个功能不完整的界面就没有意义。持续交付通过手动批准哪些构建部署到生产环境来解决这个问题,并选择等待。然而,在持续部署的情况下,这个决策过程不可用,因此功能切换是必须的,否则我们就需要延迟与主干的合并,直到所有相关功能完成。然而,我们已经讨论过与主干不断合并的重要性,这种延迟与 CI/CD 的逻辑背道而驰。虽然有其他方法可以解决这个问题,但我认为功能切换是所有选择应用持续部署的团队不可或缺的工具。我们不会深入讨论功能切换的细节。有兴趣了解更多信息的朋友,可以访问technologyconversations.com/2014/08/26/feature-toggles-feature-switches-or-feature-flags-vs-feature-branches/ 文章。
大多数团队从持续集成开始,逐渐向交付和部署过渡,因为前者是后者的前提条件。在本书中,我们将实践持续部署。不要害怕,我们所做的一切都可以轻松修改,以便进行暂停和手动干预。例如,我们将直接将容器部署到生产环境(实际上是模拟生产环境的虚拟机),而不经过测试环境。当应用本书中的技术时,你可以轻松选择在中间添加一个测试环境。
需要注意的重要一点是,我们讨论的流水线阶段是按特定顺序执行的。这个顺序不仅是逻辑上的(例如,我们不能在编译之前进行部署),而且是根据执行时间的顺序来安排的。执行时间较短的任务会先执行。例如,作为一般规则,部署前的测试通常比部署后的测试运行得要快。这个规则也应该在每个阶段内遵循。例如,如果你在部署前阶段有不同类型的测试,那么先运行那些较快的测试。追求速度的原因是为了尽早获得反馈。我们越早发现提交中有问题,越好。理想情况下,我们应该在开始下一个开发任务之前就得到反馈。提交代码,喝一杯快速的咖啡,检查你的收件箱,如果没有收到任何愤怒的邮件说明某些事情失败了,就可以继续下一个任务。
在本书的后续内容中,你会发现,由于微服务和容器带来的优势,所呈现的管道中的一些阶段和细节有所不同。例如,打包过程最终将以不可变(无法更改)容器的形式完成,可能完全不需要部署到测试环境,我们可能选择直接在生产环境中进行测试,使用蓝绿部署技术,等等。不过,我有点超前了,所有内容都会在适当的时机讲解。
在目前处理完 CI/CD 后(暂时),是时候讨论微服务了。
微服务
我们已经在持续部署的背景下讨论过速度。这里的速度是指从新功能的构思到它完全投入使用并部署到生产环境所花的时间。我们希望能够快速行动,并提供最短的上市时间。如果新功能能够在几个小时或几天内交付,业务就能比需要几周或几个月的交付时间更快看到收益。
速度可以通过多种方式实现。例如,我们希望管道尽可能快,这不仅是为了在发生故障时提供快速反馈,还能释放资源供其他排队的任务使用。我们应该目标是在几分钟内,而不是几小时内,从代码检查到部署到生产环境。微服务有助于实现这个时间目标。对于一个庞大的单体应用来说,整个管道的运行通常比较慢。测试、打包和部署也是如此。另一方面,微服务由于更小,通常要快得多。测试的代码更少,打包的代码更少,部署的代码也更少。
如果仅仅是这个原因,我们是不会切换到微服务架构的。稍后会有整整一章内容深入探讨微服务。目前需要注意的是,由于今天的竞争目标(如灵活性、速度等),微服务可能是我们可以应用的最佳架构类型。
容器
在容器普及之前,微服务的部署是非常痛苦的。相比之下,单体应用程序的处理相对简单。例如,我们会创建一个单一的工件(JAR、WAR、DLL 等),将其部署到服务器并确保所有必需的可执行文件和库(例如 JDK)都已经到位。这个过程大多数时候是标准化的,考虑的事情相对较少。一个微服务同样简单,但当它们的数量增加到十个、百个甚至千个时,事情就开始变得复杂了。它们可能使用不同版本的依赖、不同的框架、各种应用服务器等等。我们需要考虑的事项开始呈指数级上升。毕竟,微服务的其中一个原因就是能够为每项工作选择最合适的工具。有的可能在 GoLang 中写得更好,而另一些则更适合 NodeJS。一个可能使用 JDK 7,而另一个可能需要 JDK 8。安装和维护这些内容可能会很快让服务器变成垃圾堆,让负责管理的人抓狂。那个时候最常用的解决方案是尽可能标准化。每个人的后端都必须使用 JDK 7。所有前端必须使用 JSP。共有代码应该放在共享库中。换句话说,人们试图用他们多年来在开发、维护和部署单体应用程序中学到的逻辑来解决微服务部署的问题。为了标准化而扼杀创新。而我们不能怪他们。当时唯一的替代方案是不可变虚拟机,但这仅仅是将一组问题替换成另一组问题。直到容器变得流行,且更重要的是,变得大众可用。
Docker 使得我们能够在不受苦的情况下使用容器。它们让容器变得可访问且易于使用,适用于每个人。
什么是容器?容器一词的定义是用于容纳或运输某物的物体。大多数人将容器与集装箱联系在一起。它们应具备足够的强度,以承受运输、存储和搬运。你可以看到它们通过各种方式进行运输,其中最常见的是通过船只。在大型船厂,你可以看到数百甚至数千个集装箱堆叠在一起,既横向排布,也纵向叠放。几乎所有的商品都是通过集装箱运输的,这是有原因的。它们是标准化的,易于堆叠且不易损坏。大多数参与运输的人并不知道集装箱里装的是什么。没有人关心(除了海关),因为里面的内容并不重要。唯一重要的是知道在哪儿取货,在哪里交货。这是一个明确的关注点分离。我们知道如何从外部处理它们,而里面的内容只有最初打包的人知道。
“软件”容器背后的理念是相似的。它们是隔离和不可变的镜像,提供设计好的功能,在大多数情况下只能通过其 API 进行访问。它们是让我们的软件在(几乎)任何环境中可靠运行的解决方案。无论它们运行在哪里(开发者的笔记本电脑、测试或生产服务器、数据中心等),结果应该始终是相同的。最后,我们可以避免如下对话。
QA:登录屏幕存在问题。
开发者:在我的电脑上可以正常工作!
容器使这种对话变得过时的原因是,无论它们运行在何种环境中,容器的行为都是一样的。
容器实现这一壮举的方式是通过自给自足和不可变性。传统的部署方法会将一个工件放入现有的节点,期待其他所有东西都已就绪;例如应用服务器、配置文件、依赖项等等。而容器则包含了我们软件所需的一切。其结果是,一组镜像堆叠成一个容器,包含从二进制文件、应用服务器和配置到运行时依赖和操作系统包的所有内容。这一描述引出了容器与虚拟机之间的差异问题。毕竟,到目前为止,我们所描述的内容对两者来说都是有效的。
例如,一台物理服务器运行五个虚拟机时,除了虚拟化管理程序外,还需要五个操作系统,而虚拟化管理程序比lxc更占用资源。另一方面,五个容器共享物理服务器的操作系统,并在适当时共享二进制文件和库。因此,容器比虚拟机轻量得多。对于单体应用来说,这种差别不大,特别是当单个应用就占据整个服务器时。然而对于微服务来说,考虑到可能在单台物理服务器上有数十个甚至数百个容器,这种资源利用上的优势至关重要。换句话说,一台物理服务器可以托管更多的容器,而非虚拟机:

图 2-8 —— 虚拟机与容器资源利用比较
三剑客——持续部署、微服务与容器的协同作用
持续部署、微服务和容器是天作之合。它们就像三剑客,每个都能做出伟大的成就,但当它们联合在一起时,能做到更多。
通过持续部署,我们可以提供持续的、自动的反馈,告知应用的准备情况以及部署到生产环境的状态,从而提高交付质量并减少到达市场的时间。
微服务为我们提供了更多的自由,使我们能够做出更好的决策,加快开发进度,并且,正如我们很快将看到的那样,更容易扩展我们的服务。
最终,容器为许多部署问题提供了解决方案;一般来说,特别是在处理微服务时,它们也提高了可靠性,因为它们是不可变的。
它们可以将所有这些结合起来,并做更多的事情。在本书中,我们将以快速和频繁部署为目标,完全自动化,实现零停机时间,具备回滚能力,提供跨环境的一致性可靠性,能够轻松扩展,并创建能够从故障中恢复的自愈系统。这些目标中的任何一个都非常有价值。我们能否实现所有这些目标?可以!我们手头的实践和工具可以提供这一切,我们只需要正确地将它们结合起来。前方的旅程漫长而充满激情。有很多内容需要涵盖和探索,我们需要从头开始;接下来我们将讨论我们即将开始构建的系统架构。
| 知道还不够;我们必须应用。愿意还不够;我们必须行动。 | ||
|---|---|---|
| --约翰·沃尔夫冈·冯·歌德 |
第三章:系统架构
从这里开始,整本书将会是一个大型项目。我们将经历所有阶段,从开发到生产部署和监控。每个阶段将从讨论我们可以采取的不同路径开始,以实现目标。我们将根据需求选择最佳路径并实施。目标是学习可以应用到你自己项目中的技术,所以请随时根据需要调整指引。
和大多数其他项目一样,这个项目将从高层次的需求开始。我们的目标是创建一个在线商店。完整的计划还没有出来,但我们知道销售图书是优先事项。我们应该以一种容易扩展的方式设计服务和 Web 应用。我们面前并没有完整的需求集,因此需要为未知做好准备。除了图书,我们还将销售其他类型的商品,并且会有其他功能,如购物车、注册和登录等等。我们的任务是开发书店并能够快速响应未来的需求。由于这是一个新项目,开始时预计不会有太多流量,但如果服务变得成功,我们应该能够轻松快速地扩展。我们希望能够尽快发布新功能,且没有任何停机时间,并且能够从故障中恢复过来。
让我们开始着手架构设计。显然,需求非常笼统,没有提供很多细节。这意味着我们应该为未来可能发生的变化以及新的功能请求做好准备。同时,业务要求我们构建一个小型的系统,但又要准备好扩展。我们该如何解决这些问题呢?
我们首先应该决定的是如何定义我们即将构建的应用的架构。哪种方法能够允许我们在未来可能发生方向变化、额外(但目前未知)需求以及需要准备扩展的情况下做出调整?我们应该从两种最常见的应用架构方法开始:单体架构和微服务架构。
单体应用
单体应用是作为一个整体单元进行开发和部署的。在 Java 中,结果通常是一个单独的 WAR 或 JAR 文件。C++、.Net、Scala 以及许多其他编程语言也有类似的情况。
软件开发的短短历史大多标志着我们正在开发的应用程序规模的不断增大。随着时间的推移,我们不断为应用程序添加更多内容,持续增加其复杂性和体积,同时降低我们的开发、测试和部署速度。
我们开始将应用程序分为不同的层:表示层、业务层、数据访问层等。这种分离更多的是逻辑上的,而非物理上的,每一层往往负责某一类特定操作。这种架构通常带来了即时的好处,因为它清晰地定义了每一层的责任。我们在高层次上实现了关注点的分离。生活变得美好,生产力提高,上市时间缩短,代码库的整体清晰度也更高。每个人似乎都很开心,至少有一段时间是这样:

图 3-1 – 单体应用
随着时间的推移,我们应用程序需要支持的功能数量不断增加,而这也带来了更高的复杂性。一个 UI 层的功能需要与多个业务规则进行交互,而这些业务规则又需要多个 DAO 类来访问不同的数据库表。不管我们如何努力,每一层内部的细分和层与层之间的通信变得越来越复杂,并且如果时间足够长,开发人员开始偏离最初的设计路径。毕竟,最初设计的方案通常经不起时间的考验。因此,对任何给定子层的修改往往变得更加复杂、耗时且风险较大,因为这些修改可能会影响系统的多个部分,并且往往带来无法预见的影响:

图 3-2 – 功能增加的单体应用
随着时间的推移,情况开始变得更糟。在许多情况下,层数增加了。我们可能会决定添加一个规则引擎层、API 层等等。正如通常的情况那样,层与层之间的流动在很多情况下是必须的。这样就导致了我们可能需要开发一个简单的功能,在不同的情况下可能只需要几行代码,但由于架构的原因,这几行代码最终变成了几百行甚至几千行,因为所有层都需要经过。
开发并不是唯一受到单体架构影响的领域。每次有变化或发布时,我们仍然需要对所有内容进行测试和部署。在企业环境中,应用程序的测试、构建和部署通常需要数小时之久并不罕见。测试,尤其是回归测试,往往是噩梦般的,在某些情况下,可能持续数月。随着时间的推移,我们对仅影响一个模块的更改的处理能力正在下降。层的主要目标是使它们能够轻松地被替换或升级。这个承诺几乎从未真正实现过。在大型单体应用中,替换某个部分几乎从来都不是容易且无风险的事情。
扩展单体应用通常意味着扩展整个应用程序,从而导致资源的极度不平衡。如果我们需要更多的资源,我们不得不将一切都复制到新的服务器上,即使瓶颈只是某一个模块。在这种情况下,我们最终常常会得到一个在多个节点上复制的单体应用,并在其上方加上负载均衡器。这种设置充其量只是次优的:

图 3-3 – 扩展单体应用
服务按水平进行拆分
面向服务架构(SOA)是为了解决由常常紧密耦合的单体应用所带来的问题而创建的。该方法基于我们应该实现的四个主要概念:
-
边界是显式的
-
服务是自治的
-
服务共享架构和合同,但不共享类
-
服务兼容性基于策略
SOA 取得了巨大的成功,许多软件供应商纷纷投入其中,创建了旨在帮助我们进行过渡的产品。由 SOA 运动产生的最常用类型是企业服务总线(ESB)。与此同时,曾经遇到单体应用和大规模系统问题的公司也加入了这一潮流,并以 ESB 作为引擎,开始了 SOA 的过渡。然而,这一转变的共同问题是,我们通常习惯的工作方式往往导致试图将 SOA 架构人为地应用到现有模型中。
我们依然保留之前的层次结构,但这次它们被物理上分隔开了。从这种方法中,至少可以看到一个明显的好处,那就是我们可以至少独立地开发和部署每一层。另一个改进是扩展性。通过对曾经是层的部分进行物理分离,我们能够更好地进行扩展。这种方法通常与企业服务总线(ESB)产品的购买相结合。在服务之间,我们会插入 ESB,它负责将请求从一个服务转发到另一个服务。ESB 和类似的产品各自是庞然大物,最终我们往往会得到另一个单体应用,它的规模与我们曾试图拆分的应用相同,甚至更大。我们真正需要的是通过边界上下文来拆分服务,并将它们物理上分开,每个服务在独立的进程中运行,并明确其之间的通信。于是,微服务应运而生。
微服务
微服务是构建由小型服务组成的单个应用程序的一种方法。理解微服务的关键在于它们的独立性。每个微服务都是单独开发、测试和部署的。每个服务作为一个单独的进程运行。不同微服务之间唯一的联系是通过它们暴露的 API 进行数据交换。从某种意义上说,它们继承了 Unix/Linux 中使用的小程序和管道的思想。大多数 Linux 程序都很小并生成一些输出。该输出可以作为其他程序的输入传递。当这些程序被链接在一起时,它们可以执行非常复杂的操作。这是由许多简单单元组合而成的复杂性。
从某种意义上说,微服务使用了 SOA 定义的概念。那么为什么它们被称为不同?SOA 的实现偏离了轨道。这在 ESB 产品出现后尤其明显,它们本身成为复杂的大型企业应用程序。在许多情况下,采用 ESB 产品后,业务仍然像以前一样,只是多了一个层次。微服务运动在某种程度上是对 SOA 误解的反应,并意图回归一切开始的地方。SOA 和微服务之间的主要区别在于后者应该是自给自足的,可以独立部署,而 SOA 倾向于以单体形式实现。
看看 Gartner 关于微服务的看法。虽然我不是他们预测的忠实拥护者,但他们确实触及了市场的重要方面,吸引了大型企业环境的注意。他们对市场趋势的评估通常意味着我们已经超越了新兴项目的采用阶段,技术已经准备好面向大型企业。以下是 Gary Olliffe 在 2015 年初对微服务的看法。
微服务架构承诺为基于服务的应用程序的开发和部署提供灵活性和可扩展性。但是这种承诺是如何实现的?简而言之,通过采用允许独立和动态构建和部署个别服务的架构;通过采纳 DevOps 实践的架构。
微服务更简单,开发人员更高效,系统可以快速且精确地扩展,而不是以大型单片机的形式。我甚至还没有提到多语言编码和数据持久性的潜力。
微服务的关键方面如下:
-
它们只做一件事或负责一个功能。
-
每个微服务可以由任何一组工具或语言构建,因为每个微服务都独立于其他服务。
-
它们真正是松耦合的,因为每个微服务都与其他服务物理分离。
-
不同团队开发不同微服务之间的相对独立性(假设它们暴露的 API 是预先定义的)。
-
更容易的测试和持续交付或部署。
微服务面临的一个问题是何时使用它们。在最初,当应用程序还很小的时候,微服务试图解决的问题并不存在。然而,一旦应用程序成长起来,并且微服务的应用场景成立,切换到另一种架构风格的成本可能会过高。经验丰富的团队倾向于从一开始就使用微服务,知道他们可能以后要付出的技术债务比从一开始就使用微服务的成本更高。通常,正如 Netflix、eBay 和 Amazon 的情况一样,单体应用会逐步向微服务演化。新的模块作为微服务开发并与系统的其余部分集成。一旦它们证明了自己的价值,现有单体应用的部分内容也会重构为微服务。
企业应用程序的开发者常常批评的一个问题是数据存储的去中心化。虽然微服务可以在使用集中式数据存储的情况下运行(只需做少许调整),但至少应该探索将这部分存储去中心化的选项。将与某个服务相关的数据存储在一个单独的(去中心化的)存储中,并将其与其他内容一起打包到同一个容器中,或者作为一个独立的容器进行链接,在很多情况下可能比将数据存储在集中式数据库中更为合适。我并不是建议总是使用去中心化存储,而是建议在设计微服务时考虑到这一选项。
最后,我们通常会使用某种轻量级代理服务器,负责协调所有请求,无论这些请求来自外部还是来自一个微服务到另一个微服务之间:

图 3-4 – 带代理服务的微服务
在了解了单体架构和微服务的基本知识之后,让我们比较这两者,评估它们的优缺点。
单体应用与微服务比较
从我们目前学到的内容来看,微服务比单体架构似乎是一个更好的选择。的确,在许多(但远非所有)情况下,它们是更好的选择。然而,世上没有免费的午餐。微服务也有一套缺点,其中增加的运维和部署复杂性,以及远程进程调用是最常见的问题。
运维和部署复杂性
反对微服务的主要论点是增加的运维和部署复杂性。这个论点是正确的,但由于相对较新的工具,运维复杂性可以得到缓解。配置管理(CM)工具能够相对轻松地处理环境设置和部署。使用 Docker 的容器显著减少了微服务可能带来的部署难题。CM 工具与容器结合使用,使得我们可以快速部署和扩展微服务。
在我看来,通常增加部署复杂性的论点没有考虑到我们在过去几年中看到的进展,并且被极大夸大了。这并不意味着工作的一部分没有从开发转移到 DevOps。事实上是有的。然而,在许多情况下,好处远大于这种变化产生的不便。
远程过程调用
另一个支持单体应用的论点是由微服务远程过程调用产生的性能降低。通过类和方法的内部调用更快,这个问题无法解决。这种性能损失对系统的影响程度因情况而异。重要因素是我们如何分割系统。如果我们朝着非常小的微服务方向发展(有些人建议它们的代码不应超过 10-100 行),这种影响可能是相当大的。我喜欢创建围绕界限上下文或功能(如用户、购物车、产品等)组织的微服务。这减少了远程过程调用的数量,但仍然保持服务组织在健康边界内。另外,重要的是要注意,如果一个微服务对另一个微服务的调用通过快速的内部局域网进行,负面影响相对较小。
那么,微服务相比单体应用有什么优势呢?以下列表绝不是最终版本,也不代表只有微服务才有的优势。虽然许多优点对其他类型的架构也是有效的,但它们在微服务中更为突出。
扩展
缩放微服务比单体应用程序容易得多。对于单体应用程序,我们将整个应用程序复制到新的机器中。另一方面,对于微服务,我们只复制那些需要扩展的部分。我们不仅可以扩展需要扩展的部分,而且可以更好地分配事物。例如,我们可以将 CPU 使用量大的服务与另一个使用大量内存的服务放在一起,同时将其他 CPU 需求服务移到不同的硬件上。
创新
一旦制定了单体应用程序的初始架构,就没有太多空间进行创新。我甚至可以进一步说,单体应用程序是创新的杀手。由于它们的性质,改变事物需要时间,而且试验是危险的,因为它可能影响到一切。例如,我们不能仅仅因为它更适合一个特定模块就将 Apache Tomcat 更改为 NodeJS。
我并不是建议我们为每个模块更换编程语言、服务器、持久化和其他架构方面的内容。然而,单体服务器往往走向另一个极端——如果没有不受欢迎的变化,那么变化就是风险。而在微服务中,我们可以为每个服务单独选择我们认为最合适的解决方案。一个服务可能使用 Apache Tomcat,另一个可能使用 NodeJS。一个可以用 Java 编写,另一个可以用 Scala 编写。我并不主张每个服务都与其他服务不同,而是每个服务可以根据我们认为最适合目标的方式来构建。除此之外,变更和实验变得更容易进行。毕竟,只要 API 得以遵循,我们所做的任何更改都只会影响众多微服务中的一个,而不会影响整个系统。
大小
由于微服务较小,因此更容易理解。要查看一个微服务的功能,所需查看的代码较少。这本身极大地简化了开发过程,尤其是在新成员加入项目时。除此之外,其他方面也往往更快。与用于单体应用程序的大型项目相比,IDE 在小型项目上运行更快。它们启动速度更快,因为没有庞大的服务器,也没有需要加载的大量库。
部署、回滚和故障隔离
微服务使部署变得更快、更容易。部署小的东西总是比部署大的东西更快(如果不是更容易的话)。如果我们意识到出现了问题,那么这个问题的影响可能是有限的,并且可以更容易地回滚。在我们回滚之前,故障被隔离在系统的一个小部分。持续交付或部署的速度和频率,是在大型应用程序中无法实现的。
承诺期限
单体应用程序的常见问题之一是承诺。我们通常从一开始就不得不选择架构和技术,这些选择会持续很长时间。毕竟,我们正在构建的是一个应持续很长时间的大型项目。而微服务则大大减少了对长期承诺的需求。改变一个微服务中的编程语言,如果证明这是一个不错的选择,那么可以将其应用到其他微服务。如果实验失败或不是最佳选择,只有系统中的一小部分需要重做。同样,框架、库、服务器等方面也可以如此处理。我们甚至可以使用不同的数据库。如果某些轻量级的 NoSQL 似乎最适合某个微服务,为什么不使用它并将其打包到容器中呢?
让我们回退一步,从部署的角度来看这个问题。当我们需要部署应用程序时,这两种架构方法有何不同?
部署策略
我们已经讨论过,持续交付和部署策略要求我们重新思考应用程序生命周期的各个方面。没有什么比一开始在面临架构选择时更能体现这一点了。我们不会深入讨论可能面临的每一种部署策略,而是将范围限定为我们应该做出的两个主要决策。第一个是与架构相关,涉及单体应用和微服务之间的选择。第二个与我们如何打包需要部署的工件有关。更准确地说,是我们应该执行可变部署还是不可变部署。
可变怪物服务器
今天,构建和部署应用程序的最常见方式是作为可变怪物服务器。我们创建一个包含整个应用程序的 Web 服务器,并在每次有新版本发布时更新它。更改可以涉及配置(属性文件、XML、数据库表等)、代码工件(JAR、WAR、DLL、静态文件等)以及数据库模式和数据。由于我们在每次发布时都会进行更改,因此它是可变的。
对于可变服务器,我们无法确定开发、测试和生产环境是否完全相同。即使是生产环境中的不同节点也可能存在不必要的差异。代码、配置或静态文件可能并没有在所有实例中都进行更新。
它是一个怪物服务器,因为它包含了我们需要的所有内容,作为一个单一实例。后端、前端、API 等等。此外,它会随着时间的推移而增长。过了一段时间后,没人能确定生产环境中所有组件的确切配置,而唯一能够准确复制它的方式(如新的生产节点、测试环境等)就是复制它所在的虚拟机,并开始调整配置(IP 地址、主机文件、数据库连接等)。我们不断地向其中添加内容,直到我们失去对其内容的跟踪。给足够的时间,你的“完美”设计和令人印象深刻的架构将变成另一种样子。新的层次会被添加,代码会被耦合,补丁层层叠加,人们开始迷失在代码的迷宫中。你那美丽的小项目将变成一个庞大的怪物。
你所拥有的骄傲将成为人们在咖啡休息时讨论的话题。人们会开始说,他们能做的最好的事情就是把它丢进垃圾桶,从头开始。然而,这个怪物已经太大,无法从头开始。投入了太多。重写它需要太多的时间。风险太大。我们的巨型单体应用可能会继续存在很长时间:

图 3-5 – 最初设计的可变应用程序服务器
可变部署看起来可能很简单,但实际上通常并非如此。通过将所有内容集中在一个地方,我们隐藏了复杂性,从而增加了不同实例之间出现差异的可能性。
当该服务器接收到新版本时,重新启动的时间可能会相当长。在这段时间里,服务器通常无法正常运行。新版本引起的停机时间会造成金钱和信任的损失。今天的商业环境要求我们 24/7 全天候运营,没有任何停机时间,并且在生产发布时,团队通常需要夜间工作,此时我们的服务不可用。在这种情况下,实施持续部署是一个无法触及的梦想。这是一个无法成为现实的梦想。
测试也是一个问题。无论我们在开发和测试环境中对发布做了多少测试,第一次在生产环境中尝试的时刻是我们部署它并使其不仅对测试人员可用,而且对所有用户可用的时刻。
此外,这样的服务器快速回滚几乎是不可能的。由于它是可变的,除非我们创建整个虚拟机的快照,否则无法获得前一个版本的“照片”,这会带来一系列新的问题。
通过这种架构,我们无法完成之前描述的所有要求,甚至可能连其中任何一项也无法实现。由于无法实现零停机和轻松回滚,我们无法经常进行部署。由于架构的可变性,完全自动化存在风险,从而使我们无法快速行动。
由于不经常部署,我们积累了需要发布的更改,从而增加了失败的可能性。
为了解决这些问题,部署应该是不可变的,并且由小型、独立且自给自足的应用程序组成。记住,我们的目标是频繁部署,实现零停机,能够回滚任何版本,实现自动化并提高速度。此外,我们还应该能够在用户看到发布之前,在生产环境中测试它。
不可变服务器和反向代理
每次传统部署都会引入与服务器上需要进行的更改相关的风险。如果我们将架构更改为不可变部署,我们将获得立即的好处。环境的配置变得更加简单,因为不需要考虑应用程序(它们是不可更改的)。每当我们将镜像或容器部署到生产服务器时,我们知道它与我们构建和测试的镜像完全相同。不可变部署降低了与未知相关的风险。我们知道每个部署实例与其他实例完全相同。与可变部署不同,当一个包是不可变的并且包含所有内容(应用服务器、配置和工件)时,我们就不再关心这些事情。它们在部署管道中已经为我们打包好,我们所要做的就是确保不可变的包被发送到目标服务器。它与我们在其他环境中已经测试过的包完全相同,因此,源自可变部署的那些不一致性已经消失。
可以使用反向代理来实现零停机。不可变服务器和反向代理结合使用,简化形式如下。
首先,我们从一个指向完全自给自足的不可变应用包的反向代理开始。这个包可以是虚拟机或容器。我们将这个应用称为应用镜像,以便与可变应用区分开来。在应用之上是一个代理服务,它将所有流量路由到最终目标,而不是直接暴露服务器:

图 3-6:以镜像(虚拟机或容器)形式部署的不可变应用服务器
一旦决定部署新版本,我们通过将单独的镜像部署到单独的服务器来实现。在某些情况下,我们也可以将这个镜像部署到同一台服务器上,但通常情况下,单体应用非常占用资源,我们无法在同一节点上同时运行两个实例而不影响性能。此时,我们有两个实例。一个旧的(之前的版本)和一个新的(最新的版本)。所有流量仍然通过反向代理转发到旧服务器,因此我们的应用用户并不会注意到任何变化。对他们来说,我们仍然在运行旧版且经过验证的软件。这时正是执行最终测试的时机。理想情况下,这些测试是自动化的,并且是部署过程的一部分,但手动验证也是可以接受的。例如,如果对前端进行了修改,我们可能希望进行最后一轮用户体验测试。无论执行何种类型的测试,它们都应该直接攻击新的发布版本,绕过反向代理。这些测试的好处在于,我们正在与未来的生产版本软件进行工作,并且该软件已经在生产硬件上运行。我们正在测试生产软件和硬件,而不会影响我们的用户(他们仍然被重定向到旧版本)。我们甚至可以以 A/B 测试的形式,将新版本仅启用给有限数量的用户。
总结一下,在这个阶段我们有两个服务器实例,一个(之前的版本)供用户使用,另一个(最新的版本)用于测试:

图 3-7 – 不可变应用的新版本已部署到单独的节点
一旦测试完成并且我们确信新版本按预期工作,所要做的就是更改反向代理,指向新版本。旧版本可以暂时保留,以防我们需要回滚更改。然而,对于我们的用户来说,它是不存在的。所有流量都被引导到新版本。由于在我们更改路由之前,最新版本已经正常运行,因此切换本身不会中断我们的服务(不同于例如,如果我们需要在可变部署的情况下重启服务器)。当路由更改时,我们需要重新加载反向代理。例如,nginx 会保持旧的连接,直到所有连接都切换到新路由:

图 3-8:Poxy 被重定向到指向新版本
最后,当我们不再需要旧版本时,可以将其移除。更好的是,我们可以让下一个版本为我们移除它。在后一种情况下,当时间到来时,发布流程将删除旧版本并重新开始整个过程:

图 3-9 – 旧版本被移除
上述技术称为蓝绿部署,已经使用了很长时间。我们将在稍后的 Docker 打包和部署示例中进行实践。
不可变微服务
我们可以做得更好。通过不可变部署,我们可以轻松实现流程的自动化。反向代理为我们提供了零停机时间,且保持两个版本同时运行使得我们可以轻松回滚。然而,由于我们仍然在处理一个大型应用,部署和测试可能需要很长时间才能完成。这本身可能会阻碍我们的速度,从而无法按需频繁部署。此外,将一切都作为一个大型服务器来处理增加了开发、测试和部署的复杂性。如果可以将它们拆分成更小的部分,我们就可以将复杂性划分为更易管理的小块。作为额外好处,拥有小型独立服务将使我们能够更轻松地进行扩展。它们可以部署到同一台机器上,通过网络进行横向扩展,或者在某个服务的性能成为瓶颈时进行复制。微服务来拯救我们!
对于大型应用程序,我们通常会有解耦的层次结构。前端代码应该与后端分开,业务层与数据访问层分开,等等。随着微服务的出现,我们应该开始以不同的方向思考。与其将业务层与数据访问层分开,我们应该将服务进行拆分。例如,用户管理可以与销售服务分开。另一个区别在于物理层面。传统架构是在包和类的层次上进行分离,但仍然将所有内容部署在一起,而微服务则是物理上的拆分;它们甚至可能不在同一台物理机器上。
微服务的部署遵循与之前描述的相同模式。我们像部署其他软件一样部署微服务的不可变镜像:

图 3-10 – 作为镜像(虚拟机或容器)部署的不可变微服务
当需要发布某个微服务的新版本时,我们将其与旧版本一起部署:

图 3-11 – 新版本的不可变微服务与旧版本并行部署
当微服务版本经过充分测试后,我们更改代理路由。

图 3-12 – 代理已重新配置为指向新版本
最后,我们移除旧版本的微服务。

图 3-13 – 旧版本已被移除
唯一显著的区别是,由于微服务的大小,我们通常不需要单独的服务器来与旧版本并行部署新版本。现在我们可以真正实现频繁自动部署,快速零停机并且在出现问题时回滚。
从技术上讲,这种架构可能会带来一些特定的问题,这些问题将成为接下来章节的主题。现在我们只需说,这些问题可以通过我们现有的工具和流程轻松解决。
考虑到我们的需求最为简单,且微服务相较于单体应用带来了更多的优势,选择变得显而易见。我们将使用不可变微服务方法来构建我们的应用程序。这一决定需要讨论我们应遵循的最佳实践。
微服务最佳实践
以下大部分最佳实践可以应用于面向服务的架构。然而,对于微服务来说,它们变得更加重要或有益。接下来会对这些做简要描述,并在本书后续部分详细阐述。
容器
处理大量微服务可能迅速变成一项非常复杂的工作。每个微服务可能用不同的编程语言编写,可能需要不同的(最好是轻量级的)应用服务器,或使用不同的库集。如果每个服务都打包成一个容器,许多问题将迎刃而解。我们只需运行该容器,例如使用 Docker,并相信它里面包含了所需的一切。
容器是自给自足的打包单元,包含我们所需的一切(除了内核),在隔离的进程中运行,并且是不可变的。自给自足意味着容器通常具有以下组件:
-
运行时库(JDK、Python 或应用程序运行所需的其他库)
-
应用服务器(Tomcat、nginx 等)
-
数据库(最好是轻量级的)
-
工件(JAR、WAR、静态文件等)
![容器]()
图 3-14 – 容器内的自给自足微服务
完全自给自足的容器是部署服务的最简单方式,但在扩展时会带来一些问题。如果我们希望在集群的多个节点上扩展此类容器,就需要确保嵌入到这些容器中的数据库已同步,或者它们的数据卷位于共享驱动器上。第一个选项往往会引入不必要的复杂性,而共享卷可能会对性能产生负面影响。另一种选择是通过将数据库外部化到单独的容器中,使容器几乎自给自足。在这种设置下,每个服务将有两个不同的容器,一个用于应用程序,另一个用于数据库。它们将通过代理服务(最好是通过代理服务)连接。虽然这种组合稍微增加了部署复杂性,但它在扩展时提供了更大的自由度。我们可以根据性能测试结果或流量的增加,部署多个应用程序容器实例或多个数据库实例。最后,若需要,我们完全可以同时扩展两者。

图 3-15 – 微服务与独立数据库容器内运行
自给自足和不可变性使我们能够在不同的环境(开发、测试、生产等)中移动容器,并始终期望相同的结果。这些特性与构建小型应用程序的微服务方法相结合,使我们能够以非常低的努力和比其他方法更低的风险来部署和扩展容器。
然而,在处理遗留系统时,有一个第三种常用的组合。尽管我们可能决定逐步从单体应用迁移到微服务,但数据库往往是系统中最后被批准重构的部分。虽然这远不是执行过渡的最佳方式,但现实情况,尤其是在大型企业中,数据是最有价值的资产。重写应用程序的风险远低于如果我们决定重构数据时所面临的风险。管理层通常对这种提案持怀疑态度是可以理解的。在这种情况下,我们可能会选择共享数据库(可能没有容器)。尽管这种决策在一定程度上与我们通过微服务实现的目标相悖,但最有效的模式是共享数据库,但确保每个模式或一组表仅由单一服务访问。其他需要这些数据的服务必须通过分配给它的服务的 API 来访问。在这种组合下,我们并没有实现明确的分离(毕竟,没有比物理分离更明确的了),但我们至少可以控制谁访问数据子集,并且可以清晰地确定它们与数据之间的关系。
实际上,这与通常所说的水平层次结构的概念非常相似。实际上,随着单体应用程序的增长(以及层次的增加),这种方法往往会被滥用或忽视。即使数据库是共享的,垂直分离帮助我们保持每个服务所负责的清晰边界。

图 3-16 – 微服务在容器内访问共享数据库
代理微服务或 API 网关
大型企业的前端可能需要发起数十甚至数百个 HTTP 请求(比如亚马逊网站的情况)。请求的发起往往比接收响应数据所需的时间要长。这时候,代理微服务可能会有帮助。它们的目标是调用不同的微服务并返回聚合的服务。它们不应包含任何逻辑,只需将多个响应合并,并向消费者返回聚合后的数据。
反向代理
永远不要直接暴露微服务 API。如果没有一些协调机制,消费者和微服务之间的依赖关系会变得非常紧密,以至于可能会剥夺微服务应当带给我们的自由。像 nginx、Apache Tomcat 和 HAProxy 这样的轻量级服务器非常擅长执行反向代理任务,且可以以非常小的开销轻松部署。
极简主义方法
微服务应仅包含其真正需要的包、库和框架。它们越小越好。这与单体应用程序的做法有很大不同。以前,我们可能使用像 JBoss 这样的 JEE 服务器,打包了所有可能用到或者不一定用到的工具,而微服务则在极简主义解决方案中表现最佳。拥有数百个微服务,每个都配备完整的 JBoss 服务器,显然是过度的。比如,Apache Tomcat 就是一个更好的选择。我倾向于选择更小的解决方案,例如,Spray 作为一个非常轻量级的 RESTful API 服务器。不要打包你不需要的东西。
同样的做法也应应用于操作系统层面。如果我们将微服务作为 Docker 容器部署,CoreOS 可能比 Red Hat 或 Ubuntu 更合适。它去除了我们不需要的部分,让我们能够更好地利用资源。然而,正如我们稍后会看到的,选择操作系统并不总是那么简单。
配置管理
随着微服务数量的增加,配置管理(CM)的需求也在增加。没有像 Puppet、Chef 或 Ansible(仅举几个例子)这样的工具,快速部署大量微服务会变得像噩梦一样。实际上,除了最简单的解决方案外,不使用 CM 工具就是浪费,无论是否使用微服务。
跨职能团队
尽管没有规则规定使用什么样的团队,但当负责一个微服务的团队具备多功能时,微服务的效果最佳。一个团队应该从一开始(设计)到结束(部署和维护)负责整个过程。微服务太小,不适合由不同的团队来处理(架构/设计、开发、测试、部署和维护团队)。更倾向于由一个团队负责微服务的完整生命周期。在许多情况下,一个团队可能会负责多个微服务,但不应由多个团队共同负责一个微服务。
API 版本控制
版本控制应该应用于任何 API,微服务也是如此。如果某些更改破坏了 API 格式,它应该作为一个独立版本发布。对于公共 API 以及其他内部服务使用的 API,我们无法确定是谁在使用它们,因此必须保持向后兼容性,或者至少给使用者足够的时间来适应。
最后的思考
微服务作为一种概念已经存在很长时间。看看以下示例:
ps aux | grep jav[a] | awk '{print $2}' | xargs kill
上述命令是 Unix/Linux 中使用管道的一个示例。它由四个程序组成。每个程序都期望有一个输入(stdin)和/或输出(stdout)。每个程序都高度专业化,只执行一个或很少的几个功能。虽然它们各自很简单,但当这些程序组合在一起时,它们能够执行一些非常复杂的操作。大多数今天在 Unix/Linux 发行版中找到的程序也是如此。在这个具体的例子中,我们运行 ps aux 来检索所有正在运行的进程,并将输出传递给下一个程序。这个输出被 grep jav[a] 使用,以仅限于 Java 进程。接下来,输出被传递给需要它的程序。在这个例子中,下一个程序是 awk '{print $2}',它进一步过滤,并返回第二列,即进程 ID。最后,xargs kill 接受 awk 的输出作为输入,并杀死所有与我们之前获取的 ID 匹配的进程。
那些不熟悉 Unix/Linux 的人可能会认为我们刚才检查的命令有些过于复杂。然而,经过一段时间的练习,那些使用 Linux 命令的人会发现这种方法非常灵活和实用。与其拥有需要考虑所有可能用例的“大”程序,我们更喜欢有许多可以组合使用的小程序,几乎可以完成我们需要的任何任务。这是一种源自极致简洁的力量。每个程序都很小,旨在实现一个非常具体的目标。更重要的是,它们都接受明确定义的输入,并产生文档化良好的输出。
Unix 是我所知道的,至今仍在使用的最古老的微服务示例。它由许多小型、特定的、易于推理的服务组成,并具有明确的接口。
尽管微服务存在已久,但它们最近才变得流行并非偶然。许多事物需要成熟并对所有人开放,才能让微服务对少数选定的人有用。使微服务广泛使用的一些概念包括领域驱动设计、持续交付、容器、小型自主团队、可扩展系统等。只有当所有这些都结合成一个单一框架时,微服务才开始真正发光发热。
微服务用于创建由小型自主服务组成的复杂系统,这些服务通过其 API 交换数据,并将其作用范围限制在非常特定的界限上下文中。从某种意义上说,微服务是面向对象编程最初设计的目标。当你阅读一些行业领袖,尤其是面向对象编程领域的领袖们的观点时,当他们描述的最佳实践被吸收为其逻辑而不是作者最初实施的方式时,它们实际上是在提醒我们今天微服务的样子。以下引用准确描述了微服务的一些方面。
| 大思路是“消息传递”。设计出优秀且可扩展的系统的关键是更多地设计模块之间如何通信,而不是它们的内部属性和行为应该是什么。 | ||
|---|---|---|
| --艾伦·凯 | ||
| 将那些因相同原因而变化的事物聚集在一起,将那些因不同原因而变化的事物分开。 | ||
| --罗伯特·C·马丁 |
在实施微服务时,我们倾向于将它们组织成只做一件事或执行一个特定功能。这样我们就可以为每个任务选择最佳工具。例如,我们可以用最适合目标的编程语言编写它们。由于微服务的物理分离,它们实际上是松耦合的,并且只要 API 事先清晰定义,它们提供了不同团队之间高度的独立性。除此之外,由于微服务的去中心化特性,我们的测试和持续交付或部署变得更快更简单。当我们讨论的这些概念与新工具,尤其是Docker的出现结合时,我们可以从新的角度看待微服务,并解决它们开发和部署早期所带来的一些问题。
尽管如此,不要把这本书中的建议视为适用于所有情况的标准。微服务并不是解决我们所有问题的答案。没有任何事物是。它们不是所有应用程序都应当采用的方式,也没有单一的解决方案适用于所有情况。通过微服务,我们试图解决非常具体的问题,而不是改变所有应用程序的设计方式。
决定将我们的应用程序围绕微服务进行开发后,是时候做些实际的事情了。没有开发环境就无法编写代码,因此我们的第一个目标将是创建一个开发环境。我们将为我们的书店服务创建一个开发环境。
我们已经掌握了足够的理论,现在是时候将这本书与计算机结合起来了。从现在开始,书中的大部分内容将是动手实践。
第四章:使用 Vagrant 和 Docker 设置开发环境
开发环境往往是项目中新手们首先需要面对的事情。虽然每个项目都不同,但他们花上一整天来设置环境,以及花更多的天数去理解应用程序是如何运作的,已经不是什么罕见的事情了。
比如,安装 JDK、设置本地的 JBoss 服务器实例、进行所有配置以及其他许多往往复杂的后端应用程序所需的事情需要多长时间?再加上如果前端与后端分离,做同样的事情需要多少时间。比如,理解一个单体应用程序的内部工作原理需要多长时间?这个应用程序可能有成千上万甚至百万行代码,分成了一层又一层,最初看似是一个好主意,但随着时间的推移却变得比带来的好处更多的是复杂性?
开发环境的设置和简化是容器和微服务可以大有帮助的领域之一。微服务本身很小,理解一千行(或者更少)代码需要多少时间?即便你从未使用过微服务所用的语言,也不应该花费太多时间去理解它的功能。另一方面,容器,特别是与 Vagrant 结合使用时,可以让开发环境的设置变得轻松自如。不仅设置过程可以快速且无痛,结果甚至可以和生产环境几乎一样。实际上,除了硬件外,它可以完全一样。
在开始搭建这样一个环境之前,让我们先来讨论一下我们正在构建的服务背后的技术。
注意
请注意,本书中将使用的代码可能会发生变化,因此可能无法完全反映本书中的代码片段。虽然这可能会偶尔引起困惑,但我认为您可能会从错误修复(每段代码都有错误)以及更新中受益。我们将使用的技术栈如此新颖,以至于每天都有变化和改进,我会尽量在代码中包含这些变化,即使本书已经发布。
结合微服务架构与容器技术
本书中我们将使用的微服务(books-ms)是以一种与大多数微服务支持者推荐的方式略有不同的方式创建的。
除了我们已经讨论过的服务需要小型化、限制在一个明确定义的边界上下文等内容外,还需要注意的是,大多数微服务只用于系统的后端部分。微服务的倡导者通常会将单体后端拆分为许多小的微服务,但往往会让前端保持不变。结果就是,整体架构中,前端依然是单体的,而后端被拆分成了微服务。为什么会这样?我认为答案在于我们使用的技术。我们开发前端的方式并不是为了将其拆分成更小的部分。
服务器端渲染正在成为历史。虽然企业可能不同意这个说法,并继续推动那些能够“神奇”地将 Java 对象转化为 HTML 和 JavaScript 的服务器端框架,但客户端框架将会继续增加流行度,逐渐将服务器端页面渲染送入遗忘的深渊。这就留下了我们客户端框架的使用空间。单页应用程序(SPA)是我们今天普遍使用的形式。AngularJS、React、ExtJS、ember.js 等框架证明了它们是前端开发演进的下一步。然而,无论是否是单页应用,大多数框架都在推广前端架构的单体化方法。
由于后端被拆分成微服务而前端仍然是单体的,我们所构建的服务并没有真正遵循每个服务应提供完整功能的理念。我们本应应用垂直拆分,创建小型的松耦合应用。然而,在大多数情况下,我们在这些服务中缺少了可视化的部分。
所有的前端功能(身份验证、库存、购物车等)都是单一应用的一部分,并与拆分成微服务的后端进行通信(大多数时候通过 HTTP)。与单体应用相比,这种方法是一个巨大的进步。通过保持后端服务的小巧、松耦合、单一目的且易于扩展,我们解决了许多单体架构带来的问题。尽管没有什么是完美的,微服务也有一系列问题,比如生产 bug 的排查、测试、理解代码、框架或语言的更替、隔离、责任分配等问题,但这些问题变得更易于处理。我们为此付出的代价是部署复杂性,但通过容器(如 Docker)和不可变服务器的概念,部署过程得到了显著改善。
如果我们看到微服务在后端带来的好处,难道如果我们也能将这些好处应用到前端,并设计使微服务不仅包含后端逻辑,还包括我们应用的可视部分,难道这不是一种进步吗?如果一个开发者或团队能够完全开发一个功能,并让其他人只需将其导入应用程序,这是不是有益的呢?如果我们能够以这种方式开展业务,那么前端(无论是 SPA 还是其他)将仅仅成为一个负责路由和决定导入哪些服务的框架。
我并不是说没有人以这样一种方式开发微服务,使得前端和后端都成为其中的一部分。我知道确实有一些项目是这样做的。然而,我并不认为将前端拆分成不同部分并将其与后端打包在一起的好处,能超过这种方法的缺点。直到 Web 组件出现,我才改变了看法。
我不会深入讲解 Web 组件是如何工作的,因为本书的目标之一是保持语言中立(尽可能做到这一点)。如果你有兴趣了解更多相关内容,请访问technologyconversations.com/2015/08/09/including-front-end-web-components-into-microservices/文章。
目前需要注意的是,我们即将开始使用的books-ms包含了前端 Web 组件和后端 API,打包成一个单一的微服务。这使得我们能够将所有功能集中在一个地方,并根据需要使用它。有人可能会调用该服务的 API,而其他人可能决定将 Web 组件导入他们的网站。作为服务的作者,我们不应该过于关心谁在使用它,而应该关心的是它是否提供了潜在用户可能需要的所有功能。
该服务本身是使用 Scala 和 Spray 编写的,用于处理 API 请求和静态前端文件。Web 组件则使用 Polymer 完成。所有代码都采用测试驱动开发方法,产生了单元测试和功能/集成测试。源代码位于github.com/vfarcic/books-ms GitHub 仓库中。
如果你从未使用过 Scala 或 Polymer,不用担心。我们不会深入讲解这些内容,也不会继续开发这个应用程序。我们将用它来演示概念和进行实践。目前,我们将使用该服务来设置开发环境。在此之前,让我们简要介绍一下我们将用于这个任务的工具。
Vagrant 和 Docker
我们将使用Vagrant和Docker来设置我们的开发环境。
Vagrant 是一个通过像 VirtualBox 或 VMWare 这样的虚拟机监控程序来创建和管理虚拟机的命令行工具。Vagrant 本身不是虚拟机监控程序,它只是一个提供一致界面的驱动程序。通过一个 Vagrantfile,我们可以指定 Vagrant 所需的所有信息,利用 VirtualBox 或 VMWare 创建任意数量的虚拟机。由于它只需要一个配置文件,因此可以和应用程序代码一起保存在代码库中。它非常轻量且便携,可以让我们在任何底层操作系统上创建可重复的环境。虽然容器使得虚拟机的部分使用变得过时,但当我们需要开发环境时,Vagrant 依然大放异彩。它已经被使用并经过了多年的实战测试。
注意
请确保您的 Vagrant 版本至少是 1.8。部分读者在旧版本上遇到过问题。
请注意,容器并不总是能替代虚拟机。虚拟机提供了额外的隔离层(安全性)。它们还允许比容器更多的变体。使用虚拟机时,如果你愿意,甚至可以运行 Android。虚拟机与容器是互补的。正如 Kelsey Hightower(前 CoreOS,现在 Google)所说的 “如果你用容器替代所有虚拟机,我期待看到你的站点如何在 HackerNews 的首页被黑客攻击”。话虽如此,容器减少了虚拟机的使用。虽然我们是否应该在“裸金属”上运行容器还是在虚拟机内部运行容器仍然存在争议,但已经不再需要通过为每个应用程序或服务创建一个虚拟机来浪费资源。
Docker 容器允许我们将一些软件封装在一个完整的文件系统中。它们可以包含运行该软件所需的一切:代码、运行时库、数据库、应用服务器等。由于一切都被打包在一起,容器无论在哪个环境下运行都一样。容器共享宿主操作系统的内核,因此比虚拟机更轻量,因为虚拟机需要一个完整操作的操作系统。一个服务器可以托管比虚拟机更多的容器。另一个显著的特点是它们提供了进程隔离。虽然这种隔离性不如虚拟机提供的那么强大,但虚拟机比容器要重得多,把每个微服务打包成一个单独的虚拟机非常低效。而容器则非常适合这一任务。我们可以将每个服务打包成一个单独的容器,直接部署在操作系统上(不需要虚拟机中介),并且仍然保持它们之间的隔离。除了内核外,其他一切都不共享(除非我们选择共享),每个容器都是一个独立的世界。与此同时,与虚拟机不同,容器是不可变的。每个容器都是一组不可更改的镜像,部署新版本的唯一方法是构建一个新的容器,并替换掉运行中的旧版本实例。稍后,我们将讨论蓝绿部署策略,它将并行运行两个版本,但这将是下一章的内容。正如你很快会发现的,容器的使用范围远远超过了运行生产软件。
就像定义了创建虚拟机所需一切的 Vagrantfile,Docker 也有 Dockerfile,它包含了如何构建容器的指令。
这时,你可能会问,如果 Docker 做了相同的事且更多,为什么还需要 Vagrant?我们将用它来启动一个带有 Ubuntu 操作系统的虚拟机。我不能确定你使用的是哪种操作系统。你可能是 Windows 用户,也可能是 OS X 爱好者,或许你偏好某种 Linux 发行版。本书就是在 Ubuntu 上编写的,因为这是我选择的操作系统。决定使用虚拟机是为了确保本书中的所有命令和工具都能在你的计算机上正常运行,无论你使用的是哪种操作系统。现在,我们正准备启动一个虚拟机,作为设置开发环境的示例。以后,我们将创建更多的虚拟机,它们将模拟测试、预发布、生产等不同类型的环境。我们将使用 Ubuntu 及其他一些操作系统。这并不意味着你在应用所学知识时必须使用本书中介绍的 Vagrant 虚拟机。虽然它们对开发场景和尝试新事物很有用,但你应该重新考虑是否直接在“裸机”上安装的操作系统上部署容器,或部署到生产就绪的虚拟机中。
现在是时候停止讨论,转向更实际的部分了。在本书的其余部分,我将假设你的计算机上已经安装了 Git 和 Vagrant。不会有其他要求。你可能需要的其他内容将通过说明和脚本提供。
提示
如果你使用的是 Windows,请确保 Git 配置为使用Checkout as-is。可以在设置过程中通过选择图 4-1 中显示的第二个或第三个选项来实现。如果你没有安装 SSH,请确保[PATH_TO_GIT]\bin已添加到你的 PATH 中:

图 4-1 – 在 Windows 上,Git 设置过程中应该选择“Checkout as-is”选项
开发环境设置
让我们从克隆books-ms GitHub 仓库的代码开始:
git clone https://github.com/vfarcic/books-ms.git
cd books-ms
下载完代码后,我们可以继续创建开发环境。
Vagrant
创建 Vagrant 虚拟机很简单:
vagrant plugin install vagrant-cachier
vagrant up dev
第一个命令不是强制性的,但它有助于加快新虚拟机(VM)的创建速度。它会缓存所有正在使用的包,这样下次我们需要它们时,可以直接从本地硬盘获取,而不是重新下载。第二个命令则执行“实际”工作。它会启动名为 dev 的虚拟机。第一次启动可能会需要一些时间,因为从基础镜像开始的所有内容都需要下载。之后每次启动这个虚拟机都会快得多。基于相同镜像(在此案例中是ubuntu/trusty64)启动的其他 Vagrant 虚拟机也会很快。
请注意,在本书中,我们执行的一些命令可能需要相当长的时间才能完成。一般而言,在命令运行时,您可以继续阅读(至少直到要求您运行新命令为止)。让我们利用启动虚拟机所需的时间,来浏览刚刚克隆的代码根目录下的 Vagrantfile。它包含了 Vagrant 创建开发环境虚拟机所需的所有信息,内容如下:
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "ubuntu/trusty64"
config.vm.synced_folder ".", "/vagrant"
config.vm.provider "virtualbox" do |v|
v.memory = 2048
end
config.vm.define :dev do |dev|
dev.vm.network "private_network", ip: "10.100.199.200"
dev.vm.provision :shell, path: "bootstrap.sh"
dev.vm.provision :shell,
inline: 'PYTHONUNBUFFERED=1 ansible-playbook \/vagrant/ansible/dev.yml -c local'
end
if Vagrant.has_plugin?("vagrant-cachier")
config.cache.scope = :box
end
end
对于那些不熟悉 Ruby 的人来说,语法可能看起来有点神秘,但经过短暂的练习后,你会发现使用 Vagrant 定义一个或多个虚拟机是非常简单和直接的。在我们的案例中,我们首先指定了使用的镜像为ubuntu/trusty64。
Vagrant 镜像是 Vagrant 环境的包格式。任何人都可以在 Vagrant 支持的任何平台上使用镜像,来启动一个相同的工作环境。
换句话说,镜像就像是一个虚拟机,在其基础上我们可以添加我们需要的内容。你可以从Atlas浏览可用的镜像,或者创建你自己的镜像。
在镜像之后,接下来是指定本地目录应该与虚拟机同步的内容。在我们的例子中,我们设置了当前目录(.)应与虚拟机内的/vagrant目录同步。这样,当前目录中的所有文件将在虚拟机内自由使用。
接下来,我们指定了虚拟机应该配置 2 GB 的内存,并定义了一个名为 dev 的虚拟机。接下来,在本书的后续部分,我们将看到如何在同一个 Vagrantfile 中定义多个虚拟机。
在 dev 虚拟机的定义中,我们设置了 Vagrant 将暴露的 IP 地址,并指定它应该运行 Ansible playbook dev.yml。关于 Ansible 的细节我们不会深入探讨,因为这些内容将在后面的章节中讲解。只需知道,Ansible 会确保 Docker 和 Docker Compose 正常运行。
在本书中,我们将多次使用 Vagrant,所以你有很多机会进一步了解它。不过,本书没有提供详细的指南和文档。如果你需要更多信息和完整文档,请访问 www.vagrantup.com/。
希望你有一个快速的互联网连接,并且此时执行 vagrant up 命令的过程可能已经完成。如果没有,去喝杯咖啡,休息一下吧。
让我们进入刚才创建的虚拟机,看看里面有什么:
vagrant ssh dev
ansible --version
docker --version
docker-compose --version
cd /vagrant
ll
第一个命令让我们进入 dev 虚拟机。你会看到 Ubuntu 的欢迎信息。接下来的三个命令仅仅是演示 Ansible、Docker 和 Docker Compose 是否已安装。最后,我们进入 /vagrant 目录并列出其内容。你会注意到,它与我们克隆 GitHub 仓库的宿主目录是一样的,这两个目录是同步的。
现在我们的虚拟机和所有软件都已经启动运行,让我们来看看本章的第二位明星。
Docker
我们已经简要讨论了 Docker 和容器的一般概念。不过,我们可能还想深入探讨这个话题。很少有技术能够如此迅速地被广泛采用。是什么让 Docker 如此受欢迎?
虚拟机管理程序都基于虚拟硬件的仿真。虚拟机使用的大量资源都花费在这项仿真上。具体的资源占用比例取决于每个虚拟机的配置,但通常硬件虚拟化会占用 50% 或更多的硬件资源。换句话说,这意味着虚拟机对资源的需求非常高。
另一方面,Docker 使用的是共享操作系统。仅这一特性就使其效率大大提升。通过明确的容器定义,我们可以轻松地让 5 倍以上的应用程序同时运行,而无需部署到独立的虚拟机上。通过使用宿主机内核,容器能够在没有硬件虚拟化的情况下,保持进程之间几乎相同的隔离性。即使 Docker 没有带来其他任何好处,仅凭这一点,也足以让许多人开始使用它。
有趣的是,很多人认为容器是随着 Docker 而诞生的新事物。事实上,它们至少从 2000 年就开始被使用了。Oracle Solaris Zones、LXC 和 OpenVZ 就是其中的几个例子。Google 是最早在 Docker 出现之前就开始使用容器的公司之一。你可能会问,如果容器在其首次发布之前就存在,那么 Docker 有什么特别之处呢?Docker 使我们能够轻松使用容器,并建立在 LXC 之上。它使得有用的技术变得简单易用,并在其周围建立了一个非常强大的生态系统。
Docker 公司很快就与几乎所有软件行业领导者(Canonical、RedHat、Google、Microsoft 等)建立了伙伴关系,并设法标准化容器。这种合作关系也使容器几乎普及到所有操作系统。在撰写本文时,Windows Server 2016 技术预览版已发布,其中包含原生运行 Docker 引擎的功能。
开发人员和 DevOps 喜欢它,因为它为他们提供了一种非常简单可靠的方式来打包、运输和运行几乎可以在任何地方部署的自足应用程序。另一个重要的 Docker 工具是 Hub,其中包含官方、非官方和私有容器。无论您需要的是应用程序、服务器、数据库还是介于两者之间的任何东西,您都有可能在 Docker Hub 中找到并在几分钟内使用单个命令将其运行起来。
Docker(以及容器总体上)远比我们讨论的内容要多,您将在本书中看到许多不同的用途和测试案例。现在,让我们看看如何利用 Docker 来帮助我们处理开发环境。
开发环境的使用
目前,我们不会详细介绍如何编写 Dockerfile、构建容器并将其推送到公共或私有注册表。这些将是接下来章节的主题。目前,我们将专注于运行预制容器。特别是vfarcic/books-ms-tests容器。它包含了开发人员在使用我们克隆的 books-ms 服务时可能需要的一切。
该容器本身包含 MongoDB、NodeJS、NPM、Git、Java、Scala、SBT、FireFox、Chrome 和 Gulp。它包含了项目所需的所有 Java 和 JavaScript 库,配置也已正确设置,等等。如果你恰好使用所有这些语言和框架,你可能已经在电脑上安装了它们。然而,你很可能只使用其中的一部分,其他的可能缺失。即使你已经安装了所有东西,你还是需要下载 Scala 和 JavaScript 依赖项,调整一些配置,运行你的 MongoDB 实例,等等。对于这个单一的微服务,指令可能会显得有些复杂。现在,再考虑你们企业可能需要的成百上千个微服务。如果你只处理其中的一个或少数几个,可能还需要运行其他人写的服务。例如,你的服务可能需要与其他团队开发的服务进行通信。虽然我坚信这些情况应该通过定义良好的模拟来解决,但迟早你会遇到模拟不够好的情况。
我们可能需要在books-ms服务中执行不同类型的开发任务。记住,它同时包含后端(Scala 与 Spray)和前端(JavaScript/HTML/CSS 与 PolymerJS)。
例如,我们可以执行Gulp监视器,每次客户的源代码发生变化时,它都会运行所有前端测试。获取关于代码正确性的持续反馈,特别是在你实践测试驱动开发时,非常有用。有关前端开发方式的更多信息,请参考technologyconversations.com/2015/08/09/developing-front-end-microservices-with-polymer-web-components-and-test-driven-development-part-15-the-first-component/一系列文章。
以下命令用于运行监视器:
sudo docker run -it --rm \
-v $PWD/client/components:/source/client/components \
-v $PWD/client/test:/source/client/test \
-v $PWD/src:/source/src \
-v $PWD/target:/source/target \
-p 8080:8080 \
--env TEST_TYPE=watch-front \
vfarcic/books-ms-tests
有读者评论说,在少数情况下,测试可能会失败(可能是由于并发问题)。如果这种情况发生在你身上,请重新运行测试。
在运行此容器之前,需要下载多个层。容器大约占用 2.5GB 的虚拟空间(实际的物理大小要小得多)。与生产容器尽量小巧不同,开发用容器通常要大得多。例如,仅 NodeJS 模块就占用了将近 500MB,而这些仅是前端开发的依赖项。再加上 Scala 库、运行时可执行文件、浏览器等,东西加起来很快就多了。希望你有一个快速的互联网连接,这样拉取所有层的过程就不会太长。可以继续阅读直到下载完成,或者直到你看到运行另一个命令的指示。
输出的部分内容应如下所示(为了简洁,时间戳已删除):
...
MongoDB starting : pid=6 port=27017 dbpath=/data/db/ 64-bit host=072ec2400bf0
...
allocating new datafile /data/db/local.ns, filling with zeroes...
creating directory /data/db/_tmp
done allocating datafile /data/db/local.ns, size: 16MB, took 0 secs
allocating new datafile /data/db/local.0, filling with zeroes...
done allocating datafile /data/db/local.0, size: 64MB, took 0 secs
waiting for connections on port 27017
...
firefox 43 Tests passed
Test run ended with great success
firefox 43 (93/0/0)
...
connection accepted from 127.0.0.1:46599 #1 (1 connection now open)
[akka://routingSystem/user/IO-HTTP/listener-0] Bound to /0.0.0.0:8080
...
我们刚刚使用 Firefox 运行了93 tests,同时运行了 MongoDB,并启动了带有 Scala 和 Spray 的 Web 服务器。所有的 Java 和 JavaScript 依赖项、运行时可执行文件、浏览器、MongoDB、JDK、Scala、sbt、npm、bower、gulp 以及我们可能需要的其他所有工具,都包含在这个容器内。所有这一切都是通过一个命令完成的。现在,随便更改位于 client/components 目录下的客户端源代码或 client/test 中的测试代码。你会看到,一旦保存更改,测试就会再次运行。就个人而言,我通常会将屏幕分成两半。一半显示代码,另一半显示运行测试的终端。在运行测试时,我们通过一个命令获得了持续反馈,无需任何安装或设置。
如上所述,我们通过此命令运行的不仅仅是前端测试,还有 Web 服务器和 MongoDB。通过这两者,我们可以在你喜欢的浏览器中打开10.100.199.200:8080/components/tc-books/demo/index.html来查看我们的工作成果。你看到的是我们稍后将要使用的 Web 组件的演示。
我们不会详细解释刚才运行的命令中每个参数的含义。这部分内容会留到后续章节中,当我们深入探讨 Docker CLI 时再讲解。需要注意的重要一点是,我们运行的是从 Docker Hub 下载的容器。稍后,我们将安装自己的注册表,用于存储我们的容器。另一个重要的点是,几个本地目录被挂载为容器卷,这样我们就可以在本地修改源代码文件,并在容器内使用它们。
上述命令的主要问题在于它的长度。我个人是记不住这么长的命令的,而且我们也不能指望所有开发人员都能记住它。虽然到目前为止我们所做的比起其他搭建开发环境的方法要简单得多,但这个命令本身却与我们追求的简单性相冲突。运行 Docker 命令的更好方式是通过Docker Compose。再次声明,我们将在下一章节中深入讲解。现在,让我们先尝试一下。请通过按 Ctrl + C 停止当前正在运行的容器,并运行以下命令:
sudo docker-compose -f docker-compose-dev.yml run feTestsLocal
如你所见,结果是相同的,但这次命令要短得多。运行此容器所需的所有参数都存储在 docker-compose-dev.yml 文件中的目标 feTestsLocal 下。配置文件采用的是另一种标记语言(YAML)格式,对于熟悉 Docker 的人来说,这种格式既易于编写又易于阅读。
这只是容器众多用法中的一个。另一个用法(还有很多其他用法)是一次性运行所有测试(包括后端和前端),编译 Scala 代码,压缩并准备 JavaScript 和 HTML 文件以供发布。
在继续之前,请按Ctrl + C停止当前正在运行的容器,然后运行以下命令。
sudo docker-compose -f docker-compose-dev.yml run testsLocal
这一次,我们做了更多的事情。我们启动了 MongoDB,运行了后端的功能性和单元测试,停止了数据库,运行了所有前端测试,最后,创建了 JAR 文件,稍后将用于创建最终部署到生产环境(或者在我们案例中的生产环境仿真节点)上的分发包。稍后,当我们开始工作于持续部署管道时,我们将使用相同的容器。
我们不再需要开发环境了,所以让我们停止虚拟机:
exit
vagrant halt dev
这就是 Vagrant 的另一个优势。虚拟机可以通过一个命令启动、停止或销毁。然而,即使选择了后者,也可以像从头开始一样轻松地重新创建一个新的虚拟机。现在,虚拟机已经停止。我们可能稍后需要它,下次启动时也不会花那么长时间。使用vagrant up dev,它将在几秒钟内启动并运行。
本章有两个目的。第一个是向你展示,使用 Vagrant 和 Docker,我们可以比传统方法更简单、更快速地设置开发环境。第二个目的是让你提前了解接下来的内容。很快我们将更深入地探索 Docker 和 Docker Compose,开始构建、测试和运行容器。我们的目标是开始着手部署管道的工作。我们将首先手动运行命令。下一章将处理基础知识,之后我们将逐步过渡到更高级的技术。
第五章:部署管道的实现 - 初始阶段
让我们从持续部署管道的一些基本(和最小)步骤开始。我们将查看代码,运行部署前的测试,如果测试成功,将构建一个容器并将其推送到 Docker 注册表。容器安全地保存在注册表中后,我们将切换到另一台虚拟机,作为生产服务器的模拟,运行容器并进行部署后的测试,以确保一切按预期工作。
这些步骤将涵盖被认为是持续部署流程中最基本的流程。在接下来的章节中,当我们对迄今为止所做的过程感到熟悉时,我们将进一步深入探讨。我们将探索所有必要的步骤,以确保我们的微服务能够安全可靠地无停机时间地到达生产服务器,并且可以轻松扩展,具备回滚能力,等等。
启动持续部署虚拟机
我们将从创建持续交付服务器开始。我们将通过使用 Vagrant 创建一个虚拟机来实现这一点。虽然使用虚拟机作为一种执行易于跟随的练习的手段很有用,但在实际场景中,你应该完全跳过虚拟机,直接在服务器上安装一切。请记住,在许多情况下,容器是我们习惯用虚拟机做的一些事情的更好替代品,而我们在本书中将同时使用虚拟机和容器,但通常来说,这样做只是浪费资源。话虽如此,让我们创建 cd 和 prod 虚拟机。我们将使用第一个作为持续部署服务器,第二个作为生产环境的模拟。
cd ..
git clone https://github.com/vfarcic/ms-lifecycle.git
cd ms-lifecycle
vagrant up cd
vagrant ssh cd
我们克隆了 GitHub 仓库,启动了 cd 虚拟机并进入了其中。
有一些基本的 Vagrant 操作你可能需要了解,以便跟上本书的内容。具体来说,如何停止虚拟机并再次启动它。你永远不知道什么时候你的笔记本电脑可能没电,或者你需要腾出资源处理其他任务。我不希望你在关闭笔记本电脑后无法重新进入到之前的状态,导致无法继续阅读本书。因此,让我们来了解两项基本操作:停止虚拟机和带有配置程序重新启动虚拟机。
如果你想停止这台虚拟机,只需运行 vagrant halt 命令:
exit
vagrant halt
完成后,虚拟机将停止,并且你的资源将释放出来,供其他任务使用。稍后,你可以使用 vagrant up 命令再次启动虚拟机:
vagrant up cd --provision
vagrant ssh cd
--provision 标志将确保我们需要的所有容器都已经启动并运行。与 cd 虚拟机不同,prod 虚拟机没有使用任何配置程序,因此不需要 --provision 参数。
部署管道步骤
虚拟机已经启动并运行(或者即将启动),让我们快速浏览一下这个过程。我们应该执行以下步骤:
-
查看代码
-
运行部署前的测试
-
编译和/或打包代码
-
构建容器
-
将容器推送到注册中心
-
将容器部署到生产服务器
-
集成容器
-
运行集成后测试
-
将测试容器推送到注册中心!部署管道步骤
图 5-1 – Docker 部署管道流程
目前我们将局限于手动执行,一旦我们对操作方式感到舒适,就会将这些知识迁移到其中一个 CI/CD 工具中。
检出代码
检出代码很简单,我们已经做了几次:
git clone https://github.com/vfarcic/books-ms.git
cd books
-ms
运行预部署测试、编译和打包代码
在检出代码后,我们应运行所有不需要服务已部署的测试。我们在尝试不同的开发环境操作时已经执行过这个过程。
docker build \
-f Dockerfile.test \
-t 10.100.198.200:5000/books-ms-tests \
.
docker-compose \
-f docker-compose-dev.yml \
run --rm tests
ll target/scala-2.10/
首先,我们构建了在 Dockerfile.test 文件中定义的测试容器,并使用-t参数为它打了标签。容器的名称(或标签)是10.100.198.200:5000/books-ms-tests。这是一个特殊语法,第一部分是本地注册中心的地址,第二部分是容器的实际名称。稍后我们将讨论并使用注册中心。目前,重要的是要知道我们用它来存储和检索我们构建的容器。
第二个命令运行所有的预部署测试,并将 Scala 代码编译成一个准备分发的 JAR 文件。第三个命令仅用于演示,目的是让你确认 JAR 文件确实已创建,并且位于scala-2.10目录中。
请记住,构建容器所需的时间较长是因为许多东西需要第一次下载。每次之后的构建都会快得多。
到目前为止,我们所做的只是运行不同的命令,而没有尝试理解它们背后的原理。请注意,构建 Docker 容器的命令在失败时可以重复执行。例如,你可能会失去互联网连接,在这种情况下,构建容器会失败。如果你重复构建命令,Docker 会从上次失败的镜像继续。
我希望你能从那些仅使用预制容器或其他人创建的 Dockerfile 定义的角度,了解 Docker 是如何工作的。让我们改变节奏,深入研究用于定义容器的 Dockerfile。
构建 Docker 容器
所有测试通过并且 JAR 文件已创建后,我们可以构建要部署到生产环境的容器。在此之前,让我们先检查包含所有 Docker 构建容器所需信息的 Dockerfile。Dockerfile 的内容如下:
FROM debian:jessie
MAINTAINER Viktor Farcic "viktor@farcic.com"
RUN apt-get update && \
apt-get install -y --force-yes --no-install-recommends openjdk-7-jdk && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV DB_DBNAME books
ENV DB_COLLECTION books
COPY run.sh /run.sh
RUN chmod +x /run.sh
COPY target/scala-2.10/books-ms-assembly-1.0.jar /bs.jar
COPY client/components /client/components
CMD ["/run.sh"]
EXPOSE 8080
你可以在github.com/vfarcic/books-ms GitHub 库中找到Dockerfile文件以及其他books-ms代码。
让我们逐行解析:
FROM debian:jessie
第一行指定了我们正在构建的容器应使用的基础镜像。在我们的案例中,我们使用的是Debian(版本Jessie)。这意味着我们应该拥有与 Debian 操作系统相同的大部分功能。然而,这并不意味着当我们拉取这个容器时会下载整个操作系统。请记住,Docker 使用的是主机内核,所以当我们指定容器应使用,例如 Debian 作为其基础时,我们仅下载包含我们指定操作系统特定内容的镜像,比如 Debian 的打包机制(在 Debian 中是apt)。不同的基础镜像之间有什么区别?为什么我们选择了debian镜像作为起点?
在大多数情况下,基础镜像的最佳选择是官方 Docker 镜像之一。由于 Docker 本身维护这些镜像,它们通常比社区创建的镜像更受控制。选择使用哪种镜像取决于需求。Debian 是我在许多情况下的首选。除了我喜欢基于 Debian 的 Linux 发行版外,它相对较小(大约 125MB),并且仍然是一个完整的发行版,包含你从 Debian 操作系统中可能需要的一切。另一方面,你可能熟悉 RPM 打包,偏好使用例如 CentOS。它的大小大约是 175MB(大约比 Debian 大 50%)。然而,也有一些情况,大小是最重要的,尤其是对于那些偶尔运行以执行特定操作的工具镜像。在这种情况下,Alpine 可能是一个不错的选择。它的大小为 5MB,非常小。然而,要注意,由于它的极简主义方式,当在其上执行更复杂的命令时,可能会比较难以理解。最后,在许多情况下,你可能希望使用更具体的镜像作为容器的基础。例如,如果你需要一个包含 MongoDB 的容器,并且需要在初始化时执行一些特定操作,那么你应该使用 mongo 镜像。
在托管多个容器的系统中,基础镜像的大小比使用多少种不同的基础镜像更不重要。记住,每个镜像都会被缓存到服务器上,并且在所有使用它的容器中重复使用。如果你的所有容器都,例如,继承自debian镜像,那么相同的缓存副本将在所有容器中被重用,这意味着它只会被下载一次。
我们作为基础镜像使用的也是一个容器,和其他容器没有区别。这意味着你可以使用你的容器作为其他容器的基础。例如,你可能有许多应用程序需要将 NodeJS 与 Gulp 结合使用,并且有一些特定于你组织的脚本。这种情况是一个很好的候选场景,适合使用容器并通过FROM指令被其他容器扩展。
让我们继续下一个指令:
MAINTAINER Viktor Farcic "viktor@farcic.com"
维护者字段纯粹是提供关于作者的信息;即维护该容器的人员。这里没什么可做的。继续:
RUN apt-get update && \
apt-get install -y --force-yes --no-install-recommends openjdk-7-jdk && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN指令执行的一组命令,就像这些命令在命令提示符下运行一样。你可能注意到,除了最后一行外,我们示例中的每一行都以&& \结尾。我们将多个独立的命令连接在一起,而不是将它们每个作为独立的RUN指令执行。从操作的角度来看,以下方式也能实现相同的结果:
RUN apt-get update
RUN apt-get install -y --force-yes --no-install-recommends openjdk-7-jdk
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
这确实看起来更干净且更易于维护。然而,它也有一系列问题。其中之一是,Dockerfile 中的每个指令都会生成一个单独的镜像。容器是由一层层叠加的镜像组成的。知道这一点后,最后两个RUN指令(clean和rm)并没有带来任何价值。我们可以通过假设每个镜像的大小来说明这一点(假设的数字)。前两个指令(apt-get update和apt-get install)是添加软件包(假设为 100 MB)。后两个指令(apt-get clean和rm)是删除文件(假设为 10 MB)。虽然在普通系统中删除文件确实会减少硬盘上存储的大小,但在 Docker 容器中,它仅仅是从当前镜像中删除了东西。由于每个镜像是不可变的,之前的两个镜像仍然保持 100 MB 的大小,因此即使删除的文件在容器内不可访问,整体容器的大小也没有减少。这四个镜像的大小依然是 100 MB。如果我们回到第一个示例,其中所有命令都在同一个RUN指令内执行,从而创建了一个单一的镜像,那么大小会更小(100 MB - 10 MB = 90 MB)。
需要注意的重要一点是,大小并不是唯一需要考虑的因素,我们应该尝试将其与可维护性进行平衡。Dockerfile需要具有可读性、易于维护,并且要有明确的意图。这意味着在某些情况下,如果一个巨大的RUN指令让后期维护变得困难,那么这种方式可能并不是最佳选择。
总的来说,我们示例中RUN命令的目的是更新系统中的最新软件包(apt-get update)、安装 JDK 7(apt-get install)以及删除在此过程中创建的不必要的文件(apt-get clean和rm)。
下一组指令为容器提供了可在运行时更改的环境变量:
ENV DB_DBNAME books
ENV DB_COLLECTION books
在这个特定的情况下,我们声明了DB_DBNAME和DB_COLLECTION变量并为其赋予了默认值。服务的代码使用这些变量来连接Mongo DB。如果出于某种原因,我们想要更改这些值,我们可以在执行docker run命令时进行设置(正如我们将在本书后续章节中看到的那样)。
在容器世界中,我们不鼓励将特定于环境的文件传递给在不同服务器上运行的容器。理想情况下,我们应该在没有任何外部文件的情况下运行容器。虽然在某些情况下这不太实际(例如,稍后我们将使用的nginx进行反向代理),但环境变量是传递特定于环境的信息到容器的首选方式。
接下来,在我们的示例中,有几个COPY指令:
COPY run.sh /run.sh
RUN chmod +x /run.sh
COPY target/scala-2.10/books-ms-assembly-1.0.jar /bs.jar
COPY client/components /client/components
COPY指令顾名思义,它会将文件从主机文件系统复制到我们正在构建的容器中。它的写法应该是COPY <source>... <destination>格式。source相对于Dockerfile的位置,并且必须在构建的上下文内。后者的意思是,你不能复制不在Dockerfile所在目录或其子目录中的文件。例如,COPY ../something /something是不允许的。源文件可以是单个文件或整个目录,并且可以使用符合 Go 的filepath.Match规则的通配符。目标可以是文件或目录。目标与源类型匹配。如果源是文件,目标也会是文件;如果源是目录,目标也会是目录。为了强制目标为目录,可以在目标路径末尾加上斜杠(/)。
虽然我们在示例中没有使用ADD指令,但值得注意的是,它与COPY非常相似。在大多数情况下,我鼓励你使用COPY,除非你需要ADD提供的额外功能(最明显的是TAR解压和 URL 支持)。
在我们的示例中,我们正在复制run.sh并通过chmod RUN指令使其可执行。接着,我们将复制其余的文件(后端的JAR文件和前端的组件)。
让我们一起了解一下我们 Dockerfile 中的最后两个指令。
CMD ["/run.sh"]
EXPOSE 8080
CMD指定容器启动时将执行的命令。格式为[executable, parameter1, parameter2,以此类推]。在我们的示例中,/run.sh将无参数运行。目前,脚本包含一个命令java -jar bs.jar,它将启动 Scala/Spray 服务器。请记住,CMD只提供默认的执行命令,在容器运行时可以轻松地被覆盖。
EXPOSE指令指定容器内哪个端口将在运行时可用。
我们解释的这个示例Dockerfile并不包含我们可以使用的所有指令。在本书中,我们将与其他一些指令一起工作,并更熟悉它们的格式。在此期间,请访问 Dockerfile 参考文档了解更多信息。
有了这些知识,让我们来构建容器。命令如下:
docker build -t 10.100.198.200:5000/books-ms .
让我们利用此命令的运行时间(第一次构建通常比其他构建花费更长时间),并回顾我们使用的参数。第一个参数是build,用于构建容器。参数-t允许我们为容器打上特定的标签。如果你想将容器推送到公共 Hub,标签应该使用/格式。如果你在 Docker Hub 上有账号,用户名用于标识你,并且可以在后续推送容器时使用,从而使容器在任何连接到互联网的服务器上都可以拉取。由于我不愿意分享我的密码,我们采取了不同的方法,使用注册表的 IP 和端口,而不是 Docker Hub 的用户名。这样我们就能将容器推送到私有注册表。这种方法通常更好,因为它能提供我们对容器的完全控制,通常在本地网络上速度更快,并且不会让你公司的 CEO 因将应用推送到云端而心脏病发作。最后,最后一个参数是一个点(.),表示 Dockerfile 位于当前目录。
还有一件重要的事情需要讨论,那就是Dockerfile中指令的顺序。一方面,它需要有逻辑性。例如,我们不能在安装可执行文件之前运行它,或者像我们例子中那样,在复制run.sh文件之前就改变它的权限。另一方面,我们需要考虑到 Docker 的缓存。当运行docker build命令时,Docker 会逐条检查指令,看看是否已有其他构建过程创建了镜像。一旦找到一个会创建新镜像的指令,Docker 不仅会执行该指令,还会执行所有后续的指令。这意味着,在大多数情况下,COPY和ADD指令应该放在Dockerfile的底部。即使在COPY和ADD指令的分组中,我们也应该确保把不太可能更改的文件放在上面。在我们的例子中,我们先添加run.sh,再添加JAR文件和前端组件,因为后者在每次构建时更可能发生变化。如果你第二次执行docker build命令,你会注意到 Docker 在所有步骤中输出---> 使用缓存。稍后,当我们更改源代码时,Docker 将继续输出---> 使用缓存,直到遇到最后两个COPY指令中的一个(具体是哪个,取决于我们是更改了JAR文件还是前端组件)。
我们将经常使用 Docker 命令,你将有很多机会更加熟悉它们。与此同时,请访问使用命令行页面获取更多信息。
希望到此时,容器已经构建完成。如果没有,请休息一下。我们即将运行我们新构建的容器。
运行容器
只要你知道使用哪些参数,运行容器就非常简单。我们刚刚构建的容器可以使用以下命令来运行:
docker run -d --name books-db mongo
docker run -d --name books-ms \
-p 8080:8080 \
--link books-db:db \
10.100.198.200:5000/books-ms
第一个命令启动了我们的服务所需的数据库容器。参数-d让我们可以在分离模式下运行容器,意味着它将运行在后台。第二个命令--name books-db为容器指定了一个名称。如果不指定,Docker 会分配一个随机名称。最后,最后一个参数是我们想要使用的镜像名称。在我们的例子中,我们使用的是官方的 Docker MongoDB 镜像mongo。
这个命令展示了 Docker 的一个非常有用的功能。就像 GitHub 彻底改变了我们在不同开发者和项目之间共享代码的方式一样,Docker Hub 也改变了我们部署应用程序的方式,不仅是我们自己构建的应用程序,也包括其他人构建的应用程序。欢迎访问hub.docker.com/并搜索你喜欢的应用、服务或数据库。你很可能会找到不仅是一个(通常是官方的 docker 容器),而且还有很多社区创建的其他容器。高效使用 Docker 通常是自己构建的镜像和其他人构建的镜像相结合的结果。即使没有一个镜像完全符合你的需求,通常也可以使用现有的镜像作为基础镜像。例如,如果你想要启用复制集的 MongoDB,最好的方法是将mongo作为FROM指令在你的Dockerfile中,并在其下方添加复制集命令。
第二个docker run命令稍微复杂一些。除了在分离模式下运行并指定名称外,它还暴露了 8080 端口并与books-ms-db容器进行连接。暴露端口非常简单。我们可以提供一个端口,例如-p 8080。在这种情况下,Docker 会将其内部端口8080暴露为一个随机端口。稍后我们在使用服务发现工具时会采用这种方法。在这个例子中,我们使用了两个端口,由冒号分隔(-p 8080:8080)。使用此参数时,Docker 会将其内部端口 8080 暴露为外部端口 8080。接下来的参数是--link books-db:db,它允许我们将两个容器连接起来。在这个例子中,我们要连接的容器名称是books-ms-db。在容器内部,这个链接将转换为环境变量。让我们看看这些变量是什么样的。
我们可以使用exec命令进入正在运行的容器:
docker exec -it books-ms bash
env | grep DB
exit
参数-it告诉 Docker 我们希望此执行为交互式并且带有终端。接着是运行中的容器名称。最后,我们通过bash覆盖了在Dockerfile中指定的默认命令CMD。换句话说,我们通过运行bash进入了正在运行的容器。进入容器后,我们列出了所有的环境变量并进行了筛选,仅输出包含DB的环境变量。运行容器时,我们指定它应当与books-ms-db连接,命名为db。由于所有环境变量通常都是大写的,Docker 创建了许多以DB开头的环境变量。env命令的输出如下:
DB_NAME=/books-ms/db
DB_PORT_27017_TCP=tcp://172.17.0.5:27017
DB_PORT=tcp://172.17.0.5:27017
DB_ENV_MONGO_VERSION=3.0.5
DB_PORT_27017_TCP_PORT=27017
DB_ENV_MONGO_MAJOR=3.0
DB_PORT_27017_TCP_PROTO=tcp
DB_PORT_27017_TCP_ADDR=172.17.0.5
DB_COLLECTION=books
DB_DBNAME=books
除最后两个外,其他都是通过与另一个容器的链接而得到的。我们获取了链接的名称、TCP、端口等信息。最后两个(DB_COLLECTION 和 DB_DBNAME)不是通过链接得到的,而是我们在 Dockerfile 中定义的变量。
最后,我们退出了容器。
还有一些其他操作可以确保一切正常运行:
docker ps -a
docker logs books-ms
ps -a 命令列出了所有 (-a) 容器。该命令应该输出 books-ms 和 books-ms-db。logs 命令顾名思义,输出容器 books-ms 的日志。
尽管运行 Mongo DB 和我们的容器 books-ms 非常简单,但我们仍然需要记住所有参数。实现相同结果的更简单方法是使用 Docker Compose。在看到它如何工作的之前,让我们先删除我们正在运行的容器:
docker rm -f books-ms books-db
docker ps -a
第一个命令(rm)删除所有列出的容器。-f 参数强制删除。如果没有它,只有停止的容器可以被删除。将 rm 命令与 -f 参数结合使用,相当于先通过 stop 命令停止容器,然后通过 rm 删除它们。
让我们通过 Docker Compose 运行相同的两个容器(mongo 和 books-ms):
docker-compose -f docker-compose-dev.yml up -d app
命令的输出如下:
Creating booksms_db_1
Creating booksms_app_1
这次,我们通过一个 docker-compose 命令运行了两个容器。-f 参数指定了我们想要使用的配置文件。我倾向于在 docker-compose-dev.yml 中定义所有开发配置,在默认的 docker-compose.yml 中定义生产环境配置。使用默认文件名时,无需使用 -f 参数。接下来是 up 命令,它以分离模式 (-d) 启动了 app 容器。
让我们来看一下 docker-compose-dev.yml 文件的内容:
app:
image: 10.100.198.200:5000/books-ms
ports:- 8080:8080
links:- db:db
db: image: mongo
...
上面的输出仅显示了我们现在关注的目标。还有其他主要用于测试和编译的目标。我们之前在设置开发环境时使用过它们,稍后我们还会使用它们。现在,让我们讨论一下 app 和 db 目标。它们的定义与我们已经使用过的 Docker 命令和参数非常相似,应该很容易理解。最有意思的是 links。与手动命令的链接不同,手动命令需要先启动源容器(在我们案例中是 mongo),然后再启动与之链接的容器(books-ms),而 docker-compose 会自动启动所有依赖的容器。我们运行 app 目标时,Docker Compose 发现它依赖于 db 目标,因此它首先启动了 db。
与之前一样,我们可以验证两个容器是否都已启动并运行。这次,我们将使用 Docker Compose 来验证:
docker-compose ps
输出应该类似于以下内容:
Name Command State Ports
----------------------------------------------------------------------
booksms_app_1 /run.sh Up 0.0.0.0:8080->8080/tcp
booksms_db_1 /entrypoint.sh mongod Up 27017/tcp
Docker Compose 默认使用项目名称(默认为目录名称)、目标名称(app)和实例编号(1)来命名运行中的容器。稍后,我们将运行多个相同容器实例,分布在多个服务器上,你将有机会看到这个数字的增加。
两个容器都启动并运行后,我们可以检查 Docker Compose 运行的容器日志。
docker-compose logs
请注意,Docker Compose 的日志是以 follow 模式显示的,您需要按 Ctrl + C 来停止它。
我倾向于尽可能让测试自动化,但这个话题留到后续章节再讨论,所以目前只能进行简短的手动验证。
curl -H 'Content-Type: application/json' -X PUT -d \
'{"_id": 1,"title": "My First Book","author": "John Doe","description": "Not a very good book"}' \
http://localhost:8080/api/v1/books | jq '.'
curl -H 'Content-Type: application/json' -X PUT -d \
'{"_id": 2,"title": "My Second Book","author": "John Doe","description": "Not a bad as the first book"}' \
http://localhost:8080/api/v1/books | jq '.'
curl -H 'Content-Type: application/json' -X PUT -d \
'{"_id": 3,"title": "My Third Book","author": "John Doe","description": "Failed writers club"}' \
http://localhost:8080/api/v1/books | jq '.'
curl http://localhost:8080/api/v1/books | jq '.'
curl http://localhost:8080/api/v1/books/_id/1 | jq '.'
对于不熟悉 curl 的人来说,它是一个用于通过 URL 语法传输数据的命令行工具和库。在我们的案例中,我们用它向服务发送了三个 PUT 请求,服务随后将数据存储到 MongoDB 中。最后两个命令调用了服务的 API 来获取所有书籍的列表,以及与特定书籍(ID 为 1)相关的数据。通过这些手动验证,我们确认了该服务可以正常工作并与数据库进行通信。请注意,我们使用了 jq 来格式化 JSON 输出。
请记住,这个服务还包含前端 Web 组件,但我们暂时不会尝试它们。这些留待稍后,当我们将此服务与将导入它们的网站一起部署到生产环境时再进行。
我们运行的容器位置不对。我们使用的虚拟机本应专门用于持续部署,而我们构建的容器应该在一个独立的生产服务器上运行(或者在我们的案例中,一个独立的虚拟机模拟这样的服务器)。在开始部署到生产环境之前,我们应该进行 配置管理,以便不仅能简化部署,还能配置服务器。我们已经使用 Ansible 创建了 cd 虚拟机,但还没有时间解释它是如何工作的。更糟糕的是,我们还没有决定使用哪个工具。
现在,让我们停止并删除 books-ms 容器及其依赖项,从而释放 cd 服务器,让它能够执行最初的目的:启用持续部署管道。
docke
r-compose stop
docker-compose rm -f
推送容器到注册中心
Docker Registry 可用于存储和检索容器。我们已经在本章开始时创建的 cd 虚拟机上运行它。构建好 books-ms 后,我们可以将其推送到注册中心。这将使我们能够从任何可以访问 cd 服务器的地方拉取该容器。请运行以下命令:
docker push 10.100.198.200:5000/books-ms
在本章早些时候,我们使用 10.100.198.200:5000/books-ms 标签构建了容器。这是一个用于推送到私有注册中心的特殊格式;:/。在容器标记后,我们将其推送到运行在 IP 10.100.198.200 和端口 5000 上的注册中心。10.100.198.200 是我们的 cd 虚拟机的 IP 地址。
容器安全地存储到注册表后,我们可以在任何服务器上运行它。不久之后,一旦我们通过配置管理,我们将拥有额外的服务器,在这些服务器上运行存储在此注册表中的容器。
让我们通过销毁所有虚拟机来结束本章。下一章将创建我们需要的虚拟机。这样,你可以在继续我们的冒险之前休息一下,或者跳到任何章节,不用担心之前做的任务会导致失败。每个章节都是完全独立的。虽然你会受益于之前章节获得的知识,但从技术上讲,每一章都是独立运作的。在我们销毁之前所做的一切之前,我们将推送测试容器,这样我们就不必从头开始重新构建它。注册表容器具有一个映射我们主机目录到内部路径的卷,该路径用于存储镜像。这样,所有推送的镜像都存储在主机(registry目录)上,并不依赖于运行它的虚拟机:
docker push 10.100.198.200:5000
/books-ms-tests
exit
vagrant destroy -f
清单
我们还差几个步骤就能完成部署流水线的基本实现。提醒一下,步骤如下:
-
检出代码 - 完成
-
运行预部署测试 - 完成
-
编译和/或打包代码 - 完成
-
构建容器 - 完成
-
将容器推送到注册表 - 完成
-
将容器部署到生产服务器 - 待处理
-
集成容器 - 待处理
-
运行后部署测试 - 待处理
-
将测试容器推送到注册表 - 待处理
需要注意的是,迄今为止我们执行的所有步骤都是在cd虚拟机上完成的。为了尽量减少对生产环境的影响,我们将尽可能继续在目标服务器外运行步骤(或其一部分)。

图 5-2 – 使用 Docker 的部署流水线初始阶段
我们完成了前五个步骤,或者至少完成了它们的手动版本。其余的步骤将等到我们设置好生产服务器后再继续。在下一章中,我们将讨论完成此任务的可选方案。
第六章:Docker 世界中的配置管理
任何管理超过几个服务器的人都可以确认,手动完成这种任务既浪费时间又存在风险。配置管理(CM)已经存在很长时间了,我无法想到不使用某种工具的理由。问题不是是否要采纳其中一个工具,而是选择哪个工具。那些已经采用其中一个工具并投入了大量时间和金钱的人,可能会争辩说,最好的工具就是他们选择的那个。正如事情通常发展的那样,选择随着时间的推移而变化,选择一个工具的理由可能与昨天不同。在大多数情况下,决策并不是基于可用的选项,而是基于我们誓言维护的遗留系统的架构。如果这些系统可以被忽视,或者有足够勇气和资金的人愿意对其进行现代化,那么今天的现实将被容器和微服务主导。在这种情况下,我们昨天做出的选择与今天可以做出的选择是不同的。
CFEngine
CFEngine 可以被认为是配置管理的奠基者。它于 1993 年创建,彻底改变了我们处理服务器设置和配置的方式。它最初是一个开源项目,2008 年推出了第一个企业版本,开始商业化。
CFEngine 是用 C 语言编写的,只有少数依赖项,并且运行速度极快。事实上,据我所知,没有其他工具能够超越 CFEngine 的速度。这曾经是,并且至今仍然是它的主要优势。然而,它也有一些弱点,要求具备编程技能可能是其中最大的弱点。在许多情况下,一名普通操作员无法使用 CFEngine。它需要一名 C 开发者来管理。尽管如此,它依然在一些大型企业中得到了广泛采用。然而,随着年轻工具的崛起,新的工具相继问世,今天,除了因为公司在其上的投资而被迫选择外,很少有人会选择 CFEngine。
Puppet
后来,Puppet 应运而生。它也最初作为一个开源项目开始,然后推出了企业版本。与 CFEngine 相比,它因其基于模型的方式和较小的学习曲线被认为更“操作友好”。最终,出现了一款操作部门可以使用的配置管理工具。与 CFEngine 使用的 C 语言不同,Ruby 被证明更容易理解并且更容易被操作部门接受。CFEngine 的学习曲线可能是 Puppet 能够立足配置管理市场并逐渐取代 CFEngine 的主要原因。这并不意味着 CFEngine 已经不再使用。它仍然在使用,而且似乎不会像 Cobol 那样在银行和其他金融相关业务中消失。尽管如此,它失去了作为首选工具的声誉。
厨师
然后,Chef 应运而生,承诺解决 Puppet 的一些细节问题。它确实做到了,一段时间内也很有效。后来,随着 Puppet 和 Chef 的普及,它们进入了零和博弈。当其中一个工具推出新功能或做出改进时,另一个工具也会采纳。两者都有越来越多的工具,往往使它们的学习曲线和复杂度不断增加。Chef 对开发者更友好,而 Puppet 更倾向于面向运维和系统管理员类型的任务。两者没有明显的优劣之分,选择通常更多是基于个人经验。Puppet 和 Chef 都是成熟的、广泛采用的(尤其在企业环境中),并且有着大量的开源贡献。唯一的问题是,它们对于我们试图实现的目标来说,过于复杂。它们都没有考虑到容器的出现。它们在设计时并不知道 Docker 会改变游戏规则,因为在它们设计时,Docker 并不存在。
到目前为止,我们提到的所有配置管理工具都试图解决一些问题,这些问题在我们采用容器和不可变部署后就不再存在了。我们以前遇到的服务器混乱已经不复存在。现在,我们不再面对成百上千的包、配置文件、用户、日志等,而是需要处理大量的容器和非常有限的其他资源。这并不意味着我们不再需要配置管理,我们仍然需要!然而,选择的工具应当执行的范围要小得多。在大多数情况下,我们只需要一两个用户、运行中的 Docker 服务以及一些其他组件。其他的基本都是容器。部署已经成为另一类工具的范畴,并重新定义了配置管理应做的事情。Docker Compose、Mesos、Kubernetes 和 Docker Swarm,仅是我们今天可能使用的快速增长的部署工具中的一部分。在这种情况下,我们选择的配置管理工具应当更注重简洁性和不可变性,而不是其他因素。语法应当简单、易读,即使是那些从未使用过该工具的人也能理解。不可变性可以通过强制推送模型来实现,这种模型不需要在目标服务器上安装任何东西。
Ansible
Ansible 尝试解决与其他配置管理工具相同的问题,但采用了完全不同的方式。一个显著的区别是,它通过 SSH 执行所有操作。CFEngine 和 Puppet 要求在所有需要管理的服务器上安装客户端。而 Chef 声称它不需要,但其对无代理运行的支持功能有限。与此相比,Ansible 的一个巨大优势是,它不需要服务器具备任何特殊的配置,因为 SSH(几乎)总是存在的。它利用了一个定义良好且广泛使用的协议来执行需要执行的命令,以确保目标服务器符合我们的规格。唯一的要求是 Python,而大多数 Linux 发行版上已经预装了 Python。换句话说,与那些试图强迫你以特定方式设置服务器的竞争者不同,Ansible 利用现有的现实条件,不需要任何额外配置。由于其架构,你只需要在一台 Linux 或 OS X 计算机上运行一个实例。例如,我们可以从笔记本电脑管理所有服务器。虽然这样做并不建议,Ansible 应该运行在一台真正的服务器上(最好是与其他持续集成和部署工具一起安装的服务器),但笔记本电脑的例子说明了它的简便性。根据我的经验,像 Ansible 这样的推送式系统比我们之前讨论的拉取式工具更容易理解。
与掌握其他工具所需的复杂性相比,学习 Ansible 所花费的时间极少。它的语法基于 YAML,只需简单浏览一个 playbook,即使是从未使用过该工具的人也能明白发生了什么。与 Chef、Puppet,尤其是 CFEngine 这些由开发者为开发者编写的工具不同,Ansible 是由开发者为那些有更重要事情要做的人编写的,他们不想再学习另一种语言和/或领域特定语言(DSL)。
有人会指出,Ansible 的一个主要缺点是对 Windows 的支持有限。客户端甚至无法在 Windows 上运行,且可以在 playbooks 中使用并在其上运行的模块数量非常有限。就我而言,在使用容器的情况下,这个缺点反而是一个优势。Ansible 的开发者没有浪费时间去创建一个通用工具,而是专注于最有效的方式(在 Linux 上通过 SSH 执行命令)。无论如何,Docker 目前还不能在 Windows 上运行容器。未来可能会,但在我写这篇文章时(或者至少是在那个时刻),这还在路线图上。即使忽略容器及其在 Windows 上的未来问题,其他工具在 Windows 上的表现也远不如在 Linux 上好。简而言之,Windows 的架构并不像 Linux 那样适合配置管理的目标。
我可能走得太远了,不应该过于苛刻地评价 Windows 或质疑你的选择。如果你更喜欢 Windows 服务器而非某些 Linux 发行版,那么我对 Ansible 的所有赞扬就变得毫无意义。你应该选择 Chef 或 Puppet,除非你已经在使用它,否则忽略 CFEngine。
最后的思考
如果几年前有人问我应该使用哪个工具,我会很难回答。今天,如果有机会切换到容器(无论是 Docker 还是其他类型)和不可变部署,那么选择是显而易见的(至少在我提到的工具中是这样)。Ansible(结合 Docker 和 Docker 部署工具)随时都能脱颖而出。我们甚至可以争论是否根本不需要配置管理工具。有些例子中,人们完全依赖,比如说 CoreOS、容器和 Docker Swarm 或 Kubernetes 这样的部署工具。我并没有这么激进的观点(至少现在还没有),我认为配置管理仍然是工具库中的宝贵工具。由于配置管理工具需要执行的任务范围,Ansible 正是我们需要的工具。任何更复杂或者更难学习的工具都会是浪费。我还没见过哪个人曾在维护 Ansible playbook 时遇到困难。因此,配置管理可以迅速成为整个团队的责任。我并不是在说基础设施应该轻视(它绝对不应该)。然而,项目中整个团队的贡献是任何类型任务的重大优势,配置管理也不应例外。CFEngine、Chef 和 Puppet 因其复杂的架构和陡峭的学习曲线而显得过于繁琐,至少在与 Ansible 相比时是这样。
我们简要介绍的四个工具绝不是唯一可供选择的工具。你可能会轻易地争辩说这些工具都不是最好的,而选择其他工具。没问题,这完全取决于我们尝试实现的目标和个人偏好。然而,与其他工具不同,Ansible 几乎不可能浪费时间。它非常容易学习,即使你选择不使用它,也无法说浪费了大量宝贵的时间。此外,我们学习的每一样东西都会带来新的收获,并让我们成为更优秀的专业人士。
你现在可能已经猜到,Ansible 将是我们用来进行配置管理的工具。
配置生产环境
让我们看看 Ansible 的实际操作,然后讨论它是如何配置的。我们需要两台虚拟机正常运行;cd 将用作一个服务器,我们将从这个服务器配置生产节点。
vagrant up cd prod --provision
vagrant ssh cd
ansible-playbook /vagrant/ansible/prod.yml -i /vagrant/ansible/hosts/prod
输出应该类似于以下内容:
PPLAY [prod] *******************************************************************
GATHERING FACTS ***************************************************************
The authenticity of host '10.100.198.201 (10.100.198.201)' can't be established.
ECDSA key fingerprint is 2c:05:06:9f:a1:53:2a:82:2a:ff:93:24:d0:94:f8:82.
Are you sure you want to continue connecting (yes/no)? yes
ok: [10.100.198.201]
TASK: [common | JQ is present] ************************************************
changed: [10.100.198.201]
TASK: [docker | Debian add Docker repository and update apt cache] ************
changed: [10.100.198.201]
TASK: [docker | Debian Docker is present] *************************************
changed: [10.100.198.201]
TASK: [docker | Debian python-pip is present] *********************************
changed: [10.100.198.201]
TASK: [docker | Debian docker-py is present] **********************************
changed: [10.100.198.201]
TASK: [docker | Debian files are present] *************************************
changed: [10.100.198.201]
TASK: [docker | Debian Daemon is reloaded] ************************************
skipping: [10.100.198.201]
TASK: [docker | vagrant user is added to the docker group] ********************
changed: [10.100.198.201]
TASK: [docker | Debian Docker service is restarted] ***************************
changed: [10.100.198.201]
TASK: [docker-compose | Executable is present] ********************************
changed: [10.100.198.201]
PLAY RECAP ********************************************************************
10.100.198.201 : ok=11 changed=9 unreachable=0 failed=0
Ansible(以及一般的配置管理)中最重要的一点是,我们在大多数情况下指定的是某个事物的期望状态,而不是我们想要执行的命令。Ansible 将尽力确保服务器处于该状态。从上面的输出中我们可以看到所有任务的状态要么是已更改,要么是跳过。例如,我们指定了需要 Docker 服务,Ansible 注意到目标服务器(prod)上没有安装 Docker,于是安装了它。
如果我们再次运行 playbook,会发生什么?
ansible-playbook prod.yml -i hosts/prod
你会注意到所有任务的状态都是ok:
PLAY [prod] *******************************************************************
GATHERING FACTS ***************************************************************
ok: [10.100.198.201]
TASK: [common | JQ is present] ************************************************
ok: [10.100.198.201]
TASK: [docker | Debian add Docker repository and update apt cache] ************
ok: [10.100.198.201]
TASK: [docker | Debian Docker is present] *************************************
ok: [10.100.198.201]
TASK: [docker | Debian python-pip is present] *********************************
ok: [10.100.198.201]
TASK: [docker | Debian docker-py is present] **********************************
ok: [10.100.198.201]
TASK: [docker | Debian files are present] *************************************
ok: [10.100.198.201]
TASK: [docker | Debian Daemon is reloaded] ************************************
skipping: [10.100.198.201]
TASK: [docker | vagrant user is added to the docker group] ********************
ok: [10.100.198.201]
TASK: [docker | Debian Docker service is restarted] ***************************
skipping: [10.100.198.201]
TASK: [docker-compose | Executable is present] ********************************
ok: [10.100.198.201]
PLAY RECAP ********************************************************************
10.100.198.201 : ok=10 changed=0 unreachable=0 failed=0
Ansible 访问了服务器并逐一检查了所有任务的状态。由于这是第二次运行,并且我们没有修改服务器上的任何内容,Ansible 认为无需执行任何操作,当前状态与预期一致。
我们刚刚运行的命令(ansible-playbook prod.yml -i hosts/prod)非常简单。第一个参数是 playbook 的路径,第二个参数的值表示包含应该运行此 playbook 的服务器列表的清单文件路径。
这是一个非常简单的例子。我们需要设置生产环境,此时所需的只是 Docker、Docker Compose 和一些配置文件。稍后,我们将看到更复杂的例子。
现在我们已经看到 Ansible 的实际运行,让我们来看一下我们刚刚运行的(两次)playbook 的配置。
设置 Ansible Playbook
prod.yml Ansible playbook 的内容如下:
- hosts: prod
remote_user: vagrant
serial: 1
sudo: yes
roles: - common - docker
仅通过阅读 playbook,应该能够理解它的内容。它在名为prod的主机上以用户vagrant身份运行,并以 sudo 权限执行命令。底部是角色列表,在我们的案例中只有两个角色:common 和 docker。角色是一组通常围绕某一功能、产品或操作类型等组织的任务。Ansible playbook 的组织结构基于将任务分组到角色中,角色可以组合成 playbook。
在我们查看之前,让我们先讨论一下 docker 角色的目标。我们希望确保 Docker Debian 仓库存在,并且安装了最新的 docker-engine 包。稍后,我们需要安装 docker-py(Docker 的 Python API 客户端),可以通过 pip 安装,因此我们确保系统中同时存在这两者。接下来,我们需要将标准的 Docker 配置替换为我们位于 files 目录下的文件。Docker 配置要求重新启动 Docker 服务,所以每次 files/docker 文件发生更改时,我们都会执行此操作。最后,我们确保用户 vagrant 被添加到 docker 组中,从而能够运行 Docker 命令。
让我们看一下定义我们正在使用的角色的 roles/docker 目录。它由两个子目录组成:files 和 tasks。任务是任何角色的核心,默认情况下,任务需要在 main.yml 文件中定义:
The content of the roles/docker/tasks/main.yml file is as follows.
- include: debian.yml
when: ansible_distribution == 'Debian' or ansible_distribution == 'Ubuntu'
- include: centos.yml
when: ansible_distribution == 'CentOS' or ansible_distribution == 'Red Hat Enterprise Linux'
由于我们将在 Debian(Ubuntu)和 CentOS 或 Red Hat 上运行 Docker,因此角色被拆分为 debian.yml 和 centos.yml 文件。目前,我们将使用 Ubuntu,所以让我们看看 roles/docker/tasks/debian.yml 角色。
- name: Debian add Docker repository and update apt cache
apt_repository:
repo: deb https://apt.dockerproject.org/repo ubuntu-{{ debian_version }} main
update_cache: yes
state: present
tags: [docker]
- name: Debian Docker is present
apt:
name: docker-engine
state: latest
force: yes
tags: [docker]
- name: Debian python-pip is present
apt: name=python-pip state=present
tags: [docker]
- name: Debian docker-py is present
pip: name=docker-py version=0.4.0 state=present
tags: [docker]
- name: Debian files are present
template:
src: "{{ docker_cfg }}"
dest: "{{ docker_cfg_dest }}"
register: copy_result
tags: [docker]
- name: Debian Daemon is reloaded
command: systemctl daemon-reload
when: copy_result|changed and is_systemd is defined
tags: [docker]
- name: vagrant user is added to the docker group
user:
name: vagrant
group: docker register: user_resulttags: [docker]- name: Debian Docker service is restarted
service:
name: docker
state: restarted
when: copy_result|changed or user_result|changed
tags: [docker]
如果这是一个不同的框架或工具,我会逐一讲解每个任务,你也会非常感谢获得更多的智慧。然而,我认为没有必要这样做。Ansible 非常直接。假设你有基本的 Linux 知识,我敢打赌你可以在没有任何进一步解释的情况下理解每个任务。如果我错了,你确实需要解释,请查看 Ansible 文档中的所有模块列表。例如,如果你想知道第二个任务做了什么,你可以打开 apt 模块。现在唯一需要知道的是缩进的工作原理。YAML 基于 key: value 和 parent/child 结构。例如,最后一个任务有 name 和 state 键,它们是 service 的子项,而 service 又是 Ansible 模块之一。
还有一件事我们在 prod.yml playbook 中使用过。我们执行的命令带有 -i hosts/prod 参数,我们用它来指定 inventory 文件,其中列出了 playbook 应该运行的主机。hosts/prod inventory 相当大,因为它在整个书中都有使用。目前,我们只关心 prod 部分,因为那是我们在 playbook 中指定的 hosts 参数的值:
...
[prod]
10.100.198.201
...
如果我们想将相同的配置应用到多个服务器,只需添加另一个 IP 地址。
我们稍后会看到更复杂的示例。我故意说“更复杂”,因为在 Ansible 中没有什么是非常复杂的,但根据一些任务及其相互依赖性,某些角色可能更复杂或更简单。我希望我们刚刚运行的 playbook 能给你一个 Ansible 工具类型的大致了解,也希望你喜欢它。我们将依赖它来完成所有配置管理任务以及更多工作。
你可能已经注意到,我们从未进入过prod环境,而是通过cd服务器远程运行所有操作。本书中将继续采用这种做法。借助 Ansible 和后面我们将介绍的其他工具,我们无需通过 ssh 进入服务器并手动执行任务。依我看,我们的知识和创造力应该用于编程,其他所有的事情都应该是自动化的;例如测试、构建、部署、扩展、日志记录、监控等等。这是本书的一大收获。成功的关键在于大规模的自动化,它让我们有更多时间去做令人兴奋和更具生产力的任务。
和以前一样,我们将在本章结束时销毁所有虚拟机。下一章将创建我们所需的虚拟机:
exit
vagrant destroy -f
随着第一台生产服务器(目前仅运行 Ubuntu 操作系统、Docker 和 Docker Compose)上线,我们可以继续进行部署管道的基本实现工作。
第七章:部署管道的实现 – 中间阶段
如果没有设置生产服务器,我们无法完成部署管道的基本实现。我们不需要太多。此时,Docker 是我们部署的唯一前提条件,这也为我们提供了一个很好的机会,深入了解配置管理的世界。现在,借助将设置我们的生产服务器的 Ansible playbook,我们可以继续之前的工作并将容器部署到生产服务器:
-
检出代码 - 完成
-
运行部署前测试 - 完成
-
编译和/或打包代码 - 完成
-
构建容器 - 完成
-
将容器推送到注册表 - 完成
-
将容器部署到生产服务器 - 待处理
-
集成容器 - 待处理
-
运行部署后测试 - 待处理
-
将测试容器推送到注册表 - 待处理!部署管道的实现 – 中间阶段
图 7-1 – 使用 Docker 部署管道的初始阶段
我们仅缺少手动部署管道中的四个步骤。
将容器部署到生产服务器
让我们创建并配置本章中将使用的虚拟机:
vagrant up cd prod
vagrant ssh cd
ansible-playbook /vagrant/ansible/prod.yml \
-i /vagrant/ansible/hosts/prod
第一个命令启动了cd和prod虚拟机,第二个命令让我们进入cd虚拟机。最后,第三个命令配置了prod虚拟机。
现在生产服务器已正确配置,我们可以部署books-ms容器了。尽管我们还没有将其拉取到目标服务器,但我们已经将它推送到了cd节点的 Docker 注册表(该节点映射到主机目录)并可以从那里获取。然而,我们缺少的是 Docker Compose 配置文件,该文件指定了如何运行容器。我更倾向于将与服务相关的所有内容保存在同一个仓库中,**docker-compose.yml**也不例外。我们可以从 GitHub 获取它:
wget https://raw.githubusercontent.com/vfarcic\/books-ms/master/docker-compose.yml
下载了docker-compo se.yml后,我们来快速查看一下它(本章中不使用的目标已被排除):
base:
image: 10.100.198.200:5000/books-ms
ports:- 8080environment: - SERVICE_NAME=books-ms
app:
extends:
service: base
links: - db:db
db: image: mongo
base 目标包含我们容器的基础定义。下一个目标(app)是扩展了 base 服务,避免了定义的重复。通过扩展服务,我们可以覆盖参数或添加新参数。app 目标将运行我们存储在 cd 服务器上的注册表中的容器,并且与第三个目标相链接,该目标代表服务所需的数据库。你可能会注意到我们改变了端口的指定方式。在 docker-compose-d 的 ev.yml 文件中,我们有两个用冒号分隔的数字(8080:8080)。第一个是 Docker 会暴露给主机的端口,而第二个是容器内服务器使用的内部端口。docker-compose.yml 有点不同,只有内部端口被设置。这样做的原因是为了消除潜在的冲突。在开发环境中,我们倾向于只运行少量的服务(当前需要的那些),但在生产环境中,我们可能会同时运行数十、数百甚至数千个服务。预定义端口容易导致冲突。如果其中两个使用相同的端口,结果将是失败。因此,我们将让 Docker 为主机暴露一个随机端口。
让我们运行 Docker Compose 的 app 目标:
export DOCKER_HOST=tcp://prod:2375
docker-compose up -d app
我们导出了 DOCKER_HOST 变量,这告诉本地 Docker 客户端将命令发送到位于 prod 节点和端口 2375 上的远程 Docker。第二个命令运行了 Docker Compose 的 app 目标。由于 DOCKER_HOST 指向远程主机,app 目标和链接的容器 db 被部署到 prod 服务器。我们甚至不需要进入目标服务器,部署是远程完成的。
出于安全考虑,默认情况下禁用了调用远程 Docker API 的功能。不过,Ansible playbook 的一个任务是通过修改 /etc/default/docker 配置文件来改变这一行为。其内容如下:
DOCKER_OPTS="$DOCKER_OPTS --insecure-registry 10.100.198.200:5000 -H tcp://0.0.0.0:2375 -H unix:///var/run/docker.sock"
--insecure-registry 允许 Docker 从我们位于 cd 节点(10.100.198.200)的私有注册表中拉取镜像。-H 参数告诉 Docker 在端口 2375 上监听来自任何地址(0.0.0.0)的远程请求。请注意,在实际的生产环境中,我们需要更加严格,仅允许受信任的地址访问远程 Docker API。
我们可以通过执行另一个远程调用来确认这两个容器确实运行在 prod 虚拟机上:
docker-compose ps
输出如下:
Name Command State Ports
-----------------------------------------------------------------------
vagrant_app_1 /run.sh Up 0.0.0.0:32770->80
80/tcp
vagrant_db_1 /entrypoint.sh mongod Up 27017/tcp
由于 Docker 为服务的内部端口 8080 分配了一个随机端口,我们需要找出它。可以通过 inspect 命令来完成这项工作。
docker inspect vagrant_app_1
我们感兴趣的输出部分应类似于以下内容:
...
"NetworkSettings": {
"Bridge": "",
"EndpointID": "45a8ea03cc2514b128448...",
"Gateway": "172.17.42.1",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"HairpinMode": false,
"IPAddress": "172.17.0.4",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"MacAddress": "02:42:ac:11:00:04",
"NetworkID": "dce90f852007b489f4a2fe...",
"PortMapping": null,
"Ports": {
"8080/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "32770"
}
]
},
"SandboxKey": "/var/run/docker/netns/f78bc787f617",
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null
}
...
原始输出比这大得多,包含了我们可能(或不可能)需要的所有信息。目前我们感兴趣的是NetworkSettings.Ports部分,在我的例子中,它给出了将HostPort 32770映射到内部端口8080的信息。我们可以做得更好,使用--format参数:
PORT=$(docker inspect \--format='{{(index (index .NetworkSettings.Ports "8080/tcp") 0).HostPort}}' \vagrant_app_1)
echo $PORT
不要被--format值的语法吓到。它使用了 Go 的text/template格式,确实可能让人有些畏惧。好消息是,一旦我们进入第八章,服务发现 – 分布式服务的关键章节时,我们将使用更好的方法。这个只是一个临时的变通方法。
我们已经得到了端口并将其存储到了PORT变量中。现在我们可以重复已经熟悉的curl命令,确认服务正在运行并且已连接到数据库:
curl -H 'Content-Type: application/json' -X PUT -d \
"{\"_id\": 1,
\"title\": \"My First Book\",
\"author\": \"John Doe\",
\"description\": \"Not a very good book\"}" \
http://prod:$PORT/api/v1/books \
| jq '.'
curl -H 'Content-Type: application/json' -X PUT -d \
"{\"_id\": 2,
\"title\": \"My Second Book\",
\"author\": \"John Doe\",
\"description\": \"Not a bad as the first book\"}" \
http://prod:$PORT/api/v1/books \
| jq '.'
curl -H 'Content-Type: application/json' -X PUT -d \
"{\"_id\": 3,
\"title\": \"My Third Book\",
\"author\": \"John Doe\",
\"description\": \"Failed writers club\"}" \
http://prod:$PORT/api/v1/books \
| jq '.'
curl http://prod:$PORT/api/v1/books \
| jq '.'
curl http://prod:$PORT/api/v1/books/_id/1 \
| jq '.'
上一条命令的输出如下:
{
"_id": 1,
"author": "John Doe",
"description": "Not a very good book",
"title": "My First Book"
}
和之前一样,当我们在开发环境中运行相同的命令时,我们将三本书插入数据库,并确认它们可以从数据库中检索出来。然而,这不是验证服务是否正确部署的高效方式。我们可以做得更好,运行集成测试。
需要注意的是,我们甚至没有进入prod节点。所有的部署命令都是通过远程 Docker API 完成的。
Docker UI
这可能是一个很好的机会来介绍一个不错的开源项目 DockerUI。它是作为docker Ansible 角色的一部分定义的,因此它会在我们配置 Docker 的所有服务器上运行。我们可以,例如,通过在任何浏览器中打开http://10.100.198.201:9000来查看在prod节点上运行的实例。
请注意,通过 Vagrant 创建的所有 IP 都是私有的,意味着只能从主机机器访问。如果那恰好是你的笔记本电脑,你应该不会遇到问题,能够在浏览器中打开 DockerUI 地址。另一方面,如果你在公司服务器之一上运行示例,请确保你可以访问它的桌面,并且已安装浏览器。如果你需要远程访问该服务器,请尝试使用 VNC 等远程桌面解决方案:

图 7-2 – DockerUI 仪表盘屏幕
虽然通过 CLI 操作容器要高效得多,但 DockerUI 提供了一种非常有用的方式,可以获得系统的总体概览以及每个容器、网络和镜像的相关细节。当集群中运行大量容器时,它的真正用途会显现出来。它非常轻量,不会占用太多资源。
除非另有说明,否则你会发现它在我们设置的每个虚拟机上运行。
检查清单
在继续之前,让我们看看部署管道的基本实现进展如何:
-
检出代码 - 完成
-
运行部署前测试 - 完成
-
编译和/或打包代码 - 完成
-
构建容器 - 完成
-
将容器推送到镜像仓库 - 完成
-
将容器部署到生产服务器 - 完成
-
集成容器 - 待完成
-
运行部署后测试 - 待完成
-
将测试容器推送到镜像仓库 - 待完成
![检查清单]()
图 7-3 – 使用 Docker 部署管道的中间阶段
请注意,与我们在上一章所做的步骤不同,这次的部署是在生产环境中通过远程 Docker API 进行的。如果我们部署的是第二个版本,那么在一段时间内,旧版本和新版本都无法运行。一个需要停止,而另一个则需要一些时间才能启动。无论这段时间是否短暂,我们都会经历停机时间,这本身就会阻碍我们向持续部署的目标迈进。现在我们只需要记下这个问题,稍后我们将探索蓝绿部署流程,帮助我们克服这个问题,继续朝着零停机时间部署的目标前进。
我们正在取得进展,检查清单上只剩下三项任务。然而,应用程序尚未集成,因此我们无法运行集成测试。为了继续进行,我们需要探索两个概念:服务发现和反向代理。
我们将在实验服务发现工具时使用一组新的虚拟机,因此,让我们节省一些资源并销毁当前正在运行的虚拟机。我们将在下一章创建所需的虚拟机。
exit
vagrant destroy -f
第八章:服务发现 – 分布式服务的关键
| 做事不需要太多力量,但决定做什么需要巨大的力量。 | ||
|---|---|---|
| --埃尔伯特·哈伯德 |
我们拥有的服务越多,如果使用预定义端口,发生冲突的可能性就越大。毕竟,不能有两个服务监听同一个端口。管理一个包含例如一百个服务的所有端口的准确列表本身就是一个挑战。如果再加上这些服务所需要的数据库,数量会更多。因此,我们应该部署服务时不指定端口,而是让 Docker 为我们分配随机端口。唯一的问题是,我们需要发现端口号并让其他人知道它:

图 8-1 – 单个节点上的服务作为 Docker 容器部署
当我们开始在分布式系统中工作时,情况会变得更加复杂,这时服务会部署到多个服务器中的某一台上。我们可以选择提前定义哪个服务部署到哪个服务器,但那会带来许多问题。我们应该尽可能地利用服务器资源,而如果提前定义每个服务的部署位置,这几乎是不可能的。另一个问题是,服务的自动扩展将变得困难,尤其是当我们需要自动从比如服务器故障中恢复时。另一方面,如果我们将服务部署到例如容器最少的服务器上,我们需要将 IP 地址添加到需要被发现和存储的数据列表中:

图 8-2 – 多个节点上的服务作为 Docker 容器部署
还有许多其他例子,在这些情况下,我们需要存储和检索(发现)与我们正在使用的服务相关的一些信息。
为了能够定位我们的服务,我们至少需要以下两个过程可用:
-
服务注册过程,它至少会存储服务运行所在的主机和端口。
-
服务发现过程,它允许其他人发现我们在注册过程中存储的信息:
![服务发现 – 分布式服务的关键]()
图 8-3 – 服务注册与发现
除了这些过程外,我们还需要考虑其他几个方面。如果服务停止工作,我们是否应该注销该服务并部署/注册一个新的实例?当同一个服务有多个副本时,应该怎么办?我们如何在它们之间进行负载均衡?如果某台服务器出现故障,会发生什么?这些以及其他许多问题与注册和发现过程紧密相关,并将是接下来的章节内容。目前,我们将范围限定在服务发现(这个术语涵盖了上述两个过程)和我们可能用于此任务的工具上。大多数工具都具有高可用的分布式键值存储。
服务注册中心
服务注册中心的目标很简单。提供存储服务信息的能力,要求快速、持久、容错等。从本质上讲,服务注册中心是一个范围非常有限的数据库。虽然其他数据库可能需要处理大量数据,但服务注册中心预期的负载较小。由于任务的性质,它应该暴露一些 API,以便需要其数据的人可以轻松访问。
目前没有更多需要说明的内容(直到我们开始评估不同的工具),所以我们将继续讨论服务注册。
服务注册
微服务通常非常动态。它们会被创建和销毁,部署到一台服务器上,然后移动到另一台服务器上。它们总是在变化和发展。当服务属性发生任何变化时,这些变化的信息需要存储在某个数据库中(我们称之为服务注册中心,或简称注册中心)。服务注册的逻辑很简单,尽管这种逻辑的实现可能会变得复杂。每当一个服务被部署时,它的数据(至少包括 IP 和端口)应该存储在服务注册中心。当一个服务被销毁或停止时,事情就变得有些复杂。如果这是由于人为操作所导致,服务数据应该从注册中心中删除。然而,也有一些情况是服务由于故障停止的,在这种情况下,我们可能会选择采取额外的措施来恢复该服务的正常运行。我们将在自愈章节中详细讨论这种情况。
服务注册可以通过多种方式进行。
自注册
自注册是一种常见的注册服务信息的方式。当一个服务被部署时,它会通知注册中心其存在并发送数据。由于每个服务都需要能够将数据发送到注册中心,这可以视为一种反模式。通过使用这种方式,我们破坏了我们在微服务中试图强制执行的单一职责和有界上下文原则。我们需要在每个服务中添加注册代码,从而增加了开发复杂度。更重要的是,这会将服务与特定的注册服务耦合。一旦服务数量增加,修改它们所有的代码,例如更改注册中心,将变得非常繁琐。而且,这也是我们摆脱单体应用程序的原因之一;即可以自由地修改任何服务而不影响整个系统。另一种选择是创建一个库来为我们完成注册,并将其包含在每个服务中。然而,这种方法会严重限制我们创建完全自给自足微服务的能力。我们将增加它们对外部资源(在本例中是注册库)的依赖。
注销甚至更加复杂,尤其是在自注册的概念下。当一个服务被故意停止时,它应该相对容易地将其数据从注册中心移除。然而,服务并非总是故意停止。它们可能会以意外的方式失败,或者运行它们的进程可能会停止。在这种情况下,可能很难(如果不是不可能的话)总是能够从服务本身注销它:

图 8-4 – 自注册
虽然自注册可能很常见,但这并不是一种最优或高效的操作方式。我们应该考虑其他替代方法。
注册服务
注册服务或第三方注册是一个管理所有服务注册和注销的过程。该服务负责检查哪些微服务正在运行,并相应地更新注册中心。当服务停止时,类似的过程也会应用。注册服务应当检测到某个微服务的缺失,并将其数据从注册中心删除。作为附加功能,它可以通知其他进程该微服务的缺失,进而执行一些纠正措施,比如重新部署缺失的微服务、发送电子邮件通知等。我们将称这个注册和注销过程为服务注册器,或者简称注册器(实际上,正如你很快会看到的,这个名字已经有一个相同名称的产品了):

图 8-5 – 注册服务
一个单独的注册服务比自注册要好得多。它通常更加可靠,同时也不会在我们的微服务代码中引入不必要的耦合。
既然我们已经确定了服务注册过程的基本逻辑,现在是时候讨论服务发现了。
服务发现
服务发现是服务注册的对立面。当一个客户端想要访问一个服务时(客户端也可能是另一个服务),它至少需要知道该服务的位置。我们可以采取的一种方法是自我发现。
自我发现
自我发现使用与自我注册相同的原理。每个客户端或想要访问其他服务的服务,都需要查阅注册表。与主要与我们内部连接服务的方式相关的问题的自我注册不同,自我发现可能被我们无法控制的客户端和服务使用。一个例子是运行在用户浏览器中的前端。该前端可能需要向许多独立的后端服务发送请求,这些服务运行在不同的端口甚至不同的 IP 上。我们将信息存储在注册表中并不意味着其他人能够、应该或知道如何使用它。自我发现只能有效地用于内部服务之间的通信。即使如此有限的范围也带来了许多额外的问题,其中许多问题与自我注册所产生的问题相同。根据我们目前所了解的情况,这个选项应该被放弃。
代理服务
代理服务已经存在了一段时间,并且多次证明了它们的价值。下一章将更深入地探讨它们,因此我们这里只做简要介绍。其基本思想是每个服务都应该通过一个或多个固定地址进行访问。例如,我们的 books-ms 服务的书籍列表应该仅通过 [DOMAIN]/api/v1/books 地址访问。注意,这里没有 IP、端口或任何其他与部署相关的细节。由于没有服务会有这个精确的地址,因此必须有某种机制来检测此类请求并将其重定向到实际服务的 IP 和端口。代理服务往往是能够完成这一任务的最佳工具类型。
现在我们已经有了一个大致的、并且希望是清晰的目标,来了解我们想要完成的事情,让我们看一下可以帮助我们的工具。
服务发现工具
服务发现工具的主要目标是帮助服务彼此查找并进行通信。为了履行其职责,它们需要知道每个服务的位置。这个概念并不新颖,许多工具在 Docker 诞生之前就已经存在。然而,容器技术使得这种工具的需求达到了全新的高度。
服务发现的基本思想是每个新实例的服务(或应用程序)能够识别其当前环境并存储该信息。存储本身通常是在注册中心进行,通常采用键/值格式。由于服务发现常用于分布式系统,注册中心需要具备可扩展性、容错性,并在集群中的所有节点之间分布。这类存储的主要用途是至少向所有需要与其通信的相关方提供服务的 IP 和端口。此数据通常会扩展为其他类型的信息。
服务发现工具通常会提供一些 API,服务可以通过这些 API 注册自身,其他服务也可以通过这些 API 查询该服务的信息。
假设我们有两个服务,一个是提供者,另一个是它的消费者。一旦我们部署了提供者,我们需要将其信息存储在选定的服务注册中心中。稍后,当消费者尝试访问提供者时,它首先会查询注册中心,并使用从注册中心获得的 IP 和端口来调用提供者。为了将消费者与特定实现的注册中心解耦,我们通常会使用一些代理服务。这样,消费者始终会从固定地址请求信息,该地址会驻留在代理中,代理则会使用发现服务来获取提供者的信息并重定向请求。实际上,在许多情况下,如果有一个进程每次注册中心数据变化时都会更新配置,代理就无需查询服务注册中心了。我们将在本书后面介绍反向代理。现在,重要的是要理解,这一流程是基于三个角色的:消费者、代理和提供者。
我们在服务发现工具中寻找的是数据。至少,我们应该能够找出服务的位置,它是否健康且可用,以及它的配置是什么。由于我们正在构建一个分布式系统,涉及多个服务器,工具需要足够强大,单个节点的故障不应影响数据。同时,每个节点都应该有相同的数据副本。进一步说,我们希望能够按任意顺序启动服务,能够销毁它们,或用新版替换它们。我们还应该能够重新配置服务,并看到数据相应地变化。
让我们来看一下可以用来实现我们目标的几种工具。
手动配置
大多数服务仍然是手动管理的。我们提前决定服务的部署位置、配置,并寄希望于它能够一直正常工作直到天荒地老。这种方法不容易扩展。部署服务的第二个实例意味着我们需要重新开始手动过程。我们必须启动一台新服务器,或者找出哪一台服务器的资源利用率低,创建一套新的配置并部署它。假设出现硬件故障,情况就更复杂了,因为当一切都需要手动管理时,反应时间通常会很慢。可见性也是另一个痛点。我们知道静态配置是什么,毕竟是我们提前准备好的。然而,大多数服务都有大量动态生成的信息。这些信息并不容易查看。在需要这些数据时,我们没有一个可以咨询的统一位置。
反应时间不可避免地较慢,容错性充其量可疑,且由于许多部分需要手动操作,监控也难以管理。
虽然过去或者在服务和/或服务器数量较少时,手动完成这些工作还有借口,但随着服务发现工具的出现,这个借口迅速消失了。
Zookeeper
Zookeeper 是这种类型中最古老的项目之一。它起源于 Hadoop 世界,最初是为了帮助维护 Hadoop 集群中的各种组件而构建的。它成熟、可靠,许多大公司(如 YouTube、eBay、Yahoo 等)都在使用它。它存储的数据格式类似于文件系统的组织方式。如果在服务器集群中运行,Zookeeper 将在所有节点之间共享配置的状态。每个集群会选举一个领导者,客户端可以连接到任何服务器以检索数据。
Zookeeper 带来的主要优点是它的成熟性、稳健性和功能丰富性。然而,它也有一系列的缺点,Java 和复杂性是主要的原因。虽然 Java 对许多使用场景来说很优秀,但对于这种类型的工作来说,它的体积庞大。Zookeeper 使用 Java,加上大量的依赖项,使得它比竞争对手更消耗资源。除了这些问题,Zookeeper 也很复杂。维护它需要的知识远远超过我们对这种类型应用的预期。这正是功能丰富性从优势转化为负担的地方。一个应用拥有的功能越多,我们就越有可能用不到其中的一些功能。于是,我们最终为不完全需要的功能付出了复杂性这个代价。
Zookeeper 为后来的其他工具铺平了道路,并做出了显著的改进。那些“巨头”之所以使用它,是因为当时没有更好的替代品。今天,Zookeeper 显露出它的老化,我们有更好的替代方案。
我们将跳过 Zookeeper 示例,直接进入更好的选项。
etcd
etcd 是一个通过 HTTP 访问的键/值存储系统。它是分布式的,具有层次化配置系统,可以用于构建服务发现。它非常容易部署、设置和使用,提供可靠的数据持久性,安全且文档完善。
由于其简单性,etcd 比 Zookeeper 更具优势。然而,它需要与一些第三方工具结合,才能实现服务发现的目标。
设置 etcd
让我们设置etcd。首先,我们应该创建集群中的第一个节点(serv-disc-01),并且使用之前熟悉的cd虚拟机。
vagrant up cd serv-disc-01 --provision
vagrant ssh serv-disc-01
在集群节点serv-disc-01启动并运行后,我们可以安装etcd和etcdctl(etcd 命令行客户端)。
curl -L https://github.com/coreos/etcd/releases/\
download/v2.1.2/etcd-v2.1.2-linux-amd64.tar.gz \
-o etcd-v2.1.2-linux-amd64.tar.gz
tar xzf etcd-v2.1.2-linux-amd64.tar.gz
sudo mv etcd-v2.1.2-linux-amd64/etcd* /usr/local/bin
rm -rf etcd-v2.1.2-linux-amd64*
etcd >/tmp/etcd.log 2>&1 &
我们下载、解压并将可执行文件移动到/usr/local/bin,使其易于访问。然后,我们删除了不需要的文件,最后将etcd运行,并将输出重定向到/tmp/etcd.log。
让我们看看我们能用 etcd 做些什么。
基本操作是set和get。请注意,我们可以在目录中设置键/值:
etcdctl set myService/port "1234"
etcdctl set myService/ip "1.2.3.4"
etcdctl get myService/port # Outputs: 1234
etcdctl get myService/ip # Outputs: 1.2.3.4
第一个命令将键port和值1234放入目录myService中。第二个命令将键ip也放入其中,最后两个命令用于输出这两个键的值。
我们还可以列出指定目录中的所有键,或删除某个键及其值:
etcdctl ls myService
etcdctl rm myService/port
etcdctl ls myService
最后一个命令只输出了/myService/ip的值,因为前一个命令已删除了端口。
除了etcdctl,我们还可以通过 HTTP API 运行所有命令。在尝试之前,我们先安装jq,以便查看格式化后的输出:
sudo apt-get install -y jq
比如,我们可以通过 HTTP API 将一个值放入etcd,并通过GET请求检索它。
curl http://localhost:2379/v2/keys/myService/newPort \
-X PUT \
-d value="4321" | jq '.'
curl http://localhost:2379/v2/keys/myService/newPort \
| jq '.'
jq '.'不是必须的,但我经常使用它来格式化 JSON。输出应该类似于以下内容:
{
"action": "set",
"node": {
"createdIndex": 16,
"key": "/myService/newPort",
"modifiedIndex": 16,
"value": "4321"
}
}
{
"action": "get",
"node": {
"createdIndex": 16,
"key": "/myService/newPort",
"modifiedIndex": 16,
"value": "4321"
}
}
HTTP API 在我们需要远程查询 etcd 时特别有用。大多数时候,我更喜欢使用etcdctl,当执行临时命令时,而 HTTP 则是通过一些代码与etcd交互的首选方式。
现在我们已经简要了解了 etcd 在单个服务器上的工作原理,让我们在集群中试试看。集群的设置需要传递一些额外的参数给etcd。假设我们有一个包含三个节点的集群,IP 地址分别是10.100.197.201(serv-disc-01)、10.100.197.202(serv-disc-02)和10.100.197.203(serv-disc-03)。在第一台服务器上运行的 etcd 命令如下(请暂时不要运行):
NODE_NAME=serv-disc-0$NODE_NUMBER
NODE_IP=10.100.197.20$NODE_NUMBER
NODE_01_ADDRESS=http://10.100.197.201:2380
NODE_01_NAME=serv-disc-01
NODE_01="$NODE_01_NAME=$NODE_01_ADDRESS"
NODE_02_ADDRESS=http://10.100.197.202:2380
NODE_02_NAME=serv-disc-02
NODE_02="$NODE_02_NAME=$NODE_02_ADDRESS"
NODE_03_ADDRESS=http://10.100.197.203:2380
NODE_03_NAME=serv-disc-03
NODE_03="$NODE_03_NAME=$NODE_03_ADDRESS"
CLUSTER_TOKEN=serv-disc-cluster
etcd -name serv-disc-1 \
-initial-advertise-peer-urls http://$NODE_IP:2380 \
-listen-peer-urls http://$NODE_IP:2380 \
-listen-client-urls \
http://$NODE_IP:2379,http://127.0.0.1:2379 \
-advertise-client-urls http://$NODE_IP:2379 \
-initial-cluster-token $CLUSTER_TOKEN \
-initial-cluster \
$NODE_01,$NODE_02,$NODE_03 \
-initial-cluster-state new
我将会把从一个服务器(或集群)到另一个服务器可能会改变的部分提取成变量,这样你可以清楚地看到它们。我们不会深入讨论每个参数的具体含义。你可以在coreos.com/etcd/docs/latest/clustering.html找到更多信息。可以简单地说,我们指定了这个命令应该在哪个服务器上运行的 IP 地址和名称,以及集群中所有服务器的列表。
在我们开始在集群中部署etcd之前,让我们先终止当前正在运行的实例,并创建其余的服务器(总共有三个):
pkill etcd
exit
vagrant up serv-disc-02 serv-disc-03
在多个服务器上手动执行相同的任务集既繁琐又容易出错。由于我们已经使用过 Ansible,我们可以用它在集群中设置 etcd。这应该是一个相对简单的任务,因为我们已经有了所有的命令,我们所要做的就是将已经运行的命令转换为 Ansible 格式。我们可以创建etcd角色,并将其添加到具有相同名称的 playbook 中。该角色相当简单,它将可执行文件复制到/usr/local/bin目录,并使用集群参数运行 etcd(我们上面分析过的非常长的命令)。在运行 playbook 之前,让我们先来看一下它。
roles/etcd/tasks/main.yml中的第一个任务如下:
- name: Files are copied
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: 0755
with_items: files
tags: [etcd]
这个名称纯粹是描述性的,后面跟着的是复制模块。接下来,我们指定了一些模块选项。复制选项src表示我们要复制的本地文件的名称,它相对于角色内部的files目录。第二个复制选项(dest)是远程服务器上的目标路径。最后,我们将模式设置为755。运行角色的用户将拥有读/写/执行权限,而属于同一组和其他所有人的用户将被赋予读/执行权限。接下来是with_items声明,它允许我们使用一个值的列表。在这种情况下,值是在roles/etcd/defaults/main.yml文件中指定的,内容如下:
files: [
{src: 'etcd', dest: '/usr/local/bin/etcd'},
{src: 'etcdctl', dest: '/usr/local/bin/etcdctl'}
]
外部化变量是将可能在未来发生变化的内容与任务分离开来的好方法。例如,如果我们需要通过这个角色复制另一个文件,我们可以在这里添加它,而无需打开任务文件。使用files变量的任务将对列表中的每个值进行迭代,在这种情况下,它将运行两次;第一次是针对etcd,第二次是针对etcdctl。变量的值通过变量键被{{和}}包围,并使用 Jinja2 格式表示。最后,我们将etcd设置为与该任务相关联的标签。标签可以在运行 playbook 时用来过滤任务,非常方便,当我们只想运行其中一部分任务或想排除某些任务时,标签特别有用。
第二个任务如下:
- name: Is running
shell: "nohup etcd -name {{ ansible_hostname }} \
-initial-advertise-peer-urls \
http://{{ ip }}:2380 \
-listen-peer-urls \
http://{{ ip }}:2380 \
-listen-client-urls \
http://{{ ip }}:2379,http://127.0.0.1:2379 \
-advertise-client-urls \
http://{{ ip }}:2379 \
-initial-cluster-token {{ cl_token }} \
-initial-cluster \
{{ cl_node_01 }},{{ cl_node_02 }},{{ cl_node_03 }} \
-initial-cluster-state new \
>/var/log/etcd.log 2>&1 &"
tags: [etcd]
Shell 模块通常是最后的手段,因为它不与状态一起工作。在大多数情况下,通过 shell 运行的命令不会检查某些东西是否处于正确的状态,而每次执行 Ansible playbook 时都会运行这些命令。然而,etcd 总是只运行单个实例,多个执行该命令不会导致多个实例的产生。我们有很多参数,其中可能会改变的参数都作为变量放置。像 ansible_hostname 这样的参数是由 Ansible 自动发现的,其他的参数则是我们定义的,并且放在了 roles/etcd/defaults/main.yml 文件中。定义好所有任务后,我们可以看看 playbook etcd.yml:
- hosts: etcd
remote_user: vagrant
serial: 1
sudo: yes
roles:
- common
- etcd
当运行这个 playbook 时,Ansible 会配置所有在清单中定义的服务器,使用 vagrant 作为远程用户,使用 sudo 执行命令,并执行 common 和 etcd 角色。
让我们看看 hosts/serv-disc 文件。它是我们的清单,包含了我们正在使用的所有主机列表:
[etcd]
10.100.194.20[1:3]
在这个示例中,您可以使用另一种方式来定义主机。第二行是 Ansible 的方式,表示应使用 10.100.194.201 和 10.100.194.203 之间的所有地址。总的来说,我们为此目的指定了三个 IP 地址。
让我们运行 etcd playbook,看看它是如何工作的:
vagrant ssh cd
ansible-playbook \/vagrant/ansible/etcd.yml \
-i /vagrant/ansible/hosts/serv-disc
我们可以通过一个服务器发送一个值,并从另一个服务器获取它,来检查 etcd 集群是否正确设置:
curl http://serv-disc-01:2379/v2/keys/test \
-X PUT \
-d value="works" | jq '.'
curl http://serv-disc-03:2379/v2/keys/test \
| jq '.'
这些命令的输出应该类似于以下内容:
{
"action": "set",
"node": {
"createdIndex": 8,
"key": "/test",
"modifiedIndex": 8,
"value": "works"
}
}
{
"action": "get",
"node": {
"createdIndex": 8,
"key": "/test",
"modifiedIndex": 8,
"value": "works"
}
}
我们向 serv-disc-01 服务器(10.100.197.201)发送了 HTTP PUT 请求,并通过 HTTP GET 请求从 serv-disc-03(10.100.197.203)节点获取了存储的值。换句话说,通过集群中任何服务器设置的数据都可以在所有服务器中访问。是不是很棒?
我们的集群(在我们部署几个容器之后)将如图 8-6 所示。

图 8-6 – 多节点 Docker 容器与 etcd
现在我们有了一个存储与服务相关信息的位置,我们需要一个工具来自动将这些信息发送到 etcd。毕竟,如果可以自动完成,为什么我们要手动将数据放入 etcd 呢?即使我们想手动将信息放入 etcd,我们通常也不知道这些信息是什么。记住,服务可能会部署到一个容器较少的服务器上,并且该服务器可能会分配一个随机端口。理想情况下,这个工具应该监控所有节点上的 Docker,并在运行新容器或停止现有容器时更新 etcd。可以帮助我们实现这个目标的工具之一是 Registrator。
设置 Registrator
Registrator 通过检查容器的状态,自动注册和注销服务,容器上线或停止时会触发这一过程。它当前支持 etcd、Consul 和 SkyDNS 2。
设置带有 etcd 注册表的 Registrator 很简单。我们可以按照以下方式运行 Docker 容器(请不要自己运行):
docker run -d --name registrator \
-v /var/run/docker.sock:/tmp/docker.sock \
-h serv-disc-01 \
gliderlabs/registrator \
-ip 10.100.194.201 etcd://10.100.194.201:2379
使用此命令,我们将 /var/run/docker.sock 作为 Docker 卷共享。Registrator 会监控并拦截 Docker 事件,根据事件类型,将服务信息添加到或从 etcd 中移除。通过 -h 参数,我们指定了主机名。最后,我们传递了两个参数给 Registrator。第一个是 -ip,表示主机的 IP,第二个是协议(etcd)、IP(serv-disc-01)和注册服务的端口(2379)。
在我们继续之前,让我们创建一个新的 Ansible 角色,命名为 registrator,并将其部署到集群中的所有节点。roles/registrator/tasks/main.yml 文件如下。
- name: Container is running
docker:
name: "{{ registrator_name }}"
image: gliderlabs/registrator
volumes:
- /var/run/docker.sock:/tmp/docker.sock
hostname: "{{ ansible_hostname }}"
command: -ip {{ facter_ipaddress_eth1 }} {{ registrator_protocol }}://{{ facter_ipaddress_eth1 }}:2379
tags: [etcd]
这个 Ansible 角色相当于我们之前看到的手动命令。请注意,我们将硬编码的 etcd 协议替换为一个变量。这样,我们就可以将这个角色与其他注册表一起重用。请记住,除非值以 {{ 开头(如 hos tname 的值),否则在 Ansible 中不强制要求使用引号。
让我们看看 registrator-etcd.yml playbook。
- hosts: all
remote_user: vagrant
serial: 1
sudo: yes
vars:
- registrator_protocol: etcd
- registrator_port: 2379
roles:
- common
- docker
- etcd
- registrator
这个 playbook 大部分内容与我们之前使用过的类似,唯一不同的是 vars 键。在这种情况下,我们用它来定义 Registrator 协议为 etcd,并将注册表的端口设置为 2379。
一切就绪后,我们可以运行 playbook。
ansible-playbook \
/vagrant/ansible/registrator-etcd.yml \
-i /vagrant/ansible/hosts/serv-disc
一旦 playbook 执行完毕,Registrator 将在我们集群中的所有三个节点上运行。
让我们试一下 Registrator,并在三个集群节点中的一个上运行一个容器:
export DOCKER_HOST=tcp://serv-disc-02:2375
docker run -d --name nginx \
--env SERVICE_NAME=nginx \
--env SERVICE_ID=nginx \
-p 1234:80 \
nginx
我们导出了 DOCKER_HOST 变量,以便 Docker 命令发送到集群节点 2(serv-disc-02)并运行 nginx 容器,暴露端口 1234。我们稍后会使用 nginx,并且会有很多机会熟悉它。目前,我们不关心 nginx 的具体功能,而是 Registrator 是否检测到它并将信息存储在 etcd 中。在这种情况下,我们设置了一些环境变量(SERVICE_NAME 和 SERVICE_ID),供 Registrator 用来更好地识别服务。
让我们查看一下 Registrator 的日志。
docker logs registrator
输出应该类似于以下内容:
2015/08/30 19:18:12 added: 5cf7dd974939 nginx
2015/08/30 19:18:12 ignored: 5cf7dd974939 port 443 not published on host
我们可以看到,Registrator 检测到了 ID 为 5cf7dd974939 的 nginx 容器。我们还可以看到,它忽略了端口 443。nginx 容器内部暴露了端口 80 和 443,但是我们只向外界暴露了 80 端口,因此 Registrator 决定忽略端口 443。毕竟,为什么要存储一个对任何人都不可访问的端口信息呢?
现在,让我们来看一下存储在 etcd 中的数据:
curl http://serv-disc-01:2379/v2/keys/ | jq '.'
curl http://serv-disc-01:2379/v2/keys/nginx-80/ | jq '.'
curl http://serv-disc-01:2379/v2/keys/nginx-80/nginx | jq '.'
上一个命令的输出如下:
{
"node": {
"createdIndex": 13,
"modifiedIndex": 13,
"value": "10.100.194.202:1234",
"key": "/nginx-80/nginx"
},
"action": "get"
}
第一个命令列出了根目录下的所有键,第二个列出了nginx-80中的所有键,最后一个命令检索了最终值。Registrator以与我们运行容器时使用的环境变量相匹配的/格式存储值。请注意,如果为某个服务定义了多个端口,Registrator会将其作为后缀添加(例如:nginx-80)。Registrator存储的值与容器运行所在主机的 IP 地址和我们暴露的端口相对应。
注意
请注意,尽管容器运行在节点 2 上,但我们查询的是运行在节点 1 上的etcd。这再次展示了数据是如何在所有运行etcd的节点之间复制的。
当我们删除容器时会发生什么?
docker rm -f nginx
docker logs registrator
Registrator日志的输出应与以下内容类似:
...
2015/08/30 19:32:31 removed: 5cf7dd974939 nginx
Registrator检测到我们删除了容器,并向etcd发送请求以删除相应的值。我们可以通过以下命令确认这一点:
curl http://serv-disc-01:2379/v2/keys/nginx-80/nginx | jq '.'
输出如下:
{
"index": 14,
"cause": "/nginx-80/nginx",
"message": "Key not found",
"errorCode": 100
}
服务 ID 为nginx/nginx的服务消失了。
Registrator 与 etcd 结合是一个强大而简单的组合,允许我们练习许多高级技术。每当我们启动一个容器时,数据将存储在etcd中,并传播到集群中的所有节点。我们将如何使用这些信息将是下一章的主题。

图 8-7 – 拥有多个节点的 Docker 容器、etcd 和 Registrator
还有一个缺失的部分。我们需要一种方式,用于创建包含存储在etcd中的数据的配置文件,并在这些文件创建时运行某些命令。
配置 confd
confd 是一个轻量级的工具,可以用来维护配置文件。该工具最常见的用途是使用存储在etcd、consul以及其他少数数据注册表中的数据来保持配置文件的最新状态。它还可以在配置文件更改时重新加载应用程序。换句话说,我们可以利用它作为重新配置服务的一种方式,依靠存储在etcd(或其他少数注册表)中的信息。
安装confd非常简单。命令如下(请勿立即运行):
wget https://github.com/kelseyhightower/confd/releases\/download/v0.10.0/confd-0.10.0-linux-amd64
sudo mv confd-0.10.0-linux-amd64 /usr/local/bin/confd
sudo chmod 755 /usr/local/bin/confd
sudo mkdir -p /etc/confd/{conf.d,templates}
为了让confd正常工作,我们需要在/etc/confd/conf.d/目录下放置一个配置文件,并在/etc/confd/templates目录中放置一个模板。
示例配置文件如下:
[template]
src = "nginx.conf.tmpl"
dest = "/tmp/nginx.conf"
keys = [
"/nginx/nginx"
]
至少,我们需要指定模板源、目标文件以及将从注册表中提取的键。
模板使用 GoLang 文本模板格式。一个示例模板如下:
The address is {{getv "/nginx/nginx"}};
当处理此模板时,它会用注册表中的值替换{{getv "/nginx/nginx"}}。
最后,confd可以以两种模式运行。在守护进程模式下,它会轮询注册表,并在相关值变化时更新目标配置文件。onetime模式则只运行一次。onetime模式的示例如下(请勿立即运行):
confd -onetime -backend etcd -node 10.100.197.202:2379
该命令将在 onetime 模式下运行,并将 etcd 作为后台在指定节点上运行。执行时,目标配置将使用来自 etcd 注册表的值进行更新。
现在我们已经了解了 confd 的基本工作原理,接下来让我们看看 Ansible 角色 confd,它将确保在集群中的所有服务器上安装该工具。
roles/confd/tasks/main.yml 文件内容如下:
- name: Directories are created
file:
path: "{{ item }}"
state: directory
with_items: directories
tags: [confd]
- name: Files are copied
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
with_items: files
tags: [confd]
这个 Ansible 角色比我们为 etcd 创建的角色还要简单,因为我们甚至没有运行二进制文件。它确保了目录的创建,并将文件复制到目标服务器。由于涉及多个目录和文件,我们将它们定义为 roles/confd/defaults/main.yml 文件中的变量:
directories:
- /etc/confd/conf.d
- /etc/confd/templates
files: [
{ src: 'example.toml', dest: '/etc/confd/conf.d/example.toml', mode: '0644' },
{ src: 'example.conf.tmpl', dest: '/etc/confd/templates/example.conf.tmpl', mode: '0644' },
{ src: 'confd', dest: '/usr/local/bin/confd', mode: '0755' }
]
我们定义了存放配置和模板的目录。我们还定义了需要复制的文件:一个二进制文件、一个配置文件,以及一个模板文件,我们将使用它来尝试 confd。
最后,我们需要一个 confd.yml 文件,它将作为 Ansible 的 playbook:
- hosts: confd
remote_user: vagrant
serial: 1
sudo: yes
roles:
- common
- confd
由于该文件几乎与我们之前处理的其他 playbooks 相同,因此没有新内容需要讨论。
一切设置好后,我们可以将 confd 部署到所有集群服务器上:
ansible-playbook \
/vagrant/ansible/confd.yml \
-i /vagrant/ansible/hosts/serv-disc
在集群中的所有节点上安装了 confd 后,我们可以开始尝试了。
让我们重新启动 nginx 容器,这样 Registrator 就能将一些数据放入 etcd 中:
export DOCKER_HOST=tcp://serv-disc-01:2375
docker run -d --name nginx \
--env SERVICE_NAME=nginx \
--env SERVICE_ID=nginx \
-p 4321:80 \
Nginx
confd -onetime -backend etcd -node 10.100.194.203:2379
我们在 serv-disc-01 节点上运行了 nginx 容器,并暴露了端口 4321。由于 Registrator 已经在该服务器上运行,它将数据放入了 etcd 中。最后,我们运行了本地的 confd 实例,它检查了所有的配置文件,并将其中的键与存储在 etcd 中的键进行比较。由于 nginx/nginx 键在 etcd 中发生了变化,它处理了模板并更新了目标配置。输出结果应该类似于以下内容(为了简洁,已去掉时间戳):
cd confd[15241]: INFO Backend set to etcd
cd confd[15241]: INFO Starting confd
cd confd[15241]: INFO Backend nodes set to 10.100.194.203:2379
cd confd[15241]: INFO Target config /tmp/example.conf out of sync
cd confd[15241]: INFO Target config /tmp/example.conf has been updated
它发现 /tmp/example.conf 与实际不符并进行了更新。让我们确认一下:
cat /tmp/example.conf
输出结果如下:
The address is 10.100.194.201:4321
如果模板或 etcd 数据中的任何更改已更新,运行 confd 将确保所有目标配置也会相应更新:

图 8-8 – 带有 Docker 容器、etcd、Registrator 和 confd 的多个节点
结合 etcd、Registrator 和 confd
当 etcd、Registrator 和 confd 结合使用时,我们就能得到一种简单而强大的方式来自动化所有的服务发现和配置需求。这在我们开始处理更高级的部署策略时将大有帮助。这个组合还展示了拥有正确的小工具组合的有效性。这三者完成了我们需要它们完成的工作。如果少了其中任何一个,我们将无法完成预定的目标。相反,如果它们是以更大范围的目标设计的,我们将引入不必要的复杂性,增加服务器资源和维护的负担。
在我们做出最终决定之前,让我们看一下另一个目标相似的工具组合。毕竟,我们永远不应该在没有调查其他选择的情况下就决定某个解决方案。
Consul
Consul 是一个强一致性的数据库,它使用 gossip 协议形成动态集群。它具有层级化的键/值存储,不仅可以用于存储数据,还可以注册监控,用于各种任务,例如发送数据变化的通知、运行健康检查和根据输出运行自定义命令。
与 Zookeeper 和 etcd 不同,Consul 实现了内嵌的服务发现系统,因此无需自己构建或使用第三方的。此发现系统包括,除了其他功能外,还能检查节点及其上运行的服务的健康状态。
ZooKeeper 和 etcd 仅提供一个原始的 K/V 存储,并要求应用开发者构建自己的系统来提供服务发现。另一方面,Consul 提供了一个内建的服务发现框架。客户端只需注册服务,并通过 DNS 或 HTTP 接口进行发现。另两个工具则需要手动解决方案或使用第三方工具。
Consul 提供开箱即用的本地多数据中心支持,并且其 gossip 系统不仅能与同一集群中的节点协作,还能跨数据中心工作。
Consul 还有另一个使其与其他工具不同的优点。它不仅可以用来发现有关已部署服务和节点的信息,还提供了通过 HTTP 和 TCP 请求、TTL(生存时间)、自定义脚本甚至 Docker 命令的健康检查,且易于扩展。
设置 Consul
和以前一样,我们首先通过手动安装命令来开始,然后用 Ansible 自动化它们。我们将在 cd 节点上进行配置作为练习:
sudo apt-get install -y unzip
wget https://releases.hashicorp.com/consul/0.6.4/consul_0.6.4_linux_amd64.zip
unzip consul_0.6.4_linux_amd64.zip
sudo mv consul /usr/local/bin/consul
rm -f consul_0.6.4_linux_amd64.zip
sudo mkdir -p /data/consul/{data,config,ui}
我们首先安装了 unzip,因为它不包含在默认的 Ubuntu 发行版中。然后我们下载了 Consul 的 ZIP 文件,解压后将其移动到 /usr/local/bin 目录,删除了 ZIP 文件,因为我们不再需要它,最后创建了一些目录。Consul 会将其信息存放在 data 目录中,配置文件则放在 config 中。
接下来我们可以运行 consul:
sudo consul agent \
-server \
-bootstrap-expect 1 \
-data-dir /data/consul/data \
-config-dir /data/consul/config \
-node=cd \
-bind=10.100.198.200 \
-client=0.0.0.0 \
-ui \
>/tmp/consul.log &
运行 Consul 非常直接。我们指定它应该作为 server 运行 agent,并且只会有一个服务器实例 (-bootstrap-expect 1)。接着是关键目录的路径:ui、data 和 config。然后我们指定了 node 的名称、它将 bind 的地址以及可以连接到它的 client(0.0.0.0 表示所有地址)。最后,我们将输出重定向并确保它在后台运行(&)。
让我们验证 Consul 是否正确启动。
cat /tmp/consul.log
日志文件的输出应该类似于以下内容(为了简洁,省略了时间戳)。
==> Starting Consul agent...
==> Starting Consul agent RPC...
==> Consul agent running!
Node name: 'cd'
Datacenter: 'dc1'
Server: true (bootstrap: true)
Client Addr: 0.0.0.0 (HTTP: 8500, HTTPS: -1, DNS: 8600, RPC: 8400)
Cluster Addr: 10.100.198.200 (LAN: 8301, WAN: 8302)
Gossip encrypt: false, RPC-TLS: false, TLS-Incoming: false
Atlas: <disabled>
==> Log data will now stream in as it occurs:
[INFO] serf: EventMemberJoin: cd 10.100.198.200
[INFO] serf: EventMemberJoin: cd.dc1 10.100.198.200
[INFO] raft: Node at 10.100.198.200:8300 [Follower] entering Follower state
[WARN] serf: Failed to re-join any previously known node
[INFO] consul: adding LAN server cd (Addr: 10.100.198.200:8300) (DC: dc1)
[WARN] serf: Failed to re-join any previously known node
[INFO] consul: adding WAN server cd.dc1 (Addr: 10.100.198.200:8300) (DC: dc1)
[ERR] agent: failed to sync remote state: No cluster leader
[WARN] raft: Heartbeat timeout reached, starting election
[INFO] raft: Node at 10.100.198.200:8300 [Candidate] entering Candidate state
[INFO] raft: Election won. Tally: 1
[INFO] raft: Node at 10.100.198.200:8300 [Leader] entering Leader state
[INFO] consul: cluster leadership acquired
[INFO] consul: New leader elected: cd
[INFO] raft: Disabling EnableSingleNode (bootstrap)
我们可以看到,作为服务器模式运行的 Consul 代理自选为领导者(这也在预期之中,因为它是唯一的一个)。
启动并运行 Consul 后,让我们看看如何向其添加一些数据。
curl -X PUT -d 'this is a test' \
http://localhost:8500/v1/kv/msg1
curl -X PUT -d 'this is another test' \
http://localhost:8500/v1/kv/messages/msg2
curl -X PUT -d 'this is a test with flags' \
http://localhost:8500/v1/kv/messages/msg3?flags=1234
第一个命令创建了msg1键,值为this is a test。第二个命令将msg2键嵌套到父键messages中。最后,最后一个命令添加了flag,值为1234。标志可以用来存储版本号或任何其他可以表示为整数的信息。
让我们看看如何检索我们刚刚存储的信息:
curl http://localhost:8500/v1/kv/?recurse \
| jq '.'
命令的输出如下(顺序无法保证):
[
{
"CreateIndex": 141,
"Flags": 0,
"Key": "messages/msg2",
"LockIndex": 0,
"ModifyIndex": 141,
"Value": "dGhpcyBpcyBhbm90aGVyIHRlc3Q="
},
{
"CreateIndex": 142,
"Flags": 1234,
"Key": "messages/msg3",
"LockIndex": 0,
"ModifyIndex": 147,
"Value": "dGhpcyBpcyBhIHRlc3Qgd2l0aCBmbGFncw=="
},
{
"CreateIndex": 140,
"Flags": 0,
"Key": "msg1",
"LockIndex": 0,
"ModifyIndex": 140,
"Value": "dGhpcyBpcyBhIHRlc3Q="
}
]
由于我们使用了recurse查询,键值是从根递归返回的。
在这里我们可以看到我们插入的所有键。但是,值是经过 base64 编码的。Consul 不仅能存储文本,实际上它在幕后存储所有内容为二进制数据。由于并非所有内容都可以表示为文本,因此你可以将任何内容存储在 Consul 的键值对中,但有大小限制。
我们还可以检索单个键:
curl http://localhost:8500/v1/kv/msg1 \
| jq '.'
输出与之前相同,但仅限于键msg1:
[
{
"CreateIndex": 140,
"Flags": 0,
"Key": "msg1",
"LockIndex": 0,
"ModifyIndex": 140,
"Value": "dGhpcyBpcyBhIHRlc3Q="
}
]
最后,我们可以只请求值:
curl http://localhost:8500/v1/kv/msg1?raw
这次,我们添加了raw查询参数,结果仅返回请求的键的值:
this is a test
正如你可能猜到的,Consul 的键可以轻松地被删除。例如,删除messages/msg2键的命令如下:
curl -X DELETE http://localhost:8500/v1/kv/messages/msg2
我们也可以递归删除:
curl -X DELETE http://localhost:8500/v1/kv/?recurse
我们部署的 Consul 代理被设置为服务器模式。然而,大多数代理不需要在服务器模式下运行。根据节点数量,我们可以选择三个 Consul 代理运行在服务器模式下,其他非服务器代理加入它。另一方面,如果节点数非常多,我们可能会将运行在服务器模式下的代理数量增加到五个。如果只运行一个服务器,若其失败则会导致数据丢失。在我们的情况下,由于集群仅由三个节点组成,而且这是一个演示环境,一个运行在服务器模式下的 Consul 代理就足够了。
在serv-disc-02节点上运行代理并使其加入集群的命令如下(请不要立即运行):
sudo consul agent \
-data-dir /data/consul/data \
-config-dir /data/consul/config \
-node=serv-disc-02 \
-bind=10.100.197.202 \
-client=0.0.0.0 \
>/tmp/consul.log &
与之前的执行相比,唯一的区别是移除了-server和-bootstrap-expect 1参数。然而,在集群服务器上运行 Consul 还不够,我们需要将其与运行在其他服务器上的 Consul 代理连接起来。完成这项工作的命令如下(请不要立即运行)。
consul join 10.100.198.200
执行此命令的效果是两个服务器的代理会聚集在一起,数据在它们之间同步。如果我们继续向其他服务器添加 Consul 代理并让它们加入,效果将是 Consul 中注册的集群节点数增加。无需加入多个代理,因为 Consul 使用 gossip 协议来管理成员身份并广播消息到集群。这是与etcd相比的一个有用改进,etcd要求我们指定集群中所有服务器的列表。而当服务器数量增加时,管理这样的列表会变得更加复杂。通过 gossip 协议,Consul 能够在不告知它节点位置的情况下自动发现集群中的节点。
在掌握 Consul 的基础知识后,让我们看看如何在集群中的所有服务器上自动化配置它。由于我们已经使用 Ansible,我们将为 Consul 创建一个新的角色。虽然我们即将探索的配置与之前的配置非常相似,但也有一些我们之前没有看到的新细节。Ansible 角色roles/consul/tasks/main.yml中的任务如下:
- name: Directories are created
file:
path: "{{ item }}"
state: directory
with_items: directories
tags: [consul]
- name: Files are copied
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
with_items: files
tags: [consul]
我们首先创建了目录并复制了文件。这两个任务都使用在with_items标签中指定的变量数组。
让我们来看看这些变量。它们在roles/consul/defaults/main.yml文件中定义:
logs_dir: /data/consul/logs
directories:
- /data/consul/data
- /data/consul/config
- "{{ logs_dir }}"
files: [
{ src: 'consul', dest: '/usr/local/bin/consul', mode: '0755' },
{ src: 'ui', dest: '/data/consul', mode: '0644' }
]
即使我们可以在roles/consul/tasks/main.yml文件中指定所有这些变量,将它们分开存放可以让我们更容易地更改它们的值。在这种情况下,我们有一个简单的目录列表和一个以 JSON 格式表示的文件列表,其中包括源、目标和模式。然后继续执行roles/consul/tasks/main.yml中的任务。第三个任务如下:
- name: Is running
shell: "nohup consul agent {{ consul_extra }} \
-data-dir /data/consul/data \
-config-dir /data/consul/config \
-node={{ ansible_hostname }} \
-bind={{ ip }} \
-client=0.0.0.0 \
>{{ logs_dir }}/consul.log 2>&1 &"
tags: [consul]
由于 Consul 确保在任何时候只有一个进程在运行,因此多次执行此任务并不会造成危险。它相当于我们手动执行的命令,只是多了几个变量。
如果你还记得手动执行 Consul 的过程,应该有一个节点在服务器节点上运行 Consul,其余节点至少加入一个节点,以便 Consul 能够将这些信息传播到整个集群中。我们将这些差异定义为(consul_extra)变量。与之前定义的变量不同,这些变量定义在roles/consul/defaults/main.yml文件内,而consul_extra是在hosts/serv-disc清单文件中定义的。我们来看看它:
[consul]
10.100.194.201 consul_extra="-server -bootstrap"
10.100.194.20[2:3] consul_server_ip="10.100.194.201"
我们在服务器 IP 的右侧定义了变量。在这种情况下,.201作为服务器使用。其余部分定义了我们稍后将讨论的consul_server_ip变量。
让我们跳到roles/consul/tasks/main.yml文件中定义的第四个(也是最后一个)任务:
- name: Has joined
shell: consul join {{ consul_server_ip }}
when: consul_server_ip is defined
tags: [consul]
这个任务确保除了在服务器模式下运行的 Consul 代理外,其他所有 Consul 代理都加入了集群。该任务执行与我们手动执行的相同命令,并添加了 consul_server_ip 变量,该变量有双重用途。第一个用途是为 shell 命令提供值。第二个用途是决定是否运行此任务。我们通过使用 when: consul_server_ip is defined 定义实现了这一点:
最后,我们有 consul.yml playbook,内容如下:
- hosts: consul
remote_user: vagrant
serial: 1
sudo: yes
roles:
- common
- consul
对于它没有太多要说的,因为它遵循了我们之前使用的 playbook 的相同结构。
现在我们有了 playbook,让我们执行它并查看 Consul 节点。
ansible-playbook \
/vagrant/ansible/consul.yml \
-i /vagrant/ansible/hosts/serv-disc
我们可以通过向其中一个代理发送 nodes 请求来确认 Consul 是否确实在所有节点上运行:
curl serv-disc-01:8500/v1/catalog/nodes \
| jq '.'
命令的输出如下。
[
{
"Address": "10.100.194.201",
"Node": "serv-disc-01"
},
{
"Address": "10.100.194.202",
"Node": "serv-disc-02"
},
{
"Address": "10.100.194.203",
"Node": "serv-disc-03"
}
]
集群中的所有三个节点现在都在运行 Consul。现在可以回到 Registrator,看看它与 Consul 配合使用时的表现。

图 8-9 – 使用 Docker 容器和 Consul 的多个节点
设置 Registrator
Registrator 有两种 Consul 协议。我们先看一下 consulkv,因为它的结果应该与使用 etcd 协议时获得的结果非常相似:
export DOCKER_HOST=tcp://serv-disc-01:2375
docker run -d --name registrator-consul-kv \
-v /var/run/docker.sock:/tmp/docker.sock \
-h serv-disc-01 \
gliderlabs/registrator \
-ip 10.100.194.201 consulkv://10.100.194.201:8500/services
让我们查看 Registrator 日志,检查一切是否正常工作:
docker logs registrator-consul-kv
输出应该类似于以下内容(为了简洁,已移除时间戳):
Starting registrator v6 ...
Forcing host IP to 10.100.194.201
consulkv: current leader 10.100.194.201:8300
Using consulkv adapter: consulkv://10.100.194.201:8500/services
Listening for Docker events ...
Syncing services on 1 containers
ignored: 19c952849ac2 no published ports
ignored: 46267b399098 port 443 not published on host
added: 46267b399098 nginx
结果与我们使用 etcd 协议运行 Registrator 时相同。它找到了运行中的 nginx 容器(就是我们之前在练习 etcd 时启动的那个),并将公开的端口 4321 发布到 Consul。我们可以通过查询 Consul 来确认这一点:
curl http://serv-disc-01:8500/v1/kv/services/nginx/nginx?raw
正如预期的那样,输出是通过 nginx 容器暴露的 IP 和端口:
10.100.194.201:4321
然而,Registrator 还有另一种协议叫做 consul(我们刚刚使用的是 consulkv),它利用 Consul 存储服务信息的格式。
docker run -d --name registrator-consul \
-v /var/run/docker.sock:/tmp/docker.sock \
-h serv-disc-01 \
gliderlabs/registrator \
-ip 10.100.194.201 consul://10.100.194.201:8500
让我们看看这次 Registrator 向 Consul 发送了什么信息:
curl http://serv-disc-01:8500/v1/catalog/service/nginx-80 | jq '.'
这次,数据更完整一些,但仍然保持非常简单的格式:
[
{
"ModifyIndex": 185,
"CreateIndex": 185,
"Node": "serv-disc-01",
"Address": "10.100.194.201",
"ServiceID": "nginx",
"ServiceName": "nginx-80",
"ServiceTags": [],
"ServiceAddress": "10.100.194.201",
"ServicePort": 4321,
"ServiceEnableTagOverride": false
}
]
除了通常与 etcd 或 consulkv 协议一起存储的 IP 和端口外,这次我们得到了更多的信息。我们知道服务运行的节点、服务 ID 和名称。通过添加几个额外的环境变量,我们甚至可以做得更好。让我们再启动一个 nginx 容器,看看 Consul 中存储的数据:
docker run -d --name nginx2 \
--env "SERVICE_ID=nginx2" \
--env "SERVICE_NAME=nginx" \
--env "SERVICE_TAGS=balancer,proxy,www" \
-p 1111:80 \
nginx
curl http://serv-disc-01:8500/v1/catalog/service/nginx-80 | jq '.'
上一个命令的输出如下。
[
{
"ModifyIndex": 185,
"CreateIndex": 185,
"Node": "serv-disc-01",
"Address": "10.100.194.201",
"ServiceID": "nginx",
"ServiceName": "nginx",
"ServiceTags": [],
"ServiceAddress": "10.100.194.201",
"ServicePort": 4321,
"ServiceEnableTagOverride": false
},
{
"ModifyIndex": 202,
"CreateIndex": 202,
"Node": "serv-disc-01",
"Address": "10.100.194.201",
"ServiceID": "nginx2",
"ServiceName": "nginx",
"ServiceTags": [
"balancer",
"proxy",
"www"
],
"ServiceAddress": "10.100.194.201",
"ServicePort": 1111,
"ServiceEnableTagOverride": false
}
]
第二个容器(nginx2)已注册,这次,Consul 获取了我们可能稍后会用到的标签。由于这两个容器都列在同一名称下,Consul 将它们视为同一服务的两个实例。
现在我们知道了 Registrator 如何与 Consul 配合工作,让我们在集群中的所有节点上配置它。好消息是角色已经创建,并且我们设置了通过 protocol 变量定义协议。我们还将容器的名称作为 registrator_name 变量,以便能够通过 Consul 协议启动 Registrator 容器,而不会与之前配置的 etcd 容器发生冲突:
playbook registrator.yml 如下所示。
- hosts: registrator
remote_user: vagrant
serial: 1
sudo: yes
vars:
- registrator_name: registrator-c
onsul
docker
- consul
- registrator
registrator-etcd.yml 文件中的 registrator_protocol 变量设置为 etcd,registrator_port 设置为 2379。在本例中我们不需要它,因为我们已经在 roles/registrator/defaults/main.yml 文件中设置了默认值为 consul 和 8500。另一方面,我们确实覆盖了 registrator_name 的默认值:
一切准备就绪后,我们可以运行 playbook:
ansible-playbook \
/vagrant/ansible/registrator.yml \
-i /vagrant/ansible/hosts/serv-disc
一旦此 playbook 执行完成,使用 Consul 协议的 Registrator 将在集群中的所有节点上配置完成:

图 8-10 – 多个节点与 Docker 容器、Consul 和 Registrator
模板化怎么样?我们应该使用 confd 还是其他工具?
设置 Consul Template
我们可以像在 etcd 中使用 confd 一样在 Consul 中使用它。然而,Consul 有自己独特的模板服务,其功能更符合 Consul 提供的功能。
Consul Template 是一种非常方便的方式,可以使用从 Consul 获得的值创建文件。作为额外的好处,它还可以在文件更新后运行任意命令。与 confd 一样,Consul Template 也使用 Go Template 格式。
到现在为止,你可能已经习惯了这个流程。首先我们将手动尝试 Consul Template。与本章中设置的所有其他工具一样,安装过程包括下载发布版本、解压缩并确保可执行文件位于系统路径中。
wget https://releases.hashicorp.com/consul-template/0.12.0/\
consul-template_0.12.0_linux_amd64.zip
sudo apt-get install -y unzip
unzip consul-template_0.12.0_linux_amd64.zip
sudo mv consul-template /usr/local/bin
rm -rf consul-template_0.12.0_linux_amd64*
在节点上安装了 Consul Template 后,我们应该创建一个模板:
echo '
{{range service "nginx-80"}}
The address is {{.Address}}:{{.Port}}
{{end}}
' >/tmp/nginx.ctmpl
当这个模板被处理时,它将对所有名为 nginx-80 的服务进行迭代(range)。每次迭代都会生成包含服务 Address 和 Port 的文本。模板已创建为 /tmp/nginx.ctmpl。
在运行 Consul Template 之前,让我们再看看我们在 Consul 中存储的 nginx 服务信息:
curl http://serv-disc-01:8500/v1/catalog/service/nginx-80 | jq '.'
输出如下:
[
{
"ModifyIndex": 185,
"CreateIndex": 185,
"Node": "serv-disc-01",
"Address": "10.100.194.201",
"ServiceID": "nginx",
"ServiceName": "nginx-80",
"ServiceTags": [],
"ServiceAddress": "10.100.194.201",
"ServicePort": 4321,
"ServiceEnableTagOverride": false
},
{
"ModifyIndex": 202,
"CreateIndex": 202,
"Node": "serv-disc-01",
"Address": "10.100.194.201",
"ServiceID": "nginx2",
"ServiceName": "nginx-80",
"ServiceTags": [
"balancer",
"proxy",
"www"
],
"ServiceAddress": "10.100.194.201",
"ServicePort": 1111,
"ServiceEnableTagOverride": false
}
]
我们有两个 nginx 服务已经启动并在 Consul 中注册。让我们看看应用我们创建的模板后的结果:
consul-template \
-consul serv-disc-01:8500 \
-template "/tmp/nginx.ctmpl:/tmp/nginx.conf" \
-once
cat /tmp/nginx.conf
第二个命令的结果如下:
The address is 10.100.194.201:4321
The address is 10.100.194.201:1111
我们执行的 Consul Template 命令找到了两个服务,并以我们指定的格式生成了输出。我们指定它应该只运行一次。另一种方式是以守护进程模式运行。在这种情况下,它将监视注册表的变化,并将这些变化应用到指定的配置文件中。
等到我们在部署流水线中开始使用 Consul 模板时,我们将详细介绍它的工作原理。在那之前,请自行查阅www.consul.io/docs/。目前重要的是要理解,它可以获取存储在 Consul 中的任何信息,并将其应用到我们指定的模板中。除了创建文件之外,它还可以运行自定义命令。这将在我们下一章节讨论的反向代理中非常有用。
我们没有尝试将 Consul 模板应用于 Consul 的键/值格式。在这种组合中,与 confd 相比并没有显著的区别。
Consul 模板的主要缺点是与 Consul 的紧密耦合。与可以与许多不同的注册中心一起使用的 confd 不同,Consul 模板被创建为与 Consul 紧密集成的模板引擎。这一点既是优势,因为它理解 Consul 的服务格式。如果选择使用 Consul,Consul 模板非常合适。
在我们继续下一个主题之前,让我们创建 Consul 模板角色,并在所有节点上配置它。roles/consul-template/tasks/main.yml文件如下所示。
- name: Directory is created
file:
path: /data/consul-template
state: directory
tags: [consul-template]
- name: File is copied
copy:
src: consul-template
dest: /usr/local/bin/consul-template
mode: 0755
tags: [consul-template]
这个角色并没有什么特别的。到目前为止,这可能是我们做过的最简单的一个角色。对于consul-template.ymlplaybook 也是一样:
- hosts: consul-template
remote_user: vagrant
serial: 1
sudo: yes
roles:
- common
- consul-template
最后,我们可以在所有节点上进行配置:
ansible-playbook \
/vagrant/ansible/consul-template.yml \
-i /vagrant/ansible/hosts/serv-disc
最终结果与 etcd/Registrator 组合非常相似,唯一的区别在于发送到 Consul 的数据格式:

图 8-11 – 使用 Docker 容器、Consul、Registrator 和 Consul 模板的多节点
到目前为止,我们已经涵盖了 Consul 的功能,这些功能在某种程度上类似于 etcd/registrator/confd 组合。现在是时候看看使 Consul 确实脱颖而出的特性了。
Consul 健康检查、Web UI 和数据中心
监控集群节点和服务的健康状况和测试、部署本身一样重要。虽然我们应该努力实现稳定的环境,避免故障,但也应该承认意外的故障会发生,并做好相应的准备。例如,我们可以监控内存使用情况,如果达到某个阈值,可以将一些服务迁移到集群中的其他节点。这是发生“灾难”前采取的预防性措施。另一方面,并不是所有潜在的故障都能及时检测到,以便我们及时采取行动。一项服务可能会失败,一个节点可能因硬件故障而停止工作。在这种情况下,我们应该准备好尽快采取行动,例如,通过更换一个新节点并迁移失败的服务。我们不会详细讨论 Consul 如何帮助我们完成这项任务,因为有一整章专门讲解 自愈系统,而 Consul 在其中将发挥重要作用。现在,足以说 Consul 具有一种简单、优雅且强大的健康检查方式,可以帮助我们在健康阈值达到时确定应该采取的措施。
如果您谷歌过 etcd ui 或 etcd dashboard,您可能看到了一些现有的解决方案,您可能会问为什么我们没有展示它们。原因很简单:etcd 只是一个键/值存储,没什么更多的功能。拥有一个 UI 来展示数据并没有太大用处,因为我们可以通过 etcdctl 容易地获取数据。这并不意味着 etcd UI 没有用处,而是由于其功能范围有限,它并不会带来太大的区别。
Consul 不仅仅是一个简单的键/值存储。正如我们已经看到的,除了存储键/值对,它还包含与之关联的服务和数据。它还可以执行健康检查,因此成为一个理想的仪表板,可以用来查看我们节点和在其上运行的服务的状态。最后,它理解多个数据中心的概念。所有这些功能结合起来,让我们以不同的角度看待仪表板的需求。
通过 Consul Web UI,我们可以查看所有服务和节点,监控健康检查及其状态,读取和设置键/值数据,并在不同数据中心之间切换。要查看实际效果,请在您喜欢的浏览器中打开 http://10.100.194.201:8500/ui。您将看到顶部菜单中的项目,这些项目对应我们之前通过 API 执行的步骤。
Services 菜单项列出了我们注册的所有服务。目前没有多少,因为只有 Consul 服务器、Docker UI 和两个 nginx 服务实例正在运行。我们可以通过名称或状态进行过滤,并通过点击其中一个注册的服务来查看详细信息:

图 8-12 – Consul Web UI 服务
节点显示我们所选数据中心中所有节点的列表。在我们的例子中,我们有三个节点。第一个节点有三个注册服务:

图 8-13 – Consul Web UI 节点
Key/Value 屏幕可用于显示和修改数据。在该屏幕上,您可以看到由 Registrator 实例(已设置为使用 consulkv 协议)放入 Consul 的数据。请随意添加数据,并查看它们如何在 UI 中进行可视化。除了通过我们之前使用的 API 操作 Consul 键值数据外,您还可以通过 UI 管理这些数据。

图 8-14 – Consul Web UI 键值
注意
请注意,Consul 允许我们将节点分组到数据中心中。由于我们只有三个节点,因此并未使用此功能。当集群中的节点开始增加时,将它们分割到不同的数据中心通常是一个好主意,且 Consul 帮助我们通过其 UI 进行可视化。
将 Consul、Registrator、Template、Health Checks 和 Web UI 结合使用
与我们探索的工具一起,Consul 在许多情况下比 etcd 提供的解决方案更好。它是专为服务架构和发现设计的。它简单却强大,提供了一个完整的解决方案而没有牺牲简洁性,在许多情况下,它是服务发现和健康检查需求的最佳工具(至少在我们评估的工具中是这样)。
服务发现工具对比
所有这些工具都基于相似的原则和架构。它们运行在节点上,需要法定人数才能操作,并且具有强一致性。它们都提供某种形式的键值存储。
Zookeeper 是三者中最古老的,它的复杂性、资源利用和试图实现的目标都显现出它的年代感。它的设计时代与我们评估的其他工具不同(尽管它并不比其他工具早多少)。
etcd 与 Registrator 和 confd 的组合是一个非常简单,但又非常强大的组合,可以解决我们大多数(如果不是所有)服务发现的需求。它展示了当我们将简单且非常具体的工具组合时可以获得的强大功能。每个工具都执行一个非常具体的任务,通过成熟的 API 进行通信,并能够相对独立地工作。它们在架构和功能上都是 微服务。
Consul的独特之处在于它支持多个数据中心和健康检查,而无需使用第三方工具。这并不意味着使用第三方工具是错误的。事实上,在本书中,我们正尝试通过选择那些性能优于其他工具且不引入不必要的功能开销的工具,来将不同工具结合使用。当我们使用适合工作的工具时,可以获得最佳的结果。如果工具做得比我们要求的更多,它的效率会下降。另一方面,不能满足我们需求的工具则毫无用处。Consul 在这方面达到了平衡。它做的事情很少,而且做得很好。
Consul 通过使用 gossip 协议传播集群信息,使其比 etcd 更易于设置,尤其是在大数据中心的情况下。将数据作为服务存储的能力使其比 etcd 中使用的键值存储更加完整和实用(尽管 Consul 也有这个选项)。虽然我们可以通过在 etcd 中插入多个键来实现相同的效果,但 Consul 的服务实现了更紧凑的结果,通常只需要一次查询即可检索与该服务相关的所有数据。更重要的是,Registrator 对 Consul 协议有很好的实现,使得两者成为一个出色的组合,尤其是在将 Consul Template 加入其中时。Consul 的 Web UI 就像蛋糕上的樱桃,为你提供了一个很好的方式来可视化你的服务及其健康状态。
我不能说 Consul 是明显的赢家。相反,与 etcd 相比,它有一些优势。作为一个概念,服务发现以及我们可以使用的工具是如此新颖,以至于我们可以预期这个领域会发生很多变化。当你读到这本书时,可能会有新的工具出现,或者我们评估的工具发生足够的变化,以至于我们做的一些练习会变得过时。保持开放的心态,尝试以适当的批判态度接受本章的一些建议。我们使用的逻辑是坚实的,不太可能很快改变,但工具则不一定如此。它们很可能会迅速发展。
在我们回到部署过程之前,还剩下一个主题。集成步骤将要求我们了解反向代理。
在我们继续之前,先销毁我们为服务发现实践创建的虚拟机,并为下一章释放一些资源。
exit
vagrant destroy -f
第九章:代理服务
我们已经到了需要将我们正在部署的容器连接在一起的阶段。我们需要简化对服务的访问,并统一我们容器部署所在的所有服务器和端口。许多解决方案正在尝试解决这个问题,其中企业服务总线(ESB)产品是最常见的。然而,这并不是说它们的唯一目标是将请求重定向到目标服务。事实并非如此,这也是我们拒绝将 ESB 作为(我们架构中的一部分)解决方案的原因之一。其方法的显著区别在于,ESB 倾向于做很多(远远超出我们的需求),而我们则试图通过使用非常具体的小型组件或服务来构建系统,这些组件或服务几乎恰好做我们需要的事,不多,也不少。ESB 与微服务相对立,从某种程度上讲,它背离了面向服务架构的初衷。由于我们致力于微服务并寻找更具体的解决方案,因此替代方案就是代理服务。显然,我们应该多花一些时间讨论什么是代理服务,以及哪些产品可能帮助我们实现架构和流程。
代理服务是指在执行请求的客户端和提供这些请求的服务之间充当中介的服务。客户端将请求发送到代理服务,代理服务再将该请求转发到目标服务,从而简化并控制架构中服务背后的复杂性。
至少有三种不同类型的代理服务:
-
网关或隧道服务是一种代理服务,它将请求重定向到目标服务,并将响应返回给发出请求的客户端。
-
正向代理用于从不同(主要是互联网)来源检索数据。
-
反向代理通常用于控制和保护对私有网络上服务器或服务的访问。除了其主要功能外,反向代理通常还执行负载均衡、解密、缓存和身份验证等任务。
反向代理可能是解决当前问题的最佳方案,因此我们将花更多时间来更好地理解它。
反向代理服务
代理服务的主要目的是隐藏其余服务并将请求重定向到最终目的地。响应也是如此。一旦某个服务响应请求,该响应将返回给代理服务,并从代理服务重定向回最初请求的客户端。从目的地服务的角度来看,所有请求都是来自代理的。换句话说,生成请求的客户端不知道代理背后是什么,响应请求的服务也不知道请求来自代理之外。也就是说,客户端和服务只知道代理服务的存在。
我们将集中讨论基于(微)服务架构中的代理服务使用。然而,如果代理服务应用于整个服务器,许多概念也是相同的(只是它会被称为代理服务器)。
代理服务的主要目的之一(除了请求和响应的协调)如下:
-
尽管几乎任何应用服务器都可以提供加密(最常见的是安全套接字层(SSL)),但通常让中间人负责这一过程更为简便。
-
负载均衡是指在这种情况下,代理服务在多个相同服务的实例之间平衡负载。在大多数情况下,这些实例会分布在多个服务器上。结合负载均衡和扩展,特别是在微服务架构的基础上,我们可以快速实现性能提升,避免超时和停机。
-
压缩是另一个在单一服务中集中实现的功能候选项。作为代理服务的主要产品在压缩方面非常高效,并且设置相对简单。压缩流量的主要原因是加快加载时间。文件越小,加载速度就越快。
-
缓存是代理服务中另一个容易实现的功能,在某些情况下,它的集中化管理是有利的。通过缓存响应,我们可以卸载部分服务需要处理的工作。缓存的核心思想是我们设定规则(例如,缓存与产品列表相关的请求)和缓存超时。之后,代理服务只会第一次向目标服务发送请求,并将响应存储在内部。从那时起,只要请求相同,代理服务就会直接提供响应,而无需将请求转发到目标服务。直到超时发生,流程才会重复。我们还可以采用更复杂的组合方式,但最常见的用法就是我们描述的这种。
-
大多数代理服务作为单一入口点服务公开的公共 API。仅此一点就增加了安全性。在大多数情况下,只有端口
80(HTTP)和443(HTTPS)会对外公开,所有其他服务所需的端口应仅对内部使用开放。 -
不同类型的身份验证(例如 OAuth)可以通过代理服务实现。当请求没有用户身份时,代理服务可以设置为返回适当的响应码给调用者。另一方面,当身份信息存在时,代理可以选择继续访问目标,并将身份验证交由目标服务处理,或者由代理自行处理。当然,许多不同的变体可以用来实现身份验证。关键要注意的是,如果使用了代理,它很可能会在这个过程中以某种方式参与其中。
这份清单绝不是详尽无遗的,也不是最终的,但包含了一些最常见的使用案例。许多其他组合也可能存在,包括合法和非法的用途。例如,代理是任何想要保持匿名的黑客不可或缺的工具。
在本书中,我们将主要关注代理服务的基本功能;我们将使用代理服务作为代理。它们将负责所有微服务之间流量的调度,这些微服务将被部署。我们从部署中使用的简单用法开始,逐步推进到更复杂的调度方式,即蓝绿部署。
对某些人来说,代理服务可能偏离了微服务的思路,因为它(通常情况下)可以做多件事。然而,从功能角度看,它只有一个单一的目的。它提供了外部世界与我们内部托管的所有服务之间的桥梁。同时,它往往资源占用非常低,只需要几个配置文件即可处理。
在掌握了代理服务的基本概念后,现在是时候了解我们可以使用的一些产品了。
从现在开始,我们将反向代理简化为代理。
代理服务如何帮助我们的项目?
到目前为止,我们已经成功地实现了一种控制方式来部署我们的服务。由于我们尝试实现的部署性质,这些服务应该部署在我们事先无法确定的端口和可能是服务器上。灵活性是可扩展架构、容错能力以及我们将进一步探讨的许多其他概念的关键。然而,这种灵活性是有代价的。我们可能无法提前知道服务将部署在哪些地方,或者它们暴露了哪些端口。即使这些信息在部署之前可以获得,我们也不应该强迫我们的服务用户在发送请求时指定不同的端口和 IP。解决方案是将来自第三方和内部服务的所有通信集中到一个单一的点。负责重定向请求的唯一地方将是一个代理服务。我们将探讨一些可用的工具,并比较它们的优缺点。
和之前一样,我们将从创建虚拟机开始,利用这些虚拟机来实验不同的代理服务。我们将重新创建 cd 节点,并用它来为 proxy 服务器配置不同的代理服务。
vagrant up cd proxy
我们将探索的第一个工具是 nginx。
nginx
nginx(引擎 x)是一个 HTTP 和反向代理服务器,一个邮件代理服务器,以及一个通用的 TCP 代理服务器。它最初由 Igor Sysoev 编写。最初,它为许多俄罗斯网站提供服务。从那时起,它成为了世界上一些访问量最大的网站的首选服务器(Netflix、Wordpress 和 FastMail 只是其中的一些例子)。根据 Netcraft 的数据,nginx 在 2015 年 9 月服务或代理了约 23% 的最繁忙网站。这使得它仅次于 Apache。虽然 Netcraft 提供的数据可能值得怀疑,但显然 nginx 非常受欢迎,可能在 Apache 和 IIS 之后位居第三。由于我们到目前为止所做的工作都是基于 Linux,因此 Microsoft IIS 应该被排除在外。这使得 Apache 成为我们选择代理服务的有效候选者。合理的推测是,这两者应该进行比较。
Apache 已经存在多年,并建立了庞大的用户基础。它的巨大受欢迎程度部分得益于 Tomcat,后者运行在 Apache 之上,是目前最受欢迎的应用服务器之一。Tomcat 只是 Apache 灵活性的众多例子之一。通过其模块,Apache 可以扩展以处理几乎任何编程语言。
最受欢迎并不一定意味着是最好的选择。由于设计缺陷,Apache 在重负载下可能会变得极为缓慢。它会生成新的进程,这些进程又会消耗大量的内存。此外,它会为每个请求创建新的线程,导致这些线程竞争 CPU 和内存的访问权限。最后,如果它达到可配置的进程限制,它将拒绝新的连接。Apache 并不是为了作为代理服务而设计的。这个功能实际上是事后才加入的。
nginx 是为了解决 Apache 的一些问题而创建的,尤其是 C10K 问题。当时,C10K 对于 Web 服务器来说是一个挑战,要求能够处理一万个并发连接。nginx 于 2004 年发布,并达成了这一目标。与 Apache 不同,nginx 的架构基于异步、非阻塞、事件驱动架构。不仅如此,它在处理并发请求的数量上超过了 Apache,而且它的资源使用率也低得多。它是在 Apache 之后诞生的,从零开始设计,解决了并发问题。我们得到了一个能够处理更多请求、成本更低的服务器。
nginx 的缺点是它是为提供静态内容而设计的。如果你需要一个能够提供由 Java、PHP 及其他动态语言生成的内容的服务器,Apache 是更好的选择。在我们的情况下,这个缺点几乎不重要,因为我们只需要一个能够进行负载均衡和一些其他功能的代理服务。我们不会通过代理直接提供任何内容(无论是静态的还是动态的),而是将请求重定向到专门的服务。
总的来说,虽然在其他场景中 Apache 可能是一个不错的选择,但对于我们要完成的任务,nginx 显然是更好的选择。如果它的唯一任务是充当代理和负载均衡器,它的性能将远超 Apache。它的内存消耗非常少,并且能够处理大量的并发请求。至少,在我们考虑其他代理竞选者之前,这是我们的结论。
配置 nginx
在我们配置 nginx 代理服务之前,先快速浏览一下我们即将运行的 Ansible 文件。nginx.yml playbook 类似于我们之前使用的文件。我们将运行之前已经运行过的角色,并加上 nginx 角色:
- hosts: proxy
remote_user: vagrant
serial: 1
sudo: yes
roles:
- common
- docker
- docker-compose
- consul
- registrator
- consul-
oles/nginx/tasks/main.yml角色也没有包含什么特别的内容:
- name: Directories are present
file:
dest: "{{ item }}"
state: directory
with_items: directories
tags: [nginx]
- name: Container is running
docker:
image: nginx
name: nginx
state: running
ports: "{{ ports }}"
volumes: "{{ volumes }}"
tags: [nginx]
- name: Files are present
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
with_items: files
register: result
tags: [nginx]
- name: Container is reloaded
shell: docker kill -s HUP nginx
when: result|changed
tags: [nginx]
- name: Info is sent to Consul
uri:
url: http://localhost:8500/v1/kv/proxy/ip
method: PUT
body: "{{ ip }}"
ignore_errors: yes
tags: [nginx]
我们创建了几个目录,确保 nginx 容器正在运行,传送了一些文件,并且如果它们有变动,则重新加载 nginx。最后,我们将 nginx 的 IP 地址写入 Consul,以备后用。需要注意的唯一重要事项是 nginx 的配置文件roles /nginx/files/services.conf:
log_format upstreamlog
'$remote_addr - $remote_user [$time_local] '
'"$request" $status $bytes_sent '
'"$http_referer" "$http_user_agent" "$gzip_ratio" '
'$upstream_addr';
server {
listen 80;
server_name _;
access_log /var/log/nginx/access.log upstreamlog;
include includes/*.conf;
}
include upstreams/*.conf;
目前,你可以忽略日志格式,并跳到server规格说明。我们指定 nginx 应该监听标准 HTTP 端口80,并接受发送到任何服务器的请求(server_name _)。接下来是include语句。通过使用 include,我们可以为每个服务单独添加配置,而不是将所有配置集中在一个地方。这样,我们可以专注于一次配置一个服务,并确保我们部署的服务配置正确。稍后,我们将更深入地探讨这些 includes 中包含的不同类型的配置。
让我们运行 nginx playbook 并开始玩它。我们将进入cd节点并执行该 playbook,它将配置proxy节点:
vagrant ssh cd
ansible-playbook /vagrant/ansible/nginx.yml \
-i /v
agrant/ansible/hosts/proxy
没有代理的生活
在我们看到 nginx 正在工作之前,回顾一下没有代理服务时我们所面临的困难可能是值得的。我们将通过运行books-ms应用程序来开始:
wget https://raw.githubusercontent.com/vfarcic\/books-ms/master/docker-compose.yml
export DOCKER_HOST=tcp://proxy:2375
docker-compose up -d app
docker-compose ps
curl http://proxy/api/v1/books
上一条命令的输出如下:
<html>
<head><title>404 Not Found</title></head>
<body bgcolor="white">
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.9.9</center>
</body>
</html>
尽管我们使用docker-compose运行应用程序,并通过执行docker-compose ps确认它在proxy节点上运行,但通过curl我们发现服务无法在标准 HTTP 端口 80 上访问(通过 nginx 返回了404 Not Found消息)。这一结果是可以预期的,因为我们的服务运行在一个随机端口上。即使我们指定了端口(我们已经讨论过这样做是一个坏主意),也不能指望用户记住每个单独部署的服务的不同端口。而且,我们已经通过 Consul 实现了服务发现:
curl http://10.100.193.200:8500/v1/catalog/service/books-ms | jq '.'
上一条命令的输出如下:
[
{
"ModifyIndex": 42,
"CreateIndex": 42,
"Node": "proxy",
"Address": "10.100.193.200",
"ServiceID": "proxy:vagrant_app_1:8080",
"ServiceName": "books-ms",
"ServiceTags": [],
"ServiceAddress": "10.100.193.200",
"ServicePort": 32768,
"ServiceEnableTagOverride": false
}
]
我们还可以通过检查容器来获取端口:
PORT=$(docker inspect \
--format='{{(index (index .NetworkSettings.Ports "8080/tcp") 0).HostPort}}' \
vagrant_app_1)
echo $PORT
curl http://proxy:$PORT/api/v1/books | jq '.'
我们检查了容器,应用格式化操作以仅获取服务的端口,并将该信息存储在PORT变量中。之后,我们使用该变量向服务发出正确的请求。正如预期的那样,这次结果是正确的。由于没有数据,服务返回了一个空的 JSON 数组(这次没有出现 404 错误)。
尽管这次操作成功了,但对我们的用户来说,这样的方式是不可接受的。我们不能仅仅给他们访问服务器的权限,让他们查询 Consul 或检查容器以获取所需的信息。没有代理,服务是无法访问的。它们虽然在运行,但没人能使用它们:

图 9-1 – 没有代理的服务
现在我们已经感受到没有代理时用户会遇到的痛苦,接下来让我们正确配置 nginx。我们将从手动配置开始,然后逐步过渡到自动化配置。
手动配置 nginx
你还记得 nginx 配置中的第一个includes语句吗?让我们使用它。我们已经有了PORT变量,接下来我们要做的就是确保所有进入 nginx 端口80并以/api/v1/books地址开头的请求被重定向到正确的端口。我们可以通过运行以下命令来实现:
echo "
location /api/v1/books {
proxy_pass http://10.100.193.200:$PORT/api/v1/books;
}
" | tee books-ms.conf
scp books-ms.conf \
proxy:/data/nginx/includes/books-ms.conf # pass: vagrant
docker kill -s HUP nginx
我们创建了books-ms.conf文件,该文件将所有对/api/v1/books的请求代理到正确的 IP 和端口。location语句会匹配所有以/api/v1/books开头的请求,并将其代理到运行在指定 IP 和端口上的相同地址。虽然 IP 并不是必须的,但使用它是一种好习惯,因为在大多数情况下,代理服务会运行在单独的服务器上。接下来,我们使用安全复制(scp)将该文件传输到proxy节点的/data/nginx/includes/目录中。配置文件复制完成后,我们只需使用kill -s HUP命令重新加载 nginx:
让我们看看我们刚才所做的更改是否正确生效:
curl -H 'Content-Type: application/json' -X PUT -d \
"{\"_id\": 1,
\"title\": \"My First Book\",
\"author\": \"John Doe\",
\"description\": \"Not a very good book\"}" \
http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
我们成功地进行了一个 PUT 请求,将一本书插入数据库,并查询了返回相同书籍的服务。最后,我们可以在不担心端口问题的情况下发起请求。
我们的问题解决了吗?仅部分解决。我们仍然需要找到一种方法,使这些 nginx 配置的更新自动化。毕竟,如果我们将频繁部署微服务,我们不能依赖人工操作员持续监控部署并进行配置更新:

图 9-2 – 使用手动代理的服务
自动配置 nginx
我们已经讨论过服务发现工具,之前运行的 nginx 剧本确保 Consul、Registrator 和 Consul Template 在 代理 节点上得到了正确配置。这意味着 Registrator 检测到了我们运行的服务容器,并将该信息存储到了 Consul 注册表中。剩下的就是创建一个模板,将其传递给 Consul Template,后者将输出配置文件并重新加载 nginx。
让我们让情况变得更复杂一些,通过运行两个实例来扩展我们的服务。使用 Docker Compose 进行扩展相对简单:
docker-compose scale app=2
docker-compose ps
后一个命令的输出如下:
Name Command State Ports
-----------------------------------------------------------------------
vagrant_app_1 /run.sh Up 0.0.0.0:32768->8080/tcp
vagrant_app_2 /run.sh Up 0.0.0.0:32769->8080/tcp
vagrant_db_1 /entrypoint.sh mongod Up 27017/tcp
我们可以观察到,我们的服务有两个实例,分别使用不同的随机端口。对于 nginx 来说,这意味着几件事,其中最重要的一点是我们不能像以前那样进行代理。运行两个实例并将所有请求仅重定向到其中一个实例是没有意义的。我们需要将代理与 负载 均衡 结合起来。
我们不会深入探讨所有可能的负载均衡技术。相反,我们将使用最简单的技术——轮询,它是 nginx 默认使用的。轮询意味着代理将均等地分配请求到所有服务之间。如前所述,项目中密切相关的内容应该与代码一起存储在仓库中,nginx 配置文件和模板也不应例外。看一下 nginx-includes.conf 配置文件:
location /api/v1/books {
proxy_pass http://books-ms/api/v1/books;
proxy_next_upstream error timeout invalid_header http_500;
}
这次,我们不是指定 IP 和端口,而是使用 books_ms。显然,这个域名并不存在。它是我们告诉 nginx 将所有来自该位置的请求代理到上游的一种方式。此外,我们还添加了 proxy_next_upstream 指令。如果服务响应返回错误、超时、无效的头部或错误 500,nginx 将转发请求到下一个上游连接。
这时我们可以开始使用主配置文件中的第二个包含语句。然而,由于我们不知道服务将使用的 IP 和端口,上游就是 Consul Template 文件 nginx-upstreams.ctmpl:
upstream books-ms {
{{range service "books-ms" "any"}}
server {{.Address}}:{{.Port}};
{{end}}
}
这意味着我们设置的上游请求 books-ms 会在该服务的所有实例之间进行负载均衡,并且数据将从 Consul 中获取。我们运行 Consul Template 后就能看到结果。
首先,下载我们刚刚讨论的两个文件:
wget http://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-includes.conf
wget http://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-upstreams.ctmpl
现在,代理配置和上游模板已经放置在cd服务器上,我们应该运行 Consul 模板:
consul-template \
-consul proxy:8500 \
-template "nginx-upstreams.ctmpl:nginx-upstreams.conf" \
-once
cat nginx-upstreams.conf
Consul 模板将下载的模板作为输入,并创建了books-ms.conf上游配置。第二个命令的输出应该类似于以下内容:
upstream books-ms {
server 10.100.193.200:32768;
server 10.100.193.200:32769;
}
由于我们运行的是两个相同服务的实例,Consul 模板获取了它们的 IP 和端口,并将其放入我们在books-ms.ctmpl模板中指定的格式。
请注意,我们本可以将第三个参数传递给 Consul 模板,它会运行我们指定的任何命令。我们将在本书后续章节中使用它。
现在所有配置文件已经创建完成,我们应该将它们复制到proxy节点并重新加载 nginx:
scp nginx-includes.conf \
proxy:/data/nginx/includes/books-ms.conf # Pass: vagrant
scp nginx-upstreams.conf \
proxy:/data/nginx/upstreams/books-ms.conf # Pass: vagrant
docker kill -s HUP nginx
剩下的就是再次确认代理是否正常工作,并且在这两个实例之间正确地负载均衡请求:
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
docker logs nginx
在进行了四次请求后,我们输出了 nginx 日志,日志应该如下所示(时间戳已去除以简化显示)。
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32768
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32769
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32768
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32769
虽然端口在你的情况下可能不同,但显然第一个请求被发送到了端口32768,接下来的请求发送到32769,然后又回到32768,最后又发送到32769。这是成功的,nginx 不仅充当了代理,还在我们部署的所有服务实例之间进行了负载均衡:

图 9-3 – 使用 Consul 模板自动配置代理的服务
我们仍然没有测试使用proxy_next_upstream指令设置的错误处理。让我们移除一个服务实例,并确认 nginx 是否正确处理故障:
docker stop vagrant_app_2
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
我们停止了一个服务实例并进行了几次请求。如果没有proxy_next_upstream指令,nginx 会在每第二个请求时失败,因为设置为上游的两个服务之一已经无法工作了。然而,所有四次请求都正确地处理了。我们可以通过查看 nginx 日志来观察它的行为:
docker logs nginx
输出应该类似于以下内容(时间戳已去除以简化显示):
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32768
[error] 12#12: *98 connect() failed (111: Connection refused) while connecting to upstream, client: 172.17.42.1, server: _, request: "GET /api/v1/books HTTP/1.1", upstream: "http://10.100.193.200:32769/api/v1/books", host: "localhost"
[warn] 12#12: *98 upstream server temporarily disabled while connecting to upstream, client: 172.17.42.1, server: _, request: "GET /api/v1/books HTTP/1.1", upstream: "http://10.100.193.200:32768/api/v1/books", host: "localhost"
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32768, 10.100.193.200:32768
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32768
"GET /api/v1/books HTTP/1.1" 200 268 "-" "curl/7.35.0" "-" 10.100.193.200:32768
第一个请求被发送到了由仍在运行的实例提供服务的端口32768。正如预期,nginx 将第二个请求发送到了端口32768。由于响应是111(连接被拒绝),它决定暂时禁用这个上游,并尝试下一个上游。之后,所有的请求都被代理到端口32768。
只需在配置文件中添加几行,我们就成功设置了代理,并将其与负载均衡和故障转移策略结合起来。稍后,当我们进入探讨自愈系统的章节时,我们将进一步深入,确保代理不仅仅与运行中的服务工作,还会恢复整个系统到健康状态。
当 nginx 与服务发现工具结合使用时,我们有了一个优秀的解决方案。然而,我们不应使用第一个遇到的工具,因此我们会评估更多的选项。让我们停止 nginx 容器,看看HAProxy的表现:
docker stop nginx
HAProxy
与 nginx 一样,HAProxy 是一个免费的、非常快速和可靠的解决方案,提供高可用性、负载均衡和代理功能。它特别适合高流量网站,并为世界上许多最受欢迎的网站提供服务。
我们稍后会讨论所有代理解决方案的比较时的差异。目前,只需说明 HAProxy 是一个优秀的解决方案,可能是 nginx 最好的替代品。
我们将从实际操作开始,并尝试使用 HAProxy 实现与 nginx 相同的行为。在为 proxy 节点配置 HAProxy 之前,让我们快速查看 Ansible 角色 haproxy 中的任务:
- name: Directories are present
file:
dest: "{{ item }}"
state: directory
with_items: directories
tags: [haproxy]
- name: Files are present
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
with_items: files
register: result
tags: [haproxy]
- name: Container is running
docker:
image: million12/haproxy
name: haproxy
state: running
ports: "{{ ports }}"
volumes: /data/haproxy/config/:/etc/haproxy/
tags: [haproxy]
haproxy 角色与我们为 nginx 使用的非常相似。我们创建了一些目录并复制了一些文件(稍后我们会看到它们)。需要注意的主要一点是,与我们没有构建的其他大多数容器不同,我们没有使用官方的 haproxy 容器。主要原因是官方镜像没有办法重新加载 HAProxy 配置。每次更新 HAProxy 配置时,我们都需要重启容器,而这会导致停机时间。由于我们的目标之一是实现零停机,因此重启容器并不是一个可选方案。因此,我们不得不寻找替代方案,用户 million12 恰好提供了我们所需要的。million12/haproxy 容器自带 inotify(inode notify)。它是一个 Linux 内核子系统,作用是扩展文件系统以检测变化并将其报告给应用程序。在我们的案例中,每当我们更改 HAProxy 配置时,inotify 会重新加载 HAProxy:
让我们继续在代理节点上配置 HAProxy:
ansible-playbook /vagrant/ansible/haprox
y.yml \
-i /vagrant/ansible/hosts/proxy
手动配置 HAProxy
我们首先检查 HAProxy 是否在运行:
export DOCKER_HOST=tcp://proxy:2375
docker ps -a
docker logs haproxy
docker ps 命令显示 haproxy 容器的状态为 Exited,日志输出类似于以下内容:
[2015-10-16 08:55:40] /usr/local/sbin/haproxy -f /etc/haproxy/haproxy.cfg -D -p /var/run/haproxy.pid
[2015-10-16 08:55:40] Current HAProxy config /etc/haproxy/haproxy.cfg:
====================================================================================================
cat: /etc/haproxy/haproxy.cfg: No such file or directory
====================================================================================================
[ALERT] 288/085540 (9) : Could not open configuration file /etc/haproxy/haproxy.cfg : No such file or directory
[ALERT] 288/085540 (10) : Could not open configuration file /etc/haproxy/haproxy.cfg : No such file or directory
HAProxy 报告没有 haproxy.cfg 配置文件,进程停止了。实际上,问题出在我们运行的 playbook 上。我们创建的唯一文件是 haproxy.cfg.orig(稍后会详细介绍),而没有 haproxy.cfg 文件。与 nginx 不同,HAProxy 不能在没有至少一个代理设置的情况下运行。我们很快就会设置第一个代理,但目前我们还没有。由于在没有任何代理的情况下创建配置是浪费时间(HAProxy 无论如何都会失败),而在第一次配置节点时我们也无法提供一个代理,因为到那个时候没有服务在运行,我们因此跳过了 haproxy.cfg 的创建。
在继续配置第一个代理之前,让我们先提到另一个可能会使过程复杂化的差异。与 nginx 不同,HAProxy 不允许使用 include。完整的配置需要在一个文件中。这将带来一些问题,因为我们的目标是仅添加或修改我们正在部署的服务的配置,忽略系统的其他部分。不过,我们可以通过将配置的部分内容创建为独立的文件,并在每次部署新容器时将它们连接起来,从而模拟 include。正因如此,我们在配置过程中复制了 haproxy.cfg.orig 文件。随时可以查看它。我们不会详细说明,因为它主要包含默认设置,并且 HAProxy 有一份不错的文档供你查阅。需要注意的重要一点是,haproxy.cfg.orig 文件包含的是没有设置任何代理的配置。
我们将以与之前类似的方式创建与我们运行的服务相关的 HAProxy 配置:
PORT=$(docker inspect \
--format='{{(index (index .NetworkSettings.Ports "8080/tcp") 0).HostPort}}' \
vagrant_app_1)
echo $PORTecho "
frontend books-ms-fe
bind *:80
option http-server-close
acl url_books-ms path_beg /api/v1/books
use_backend books-ms-be if url_books-ms
backend books-ms-be
server books-ms-1 10.100.193.200:$PORT check
" | tee books-ms.service.cfg
我们首先检查了 vagrant_app_1 容器,以便将当前端口分配给 PORT 变量,并使用它来创建 books-ms.service.cfg 文件。
虽然 HAProxy 与 nginx 使用的名称不同,但其逻辑是类似的。frontend 定义了如何将请求转发到 backends。某种程度上,frontend 类似于 nginx 的 location 指令,backend 则类似于 upstream。我们所做的可以转化为以下配置。定义一个名为 books-ms-fe 的 frontend,将其绑定到端口 80,每当请求的路径以 /api/v1/books 开头时,使用名为 books-ms-be 的 backend。当前,backend books-ms-be 只定义了一个服务器,IP 为 10.100.193.200,端口由 Docker 分配。check 参数(或多或少)与 nginx 中的含义相同,用于跳过对不健康服务的代理。
现在我们已经有了 haproxy.cfg.orig 文件中的通用设置以及针对我们正在部署的服务的特定设置(以 .service.cfg 扩展名命名),我们可以将它们连接成一个单一的 haproxy.cfg 配置文件,并将其复制到 proxy 节点:
cat /vagrant/ansible/roles/haproxy/files/haproxy.cfg.orig \
*.service.cfg | tee haproxy.cfg
scp haproxy.cfg proxy:/data/haproxy/config/haproxy.cfg
由于容器没有运行,我们需要启动它(再次),然后可以通过查询服务来检查代理是否正常工作:
curl http://proxy/api/v1/books | jq '.'
docker start haproxy
docker logs haproxy
curl http://proxy/api/v1/books | jq '.'
第一个请求返回了 Connection refused 错误。我们利用这个错误确认没有代理在运行。然后我们启动了 haproxy 容器,并通过容器日志看到我们创建的配置文件是有效的,确实被代理服务使用了。最后,我们再次发送请求,这次返回了有效的响应。
到目前为止,一切顺利。我们可以继续并使用 Consult Template 来自动化这一过程。
自动配置 HAProxy
我们将尝试执行与之前在 nginx 上所做的相同或非常相似的步骤。这样,你可以更容易地比较这两个工具。
我们将从扩展服务开始:
docker-compose scale
compose ps
接下来我们应该从代码仓库中下载haproxy.ctmpl模板。在我们操作之前,让我们快速查看一下它的内容:
frontend books-ms-fe
bind *:80
option http-server-close
acl url_books-ms path_beg /api/v1/books
use_backend books-ms-be if url_books-ms
backend books-ms-be
{{range service "books-ms" "any"}}
server {{.Node}}_{{.Port}} {{.Address}}:{{.Port}} check
{{end}}
我们创建模板的方式遵循了与 nginx 相同的模式。唯一的不同是,HAProxy 需要每个服务器都有唯一标识,因此我们添加了服务Node和Port,作为服务器 ID。
让我们下载模板并通过 Consul Template 运行它:
wget http://raw.githubusercontent.com/vfarcic\
/books-ms/master/haproxy.ctmpl \
-O haproxy.ctmpl
sudo consul-template \
-consul proxy:8500 \
-template "haproxy.ctmpl:books-ms.service.cfg" \
-once
cat books-ms.service.cfg
我们使用wget下载了模板,并运行了consul-template命令。
让我们将所有文件连接成 haproxy.cfg,复制到proxy节点,并查看haproxy日志:
cat /vagrant/ansible/roles/haproxy/files/haproxy.cfg.orig \
*.service.cfg | tee haproxy.cfg
scp haproxy.cfg proxy:/data/haproxy/config/haproxy.cfg
docker logs haproxy
curl http://proxy/api/v1/books | jq '.'
剩下的就是仔细检查负载均衡是否在两个实例下正常工作:
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
不幸的是,HAProxy 无法将日志输出到 stdout(Docker 容器首选的日志输出方式),因此我们无法确认负载均衡是否正常工作。我们可以将日志输出到 syslog,但这超出了本章的讨论范围。
我们仍然没有测试我们通过backend指令设置的错误处理。让我们移除一个服务实例,并确认 HAProxy 是否能正确处理故障:
docker stop vagrant_app_1
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
curl http://proxy/api/v1/books | jq '.'
我们停止了一个服务实例,并进行了几次请求,所有请求都正常工作。
由于无法将文件包含到 HAProxy 配置中,我们的工作稍微复杂了一些。无法将日志输出到 stdout 的问题可以通过 syslog 解决,但这会偏离容器最佳实践之一。HAProxy 这种行为是有原因的。日志输出到 stdout 会拖慢它的速度(只有在大量请求时才会明显)。然而,如果可以将此作为我们的选择,甚至作为默认行为,而不是完全不支持,可能会更好。最后,无法使用官方的 HAProxy 容器可能被认为是一个小小的不便。这些问题并不重大。我们解决了缺少 includes 的问题,可以将日志输出到 syslog,并最终使用了来自million12/haproxy的容器(我们也可以创建一个自己的容器,基于官方容器进行扩展)。
代理工具对比
Apache、nginx 和 HAProxy 绝不是我们可以使用的唯一解决方案。现在有许多项目可供选择,做出决策比以往任何时候都要困难。值得尝试的开源项目之一是lighttpd(发音为 lighty)。与 nginx 和 HAProxy 一样,它也设计用于安全性、速度、合规性、灵活性和高性能。它具有小巧的内存占用和高效的 CPU 负载管理。
如果 JavaScript 是你喜欢的语言,[node-http-proxy]可能是一个值得考虑的候选工具。与我们探索的其他产品不同,node-http-proxy 使用 JavaScript 代码来定义代理和负载均衡。
VulcanD 是一个值得关注的项目。它是一个由 etcd 支持的可编程代理和负载均衡器。我们与 Consul Template 和 nginx/HAProxy 的相似过程也被 VulcanD 采用。它可以与 Sidekick 结合,提供类似于 nginx 和 HAProxy 中的check参数的功能。
有许多类似的项目可供选择,而且新旧项目都在不断制作中。我们可以预见到会有更多非传统项目出现,它们将在许多不同的方式中结合代理、负载均衡和服务发现。
然而,目前为止,我的选择依然是 nginx 或 HAProxy。我们讨论的其他产品都没有什么可添加的优点,反而每个产品至少存在一个缺点。
Apache 是基于进程的,这使得它在面对大量流量时的表现不太理想。与此同时,它的资源使用会迅速飙升。如果你需要一个可以提供动态内容的服务器,Apache 是一个很好的选择,但不应作为代理使用。
Lighttpd 刚推出时很有前景,但遇到了许多障碍(内存泄漏、CPU 使用率过高等),导致部分用户转向其他替代品。它的维护社区远小于 nginx 和 HAProxy 的社区。尽管它曾经有过一段辉煌时光,许多人对它寄予了很高的期望,但如今它已不再是推荐的解决方案。
那么,关于node-http-proxy可以说些什么呢?尽管它不如 nginx 和 HAProxy 强大,但它非常接近。主要的障碍在于它的可编程配置,这对于需要频繁变化的代理并不太合适。如果你的编程语言选择是 JavaScript,且代理需要相对静态,node-http-proxy 是一个有效的选择。然而,它仍然没有提供比 nginx 和 HAProxy 更大的优势。
VulcanD 与 Sidekick 配合使用是一个值得关注的项目,但它还没有达到生产环境的准备状态(至少在本文撰写时尚未就绪)。它很可能无法超越主要竞争者。VulcanD 的潜在问题是它捆绑了 etcd。如果你已经在使用 etcd,那就太好了。另一方面,如果你选择了其他类型的注册表(例如 Consul 或 Zookeeper),那么 VulcanD 就无法提供任何帮助。我更倾向于将代理和服务发现分开,并自己将它们连接起来。VulcanD 真正提供的价值在于它结合代理服务和服务发现的新思路,它可能会被视为开辟新型代理服务的大门之一。
那么,剩下的就是 nginx 和 HAProxy 了。如果你多花点时间调查各方意见,你会发现两边都有大量用户支持各自的优点。有些领域中 nginx 优于 HAProxy,而另一些领域中则相反。HAProxy 缺少某些功能,而 nginx 也有缺失。然而,事实是,二者都经过了严酷的实战检验,都是极好的解决方案,都有庞大的用户基础,且都在流量巨大的公司中成功使用。如果你正在寻找一个具有负载均衡功能的代理服务,那么选择它们任何一个都不会错。
我稍微倾向于使用 nginx,因为它有更好的(官方)Docker 容器(例如,它允许通过 HUP 信号重新加载配置)、能够将日志输出到 stdout,并且支持包含配置文件。排除 Docker 容器外,HAProxy 做出了有意识的决定,不支持这些功能,因为它们可能会带来性能问题。然而,我更喜欢在适当的时候能够选择使用这些功能,而在不适合的时候不使用。所有这些其实都只是些无关紧要的偏好,在许多情况下,选择依据是具体的使用场景。不过,有一个关键的 nginx 特性是 HAProxy 不支持的。HAProxy 在重新加载时可能会丢失流量。如果采用微服务架构、持续部署和蓝绿部署过程,配置重新加载是非常常见的。我们每天可能会有几次甚至几百次重新加载。无论重新加载的频率如何,使用 HAProxy 时都可能会出现停机时间。
我们必须做出选择,而它最终选择了 nginx。它将是本书余下部分中我们选择的代理。
话虽如此,让我们销毁本章中使用的虚拟机,并完成部署管道的实现。通过服务发现和代理,我们已经拥有了一切所需的工具:
exit
vagrant destroy -f
第十章. 部署管道的实施 – 后期阶段
我们不得不中断部署管道的实施并探索服务发现和代理服务。如果没有代理服务,我们的容器将无法以简单和可靠的方式访问。为了提供所有数据代理服务需要的数据,我们花了一些时间探索不同的选项,并提出了几种可能作为服务发现解决方案的组合。
带着服务发现和代理服务的工具,我们可以继续上次离开的地方,并完成部署管道的手动执行:
-
检查代码 – 完成
-
运行部署前测试 – 完成
-
编译和/或打包代码 – 完成
-
构建容器 – 完成
-
将容器推送到注册表 – 完成
-
部署容器到生产服务器 – 完成
-
集成容器 – 待完成
-
运行部署后测试 – 待完成
-
推送测试容器到注册表 – 待完成
图 10-1 – 使用 Docker 的部署管道的中间阶段
我们的部署管道缺少三个步骤。我们应该集成我们的容器,并且一旦完成,运行部署后测试。最后,我们应该将我们的测试容器推送到注册表,以便所有人都可以使用它。
我们将从启动我们用于部署管道的两个节点开始:
vagrant up cd prod
我们将使用 prod2.y ml Ansible playbook 来配置 prod 节点。它包含我们在前一章中已经讨论过的服务发现和代理角色:
- hosts: prod
remote_user: vagrant
serial: 1
sudo: yes
roles:
- common
- docker
- docker-compose
- consul
- registrator
- consul-template
- nginx
一旦运行,我们的 prod 节点将会运行 Consul、Registrator、Consul Template 和 nginx。它们将允许我们将所有请求代理到它们目标服务(目前只有 books-ms)。让我们从 cd 节点运行 playbook。
vagrant ssh cd
ansible-playbook /vagrant/ansible/prod2.yml \
-i /vagrant/ansible/hosts/pro
d
启动容器
在我们继续集成之前,我们应该运行这些容器:
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/docker-compose.yml
export DOCKER_HOST=tcp://prod:2375
docker-compose up -d app
由于我们使用 Consul 和 Registrator 配置了这个节点,这两个容器中的 IP 和端口应该在注册表中可用。我们可以通过在浏览器中打开 http://10.100.198.201:8500/ui 访问 Consul UI 来确认这一点。
如果我们点击 Nodes 按钮,我们可以看到 prod 节点已注册。更进一步,点击 prod 节点按钮应该会显示它包含两个服务;consul 和 books-ms。我们启动的 mongo 容器没有注册,因为它没有暴露任何端口:
启动容器 – 完成
图 10-2 – 带有生产节点和运行在其上的服务的 Consul 截图
发送请求到 Consul,可以看到相同的信息:
curl prod:8500/v1/catalog/services | jq '.'
curl prod:8500/v1/catalog/service/books-ms | jq '.'
第一个命令列出了 Consul 中注册的所有服务。输出如下:
{
"dockerui": [],
"consul": [],
"books-ms": []
}
第二个命令输出与 books-ms 服务相关的所有信息:
[
{
"ModifyIndex": 27,
"CreateIndex": 27,
"Node": "prod",
"Address": "10.100.198.201",
"ServiceID": "prod:vagrant_app_1:8080",
"ServiceName": "books-ms",
"ServiceTags": [],
"ServiceAddress": "10.100.198.201",
"ServicePort": 32768,
"ServiceEnableTagOverride": false
}
]
在容器启动并运行,且其信息已存储在服务注册表中后,我们可以重新配置 nginx,使得 books-ms 服务可以通过标准 HTTP 端口 80 进行访问。
集成服务
我们将从确认 nginx 并不认识我们的服务开始:
curl http://prod/api/v1/books
发送请求后,nginx 返回了 404 Not Found 消息。我们来修改一下:
Exit
vagrant ssh prod
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-includes.conf \
-O /data/nginx/includes/books-ms.conf
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-upstreams.ctmpl \
-O /data/nginx/upstreams/books-ms.ctmpl
consul-template \
-consul localhost:8500 \
-template "/data/nginx/upstreams/books-ms.ctmpl:\
/data/nginx/upstreams/books-ms.conf:\
docker kill -s HUP nginx" \
-once
我们已经在上一章中完成了大部分步骤,因此这次我们将简要介绍。我们进入了 prod 节点,并从代码仓库下载了包含文件和上游模板。然后,我们运行了 consul-template,它从 Consul 获取数据并应用到模板中,结果就是 nginx 上游配置文件。请注意,这一次,我们添加了第三个参数 docker kill -s HUP nginx。不仅 consul-template 从模板中创建了配置文件,它还重新加载了 nginx。之所以从 prod 服务器运行这些命令,而不是像上一章那样远程操作,是因为自动化的原因。我们刚刚执行的步骤更接近于我们将在下一章中自动化这一部分过程的方式。
现在我们可以测试我们的服务是否确实可以通过端口 80 进行访问:
exit
vagrant ssh cd
curl -H 'Content-Type: application/json' -X PUT -d \
"{\"_id\": 1,
\"title\": \"My First Book\",
\"author\": \"John Doe\",
\"description\": \"Not a very good book\"}" \
http://prod/api/v1/books | jq '.'
curl http://prod/api/v1/books | jq
'.'
运行部署后测试
尽管我们确实通过发送请求并观察正确的响应来确认服务可以从 nginx 访问,但如果我们试图实现过程的完全自动化,这种验证方式并不可靠。相反,我们应该重复执行集成测试,但这次使用端口 80(或者不指定端口,因为 80 是标准的 HTTP 端口):
git clone https://github.com/vfarcic/books-ms.git
cd books-ms
docker-compose \
-f docker-compose-dev.yml \
run --rm \
-e DOMAIN=http://10.100.198.201 \
integ
输出如下:
[info] Loading project definition from /source/project
[info] Set current project to books-ms (in build file:/source/)
[info] Compiling 2 Scala sources to /source/target/scala-2.10/classes...
[info] Compiling 2 Scala sources to /source/target/scala-2.10/test-classes...
[info] ServiceInteg
[info]
[info] GET http://10.100.198.201/api/v1/books should
[info] + return OK
[info]
[info] Total for specification ServiceInteg
[info] Finished in 23 ms
[info] 1 example, 0 failure, 0 error
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 27 s, completed Sep 17, 2015 7:49:28 PM
正如预期的那样,输出显示集成测试成功通过。事实上,我们只有一个测试,它发出了与之前运行的 curl 命令相同的请求。然而,在“真实世界”情况下,测试的数量会增加,使用适当的测试框架比运行 curl 请求要可靠得多。
将测试容器推送到注册表
说实话,我们已经将这个容器推送到了注册表,以避免每次需要它时重新构建,从而节省等待时间。然而,这一次,我们应该将它作为部署管道过程的一部分进行推送。我们尝试按任务的重要性顺序运行,以便尽快获得反馈。将包含测试的容器推送是我们优先级列表中的低项,因此我们将其留到最后。现在,其他一切都顺利运行后,我们可以推送容器,让其他人从注册表中拉取并按需使用。
docker push 10.100.198.200:5000/books-
ms-tests
检查清单
我们成功完成了整个部署流程。由于我们需要休息几次并探索不同的方式,所以花费了相当多的时间。我们无法在不探索配置管理概念和工具的情况下部署到生产环境。后来,我们再次遇到瓶颈,必须学习服务发现和代理,才能成功集成服务容器:
-
检出代码 – 完成
-
运行部署前的测试 – 完成
-
编译和/或打包代码 – 完成
-
构建容器 – 完成
-
将容器推送到注册表 – 完成
-
将容器部署到生产服务器 – 完成
-
运行部署后的测试 – 完成
-
将测试容器推送到注册表 – 完成!检查清单
图 10-3 – 使用 Docker 部署流程的后期阶段
现在我们已经准备好。我们能够手动执行部署过程。下一步是将所有这些命令自动化,并开始从头到尾自动运行整个流程。我们将销毁当前使用的节点,以便重新开始并确认自动化过程是否确实有效:
exit
vagrant destroy -f
第十一章:自动化部署流水线的实现
现在我们已控制手动执行部署流水线的过程,可以开始着手创建一个完全自动化的版本。毕竟,我们的目标并不是雇佣一支操作员队伍,让他们坐在电脑前不停地执行部署命令。在继续之前,让我们再快速回顾一下这个过程。
部署流水线步骤
流水线的步骤如下:
-
检出代码
-
运行预部署测试,编译并打包代码
-
构建容器
-
将容器推送到注册表
-
将容器部署到生产服务器
-
集成容器
-
运行后部署测试
-
将测试容器推送到注册表!部署流水线步骤
图 11-1 – 部署流水线
为了尽量减少流水线对我们业务的影响,我们尽最大努力将尽可能多的任务放在生产服务器之外执行。我们只在prod节点上执行了两个步骤:部署本身和集成(目前仅与代理服务集成)。其他所有步骤都在cd服务器上完成:

图 11-2 – CD 节点与生产节点之间的任务分配
我们已经选择了 Ansible 作为用于服务器配置的工具。我们在多个场合使用它来安装软件包、设置配置等。到目前为止,所有这些使用都旨在提供部署我们容器所需的所有要求。我们将扩展 Ansible playbook 的使用,并将部署流水线添加进去:

图 11-3 – 使用 Ansible 的自动化部署流水线
在所有涉及的步骤中,我们将只把其中一个步骤排除在自动化范围之外。我们不会使用 Ansible 来检出代码。这样做的原因不是 Ansible 无法克隆 Git 仓库,事实上它是可以的。问题在于,Ansible 并不是一个设计用来持续运行并监控代码仓库变化的工具。还有一些我们尚未处理的问题。例如,在过程失败的情况下,我们没有一套应该执行的操作。当前流水线的另一个问题是,每次部署都会有短暂的停机时间。该过程会停止当前运行的版本并启动新版本。在这两个动作之间,有一个(短暂的)时期,我们正在部署的服务不可用。
我们将把这些以及其他可能的改进留到以后再做:

图 11-4 – 部署流水线中缺失的部分
为了更好地理解这个过程,我们将回顾我们之前执行的每个手动步骤,并看看如何通过 Ansible 完成这些步骤。
我们将从创建节点和克隆代码开始:
vagrant up cd prod
vagrant ssh cd
git clone https://github.com/vfarcic/books-ms.git
Playbook 和 Role
如果你已经尝试过自动化部署,那么你创建的脚本很可能大部分都与部署本身有关。使用 Ansible(以及一般的 CM 工具),我们可以选择每次从头开始执行整个过程。我们不仅会自动化部署,还会配置整个服务器。我们无法确定服务器的当前状态。例如,服务器可能已经安装了 nginx,也可能没有。也许它曾经运行着 nginx 容器,但由于某种原因,它的进程停止了。即使进程正在运行,某些关键配置也可能已经改变。相同的逻辑可以应用于任何与我们想要部署的服务直接或间接相关的内容。我们采用的方法是,拥有一个 playbook,确保所有内容都配置正确。Ansible 足够智能,能够检查所有这些依赖项的状态,并且仅在出现问题时才会应用更改。
让我们看一下 se rvice.yml playbook:
- hosts: prod
remote_user: vagrant
serial: 1
sudo: yes
roles:
- common
- docker
- docker-compose
- consul
- registrator
- consul-template
- nginx
- service
service 角色将包含与部署直接相关的任务,而在它之前的所有任务都是我们的服务正常运行所需的依赖项。由于我们已经完成了该 playbook 中除了最后一个角色之外的所有部分,因此我们应该直接跳到 roles/service/tasks /main.yml 文件中定义的任务列表:
- include: pre-deployment.yml
- include: deployment.yml
- include: post-deployment.yml
由于这个角色将比我们之前使用的角色稍大,我们决定将它们拆分为逻辑组(预部署、部署 和 后部署),并将它们包含到 main.yml 文件中。这样我们就不会一次处理太多任务,并且可以提高角色的可读性。
预部署任务
我们应该做的第一件事是构建测试容器。我们已经使用了以下命令(请不要运行它):
docker pull \
-t 10.100.198.200:5000/books-ms-tests
docker build \
-t 10.100.198.200:5000/books-ms-tests \
-f Dockerfile.test \
.
在 Ansible 中复制相同的命令非常简单,使用 Sh ell module:
- name: Tests container is pulled
shell: docker pull \
{{ registry_url }}{{ service_name }}-tests
delegate_to: 127.0.0.1
ignore_errors: yes
tags: [service, tests]
- name: Tests container is built
shell: docker build \
-t {{ registry_url }}{{ service_name }}-tests \
-f Dockerfile.test \
.
args:
chdir: "{{ repo_dir }}"
delegate_to: 127.0.0.1
tags: [service, tests]
我们修改了命令本身,将可能会发生变化的部分作为变量使用。第一个变量是registry_url,它应该包含 Docker 注册表的 IP 和端口。默认值在grou p_vars/all文件中指定。第二个变量更有趣。我们创建这个角色的目的不是为了与服务books-ms配合工作,而是为了能够与(几乎)任何服务一起使用,因为所有服务都可以遵循相同的模式。我们可以做这些事情而不牺牲自由,因为关键指令存储在每个服务的代码库中的几个文件里。最重要的文件是Dockerfile.test和Dockerfile,它们定义了测试和服务容器,Docker Compose 配置定义了容器如何运行,最后是代理配置和模板。所有这些文件都与我们正在创建的过程分开,项目负责人有完全的自由根据需要定制它们。这展示了我想要推广的一个非常重要的方面。确保不仅有正确的流程,而且脚本、配置和代码位置合适是至关重要的。所有对多个项目通用的内容应集中存储(就像位于github.com/vfarcic/ms-lifecycle仓库中的 Ansible playbooks)。另一方面,可能特定于某个项目的内容应存储在该项目所在的仓库中。如果把所有东西都存储在一个集中的地方,会引入大量等待时间,因为项目团队需要向交付团队请求更改。另一种极端情况同样错误。如果所有东西都存储在项目的仓库中,会产生大量重复。每个项目都需要编写脚本来设置服务器、部署服务等。
接下来我们指定了一个单一的参数chdir。它将确保命令从包含Dockerfile.test文件的目录运行。在这种情况下,chdir的值是变量repo_dir,与registry_url不同,它没有默认值。我们将在运行 playbook 时在运行时指定它。然后是delegate_to指令。由于我们致力于尽量减少对目标服务器的干扰,像这样的任务将会在本地主机(127.0.0.1)上运行。最后,我们设置了几个标签,可以用来筛选哪些任务会被执行,哪些不会。
在构建之前拉取测试容器的原因是为了节省时间。执行 playbook 在不同服务器上的表现可能有所不同,如果发生这种情况,如果不首先从注册中心拉取容器,Docker 将会重新构建所有层,即使大部分层很可能和之前一样。请注意,我们引入了ignore_errors指令。没有它,如果这是容器的第一次构建且没有可拉取的内容,playbook 会失败。
请记住,在大多数情况下应该避免使用shell模块。Ansible 的理念是指定期望的行为,而不是要执行的动作。一旦运行该行为,Ansible 将尝试执行正确的操作。例如,如果我们指定某个软件包应该被安装,Ansible 会检查该软件包是否已经存在,只有在不存在的情况下才会进行安装。我们使用的 shell 模块,在这种情况下,将始终运行,无论系统状态如何。在这个特定情况下,这是可以接受的,因为 Docker 本身会确保只构建发生变化的层。它不会每次都构建整个容器。在设计角色时请牢记这一点。
我们在预部署阶段使用的其余命令如下(请不要运行它们):
docker-compose -f docker-compose-dev.yml \
run --rm tests
docker pull 10.100.198.200:5000/books-ms
docker build -t 10.100.198.200:5000/books-ms .
docker push 10.100.198.200:5000/books-ms
当转换为 Ansible 格式时,结果如下:
- name: Pre-deployment tests are run
shell: docker-compose \
-f docker-compose-dev.yml \
run --rm tests
args:
chdir: "{{ repo_dir }}"
delegate_to: 127.0.0.1
tags: [service, tests]
- name: Container is built
shell: docker build \
-t {{ registry_url }}{{ service_name }} \
.
args:
chdir: "{{ repo_dir }}"
delegate_to: 127.0.0.1
tags: [service]
- name: Container is pushed
shell: docker push \
{{ registry_url }}{{ service_name }}
delegate_to: 127.0.0.1
tags: [service]
对于这些任务没什么可说的。它们都使用 shell 模块,并且都在本地主机上运行。我们运行的测试容器,除了检查代码质量的显而易见功能外,还会编译服务。该编译结果将用于构建服务容器,之后推送到 Docker 注册表。
最终结果可以在roles/service/tasks/pre- deployment.yml 文件中看到,我们可以继续进行部署任务。
部署任务
我们在手动运行部署流水线时执行的下一组命令,目的是创建所需的目录和文件。它们如下(请不要运行它们)。
mkdir -p /data/books-ms
cd /data/books-ms
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/docker-compose.yml
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-includes.conf \
-O /data/nginx/includes/books-ms.conf
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-upstreams.ctmpl \
-O /data/nginx/upstreams/books-ms.ctmpl
我们创建了服务目录,并从代码库中下载了docker-compose.yml、nginx-includes.conf 和 nginx-upstreams.ctmpl 文件。后两个文件将在修改代理时再下载,但我们可以将它们作为一个单独的 Ansible 任务一起处理。使用 Ansible 时,我们会稍微有所不同。由于我们已经检出了代码,因此没有理由重新下载这些文件。我们可以直接将它们复制到目标服务器。复制这组命令的 Ansible 任务如下:
- name: Directory is created
file:
path: /data/{{ service_name }}
recurse: yes
state: directory
tags: [service]
- name: Files are copied
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
with_items: files
tags: [service]
我们创建了两个任务。第一个任务使用 Ansible 的file模块创建服务目录。由于这个角色应该是通用的并适用于(几乎)任何服务,服务的名称是一个变量,我们将在运行剧本时动态设置它。第二个任务使用copy模块将我们在目标服务器上需要的所有文件复制过去。我们使用了with_items指令,它会对*files_ variable中的每个条目重复此任务。这个变量定义在roles/service/defaults/main.yml文件中,内容如下:
files: [
{
src: "{{ repo_dir }}/docker-compose.yml",
dest: "/data/{{ service_name }}/docker-compose.yml"
}, {
src: "{{ repo_dir }}/nginx-includes.conf",
dest: "/data/nginx/includes/{{ service_name }}.conf"
}, {
src: "{{ repo_dir }}/nginx-upstreams.ctmpl",
dest: "/data/nginx/upstreams/{{ service_name }}.ctmpl"
}
]
所有这些文件的源都利用了我们在预部署任务中已经使用过的repo_dir变量。同样,文件的目标位置则使用了service_name变量。
一旦我们确认所有需要的文件都已经放置到目标服务器上,我们就可以继续进行实际的部署,这包括两个步骤(请不要运行它们)。
docker-compose pull app
docker-compose up -d app
consul-template \
-consul localhost:8500 \
-template "/data/nginx/upstreams/books-ms.ctmpl:\
/data/nginx/upstreams/books-ms.conf:\
docker kill -s HUP nginx" \
-once
首先,我们从 Docker 仓库拉取了最新的镜像,然后将其启动。当运行docker-compose up时,它会检查容器镜像或其配置是否与正在运行的容器有所不同。如果确实不同,Docker Compose 会停止正在运行的容器并启动新的容器,同时保留挂载的卷。我们已经讨论过,在某段时间(从停止当前版本到运行新版本之间),我们的服务将不可用。我们稍后会处理这个问题。现在,短暂的停机时间是我们必须接受的。最后,我们运行consul-template来更新配置并重新加载 nginx。
正如你可能猜到的,我们将通过 Ansible 的shell模块运行这两个命令:
- name: Containers are pulled
shell: docker-compose pull app
args:
chdir: /data/{{ service_name }}
tags: [service]
- name: Containers are running
shell: docker-compose up -d app
args:
chdir: /data/{{ service_name }}
tags: [service]
- name: Proxy is configured
shell: consul-template \
-consul localhost:8500 \
-template "{{ ct_src }}:{{ ct_dest }}:{{ ct_cmd }}" \
-once
tags: [service]
我们没有做任何新操作。这与我们在预部署任务中定义的 shell 任务模式相同。唯一值得注意的是,我们使用了变量作为-template值。这样做的唯一原因是,由于书籍的每行字符数有限,所有参数无法放下。那些变量定义在roles/service/defaults/main.yml文件中,内容如下:
ct_src: /data/nginx/upstreams/{{ service_name }}.ctmpl
ct_dest: /data/nginx/upstreams/{{ service_name }}.conf
ct_cmd: docker kill -s HUP nginx
最终的结果可以在roles/service/tas ks/deployment.yml文件中看到。请注意,与预部署任务不同,这一组中的所有任务确实会在目标服务器上运行。从缺少delegate_to: 127.0.0.1指令可以看出这一点。
我们已经完成了部署,可以将注意力转向最后一组任务。
部署后任务
剩下的就是运行集成测试并将测试容器推送到仓库。提醒一下,命令如下(请不要运行它们)。
docker-compose \
-f docker-compose-dev.yml \
run --rm \
-e DOMAIN=http://10.100.198.201 \
integ
docker push 10.100.198.200:5000/books-ms-tests
这些命令的 Ansible 等效命令如下:
- name: Post-deployment tests are run
shell: docker-compose \
-f docker-compose-dev.yml \
run --rm \
-e DOMAIN={{ proxy_url }} \
Integ
args:
chdir: "{{ repo_dir }}"
delegate_to: 127.0.0.1
tags: [service, tests]
- name: Tests container is pushed
shell: docker push \
{{ registry_url }}{{ service_name }}-tests
delegate_to: 127.0.0.1
tags: [service, tests]
这里没有什么新内容,所以我们不再详细讨论。完整的部署后任务可以在roles/service/tasks/p ost-deploym ent.yml文件中找到。
运行自动化部署管道
让我们看看service剧本的实际运行:
cd ~/books-ms
ansible-playbook /vagrant/ansible/service.yml \
-i /vagrant/ansible/hosts/prod \
--extra-vars "repo_dir=$PWD service_name=books-ms"
我们运行了service.yml的剧本,清单指向hosts/prod文件,并带有一些额外的变量。第一个是repo_dir,它的值为当前目录($PWD)。第二个表示我们想要部署的服务名称(books-ms)。目前,我们只有这个服务。如果将来有更多服务,它们都可以通过修改此变量的值来使用相同的剧本进行部署。
我们不仅实现了完全自动化的部署,还实现了目标服务器的配置。第一次运行剧本是在一台全新的 Ubuntu 服务器上完成的,因此 Ansible 确保了部署所需的一切都已正确配置。结果虽然不是完美的,但已经是一个不错的起点。
随时可以重复执行剧本并观察与第一次运行相比的差异。你会注意到,大多数 Ansible 任务的状态都会是ok,因为没有需要做的事情,且剧本运行得更快。
我们可能错过了哪些东西?有不少。不过,在我们继续并尝试修复它们之前,我们应该建立一个合适的持续部署平台,看看它是否能帮助当前的流程。在那之前,先销毁虚拟机,让你的计算机休息一下:
exit
vagrant destroy -f
第十二章。持续集成、交付和部署工具
我们大多数的流程已经通过 Ansible 自动化。直到现在,我们使用 playbook 来自动化两种任务:服务器的配置和准备以及部署流程。虽然 Ansible 作为一个工具在准备和配置服务器方面表现出色,但在部署(至少在我们的场景中)并不是它的强项。我们主要把它当作 bash 脚本的替代品来使用。我们现在大部分的部署任务都是使用 Ansible 的shell模块。我们本可以使用 shell 脚本,结果也差不多。Ansible 设计时是为了使用承诺(promises)来确保系统处于正确的状态。当需要条件判断、try/catch 语句或其他类型的逻辑时,它在部署时表现得并不好。我们使用 Ansible 来部署容器的主要原因是避免将流程拆分为多个命令(用 ansible 进行准备、运行脚本、再准备更多、再运行更多脚本,依此类推)。第二个也是更重要的原因是我们当时没有覆盖 CI/CD 工具,所以使用了现有的工具。这种情况很快就会发生变化。
我们在部署流水线中缺少了什么?我们正在使用 Ansible 来配置和准备服务器,这部分运行得很好。我们仍在寻找一种更好的方式来部署软件(调用 Ansible 的shell模块有点繁琐)。我们也缺少一种方式来监控代码仓库,以便在代码发生变化时可以自动执行新的部署。当流程的某一部分失败时,我们没有机制发送通知。我们也缺少对所有构建和部署的可视化表示。这些问题还有很多可以继续列举。所有这些缺失的功能有一个共同点,那就是它们都可以通过 CI/CD 工具轻松解决。因此,我们应该开始寻找我们可以使用的 CI/CD 平台,并采纳其中一种。
CI/CD 工具比较
划分 CI/CD 工具的一种方式是将它们分为云服务和自托管解决方案两大类。云服务有很多,既有免费的也有付费的。它们中的大多数非常适合处理比我们当前尝试实现的更简单的流程。如果你有一个由少数服务组成的小型应用,并且它们运行在不超过几台服务器上,云解决方案非常优秀。我曾在我的个人项目中使用过许多此类服务。Travis、Shippable、CircleCI 和 Drone.io 只是其中的几个。它们会运行你的脚本、构建你的应用和服务,并将它们打包成容器。它们大多数都没有设计或能力来处理一组服务器,特别是在私有或自托管环境下。并不是说没有适合这种场景的云解决方案,确实有,但它们在大规模使用时通常成本过高。考虑到这一点,我们应该寻找自托管解决方案。
市面上有很多自托管的 CI/CD 工具,种类繁多,从免费的到非常昂贵的都有。像 Jenkins、Bamboo、GoCD、Team City 和 Electric Cloud 等一些常用的自托管 CI/CD 工具,只是众多工具中的一部分。它们各有优缺点。然而,Jenkins 之所以能从中脱颖而出,主要得益于其社区。没有其他工具能像它一样,每天都有如此多的人在贡献代码。它有着出色的支持,通过插件,它可以扩展到几乎可以满足我们所有需求的程度。你几乎不会发现自己需要某些功能,而这些功能没有被一个或多个插件覆盖。即使你发现有一个用例没有被覆盖,编写你自己的插件(并希望能让它公开,供他人使用)也是一件非常容易的事。社区和插件是它的最大优势,这也是它比任何其他工具都更广泛采用的原因。
你很可能已经使用过 Jenkins,或者至少听说过它。企业选择使用其他工具(尤其是 Bamboo 和 Team City)的一个主要原因是它们的企业版。当一个组织变大时,它需要企业级的支持和可靠性。它需要那些企业级产品所提供的额外功能和技术支持。CloudBees 就是最近成立的一家这样的公司。他们提供 Jenkins 企业版,并拥有一流的支持,能够处理几乎所有与持续集成、交付或部署相关的场景。他们还提供免费的 Jenkins 社区版,但同时也提供付费的企业版功能和支持。这也是选择 Jenkins 的另一个理由。没有其他工具(至少是前面提到的那些)同时拥有完全免费的工具,同时还能提供付费支持和附加功能。Team City 虽然可以免费下载,但限制了代理的数量。GoCD 是免费的,但不提供任何支持。Bamboo 与 Team City 类似,免费版也有限制。通过选择 Jenkins,我们选择了一款经过战斗测试、广泛使用的工具,且它得到了一个庞大社区的支持,如果有需要,还可以通过 CloudBees 获得付费支持和功能。
注意
在写这本书时,我选择加入了 CloudBees 团队(即 Enterprise Jenkins 背后的公司)。我决定在整本书中推广 Jenkins,并非因为我在 CloudBees 工作,而恰恰相反。我选择加入他们,是因为我相信 Jenkins 是市场上最好的 CI/CD 工具。
CI/CD 工具的简短历史
Jenkins(在与甲骨文的争执后从 Hudson 分叉出来)已经存在很长时间,并且确立了自己作为创建持续集成(CI)和持续交付/部署(CD)管道的领先平台。其背后的理念是我们应该创建执行诸如构建、测试、部署等操作的工作任务。这些任务应当相互连接,以创建一个 CI/CD 管道。它的成功如此巨大,以至于其他产品纷纷效仿,产生了 Bamboo、Team City 等。它们都采用了类似的逻辑,即拥有任务并将它们连接起来。操作、维护、监控和创建任务主要通过它们的用户界面(UI)来完成。然而,其他产品并未能够压倒 Jenkins,主要是由于其强大的社区支持。Jenkins 拥有超过一千个插件,可以说很难想象有哪项任务不被至少一个插件支持。Jenkins 所具备的支持、灵活性和可扩展性使得它能够保持作为最受欢迎且广泛使用的 CI/CD 工具的地位。基于大量使用用户界面的方式可以被认为是第一代 CI/CD 工具(尽管在此之前也有其他工具)。
随着时间的推移,新的产品应运而生,并且随着它们也诞生了新的方法。Travis、CircleCI 等工具将过程迁移到云端,并且依赖于自动发现以及主要基于 YML 的配置,这些配置与需要在管道中移动的代码存储在同一个代码库中。这一理念非常不错,也带来了很大的清新感。与其在集中位置定义你的任务,这些工具会检查你的代码并根据项目的类型采取相应的行动。例如,如果它们发现 build.gradle 文件,它们会假设你的项目应该使用 Gradle 进行测试和构建。作为结果,它们会运行 gradle check 来测试代码,如果测试通过,接着运行 gradle assemble 来生成构件。我们可以认为这些产品是 CI/CD 工具的第二代。
第一代和第二代工具各自存在不同的问题。像 Jenkins 这样的工具具有强大的功能和灵活性,使得我们能够创建量身定制的管道,处理几乎任何复杂度的任务。这种强大带来了代价。当你有几十个任务时,维护起来相对简单。然而,当数量增加到数百个时,管理它们可能变得相当繁琐且耗时。
假设一个普通的流水线有五个作业(构建、预部署测试、部署到暂存环境、后部署测试以及部署到生产环境)。实际上,作业的数量通常会超过五个,但我们就保持乐观估算。如果我们将这些作业乘以假设的二十个流水线,分别属于二十个不同的项目,那么总数就是一百。现在,想象一下我们需要将所有这些作业从,比如说,Maven 改为 Gradle。我们可以选择通过 Jenkins 的 UI 开始修改,或者大胆地直接在 Jenkins XML 文件中应用这些变更,这些文件代表了那些作业。无论哪种方式,这个看似简单的更改都需要相当大的投入。而且,由于其性质,所有内容都集中在一个地方,这使得各团队难以管理属于他们项目的作业。此外,项目特定的配置和代码与应用程序代码的其他部分一起存储在同一个代码库中,而不是在某个中心位置。Jenkins 并不是唯一遇到这个问题的工具,许多其他自托管工具也存在类似的问题。这源于一个时代,当时人们认为重度集中化和任务的横向划分是一种好主意。大约在那个时候,我们认为用户界面应该解决大部分问题。今天,我们知道很多类型的任务,比起通过某个 UI,作为代码定义和维护要容易得多。
我记得 Dreamweaver 流行的那段日子。那是九十年代末和二千年初的事(请记住,那时的 Dreamweaver 和今天的完全不同)。它看起来像是梦想成真(因此才有这个名字?)。我可以用鼠标创建整张网页。拖拽一个小部件,选择几个选项,写个标签,然后重复。我们能够非常快速地创建内容。当时并不那么明显的是,结果就像是一个贷款,需要用利息偿还。Dreamweaver 为我们创建的代码一点也不易于维护。事实上,有时候重新开始比修改用它创建的页面还要容易,尤其是当我们需要做一些它的部件没有包含的事情时。这是一个噩梦。今天,几乎没有人再使用拖放工具编写 HTML 和 JavaScript 了。我们自己编写代码,而不是依赖其他工具为我们编写代码。还有很多类似的例子。例如,Oracle ESB,至少在它的早期,也存在类似的问题。拖放不是一个值得依赖的东西(但对销售来说很有帮助)。这并不意味着图形界面不再被使用。它们依然存在,但仅限于非常特定的用途。一个网页设计师可能会依赖拖放工具,然后再将结果交给编码人员。
我的意思是,不同的方法适用于不同的情境和任务类型。Jenkins 及类似工具通过其用户界面(UI)在监控和状态可视化方面提供了极大的便利。它的不足之处在于作业的创建和维护。这类任务通过代码来完成会更为高效。使用 Jenkins 时,我们拥有强大的功能,但也必须为此付出维护的代价。
第二代 CI/CD 工具(如 Travis、CircleCI 等)将维护问题降到了几乎可以忽略不计的程度。在许多情况下,几乎无需做任何事情,因为它们会自动识别项目类型并执行正确的操作。在其他一些情况下,我们需要编写一个travis.yml、circle.yml或类似的文件,为工具提供额外的指令。即便是这种情况,该文件通常也只有几行配置,并且与代码一起存放,便于项目团队进行管理。然而,这些工具并没有取代第一代工具,因为它们通常只在小型项目和非常简单的管道上表现良好。真正的持续交付/部署管道远比这些工具所能处理的要复杂。换句话说,我们获得了低维护,但失去了力量,而且在许多情况下,灵活性也受到了影响。
如今,像 Jenkins、Bamboo 和 Team City 这样的老牌工具依然主导着市场,并且是推荐用于大型项目的工具。同时,像 Travis 和 CircleCI 这样的云工具在小型环境中占据主导地位。负责 Jenkins 代码库的团队意识到,必须引入一些重要的改进,结合两代工具的优点,甚至更多。我将这种变化称为 CI/CD 工具的第三代。它们引入了 Jenkins Workflow 和 Jenkinsfile。它们一起带来了非常有用且强大的功能。通过 Jenkins Workflow,我们可以使用基于 Groovy 的 DSL 编写完整的流水线。整个过程可以写成一个脚本,利用现有的大部分 Jenkins 功能。结果是代码量大大减少(Workflow 脚本比传统的 Jenkins 作业定义(XML)小得多),作业数量也减少(一个 Workflow 作业可以替代多个传统的 Jenkins 作业)。这使得管理和维护变得更加简单。另一方面,新引入的 Jenkinsfile 使我们能够将 Workflow 脚本定义在仓库中,与代码一起管理。这意味着负责项目的开发人员也可以控制 CI/CD 流水线。这样,责任划分就更加明确了。总体上,Jenkins 管理是集中化的,而每个 CI/CD 流水线则被放置在它所属的位置(与应该在其中移动的代码一起)。此外,如果我们将这些与 Multibranch Workflow 作业类型结合使用,还可以根据分支微调流水线。例如,我们可能在 master 分支中定义完整的过程,而在每个功能分支中定义较短的流程。每个 Jenkinsfile 中放入的内容由各自维护每个仓库/分支的人决定。使用 Multibranch Workflow 作业时,Jenkins 会在每次创建新分支时创建作业,并运行文件中定义的内容。类似地,当分支被删除时,它也会删除相关作业。最后,Docker Workflow 也被引入,使 Docker 成为 Jenkins 中的第一公民。
注意
Jenkins 拥有悠久的历史,这使得它走向了 Pipeline 插件的诞生。首先是提供连接作业可视化的 Build Pipeline 插件,随后出现了引入 Groovy DSL 概念的 Build Flow 插件,用于定义 Jenkins 作业。后者遇到了许多障碍,促使其作者重新开始并创建了 Workflow 插件,后来将其重命名为 Pipeline 插件。
所有这些改进将 Jenkins 推向了一个全新的高度,确认了其在 CI/CD 平台中的主导地位。
如果需要更多功能,还可以使用 CloudBees Jenkins Platform - Enterprise Edition,它提供了极好的功能,尤其是在我们需要大规模运行 Jenkins 时。
注意
工作流作者决定将插件重命名为 Pipeline。然而,目前并非所有源代码都已被重命名,并且同时存在对 pipeline 和 workflow 的引用。为了保持一致性,并避免可能的失败,我选择坚持使用旧名称,并在整本书中使用 Workflow 一词。这个变化只是语义上的,并没有引入任何功能上的变化。
Jenkins
Jenkins 通过其插件表现出色。它的插件种类繁多,以至于很难找到任何我们想要实现的功能,而这些功能没有被至少一个插件覆盖。想连接到代码仓库?有插件。想向 Slack 发送通知?有插件。想用自己的公式解析日志?有插件。
能够从如此多的插件中进行选择是把双刃剑。人们往往滥用它,安装比实际需要更多的插件。一个例子就是 Ansible 插件。
我们可以将其选择为构建步骤,并填写如 Playbook 路径、库存、跳过的标签、附加参数 等字段。屏幕可能会显示如图 12-01 所示的界面:

图 12-01 – 在 Jenkins 作业中使用的 Ansible 插件
Ansible 插件的替代方案是直接使用 执行 Shell 构建步骤(Jenkins 核心的一部分),并放入我们想要执行的命令。我们自己编写了自动化脚本,并熟悉应该执行的命令。通过使用相同的命令,字段需要填写或忽略的部分更少,我们知道将要执行的内容,并且可以将这些命令作为参考,如果同样的过程需要在 Jenkins 之外重复执行:

图 12-02 – 将 Ansible playbook 作为 Shell 命令运行
在许多情况下,自动化应该在 Jenkins(或任何其他 CI/CD 工具)之外完成。从那时起,我们要做的就是告诉 Jenkins 运行哪个脚本。这个脚本可以与我们要部署的服务代码一起存储在代码库中(例如 deploy.sh),或者像我们这次做的那样,通过一些命名约定进行通用化,并供所有服务使用。无论自动化脚本如何组织,通常在 Jenkins 中使用它们的最好、最简单的方法,就是直接运行与这些脚本相关的命令。这个方法一直适用,直到最近。现在,随着 Jenkinsfile 的加入,我们可以继续按照相同的逻辑创建项目特定的脚本并将其保存在项目库中。它带来的附加好处是,我们可以在 Jenkinsfile 中的 Workflow 脚本内使用 Jenkins 特有的功能。如果你需要在某个特定节点上运行某些操作,有一个模块可以使用。如果你需要使用存储在 Jenkins 中的认证信息,也有相应的模块。功能的清单一长串,但核心要点是,通过 Jenkinsfile 和 Workflow,我们可以继续依赖存放在代码库中的脚本,同时利用 Jenkins 的高级功能。
现在是时候亲自动手设置 Jenkins 了。
设置 Jenkins
和往常一样,我们将首先创建虚拟机,用于探索 Jenkins。我们将创建 cd 节点,作为承载 Jenkins 服务器以及我们将通过其运行的 Ansible playbook 的平台:
vagrant up cd prod
一旦两台服务器都启动并运行,我们就可以继续像之前一样配置 prod 节点:
vagrant ssh cd
ansible-playbook /vagrant/ansible/prod2.yml \
-i /vagrant/ansible/hosts/prod
现在我们已经准备好启动 Jenkins。通过 Docker 设置基础安装非常简单。我们只需要运行一个带有几个参数的容器:
sudo mkdir -p /data/jenkins
sudo chmod 0777 /data/jenkins
docker run -d --name jenkins \
-p 8080:8080 \
-v /data/jenkins:/var/jenkins_home \
-v /vagrant/.vagrant/machines:/machines \
jenkins
Docker 检测到本地没有 Jenkins 容器的副本,并开始从 Docker Hub 拉取它。拉取完成后,我们将拥有一个正在运行的实例,它会暴露 8080 端口并共享几个卷。/var/jenkins_home 目录包含所有 Jenkins 配置。为了配置管理的方便,我们将它共享出来,稍后会详细讲解。由于容器中的进程作为 jenkins 用户运行,而这个用户在我们的系统中不存在,我们为主机上的该目录赋予了完全权限(0777)。从安全角度来看,这不是一个好解决方案,但目前应该足够用了。第二个共享目录是 /machines,它映射到主机的 /vagrant/.vagrant/machines 目录。这里是 Vagrant 存储所有 SSH 密钥的位置,稍后我们将需要这些密钥来配置 Jenkins 节点,在这些节点上将执行实际的作业。请注意,如果你在生产服务器上运行这个操作,你应该使用 ssh-copy-id 生成密钥并共享,而不是使用 Vagrant 生成的密钥。
一旦 Jenkins 容器启动,我们可以打开 http:/ /10.100.198.200:8080 并浏览图形界面:

图 12-03 – 标准安装后的 Jenkins 主页
如果这是你第一次接触 Jenkins,请先从这本书中休息一下,花点时间熟悉它。它的 GUI 非常直观,网上有许多资源可以帮助你对其工作原理有一个基本的了解。我们即将深入探讨 Jenkins 管理的自动化。尽管我们不会在这过程中使用 GUI,但理解它的可视化界面将帮助你更好地理解我们即将执行的任务。花些时间熟悉它,当你感到舒适时,再回来继续学习。
我认识的大多数人都只通过 Jenkins 的 GUI 使用它。有些人可能会使用它的 API 来运行作业或自动化一些基本操作。这样做没问题,一段时间是可以的。你从安装一些插件开始,创建一些作业,并且为能够迅速完成很多工作而感到高兴。随着时间的推移,作业数量增加,维护工作也随之增多。定义并定期运行的作业,或者通过某些事件(例如代码提交)触发的作业,可能有数十个、数百个,甚至数千个。通过 GUI 管理这些作业是困难且费时的。试想一下,比如你想为所有作业添加 Slack 通知。当作业数量较多时,一个一个地修改作业并不是一个好选择。
我们可以通过不同的方式来解决 Jenkins 自动化问题,主要是集中在作业的创建和维护上。一种方法是使用一些 Jenkins 插件,这些插件可以帮助我们。Job DSL 和 Job Generator 插件就是其中的一些。我们将采取不同的方法。所有 Jenkins 设置都存储为 XML 文件,位于/var/jenkins_home directory(我们将其暴露为 Docker 卷)。我们可以简单地添加新文件或修改现有文件,当需要更改某些 Jenkins 行为时。由于我们已经熟悉 Ansible,我们可以继续将其作为工具,不仅用于安装 Jenkins,还用于维护 Jenkins。基于这个思路,我们将删除当前的 Jenkins 安装,并通过 Ansible 重新开始:
docker rm -f jenkins
sudo rm -rf /data/jenkins
我们已删除 Jenkins 容器并删除了我们暴露作为卷的目录。现在,我们可以通过 Ansible 安装并配置它。
使用 Ansible 设置 Jenkins
使用 Ansible 设置 Jenkins 很简单,尽管我们将使用的角色包含一些我们以前没有遇到过的复杂情况。由于 playbook 的执行需要几分钟时间,我们先运行它,并在等待它完成时讨论它的定义:
ansible-playbook /vagrant/ansible/jenkins-node.yml \
-i /vagrant/ansible/hosts/prod
ansible-playbook /vagrant/ansible/jenkins.yml \
-c local
首先,我们设置将来会使用的 Jenkins 节点。执行第一个剧本应该不会花费太多时间,因为它只需要确保安装了 JDK(Jenkins 所需,以便能够连接到节点)并创建单一目录/data/jenkins_slaves。Jenkins 将使用该目录在这些节点上执行进程时存储文件。jenkins角色定义在jenkins.yml剧本中,稍微长一些,值得花些时间详细了解。我们来更详细地探讨一下。jenkins.yml剧本如下:
- hosts: localhost
remote_user: vagrant
serial: 1
sudo: yes
roles:
- consul-template
- jenkins
它安装了我们已经熟悉的 Consul Template,因此我们可以直接进入roles/jenkins角色。任务定义在roles/jenkins/tasks/main.yml文件中,我们将逐一查看这些任务。
第一个任务是创建我们需要的目录。如之前所述,变量定义在roles/jenkins/defaults/main.yml文件中:
- name: Directories are created
file:
path: "{{ item.dir }}"
mode: 0777
recurse: yes
state: directory
with_items: configs
tags: [jenkins]
创建完目录后,我们可以启动 Jenkins 容器。尽管容器启动几乎不需要时间,但 Jenkins 本身需要一些耐心,直到它完全启动并投入使用。稍后我们将向 Jenkins API 发出一些命令,因此我们必须暂停剧本,暂时等候半分钟,以确保 Jenkins 已经完全启动。这也给我们提供了一个机会来观察pause模块的作用(尽管它应该很少使用)。请注意,我们在此注册了变量container_result,并在稍后的暂停中确保容器内的 Jenkins 应用完全启动,才能继续执行后续任务。如果 Jenkins 容器的状态发生变化,将执行此暂停操作:
- name: Container is running
docker:
name: jenkins
image: jenkins
ports: 8080:8080
volumes:
- /data/jenkins:/var/jenkins_home
- /vagrant/.vagrant/machines:/machines
register: container_result
tags: [jenkins]
- pause: seconds=30
when: container_result|changed
tags: [jenkins]
接下来,我们应该复制一些配置文件。我们将从roles/jenkins/files/credentials.xml开始,然后是几个节点文件(roles/jenkins/files/cd_config.xml、roles/jenkins/files/prod_config.xml等)以及一些其他不太重要的配置文件。可以随意查看这些文件的内容。目前,重要的是要理解我们需要这些配置:
- name: Configurations are present
copy:
src: "{{ item.src }}"
dest: "{{ item.dir }}/{{ item.file }}"
mode: 0777
with_items: configs
register: configs_result
tags: [jenkins]
然后,我们应该确保安装了一些插件。由于我们的代码在 GitHub 上,我们需要安装Git Plugin。另一个我们将使用的有用插件是Log Parser。由于 Ansible 日志较大,我们将使用此插件将其分解成更易于管理的部分。还会安装其他一些插件,具体使用时会详细讨论。
大多数人往往只会下载他们需要的插件。即使是我们正在使用的官方 Jenkins 容器,也有一种指定要下载哪些插件的方式。然而,这种方法非常危险,因为我们不仅需要定义我们需要的插件,还需要定义它们的依赖项、这些依赖项的依赖项,等等。很容易忘记其中一个或指定错误的依赖项。如果发生这种情况,最好的结果是我们想使用的插件无法工作。在某些情况下,甚至整个 Jenkins 服务器可能会停止运行。我们将采取不同的方法。可以通过向/pluginManager/installNecessaryPlugins发送带有 XML 内容的 HTTP 请求来安装插件。Jenkins 在接收到请求后,会下载我们指定的插件及其依赖项。由于我们不希望在插件已安装的情况下再次发送请求,我们将使用 creates 指令,指定插件的路径。如果插件已存在,则该任务不会执行。
大多数插件需要重新启动应用程序,因此如果添加了任何插件,我们将重新启动容器。由于安装插件的请求是异步的,我们首先需要等待插件目录创建完成(Jenkins 会将插件解压到与插件同名的目录中)。一旦确认所有插件都已安装,我们将重新启动 Jenkins 并再次等待一段时间,直到其完全恢复正常运行。换句话说,我们向 Jenkins 发送安装插件的请求,如果插件尚未安装,则等待 Jenkins 完成安装,重新启动容器以使新插件生效,并等待一段时间直到重启完成:
- name: Plugins are installed
shell: "curl -X POST \
-d '<jenkins><install plugin=\"{{ item }}@latest\" /></jenkins>' \
--header 'Content-Type: text/xml' \
http://{{ ip }}:8080/pluginManager/installNecessaryPlugins"
args:
creates: /data/jenkins/plugins/{{ item }}
with_items: plugins
register: plugins_result
tags: [jenkins]
- wait_for:
path: /data/jenkins/plugins/{{ item }}
with_items: plugins
tags: [jenkins]
- name: Container is restarted
docker:
name: jenkins
image: jenkins
state: restarted
when: configs_result|changed or plugins_result|changed
tags: [jenkins]
- pause: seconds=30
when: configs_result|changed or plugins_result|changed
tags: [jenkins]
现在我们准备创建作业了。由于所有作业的工作方式(或多或少)相同,我们可以使用一个通用模板来处理所有与服务部署相关的作业。我们需要为每个作业创建一个单独的目录,应用模板,将结果复制到目标服务器,最后,如果有任何作业发生了变化,重新加载 Jenkins。与需要完全重启的插件不同,Jenkins 在重新加载后会立即开始使用新的作业,这个过程非常快速(几乎是即时的):
- name: Job directories are present
file:
path: "{{ home }}/jobs/{{ item.name }}"
state: directory
mode: 0777
with_items: jobs
tags: [jenkins]
- name: Jobs are present
template:
src: "{{ item.src }}"
dest: "{{ home }}/jobs/{{ item.name }}/config.xml"
mode: 0777
with_items: jobs
register: jobs_result
tags: [jenkins]
- name: Jenkins is reloaded
uri:
url: http://{{ ip }}:8080/reload
method: POST
status_code: 200,302
when: jobs_result|changed
ignore_errors: yes
tags: [jenkins]
未来,如果我们想要添加更多的作业,只需要向 jobs 变量中添加更多条目。通过这种系统,我们可以轻松地创建与服务一样多的 Jenkins 作业,几乎无需任何额外的努力。不仅如此,如果作业需要更新,我们只需要更改模板并重新运行 playbook,所有负责构建、测试和部署我们服务的作业都会收到更新。
在 role s/jenkins/defaults/main.yml 文件中定义的 jobs 变量如下所示:
jobs: [
{
name: "books-ms-ansible",
service_name: "books-ms",
src: "service-ansible-config.xml"
},
...
]
name 和 service_name 的值应该很容易理解,它们分别代表作业的名称和服务的名称。第三个值是我们用来创建作业配置的源模板:
最后,让我们来看看 roles/jenkins/templates 下的 /service-ansible-config.xml 模板。
<?xml version='1.0' encoding='UTF-8'?>
<project>
<actions/>
<description></description>
<logRotator class="hudson.tasks.LogRotator">
<daysToKeep>-1</daysToKeep>
<numToKeep>25</numToKeep>
<artifactDaysToKeep>-1</artifactDaysToKeep>
<artifactNumToKeep>-1</artifactNumToKeep>
</logRotator>
<keepDependencies>false</keepDependencies>
<properties>
</properties>
<scm class="hudson.plugins.git.GitSCM" plugin="git@2.4.1">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>https://github.com/vfarcic/{{ item.service_name }}.git</url>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/master</name>
</hudson.plugins.git.BranchSpec>
</branches>
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
<submoduleCfg class="list"/>
<extensions/>
</scm>
<canRoam>true</canRoam>
<disabled>false</disabled>
<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
<blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
<triggers/>
<concurrentBuild>false</concurrentBuild>
<builders>
<hudson.tasks.Shell>
<command>export PYTHONUNBUFFERED=1
ansible-playbook /vagrant/ansible/service.yml \
-i /vagrant/ansible/hosts/prod \
--extra-vars "repo_dir=${PWD} service_name={{ item.service_name }}"</command>
</hudson.tasks.Shell>
</builders>
<publishers/>
<buildWrappers/>
</project>
这是一个相对较大的 Jenkins 任务 XML 定义。我通过 GUI 手动创建了它,复制了文件并用变量替换了值。一个关键条目是告诉 Jenkins 代码仓库位置的条目:
<url>https://github.com/vfarcic/{{ item.service_name }}.git</url>
如你所见,我们再次使用了命名规范。仓库的名称与服务的名称相同,并将被我们之前看到的变量值替换。
第二个条目是执行命令的条目,该命令运行 Ansible playbook,构建、打包、测试并部署服务:
<command>export PYTHONUNBUFFERED=1
ansible-playbook /vagrant/ansible/service.yml \
-i /vagrant/ansible/hosts/prod \
--extra-vars "repo_dir=${PWD} service_name={{ item.service_name }}"</command>
如你所见,我们正在运行我们在上一章中创建的相同 Ansible playbook。
最后,jenkins 角色中的最后一个任务如下:
- name: Scripts are present
copy:
src: scripts
dest: /data
mode: 0766
tags: [jenkins]
它将脚本复制到 /data 目录。我们稍后将深入探讨这些脚本。
Ansible 角色 jenkins 是一个更复杂用例的好例子。直到这一章之前,我们用 Ansible 完成的大部分配置和设置都相对简单。在大多数情况下,我们会更新 APT 仓库、安装软件包,或许还会复制一些配置文件。在其他情况下,我们可能只会运行一个 Docker 容器。虽然还有很多其他情况,但本质上它们都非常简单,因为其他工具不需要太多的配置。Jenkins 则大不相同。除了运行容器外,我们还需要创建相当多的配置文件,安装若干插件,创建一些任务等等。作为替代方案,我们本可以(也可能应该)创建一个容器,把所有内容都放进去,只留下任务。这会简化设置,同时提供一个更可靠的解决方案。不过,我还是想展示一下更复杂的 Ansible 过程。
我将 Jenkins 自定义镜像的创建留给你作为练习。该镜像应包含所有内容,除了任务。创建一个 Dockerfile,构建并推送镜像到 Docker Hub,然后修改 Ansible 角色 jenkins,使其使用新的容器。它应与 SSH 密钥和任务共享卷,以便可以从容器外部更新它们。
运行 Jenkins 任务
到目前为止,我们之前运行的 Ansible playbook 应该已经完成执行。不仅 Jenkins 已经启动并运行,books-ms 任务也已创建并等待我们使用。
让我们来看一下 Jenkins 的图形用户界面。请打开http://10.100.198.200:8080。你将看到主页上显示着几个任务。我们首先要探索的任务是book-ms-ansible任务。在其他情况下,我们的代码仓库会触发一个请求给 Jenkins 执行构建。但是,由于我们使用的是公共 GitHub 仓库,并且这个 Jenkins 实例(可能)运行在你的笔记本电脑上,且无法从公共网络访问,我们需要手动执行任务。点击为 books-ms-ansible 安排构建按钮(图标是时钟和播放箭头)。你会看到,books-ms-ansible任务的第一次构建正在 cd 节点上运行,该节点位于屏幕的左侧:

图 12-04 – Jenkins 主屏幕,显示一些任务
点击books-ms-ansible任务,然后点击Build History中的#1链接,最后点击Console Output。同样的操作也可以通过打开http://10.100.198.200:8080/job/books-ms-ansible/lastBuild/console的 URL 来完成。你将看到该任务最后一次构建的输出。正如你可能注意到的,日志有点大,因此很难找到有关特定任务的信息。幸运的是,我们安装了Log Parser插件,它可以帮助我们更轻松地查找日志。但是,首先,我们需要等构建完成。我们会利用这段时间,来探索任务的配置。
请返回到books-ms-ansible任务的主屏幕,并点击左侧菜单中的Configure链接(或者打开http://10.100.198.200:8080/job/books-ms-ansible/configure的链接)。
books-ms-ansible是一个非常简单的任务,然而在大多数情况下,如果我们的自动化脚本编写得正确(无论是否使用 Ansible),我们不需要任何更复杂的任务配置。你将看到,这个任务被限制在cd节点上,这意味着它只能在名为cd的服务器上运行。这样,我们可以控制哪些任务在特定的服务器上运行。Jenkins 的设置过程之一是创建了一个名为cd的节点。
源代码管理部分包含了 GitHub 仓库的引用。请注意,我们缺少一个触发器,这个触发器会在有新的提交时运行这个任务。可以通过多种方式实现这一点。我们可以将构建触发器设置为Poll SCM并安排定期运行(比如每 10 秒)。请注意,调度格式使用的是cron语法。在这种情况下,Jenkins 将定期检查仓库,如果有任何更改(比如提交),它会运行任务。一种更好的方式是在仓库中直接创建一个webhook。该钩子会在每次提交时调用 Jenkins 构建。这样,构建会在提交后几乎立即开始运行。同时,也不会有定期检查仓库所产生的开销。然而,这种方法要求 Jenkins 能够从仓库(在此案例中是 GitHub)访问,而我们目前是在私有网络内运行 Jenkins。我们选择了两者都不使用,因为在你阅读这本书时,books-ms仓库很可能不会有提交。如何触发这个任务留给你去探索。我们将通过手动运行构建来模拟相同的过程。不论任务是如何运行的,它首先会做的就是使用源代码管理部分提供的信息克隆仓库。
现在我们已经进入了工作的主要部分;构建部分。我之前提到过,我们本可以使用Ansible插件来帮助我们运行 playbook。然而,我们需要运行的命令非常简单,使用插件只会增加额外的复杂性。在构建部分中,我们有一个Execute shell步骤,它以与手动运行相同的方式执行service.yml playbook。我们使用 Jenkins 仅作为一个工具,用来检测代码仓库的变化并运行我们本来会执行的相同命令。

图 12-05 – Jenkins books-ms-ansible 作业配置界面
最后,我们将Console output (build log) parsing设置为Post-build actions步骤。它解析(在此案例中)Ansible 日志,使其以更用户友好的方式显示。到此时,构建的执行可能已经完成,我们可以查看解析后的日志。
返回到books-ms任务的构建#1,然后点击左侧菜单中的Parsed Console Output链接,或者打开网址http://10.100.198.200:8080/job/books-ms-a nsible/lastBuild/parsed_console/。在Info部分,你将看到每个 Ansible 任务被单独列出,并可以点击任何一个任务跳转到与该任务相关的输出部分。如果执行过程中出现了问题,它们会出现在Error链接下。我们不会详细讲解Log Parser插件是如何工作的。我将它加入到这个任务中,主要是为了展示 Jenkins 通过插件提供的强大功能。市面上有超过一千个插件可供使用,且不断有新的插件发布。插件可能是 Jenkins 相较于其他 CI/CD 工具的最大优势。背后有如此庞大的社区支持,你可以放心几乎任何需求都能得到(可能)解决。更棒的是,通过探索现有插件,你会获得新的灵感。
尽管这个任务满足了部署服务所需的所有基本功能(检出代码并运行 Ansible playbook),我们仍然可以为任务添加一些额外的功能。最有趣的事情之一就是在任务失败时添加通知。这可以是电子邮件、Slack 通知或我们习惯的几乎任何类型的通知。我将这一部分留给你作为练习。花点时间查看可以帮助发送通知的插件,选择一个并安装它。管理插件界面可以通过点击首页左侧菜单中的Manage Jenkins来访问。作为替代,你也可以通过打开网址http://10.100.198.200:8080/pluginManager/来访问同一界面。进入后,按照插件说明操作,并将其添加到books-ms-ansible任务中。一旦你熟悉了它,尝试通过 Ansible 完成相同操作。将新插件添加到plugins变量,并在service-ansible-config.xml模板中加入必要的条目。最简单的方法是通过 UI 应用更改,然后检查 Jenkins 对/data/jenkins/jobs/books-ms-ansible/conf中ig.xml文件所做的更改。
设置 Jenkins 工作流任务
有没有更好的方式来构建一个部署books-ms服务的作业?目前的做法是由多个步骤组成的一个作业。一部分步骤是检出代码,另一部分是运行 Ansible 脚本。我们指定它应该在cd节点上运行,并进行了几个小的附加步骤。目前缺少通知功能(除非你自己实现了),通知应该作为作业的另一个步骤。每个步骤都是一个单独的插件。有些插件是 Jenkins 核心自带的,其他的则是我们自己添加的。随着时间的推移,步骤的数量可能会大幅增加。同时,虽然 Ansible 在配置和部署服务器方面表现出色,但作为构建、测试和部署服务的工具,它确实显得有些繁琐,且缺少一些可以通过简单 bash 脚本轻松完成的功能。
另一方面,bash 脚本缺少一些 Ansible 所具有的特性。例如,Ansible 在远程执行命令方面要好得多。第三种选择是将部署过程移到传统的 Jenkins 作业中。这也不是一个理想的解决方案。我们最终会得到很多作业,它们可能也会运行 bash 脚本。一个作业会在 cd 节点上执行预部署任务,另一个负责 prod 节点的部署,还有第三个作业会在 cd 节点上执行后部署步骤。最少,我们将有三个链式作业。更可能的是会有更多。维护很多作业既费时又复杂。
我们可以利用 Jenkins 的 Workflow Plugin 来编写一个脚本,执行所有步骤。我们可以将其作为目前使用 Ansible 部署的替代方案。我们已经讨论过 Ansible 在服务器配置和部署方面表现出色,但部署部分可以进一步优化。Workflow 插件允许我们编写整个作业的脚本。这个特性本身就是一个很好的方式,可以继续依赖自动化。特别是考虑到 Jenkins XML 非常繁琐,且难以编写和阅读。只需看看我们用来定义一个简单部署服务作业的 service-ansible-config.xml 文件:Jenkins XML 很晦涩,并且包含大量的模板定义,Ansible 并不设计用于条件语句,也没有合适的替代方案来实现 try/catch 语句,bash 脚本只是额外增加了复杂性。确实,在这一点上,我们的过程变得复杂,我们应该力求在不牺牲目标的情况下,保持尽可能简单。
我们可以试试 Workflow 插件,看看它是否能够帮助我们。我们将结合使用 CloudBees Docker Workflow Plugin。
我们将首先查看 books-ms 作业的配置。我们可以通过 Jenkins UI 导航到作业设置页面,或者直接打开 http://10.100.1 98.200:8080/job/books-ms/configure URL:

图 12-06 – books-ms Jenkins 工作流作业的配置界面
进入books-ms配置后,你会注意到整个作业只包含几个参数和工作流脚本。与常规作业不同,工作流允许我们编写(几乎)所有内容。这反过来使得管理 Jenkins 作业变得更加轻松。我们正在使用的roles/je nkins/templates/service-flow.groovy脚本如下所示:
node("cd") {
git url: "https://github.com/vfarcic/${serviceName}.git"
def flow = load "/data/scripts/workflow-util.groovy"
flow.provision("prod2.yml")
flow.buildTests(serviceName, registryIpPort)
flow.runTests(serviceName, "tests", "")
flow.buildService(serviceName, registryIpPort)
flow.deploy(serviceName, prodIp)
flow.updateProxy(serviceName, "prod")
flow.runTests(serviceName, "integ", "-e DOMAIN=http://${proxyIp}")
}
脚本以节点定义开始,告诉 Jenkins 所有指令应在cd节点上运行。
节点内的第一条指令是从 Git 仓库中检出代码。git模块是为 Jenkins 工作流创建的 DSL 示例之一。此指令使用 Jenkins 作业中定义的serviceName参数。
接下来,我们使用load指令,该指令将包括workflow-util.groovy脚本中定义的所有工具函数。这样,我们在创建具有不同目标和流程的作业时,就不需要重复编写代码。我们很快会深入探讨workflow-util.groovy脚本。load的结果被赋值给flow变量。
从这一点开始,脚本的其余部分应该不难理解。我们调用了provision函数并传递了prod2.yml作为变量。然后我们调用buildTest函数,并将serviceName和registryIpPort作业参数作为变量传递。如此等等。我们调用的函数执行与通过 Ansible 实现的相同操作,并代表了部署流水线。通过将工具函数作为单独文件加载并与工作流脚本本身分开,我们能够适当划分职责。工具脚本为多个工作流脚本提供函数,集中管理有很大好处,这样改进可以只做一次。另一方面,不同的工作流可能不同,因此在这种情况下,它主要包含对工具函数的调用:
让我们仔细看看workflow-util.groovy脚本中的函数:
def provision(playbook) {
stage "Provision"
env.PYTHONUNBUFFERED = 1
sh "ansible-playbook /vagrant/ansible/${playbook} \
-i /vagrant/ansible/hosts/prod"
}
provision函数负责在部署之前为我们的服务器进行预配。它定义了stage,帮助我们更好地识别该函数负责的任务集。接着声明了PYTHONUNBUFFERED环境变量,告知 Ansible 跳过日志缓存并尽可能快地显示输出。最后,我们使用工作流模块sh调用 Ansible 剧本,sh模块可以运行任何 Shell 脚本。由于我们可能会根据 Jenkins 作业的类型运行不同的剧本,因此我们将剧本名称作为函数变量传递。
接下来我们将探讨的函数负责构建测试:
def buildTests(serviceName, registryIpPort) {
stage "Build tests"
def tests = docker.image("${registryIpPort}/${serviceName}-tests")
try {
tests.pull()
} catch(e) {}
sh "docker build -t \"${registryIpPort}/${serviceName}-tests\" \
-f Dockerfile.test ."
tests.push()
}
这次,我们使用 docker 模块来声明 Docker 镜像,并将结果赋值给 tests 变量。接下来,我们拉取镜像,运行一个 Shell 脚本,在发生变化时构建一个新的镜像,最后将结果推送到注册表。请注意,镜像拉取是在 try/catch 语句中进行的。当第一次运行工作流时,不会有镜像可以拉取,如果没有 try/catch 语句,脚本会失败。
接下来是运行测试和构建服务镜像的功能:
def runTests(serviceName, target, extraArgs) {
stage "Run ${target} tests"
sh "docker-compose -f docker-compose-dev.yml \
-p ${serviceName} run --rm ${extraArgs} ${target}"
}
def buildService(serviceName, registryIpPort) {
stage "Build service"
def service = docker.image("${registryIpPort}/${serviceName}")
try {
service.pull()
} catch(e) {}
docker.build "${registryIpPort}/${serviceName}"
service.push()
}
这两个功能使用了与我们已讨论的相同指令,因此我们跳过它们。
部署服务的功能可能需要进一步的解释:
def deploy(serviceName, prodIp) {
stage "Deploy"
withEnv(["DOCKER_HOST=tcp://${prodIp}:2375"]) {
try {
sh "docker-compose pull app"
} catch(e) {}
sh "docker-compose -p ${serviceName} up -d app"
}
}
新的指令是 withEnv。我们使用它来创建一个作用域有限的环境变量。它只会存在于大括号内声明的指令中。在这种情况下,环境变量 DOCKER_HOST 仅用于在远程主机上拉取和运行 app 容器。
最后的功能更新了代理服务:
def updateProxy(serviceName, proxyNode) {
stage "Update proxy"
stash includes: 'nginx-*', name: 'nginx'
node(proxyNode) {
unstash 'nginx'
sh "sudo cp nginx-includes.conf /data/nginx/includes/${serviceName}.conf"
sh "sudo consul-template \
-consul localhost:8500 \
-template \"nginx-upstreams.ctmpl:/data/nginx/upstreams/${serviceName}.conf:docker kill -s HUP nginx\" \
-once"
}
}
新的指令是 stash 和 unstash。由于我们在不同的节点(定义为 proxyNode 变量)上更新代理服务,我们必须将一些文件从 cd 服务器中 stash(暂存),然后在代理节点上 unstash(取出)。换句话说,stash/unstash 组合等同于将文件从一个服务器或目录复制到另一个服务器或目录。
总的来说,使用 Jenkins Workflow 和 Groovy DSL 的方法去除了 Ansible 中定义的部署需求。我们将继续使用 Ansible playbook 进行配置和管理,因为这些是它真正擅长的领域。另一方面,Jenkins Workflow 和 Groovy DSL 在定义部署过程时提供了更多的力量、灵活性和自由度。主要的区别在于,Groovy 是一种脚本语言,因此在此类任务中提供了更好的语法。同时,它与 Jenkins 的集成使我们能够利用一些强大的功能。例如,我们可以定义五个带有 tests 标签的节点。如果稍后我们指定某些 Workflow 指令应在 tests 节点上运行,Jenkins 将确保使用这五个节点中最少使用的那个(或者根据我们设置的不同逻辑,也可能有其他的选择标准)。
同时,通过使用 Jenkins Workflow,我们避免了传统 Jenkins 作业所需的复杂且难以理解的 XML 定义,并减少了作业的总数。Workflow 提供了许多其他优势,我们稍后会讨论。最终的结果是一个单一脚本,远比我们以前的 Ansible 部署任务要简短,同时也更容易理解和更新。我们在 Jenkins 擅长的任务上取得了成功,同时保持 Ansible 用于服务器配置和管理。最终结果是一个结合了两者优点的方案。
让我们再看一遍 books-ms 作业的配置。请在你喜欢的浏览器中打开 books-ms 配置 屏幕。你会看到该作业只包含两组配置。它以参数开始,以我们之前讨论的工作流脚本结束。脚本本身可以非常通用,因为差异通过参数声明。我们可以为所有服务复制这个作业,唯一的区别就是 Jenkins 参数。这样,这些作业的管理可以通过在 roles/jenkin 文件夹中的 s/templates/service-workflow-config.xml 文件中定义的单一 Ansible 模板来处理。
让我们构建一下作业,看看它的表现。请打开 books-ms 构建 屏幕。你会看到参数已经预先定义了合理的值。服务的名称是 books-ms 参数,生产服务器的 IP 是 prodIp 参数,代理服务器的 IP 是 proxyIp 参数,最后,Docker 仓库的 IP 和端口被定义为 registryIpPort 参数。点击 构建 按钮后,部署将会开始:

图 12-07 – books-ms Jenkins 工作流作业的构建屏幕
我们可以通过打开最后一次构建的 books-ms 控制台 屏幕来监控作业的执行情况:

图 12-08 – books-ms Jenkins 工作流作业的控制台屏幕
如你所知,我们的部署过程涉及很多步骤,日志可能会非常庞大,导致我们难以快速找到所需内容。幸运的是,Jenkins 工作流作业有一个 工作流步骤 功能可以帮助我们。当执行完成后,请在导航至最后一个 books-ms 构建 后,点击工作流步骤链接。你将看到每个阶段和步骤都会显示一个链接(图标表示终端屏幕),该链接允许我们仅调查与该步骤相关的日志:

图 12-09 – books-ms Jenkins 工作流作业的工作流步骤屏幕
Jenkins 工作流远不止我们在这里展示的内容。请花一些时间浏览在线教程,以便更熟悉它。作为练习,举个例子,向脚本中添加电子邮件通知。在探索 Jenkins 工作流时,请确保选中位于 books-ms 配置 屏幕下方的代码片段生成器复选框。这是一个非常有用的方式,可以帮助我们了解每个代码片段的作用以及如何使用它。
尽管 Workflow 相对于通过 playbook 定义的部署提供了很多好处,但通过 Ansible 管理脚本仍然是次优的解决方案。更好的方法是将部署流水线作为脚本放在代码仓库中,与其他服务代码一起管理。这样,维护服务的团队将完全掌控部署过程。除了需要将 Workflow 脚本放入代码仓库外,如果 Jenkins 作业不仅能处理主分支,还能处理所有分支或我们选择值得管理的分支,那将非常有益。幸运的是,Multibranch Workflow 插件和 Jenkinsfile 都能实现这两项改进。
配置 Jenkins Multibranch Workflow 和 Jenkinsfile
Jenkins Multibranch Workflow 插件新增了一种工作类型,使我们可以将 Workflow 脚本保存在代码仓库中。此类工作会为它在仓库中找到的每个分支创建一个子项目,并期望在每个分支中找到 Jenkinsfile。这使得我们可以将 Workflow 脚本保存在仓库中,而不是将其集中在 Jenkins 内部。这样一来,负责项目的开发人员可以完全自由地定义部署流水线。由于每个分支都会创建一个具有不同 Jenkinsfile 的单独 Jenkins 项目,我们可以根据分支的类型微调流程。例如,我们可以决定在主分支中的 Jenkinsfile 中定义完整的流水线,而选择仅在功能分支中定义构建和测试任务。还有更多。Jenkins 不仅会检测所有分支并保持该列表的更新,还会在相应的分支被删除时移除子项目。
让我们试试 Multibranch Workflow 和 Jenkinsfile。首先打开 books-ms-multibranch job。你会看到一条消息,说明该项目扫描 SCM 中的分支,并为每个分支生成一个工作,但是你没有配置任何分支。请点击左侧菜单中的 Branch Indexing,然后点击 Run Now 链接。Jenkins 将根据我们在配置中指定的过滤器对所有匹配的分支进行索引。一旦分支被索引,它将为每个分支创建子项目并启动构建。让我们在构建进行时探索一下该工作的配置。
请打开 books-ms-multibranch configuration 屏幕。工作配置中唯一重要的部分是 Branch Sources。我们使用它来定义代码仓库。请注意 Advanced 按钮。点击后,你会看到仅包含名称中含有 workflow 的分支。这个设置配置的原因有两个。第一个是为了演示过滤哪些分支将被包含的选项,另一个是为了避免在内存和处理能力有限的虚拟机中构建过多分支(cd 节点只有 1 个 CPU 和 1 GB 的内存)。
到目前为止,分支索引可能已经完成。如果你返回到 books-ms-multibranch 作业屏幕,你会看到两个项目符合过滤条件,jenkins-workflow 和 jenkins-workflow-simple,并且 Jenkins 已启动了这两个项目的构建。由于 cd 节点配置为只允许一个执行器,第二次构建将在第一次构建完成后才会开始。让我们来看看这些分支中的 Jenkinsfile。
jenkins-workflow 分支中的 Jenkinsfile 如下:
node("cd") {
def serviceName = "books-ms"
def prodIp = "10.100.198.201"
def proxyIp = "10.100.198.201"
def registryIpPort = "10.100.198.200:5000"
git url: "https://github.com/vfarcic/${serviceName}.git"
def flow = load "/data/scripts/workflow-util.groovy"
flow.provision("prod2.yml")
flow.buildTests(serviceName, registryIpPort)
flow.runTests(serviceName, "tests", "")
flow.buildService(serviceName, registryIpPort)
flow.deploy(serviceName, prodIp)
flow.updateProxy(serviceName, "prod")
flow.runTests(serviceName, "integ", "-e DOMAIN=http://${proxyIp}")
}
这个脚本几乎与我们之前在处理嵌入在 Jenkins 作业 books-ms 中的 Jenkins 工作流时定义的脚本相同。唯一的区别是,这次变量是定义在脚本内部,而不是使用 Jenkins 属性。由于项目团队现在完全负责这个过程,因此无需将这些变量外部化。我们达成了与之前相同的结果,但这次我们将脚本移到了代码仓库中。jenkins-workflow-simple 分支中的 Jenkinsfile 更加简单:
node("cd") {
def serviceName = "books-ms"
def registryIpPort = "10.100.198.200:5000"
git url: "https://github.com/vfarcic/${serviceName}.git"
def flow = load "/data/scripts/workflow-util.groovy"
flow.buildTests(serviceName, registryIpPort)
flow.runTests(serviceName, "tests", "")
}
通过检查脚本,我们可以得出结论,创建该分支的开发者希望通过 Jenkins 每次提交代码时都运行测试。他从中删除了部署和部署后测试,因为代码可能还没有准备好部署到生产环境,或者政策规定只有主分支或其他选定的分支的代码才会被部署。一旦他合并代码,将会运行另一个脚本,并将他的更改部署到生产环境,前提是他没有引入任何错误,且流程成功。
引入 Multibranch Workflow 和 Jenkinsfile 大大改善了我们的部署流水线。我们在 cd 节点中有一个工具脚本,供其他人重用常见功能。从那时起,我们允许每个团队将他们的脚本托管在自己仓库中的 Jenkinsfile 中。此外,我们给予了他们自由,不仅决定构建、测试和部署服务的合适方式,还能够根据每个分支的需求灵活调整流程。
最终思考
这只是对 CI/CD 工具,特别是 Jenkins 的简要介绍。除了需要使用 CI/CD 工具外,Jenkins 将是下一章的一个基石。我们将在 蓝绿部署 工具集中使用它。如果你是 Jenkins 新手,我建议你先暂停阅读这本书,花些时间了解 Jenkins,阅读一些教程并尝试不同的插件。投入到 Jenkins 上的时间确实是一个有价值的投资,很快就会得到回报。
Jenkins 工作流的引入与 Docker 和 Multibranch 插件的结合,证明是我们工具库中不可或缺的补充。我们在使用 Jenkins UI 提供的所有功能的同时,依然保持了脚本提供的部署管道灵活性。工作流领域特定语言(DSL)和 Groovy 融合了两者的优点。通过工作流领域特定语言(DSL),我们有了专门为部署目的量身定制的语法和功能。另一方面,Groovy 本身提供了当 DSL 功能不足时我们可能需要的一切。同时,我们几乎可以访问 Jenkins 提供的任何功能。Docker 对工作流的补充提供了几个有用的快捷方式,Multibranch 与 Jenkinsfile 一起使得我们可以将管道(或其一部分)应用于所有分支(或我们选择的分支)。总的来说,我们将高层次与低层次工具结合成了一个强大且易于使用的组合。
我们通过 Ansible 创建 Jenkins 作业的方式远未理想。我们本可以使用像 Template Project Plugin 这样的 Jenkins 插件来创建模板。然而,这些插件并没有一个是完美的,都存在一些不足之处。CloudBees 的 Jenkins Enterprise Edition 确实提供了解决模板化及其他许多问题的工具。然而,到目前为止,我们使用的所有示例都是基于开源软件的,并且我们将继续沿用这种方式贯穿整本书。这并不意味着付费解决方案就不值得投资。它们往往是值得的,应该进行评估。如果你选择使用 Jenkins,并且你的项目或组织的规模足以支持这一投资,我建议你评估一下 Jenkins Enterprise Edition。它相较于开源版本带来了许多改进。
考虑到我们拥有的工具以及运行部署步骤的相对统一方式,当前的解决方案可能是我们能做到的最好选择,现在是时候进入下一个主题,探索我们可以从 蓝绿部署 中获得的好处。
在我们继续之前,让我们销毁本章中使用的虚拟机(VMs):
exit
vagrant destroy -f
第十三章:蓝绿部署
传统上,我们通过替换当前版本来部署新版本。旧版本被停止,新版本取而代之。这个方法的问题在于,从停止旧版本到新版本完全投入运行之间会出现停机时间。不管你多么快速地尝试执行这个过程,总会有一些停机时间。这可能只是几毫秒,也可能持续几分钟,或者在极端情况下,甚至几个小时。采用单体应用程序会引入额外的问题,例如,必须等待相当长的时间才能初始化应用程序。人们试图通过各种方式解决这个问题,其中大多数人使用了某种变种的蓝绿部署过程。其背后的理念很简单:在任何时候,都应该有一个版本在运行,这意味着在部署过程中,我们必须并行部署新版本和旧版本。新版本和旧版本分别称为蓝色和绿色版本。

图 13-1 – 在任何时刻,至少有一个服务版本在运行
我们将一个版本作为当前版本运行,启动另一个版本作为新版本,一旦它完全投入运行,就将所有流量从当前版本切换到新版本。这个切换通常通过路由器或代理服务来完成。
通过蓝绿部署过程,我们不仅消除了部署停机时间,还减少了部署可能带来的风险。无论我们在软件到达生产节点之前进行了多么充分的测试,总是有可能出现问题。当问题发生时,我们仍然可以依赖当前版本。直到新版本经过充分测试,并且验证了生产节点的一些特定问题的合理失败可能性之前,都没有必要将流量切换到新版本。这通常意味着在部署之后、切换之前需要进行集成测试。即使这些验证返回了假阴性,并且在流量重定向后发生了故障,我们仍然可以快速切换回旧版本,将系统恢复到之前的状态。我们可以比需要从备份恢复应用程序或重新部署更快地回滚。
如果我们将蓝绿部署过程与不可变部署结合起来(过去通过虚拟机,今天通过容器实现),那么结果将是一个非常强大、安全且可靠的部署流程,可以更频繁地执行。如果架构基于微服务并与容器结合使用,我们就不需要两个节点来执行此过程,可以并行运行两个版本。
这种方法的主要挑战在于数据库。在许多情况下,我们需要以一种支持两个版本的方式升级数据库模式,然后继续进行部署。这种数据库升级可能带来的问题通常与版本之间的时间间隔有关。当版本发布频繁时,数据库模式的变化通常较小,这使得在两个版本之间保持兼容性变得容易。如果版本之间隔了几周或几个月,数据库的变化可能会非常大,以至于向后兼容性可能变得不可能或不值得做。如果我们的目标是持续交付或部署,那么两个版本之间的时间应该很短,或者如果时间较长,代码库的变化量应该相对较小。
蓝绿部署过程
蓝绿部署过程应用于打包为容器的微服务时,步骤如下。
当前的版本(例如蓝色)正在服务器上运行。所有流量都通过代理服务路由到该版本。微服务是不可变的,并以容器形式部署。

图 13-2 – 作为容器部署的不可变微服务
当新版本(例如绿色)准备好部署时,我们将其与当前版本并行运行。这样,我们可以在不影响用户的情况下测试新版本,因为所有流量仍然会发送到当前版本。

图 13-3 – 新版本的不可变微服务与旧版本并排部署
一旦我们认为新版本按预期工作,我们会更改代理服务的配置,使流量重定向到该版本。大多数代理服务会允许现有请求继续使用旧的代理配置完成执行,这样就不会中断服务。

图 13-4 – 代理已重新配置以指向新版本
当所有发往旧版本的请求都收到响应后,先前的服务版本可以被移除,或者更好的是,停止运行。如果使用后者选项,万一新版本失败,回滚几乎是瞬间的,因为我们只需将旧版本重新启动即可。

图 13-5 – 旧版本被移除
了解了蓝绿部署过程的基本逻辑后,我们可以尝试进行设置。我们将从手动命令开始,一旦熟悉了过程的实际操作,我们将尝试自动化这一过程。
我们需要确保常规的两个节点(cd和prod)处于运行状态,因此我们需要创建并配置虚拟机(VM)。
vagrant up cd prod
vagrant ssh cd
ansible-playbook /vagrant/ansible/prod2.yml \
-i /vagrant/ansible/hosts/prod
手动运行蓝绿部署
请注意,我们将在尝试实现之前目标的基础上进行整个蓝绿部署过程。我们不仅要并行运行两个发布版本,还要确保在多个阶段中彻底测试其中的所有内容。这将使过程比假设一切正常的蓝绿部署程序更复杂。大多数实现都没有考虑到在更改代理服务之前需要进行测试。我们可以,也将,做得更好。另一个需要注意的事情是,我们将探索手动步骤,以便你理解整个过程。之后,我们将使用已经熟悉的工具自动化所有操作。我选择这种方式是为了确保你理解持续部署和蓝绿部署结合背后的复杂性。通过真正理解如何手动执行,你将能够做出明智的决定,判断我们将在本书后续章节中探索的工具的好处是否大于它们所缺失的部分。
我们将首先下载上一章中使用的 Docker Compose 和 nginx 配置文件。
mkdir books-ms
cd books-ms
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/docker-compose.yml
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-includes.conf
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-upstreams-blue.ctmpl
wget https://raw.githubusercontent.com/vfarcic\
/books-ms/master/nginx-upstreams-green.ctmpl
在所有配置文件准备好之后,让我们部署第一次发布。我们之前探索的工具将派上用场。我们将使用 Consul 作为服务注册中心,使用 Registrator 来注册和注销容器,使用 nginx 作为代理服务,使用 Consul Template 来生成配置并重新加载 nginx。
部署蓝色版本
由于此时我们还没有启动books-ms服务,我们将第一次发布命名为blue。目前我们唯一需要做的就是确保我们将要运行的容器名称中包含blue字样,以避免与下一个发布版本冲突。我们将使用 Docker Compose 来运行容器,让我们快速查看一下我们刚刚下载的docker-compose.yml文件中定义的目标(仅展示相关目标)。
...
base:
image: 10.100.198.200:5000/books-ms
ports:
- 8080
environment:
- SERVICE_NAME=books-ms
app-blue:
extends:
service: base
environment:
- SERVICE_NAME=books-ms-blue
links:
- db:db
app-green:
extends:
service: base
environment:
- SERVICE_NAME=books-ms-green
links:
- db:db
...
我们不能直接使用app目标,因为我们将部署两个不同的目标(每种颜色一个),以避免它们互相覆盖。此外,我们还希望在 Consul 中区分它们,因此SERVICE_NAME环境变量应该是唯一的。为此,我们创建了两个新目标,分别是app-blue和app-green。这些目标以与之前章节中app目标扩展base服务相同的方式扩展base服务。app-blue和app-green目标与base服务之间的唯一区别是(除了目标名称外)环境变量SERVICE_NAME。
定义了这两个目标后,我们可以部署蓝色版本。
export DOCKER_HOST=tcp://prod:2375
docker-compose pull app-blue
docker-compose up -d app-blue
我们从注册中心拉取了最新版本,并将其作为服务的蓝色版本启动。为了确保安全,我们快速检查一下服务是否正在运行,并且已经在 Consul 中注册。
docker-compose ps
curl prod:8500/v1/catalog/service/books-ms-blue \
| jq '.'
两个命令的输出合并如下。
Name Command State Ports
----------------------------------------------------------------
booksms_app-blue_1 /run.sh Up 0.0.0.0:32768->8080/tcp
booksms_db_1 /entrypoint.sh mongod Up 27017/tcp
...
[
{
"ModifyIndex": 38,
"CreateIndex": 38,
"Node": "prod",
"Address": "10.100.198.201",
"ServiceID": "prod:booksms_app-blue_1:8080",
"ServiceName": "books-ms-blue",
"ServiceTags": [],
"ServiceAddress": "10.100.198.201",
"ServicePort": 32768,
"ServiceEnableTagOverride": false
}
]
第一个命令显示app-blue和db容器都在运行。第二个命令显示了在 Consul 中注册的books-ms-blue服务的详细信息。现在,我们已经启动了服务的第一个版本,但它还没有与 nginx 集成,因此无法通过端口 80 访问。我们可以通过向该服务发送请求来确认这一点。
curl -I prod/api/v1/books
输出如下。
HTTP/1.1 404 Not Found
Server: nginx/1.9.9
Date: Sun, 03 Jan 2016 20:47:59 GMT
Content-Type: text/html
Content-Length: 168
Connection: keep-alive
请求响应是404 Not Found错误消息,证明我们还没有配置代理。

图 13-6 – 蓝色容器已部署
集成蓝色版本
我们可以以类似之前的方式集成该服务。唯一的区别是我们在 Consul 中注册的服务目标。
让我们首先看看我们之前下载的 nginx Consul 模板nginx-upstreams-blue.ctmpl。
upstream books-ms {
{{range service "books-ms-blue" "any"}}
server {{.Address}}:{{.Port}};
{{end}}
}
服务名称是books-ms-blue,我们可以通过运行 Consul Template 来生成最终的 nginx upstream 配置。
consul-template \
-consul prod:8500 \
-template "nginx-upstreams-blue.ctmpl:nginx-upstreams.conf" \
-once
运行的命令是 Consul Template,它生成了 nginx upstream 配置文件并重新加载了服务。
让我们检查配置文件是否确实已正确创建。
cat nginx-upstreams.conf
输出如下。
upstream books-ms {
server 10.100.198.201:32769;
}
最后,剩下的就是将配置文件复制到prod服务器并重新加载nginx。系统提示时,请使用vagrant作为密码。
scp nginx-includes.conf \
prod:/data/nginx/includes/books-ms.conf
scp nginx-upstreams.conf \
prod:/data/nginx/upstreams/books-ms.conf
docker kill -s HUP nginx
我们将两个配置文件复制到服务器并通过发送HUP信号重新加载nginx。
让我们检查一下我们的服务是否确实与代理集成。
curl -I prod/api/v1/books
输出如下。
HTTP/1.1 200 OK
Server: nginx/1.9.9
Date: Sun, 03 Jan 2016 20:51:12 GMT
Content-Type: application/json; charset=UTF-8
Content-Length: 2
Connection: keep-alive
Access-Control-Allow-Origin: *
这次,响应代码是200 OK,表明服务确实回应了请求。

图 13-7 – 蓝色容器与代理服务集成
我们已经完成了最简单的场景——部署了第一个(蓝色)版本。正如你很快将看到的,部署第二个(绿色)版本的过程不会有太大不同。
部署绿色版本
第二次(绿色)版本的部署可以通过与第一次(蓝色)版本相同的步骤进行。唯一的区别是这次我们将部署books-ms-green而不是books-ms-blue目标。
与上一次的部署不同,这次新版本(绿色)将与当前版本(蓝色)并行运行。
docker-compose pull app-green
docker-compose up -d app-green
新版本已被拉取并运行。我们可以通过运行docker-compose ps命令来确认。
docker-compose ps
结果如下。
Name Command State Ports
-----------------------------------------------------------------
booksms_app-blue_1 /run.sh Up 0.0.0.0:32769->8080/tcp
booksms_app-green_1 /run.sh Up 0.0.0.0:32770->8080/tcp
booksms_db_1 /entrypoint.sh mongod Up 27017/tcp
输出显示两个服务(蓝色和绿色)并行运行。同样,我们可以确认两个版本都已在 Consul 中注册。
curl prod:8500/v1/catalog/services \
| jq '.'
输出如下。
{
"dockerui": [],
"consul": [],
"books-ms-green": [],
"books-ms-blue": []
}
如之前所示,我们也可以检查新部署的服务的详细信息。
curl prod:8500/v1/catalog/service/books-ms-green \
| jq '.'
最后,我们可以确认旧版本仍然可以通过代理访问。
curl -I prod/api/v1/books
docker logs nginx
最后一个命令的输出应该类似于以下内容(为了简洁,已删除时间戳)。
"GET /api/v1/books HTTP/1.1" 200 201 "-" "curl/7.35.0" "-" 10.100.198.201:32769
"GET /api/v1/books HTTP/1.1" 200 201 "-" "curl/7.35.0" "-" 10.100.198.201:32769
请记住,部署在您计算机上的服务端口可能与前面示例中的端口不同。
nginx 日志的输出应该显示我们发出的请求被重定向到蓝色版本的端口。我们可以通过检查最后一个请求是否进入了与我们在部署 green 版本之前相同的端口来观察这一点。

图 13-8 – 绿色容器与蓝色容器并行部署
此时,我们有两个版本(蓝色和绿色)并行运行,代理服务仍然将所有请求重定向到旧版本(蓝色)。下一步应该是在更改代理配置之前先测试新的版本。我们将在自动化部分之前跳过测试,直接进入绿色版本与 nginx 的集成。
集成绿色版本
将第二个(绿色)版本与代理服务集成的过程与我们之前所做的类似。
consul-template \
-consul prod:8500 \
-template "nginx-upstreams-green.ctmpl:nginx-upstreams.conf" \
-once
scp nginx-upstreams.conf \
prod:/data/nginx/upstreams/books-ms.conf
docker kill -s HUP nginx
我们可以向代理发送请求,并检查其日志,以查看它是否确实指向新的(绿色)版本。
curl -I prod/api/v1/books
docker logs nginx
nginx 日志应该类似于以下内容(时间戳已移除以简化显示)。
"GET /api/v1/books HTTP/1.1" 200 201 "-" "curl/7.35.0" "-" 10.100.198.201:32769
"GET /api/v1/books HTTP/1.1" 200 201 "-" "curl/7.35.0" "-" 10.100.198.201:32769
"GET /api/v1/books HTTP/1.1" 200 201 "-" "curl/7.35.0" "-" 10.100.198.201:32770
很明显,最后一个请求进入了不同的端口(32770),与我们之前的请求所用的端口(32769)不同。我们将代理从蓝色版本切换到了绿色版本。由于我们等到新的版本完全启动并运行后才更改代理配置,因此整个过程中没有出现停机时间。此外,nginx 足够智能,只有在重新加载后才会将配置更改应用于所有请求,而不是应用于所有请求。换句话说,所有在重新加载前发起的请求继续使用旧版本,而所有在重新加载后发起的请求则会被发送到新版本。我们通过最小的努力,使用 nginx 作为代理和 Consul(配合 Registrator 和 Consul Template)来存储和获取服务信息,实现了零停机时间。

图 13-9 – 绿色容器与代理服务集成
到目前为止,我们所做的操作使得新的版本与旧版本并行部署,并且代理已更改为指向新的版本。现在我们可以安全地移除旧版本了。
移除蓝色版本
移除版本很简单,我们之前已经做过很多次。我们只需要确保在运行 stop 命令时使用了正确的目标。
docker-compose stop app-blue
docker-compose ps
第一个命令停止了蓝色版本,第二个命令列出了所有指定为 Docker Compose 目标的进程。列出进程的命令输出如下。
Name Command State Ports
-----------------------------------------------------------------
booksms_app-blue_1 /run.sh Exit 137
booksms_app-green_1 /run.sh Up 0.0.0.0:32770->8080/tcp
booksms_db_1 /entrypoint.sh mongod Up 27017/tcp
请注意,booksms_app-blue_1 的状态是 Exit 137。只有绿色版本和数据库容器在运行。
我们还可以通过向 Consul 发送请求来确认这一点。
curl prod:8500/v1/catalog/services | jq '.'
Consul 的响应如下。
{
"dockerui": [],
"consul": [],
"books-ms-green": []
}
Registrator 检测到蓝色版本被移除,并从 Consul 中删除了它。
我们还需要检查绿色版本是否仍然与代理服务集成。
curl -I prod/api/v1/books
正如预期的那样,nginx 仍然将所有请求发送到绿色版本,我们的工作完成了(暂时)。总结来说,我们在旧版本并行部署了新版本,修改了代理服务使其指向新版本,等到所有与旧版本关联的请求完成响应后,移除了旧版本。

图 13-10 – 蓝色容器被移除
在我们进行自动化部署之前,剩下的唯一任务是找到一种更好的方法来确定要部署哪个版本(蓝色或绿色)。在手动操作时,我们可以通过简单地列出 Docker 进程或在 Consul 中注册的服务,并观察哪个颜色没有运行,从而轻松找到这个信息。自动化部署将需要稍微不同的方法。我们需要发现应该运行哪个版本。
让我们移除容器并重新开始。
docker-compose stop
docker-com
pose rm -f
确定要部署哪个版本并回滚
确定下一个要部署的颜色的一种方法是将已部署的颜色存储到 Consul 中,并在下次部署时使用该信息。换句话说,我们应该有两个过程:颜色发现和颜色注册。
让我们考虑一下颜色发现的使用场景。有三种可能的组合:
-
我们正在部署第一个版本,注册表中没有存储颜色信息。
-
蓝色版本正在运行,并已存储在注册表中。
-
绿色版本正在运行,并已存储在注册表中。
我们可以将这些组合减少为两种。如果注册了蓝色版本,下一步就是绿色。否则,下一步就是蓝色,涵盖当前颜色为绿色或未注册任何颜色(即服务从未部署过)这两种情况。通过这种策略,我们可以创建以下 bash 脚本(请不要现在运行它)。
#!/usr/bin/env bash
SERVICE_NAME=$1
PROD_SERVER=$2
CURR_COLOR=`curl \
http://$PROD_SERVER:8500/v1/kv/$SERVICE_NAME/color?raw`
if [ "$CURR_COLOR" == "blue" ]; then
echo "green"
else
echo "blue"
fi
由于我们可以使用相同的脚本来处理多个服务,它接受两个参数:我们即将部署的服务名称和目标(生产)服务器。然后,我们在生产服务器上查询 Consul 并将结果存储到 CURR_COLOR 变量中。接着是一个简单的 if…else 语句,将 green 或 blue 字符串输出到 STDOUT。有了这样的脚本,我们可以轻松地获取应该用于部署服务的颜色。
让我们创建脚本:
echo '#!/usr/bin/env bash
SERVICE_NAME=$1
PROD_SERVER=$2
CURR_COLOR=`curl \
http://$PROD_SERVER:8500/v1/kv/$SERVICE_NAME/color?raw`
if [ "$CURR_COLOR" == "blue" ]; then
echo "green"
else
echo "blue"
fi
' | tee get-color.sh
chmod +x get-color.sh
我们创建了 get-color.sh 脚本并赋予了它可执行权限。现在我们可以使用它来获取下一个颜色,并重复之前练习过的过程。
NEXT_COLOR=`./get-color.sh books-ms prod`
export DOCKER_HOST=tcp://prod:2375
docker-compose pull app-$NEXT_COLOR
docker-compose up -d app-$NEXT_COLOR
与我们之前执行的命令唯一的不同之处在于,我们使用了 NEXT_COLOR 变量,而不是硬编码的 blue 和 green 值。因此,我们已经成功启动了第一个版本(蓝色)。

图 13-11 – 当前版本的颜色从 Consul 中获取
让我们借此机会简短讨论一下测试。 一方面,我们希望在更改代理指向新发布版本之前尽可能多地进行测试。另一方面,我们仍然需要在代理更改后进行一次测试,以确保一切(包括代理的更改)按预期运行。我们将这两种类型的测试称为预集成测试和后集成测试。请记住,它们的范围应仅限于那些无法通过预部署测试覆盖的情况。对于(相对较小的)books-ms服务来说,如果预集成测试验证服务能够与数据库通信,那么这就足够了。在这种情况下,集成后唯一需要检查的就是 nginx 是否已正确重新配置。
我们从预集成测试开始。我们将使用curl模拟测试。由于代理尚未更改为指向新部署的服务,我们需要找出新发布服务的端口。我们可以从 Consul 中找到端口,并创建一个类似于get-color.sh的脚本。可以使用以下命令创建脚本。
echo '#!/usr/bin/env bash
SERVICE_NAME=$1
PROD_SERVER=$2
COLOR=$3
echo `curl \
$PROD_SERVER:8500/v1/catalog/service/$SERVICE_NAME-$COLOR \
| jq ".[0].ServicePort"`
' | tee get-port.sh
chmod +x get-port.sh
这次,我们创建了一个名为get-port.sh的脚本,包含三个参数:服务名称、生产服务器地址和颜色。通过这三个参数,我们从 Consul 查询信息并将结果发送到 STDOUT。
让我们尝试一下。
NEXT_PORT=`./get-port.sh books-ms prod $NEXT_COLOR`
echo $NEXT_PORT
输出会因 Docker 随机分配给我们的服务的端口不同而有所不同。通过将端口存储在变量中,我们可以在将服务与代理集成之前进行测试。
curl -I prod:$NEXT_PORT/api/v1/books
服务返回了状态码200 OK,所以我们可以按照之前的方式继续进行集成。当需要时,请使用vagrant作为密码。
consul-template \
-consul prod:8500 \
-template "nginx-upstreams-$NEXT_COLOR.ctmpl:nginx-upstreams.conf" \
-once
scp nginx-upstreams.conf \
prod:/data/nginx/upstreams/books-ms.conf
docker kill -s HUP nginx
服务集成后,我们可以再次进行测试,但这次不需要使用端口。
curl -I prod/api/v1/books
最后,我们应该停止其中一个容器。停止哪一个容器取决于测试结果。如果预集成测试失败,我们应该停止新的发布版本。此时,代理不需要做任何操作,因为它仍然将所有请求发送到旧版本。另一方面,如果集成后测试失败,不仅需要停止新的发布版本,还应该将代理服务的更改回滚,以便所有流量返回到旧版本。此时,我们不会逐一列举所有可能需要采取的路径。这些内容会留待我们稍后探索的自动化部分。现在,我们将颜色信息放入 Consul 注册表,并停止旧版本。
curl -X PUT -d $NEXT_COLOR \
prod:8500/v1/kv/books-ms/color
CURR_COLOR=`./get-color.sh books-ms prod`
docker-compose stop app-$CURR_COLOR
这一系列命令将新颜色加入注册表,获取下一个应该与旧版本颜色相同的颜色,最后停止旧版本。由于我们重新开始并且这是第一次发布,因此没有旧版本需要停止。然而,下次运行该过程时,旧版本将会被停止。

图 13-12 – 当前版本的颜色被发送到 Consul
通过这种方式,我们结束了蓝绿部署的手动过程。它的实现方式使得这一过程可以轻松自动化。在继续之前,让我们再运行几次这些命令,并观察颜色从蓝色变为绿色,再从绿色变为蓝色,依此类推。所有命令汇总如下。
NEXT_COLOR=`./get-color.sh books-ms prod`
docker-compose pull app-$NEXT_COLOR
docker-compose up -d app-$NEXT_COLOR
NEXT_PORT=`./get-port.sh books-ms prod $NEXT_COLOR`
consul-template \
-consul prod:8500 \
-template "nginx-upstreams-$NEXT_COLOR.ctmpl:nginx-upstreams.conf" \
-once
scp nginx-upstreams.conf \
prod:/data/nginx/upstreams/books-ms.conf
docker kill -s HUP nginx
curl -I prod/api/v1/books
curl -X PUT -d $NEXT_COLOR \
prod:8500/v1/kv/books-ms/color
CURR_COLOR=`./get-color.sh books-ms prod`
docker-compose stop app-$CURR_COLOR
curl -I prod/api/v1/books
docker-compose ps
最后一个命令显示了 Docker 进程。你会看到,在第一次运行之后,绿色版本会在运行状态,而蓝色版本则处于“已退出”状态;接着,在下一次运行后,蓝色版本会处于运行状态,绿色版本则会进入“已退出”状态,依此类推。我们成功地在没有任何停机时间的情况下部署了新版本。唯一的例外是如果集成后测试失败,这种情况发生的可能性非常小,因为唯一的原因可能是代理服务本身由于配置错误导致的故障。由于这个过程将很快完全自动化,这种情况发生的可能性非常小。集成后测试失败的另一个原因是代理服务本身的故障。要消除这种可能性,唯一的办法是拥有多个代理服务实例(此书范围外)。
话虽如此,让我们来看一下 nginx 日志。
docker logs nginx
你会注意到,我们每次发送的请求都被发送到不同的端口,这意味着一个新的容器确实已经被部署,并在新的端口上运行。
现在,在完成所有命令和实验后,我们已经准备好开始蓝绿部署过程的自动化工作。
我们将销毁虚拟机并重新开始,以确保一切正常运行。
exit
vagr
ant destroy -f
使用 Jenkins 工作流自动化蓝绿部署
我们将从创建虚拟机开始,配置prod节点,并启动 Jenkins——我们选择的部署工具。
vagrant up cd prod
vagrant ssh cd
ansible-playbook /vagrant/ansible/prod2.yml \
-i /vagrant/ansible/hosts/prod
ansible-playbook /vagrant/ansible/jenkins-node.yml \
-i /vagrant/ansible/hosts/prod
ansible-playbook /vagrant/ansible/jenkins.yml \
-c local
由于还需要几分钟时间才能完成设置,我们来讨论一下应该自动化什么以及如何自动化。我们已经熟悉了 Jenkins 工作流。它为我们提供了很好的支持,所以目前没有理由更换工具。我们将使用它来自动化蓝绿部署过程。这个流程将包含很多步骤,因此我们将把它们分解成多个函数,以便更容易理解,同时也能扩展我们的工作流工具脚本。接下来将详细讨论和实现这些函数。

图 13-13 – 蓝绿部署自动化流程
蓝绿部署角色
我们将使用 Multibranch Workflow Jenkins 作业 books-ms-blue-green。它筛选 vfarcic/books-ms 仓库的分支,仅包含名称中含有 blue-green 的分支。
由于第一次运行可能需要相当长的时间,我们先对分支进行索引,这样 Jenkins 就可以在我们探索脚本的同时运行子项目。
请打开 Jenkins Multibranch Workflow 作业 books-ms-blue-green,点击左侧菜单中的 Branch Indexing 和 Run Now 链接。分支索引完成后,Jenkins 会发现 blue-green 分支符合作业中设置的筛选条件,创建一个相同名称的子项目并开始运行。索引状态可以在屏幕左下角的 master 节点执行器中查看。

图 13-14 – Jenkins Multibranch Workflow 作业 books-ms-blue-green 和蓝绿子项目
我们将让 Jenkins 继续运行构建并探索 blue-green 分支中的 Jenkinsfile。
node("cd") {
def serviceName = "books-ms"
def prodIp = "10.100.198.201"
def proxyIp = "10.100.198.201"
def proxyNode = "prod"
def registryIpPort = "10.100.198.200:5000"
def flow = load "/data/scripts/workflow-util.groovy"
git url: "https://github.com/vfarcic/${serviceName}.git"
flow.provision("prod2.yml")
flow.buildTests(serviceName, registryIpPort)
flow.runTests(serviceName, "tests", "")
flow.buildService(serviceName, registryIpPort)
def currentColor = flow.getCurrentColor(serviceName, prodIp)
def nextColor = flow.getNextColor(currentColor)
flow.deployBG(serviceName, prodIp, nextColor)
flow.runBGPreIntegrationTests(serviceName, prodIp, nextColor)
flow.updateBGProxy(serviceName, proxyNode, nextColor)
flow.runBGPostIntegrationTests(serviceName, prodIp, proxyIp, proxyNode, currentColor, nextColor)
}
文件以声明一些变量开始,然后加载 workflow-util.groovy 脚本。接下来是调用一些函数来配置环境、构建并运行测试,以及构建服务。直到这一点为止,脚本与我们在上一章中探索的脚本相同。
第一个新增的内容是调用实用函数 getCurrentColor 和 getNextColor,并将它们返回的值赋给 currentColor 和 nextColor 变量。以下是这些函数的代码。
def getCurrentColor(serviceName, prodIp) {
try {
return sendHttpRequest("http://${prodIp}:8500/v1/kv/${serviceName}/color?raw")
} catch(e) {
return ""
}
}
def getNextColor(currentColor) {
if (currentColor == "blue") {
return "green"
} else {
return "blue"
}
}
如你所见,这些函数遵循与我们使用手动命令练习时相同的逻辑,只不过这次是翻译成 Groovy 语言。当前颜色从 Consul 获取,并用于推断我们应该部署的下一个颜色。
现在我们知道当前运行的颜色以及下一个应该部署的颜色,我们可以使用 deployBG 部署新版本。该函数如下所示。
def deployBG(serviceName, prodIp, color) {
stage "Deploy"
withEnv(["DOCKER_HOST=tcp://${prodIp}:2375"]) {
sh "docker-compose pull app-${color}"
sh "docker-compose -p ${serviceName} up -d app-${color}"
}
}
我们创建了指向生产节点上 Docker CLI 的 DOCKER_HOST 环境变量。该变量的作用范围仅限于花括号内的命令。在这些命令中,我们拉取最新版本并通过 Docker Compose 运行。与我们在上一章中探索的 Jenkinsfile 脚本相比,唯一重要的不同之处是通过 color 变量动态生成目标。将使用的目标取决于用于调用该函数的 nextColor 的实际值。
在脚本的这一部分,一个新版本被部署,但尚未与代理服务集成。我们的服务用户仍然会使用旧版本,从而为我们提供了在公开发布之前测试新部署版本的机会。我们称之为预集成测试。它们通过调用位于 workflow-util.groovy 脚本中的实用函数 runBGPreIntegrationTests 来运行。
def runBGPreIntegrationTests(serviceName, prodIp, color) {
stage "Run pre-integration tests"
def address = getAddress(serviceName, prodIp, color)
try {
runTests(serviceName, "integ", "-e DOMAIN=http://${address}")
} catch(e) {
stopBG(serviceName, prodIp, color);
error("Pre-integration tests failed")
}
}
该函数首先通过调用getAddress函数从 Consul 获取新部署服务的地址。请通过查看workflow-util.groovy脚本来了解该函数的详细信息。接下来,我们在try…catch块中运行测试。由于新版本仍未与 nginx 集成,因此无法通过端口80访问,我们将新版本的address作为环境变量DOMAIN传递。如果测试执行失败,脚本将跳转到catch块,并调用stopBG函数来停止新版本的服务。由于我们的服务器运行着[Registrator],一旦新版本停止,它的数据将从 Consul 中删除。没有其他操作需要执行。代理服务将继续指向旧版本,用户将通过它继续使用经过验证的旧版本服务。请查看workflow-util.groovy脚本以了解stopBG函数的详细信息。
如果预集成测试通过,我们将调用updateBGProxy函数来更新代理服务,从而使新版本可以对用户可用。该函数如下所示。
def updateBGProxy(serviceName, proxyNode, color) {
stage "Update proxy"
stash includes: 'nginx-*', name: 'nginx'
node(proxyNode) {
unstash 'nginx'
sh "sudo cp nginx-includes.conf /data/nginx/includes/${serviceName}.conf"
sh "sudo consul-template \
-consul localhost:8500 \
-template \"nginx-upstreams-${color}.ctmpl:/data/nginx/upstreams/${serviceName}.conf:docker kill -s HUP nginx\" \
-once"
sh "curl -X PUT -d ${color} http://localhost:8500/v1/kv/${serviceName}/color"
}
}
与上一章节中使用的updateProxy函数相比,主要的区别在于模板名称的使用,即nginx-upstreams-${color}.ctmpl。根据我们传递给函数的值,nginx-upstreams-blue.ctmpl或nginx-upstreams-green.ctmpl将被使用。作为额外的指令,我们向 Consul 发送请求,以存储与新部署版本相关的颜色信息。该函数的其他部分与updateProxy函数相同。
最终,现在新版本已经部署完成,并且代理服务已重新配置,我们正在进行另一轮测试,以确认与代理的集成是否确实正确。我们通过调用位于workflow-util.groovy脚本中的runBGPostIntegrationTests函数来进行测试。
def runBGPostIntegrationTests(serviceName, prodIp, proxyIp, proxyNode, currentColor, nextColor) {
stage "Run post-integration tests"
try {
runTests(serviceName, "integ", "-e DOMAIN=http://${proxyIp}")
} catch(e) {
if (currentColor != "") {
updateBGProxy(serviceName, proxyNode, currentColor)
}
stopBG(serviceName, prodIp, nextColor);
error("Post-integration tests failed")
}
stopBG(serviceName, prodIp, currentColor);
}
我们首先运行集成测试,这次使用指向代理的公共域名。如果测试失败,我们将通过调用updateBGProxy函数来恢复代理服务的更改。通过传递currentColor作为变量,updateBGProxy将重新配置 nginx 以与旧版本服务配合使用。如果测试失败的第二个指令是通过调用stopBG函数并传入nextColor来停止新版本。另一方面,如果所有测试都通过,我们将停止旧版本。
如果你是 Groovy 的新手,这个脚本可能会让你感到不知所措。然而,只要稍加练习,你会发现,对于我们的目的来说,Groovy 是非常简单的,并且借助 Jenkins Workflow DSL,许多事情变得更加容易。
值得注意的是,Workflow 插件是有限制的。出于安全原因,某些 Groovy 类和函数的调用需要获得批准。我已经在通过jenkins.yml Ansible playbook 定义的供应和配置过程中为你完成了这项操作。如果你想查看最终结果或需要做出新的批准,请打开正在处理的脚本审批屏幕,该屏幕位于管理 Jenkins中。刚开始时,这些安全限制可能显得过于严苛,但它们背后的理由是至关重要的。由于 Workflow 脚本可以访问 Jenkins 平台的几乎任何部分,让任何内容在其中运行可能会带来严重后果。因此,某些指令默认被允许,而其他的则需要批准。如果由于这个限制,Workflow 脚本失败,你将在正在处理的脚本审批屏幕中看到一个新的条目,等待你的批准(或拒绝)。这些批准背后的 XML 文件位于/data/jenkins/scri ptApproval.xml文件中。
运行蓝绿部署
希望到这个时候,子项目已经完成运行。你可以通过打开蓝绿子项目控制台屏幕来监控该进程。一旦子项目的第一次运行完成,我们可以手动确认一切是否正确运行。我们将利用这个机会展示一些我们尚未使用的ps参数。第一个是--filter,它可以用来(你猜对了)过滤通过ps命令返回的容器。第二个是--format。由于ps命令的标准输出可能非常长,我们将使用它来只获取容器的名称。
export DOCKER_HOST=tcp://prod:2375
docker ps -a --filter name=books --format "table {{.Names}}"
ps命令的输出如下。
NAMES
booksms_app-blue_1
booksms_db_1
我们可以看到blue版本已经与数据库一起部署。我们还可以确认该服务已存储在 Consul 中。
curl prod:8500/v1/catalog/services | jq '.'
curl prod:8500/v1/catalog/service/books-ms-blue | jq '.'
两个请求对 Consul 的合并输出如下。
{
"dockerui": [],
"consul": [],
"books-ms-blue": []
}
...
[
{
"ModifyIndex": 461,
"CreateIndex": 461,
"Node": "prod",
"Address": "10.100.198.201",
"ServiceID": "prod:booksms_app-blue_1:8080",
"ServiceName": "books-ms-blue",
"ServiceTags": [],
"ServiceAddress": "10.100.198.201",
"ServicePort": 32780,
"ServiceEnableTagOverride": false
}
]
books-ms-blue已经注册为一个服务,除了dockerui和consul之外。第二个输出显示了服务的所有详细信息。
最后,我们应该验证颜色已经存储在 Consul 中,并且服务本身确实与 nginx 集成。
curl prod:8500/v1/kv/books-ms/color?raw
curl -I prod/api/v1/books
第一个命令返回了blue,通过代理请求到服务的状态是200 OK。一切似乎都在正常运行。
请通过打开books-ms-blue-green作业并点击位于右侧的安排蓝绿构建图标,再运行几次作业。
你可以通过打开蓝绿子项目控制台屏幕来监控该进程。

图 13-15 – Jenkins 蓝绿子项目控制台屏幕
如果你重复手动验证,你会注意到第二次时,green 版本会运行,而 blue 版本会停止。第三次运行时,颜色会反转,blue 版本会运行,而 green 版本会停止。正确的颜色会存储在 Consul 中,代理服务会始终将请求重定向到最新的版本,并且在部署过程中不会出现停机时间。
即使我们即将结束本章内容,我们仍未完成蓝绿部署的练习。虽然我们将改变运行程序的方式,但它将是我们接下来在本书中探讨的几个实践的核心部分。我们实现了零停机时间的部署,但在达到零停机时间的系统之前,还有很多工作要做。我们当前的过程虽然在部署期间不会产生停机时间,但这并不意味着整个系统是容错的。
我们已经达到了一个重要的里程碑,但仍然有许多障碍需要克服。其中之一就是集群和扩展。我们现有的解决方案在单台服务器上运行良好。我们可以很容易地将其扩展,以支持更多的服务器,可能是几台,甚至十台。然而,服务器的数量越多,就越需要寻找一种更好的方式来管理集群和扩展。这将是下一章的主题。在那之前,让我们销毁我们一直在使用的环境,以便重新开始。
exit
vagrant destroy -f
第十四章:集群与服务扩展
| 设计系统的组织……受到这些组织沟通结构的制约,必须生产出这些沟通结构的复制品。 | ||
|---|---|---|
| --M. Conway |
很多人会告诉你他们有一个可扩展的系统。毕竟,扩展很简单。买台服务器,安装 WebLogic(或者你使用的其他大型应用服务器),然后部署你的应用程序。接下来,等上几周,直到你发现一切都那么快,你可以点击一个按钮,去喝咖啡,当你回到桌前时,结果就等着你了。你怎么办?你扩展。你再买几台服务器,安装你的大型应用服务器,把你的大型应用程序部署到它们上面。系统的瓶颈在哪里?没人知道。为什么要复制所有的东西?因为你必须这么做。然后,时间继续过去,你不断扩展,直到钱用光,同时,员工也开始崩溃。今天,我们不再这样处理扩展问题。今天我们明白,扩展涉及到许多其他方面。它关乎弹性。它意味着能够根据流量变化和业务增长,快速而轻松地进行扩展和缩减,而且在这个过程中,你不应该破产。它关乎几乎每家公司都需要扩展其业务,而不是把 IT 部门当作负担。它关乎摆脱那些庞然大物。
扩展性
让我们暂时退后一步,讨论一下为什么我们要扩展应用程序。主要原因是高可用性。为什么我们需要高可用性?我们需要它,因为我们希望我们的业务在任何负载下都能保持可用。负载越大越好(除非你遭遇了 DDoS 攻击)。这意味着我们的业务正在蓬勃发展。有了高可用性,我们的用户会很满意。我们都希望速度快,很多人如果加载太慢,就直接离开网站。我们希望避免宕机,因为每一分钟我们的业务无法运作,都意味着金钱的损失。如果一个在线商店无法使用,你会怎么做?可能去别的商店。也许第一次不会,第二次也不一定,但迟早你会受够了,换到别的商店。我们已经习惯了所有东西都很快、很响应,并且有这么多的替代选项,我们在尝试其他选择时不会再犹豫。如果那个替代选项更好……一个人的损失是另一个人的收获。我们通过扩展解决了所有问题吗?远远不够。还有许多其他因素决定着我们应用程序的可用性。然而,扩展性是其中一个重要部分,而且它正是本章讨论的主题。
什么是扩展性?它是系统的一种特性,表示其在优雅地应对增加的负载时的能力,或者表示其随着需求增加而扩展的潜力。它是接受增加的流量或负载的能力。
事实上,应用程序的设计方式决定了可用的扩展选项。如果应用程序没有设计成可以扩展的,它就无法很好地扩展。这并不是说没有扩展设计的应用程序就无法扩展。一切都可以扩展,但不是所有东西都能扩展得很好。
常见的情况如下:
我们从一个简单的架构开始,有时有负载均衡器,有时没有,设置几个应用服务器和一个数据库。一切都很顺利,复杂度低,我们可以很快地开发新功能。运营成本低,收入高(考虑到我们刚刚起步),每个人都很开心和有动力。
业务在增长,流量也在增加。事情开始出现故障,性能下降。于是添加了防火墙,设置了额外的负载均衡器,扩展了数据库,增加了更多的应用服务器,等等。事情仍然相对简单。我们面临着新的挑战,但障碍可以及时克服。尽管复杂度在增加,但我们仍能相对轻松地应对。换句话说,我们做的事情大致上还是一样,只是规模更大了。业务做得不错,但仍然相对较小。
然后,它发生了。你一直期待的重大事件。也许是某个营销活动打中了要害,也许是竞争对手发生了不利变化,或者最后一个功能真的是个杀手级功能。不管原因是什么,业务获得了大幅提升。在短暂的欣喜之后,你的痛苦增加了十倍。增加更多的数据库似乎还不够,增加应用服务器也似乎无法满足需求。你开始增加缓存等等。你开始有这种感觉:每次增加某个组件时,带来的好处并没有成比例地增长。成本在增加,而你仍然无法满足需求。数据库复制太慢,新的应用服务器已经不再产生那么大的效果了。运营成本增长得比你预期的快。局势开始伤害到业务和团队。你开始意识到,你曾经引以为豪的架构无法应对这种负载的增加。你无法将它拆分。你无法扩展那些最痛苦的部分。你不能从头开始。你能做的就是不断地增加组件,但每次增加带来的好处都在减少。
上述情况非常常见。一开始有效的方法,随着需求的增加,不一定就能继续适用。我们需要平衡你不需要它(YAGNI)原则和长期愿景。我们不能一开始就构建一个为大型公司优化的系统,因为它成本过高,并且当业务还很小的时候,它并不能带来足够的好处。另一方面,我们不能忽视任何业务的主要目标之一。我们不能从第一天起就不考虑扩展性。设计可扩展的架构并不意味着我们需要从一开始就部署一个百台服务器的集群。它也不意味着我们从一开始就要开发一个庞大复杂的系统。它意味着我们应该从小做起,但在设计时考虑到,当它变得庞大时,扩展会变得容易。虽然微服务并不是实现这一目标的唯一方式,但它们确实是解决这个问题的一种好方法。成本不在于开发,而在于运营。如果运营是自动化的,那么这一成本可以迅速被吸收,并且不需要大规模的投资。如你所见(并且在本书的其余部分你将继续看到),我们有优秀的开源工具可供使用。自动化的最大优点是,投资的维护成本通常低于手动操作时的维护成本。
我们已经讨论了微服务及其在小规模上的自动化部署。现在是时候将这个小规模转变为更大的规模了。在我们进入实际部分之前,让我们先探讨一下可能的不同扩展方式。
我们常常受限于设计,选择应用程序的构建方式严重限制了我们的选择。虽然有许多不同的缩放方式,但最常见的一种叫做坐标轴缩放。
坐标轴缩放
坐标轴缩放可以通过一个立方体的三维来最好地表示;x 轴、y 轴和z 轴。每个维度描述了一种缩放方式。
-
X 轴:水平复制
-
Y 轴:功能分解
-
Z 轴:数据分区
![坐标轴缩放]()
图 14-1 – 缩放立方体
让我们逐一了解各个轴。
X 轴缩放
简而言之,x 轴缩放是通过运行多个应用程序或服务实例来实现的。在大多数情况下,顶部有一个负载均衡器,确保流量在所有这些实例之间共享。x 轴缩放的最大优势是简单性。我们所需要做的就是在多个服务器上部署相同的应用程序。因此,这种缩放方式是最常用的。然而,当它应用于单体应用程序时,它也有一系列的缺点。拥有一个庞大的应用程序通常需要大量缓存,这会消耗大量内存。当这样的应用程序被复制时,所有东西都会随之复制,包括缓存。
另一个,通常更重要的问题是资源的使用不当。性能问题几乎从来不会与整个应用程序相关。并非所有模块都受到相同的影响,然而我们却将一切都进行扩展。这意味着,尽管我们本可以通过仅扩展那些需要此类操作的部分应用来获得更好的效果,但我们却扩展了所有部分。尽管如此,X 轴扩展在任何架构中都是重要的。主要区别在于这种扩展的效果。通过使用微服务,我们并不是消除了 X 轴扩展的需求,而是确保由于其架构的原因,这种扩展的效果比传统架构方法更为显著。在微服务中,我们可以精细调节扩展。我们可以为负载较重的服务提供多个实例,而对于使用较少或需求较少资源的服务,只提供少量实例。除此之外,由于微服务较小,我们可能永远不会达到服务的限制。一个小型服务在一个大服务器上,必须接收非常庞大的流量,才会需要扩展。扩展微服务更常与容错而非性能问题相关。我们希望有多个副本在运行,以便如果其中一个副本失败,其他副本能够接管,直到恢复:

图 14-2 – 在集群内部扩展的单体应用
Y 轴扩展
Y 轴扩展关注的是将应用程序分解成更小的服务。尽管有不同的方法可以完成这种分解,微服务可能是我们可以采取的最佳方法。当它们与不可变性和自足性结合时,的确没有更好的选择(至少从 Y 轴扩展的角度来看)。与 X 轴扩展不同,Y 轴扩展并不是通过运行多个相同实例的应用来实现,而是通过将多个不同的服务分布在集群中来实现。
Z 轴扩展
Z 轴扩展很少应用于应用程序或服务。它的主要和最常见的应用是在数据库中。这种扩展的背后思想是将数据分布到多个服务器之间,从而减少每个服务器需要执行的工作量。数据被分割和分布,使得每个服务器只需要处理数据的一个子集。这种分割方式通常被称为分片(sharding),并且有许多数据库是专门为此目的设计的。Z 轴扩展的好处在于 I/O 操作和缓存与内存利用率上的显著提升。
集群
服务器集群由一组连接在一起的服务器组成,这些服务器协同工作,并且可以被视为一个单一的系统。它们通常通过高速局域网(LAN)连接。集群与普通服务器组的主要区别在于,集群作为一个单一系统,旨在提供高可用性、负载均衡和并行处理。
如果我们将应用程序或服务部署到单独管理的服务器上,并将它们视为独立单元,那么资源的利用率将会低于最佳水平。我们无法提前知道哪些服务组应该部署到某台服务器上,并最大化资源利用率。更重要的是,资源使用往往会波动。例如,早晨某个服务可能需要大量内存,而下午该服务的内存使用量可能会较低。预定义的服务器无法提供足够的弹性,以最佳方式平衡这种资源使用。即使不需要如此高程度的动态性,预定义的服务器也会在出现故障时造成问题,导致需要手动重新部署受影响的服务到健康节点:

图 14-3 – 部署到预定义服务器的集群与容器
真正的集群是在我们停止考虑单独的服务器,而开始考虑整个集群时实现的;把所有服务器视为一个大的整体。如果我们稍微降到较低的层次,可以更好地解释这一点。当我们部署应用程序时,通常会指定它可能需要多少内存或 CPU。然而,我们并不决定应用程序将使用哪些内存插槽,或它应该利用哪些 CPU。例如,我们不会指定某个应用程序应该使用 CPU 4、5 和 7。这样做既低效又可能危险。我们只决定需要多少 CPU。我们应该在更高的层次上采取相同的做法。我们不应该关心应用程序或服务将部署到哪里,而是关心它需要什么。我们应该能够定义该服务的具体需求,并告诉某个工具将它部署到集群中的任何服务器,只要它满足我们的需求。实现这一目标的最佳方法(如果不是唯一的方法)就是将整个集群视为一个整体。我们可以通过增加或移除服务器来增加或减少集群的容量,但无论我们做什么,集群仍然应该是一个单一的整体。我们定义一个策略,并让我们的服务在集群内的某个地方部署。像Amazon Web Services(AWS)、微软的 Azure 和Google Cloud Engine(GCP)等云服务提供商的用户,尽管可能没有意识到,但已经习惯了这种方式。
在本章接下来的部分,我们将探索创建集群的方法,并探讨一些可以帮助我们实现这一目标的工具。我们将在本地模拟集群,这并不意味着这些相同的策略无法应用于公有云、私有云和数据中心。恰恰相反:

图 14-4 – 根据预定义策略部署到服务器的集群与容器
Docker 集群工具比较 – Kubernetes 与 Docker Swarm 与 Mesos
Kubernetes 和 Docker Swarm 可能是当前最常用的两个容器部署工具。它们都是作为辅助平台创建的,可用于管理集群中的容器,并将所有服务器视为一个整体。虽然它们的目标在某种程度上相似,但在方法上有显著差异。
Kubernetes
Kubernetes 基于 Google 多年来使用 Linux 容器的经验。从某种意义上说,它是 Google 长期以来所做工作的复制品,但这一次,它是为 Docker 量身定制的。这个方法在很多方面都很棒,最重要的是他们从一开始就利用了自己的经验。如果你在 Docker 1.0 版本(或更早)左右开始使用 Kubernetes,Kubernetes 的体验非常好。它解决了 Docker 本身的许多问题。我们可以挂载持久化卷,使我们能够在不丢失数据的情况下移动容器,它使用 flannel 在容器之间创建网络,它集成了负载均衡器,使用 etcd 进行服务发现,等等。然而,Kubernetes 是有代价的。与 Docker 相比,它使用了不同的 CLI、不同的 API 和不同的 YAML 定义。换句话说,你不能使用 Docker CLI,也不能使用 Docker Compose 来定义容器。所有的操作都必须从头开始,完全为 Kubernetes 定制。就好像这个工具并不是为 Docker 编写的(某种程度上是事实)。Kubernetes 将集群管理提升到了一个新层次,但代价是可用性和陡峭的学习曲线。
Docker Swarm
Docker Swarm 采取了不同的方法。它是 Docker 的本地集群管理工具。最棒的部分是它暴露了标准的 Docker API,这意味着你曾经用来与 Docker 交互的任何工具(Docker CLI、Docker Compose、Dokku、Krane 等)都可以与 Docker Swarm 完美兼容。这本身既是优点也是缺点。能够使用你熟悉的工具当然很好,但正因为如此,我们也受限于 Docker API 的限制。如果 Docker API 不支持某个功能,那么 Swarm API 也无法绕过这个问题,需要一些巧妙的技巧来实现。
Apache Mesos
接下来可以用来管理集群的工具是 Apache Mesos。它是集群管理的老兵。Mesos 将 CPU、内存、存储和其他资源从机器(物理或虚拟)中抽象出来,使得容错和弹性分布式系统能够轻松构建并高效运行。
Mesos 使用与 Linux 内核相同的原理,只是抽象的层次不同。Mesos 内核运行在每台机器上,为应用程序提供资源管理和调度的 API,覆盖整个数据中心和云环境。与 Kubernetes 和 Docker Swarm 不同,Mesos 不限于容器。它几乎可以与任何类型的部署工作,包括 Docker 容器。
Mesos 使用 Zookeeper 进行服务发现。它使用 Linux 容器来隔离进程。例如,如果我们在不使用 Docker 的情况下部署 Hadoop,Mesos 会将其作为一个本地 Linux 容器运行,提供类似于将其打包为 Docker 容器的功能。
Mesos 提供了一些 Swarm 当前没有的功能,主要是更强大的调度器。除了调度器之外,使 Mesos 吸引人的地方在于,我们可以将其用于 Docker 和非 Docker 部署。许多组织可能不想使用 Docker,或者他们可能决定同时使用 Docker 和非 Docker 部署的组合。在这种情况下,如果我们不想处理两套集群工具——一种用于容器,另一种用于其他部署——Mesos 真的是一个很好的选择。
然而,Mesos 有些过时,并且对我们想要实现的目标来说过于庞大。更重要的是,Docker 容器是其事后添加的。该平台最初并没有为 Docker 设计,但后来才添加了对 Docker 的支持。与 Docker 一起使用 Mesos 感觉很别扭,并且从一开始就显而易见,这两者并不是为了共同使用而设计的。考虑到 Swarm 和 Kubernetes 的存在,Mesos 对那些决定拥抱 Docker 的人来说已经毫无优势。Mesos 正在落后。它相较于另外两种工具的主要优势是广泛的采用。许多人在 Docker 出现之前就开始使用它,并可能选择继续使用它。对于那些可以重新开始的人,选择应该在 Kubernetes 和 Docker Swarm 之间做出。
我们将更详细地探讨 Kubernetes 和 Docker Swarm,抛开 Mesos。探索将基于它们的设置和它们为在集群中运行容器提供的功能。
设置它
设置 Docker Swarm 既简单又直接,且具有灵活性。我们需要做的就是安装其中一个服务发现工具,并在所有节点上运行 swarm 容器。由于该分发本身已打包在 Docker 容器中,因此无论操作系统如何,它的工作方式都相同。我们运行 swarm 容器,暴露端口,并告知其服务发现地址。做到这一点几乎不可能更简单了。我们甚至可以在没有任何服务发现工具的情况下开始使用它,看看是否喜欢,并在使用变得更加严肃时,添加 etcd、Consul 或其他支持的工具。
Kubernetes 的设置要复杂得多且不透明。安装说明因操作系统和提供商而异。每个操作系统或托管提供商都有自己的安装说明,每个说明都有不同的维护团队,面临不同的问题。例如,如果你选择使用 Vagrant 来尝试,那么你就只能使用 Fedora。这并不意味着你不能在 Vagrant 上运行它,并且选择使用 Ubuntu 或 CoreOS 也是可以的。你可以,但需要开始在官方 Kubernetes 入门页面以外寻找说明。无论你的需求是什么,社区很可能已经有了解决方案,但你仍然需要花费一些时间去寻找,并希望它能够在第一次尝试时就成功。更大的问题是,安装依赖于一个 bash 脚本。如果我们不生活在一个配置管理成为必须的时代,这本身并不是一个大问题。如果我们不想运行脚本,而是希望 Kubernetes 成为我们 Puppet、Chef 或 Ansible 配置的一部分,那也可以克服。你可以找到用于运行 Kubernetes 的 Ansible 剧本,或者编写自己的剧本。虽然这些问题并不算大问题,但与 Swarm 相比,它们还是有点痛苦。对于 Docker,我们本应不需要安装说明(除了几个 docker run 参数)。我们本应只运行容器。Swarm 实现了这个承诺,而 Kubernetes 并没有。
虽然有些人可能不在乎使用哪个发现工具,但我喜欢 Swarm 的简单性以及“包含电池但可拆卸”的逻辑。一切开箱即用,但我们仍然可以选择替换其中的某个组件。与 Swarm 不同,Kubernetes 是一个有明确观点的工具。你需要接受它为你做出的选择。如果你想使用 Kubernetes,你必须使用 etcd。我并不是说 etcd 很差(恰恰相反),但如果你比如说更喜欢使用 Consul,那么你就会陷入一个非常复杂的局面,你需要为 Kubernetes 使用一个工具,而为其他服务发现需求使用另一个工具。我还不喜欢 Kubernetes 的另一点是它需要在设置之前就知道一些事情。你需要告诉它所有节点的地址、每个节点的角色、集群中有多少个从节点等等。而使用 Swarm,我们只需要启动一个节点并让它加入网络。无需提前设置任何信息,因为集群的相关信息会通过 gossip 协议进行传播。
设置可能不是这些工具之间最显著的区别。无论选择哪个工具,迟早一切都会顺利运行,你会忘记在过程中遇到的任何问题。你可能会说,我们不应仅因为某个工具更容易设置而选择它。这个说法有道理。我们继续讨论如何定义应该使用这些工具运行的容器之间的区别。
运行容器
你如何定义在 Swarm 中运行 Docker 容器所需的所有参数?你不需要!其实,你需要,但这与你在 Swarm 之前定义它们的方式没有任何不同。如果你习惯通过 Docker CLI 运行容器,你可以继续使用它,几乎是相同的命令。如果你更喜欢使用 Docker Compose 来运行容器,你可以继续在 Swarm 集群中使用它来运行容器。不管你以前是如何运行容器的,你很可能可以继续用相同的方式在 Swarm 上运行,只不过是在更大规模上。
Kubernetes 要求你学习它的 CLI 和配置。你不能使用你之前创建的 docker-compose.yml 定义。你必须创建 Kubernetes 的等效配置。你不能使用你之前学过的 Docker CLI 命令。你必须学习 Kubernetes CLI,并且很可能需要确保整个组织也都学会它。
无论你选择哪个工具来进行集群部署,很可能你已经熟悉 Docker。你可能已经习惯了使用 Docker Compose 来定义你将运行的容器的参数。如果你玩了几个小时,你已经在将 Docker Compose 当作 Docker CLI 的替代品使用了。你用它来运行容器、查看日志、扩展容器,等等。另一方面,你可能是一个硬核的 Docker 用户,不喜欢 Docker Compose,宁愿通过 Docker CLI 来运行一切,或者你可能有自己的 bash 脚本来为你运行容器。无论你选择什么,它都应该能与 Docker Swarm 一起使用。
如果你采用 Kubernetes,做好准备,你将会有多个不同的定义来描述相同的事物。你需要 Docker Compose 来在 Kubernetes 之外运行你的容器。开发人员仍然需要在他们的笔记本电脑上运行容器,你的暂存环境可能是一个大集群,也可能不是,等等。
换句话说,一旦你采用了 Docker,Docker Compose 或 Docker CLI 是不可避免的。你必须以某种方式使用它们。一旦你开始使用 Kubernetes,你会发现所有的 Docker Compose 定义(或你可能在使用的其他工具)需要转换为 Kubernetes 描述事物的方式,之后你将不得不同时维护两者。在 Kubernetes 中,一切都需要被重复定义,导致更高的维护成本。而且这不仅仅是关于重复的配置。你在集群外运行的命令将与在集群内运行的命令不同。你学会并喜爱的所有 Docker 命令,将不得不在集群内找到它们的 Kubernetes 对应命令。
Kubernetes 背后的团队并不是想通过强迫你按“他们的方式”做事来让你的生活变得痛苦。这么大的差异的原因在于 Swarm 和 Kubernetes 采用了不同的方法来解决相同的问题。Swarm 团队决定将其 API 与 Docker 的 API 相匹配。结果,我们几乎实现了完全兼容。几乎所有 Docker 可以做的事情,Swarm 也能做,只不过是规模更大。没有什么新东西要做,也没有配置需要重复,也没有什么新东西要学习。无论你是直接使用 Docker CLI 还是通过 Swarm,API (或多或少)都是相同的。这种做法的负面一面是,如果你希望 Swarm 执行某些 Docker API 没有的操作,那么你就会失望。让我们简化一下。如果你正在寻找一个用于在集群中部署容器的工具,并且希望使用 Docker API,那么 Swarm 就是解决方案。另一方面,如果你需要一个能够克服 Docker 限制的工具,那么你应该选择 Kubernetes。它是功能(Kubernetes)与简易性(Swarm)之间的较量。或者,至少直到最近,它一直是这样。但,我现在有些超前了。
唯一未解答的问题是这些限制是什么。两个主要的限制是网络、持久化卷和在一个或多个容器或整个节点停止工作时的自动故障转移。
在 Docker Swarm 1.0 发布之前,我们不能将运行在不同服务器上的容器进行链接。我们现在依然无法将它们链接起来,但现在我们有了 multi-host networking 来帮助我们连接在不同服务器上运行的容器。这是一个非常强大的功能。Kubernetes 使用 flannel 来实现网络连接,现在自 Docker 1.9 版本起,该功能已作为 Docker CLI 的一部分提供。
另一个问题是持久化卷。Docker 在 1.9 版本中引入了持久化卷。直到最近,如果你持久化一个卷,那么该容器就会绑定到存储该卷的服务器上。它无法在没有一些复杂操作的情况下被迁移,比如从一台服务器复制卷目录到另一台服务器。这个操作本身就是一个非常慢的过程,违背了像 Swarm 这类工具的目标。此外,即使你有时间将卷从一台服务器复制到另一台,你也不知道该复制到哪里,因为集群工具往往将整个数据中心视为一个单一的实体。你的容器会被部署到最适合它们的位置(容器运行数量最少,CPU 或内存可用最多,等等)。现在,Docker 本身就原生支持持久化卷。
最后,自动故障转移可能是 Kubernetes 相比 Swarm 唯一的特性优势。然而,Kubernetes 提供的故障转移解决方案并不完整。如果一个容器崩溃,Kubernetes 会检测到并在健康的节点上重新启动它。问题在于,容器或整个节点通常不会无缘无故地失败。所需的工作远远超过简单的重新部署。需要有人被通知,故障前的信息需要进行评估,等等。如果仅仅需要重新部署,Kubernetes 是一个不错的解决方案。如果需要更多的功能,由于 Swarm 提供的“自带但可移除”的哲学,它允许你构建自己的解决方案。在故障转移方面,问题在于是选择一个开箱即用且难以扩展的解决方案(Kubernetes),还是选择一个为了容易扩展而构建的解决方案(Swarm)。
网络和持久卷的问题曾是 Kubernetes 支持的特性之一,也是许多人选择它而不是 Swarm 的原因。然而,这一优势在 Docker 1.9 版本发布后消失了。自动故障转移仍然是 Kubernetes 相较于 Swarm 的一个优势,尤其是在考虑开箱即用的解决方案时。对于 Swarm,我们需要自己开发故障转移策略。
选择
在做出 Docker Swarm 和 Kubernetes 之间的选择时,可以从以下几个方面进行思考。你是否希望依赖 Docker 来解决与集群相关的问题?如果是的话,选择 Swarm。如果 Docker 不支持某些功能,那么 Swarm 也不太可能支持这些功能,因为它依赖于 Docker API。另一方面,如果你希望使用一个能够绕过 Docker 限制的工具,Kubernetes 可能更适合你。Kubernetes 不是围绕 Docker 构建的,而是基于 Google 在容器方面的经验。它有明确的理念,并且尝试以自己的方式做事。
真正的问题是 Kubernetes 的做事方式,与我们使用 Docker 的方式完全不同,是否被它提供的优势所掩盖。或者,我们应该把赌注押在 Docker 本身上,并希望它能解决这些问题?在你回答这些问题之前,看一看 Docker 的 1.9 版本。我们得到了持久卷和软件网络。我们还得到了 unless-stopped 重启策略,可以管理我们不想要的故障。现在,Kubernetes 和 Swarm 之间的差距已经变少了。事实上,如今 Kubernetes 比 Swarm 拥有的优势已经很少了。Kubernetes 提供的自动故障转移既是一种福音,又是一种诅咒。另一方面,Swarm 使用 Docker API,这意味着您可以保留所有命令和 Docker Compose 配置。就我个人而言,我把赌注押在 Docker 引擎的改进和运行在其上的 Docker Swarm 上。这两者之间的差异很小。两者都已经准备好投入生产,但 Swarm 更容易设置,更易于使用,并且在移动到集群之前,我们可以保留所有之前构建的内容;在集群和非集群配置之间没有重复。
我的建议是选择 Docker Swarm。Kubernetes 太过于主观,设置困难,与 Docker CLI/API 有很大不同,并且除了自动故障转移之外,在 Docker 1.9 版本发布之后,它没有真正的优势。这并不意味着 Kubernetes 没有不受 Swarm 支持的功能。在两个方向上都存在功能差异。然而,在我看来,这些差异并不是重大的,而且随着每个 Docker 版本的发布,这种差距正在变小。实际上,对于许多用例来说,根本就没有差距,而 Docker Swarm 更容易设置、学习和使用。
让我们来试试 Docker Swarm,并看看它的表现如何。
Docker Swarm 演练
要设置 Docker Swarm,我们需要其中一种服务发现工具。Consul 在这方面表现良好,我们将继续使用它。它是一个很棒的工具,并且与 Swarm 配合良好。我们将设置三个服务器。一个将充当主节点,另外两个将作为集群节点:

图 14-5 – 使用 Consul 的 Docker Swarm 集群进行服务发现
Swarm 将使用 Consul 实例来注册和检索有关节点和其上部署的服务的信息。每当我们启动一个新节点或停止一个现有节点时,该信息会传播到所有 Consul 实例并传递到 Docker Swarm,后者会知道在哪里部署我们的容器。主节点将运行 Swarm 主程序。我们将使用其 API 来指示 Swarm 部署什么内容以及其要求是什么(如 CPU 数量、内存大小等)。节点服务器将部署 Swarm 节点。每当 Swarm 主节点收到部署容器的指令时,它会评估当前集群的状态,并将指令发送到某个节点执行部署:

图 14-6 – Docker Swarm 集群,包含一个主节点和两个节点
我们将从分布策略开始,该策略会将容器部署到运行容器数量最少的节点上。由于开始时所有节点都是空的,当给出部署第一个容器的指令时,Swarm 主节点会将容器部署到其中一个节点,因为此时两个节点都是空的:

图 14-7 – Docker Swarm 集群,已部署第一个容器
当收到第二个容器部署指令时,Swarm 主节点会决定将其传播到另一个 Swarm 节点,因为第一个节点已经有一个容器在运行:

图 14-8 – Docker Swarm 集群,已部署第二个容器
如果我们继续部署容器,过一段时间后我们的微型集群将会变得饱和,在服务器崩溃之前必须采取一些措施:

图 14-9 – Docker Swarm 集群,所有节点已满
我们需要做的唯一事情来增加集群容量,就是启动一台新的服务器并部署 Consul 和 Swarm 节点。只要这样的节点被启动,它的信息就会在所有 Consul 实例之间传播,并传递到 Swarm 主节点。从那一刻起,Swarm 会将这个节点包含在内,作为所有新部署的一部分。由于这台服务器启动时没有容器,并且我们使用的是简单的分布策略,所有新部署的容器都会部署到这个节点,直到它运行的容器数量与其他节点相同:

图 14-10 – Docker Swarm 集群,容器部署到新节点
在某个节点因故障停止响应的情况下,可以观察到相反的情况。Consul 集群会检测到其某个成员未响应,并将该信息传播到整个集群,从而到达 Swarm 主节点。从那一刻起,所有新部署的容器都会被发送到健康节点之一:

图 14-11 – Docker Swarm 集群,某个节点失败,容器分布到健康的节点上
让我们深入讨论刚才提到的简单示例。稍后,我们将探索其他策略以及在设置某些约束时 Swarm 的行为方式;例如 CPU、内存等。
设置 Docker Swarm
要看到 Docker Swarm 的工作过程,我们将模拟一个 Ubuntu 集群。我们将启动用于编排的 cd 节点,一个将充当 Swarm 主节点的节点以及两个将形成集群的节点。到目前为止,我们始终使用 Ubuntu 14.04 LTS(长期支持),因为它被认为是稳定且长期受支持的。下一个长期支持版本将是 15.04 LTS(在书写本书时尚未发布)。由于我们稍后将要探索的一些功能需要相对较新的内核,swarm 节点将运行 Ubuntu 15.04. 如果您打开 Vagrantfile,您会注意到 Swarm 主节点和节点有以下行:
d.vm.box = "ubuntu/vivid64"
Vivid64 是 Ubuntu 15.04 的代号。
让我们启动节点:
vagrant up cd swarm-master swarm-node-1 swarm-node-2
所有四个节点都已启动并运行,我们可以继续创建 Swarm 集群。与以往一样,我们将使用 Ansible 进行配置:
vagrant ssh cd
ansible-playbook /vagrant/ansible/swarm.yml \
-i /vagrant/ansible/hosts/prod
让我们明智地利用时间,探索 swarm.yml playbook,同时 Ansible 正在为我们的服务器进行配置。swarm.yml 文件的内容如下:
- hosts: swarm
remote_user: vagrant
serial: 1
sudo: yes
vars:
- debian_version: vivid
- docker_cfg_dest: /lib/systemd/system/docker.service
- is_systemd: true
roles:
- common
- docker
- consul
- swarm
- registrator
我们从设置 docker 开始。由于这次我们使用的是不同版本的 Ubuntu,因此我们必须将这些差异作为变量进行指定,以便使用正确的存储库 (debian_version),以及重新加载服务配置 (is_systemd)。我们还设置了 docker_cfg_dest 变量,以便将配置文件发送到正确的位置。
我们在 hosts/prod 文件中设置了几个额外的变量:
[swarm]
10.100.192.200 swarm_master=true consul_extra="-server -bootstrap-expect 1" docker_cfg=docker-swarm-master.service
10.100.192.20[1:2] swarm_master_ip=10.100.192.200 consul_server_ip=10.100.192.200 docker_cfg=docker-swarm-node.service
我们稍后将探索 swarm_master 和 swarm_master_ip。现在,请记住它们在 prod 文件中定义,以便根据服务器类型(主节点或节点)应用(或省略)它们。根据我们是配置主节点还是节点,Docker 配置文件分别为 docker-swarm-master.service 或 docker-swarm-node.service。
让我们来看看 roles/docker/templates/docker-swarm-master.service 中主节点 Docker 配置的 ExecStart 部分(其余部分与 Docker 软件包提供的标准配置相同):
ExecStart=/usr/bin/docker daemon -H fd:// \
--insecure-registry 10.100.198.200:5000 \
--registry-mirror=http://10.100.198.200:5001 \
--cluster-store=consul://{{ ip }}:8500/swarm \
--cluster-advertise={{ ip }}:2375 {{ docker_extra }}
我们告诉 Docker 在我们的私有注册表运行的 IP/端口上允许不安全注册。我们还指定 Swarm 集群信息应存储在同一节点上运行的 Consul 中,并且应该广播到端口 2375:
在 roles/docker/templates/docker-swarm-node.service 中定义的节点配置有几个额外的参数:
ExecStart=/usr/bin/docker daemon -H fd:// \
-H tcp://0.0.0.0:2375 \
-H unix:///var/run/docker.sock \
--insecure-registry 10.100.198.200:5000 \
--registry-mirror=http://10.100.198.200:5001 \
--cluster-store=consul://{{ ip }}:8500/swarm \
--cluster-advertise={{ ip }}:2375 {{ docker_extra }}
除了与主节点相同的参数外,我们还告诉 Docker 允许通过端口 2375 (-H tcp://0.0.0.0:2375) 和通过套接字 (-H unix:///var/run/docker.sock) 进行通信:
master和node配置都遵循官方 Docker Swarm 文档中推荐的标准设置,特别是与 Consul 一起使用时。
swarm.yml playbook 中使用的其余角色有consul、swarm和registrator。由于我们已经使用并看到过 Consul 和 Registrator 角色,接下来我们只探索roles/swarm/tasks/main.yml文件中定义的swarm角色相关任务:
- name: Swarm node is running
docker:
name: swarm-node
image: swarm
command: join --advertise={{ ip }}:2375 consul://{{ ip }}:8500/swarm
env:
SERVICE_NAME: swarm-node
when: not swarm_master is defined
tags: [swarm]
- name: Swarm master is running
docker:
name: swarm-master
image: swarm
ports: 2375:2375
command: manage consul://{{ ip }}:8500/swarm
env:
SERVICE_NAME: swarm-master
when: swarm_master is defined
tags: [swarm]
如你所见,运行 Swarm 相当简单。我们需要做的就是运行swarm容器,并根据它是主节点还是普通节点,指定不同的命令。如果服务器作为 Swarm 节点运行,使用的命令是join --advertise={{ ip }}:2375 consul://{{ ip }}:8500/swarm,翻译成通俗的语言就是:它应该加入集群,在端口2375上发布自己的存在,并使用在同一服务器上运行的 Consul 进行服务发现。在 Swarm 主节点上使用的命令则更短;manage consul://{{ ip }}:8500/swarm。我们只需要指定这个 Swarm 容器应该用来管理集群,并且与 Swarm 节点一样,使用 Consul 进行服务发现。
希望我们之前运行的 playbook 已经完成执行。如果没有,去喝杯咖啡,等它执行完再继续阅读。接下来我们将检查我们的 Swarm 集群是否按预期工作。
由于我们仍然在cd节点内,我们应该告诉 Docker CLI 使用不同的主机。
export DOCKER_HOST=tcp://10.100.192.200:2375
在cd上运行 Docker 客户端,并使用swarm-master节点作为主机,我们可以远程控制 Swarm 集群。首先,我们可以查看集群的相关信息:
docker info
输出如下:
Containers: 4
Images: 4
Role: primary
Strategy: spread
Filters: health, port, dependency, affinity, constraint
Nodes: 2
swarm-node-1: 10.100.192.201:2375
└ Status: Healthy
└ Containers: 3
└ Reserved CPUs: 0 / 1
└ Reserved Memory: 0 B / 1.535 GiB
└ Labels: executiondriver=native-0.2, kernelversion=3.19.0-42-generic, operatingsystem=Ubuntu 15.04, storagedriver=devicemapper
swarm-node-2: 10.100.192.202:2375
└ Status: Healthy
└ Containers: 3
└ Reserved CPUs: 0 / 1
└ Reserved Memory: 0 B / 1.535 GiB
└ Labels: executiondriver=native-0.2, kernelversion=3.19.0-42-generic, operatingsystem=Ubuntu 15.04, storagedriver=devicemapper
CPUs: 2
Total Memory: 3.07 GiB
Name: b358fe59b011
这不是很棒吗?只需一个命令,我们就能查看整个集群的概览。虽然目前我们只有两台服务器(swarm-node-1和swarm-node-2),但如果有一百台、一千台甚至更多的节点,docker info会提供所有节点的信息。在这种情况下,我们可以看到四个容器正在运行,并且有四个镜像。这是正确的,因为每个节点都在运行 Swarm 和 Registrator 容器。接下来,我们可以看到Role、Strategy和Filters。然后是构成集群的各个节点,后面是每个节点的信息。我们可以看到每个节点正在运行多少个容器(当前是两个),为容器预留了多少 CPU 和内存,以及与每个节点关联的标签。最后,我们还可以看到整个集群的 CPU 和内存总数。docker info展示的所有信息不仅是数据,也是 Swarm 集群的功能。现在请注意,所有这些信息都可以用来检查。稍后我们会探索如何利用这些信息来为我们带来更多好处。
Docker Swarm 的最大优点在于它与 Docker 使用相同的 API,因此我们在本书中已经使用过的所有命令都可以继续使用。唯一的区别是,使用 Swarm 时,我们操作的是整个集群,而不是单一服务器。例如,我们可以列出整个 Swarm 集群中的所有镜像和进程:
docker images
docker ps -a
通过运行 docker images 和 docker ps -a,我们可以观察到集群中拉取了两个镜像,并且有四个容器在运行(每台服务器上各有两个容器)。唯一的视觉差异是,运行的容器名称前面会加上它们运行的服务器的名称。例如,名为 registrator 的容器显示为 swarm-node-1/registrator 和 swarm-node-2/registrator。这两个命令的组合输出如下:
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
swarm latest a9975e2cc0a3 4 weeks ago 17.15 MB
gliderlabs/registrator latest d44d11afc6cc 4 months ago 20.93 MB
...
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a2c7d156c99d gliderlabs/registrator "/bin/registrator -ip" 2 hours ago Up 2 hours swarm-node-2/registrator
e9b034aa3fc0 swarm "/swarm join --advert" 2 hours ago Up 2 hours 2375/tcp swarm-node-2/swarm-node
a685cdb09814 gliderlabs/registrator "/bin/registrator -ip" 2 hours ago Up 2 hours swarm-node-1/registrator
5991e9bd2a40 swarm "/swarm join --advert" 2 hours ago Up 2 hours 2375/tcp swarm-node-1/swarm-node
现在我们知道,在远程服务器(swarm-master)上运行 Docker 命令时,它的工作方式与本地一样,并且可以用来控制整个集群(swarm-node-1 和 swarm-node-2)。让我们尝试部署我们的 books-ms 服务。
使用 Docker Swarm 部署
我们将从重复之前的部署过程开始,但这次我们将向 Swarm 主节点发送命令:
git clone https://github.com/vfarcic/books-ms.git
cd ~/books-ms
我们克隆了 books-ms 仓库,现在可以通过 Docker Compose 运行该服务:
docker-compose up -d app
由于 app 目标与 db 目标相连,Docker Compose 一并启动了它们。目前为止,效果与我们在没有 Docker Swarm 的情况下运行相同命令的情况没有什么不同。让我们来看一下创建的进程:
docker ps --filter name=books --format "table {{.Names}}"
输出结果如下:
NAMES
swarm-node-2/booksms_app_1
swarm-node-2/booksms_app_1/booksms_db_1,swarm-node-2/booksms_app_1/db,swarm-node-2/booksms_app_1/db_1,swarm-node-2/booksms_db_1
如我们所见,这两个容器都在 swarm-node-2 上运行。在你的情况下,它可能是 swarm-node-1。我们并没有决定在哪里部署容器,Swarm 为我们做了这个决定。因为我们使用的是默认策略,它会在没有指定额外约束的情况下,将容器部署到正在运行容器数量最少的服务器上。由于 swarm-node-1 和 swarm-node-2 都是空闲状态(或都已满),Swarm 很容易做出选择,并可以将容器部署到其中任何一台服务器上。在这个例子中,它选择了 swarm-node-2。
我们刚才执行的部署存在一个问题,即两个目标(app 和 db)是相互链接的。在这种情况下,Docker 别无选择,只能将这两个容器放在同一台服务器上。这从某种意义上说违背了我们想要实现的目标。我们希望将容器部署到集群中,并且正如你很快会发现的那样,能够轻松地扩展它们。如果这两个容器必须运行在同一台服务器上,那么我们就限制了 Swarm 正确分布它们的能力。在这个例子中,这两个容器最好运行在不同的服务器上。如果在部署这些容器之前,两个服务器上运行的容器数量相等,那么将 app 运行在一个服务器上,将 db 运行在另一个服务器上会更有意义。这样我们就能更好地分配资源使用。现在,swarm-node-2 必须承担所有工作,而 swarm-node-1 是空闲的。我们首先应该做的是去除链接。
让我们停止正在运行的容器并重新开始:
docker-compose stop
docker-compose rm -f
这又是 Swarm 提供的另一个优势示例。我们将 stop 和 rm 命令发送给 Swarm 主节点,它为我们定位了容器。从现在起,所有行为将是相同的,意味着通过 Swarm 主节点,我们将把整个集群当作一个单一的单位来处理,而不关心每台服务器的具体情况。
在没有链接的情况下使用 Docker Swarm 部署
为了正确地将容器部署到 Docker Swarm 集群中,我们将使用一个不同的文件来定义 Docker Compose;docker-compose-no-links.yml。目标如下:
app:
image: 10.100.198.200:5000/books-ms
ports:
- 8080
db:
image: mongo
在 docker-compose.yml 和 docker-compose-swarm.yml 中定义的 app 和 db 目标之间唯一显著的区别是,后者没有使用链接。正如你很快会看到的,这将允许我们在集群内自由地分配容器。
让我们看看如果我们在没有链接的情况下启动 db 和 app 容器会发生什么。
docker-compose -f docker-compose-no-links.yml up -d db app
docker ps --filter name=books --format "table {{.Names}}"
docker ps 命令的输出如下:
NAMES
swarm-node-1/booksms_db_1
swarm-node-2/booksms_app_1
正如你所看到的,这次,Swarm 决定将每个容器放置在不同的服务器上。它启动了第一个容器,并且由于从那时起一台服务器上的容器数量多于另一台,它选择在另一个节点上启动第二个容器。
通过移除容器之间的链接,我们解决了一个问题,但引入了另一个问题。现在我们的容器可以更有效地分布,但它们无法相互通信。我们可以通过使用 proxy 服务(如 nginx、HAProxy 等)来解决这个问题。然而,我们的 db 目标没有对外暴露任何端口。一个好的做法是仅暴露那些对外公开的服务端口。因此,app 目标暴露了端口 8080,而 db 目标没有暴露任何端口。db 目标仅供内部使用,并且仅供 app 使用。从 Docker 1.9 版本开始,链接可以被视为已废弃,因为出现了一个名为 networking 的新特性:
让我们删除容器并尝试启用网络功能启动它们:
docker-compose -f docker-compose-no-links.yml sto
p
docker-compose -f docker-compose-no-links.yml rm -f
使用 Docker Swarm 和 Docker 网络部署
在我写这章时,Docker 推出了新的 1.9 版本。这无疑是自 1.0 版本以来最重要的版本。它给我们带来了两个期待已久的功能:多主机网络和持久化存储卷。网络功能使得链接功能不再被推荐使用,这是我们连接跨多个主机的容器所需要的功能。现在不再需要代理服务来进行容器内部的连接。这并不是说代理不重要,而是我们应该将代理用作面向外部服务的公共接口,而使用网络来连接构成逻辑组的容器。新的 Docker 网络和代理服务有不同的优点,应该用于不同的场景。代理服务提供负载均衡,并且可以控制对我们服务的访问。Docker 网络是一种方便的方式,用于连接在同一网络上、构成单一服务的不同容器。Docker 网络的典型应用场景是一个需要连接数据库的服务。我们可以通过网络将它们连接起来。此外,服务本身可能需要扩展并运行多个实例。一个带负载均衡器的代理服务应该满足这一需求。最后,其他服务可能需要访问该服务。由于我们希望利用负载均衡,因此这种访问也应该通过代理进行:

图 14-12 – 多主机网络与代理和负载均衡服务的结合
图 14-12 展示了一个常见的应用场景。我们有一个扩展的服务,在nodes-01和nodes-03上运行了两个实例。所有对这些服务的通信都通过一个代理服务进行,代理服务负责负载均衡和安全性。任何想要访问我们服务的服务(无论是外部的还是内部的)都需要通过代理。内部服务使用数据库。服务实例和数据库之间的通信是内部的,并通过多主机网络进行。这种设置使我们能够在集群内轻松扩展,同时保持容器之间的所有内部通信仅限于构成单一服务的容器之间。换句话说,构成单一服务的容器之间的所有通信都通过网络进行,而服务之间的通信则通过代理进行。
创建多主机网络有不同的方法。我们可以手动设置网络:
docker network create my-network
docker network ls
network ls命令的输出如下:
NETWORK ID NAME DRIVER
5fc39aac18bf swarm-node-2/host host
aa2c17ae2039 swarm-node-2/bridge bridge
267230c8d144 my-network overlay
bfc2a0b1694b swarm-node-2/none null
b0b1aa45c937 swarm-node-1/none null
613fc0ba5811 swarm-node-1/host host
74786f8b833f swarm-node-1/bridge bridge
你可以看到其中一个网络是我们之前创建的my-network。它跨越了整个 Swarm 集群,我们可以使用--net参数来使用它:
docker run -d --name books-ms-db \
--net my-network \
mongo
docker run -d --name books-ms \
--net my-network \
-e DB_HOST=books-ms-db \
-p 8080 \
10.100.198.200:5000/books-ms
我们启动了两个组成单一服务的容器;books-ms是与books-ms-db通信的 API,后者充当数据库。由于两个容器都具有--net my-network参数,它们都属于my-network网络。因此,Docker 更新了 hosts 文件,为每个容器提供了一个别名,可用于内部通信。
让我们进入books-ms容器并查看主机文件:
docker exec -it books-ms bash
cat /etc/hosts
exit
exec命令的输出如下所示:
10.0.0.2 3166318f0f9c
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
10.0.0.2 books-ms-db
10.0.0.2 books-ms-db.my-network
hosts文件的有趣部分是最后两个条目。Docker 检测到books-ms-db容器使用与books-ms容器相同的网络,并通过添加books-ms-db和books-ms-db.my-network别名来更新hosts文件。如果使用某种约定,编写代码以使服务使用类似的别名与位于单独容器中的资源通信是微不足道的(在这种情况下是与数据库通信)。
我们还向book-ms传递了一个名为DB_HOST的环境变量。这表明我们的服务将使用哪个主机来连接数据库。我们可以通过输出容器的环境变量来查看这一点:
docker exec -it books-ms env
命令的输出如下所示:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=eb3443a66355
DB_HOST=books-ms-db
DB_DBNAME=books
DB_COLLECTION=books
HOME=/root
如您所见,其中一个环境变量是值为books-ms-db的DB_HOST。
现在我们有由 Docker 网络创建的主机别名books-ms-db指向的 IP。我们还有一个环境变量DB_HOST,其值为books-ms-db。服务的代码使用该变量连接到数据库。
正如预期的那样,我们可以在 Docker Compose 规范中指定network。在尝试之前,让我们删除这两个容器和网络。
docker rm -f books-ms books-ms-db
docker network rm my-network
这次,我们将通过 Docker Compose 运行容器。我们将在docker-compose-swarm.yml中使用net参数,并以与之前相同的方式执行操作。另一种方法是使用新的 Docker Compose 参数--x-networking,它会为我们创建网络,但目前处于试验阶段并不完全可靠。在继续之前,让我们快速查看docker-compose-swarm.yml文件中的相关目标:
app:
image: 10.100.198.200:5000/books-ms
ports:
- 8080
net: books-ms
environment:
- SERVICE_NAME=books-ms
- DB_HOST=books-ms-db
db:
container_name: books-ms-db
image: mongo
net: books-ms
environment:
- SERVICE_NAME=books-ms-db
唯一重要的区别是添加了net参数。其他方面与我们目前探索的许多其他目标基本相同。
让我们通过 Docker Compose 创建网络并运行我们的容器:
docker network create books-ms
docker-compose -f docker-compose-swarm.yml \
up -d db app
我们刚刚运行的命令的输出如下所示:
Creating booksms_app_1
Creating books-ms-db
在创建app和db服务之前,我们创建了一个名为books-ms的新网络。网络的名称与docker-compose-swarm.yml文件中指定的net参数的值相同。
通过运行docker network ls命令,我们可以确认网络已创建:
docker network ls
输出如下:
NETWORK ID NAME DRIVER
6e5f816d4800 swarm-node-1/host host
aa1ccdaefd70 swarm-node-2/docker_gwbridge bridge
cd8b1c3d9be5 swarm-node-2/none null
ebcc040e5c0c swarm-node-1/bridge bridge
6768bad8b390 swarm-node-1/docker_gwbridge bridge
8ebdbd3de5a6 swarm-node-1/none null
58a585d09bbc books-ms overlay
de4925ea50d1 swarm-node-2/bridge bridge
2b003ff6e5da swarm-node-2/host host
如您所见,overlay网络books-ms已创建。
我们还可以再次检查容器内的hosts文件是否已更新:
docker exec -it booksms_app_1 bash
cat /etc/hosts
exit
命令的输出如下所示:
10.0.0.2 3166318f0f9c
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
10.0.0.3 books-ms-db
10.0.0.3 books-ms-db.my-network
最后,让我们看看 Swarm 是如何分配我们的容器的:
docker ps --filter name=books --format "table {{.Names}}"
输出如下:
NAMES
swarm-node-2/books-ms-db
swarm-node-1/booksms_app_1
Swarm 将 app 容器部署到 swarm-node-1,将 db 容器部署到 swarm-node-2。
最后,让我们测试一下 book-ms 服务是否正常工作。我们不知道 Swarm 将容器部署到了哪里,也不知道暴露了哪个端口。由于我们(还)没有代理服务,我们将从 Consul 获取服务的 IP 和端口,发送 PUT 请求将数据存储到不同容器中的数据库中,最后发送 GET 请求检查是否能检索到记录。由于我们没有代理服务来确保请求被重定向到正确的服务器和端口,我们必须从 Consul 获取 IP 和端口:
ADDRESS=`curl \
10.100.192.200:8500/v1/catalog/service/books-ms \
| jq -r '.[0].ServiceAddress + ":" + (.[0].ServicePort | tostring)'`
curl -H 'Content-Type: application/json' -X PUT -d \
'{"_id": 2,
"title": "My Second Book",
"author": "John Doe",
"description": "A bit better book"}' \
$ADDRESS/api/v1/books | jq '.'
curl $ADDRESS/api/v1/books | jq '.'
The output of the last command is as follows.
[
{
"author": "John Doe",
"title": "My Second Book",
"_id": 2
}
]
如果服务无法与位于不同节点的数据库通信,我们将无法进行数据的插入和读取。部署到不同服务器上的容器之间的网络连接正常!我们所需要做的就是在 Docker Compose 中使用额外的参数(net),并确保服务代码使用主机文件中的信息。
Docker 网络的另一个优点是,如果某个容器停止工作,我们可以重新部署它(可能部署到另一个服务器),并且假设服务能够处理临时的连接中断,我们可以像什么都没发生一样继续使用它。
使用 Docker Swarm 扩展服务
正如你已经看到的,使用 Docker Compose 进行扩展非常简单。虽然到目前为止我们运行的例子都局限于单台服务器,但使用 Docker Swarm 我们可以将扩展范围扩展到整个集群。现在我们有一个 books-ms 实例在运行,我们可以将其扩展到三个实例:
docker-compose -f docker-compose-swarm.yml \
scale app=3
docker ps --filter name=books \
--format "table {{.Names}}"
ps 命令的输出如下:
NAMES
swarm-node-2/booksms_app_3
swarm-node-1/booksms_app_2
swarm-node-2/books-ms-db
swarm-node-1/booksms_app_1
我们可以看到 Swarm 继续均匀地分配容器。每个节点当前运行两个容器。由于我们请求 Docker Swarm 将 books-ms 容器扩展到三个,所以现在其中两个容器独立运行,第三个容器与数据库一起部署。以后,当我们开始自动化部署到 Docker Swarm 集群时,我们还会确保所有服务实例正确设置到代理中。
为了以后参考,我们可能想将实例数量存储在 Consul 中。以后,当我们想增加或减少实例数量时,这会非常有用:
curl -X PUT -d 3 \
10.100.192.200:8500/v1/kv/books-ms/instances
Services can be as easily descaled. For example, the traffic might drop, later during the day, and we might want to free resources for other services.
docker-compose -f docker-compose-swarm.yml \
scale app=1
curl -X PUT -d 1 \
10.100.192.200:8500/v1/kv/books-ms/instances
docker ps --filter name=books \
--format "table {{.Names}}"
由于我们指示 Swarm 将实例数量缩放(减少)到一个,而当时有三个实例在运行,Swarm 删除了第二和第三个实例,最终系统只剩下一个实例。这可以从 docker ps 命令的输出中看到,输出如下:
NAMES
swarm-node-2/books-ms-db
swarm-node-1/booksms_app_1
我们进行了缩容并回到了最初的状态,每个目标运行一个实例。
接下来我们将探讨更多的 Swarm 选项。在继续之前,让我们停止并删除当前运行的容器,然后重新开始:
docker-compose stop
docker-compose rm -f
根据保留的 CPU 和内存调度容器
到目前为止,Swarm 一直在将部署调度到运行容器最少的服务器上。这是没有指定其他约束条件时应用的默认策略。通常情况下,不现实地期望所有容器都能平等访问资源。我们可以通过向 Swarm 提供容器期望的提示来进一步优化部署。例如,我们可以指定某个容器需要多少个 CPU。让我们试试看。
docker info
命令输出的相关部分如下:
...
Nodes: 2
swarm-node-1: 10.100.192.201:2375
└ Containers: 2
└ Reserved CPUs: 0 / 1
└ Reserved Memory: 0 B / 1.535 GiB
...
swarm-node-2: 10.100.192.202:2375
└ Containers: 2
└ Reserved CPUs: 0 / 1
└ Reserved Memory: 0 B / 1.535 GiB
...
尽管我们每个节点已经运行了两个容器(Registrator 和 Swarm),但没有预留 CPU,也没有预留内存。当我们运行这些容器时,并未指定需要预留 CPU 或内存。
让我们尝试运行 Mongo DB,并为进程预留一个 CPU。请记住,这只是一个提示,并不会阻止已经部署在这些服务器上的其他容器使用该 CPU。
docker run -d --cpu-shares 1 --name db1 mongo
docker info
由于每个节点只有一个 CPU 被分配,我们无法分配更多。docker info 命令的相关部分输出如下:
...
Nodes: 2
swarm-node-1: 10.100.192.201:2375
└ Status: Healthy
└ Containers: 3
└ Reserved CPUs: 1 / 1
└ Reserved Memory: 0 B / 1.535 GiB
...
swarm-node-2: 10.100.192.202:2375
└ Status: Healthy
└ Containers: 2
└ Reserved CPUs: 0 / 1
└ Reserved Memory: 0 B / 1.535 GiB
...
这次,swarm-node-1 保留了一个(共一个)CPU。由于该节点没有更多的 CPU 可用,如果我们重复该过程并启动另一个 Mongo DB,且设置相同的约束,Swarm 将别无选择,只能将其部署到第二个节点。让我们试试看:
docker run -d --cpu-shares 1 --name db2 mongo
docker info
ps 命令输出的相关部分如下:
...
Nodes: 2
swarm-node-1: 10.100.192.201:2375
└ Status: Healthy
└ Containers: 3
└ Reserved CPUs: 1 / 1
└ Reserved Memory: 0 B / 1.535 GiB
...
swarm-node-2: 10.100.192.202:2375
└ Status: Healthy
└ Containers: 3
└ Reserved CPUs: 1 / 1
└ Reserved Memory: 0 B / 1.535 GiB
...
这次,两个节点都预留了所有的 CPU。
我们可以查看进程,并确认两个数据库确实正在运行:
docker ps --filter name=db --format "table {{.Names}}"
输出如下:
NAMES
swarm-node-2/db2
swarm-node-1/db1
确实,两个容器都在运行,每个节点一个。
让我们看看,如果我们尝试启动一个需要一个 CPU 的容器,会发生什么:
docker run -d --cpu-shares 1 --name db3 mongo
这次,Swarm 返回了以下错误消息:
Error response from daemon: no resources available to schedule container
我们请求部署一个需要一个 CPU 的容器,Swarm 回复说没有可用的节点能满足这个要求。在我们继续探索其他约束之前,请记住,CPU Shares 在 Swarm 中的工作方式与在单个服务器上运行的 Docker 中不同。有关此类情况的更多信息,请参阅 docs.docker.com/engine/reference/run/#cpu-share-constraint 页面。
让我们移除容器并重新开始:
docker rm -f db1 db2
我们也可以将内存作为一种约束。例如,我们可以指示 Swarm 部署一个保留一个 CPU 和一 GB 内存的容器:
docker run -d --cpu-shares 1 -m 1g --name db1 mongo
docker info
docker info 命令的输出如下(仅限相关部分):
...
Nodes: 2
swarm-node-1: 10.100.192.201:2375
└ Status: Healthy
└ Containers: 3
└ Reserved CPUs: 1 / 1
└ Reserved Memory: 1 GiB / 1.535 GiB
...
swarm-node-2: 10.100.192.202:2375
└ Status: Healthy
└ Containers: 2
└ Reserved CPUs: 0 / 1
└ Reserved Memory: 0 B / 1.535 GiB
...
这次不仅保留了一个 CPU,而且几乎所有的内存也被保留。尽管我们使用 CPU 约束时未能演示出太多内容,因为我们的节点每个只有一个,但在内存方面我们有更大的操作余地。例如,我们可以启动三个 Mongo DB 实例,每个实例预留 100 MB 内存:
docker run -d -m 100m --name db2 mongo
docker run -d -m 100m --name db3 mongo
docker run -d -m 100m --name db4 mongo
docker info
docker info命令的输出如下(只列出相关部分):
...
Nodes: 2
swarm-node-1: 10.100.192.201:2375
└ Status: Healthy
└ Containers: 3
└ Reserved CPUs: 1 / 1
└ Reserved Memory: 1 GiB / 1.535 GiB
...
swarm-node-2: 10.100.192.202:2375
└ Status: Healthy
└ Containers: 5
└ Reserved CPUs: 0 / 1
└ Reserved Memory: 300 MiB / 1.535 GiB
...
很明显,所有这三个容器都被部署到swarm-node-2。Swarm 意识到第二个节点在swarm-node-1上的可用内存较少,决定将新容器部署到swarm-node-2。由于使用了相同的限制,这一决策被重复了两次。因此,swarm-node-2现在运行着这三个容器,并预留了 300MB 的内存。我们可以通过检查运行中的进程来确认这一点:
docker ps --filter name=db --format "table {{.Names}}"
输出如下:
NAMES
swarm-node-2/db4
swarm-node-2/db3
swarm-node-2/db2
swarm-node-1/db1
我们可以通过多种方式向 Swarm 提供容器部署提示,但我们不会探索所有方法。我邀请你查看 Docker 文档中的策略(docs.docker.com/swarm/scheduler/strategy/)和过滤器(docs.docker.com/swarm/scheduler/filter/)。
此刻,我们已经掌握了足够的知识,可以尝试将部署自动化到 Docker Swarm 集群。
在继续之前,我们先删除到目前为止运行的容器:
docker rm -f db1 db2 db3 db4
使用 Docker Swarm 和 Ansible 自动化部署
我们已经熟悉 Jenkins 工作流,应该相对容易将这些知识扩展到 Docker Swarm 的部署上。
首先,最重要的是。我们需要为cd节点配置 Jenkins:
ansible-playbook /vagrant/ansible/jenkins-node-swarm.yml \
-i /vagrant/ansible/hosts/prod
ansible-playbook /vagrant/ansible/jenkins.yml \
-c local
这两个剧本部署了一个熟悉的 Jenkins 实例,包含两个节点。这次,我们运行的从节点是cd和swarm-master。在其他任务中,剧本基于Multibranch Workflow创建了books-ms-swarm任务。与之前使用的其他多分支任务的唯一区别在于Include branches过滤器,这次设置为swarm:

图 14-13 – books-ms-swarm Jenkins 任务的配置屏幕
让我们索引分支并让任务运行,同时我们可以查看位于books-ms swarm分支中的 Jenkinsfile。
请打开books-ms-swarm任务并点击Branch Indexing,然后点击Run Now。由于只有一个分支符合指定的过滤器,Jenkins 将创建一个名为swarm的子项目并开始构建。如果你对构建进度感兴趣,可以通过打开构建控制台来监视进展。
检查 Swarm 部署剧本
Jenkinsfile 中定义的 Jenkins 工作流内容如下:
node("cd") {
def serviceName = "books-ms"
def prodIp = "10.100.192.200" // Modified
def proxyIp = "10.100.192.200" // Modified
def proxyNode = "swarm-master"
def registryIpPort = "10.100.198.200:5000"
def swarmPlaybook = "swarm.yml" // Modified
def proxyPlaybook = "swarm-proxy.yml" // Added
def instances = 1 // Added
def flow = load "/data/scripts/workflow-util.groovy"
git url: "https://github.com/vfarcic/${serviceName}.git"
flow.provision(swarmPlaybook) // Modified
flow.provision(proxyPlaybook) // Added
flow.buildTests(serviceName, registryIpPort)
flow.runTests(serviceName, "tests", "")
flow.buildService(serviceName, registryIpPort)
def currentColor = flow.getCurrentColor(serviceName, prodIp)
def nextColor = flow.getNextColor(currentColor)
flow.deploySwarm(serviceName, prodIp, nextColor, instances) // Modified
flow.runBGPreIntegrationTests(serviceName, prodIp, nextColor)
flow.updateBGProxy(serviceName, proxyNode, nextColor)
flow.runBGPostIntegrationTests(serviceName, prodIp, proxyIp, proxyNode, currentColor, nextColor)
}
我在修改和新增的行上添加了注释(与上一章中的 Jenkinsfile 进行对比),这样我们可以探索与蓝绿分支中定义的 Jenkinsfile 的差异。
变量prodIp和proxyIp已经修改为指向swarm-master节点。这一次,我们使用了两个 Ansible playbook 来配置集群。swarmPlaybook变量保存配置整个Swarm集群的 playbook 名称,而proxyPlaybook变量引用的是负责在swarm-master节点上设置nginx代理的 playbook。在现实情况下,Swarm 主节点和代理服务应当分开,但在这里,我选择不使用额外的虚拟机,以节省你笔记本上的一些资源。最后,instances变量(默认值为1)被添加到了脚本中。我们将很快探讨它的使用。
唯一真正显著的区别是使用了deploySwarm函数来替代deployBG。这是在workflow-util.groovy脚本中定义的另一个工具函数。其内容如下:
def deploySwarm(serviceName, swarmIp, color, instances) {
stage "Deploy"
withEnv(["DOCKER_HOST=tcp://${swarmIp}:2375"]) {
sh "docker-compose pull app-${color}"
try {
sh "docker network create ${serviceName}"
} catch (e) {}
sh "docker-compose -f docker-compose-swarm.yml \
-p ${serviceName} up -d db"
sh "docker-compose -f docker-compose-swarm.yml \
-p ${serviceName} rm -f app-${color}"
sh "docker-compose -f docker-compose-swarm.yml \
-p ${serviceName} scale app-${color}=${instances}"
}
putInstances(serviceName, swarmIp, instances)
}
如同之前,我们首先从注册表中拉取最新的容器。新增的内容是创建一个 Docker 网络。由于网络只能创建一次,所有后续尝试都会导致错误,因此sh命令被封装在try/catch块中,这样可以防止脚本失败。
创建网络后,接着是部署db和app目标。与始终作为单个实例部署的数据库不同,app目标可能需要扩展。因此,第一个目标通过up命令部署,而第二个通过 Docker Compose 提供的scale命令进行部署。scale命令利用instances变量来确定应部署多少个副本。我们可以通过简单地改变 Jenkinsfile 中的instances变量来增加或减少副本数。一旦此更改提交到仓库,Jenkins 会运行新的构建并部署我们指定数量的实例。
最后,我们通过调用辅助函数putInstances将实例数传递给 Consul,该函数执行了一个简单的 Shell 命令。尽管我们现在不会使用这些信息,但它将在下一章构建自愈系统时派上用场。
就这样。我们只需对 Jenkinsfile 做几个小修改,就能将blue-green部署从单一服务器扩展到整个 Swarm 集群。Docker Swarm 和 Jenkins Workflow 证明非常易于使用,甚至更容易维护,而且依然非常强大。
到这个时候,swarm 子项目的构建可能已经完成。我们可以从构建控制台屏幕验证这一点,或者直接通过打开 books-ms-swarm 作业,确认最后一次构建的状态由 blue 球表示。如果你想知道为什么成功状态是用蓝色而不是绿色表示的,请阅读 为什么 Jenkins 显示蓝球? 文章,链接在此:jenkins.io/blog/2012/03/13/why-does-jenkins-have-blue-balls/:

图 14-14 – books-ms-swarm Jenkins 作业屏幕
现在我们了解了 Jenkinsfile 脚本的背后含义,并且构建已完成,我们可以手动验证一切是否正常工作。
运行 Swarm Jenkins 工作流
Swarm 子项目的第一次运行是在 Jenkins 自动启动的,一旦完成分支索引。剩下的工作就是再次检查整个过程是否确实正确执行。
这是第一次部署,因此蓝色版本应该在集群的某个地方运行。让我们来看看 Swarm 决定将我们的容器部署在哪里:
export DOCKER_HOST=tcp://10.100.192.200:2375
docker ps --filter name=books --format "table {{.Names}}"
ps 命令的输出如下:
NAMES
swarm-node-2/booksms_app-blue_1
swarm-node-1/books-ms-db
在这种情况下,Swarm 将 books-ms 容器部署到了 swarm-node-2,将 Mongo DB 部署到了 swarm-node-1。我们还可以验证服务是否已正确存储在 Consul 中:
curl swarm-master:8500/v1/catalog/service/books-ms-blue \
| jq '.'
curl swarm-master:8500/v1/kv/books-ms/color?raw
curl swarm-master:8500/v1/kv/books-ms/instances?raw
所有三个命令的输出如下:
[
{
"ServicePort": 32768,
"ServiceAddress": "10.100.192.202",
"ServiceTags": null,
"ServiceName": "books-ms-blue",
"ServiceID": "swarm-node-2:booksms_app-blue_1:8080",
"Address": "10.100.192.202",
"Node": "swarm-node-2"
}
]
...
blue
...
1
根据 Consul,版本已部署到 swarm-node-2 (10.100.192.202),并且端口为 32768。我们当前正在运行的是 blue 版本,且仅有一个实例在运行。
最后,我们可以通过向服务发送几个请求来再次确认服务确实在工作:
curl -H 'Content-Type: application/json' -X PUT -d \
'{"_id": 1,
"title": "My First Book",
"author": "John Doe",
"description": "Not a very good book"}' \
swarm-master/api/v1/books | jq '.'
curl swarm-master/api/v1/books | jq '.'
第一个请求是 PUT,向服务发送信号,表示我们想要存储这本书。第二个请求则是检索所有书籍的列表。
自动化过程在第一次运行时似乎工作正常。我们将再次执行构建并部署绿色版本。
Swarm 部署剧本的第二次运行
让我们部署下一个版本。
请打开 swarm 子项目并点击 "立即构建" 链接。构建将开始,我们可以从控制台屏幕进行监控。几分钟后,构建将完成执行,我们将能够检查结果:
docker ps -a --filter name=books --format "table {{.Names}}\t{{.Status}}"
ps 命令的输出如下:
NAMES STATUS
swarm-node-2/booksms_app-green_1 Up 7 minutes
swarm-node-2/booksms_app-blue_1 Exited (137) 15 seconds ago
swarm-node-1/books-ms-db Up 10 hours
由于我们运行的是 green 版本,blue 版本处于 Exited 状态。我们可以通过 Consul 查看当前运行版本的信息:
curl swarm-master:8500/v1/catalog/service/books-ms-green \
| jq '.'
Consul 请求的响应如下:
[
{
"ModifyIndex": 3314,
"CreateIndex": 3314,
"Node": "swarm-node-2",
"Address": "10.100.192.202",
"ServiceID": "swarm-node-2:booksms_app-green_1:8080",
"ServiceName": "books-ms-green",
"ServiceTags": [],
"ServiceAddress": "10.100.192.202",
"ServicePort": 32770,
"ServiceEnableTagOverride": false
}
]
现在我们可以测试服务本身:
curl swarm-master/api/v1/books | jq '.'
由于我们已经启动了 Consul UI,请在您喜欢的浏览器中打开 http://10.100.192.200:8500/ui 地址,以获取我们已部署服务的可视化展示。
作为一个练习,fork books-ms 仓库并修改作业以使用你自己的仓库。打开 swarm 分支中的 Jenkinsfile,将其修改为部署三个实例的服务,并推送更改。再次运行构建,完成后确认三个实例已部署到集群中。
清理
这就是我们对 Docker Swarm 的介绍。我们将在接下来的章节中更多地使用它。在进入下一个主题之前,让我们销毁虚拟机。需要时我们会重新创建它们:
exit
vagrant destroy -f
我们开发的解决方案仍然存在不少问题。系统不具备容错能力,且难以监控。下一章将通过创建自愈系统来解决第一个问题。
第十五章:自我修复系统
| 修复需要勇气,我们都有勇气,即使我们需要挖掘一些才能找到它。 | ||
|---|---|---|
| --Tori Amos |
让我们面对现实吧。我们正在创建的系统并不完美。迟早,我们的某个应用会失败,我们的某个服务无法承受增加的负载,我们的某个提交会引入致命的 bug,某个硬件会损坏,或者某些完全意想不到的事情会发生。
我们如何应对意外情况?大多数人都在尝试开发一个防弹系统。我们试图创造前所未有的东西。我们追求终极完美,希望结果是一个没有任何 bug、运行在永不故障的硬件上的、能够处理任何负载的系统。这里有一个提示。没有完美这个东西。没有人是完美的,也没有什么是没有缺陷的。这并不意味着我们不应该追求完美。在时间和资源允许的情况下,我们应该追求完美。然而,我们也应该接受不可避免的事实,并设计我们的系统,不是为了完美,而是为了能够从故障中恢复,能够预测未来可能发生的情况。我们应该为最好的结果做好准备,但也要为最坏的情况做好准备。
在软件工程之外,有许多坚韧系统的例子,而没有哪个比生命本身更好。我们可以以我们自己,人类,作为例子。我们是一个经过漫长实验的结果,这些实验基于小而渐进的进化改善,经过了数百万年。我们可以从人体中学到很多,并将这些知识应用于我们的软件和硬件。我们(人类)拥有的一个迷人能力就是自我修复的能力。
人体具有惊人的自我修复能力。人体最基本的单位是细胞。在我们的一生中,体内的细胞不断工作,帮助我们恢复到平衡状态。每个细胞都是一个动态的、活跃的单元,不断监控和调整自身的过程,努力根据其最初的 DNA 代码恢复自我,并维持体内的平衡。细胞不仅具备自我修复的能力,还能生成新的细胞,替代那些被永久损坏或摧毁的细胞。即使大量细胞被摧毁,周围的细胞也会复制生成新细胞,从而迅速替代已被摧毁的细胞。这种能力并不能使我们免于死亡,但它确实让我们具有了很强的韧性。我们不断受到病毒的攻击,我们会生病,但在大多数情况下,我们最终会胜利。然而,把我们当作个体来看待就意味着我们忽视了更大的图景。即使我们自身的生命终结,生命本身不仅会生存下来,还会繁荣发展,持续生长,不断适应。
我们可以把计算机系统看作由各种类型的细胞组成的人体。这些细胞可以是硬件或软件。当它们是软件单元时,越小的单元越容易自愈、从故障中恢复、复制,甚至在需要时销毁。我们称这些小单元为微服务,它们的行为实际上类似于人体中的一些行为。我们正在构建的基于微服务的系统可以设计成具有自愈能力。并不是说我们即将探讨的自愈仅适用于微服务,它并非如此。然而,就像我们探讨的其他大多数技术一样,自愈可以应用于几乎任何类型的架构,但与微服务结合时能获得最佳效果。就像由个体组成的整体生态系统一样,每个计算机系统都是更大系统的一部分。它与其他系统进行通信、合作,并适应其他系统,形成一个更大的整体。
自愈的层级与类型
在软件系统中,"自愈"一词描述的是任何能够发现自身工作不正常,并且在没有任何人工干预的情况下,做出必要的调整以恢复到正常或设计状态的应用程序、服务或系统。自愈的关键在于使系统能够通过持续检查和优化自身状态,自动适应变化的条件,从而做出决策。其目标是使系统具备容错性和响应性,能够应对需求的变化并从故障中恢复。
自愈系统可以根据我们所监控和处理的资源的大小和类型分为三个层级,这些层级如下:
-
应用层级
-
系统层级
-
硬件层级
我们将分别探讨这三种类型。
应用层的自愈
应用层自愈是指应用程序或服务能够在内部自我修复的能力。传统上,我们习惯通过异常捕获问题,并在大多数情况下将其记录以便后续检查。当发生此类异常时,我们往往会忽略它并继续前进(在记录日志之后),仿佛什么都没有发生,期待未来一切顺利。在其他情况下,如果发生某种类型的异常,我们往往会停止应用程序。例如,连接数据库时。如果应用程序启动时未能建立连接,我们通常会停止整个过程。如果我们稍微有经验一点,可能会尝试重复连接数据库。希望这些尝试是有限的,否则我们很容易陷入一个永无止境的循环,除非数据库连接失败是暂时性的,并且数据库很快恢复在线。随着时间的推移,我们找到了更好的方法来处理应用程序内部的问题。其中之一就是 Akka。它使用的监督者机制和它所推广的设计模式,让我们能够创建具有自愈能力的内部应用程序和服务。Akka 并不是唯一的,还有许多其他库和框架让我们能够创建具备容错能力的应用程序,从潜在的灾难性情况下恢复。由于我们尽量保持编程语言的独立性,我将把如何实现内部自愈留给你,亲爱的读者,去探索。请记住,这里的自愈是指内部过程的自我修复,并不包括例如从失败的进程中恢复。而且,如果我们采用微服务架构,我们可能会很快遇到使用不同语言、不同框架编写的服务等等。每个服务的开发人员真正决定了它如何设计,使其能够自我修复并从故障中恢复。
让我们进入第二个层次。
系统层级的自愈
与依赖于编程语言和我们内部应用的设计模式的应用级自愈不同,系统级自愈可以广泛应用于所有服务和应用,无论其内部结构如何。这是我们可以在整个系统级别设计的自愈类型。虽然在系统级别上可能会发生许多情况,但最常监控的两个方面是进程失败和响应时间。如果进程失败,我们需要重新部署服务或重启进程。另一方面,如果响应时间不合适,我们需要进行扩展或缩减,具体取决于我们是否已达到响应时间的上限或下限。从进程失败中恢复通常是不够的。尽管这样的操作可能会将系统恢复到所需状态,但通常仍然需要人工干预。我们需要调查故障原因,修正服务的设计或修复 Bug。也就是说,自愈往往伴随故障原因的调查。系统会自动恢复,而我们(人类)则尝试从这些失败中学习,并改进整个系统。因此,某种类型的通知也是必需的。在故障和流量增加的两种情况下,系统都需要自我监控并采取措施。
系统如何监控自己?它如何检查组件的状态?有许多方法,但最常用的两种是 TTL 和 Ping。
生存时间
生存时间(TTL)检查期望服务或应用程序定期确认其正常运行。接收 TTL 信号的系统会跟踪给定 TTL 的最后已知报告状态。如果该状态在预定时间内没有更新,监控系统会认为服务失败,并需要恢复到其设计状态。例如,一个健康的服务可以发送一个 HTTP 请求,宣布它仍然活着。如果服务运行的进程失败,它将无法发送该请求,TTL 会过期,且会执行相应的应急措施。
TTL 的主要问题是耦合性。应用程序和服务需要与监控系统绑定。实现 TTL 会成为一种微服务反模式,因为我们试图以尽可能自主的方式设计它们。此外,微服务应该具有明确的功能和单一的目的。在它们内部实现 TTL 请求会增加额外的功能,且会使开发过程更加复杂:

图 15-01 – 系统级自愈与生存时间(TTL)
Ping 操作
Ping 的想法是从外部检查应用程序或服务的状态。监控系统应定期 ping 每个服务,如果未收到响应,或响应的内容不合适,则执行自愈措施。Ping 可以有多种形式。如果服务暴露了 HTTP API,通常只需要一个简单的请求,其中期望的响应应该是 2XX 范围内的 HTTP 状态。在其他情况下,当没有暴露 HTTP API 时,ping 可以通过脚本或任何其他能够验证服务状态的方法来完成。
Ping 检查与 TTL 相反,当可能时,它是检查系统各个部分状态的首选方式。它消除了重复、耦合和实现 TTL 时可能出现的复杂性。

图 15-02 – 使用 ping 的系统级自愈
硬件级自愈
说实话,硬件自愈并不存在。我们无法拥有一个能够自动修复失败的内存、修复损坏的硬盘、修复故障的 CPU 等的过程。硬件级自愈真正的含义是将服务从一个不健康的节点重新部署到一个健康的节点。与系统级别一样,我们需要定期检查不同硬件组件的状态,并根据情况采取相应的行动。实际上,大多数因硬件级故障引起的自愈将在系统级别发生。如果硬件工作不正常,那么服务很可能会失败,从而通过系统级的自愈来修复。硬件级自愈更多是与我们稍后将讨论的预防性检查相关:

图 15-03 – 硬件级自愈
除了根据检查级别进行划分外,我们还可以根据采取行动的时机进行划分。我们可以对故障做出反应,或者我们可以尝试预防故障。
反应性自愈
大多数实施某种自愈系统的组织集中在反应性自愈上。在检测到故障后,系统会做出反应并将自身恢复到设计状态。服务进程崩溃,ping 返回 404(未找到)代码,采取纠正措施后,服务恢复正常。这无论服务是因为进程失败还是整个节点停止工作而导致失败(假设我们有一个可以重新部署到健康节点的系统),都有效。这是最重要的自愈类型,同时也是最容易实现的。只要我们做好所有检查,并且在故障发生时能执行应采取的行动,并且每个服务至少有两个实例分布在不同的物理节点上,我们就应该(几乎)永远不会有停机时间。我之所以说几乎永远不,因为例如整个数据中心可能会失去电力,从而导致所有节点停止工作。一切都在于评估风险与防范这些风险的成本之间的平衡。
有时候,拥有两个位于不同地点的数据中心是值得的,而在其他情况下则不然。我们的目标是争取零停机时间,同时也要接受一些情况下,防止的努力并不值得。
无论我们是在争取零停机时间,还是几乎零停机时间,反应式自愈应该是除了最小规模环境之外的所有环境必须具备的,尤其是因为它不需要大量投资。你可以投资备用硬件,或者投资独立的数据中心。这些决策与自愈没有直接关系,而是与特定用例可接受的风险水平有关。反应式自愈的投资主要是知识和实施时间。虽然时间本身就是一种投资,但我们可以明智地利用它,创造一个通用的解决方案,适用于(几乎)所有情况,从而减少我们在实施该系统时需要投入的时间。
预防性修复
预防性修复的理念是预测我们未来可能遇到的问题,并采取措施避免这些问题的发生。我们如何预测未来呢?更准确地说,我们使用什么数据来预测未来?
一种相对容易,但不太可靠的预测未来的方法,是基于(近)实时数据来做假设。例如,如果我们用来检查服务健康状况的某个 HTTP 请求响应时间超过了 500 毫秒,我们可能会考虑扩展该服务。我们甚至可以做相反的事情。沿用同样的例子,如果响应时间少于 100 毫秒,我们可能想要缩减该服务,并将资源重新分配给另一个可能更需要的服务。考虑当前状态来预测未来的问题是其变动性。如果请求和响应之间的时间较长,这可能确实是扩展的信号,但也可能是流量的暂时性增加,下一次检查(流量高峰过后)会推测需要缩减。如果应用了微服务架构,这可能是一个小问题,因为它们小且易于移动,容易扩展和缩减。选择这种策略时,单体应用往往会更加棘手。
如果考虑到历史数据,预防性修复变得更加可靠,但同时也更加复杂,实施起来也更加困难。信息(响应时间、CPU、内存等)需要存储在某个地方,且通常需要使用复杂的算法来评估趋势并得出结论。例如,我们可能会观察到,在过去的一小时内,内存使用量稳步上升,达到了一个临界点,假设是 90%。这将清楚地表明,导致这一增长的服务需要进行扩展。系统还可以考虑更长时间段的数据,并推测每周一会突然出现流量激增,提前扩展服务以避免响应延迟。例如,如果一个服务部署后内存使用量持续增加,而当发布新版本时突然下降,这可能意味着存在内存泄漏问题。在这种情况下,系统可能需要在达到某个阈值时重启应用程序,并希望开发人员修复该问题(因此需要通知功能)。
让我们改变焦点,讨论一下架构。
自愈架构
无论内部流程和工具如何,每个自愈系统都会有一些共同的元素。
一开始,首先是集群。单个服务器无法做到容错。如果其硬件发生故障,我们无法修复它,也没有现成的替代品。因此,系统必须从集群开始。集群可以由两台或两百台服务器组成。规模不是关键,关键在于在发生故障时能够从一台硬件迁移到另一台硬件。记住,我们始终需要评估收益与成本的关系。如果财务上可行,我们会至少拥有两个物理和地理上分离的数据中心。这样,如果一个数据中心发生停电,另一个数据中心将能够完全运作。然而,在很多情况下,这不是一个经济可行的选择:

图 15-04 – 自愈系统架构:一切从集群开始
一旦我们让集群启动并运行,我们就可以开始部署我们的服务。然而,在没有某种调度器的情况下管理集群中的服务,充其量也只是繁琐。这需要时间,且常常导致资源使用非常不平衡:

图 15-05 – 自愈系统架构:服务被部署到集群中,但资源使用非常不平衡
在大多数情况下,人们把集群看作一组独立的服务器,这是错误的。如今我们手头有一些工具,可以帮助我们更好地进行编排。通过 Docker Swarm、Kubernetes 或 Apache Mesos,我们可以解决集群中的编排问题。集群编排不仅有助于简化服务的部署,而且在发生故障(无论是软件故障还是硬件故障)时,还可以快速重新部署到健康节点。记住,我们需要在代理后面至少运行每个服务的两个实例。在这种情况下,如果某个实例发生故障,其他实例可以接管它的负载,从而避免系统在重新部署故障实例时发生停机:

图 15-06 – 自愈系统架构:需要某种部署编排器来在集群中分发服务
任何自愈系统的基础都是监控已部署服务或应用程序的状态,以及底层硬件的状态。监控它们的唯一方法是掌握关于它们存在的信息。这些信息可以有多种不同形式,从手动维护的配置文件、传统的数据库,到像 Consul、etcd 或 Zookeeper 这样高可用的分布式服务注册表。在某些情况下,服务注册表由我们自行选择,而在另一些情况下,它是集群编排器的一部分。例如,Docker Swarm 具有灵活性,允许它与多个注册表一起工作,而 Kubernetes 则与 etcd 紧密集成:

图 15-07 – 自愈系统架构:监控系统状态的主要要求是将系统信息存储在服务注册表中
无论我们选择哪个工具作为服务注册表,接下来的障碍就是将信息放入所选的服务注册表中。原则很简单:需要有某种方式来监控硬件和服务,并在新增或移除某个服务时更新注册表。市面上有很多工具能够实现这一点。我们已经熟悉Registrator,它很好地完成了这一角色。与服务注册表类似,一些集群编排器已经自带了注册和注销服务的方式。无论选择哪种工具,主要的要求是能够监控集群并实时将信息发送到服务注册表:

图 15-08 – 自愈系统架构:如果没有机制来监控系统并存储新信息,服务注册表就没有意义
现在,我们已经有了运行中的集群服务,并且我们在服务注册表中有了系统信息,我们可以使用一些健康监控工具来检测异常。这样的工具不仅需要知道期望的状态是什么,还需要随时了解当前的实际状态。Consul Watches 可以履行这一角色,而 Kubernetes 和 Mesos 自带了针对这类任务的工具。在更传统的环境中,Nagios 或 Icinga(仅举几例)也能胜任这一角色:

图 15-09 – 自愈系统架构:所有相关信息都存储在服务注册表中,一些健康监控工具可以利用这些信息来验证是否保持了期望的状态
下一个关键部分是一个能够执行修正操作的工具。当健康监控器检测到异常时,它将发送一个消息来执行修正措施。最低要求是,该修正操作应向集群调度器发送信号,进而重新部署失败的服务。即使故障是由硬件问题引起的,集群调度器也会(临时)通过将服务重新部署到健康的节点上来修复这个问题。在大多数情况下,修正措施并不像那么简单。可能会有一个机制来通知相关方、记录发生的事件、回滚到旧版本的服务等。我们已经采用了 Jenkins,它非常适合作为接收来自健康监控器消息并触发修正措施的工具:

图 15-10 – 自愈系统架构:最低要求是,修正操作应向集群调度器发送信号,重新部署失败的服务
目前的流程仅处理反应式自愈。系统会持续监控,并在发现故障时采取纠正性措施,从而将系统恢复到期望的状态。我们能否更进一步,尝试实现预防性自愈?我们能否预测未来并相应采取行动?在许多情况下,我们可以做到,有些则不行。我们无法知道硬盘明天会故障,无法预测今天中午会发生停机。但是,在某些情况下,我们可以看到流量正在增加,很快就会达到需要扩展某些服务的临界点。我们可以预测即将启动的营销活动会增加负载。我们可以从错误中吸取教训,并教会系统在特定情况下如何反应。这样一套流程的核心要素与我们用于反应式自愈的流程类似。我们需要一个存储数据的地方,并且需要一个收集这些数据的过程。与处理相对较小数据量并且从分布式中受益的服务注册不同,预防性自愈需要更大的存储空间和能够进行一些分析操作的能力:

图 15-11 – 自愈系统架构:预防性自愈需要分析历史数据
类似于 registrator 服务,我们还需要一些数据收集器来发送历史数据。那些数据可能会非常庞大,包括但不限于 CPU、硬盘、网络流量、系统和服务日志等等。与主要监听由集群编排器生成事件的 registrator 不同,数据收集器应持续收集数据,消化输入内容,并生成输出,作为历史数据进行存储:

图 15-12 – 自愈系统架构:预防性自愈需要持续收集大量数据
我们已经使用了一些实现反应式自愈所需的工具。Docker Swarm 可以作为集群编排器,Registrator 和 Consul 用于服务发现,而 Jenkins 则用于执行包括纠正性操作在内的其他任务。唯一没有使用的工具是 Consul 的两个子集:检查和监控。预防性自愈将需要探索一些新的流程和工具,因此我们将稍后再处理:

图 15-13 – 自愈系统架构:工具组合之一
让我们看看是否能够设置一个示例反应式自愈系统。
使用 Docker、Consul 监控和 Jenkins 进行自愈
好消息是,我们已经使用了所有需要的工具来构建一个响应式自我修复系统。我们有 Swarm,它会确保容器部署到健康的节点(或者至少是正常运行的节点)。我们有 Jenkins,它可以用来执行修复过程,并且可能会发送通知。最后,我们可以使用 Consul,不仅可以存储服务信息,还可以执行健康检查并向 Jenkins 发送请求。直到现在,我们唯一还没有使用的工具是 Consul 的 watch 功能,它可以编程执行健康检查。
关于 Consul 如何进行健康检查,有一点需要注意,那就是它与传统的 Nagios 以及其他类似工具的工作方式不同。Consul 通过使用 gossip 避免了“雷鸣般的集群”问题,并且仅在状态发生变化时发出警报。
一如既往,我们将从创建我们将在本章其余部分使用的虚拟机(VM)开始。我们将创建一个cd节点和三个swarm服务器(一个主节点和两个节点)。
设置环境
以下命令将创建我们在本章中将使用的四个虚拟机。我们将创建cd节点,并使用它通过 Ansible 为其他节点进行配置。这个虚拟机还将托管 Jenkins,它将是自我修复过程中的一个重要部分。其他三个虚拟机将组成 Swarm 集群。
vagrant up cd swarm-master swarm-node-1 swarm-node-2
所有虚拟机正常运行后,我们可以继续设置 Swarm 集群。我们将以与之前相同的方式配置集群,然后讨论我们需要进行哪些更改来实现自我修复。
vagrant ssh cd
ansible-playbook /vagrant/ansible/swarm.yml \
-i /vagrant/ansible/hosts/prod
最后,时机已到,我们将使用 Jenkins 配置cd服务器。
ansible-playbook /vagrant/ansible/jenkins-node-swarm.yml \
-i /vagrant/ansible/hosts/prod
ansible-playbook /vagrant/ansible/jenkins.yml \
--extra-vars "main_job_src=service-healing-config.xml" \
-c local
我们已经达到整个集群都可以正常运行的阶段,Jenkins 服务器很快也将启动。我们设置了一个 Swarm 主节点(swarm-master),两个 Swarm 节点(swarm-node-1和swarm-node-2),以及一个搭载 Ansible 的服务器,并且即将启动 Jenkins(cd)。在 Jenkins 配置运行时,您可以继续阅读。我们暂时不会立即需要它。
设置 Consul 健康检查和监控硬件的 watch 功能
我们可以向 Consul 发送指令,定期检查服务或整个节点。它不提供预定义的检查,而是运行脚本、执行 HTTP 请求,或等待我们定义的 TTL 信号。虽然没有预定义检查看似是一个缺点,但它给了我们设计流程的自由。如果我们使用脚本来执行检查,Consul 将期望它们以特定的代码退出。如果我们以代码 0退出检查脚本,Consul 将认为一切正常。退出代码 1表示警告,而退出代码 2表示错误。
我们将从创建几个执行硬件检查的脚本开始。获取硬盘使用情况的信息,例如,使用df命令是相对简单的。
df -h
我们使用了-h参数来输出人类可读的信息,输出如下所示。
Filesystem Size Used Avail Use% Mounted on
udev 997M 12K 997M 1% /dev
tmpfs 201M 440K 200M 1% /run
/dev/sda1 40G 4.6G 34G 13% /
none 4.0K 0 4.0K 0% /sys/fs/cgroup
none 5.0M 0 5.0M 0% /run/lock
none 1001M 0 1001M 0% /run/shm
none 100M 0 100M 0% /run/user
none 465G 118G 347G 26% /vagrant
none 465G 118G 347G 26% /tmp/vagrant-cache
请注意,在你的情况下,输出可能会稍有不同。
我们真正需要的是根目录的数据(输出中的第三行)。我们可以过滤 df 命令的输出,使其只显示最后一列值为 / 的那一行。过滤后,我们应该提取已使用磁盘空间的百分比(第 5 列)。在提取数据的同时,我们也可以获取磁盘大小(第 2 列)和已使用空间量(第 3 列)。我们提取的数据应该存储为可以稍后使用的变量。我们可以使用以下命令来完成这一切:
set -- $(df -h | awk '$NF=="/"{print $2" "$3" "$5}')
total=$1
used=$2
used_percent=${3::-1}
由于表示已使用空间百分比的值包含 % 符号,我们在将值赋给 used_percent 变量之前去除了最后一个字符。
我们可以通过简单的 printf 命令来再次检查我们创建的变量是否包含正确的值:
printf "Disk Usage: %s/%s (%s%%)\n" $used $total $used_percent
最后一条命令的输出如下:
Disk Usage: 4.6G/40G (13%)
剩下的唯一任务是,当达到阈值时,以 1(警告)或 2(错误)退出。我们将错误阈值定义为 95%,警告阈值为 80%。唯一缺少的就是一个简单的 if/elif/else 语句:
if [ $used_percent -gt 95 ]; then
echo "Should exit with 2"
elif [ $used_percent -gt 80 ]; then
echo "Should exit with 1"
else
echo "Should exit with 0"
fi
出于测试目的,我们加入了 echo。我们将要创建的脚本应该返回 2、1 或 0 作为退出代码。
让我们进入 swarm-master 节点,创建脚本并进行测试:
exit
vagrant ssh swarm-master
我们将从创建一个目录开始,Consul 脚本将存放在该目录中:
sudo mkdir -p /data/consul/scripts
现在我们可以创建包含我们练习过的命令的脚本:
echo '#!/usr/bin/env bash
set -- $(df -h | awk '"'"'$NF=="/"{print $2" "$3" "$5}'"'"')
total=$1
used=$2
used_percent=${3::-1}
printf "Disk Usage: %s/%s (%s%%)\n" $used $total $used_percent
if [ $used_percent -gt 95 ]; then
exit 2
elif [ $used_percent -gt 80 ]; then
exit 1
else
exit 0
fi
' | sudo tee /data/consul/scripts/disk.sh
sudo chmod +x /data/consul/scripts/disk.sh
让我们试试看。由于有相当多的空闲磁盘空间,脚本应该会输出磁盘使用情况并返回零:
/data/consul/scripts/disk.sh
命令输出的结果类似于以下内容。
Disk Usage: 3.3G/39G (9%)
我们可以通过 $? 来轻松显示上一条命令的退出代码:
echo $?
echo 返回了零,脚本似乎正常工作。你可以通过将阈值设置为低于当前磁盘使用率来测试其余的退出代码。我将这个简单的练习留给你完成。
注意
Consul 检查阈值练习
修改 disk.sh 脚本,使得警告和错误阈值低于当前的硬盘使用率。通过运行脚本并输出退出代码来测试更改。完成练习后,将脚本恢复到原始值。
现在我们有了检查磁盘使用情况的脚本,我们应该让 Consul 知道它的存在。Consul 使用 JSON 格式来指定检查。利用我们刚刚创建的脚本的定义如下:
{
"checks": [
{
"id": "disk",
"name": "Disk utilization",
"notes": "Critical 95% util, warning 80% util",
"script": "/data/consul/scripts/disk.sh",
"interval": "10s"
}
]
}
该 JSON 文件会告诉 Consul,有一个 ID 为 disk、名称为 Disk utilization、注释为 Critical 95% util 和 warning 80% util 的检查。name 和 notes 只是用于可视化显示(正如你将很快看到的)。接下来,我们指定脚本的路径为 /data/consul/scripts/disk.sh。最后,我们告诉 Consul 每 10 秒运行一次脚本:
让我们创建 JSON 文件:
echo '{
"checks": [
{
"id": "disk",
"name": "Disk utilization",
"notes": "Critical 95% util, warning 80% util",
"script": "/data/consul/scripts/disk.sh",
"interval": "10s"
}
]
}
sudo tee /data/consul/config/consul_check.json
当我们启动 Consul(通过 Ansible 剧本)时,我们指定了配置文件位于/data/consul/config/目录下。我们仍然需要重新加载它,以便它能够识别我们刚刚创建的文件。重新加载 Consul 的最简单方法是向其发送HUP信号:
sudo killall -HUP consul
我们已经成功地在 Consul 中创建了硬盘检查。它将每十秒运行一次脚本,并根据退出代码判断它所运行的节点(此例中为swarm-master)的健康状态:

图 15-14 – Consul 中的硬盘检查
我们可以通过在浏览器中打开http://10.100.192.200:8500/ui/来查看 Consul UI。打开 UI 后,请点击节点按钮,然后选择swarm-master节点。在其他信息中,你将看到两个检查项。其中一个是Serf 健康状态,这是 Consul 基于 TTL 的内部检查。如果某个 Consul 节点宕机,这个信息会被传播到整个集群。另一个检查项叫做磁盘利用率,这是我们刚刚创建的检查项,且希望其状态为通过:

图 15-15 – Consul UI 中的硬盘检查
现在我们知道在 Consul 中添加检查是多么简单,我们应该定义当检查失败时应执行的操作。我们可以通过 Consul 的 watch 功能来实现这一点。与检查一样,Consul 并没有提供一个现成的最终解决方案,而是提供了一种机制,让我们能够创建符合我们需求的解决方案。
Consul 支持七种不同类型的 watch:
-
键:查看特定的 KV 对
-
键前缀:查看 KV 存储中的前缀
-
服务:查看可用服务列表
-
节点:查看节点列表
-
服务实例:查看服务实例
-
检查:查看健康检查的值
-
事件:查看自定义用户事件
每种类型在特定情况下都有其用处,结合起来,它们为构建自愈、容错系统提供了一个非常全面的框架。我们将重点关注检查类型,因为它将允许我们使用之前创建的硬盘检查。请参考 watch 的文档以获取更多信息。
我们将首先创建一个由 Consul Watcher 运行的脚本。manage_watches.sh脚本如下(请不要运行它):
#!/usr/bin/env bash
RED="\033[0;31m"
NC="\033[0;0m"
read -r JSON
echo "Consul watch request:"
echo "$JSON"
STATUS_ARRAY=($(echo "$JSON" | jq -r ".[].Status"))
CHECK_ID_ARRAY=($(echo "$JSON" | jq -r ".[].CheckID"))
LENGTH=${#STATUS_ARRAY[*]}
for (( i=0; i<=$(( $LENGTH -1 )); i++ ))
do
CHECK_ID=${CHECK_ID_ARRAY[$i]}
STATUS=${STATUS_ARRAY[$i]}
echo -e "${RED}Triggering Jenkins job http://10.100.198.200:8080/job/hardware-notification/build${NC}"
curl -X POST http://10.100.198.200:8080/job/hardware-notification/build \
--data-urlencode json="{\"parameter\": [{\"name\":\"checkId\", \"value\":\"$CHECK_ID\"}, {\"name\":\"status\", \"value\":\"$STATUS\"}]}"
done
我们首先定义了RED和NC变量,这些变量将帮助我们将输出的关键部分标记为红色。然后,我们读取 Consul 输入并将其存储到JSON变量中。接下来,创建了STATUS_ARRAY和CHECK_ID_ARRAY数组,这些数组将存储每个 JSON 元素的Status和CheckID值。最后,这些数组使我们能够遍历每一项,并向 Jenkins 发送 POST 请求以构建hardware-notification作业(稍后我们会查看)。该请求使用 Jenkins 友好的格式传递CHECK_ID和STATUS变量。有关更多信息,请查阅 Jenkins 远程访问 API。
让我们创建脚本:
echo '#!/usr/bin/env bash
RED="\033[0;31m"
NC="\033[0;0m"
read -r JSON
echo "Consul watch request:"
echo "$JSON"
STATUS_ARRAY=($(echo "$JSON" | jq -r ".[].Status"))
CHECK_ID_ARRAY=($(echo "$JSON" | jq -r ".[].CheckID"))
LENGTH=${#STATUS_ARRAY[*]}
for (( i=0; i<=$(( $LENGTH -1 )); i++ ))
do
CHECK_ID=${CHECK_ID_ARRAY[$i]}
STATUS=${STATUS_ARRAY[$i]}
echo -e "${RED}Triggering Jenkins job http://10.100.198.200:8080/job/hardware-notification/build${NC}"
curl -X POST http://10.100.198.200:8080/job/hardware-notification/build \
--data-urlencode json="{\"parameter\": [{\"name\":\"checkId\", \"value\":\"$CHECK_ID\"}, {\"name\":\"status\", \"value\":\"$STATUS\"}]}"
done
' | sudo tee /data/consul/scripts/manage_watches.sh
sudo chmod +x /data/consul/scripts/manage_watches.sh
现在我们已经有了每当检查状态为warning或critical时将执行的脚本,我们将通知 Consul 它的存在。Consul watch 的定义如下:
{
"watches": [
{
"type": "checks",
"state": "warning",
"handler": "/data/consul/scripts/manage_watches.sh >>/data/consul/logs/watches.log"
}, {
"type": "checks",
"state": "critical",
"handler": "/data/consul/scripts/manage_watches.sh >>/data/consul/logs/watches.log"
}
]
}
这个定义应该是自解释的。我们定义了两个 watch,它们都是checks类型的。第一个将在出现warning时运行,第二个则在检查处于critical状态时运行。为了简化操作,在这两个实例中,我们指定了相同的处理程序manage_watches.sh。在实际环境中,您应该区分这两种状态并执行不同的操作。
让我们创建 watch 文件:
echo '{
"watches": [
{
"type": "checks",
"state": "warning",
"handler": "/data/consul/scripts/manage_watches.sh >>/data/consul/logs/watches.log"
}, {
"type": "checks",
"state": "critical",
"handler": "/data/consul/scripts/manage_watches.sh >>/data/consul/logs/watches.log"
}
]
}'
sudo tee /data/consul/config/watches.json
在我们继续并重新加载 Consul 之前,应该简要讨论一下 Jenkins 作业hardware-notification。当我们为 Jenkins 配置时,它已经创建了该作业。可以通过打开http://10.100.198.200:8080/job/hardware-notification/configure来查看其配置。它包含两个参数,checkId和status。我们使用这两个参数作为避免为每个硬件检查创建单独作业的方式。每当 Consul 观察器发送 POST 请求以构建该作业时,它将这些值传递给这两个变量。在构建阶段,我们仅运行一个echo命令,将这两个变量的值输出到标准输出(STDOUT)。在实际情况中,这个作业会执行某些操作。例如,如果磁盘空间不足,它可能会删除未使用的日志和临时文件。另一个例子是,如果我们使用 Amazon AWS 等云服务,它可能会创建额外的节点。在其他一些情况下,可能无法进行自动化反应。无论如何,除了像这样的具体操作外,这个作业还应该发送某种形式的通知(如电子邮件、即时消息等),以便操作员得知潜在问题。由于这些情况在本地难以重现,该作业的初始定义并未执行此类操作。如何扩展这个作业以满足您的需求,就交给您了。
注意
hardware-notification Jenkins 作业练习
修改 hardware-notification Jenkins 作业,使得当 checkId 值为 disk 时,删除日志。在服务器上创建模拟日志(可以使用 touch 命令创建文件),并手动运行作业。一旦作业构建完成,确认日志确实被删除。

图 15-16 – Jenkins 作业硬件通知的设置屏幕
我们现在面临的问题是,swarm-master 节点上的硬盘几乎是空的,这阻止了我们测试刚刚设置的系统。我们必须更改 disk.sh 中定义的阈值。让我们将 80% 的警告阈值修改为 2%。当前硬盘使用量肯定超过了这个值:
sudo sed -i "s/80/2/" /data/consul/scripts/disk.sh
最后,让我们重新加载 Consul,看看会发生什么:
sudo killall -HUP consul
我们应该检查的第一件事是监视日志:
cat /data/consul/logs/watches.log
输出的相关部分如下:
Consul watch request:
[{"Node":"swarm-master","CheckID":"disk","Name":"Disk utilization","Status":"warning","Notes":"Critical 95% util, warning 80% util","Output":"Disk Usage: 3.3G/39G (9%)\n","ServiceID":"","ServiceName":""}]
Triggering Jenkins job http://10.100.198.200:8080/job/hardware-notification/build
请注意,Consul 的检查可能需要几秒钟才能运行。如果你没有从日志中收到类似的输出,请重新运行 cat 命令。
我们可以看到 Consul 发送到脚本的 JSON,并且 Jenkins 作业 hardware-notification 的构建请求已经发送。我们还可以通过在浏览器中打开 http://10.100.198.200:8080/job/hardware-notification/lastBuild/console URL 来查看该作业的 Jenkins 控制台输出:

图 15-17 – Jenkins 作业硬件通知的控制台输出
由于此时我们只使用了一个 Consul 检查来监控硬盘利用率,因此我们应该再实现一个。合适的候选项是内存。即使在某些硬件检查失败时我们不做任何纠正操作,将这些信息保存在 Consul 中本身就已经非常有用。
现在我们理解了这个过程,我们可以做得更好,使用 Ansible 来设置一切。此外,不同的检查不仅应在 swarm-master 节点设置,还应在集群的其余节点中设置,我们不希望手动执行这项工作,除非是为了学习目的。
在继续之前,让我们退出 swarm-master 节点:
exit
自动设置 Consul 健康检查和监控硬件的 Watch
此时,我们只在 swarm-master 节点配置了一个硬件监视器。现在我们已经熟悉了 Consul 的监视工作原理,可以使用 Ansible 将硬件监控部署到 Swarm 集群的所有节点。
我们将首先运行 Ansible 剧本,然后探索设置检查时使用的角色:
vagrant ssh cd
ansible-playbook /vagrant/ansible/swarm-healing.yml \
-i /vagrant/ansible/hosts/prod
swarm-healing.yml 剧本如下:
- hosts: swarm
remote_user: vagrant
serial: 1
sudo: yes
vars:
- debian_version: vivid
- docker_cfg_dest: /lib/systemd/system/docker.service
- is_systemd: true
roles:
- common
- docker
- consul-healing
- swarm
- registrator
与swarm.yml剧本相比,唯一的不同之处是使用了consul-healing角色。这两个角色(consul和consul-healing)非常相似。主要的区别在于后者将更多文件复制到目标服务器(roles/consul-healing/files/consul_check.json、roles/consul-healing/files/disk.sh和roles/consul-healing/files/mem.sh)。我们已经手动创建了所有这些文件,除了mem.sh,它用于检查内存,逻辑与disk.sh脚本类似。roles/consul-healing/templates/manage_watches.sh和roles/consul-healing/templates/watches.json文件被定义为模板,这样就可以通过 Ansible 变量自定义一些内容。总的来说,我们主要是通过 Ansible 复制手动步骤,以便整个集群的配置和部署可以自动完成。
请打开http://10.100.192.200:8500/ui/#/dc1/nodes网址,然后点击任何一个节点。你会注意到,每个节点都有磁盘使用情况和内存使用情况监视器,在出现故障时,它们会启动 Jenkins 任务hardware-notification/的构建。
在监控硬件资源并在达到阈值时执行预定义的操作是有趣且有用的,但通常会面临采取纠正措施的限制。例如,如果整个节点宕机,通常我们能做的唯一事情就是发送通知给某人,之后该人会手动调查问题。真正的好处是通过监控服务来实现的。
设置 Consul 健康检查和监视以监控服务
在我们深入了解服务检查和监视之前,让我们先启动books-ms容器的部署。这样,我们可以更有效地利用时间,一边讨论这个话题,一边让 Jenkins 为服务启动而努力工作。
我们将从索引 Jenkins 任务books-ms中定义的分支开始。请在浏览器中打开它,点击左侧菜单中的Branch Indexing链接,然后点击Run Now。索引完成后,Jenkins 会检测到swarm分支符合过滤条件,创建子项目并执行第一次构建。构建完成后,我们将把books-ms服务部署到集群中,并可以尝试更多自愈技术。你可以从控制台屏幕监控构建进度。
自愈的第一步是识别出出现了问题。在系统层面上,我们可以观察正在部署的服务,如果其中某个服务没有响应,可以采取纠正措施。我们可以继续像之前使用内存和磁盘验证一样,使用 Consul 检查。主要的区别是,这次我们将使用http检查,而不是script检查。Consul 会定期向我们的服务发送请求,并将失败信息发送到我们已设置的监视器。
在继续之前,我们应该讨论一下应该检查什么。我们是否应该检查每个服务容器?是否应该检查辅助容器如数据库?我们是否需要关注容器本身?这些检查项可能在特定场景下有用。在我们的情况下,我们将采取更通用的方法,监控整个服务。如果我们不单独监控每个容器,我们是否会失去控制?这个问题的答案取决于我们试图达成的目标。我们关心的是什么?我们关心所有容器是否都在运行,还是我们更关心服务是否按预期工作并且性能良好?如果必须选择,我认为后者更重要。如果我们的服务扩展到五个实例,即使其中两个停止工作,它仍然表现良好,那么可能没有必要采取任何措施。只有当整个服务停止工作,或者当它的表现不符合预期时,才应该采取纠正措施。
与硬件检查受益于统一性并应集中管理不同,系统检查可能会因服务不同而有所不同。为了避免维护服务的团队和负责整体 CD 流程的团队之间的依赖关系,我们将把检查定义保存在服务代码库中。这样,服务团队就可以完全自由地定义他们认为适合其开发服务的检查项。由于部分检查项是变量,我们将通过 Consul 模板格式来定义它们。同时,我们会使用命名约定,并始终使用相同的文件名。consul_check.ctmpl描述了books-ms服务的检查项,其内容如下:
{
"service": {
"name": "books-ms",
"tags": ["service"],
"port": 80,
"address": "{{key "proxy/ip"}}",
"checks": [{
"id": "api",
"name": "HTTP on port 80",
"http": "http://{{key "proxy/ip"}}/api/v1/books",
"interval": "10s",
"timeout": "1s"
}]
}
}
我们不仅定义了检查项,还定义了名为books-ms的服务,标签service,运行的端口以及地址。请注意,由于这是整个服务的定义,因此端口是80。在我们的案例中,无论我们部署多少个容器,或它们运行在哪些端口上,服务都可以通过代理访问。地址是通过 Consul 获取的,使用proxy/ip键。这个服务的行为应该是相同的,无论当前部署的是哪种颜色版本。
一旦服务定义完成,我们就进行检查(在此案例中只有一个检查)。每个检查都有一个 ID 和一个名称,仅供信息参考。关键条目是http,它定义了 Consul 用来检测该服务的地址。最后,我们指定了检测每十秒执行一次,超时时间为一秒。我们如何使用这个模板呢?为了回答这个问题,我们应该查看位于books-ms代码库的master分支中的 Jenkinsfile:
node("cd") {
def serviceName = "books-ms"
def prodIp = "10.100.192.200"
def proxyIp = "10.100.192.200"
def swarmNode = "swarm-master"
def proxyNode = "swarm-master"
def registryIpPort = "10.100.198.200:5000"
def swarmPlaybook = "swarm-healing.yml"
def proxyPlaybook = "swarm-proxy.yml"
def instances = 1
def flow = load "/data/scripts/workflow-util.groovy"
git url: "https://github.com/vfarcic/${serviceName}.git"
flow.provision(swarmPlaybook)
flow.provision(proxyPlaybook)
flow.buildTests(serviceName, registryIpPort)
flow.runTests(serviceName, "tests", "")
flow.buildService(serviceName, registryIpPort)
def currentColor = flow.getCurrentColor(serviceName, prodIp)
def nextColor = flow.getNextColor(currentColor)
flow.deploySwarm(serviceName, prodIp, nextColor, instances)
flow.runBGPreIntegrationTests(serviceName, prodIp, nextColor)
flow.updateBGProxy(serviceName, proxyNode, nextColor)
flow.runBGPostIntegrationTests(serviceName, prodIp, proxyIp, proxyNode, currentColor, nextColor)
flow.updateChecks(serviceName, swarmNode)
}
与前几章使用的 Jenkinsfile 相比,唯一显著的区别是最后一行,它调用了roles/jenkins/files/scripts/workflow-util.groovy实用脚本中的updateChecks函数。该函数如下:
def updateChecks(serviceName, swarmNode) {
stage "Update checks"
stash includes: 'consul_check.ctmpl', name: 'consul-check'
node(swarmNode) {
unstash 'consul-check'
sh "sudo consul-template -consul localhost:8500 \
-template 'consul_check.ctmpl:/data/consul/config/${serviceName}.json:killall -HUP consul' \
-once"
}
}
简而言之,这个函数将文件consul_check.ctmpl复制到swarm-master节点,并运行 Consul 模板。结果是创建另一个 Consul 配置文件,用于执行服务检查:
在定义了检查后,我们应该仔细查看roles/consul-healing/templates/manage_watches.sh脚本。相关部分如下:
if [[ "$CHECK_ID" == "mem" || "$CHECK_ID" == "disk" ]]; then
echo -e "${RED}Triggering Jenkins job http://{{ jenkins_ip }}:8080/job/hardware-notification/build${NC}"
curl -X POST http://{{ jenkins_ip }}:8080/job/hardware-notification/build \
--data-urlencode json="{\"parameter\": [{\"name\":\"checkId\", \"value\":\"$CHECK_ID\"}, {\"name\":\"status\", \"value\":\"$STATUS\"}]}"
else
echo -e "${RED}Triggering Jenkins job http://{{ jenkins_ip }}:8080/job/service-redeploy/buildWithParameters?serviceName=${SERVICE_ID}${NC}"
curl -X POST http://{{ jenkins_ip }}:8080/job/service-redeploy/buildWithParameters?serviceName=${SERVICE_ID}
fi
由于我们旨在执行两种类型的检查(硬件和服务),我们不得不引入if/else语句。当发现硬件故障时(mem或disk),会向 Jenkins 作业hardware-notification发送构建请求。这部分与我们之前创建的定义相同。另一方面,我们假设任何其他类型的检查都与服务相关,且会向service-redeploy作业发送请求。在我们的案例中,当books-ms服务失败时,Consul 将发送请求,触发service-redeploy作业,并将books-ms作为serviceName参数传递。我们在 Jenkins 中创建这个作业的方式与创建其他作业相同。主要的区别是使用了roles/jenkins/templates/service-redeploy.groovy脚本。其内容如下:
node("cd") {
def prodIp = "10.100.192.200"
def swarmIp = "10.100.192.200"
def proxyNode = "swarm-master"
def swarmPlaybook = "swarm-healing.yml"
def proxyPlaybook = "swarm-proxy.yml"
def flow = load "/data/scripts/workflow-util.groovy"
def currentColor = flow.getCurrentColor(serviceName, prodIp)
def instances = flow.getInstances(serviceName, swarmIp)
deleteDir()
git url: "https://github.com/vfarcic/${serviceName}.git"
try {
flow.provision(swarmPlaybook)
flow.provision(proxyPlaybook)
} catch (e) {}
flow.deploySwarm(serviceName, prodIp, currentColor, instances)
flow.updateBGProxy(serviceName, proxyNode, currentColor)
}
你可能注意到,这个脚本比我们之前使用的Jenkinsfile短得多。我们完全可以使用相同的脚本重新部署,就像我们用于部署的那个脚本,最终的结果将是(几乎)一样的。然而,目标是不同的。一个关键要求是速度。如果我们的服务失败了,我们希望能够尽快重新部署,并且尽可能考虑到多种不同的场景。一个重要的区别是,在重新部署过程中我们不会运行测试。所有测试在部署过程中已经通过,否则服务根本不会运行,也就没有失败的情况。此外,同一组测试针对同一个版本运行时总会产生相同的结果,否则说明我们的测试是不稳定且不可靠的,表明测试过程中存在严重的错误。你还会注意到,构建和推送到注册表的步骤缺失了。我们不想构建和部署一个新版本,这正是部署的任务所在。我们希望尽快将最新版本恢复到生产环境中。我们的需求是恢复系统到服务失败之前的状态。现在我们已经覆盖了重新部署脚本中故意省略的内容,让我们来逐步解析它。
第一个变化是在获取应该运行的实例数量的方式上。直到现在,位于服务仓库中的 Jenkinsfile 一直在决定部署多少实例。我们在 Jenkinsfile 中有一条语句 def instances = 1。然而,由于这个重新部署作业应该适用于所有服务,我们不得不创建一个名为 getInstances 的新函数,它将从 Consul 中检索存储的数字。这表示“期望的”实例数量,并与 Jenkinsfile 中指定的值相对应。如果没有这个功能,我们可能会部署一个固定数量的容器,并可能破坏别人预设的意图。也许开发人员决定运行两个实例,或者在发现负载过大后将其扩展到五个实例。因此,我们必须确定要部署多少个实例,并将这一信息加以利用。getInstances 函数定义在 roles/jenkins/files/scripts/workflow-util.groovy 脚本中,具体内容如下:
def getInstances(serviceName, swarmIp) {
return sendHttpRequest("http://${swarmIp}:8500/v1/kv/${serviceName}/instances?raw")
}
该函数向 Consul 发送一个简单的请求,并返回指定服务的实例数量。
接下来,我们在从 GitHub 克隆代码之前,会删除工作区目录中的作业。这一步骤是必要的,因为 Git 仓库在不同的服务之间是不同的,不能将一个 Git 仓库克隆到另一个仓库之上。我们并不需要所有的代码,只需要一些配置文件,特别是 Docker Compose 和 Consul 的配置文件。不过,如果我们克隆整个仓库会更方便。如果仓库很大,你可以考虑只获取需要的文件。
deleteDir()
git url: "https://github.com/vfarcic/${serviceName}.git"
现在,所有我们需要的文件(以及许多我们不需要的文件)都已在工作区中,我们可以开始重新部署。在继续之前,让我们讨论一下最初导致失败的原因。我们可以识别出三个主要的原因。一个节点停止工作,一个基础设施服务(如 Swarm、Consul 等)出现故障,或者是我们自己的服务出现了问题。我们将跳过第一个可能性,留到后面再考虑。如果是某个基础设施服务停止工作,我们可以通过运行 Ansible playbooks 来修复。而另一方面,如果集群运作正常,我们只需要重新部署包含我们服务的容器。
让我们来探讨一下使用 Ansible 进行资源配置。执行 Ansible playbooks 的脚本部分如下:
try {
flow.provision(swarmPlaybook)
flow.provision(proxyPlaybook)
} catch (e) {}
与之前的 Jenkins 工作流脚本相比,主要的区别在于这一次,配置过程被放在了 try/catch 块中。原因是可能会出现节点故障。如果此次重新部署的罪魁祸首是某个故障节点,配置过程将会失败。如果脚本的其他部分继续执行,这本身不是问题。因此,我们将这部分脚本放在 try/catch 块下,以确保无论配置结果如何,脚本都会继续执行。毕竟,如果某个节点出现故障,Swarm 会将服务重新部署到其他地方(稍后会更详细地解释)。我们继续下一个用例:
flow.deploySwarm(serviceName, prodIp, currentColor, instances)
flow.updateBGProxy(serviceName, proxyNode, currentColor)
这两行与 Jenkinsfile 中的部署脚本相同。唯一的、微妙的区别是实例的数量没有硬编码,而是如我们之前所见,动态发现的。
就这些了。通过我们探讨的脚本,我们已经涵盖了三种场景中的两种。如果我们的基础设施或服务之一出现故障,系统会恢复。让我们试试看。
我们将停止一个基础设施服务,看看系统是否会恢复到原始状态。nginx 可能是最合适的候选者。它是我们服务基础设施的一部分,缺少它,任何服务都无法正常工作。
没有 nginx,我们的服务无法通过 80 端口访问。Consul 并不会知道 nginx 失败了。相反,Consul 检查器会检测到 books-ms 服务不可用,并启动 Jenkins 作业 service-redeploy 的新构建。结果,配置和重新部署过程将会执行。Ansible 配置的一部分负责确保包括 nginx 在内的服务正在运行:
让我们进入 swarm-master 节点并停止 nginx 容器。
exit
vagrant ssh swarm-master
docker stop nginx
exit
vagrant ssh cd
当 nginx 进程停止时,books-ms 服务无法访问(至少无法通过 80 端口访问)。我们可以通过向它发送 HTTP 请求来确认这一点。请记住,Consul 会通过 Jenkins 发起重新部署,因此在服务恢复之前请尽快测试:
curl swarm-master/api/v1/books
正如预期的那样,curl 返回了 Connection refused 错误:
curl: (7) Failed to connect to swarm-master port 80: Connection refused
我们还可以查看 Consul 用户界面。books-ms 服务的检查项应该处于临界状态。你可以点击 swarm-master 链接,查看该节点上所有服务的详细信息以及它们的状态。顺便提一下,books-ms 服务被注册为运行在 swarm-master 服务器上,因为该服务器上运行着代理服务。还有 books-ms-blue 或 books-ms-green 服务,它们包含特定于已部署容器的数据:

图 15-18 – Consul 状态界面,出现一个处于临界状态的检查项
最后,我们可以查看一下服务重新部署的控制台界面。重新部署过程应该已经开始,或者更可能的是,现在已经完成了。
一旦 service-redeploy 作业的构建完成,一切应恢复到原始状态,我们就可以使用我们的服务:
curl -I swarm-master/api/v1/books
响应的输出如下:
HTTP/1.1 200 OK
Server: nginx/1.9.9
Date: Tue, 19 Jan 2016 21:53:00 GMT
Content-Type: application/json; charset=UTF-8
Content-Length: 2
Connection: keep-alive
Access-Control-Allow-Origin: *
代理服务确实已经重新部署,且一切按预期正常工作。
如果我们不是停止其中一个基础设施服务,而是完全移除 book-ms 实例,会发生什么呢?让我们移除服务容器,看看会发生什么:
export DOCKER_HOST=tcp://swarm-master:2375
docker rm -f $(docker ps --filter name=booksms --format "{{.ID}}")
请继续打开 service-redeploy Jenkins 控制台屏幕。可能需要几秒钟,直到 Consul 启动新的构建。一旦开始,我们所需要做的就是再等一会儿,直到构建完成。一旦看到 Finished: Success 消息,我们可以再次检查服务是否真的可用:

图 15-19 – service-redeploy 构建的输出
docker ps --filter name=books --format "table {{.Names}}"
curl -I swarm-master/api/v1/books
两个命令的合并输出如下:
NAMES
swarm-node-2/booksms_app-blue_1
swarm-node-1/books-ms-db
...
HTTP/1.1 200 OK
Server: nginx/1.9.9
Date: Tue, 19 Jan 2016 22:05:50 GMT
Content-Type: application/json; charset=UTF-8
Content-Length: 2
Connection: keep-alive
Access-Control-Allow-Origin: *
我们的服务确实在运行,并且通过代理可以访问。系统自我修复了。我们几乎可以停止任何一个 Swarm 节点上的进程,经过几秒钟的延迟,系统会恢复到之前的状态。唯一没尝试过的操作是停止整个节点。这种操作需要对我们的脚本进行一些额外的修改。我们稍后会探讨这些修改。请注意,这只是一个演示设置,并不意味着系统现在已经可以投入生产环境。另一方面,它也并不远了。通过一些调整,你可以考虑将它应用到你的系统中。你可能需要添加一些通知(如电子邮件、Slack 等),并根据你的需求调整这个过程。重要的部分是流程。一旦我们明白了我们想要什么,以及如何达到这个目标,其余的通常只是时间问题:
我们当前的流程如下:
-
Consul 执行周期性的 HTTP 请求,运行自定义脚本或等待服务的 生存时间 (TTL) 消息。
-
如果 Consul 的请求未返回状态码
200,脚本返回非零退出码,或未接收到 TTL 消息,Consul 会向 Jenkins 发送请求。 -
在收到 Consul 的请求后,Jenkins 启动重新部署过程,发送通知消息等:

图 15-20 – 检查和修复 Consul 持续监控服务
我们探讨了一些反应式修复的例子。尽管这些例子并不完全全面,但希望它们为你提供了一个可以深入探索并根据自己的需求调整的路径。现在,我们将注意力转向我们可以采取的预防措施。我们将研究计划的扩展和缩减。这是预防性修复的一个很好的候选方法,因为它可能是最容易实现的。
通过计划的扩展和缩减进行预防性修复
预防性修复本身就是一个庞大的话题,并且在除最简单的场景外,通常需要使用历史数据来分析系统并预测未来。由于此刻我们既没有数据,也没有生成数据的工具,我们将从一个非常简单的例子开始,这个例子不需要任何这些工具。
我们将探索的场景如下:我们正在开发一个在线书店。市场部门决定,从新年前夜开始,所有读者都能以折扣价购买书籍。该活动将持续一天,我们预计它会引起极大的关注。从技术角度看,这意味着在 24 小时内,从 1 月 1 日午夜开始,我们的系统将承受巨大的负载。我们该怎么办呢?我们已经有了能够扩展系统(或将最受影响部分扩展)的流程和工具。我们需要做的是在活动开始之前,提前扩展选定的服务,并且一旦活动结束,将其恢复到原始状态。问题是,没有人愿意在新年前夜待在办公室。我们可以通过 Jenkins 轻松解决这个问题。我们可以创建一个计划任务,首先进行服务的扩展,随后再进行缩减。问题解决了,但另一个问题随之而来。我们应该扩展到多少个实例呢?我们可以预先定义一个数字,但这样做风险较大,可能会出错。例如,我们可能决定将实例扩展到三个(目前我们只有一个)。然而,在今天和活动开始之间,可能由于某些其他原因,实例的数量会增加到五个。在这种情况下,我们不仅没有增加系统的容量,反而会适得其反。我们的计划任务会将服务从五个实例缩减到三个实例。解决方案可能是使用相对值。我们应该设置系统扩展到三个实例,而是设置为实例数量增加两个。如果当前只有一个实例运行,那么该过程会启动两个新的实例,将总体数量增加到三个。另一方面,如果有人已经将服务扩展到五个实例,最终结果将是集群内运行七个容器。活动结束后,类似的逻辑也可以应用。我们可以创建第二个计划任务,将正在运行的实例数量减少两个。从三个减少到一个,从五个减少到三个。无论此时有多少实例在运行,我们都会将该数字减少两个。
这个预防性修复过程类似于疫苗接种的使用。疫苗的主要作用不是治疗现有的感染,而是培养免疫力,防止其传播。以同样的方式,我们将安排扩容(后续会有缩容),以防止增加的负载以意外的方式影响我们的系统。与其修复感染的系统,我们选择预防系统陷入困境。
让我们看看这个过程的实际操作。
注意
请打开 Jenkins 的books-ms-scale配置界面。该作业配置非常简单,只有一个名为scale的参数,默认值为2。在启动构建时可以调整该参数。Build Triggers设置为build periodically,值为45 23 31 12。如果你已经使用过 cron 调度,这应该看起来很熟悉。格式是MINUTE HOUR DOM MONTH DOW。第一个数字代表分钟,第二个是小时,第三个是日期,接着是月份和星期几。星号表示可以为任何值。所以,我们使用的值是 12 月 31 日的 23 点 45 分。换句话说,就是在跨年夜前的 15 分钟。这给我们足够的时间在活动开始前增加实例数量。有关调度格式的更多信息,请点击位于Schedule*字段右侧的问号图标。
作业配置的第三部分是以下的工作流脚本:
node("cd") {
def serviceName = "books-ms"
def swarmIp = "10.100.192.200"
def flow = load "/data/scripts/workflow-util.groovy"
def instances = flow.getInstances(serviceName, swarmIp).toInteger() + scale.toInteger()
flow.putInstances(serviceName, swarmIp, instances)
build job: "service-redeploy", parameters: [[$class: "StringParameterValue", name: "serviceName", value: serviceName]]
}
由于没有真正的理由去重复代码,我们使用了来自roles/jenkins/files/scripts/workflow-util.groovy脚本的辅助函数。
我们首先定义要运行的实例数量。通过将scale参数的值(默认为 2)添加到我们的服务当前使用的实例数量来实现这一点。我们通过调用在本书中已经使用过的getInstances函数来获取当前的实例数量。这个新的值通过putInstances函数传送到 Consul 中。最后,我们运行service-redeploy作业的构建,执行我们需要的重新部署操作。总结来说,由于service-redeploy作业从 Consul 读取实例数量,在调用service-redeploy构建之前,我们在脚本中做的唯一事情就是改变 Consul 中的scale值。从那时起,service-redeploy作业将会做需要的工作来扩容容器。通过调用service-redeploy作业,我们避免了复制已经在其他地方使用的代码:

图 15-21 – 配置 books-ms-scale 作业,表示计划的扩容
现在我们有两条路径可以选择。一种是等待直到新年前夜并确认任务是否正常工作。我将冒昧假设你没有那么多耐心,并选择另一种方式。我们将手动运行任务。在此之前,让我们快速查看一下 Swarm 集群中的当前情况:
export DOCKER_HOST=tcp://swarm-master:2375
docker ps --filter name=books --format "table {{.Names}}"
curl swarm-master:8500/v1/kv/books-ms/instances?raw
命令的组合输出如下:
NAMES
swarm-node-1/booksms_app-blue_1
swarm-node-2/books-ms-db
...
1
我们可以看到,只有一个books-ms服务实例正在运行(booksms_app-blue_1),并且 Consul 将1作为books-ms/instances键存储。
现在让我们运行books-ms-scale Jenkins 任务。如果一切按预期工作,它应该会将books-ms实例的数量增加两个,总数为三个。请打开books-ms-scale构建屏幕并点击构建按钮。你可以通过打开books-ms-scale控制台屏幕来监控进度。你会看到,在将新的实例数量存储到 Consul 之后,它会调用service-redeploy任务的构建。几秒钟后,构建将完成,我们可以验证结果:
docker ps --filter name=books --format "table {{.Names}}"
curl swarm-master:8500/v1/kv/books-ms/instances?raw
命令的组合输出如下:
NAMES
swarm-node-2/booksms_app-blue_3
swarm-node-1/booksms_app-blue_2
swarm-node-1/booksms_app-blue_1
swarm-node-2/books-ms-db
...
3
如我们所见,这一次,服务的三个实例正在运行。我们可以通过访问key/value books-ms/instances screen在 Consul UI 中观察到相同的结果:

图 15-22 – Consul UI 键值 books-ms/instances 屏幕
我们的系统现在已经准备好在这 24 小时内承受增加的负载。如你所见,我们非常慷慨地安排它在到期前 15 分钟运行。构建的执行时间仅仅只有几秒钟。我们甚至可以通过跳过service-redeploy任务的配置部分来加速这一过程。我把这个留给你作为练习。
注意
为 service-redeploy 任务添加条件
修改service-redeploy Jenkins 任务,使得配置变得可选。你需要添加一个接受布尔值的新参数,并在工作流脚本中添加一个 if/else 语句。确保该参数的默认值设置为 true,以便除非另有说明,否则始终执行配置。一旦完成,切换到books-ms-scale任务的配置,并修改它,以便调用service-redeploy任务时传递跳过配置的信号。
24 小时过去后,活动结束,会发生什么?Jenkins 任务books-ms-descale将被执行。它与books-ms-scale任务相同,但有两个显著的不同点。scale参数被设置为-2,并计划在 1 月 2 日午夜后十五分钟运行(15 0 2 1 *)。我们给系统留出了十五分钟的冷却时间。工作流脚本保持不变。
让我们通过打开books-ms-descale构建页面并点击构建按钮来运行它。它将把实例数量减少两个,并运行service-redeploy作业的构建。完成后,我们可以再看看我们的集群:
docker ps --filter name=books --format "table {{.Names}}"
curl swarm-master:8500/v1/kv/books-ms/instances?raw
以下是命令的合并输出:
NAMES
swarm-node-1/booksms_app-blue_1
swarm-node-2/books-ms-db
...
1
我们回到了最初的地方。活动结束了,服务的实例数量从三个减少到一个。Consul 中的值也被恢复。系统成功地承受了大量试图利用我们新年折扣的访客,业务方对我们能够满足他们的需求感到满意,生活继续像以前一样。
我们本可以创建不同的公式来实现我们的目标。它可以像简单地乘以现有实例的数量那样简单。这将为我们提供一个更现实的场景。我们可以将它们乘以二,而不是增加两个新的容器。如果之前运行了三个,之后将运行六个。正如你所想,这些公式往往可以更复杂。更重要的是,它们需要更多的考虑。如果我们不是运行一个服务,而是运行五十个不同的服务,我们就不会对它们都应用同样的公式。有些服务需要大规模扩展,有些不需要,而其他的根本不需要扩展。最好的做法是使用某种压力测试,告诉我们系统中哪些部分需要扩展,以及扩展的程度。可以运行这些测试的工具有很多,JMeter 和 Gatling(我最喜欢的)只是其中一部分。
我在本章开始时提到过,预防性修复是基于历史数据的。这是一个非常简单、但却非常高效的示范方式。在这种情况下,历史数据就在我们的脑海中。我们知道营销活动会增加服务的负载,并采取措施避免潜在问题。真正的、更加复杂的预防性修复方式需要的不仅仅是我们的记忆。它需要一个能够存储和分析数据的系统。我们将在下一章讨论这种系统的需求。
使用 Docker 重启策略进行反应性修复
对 Docker 更熟悉的人可能会问,为什么我没有提到 Docker 重启策略。乍一看,它们似乎是恢复失败容器的一个非常有效的方法。它们确实是定义何时重启容器的最简单方式。我们可以在docker run上使用--restart标志(或等效的 Docker Compose 定义),容器将在退出时重启。以下表格总结了当前支持的重启策略:
| 策略 | 结果 |
|---|---|
no |
容器退出时不要自动重启。这是默认设置。 |
on-failure[:max-retries] |
仅当容器以非零退出状态退出时才重启。可选地,限制 Docker 守护进程尝试的重启重试次数。 |
always |
无论退出状态如何,始终重启容器。当指定为 always 时,Docker 守护进程将尝试无限期重启容器。容器也将在守护进程启动时始终启动,无论容器当前的状态如何。 |
unless-stopped |
无论退出状态如何,始终重启容器,但如果容器之前已被置为停止状态,则不会在守护进程启动时启动它。 |
重启策略的使用示例如下(请不要运行它)。
docker run --restart=on-failure:3 mongo
在这种情况下,mongo 将最多重启三次。重启仅会在 mongo 容器内运行的进程以非零状态退出时发生。如果我们停止该容器,则不会应用重启策略。
重启策略的问题在于有太多未被考虑到的特殊情况。容器内运行的进程可能会因与容器失败无关的问题而失败。例如,容器内的一个服务可能正在通过代理连接到数据库。如果连接无法建立,它可能被设计为停止。如果由于某种原因,代理节点无法工作,不管我们重启容器多少次,结果都会是一样的。虽然重启是可以尝试的,但迟早会有人需要被通知问题的存在。也许需要运行配置脚本来恢复系统到期望的状态,也许需要向集群添加更多节点,甚至可能整个数据中心都无法正常运行。不论原因如何,有比重启策略所允许的更多的解决路径。因此,我们确实需要一个更强大的系统来应对所有这些情况,而我们已经在创造它的路上。我们已建立的流程比简单的重启策略更强大,且已经涵盖了与 Docker 重启策略相同的问题。实际上,现在我们已经覆盖了更多的路径。我们使用 Docker Swarm 进行容器编排,确保我们的服务部署到集群中最合适的节点上。我们使用 Ansible,每次部署时持续为集群进行配置,从而确保整个基础设施处于正确的状态。我们结合使用 Consul、Registrator 和 Consul Template 进行服务发现,确保所有服务的注册表始终保持最新。最后,Consul 健康检查持续监控集群状态,如果发生故障,会向 Jenkins 发送请求,启动相应的纠正措施。
我们正在利用 Docker 的口号 电池包含但可拆卸,通过扩展系统以适应我们的需求,从中受益。
将本地部署与云节点结合
我不会开始讨论使用本地服务器还是云托管的问题。两者都有各自的优缺点。选择使用哪种方式取决于个别需求。此外,这种讨论更适合放在集群和扩展章节中。然而,云托管有一个明显的使用案例,至少能很好地满足本章某些场景的需求。
当我们需要临时增加集群容量时,云托管展现出优势。一个很好的例子是我们虚构的跨年活动场景。我们需要在一天内提升容量。如果您已经将所有服务器托管在云端,那么这个场景将需要创建几个新节点,并在负载减小到原来大小后销毁这些节点。另一方面,如果您使用的是本地托管,那将是仅为这些额外节点而选择云托管的机会。购买一整套仅在短期内使用的新服务器是非常昂贵的,尤其是考虑到成本不仅仅包括硬件价格,还包括维护费用。如果在这种情况下我们使用云节点,账单只会按实际使用的时间支付(前提是之后将其销毁)。由于我们拥有所有的自动配置和部署脚本,设置这些节点几乎不费吹灰之力。
注意
就个人而言,我更喜欢本地部署与云托管相结合的方式。我的本地服务器满足最小容量的需求,而云托管节点则在需要临时增加容量时创建(并销毁)。请注意,这种组合仅是我的个人偏好,可能不适用于您的使用场景。
重要的是,您从本书中学到的所有内容都同样适用于两种情况(本地部署或云端)。唯一显著的区别是,您不应该在生产服务器上使用 Vagrant。我们仅仅使用它来快速在您的笔记本电脑上创建虚拟机。如果您在寻找一种类似 Vagrant 的方式来创建生产虚拟机,我建议您探索另一个 HashiCorp 产品,叫做 Packer。
自我修复总结(截至目前)
到目前为止,我们构建的系统在某些情况下接近 Kubernetes 和 Mesos 开箱即用的功能,而在其他情况下则超出了它们的功能。我们正在构建的系统的真正优势在于能够根据你的需求进行微调。并不是说 Kubernetes 和 Mesos 不应该使用。你至少应该对它们有所了解。不要轻信任何人的话(甚至包括我)。试用它们并得出你自己的结论。使用场景和项目一样多,每个都不同。在某些情况下,我们构建的系统会为你提供一个很好的基础,而在其他情况下,例如,Kubernetes 或 Mesos 可能更合适。我无法在一本书中详细列出所有可能的组合,那样会让书的内容变得不可管理。
相反,我选择探索如何构建高度可扩展的系统。到目前为止,我们使用的几乎任何组件都可以扩展,或者用其他组件替代。我认为这种方法能为你提供更多的可能性来根据自己的需求调整示例,同时不仅了解某些东西是如何工作的,还能理解我们为什么选择它。
我们已经远离了这本书的最初简单起点,且还没有结束。自我修复系统的探索将继续。然而,首先我们需要将注意力转向在集群内部收集数据的不同方式。
随着自我修复主题的第一部分接近尾声,让我们销毁我们的虚拟机,重新开始新的一章。
你知道接下来会发生什么。我们将摧毁我们所做的一切,开始下一章的内容:
exit
vagrant halt
第十六章:集中式日志记录与监控
| 我的生活充满了混乱,这已经变成了一种常态。你会习惯它。你只需要放松,冷静下来,深呼吸,试着找到让事情运作的办法,而不是抱怨它们的错误。 | ||
|---|---|---|
| --汤姆·韦林 |
我们对 DevOps 实践和工具的探索引导我们走向了集群和扩展。因此,我们开发了一个系统,使我们能够以简单而高效的方式将服务部署到集群中。结果是,运行在由多个服务器组成的集群上的容器数量不断增加。监控单个服务器很容易,但监控一个服务器上的多个服务就会带来一些困难。监控多个服务器上的多个服务需要全新的思维方式和一整套新的工具。当你开始采用微服务、容器和集群时,部署的容器数量将开始迅速增加。集群中形成的服务器数量也同样如此。我们不能再像以前那样登录到一个节点查看日志,因为需要查看的日志太多了。更重要的是,这些日志分布在许多服务器之间。昨天我们可能在一台服务器上部署了两个服务实例,今天我们可能会将八个实例部署到六台服务器上。监控也同样如此。旧有的工具,比如 Nagios,并没有设计用来处理运行中的服务器和服务的不断变化。我们已经使用了 Consul,它提供了一种不同的,甚至可以说是新的方法来管理接近实时的监控和在阈值达到时的反应。然而,这还不够。实时信息对于检测某些东西出现故障很有价值,但它并不能告诉我们为什么故障发生了。我们可以知道某个服务没有响应,但我们无法知道原因。
我们需要关于我们系统的历史信息。这些信息可以以日志、硬件利用率、健康检查以及其他许多形式存在。存储历史数据的需求并不新鲜,已经使用了很长时间。然而,信息流动的方向随着时间发生了变化。过去,大多数解决方案基于集中式数据收集器,而今天,由于服务和服务器的高度动态特性,我们倾向于采用分散式的数据收集器。
对于集群日志记录和监控,我们需要的是一组合并的去中心化数据收集器,这些收集器将信息发送到一个集中式解析服务和数据存储。市面上有许多专门为满足这一需求而设计的产品,从本地解决方案到云端解决方案应有尽有。FluentD、Loggly、GrayLog、Splunk 和 DataDog 只是我们可以使用的一些解决方案。我选择通过 ELK 堆栈(ElasticSearch、LogStash 和 Kibana)来向你展示这些概念。这个堆栈的优势在于它是免费的,文档齐全,效率高,而且广泛使用。ElasticSearch 已经成为实时搜索和分析的最佳数据库之一。它是分布式的、可扩展的、高可用的,并提供了复杂的 API。LogStash 使我们能够集中数据处理。它可以轻松扩展以支持自定义数据格式,并提供了许多插件,几乎可以满足任何需求。最后,Kibana 是一个分析和可视化平台,具有直观的界面,位于 ElasticSearch 之上。我们将使用 ELK 堆栈并不意味着它比其他解决方案更好,这完全取决于特定的使用场景和需求。我将带你了解如何使用 ELK 堆栈进行集中式日志记录和监控。一旦这些原则被理解,你应该可以毫不费力地将其应用到其他堆栈中,如果你选择这样做的话。
我们改变了事物的顺序,选择了工具后才讨论集中式日志记录的需求。让我们来纠正这一点。
集中式日志记录的需求
在大多数情况下,日志消息被写入文件。这并不是说文件是存储日志的唯一方式,也不是最有效的方式。然而,由于大多数团队在某种形式下都使用基于文件的日志,因此暂时我假设你的情况也是如此。
如果我们幸运的话,每个服务或应用程序会有一个日志文件。然而,更常见的是,我们的服务会将信息输出到多个文件中。大多数时候,我们并不太关心日志中写了什么。当一切顺利时,我们没有必要浪费宝贵的时间浏览日志。日志不是我们用来打发时间的小说,也不是我们花时间去阅读的技术书籍。日志是用来在某些地方出现问题时提供有价值的信息的。
这个情况看似简单。我们将信息写入日志,平时大多数时候我们并不关注这些日志,而当出现问题时,我们查阅日志并迅速找出问题的原因。至少,这也是许多人所期望的。现实远比这复杂。除了最简单的系统外,调试过程通常更加复杂。应用程序和服务几乎总是相互关联的,往往并不容易知道是哪个造成了问题。虽然问题可能表现为某个应用程序的问题,但调查往往表明原因出在另一个地方。例如,一个服务可能没有成功实例化。花费一段时间浏览其日志后,我们可能会发现原因出在数据库上。服务无法连接到数据库,导致启动失败。我们看到了症状,但没有找到原因。我们需要切换到数据库的日志中去找出真相。通过这个简单的例子,我们已经到了仅查看一个日志是不够的阶段。
在集群上运行的分布式服务使得情况变得更加复杂。哪个服务实例出现了故障?它运行在哪台服务器上?有哪些上游服务发起了请求?故障所在节点的内存和硬盘使用情况如何?正如你可能猜到的,查找、收集和筛选出成功发现原因所需的信息往往非常复杂。系统越大,问题越难解决。即使是单体应用,情况也可能很容易失控。如果采用(微)服务架构,这些问题会被成倍增加。集中式日志对所有除最简单、最小的系统外都是必须的。相反,当事情出错时,我们中的许多人开始在不同的服务器之间奔波,从一个文件跳到另一个文件。就像无头的苍蝇一样,四处乱跑,毫无方向。我们往往接受日志所带来的混乱,并将其视为职业的一部分。
在集中式日志中,我们需要关注什么?其实有很多方面,但最重要的包括以下几点:
-
一种解析数据并将其近实时发送到中央数据库的方法。
-
数据库处理近实时数据查询和分析的能力。
-
通过过滤后的表格、仪表板等方式,数据的可视化呈现。
我们已经选择了能够满足所有这些需求(甚至更多)工具。ELK 堆栈(LogStash、ElasticSearch 和 Kibana)可以做到这一点。与我们探索的其他所有工具一样,这个堆栈可以轻松扩展,以满足我们将面临的具体需求。
现在我们对要完成的目标有了模糊的概念,并且有了实现这些目标的工具,让我们来探索几种可以使用的日志策略。我们将从最常见的场景开始,逐渐过渡到更复杂、更高效的日志策略定义方法。
不再多说,我们来创建将用于实验集中式日志记录以及后续监控的环境。我们将创建三个节点。你应该已经对cd和prod虚拟机很熟悉了。第一个主要用于配置,而第二个将作为生产服务器。我们将引入一个新的虚拟机,名为logging。它将模拟生产服务器,旨在运行所有日志记录和监控工具。理想情况下,替代单一的生产服务器(prod),我们可以将示例运行在 Swarm 集群上。这将使我们能够在更接近生产的环境中看到优势。然而,由于前几章已经把单台笔记本电脑能承载的极限拉得很远,我不想冒险,所以选择了单台虚拟机。话虽如此,所有示例同样适用于一台、十台、百台或千台服务器。你应该没有问题将它们扩展到整个集群:
vagrant up cd prod logging
vagrant
ssh cd
将日志条目发送到 ElasticSearch
我们将从使用 ELK 栈(ElasticSearch、LogStash 和 Kibana)来配置logging服务器开始。我们将继续使用 Ansible 进行配置,因为它已经转变为我们最喜欢的配置管理工具。
让我们运行 elk.yml 剧本,并在执行过程中进行探索:
ansible-playbook /vagrant/ansible/elk.yml \
-i /vagrant/ansible/hosts/prod \
--extra-vars "logstash_config=file.conf"
剧本的定义如下:
- hosts: logging
remote_user: vagrant
serial: 1
roles:
- common
- docker
- elasticsearch
- logstash
- kibana
我们之前已经使用了common和docker角色很多次,因此我们会跳过它们,直接进入在roles/elasticsearch/tasks /main.yml文件中定义的elasticsearch任务:
- name: Container is running
docker:
name: elasticsearch
image: elasticsearch
state: running
ports:
- 9200:9200
volumes:
/data/elasticsearch:/usr/share/elasticsearch/data
tags: [elasticsearch]
多亏了 Docker,我们只需要运行官方的elasticsearch镜像。它通过端口9200暴露其 API,并定义了一个我们将用来在主机上持久化数据的卷。
接下来是logstash角色。在roles/logstash/tasks/main.yml文件中定义的任务如下:
- name: Directory is present
file:
path: "{{ item.path }}"
recurse: yes
state: directory
mode: "{{ item.mode }}"
with_items: directories
tags: [logstash]
- name: File is copied
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
with_items: files
tags: [logstash]
- name: Container is running
docker:
name: logstash
image: logstash
state: running
expose:
- 5044
- 25826
- 25826/udp
- 25827
- 25827/udp
ports:
- 5044:5044
- 5044:5044/udp
- 25826:25826
- 25826:25826/udp
- 25827:25827
- 25827:25827/udp
volumes:
- /data/logstash/conf:/conf
- /data/logstash/logs:/logs
links:
- elasticsearch:db
command: logstash -f /conf/{{ logstash_config }}
tags: [logstash]
虽然比elasticsearch任务稍多一些,这些任务仍然相对简单。任务会创建一个目录,复制一些配置文件,这些文件将在本章中使用,并运行官方的 logstash 镜像。由于我们将实验许多不同的场景,需要暴露并定义不同的端口。这个角色暴露了两个卷。第一个卷将保存配置文件,而第二个卷则作为一个目录来存放日志。最后,任务会创建与elasticsearch容器的链接,并指定command应使用定义为变量的配置文件启动logstash。我们用来运行剧本的命令包含了设置为file.conf的logstash_config变量。我们快速看一下它:
input {
file {
path => "/logs/**/*"
}
}
output {
stdout {
codec => rubydebug
}
elasticsearch {
hosts => db
}
}
LogStash 的配置由三大部分组成:input、output和filters。我们暂时跳过filter,集中讨论其他两个部分。
input 部分定义了一个或多个日志来源。在此案例中,我们定义了输入应该通过文件插件处理,path 设置为 /logs/**/*。一个星号表示任何文件或目录,而两个连续的星号表示任何目录或子目录中的文件。/logs/**/* 这个值可以描述为 /logs/ 目录中的任何文件或其任何子目录中的文件。请记住,尽管我们只指定了一个输入,但可能会有多个输入,并且通常是这样。有关所有支持的输入插件的更多信息,请参阅官方的输入插件页面。
output 部分定义了通过输入收集的日志条目的目的地。在此案例中,我们设置了两个输出。第一个是使用 stdout 输出插件,它会通过 rubydebug 编解码器将所有内容打印到标准输出。请注意,我们仅将 stdout 用于演示目的,以便快速查看结果。在生产环境中,出于性能考虑,您应该删除它。第二个输出更有趣,它使用 ElasticSearch 输出插件将所有日志条目发送到数据库。请注意,hosts 变量设置为 db。由于我们已将 logstash 和 elasticsearch 容器连接在一起,Docker 在 /etc/hosts 文件中创建了 db 条目。有关所有支持的输出插件的更多信息,请参阅 www.elastic.co/guide/en/logstash/current/output-plugins.html 页面。
这个配置文件可能是我们可以开始使用的最简单的文件。在我们看到它的实际效果之前,让我们来看看堆栈中的最后一个元素。Kibana 将提供一个用户界面,供我们与 ElasticSearch 交互。kibana 角色的任务在 roles/kibana/tasks/main.yml 文件中定义。它包含了备份恢复任务,我们现在可以跳过这些任务,专注于运行容器的部分:
- name: Container is running
docker:
image: kibana
name: kibana
links:
- elasticsearch:elasticsearch
ports:
- 5601:5601
tags: [kibana]
就像 ELK 堆栈中的其他组件一样,Kibana 也有官方的 Docker 镜像。我们只需要将容器链接到 elasticsearch,并暴露我们将用于访问 UI 的 6501 端口。我们很快就能看到 Kibana 的实际效果。
在我们模拟一些日志条目之前,我们需要进入运行 ELK 堆栈的 logging 节点:
exit
vagrant ssh logging
由于 /data/logstash/logs 卷与容器共享,并且 LogStash 正在监视其中的任何文件,我们可以创建一个仅包含一个条目的日志:
echo "my first log entry" \
>/data/logstash/logs/my.log
让我们看看 LogStash 的输出,看看发生了什么:
docker logs logstash
请注意,处理第一个日志条目可能需要几秒钟,如果 docker logs 命令没有返回任何内容,请重新执行它。所有新条目对同一文件的处理速度会快得多:
输出如下:
{
"message" => "my first log entry",
"@version" => "1",
"@timestamp" => "2016-02-01T18:01:04.044Z",
"host" => "logging",
"path" => "/logs/my.log"
}
如您所见,LogStash 处理了我们的 my first log entry 并添加了一些额外的信息。我们得到了时间戳、主机名以及日志文件的路径:
让我们再添加几个条目:
echo "my second log entry" \
>>/data/logstash/logs/my.log
echo "my third log entry" \
>>/data/logstash/logs/my.log
docker logs logstash
docker logs命令的输出如下:
{
"message" => "my first log entry",
"@version" => "1",
"@timestamp" => "2016-02-01T18:01:04.044Z",
"host" => "logging",
"path" => "/logs/my.log"
}
{
"message" => "my second log entry",
"@version" => "1",
"@timestamp" => "2016-02-01T18:02:06.141Z",
"host" => "logging",
"path" => "/logs/my.log"
}
{
"message" => "my third log entry",
"@version" => "1",
"@timestamp" => "2016-02-01T18:02:06.150Z",
"host" => "logging",
"path" => "/logs/my.log"
}
如预期的那样,所有三条日志条目都已被 LogStash 处理,现在是通过 Kibana 可视化它们的时候了。请从浏览器打开http://10.100.198.202:5601/。由于这是第一次运行 Kibana,它会要求我们配置一个索引模式。幸运的是,它已经找到了索引格式(logstash-*)以及哪个字段包含时间戳(@timestamp)。请点击Create按钮,然后点击顶部菜单中的Discover:

图 16-01 – 带有若干日志条目的 Kibana Discover 屏幕
默认情况下,Discover屏幕会显示过去十五分钟内在 ElasticSearch 中生成的所有条目。稍后当我们生成更多日志时,我们将探索此屏幕提供的功能。目前,请点击其中一个日志条目最左侧列的箭头。你将看到所有 LogStash 生成并发送到 ElasticSearch 的字段。目前,由于我们没有使用任何过滤器,这些字段仅限于表示整个日志条目的消息,以及 LogStash 生成的一些通用字段。
我们使用的示例是简单的,甚至不像一个日志条目。让我们增加日志的复杂性。我们将使用我准备的一些条目。示例日志位于/tmp/apache.log文件中,包含了一些遵循 Apache 格式的日志条目。其内容如下:
127.0.0.1 - - [11/Dec/2015:00:01:45 -0800] "GET /2016/01/11/the-devops-2-0-toolkit/ HTTP/1.1" 200 3891 "http://technologyconversations.com" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0"
127.0.0.1 - - [11/Dec/2015:00:01:57 -0800] "GET /2016/01/18/clustering-and-scaling-services/ HTTP/1.1" 200 3891 "http://technologyconversations.com" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0"
127.0.0.1 - - [11/Dec/2015:00:01:59 -0800] "GET /2016/01/26/self-healing-systems/ HTTP/1.1" 200 3891 "http://technologyconversations.com" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0"
由于 LogStash 期望在/data/logstash/logs/目录中找到日志文件,让我们复制示例:
cat /tmp/apache.log \
>>/data/logstash/logs/apache.log
让我们看看 LogStash 生成的输出:
docker logs logstash
LogStash 可能需要几秒钟才能检测到有新文件需要监控。如果docker logs输出没有显示任何新内容,请重复执行该命令。输出应该类似于以下内容:
{
"message" => "127.0.0.1 - - [11/Dec/2015:00:01:45 -0800] \"GET /2016/01/11/the-devops-2-0-toolkit/ HTTP/1.1\" 200 3891 \"http://technologyconversations.com\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0\"",
"@version" => "1",
"@timestamp" => "2016-02-01T19:06:21.940Z",
"host" => "logging",
"path" => "/logs/apache.log"
}
{
"message" => "127.0.0.1 - - [11/Dec/2015:00:01:57 -0800] \"GET /2016/01/18/clustering-and-scaling-services/ HTTP/1.1\" 200 3891 \"http://technologyconversations.com\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0\"",
"@version" => "1",
"@timestamp" => "2016-02-01T19:06:21.949Z",
"host" => "logging",
"path" => "/logs/apache.log"
}
{
"message" => "127.0.0.1 - - [11/Dec/2015:00:01:59 -0800] \"GET /2016/01/26/self-healing-systems/ HTTP/1.1\" 200 3891 \"http://technologyconversations.com\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0\"",
"@version" => "1",
"@timestamp" => "2016-02-01T19:06:21.949Z",
"host" => "logging",
"path" => "/logs/apache.log"
}
相同的数据可以从运行在http://10.100.198.202:5601/上的 Kibana 中观察到。
我们刚刚开始,但已经取得了巨大的进展。当服务器出现故障时,我们不需要知道是哪项服务失败,也不需要知道它的日志在哪里。我们可以从一个地方获取该服务器的所有日志条目。无论是开发人员、测试人员、操作员还是其他任何角色,都可以打开运行在该节点上的 Kibana,检查所有服务和应用程序的日志。
之前的 Apache 日志示例比我们使用的第一个更接近生产环境。然而,条目仍然作为一条大消息存储。虽然 ElasticSearch 几乎可以搜索任何格式的几乎任何内容,但我们应该稍微帮助它,并尝试将这条日志拆分成多个字段。
解析日志条目
我们之前提到过,LogStash 的配置由三个主要部分组成:input、output和filters。之前的示例只使用了input和output,现在是时候介绍第三部分了。我已经准备好了一个示例配置文件,可以在roles/logstash/files/file-with-filters.conf文件中找到。它的内容如下:
input {
file {
path => "/logs/**/*"
}
}
filter {
grok {
match => { "message" => "%{COMBINEDAPACHELOG}" }
}
date {
match => [ "timestamp" , "dd/MMM/yyyy:HH:mm:ss Z" ]
}
}
output {
stdout {
codec => rubydebug
}
elasticsearch {
hosts => db
}
}
input和output部分与之前相同。不同之处在于添加了filter。和其他两个部分一样,我们可以使用一个或多个插件。在这个例子中,我们指定了使用 grok 过滤器插件。如果没有其他理由,至少官方插件描述应该促使你尝试使用它。
Grok 目前是 LogStash 中解析混乱的非结构化日志数据为结构化并可查询数据的最佳方式。
Grok 基于正则表达式,LogStash 本身已经包含了很多模式。这些模式可以在github.com/logstash-plugins/logstash-patterns-core/blob/master/patterns/grok-patterns库中找到。在我们的例子中,由于我们使用的日志匹配的是 Apache 格式,这个格式已经包含在内,所以我们只需要告诉 LogStash 使用COMBINEDAPACHELOG模式解析message。稍后我们会看到如何组合不同的模式,但目前COMBINEDAPACHELOG应该足够用了。
我们将使用的第二个过滤器是通过日期插件定义的。它将把日志条目的时间戳转换为 LogStash 格式。
请更详细地探索过滤器插件。你很可能会找到一个或多个适合你需求的插件。
我们将file.conf替换为file-with-filters.conf文件,重启 LogStash,然后看看它的表现如何:
sudo cp /data/logstash/conf/file-with-filters.conf \
/data/logstash/conf/file.conf
docker restart logstash
使用新的 LogStash 配置,我们可以再添加一些 Apache 日志条目:
cat /tmp/apache2.log \
>>/data/logstash/logs/apache.log
docker logs logstash
最后一条日志的docker logs输出如下:
{
"message" => "127.0.0.1 - - [12/Dec/2015:00:01:59 -0800] \"GET /api/v1/books/_id/5 HTTP/1.1\" 200 3891 \"http://cadenza/xampp/navi.php\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0\"",
"@version" => "1",
"@timestamp" => "2015-12-12T08:01:59.000Z",
"host" => "logging",
"path" => "/logs/apache.log",
"clientip" => "127.0.0.1",
"ident" => "-",
"auth" => "-",
"timestamp" => "12/Dec/2015:00:01:59 -0800",
"verb" => "GET",
"request" => "/api/v1/books/_id/5",
"httpversion" => "1.1",
"response" => "200",
"bytes" => "3891",
"referrer" => "\"http://cadenza/xampp/navi.php\"",
"agent" => "\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:25.0) Gecko/20100101 Firefox/25.0\""
}
如你所见,消息仍然完整显示。此外,这次我们得到了不少额外的字段。clientip、verb、referrer、agent以及其他数据都得到了正确的分离。这将使我们能够更高效地过滤日志。
让我们打开 Kibana,地址是http://10.100.198.202:5601/。你会注意到的一个问题是,Kibana 显示没有找到结果,尽管我们刚刚解析了三条日志。原因在于第二个过滤器将日志时间戳转换为 LogStash 格式。由于默认情况下,Kibana 显示的是最近 15 分钟的日志,而日志条目是在 2015 年 12 月生成的,它们确实超过了 15 分钟的时间范围。点击屏幕右上角的Last 15 minutes按钮,选择Absolute并设置时间范围为 2015 年 12 月 1 日到 2015 年 12 月 31 日。这样我们就能看到 2015 年 12 月期间的所有日志。
点击Go按钮,观察到我们刚刚通过 LogStash 发送到 ElasticSearch 的三个日志条目已显示在屏幕上。你会注意到右侧菜单中出现了许多新字段。我们稍后会在探索 Kibana 过滤器时使用这些字段。目前需要注意的是,这一次我们在发送日志条目到 ElasticSearch 之前对其进行了解析。
通过使用 LogStash 过滤器,我们改善了存储在 ElasticSearch 中的数据。该解决方案依赖于将整个 ELK 堆栈安装在日志所在的同一台服务器上,并且我们可以通过单一接口(Kibana)查看所有我们决定跟踪的日志。问题在于,该解决方案仅限于单一服务器。例如,如果我们有十台服务器,我们将需要安装十个 ELK 堆栈。这将引入相当大的资源开销。ElasticSearch 很占用内存,而 LogStash 会消耗更多的 CPU,这是我们不愿意让步的。与此同时,虽然我们目前所做的改进有所帮助,但仍远未理想。我们仍然需要知道哪个服务器产生了问题,并且可能需要在不同的 Kibana 之间切换,尤其是在尝试交叉参考不同的服务和应用时:

图 16-02 – 在单台服务器上运行的 ELK 堆栈
在我向你介绍去中心化日志和集中式日志解析的概念之前,让我们先移除 LogStash 实例,并回到 cd 节点:
docker rm -f lo
gstash
exit
vagrant ssh cd
将日志条目发送到中央 LogStash 实例
到目前为止我们所做的对解决问题有帮助,但仍然无法解决将所有日志集中到一个地方的问题。目前,我们只有单台服务器的所有日志在一个位置。我们该如何改变这一点?
一种简单的解决方案是将 LogStash 安装在每台服务器上,并配置它将日志条目发送到远程 ElasticSearch。至少,这就是我与之合作的大多数公司解决问题的方式。我们是否也应该这样做?答案是否定的;我们不应该。问题出在 LogStash 本身。虽然它是一个用于收集、解析和输出日志的优秀解决方案,但它消耗的资源过多。在每台服务器上都安装 LogStash 会导致资源的巨大浪费。相反,我们将使用 Filebeat。
Filebeat 是一个轻量级的日志文件传输工具,代表了 LogStash Forwarder 的下一代产品。与 LogStash 一样,它会跟踪日志文件。不同之处在于,它被优化为只负责跟踪和发送日志,不进行任何解析。另一个不同之处是它是用 Go 语言编写的。这两点使得它比 LogStash 更加高效,占用更少的资源,具有如此小的资源占用,我们可以安全地在所有服务器上运行,而不会显著增加内存和 CPU 的消耗。
在我们看到 Filebeat 的实际效果之前,我们需要更改 LogStash 配置中的 input 部分。新的配置位于 roles/logstash/files/beats.conf 文件中,内容如下:
input {
beats {
port => 5044
}
}
output {
stdout {
codec => rubydebug
}
elasticsearch {
hosts => db
}
}
如您所见,唯一的区别在于输入部分。它使用了设置为监听端口 5044 的 beats 插件。通过此配置,我们可以运行一个 LogStash 实例,并让所有其他服务器将它们的日志发送到这个端口。
让我们使用以下设置部署 LogStash:
ansible-playbook /vagrant/ansible/elk.yml \
-i /vagrant/ansible/hosts/prod \
--extra-vars "logstash_config=beats.conf"
LogStash 现在在 logging 服务器上运行,并且正在监听端口 5044 上的 beats 数据包。在我们继续部署 Filebeat 到,比如说,prod 节点之前,让我们快速看一下 prod3.yml playbook:
- hosts: prod
remote_user: vagrant
serial: 1
roles:
- common
- docker
- docker-compose
- consul
- registrator
- consul-template
- nginx
- file
新增项是 roles/filebe at 角色。其任务在 roles/filebeat/tasks/main.yml 文件中定义,内容如下:
- name: Download the package
get_url:
url: https://download.elastic.co/beats/filebeat/filebeat_1.0.1_amd64.deb
dest: /tmp/filebeat.deb
tags: [filebeat]
- name: Install the package
apt:
deb: /tmp/filebeat.deb
tags: [filebeat]
- name: Configuration is present
template:
src: filebeat.yml
dest: /etc/filebeat/filebeat.yml
tags: [filebeat]
- name: Service is started
service:
name: filebeat
state: started
tags: [filebeat]
这些任务将下载软件包、安装它、复制配置文件,并最终运行服务。唯一值得关注的是 r oles/filebeat/templates/filebeat.yml 配置文件:
filebeat:
prospectors:
-
paths:
- "/var/log/**/*.log"
output:
logstash:
hosts: ["{{ elk_ip }}:5044"]
filebeat 部分指定了一个前景程序列表,用于定位和处理日志文件。每个前景程序项以破折号(-)开始,并指定特定于前景程序的配置选项,包括用于爬取日志文件的路径列表。在我们的情况下,只有一个路径被设置为 /var/log/**/*.log。当启动时,Filebeat 将查找位于 /var/log/* 目录下或其任何子目录中的所有以 .log 结尾的文件。由于这个位置正是大多数 Ubuntu 日志所在的位置,因此我们将处理大量的日志条目。
output 部分用于将日志条目发送到各种目标。在我们的例子中,我们指定了 LogStash 作为唯一的输出。由于当前的 LogStash 配置没有任何过滤功能,我们本可以将 ElasticSearch 设置为输出,结果是相同的,但开销较小。然而,由于未来很可能会添加一些过滤器,因此输出被设置为 LogStash。
请注意,过滤器既是福音也是诅咒。它们让我们可以将日志条目拆分成更易于管理的字段。另一方面,如果日志格式差异过大,您可能会花费大量时间编写解析器。是否使用过滤器,或者依赖 ElasticSearch 的过滤功能而不使用专门字段,完全取决于您。我个人的做法是两者兼顾。如果日志包含重要的信息(正如接下来某些例子所示),过滤日志是必须的。如果日志条目只是一些没有分析价值的普通信息,我则完全跳过过滤。通过一些练习,您会建立起自己的规则。
有关配置选项的更多信息,请参考 www.elastic.co/guide/en/beats/filebeat/current/filebeat-configuration-details.html 页面。
让我们运行 playbook,看看 Filebeat 的实际运行情况。
ansible-playbook /vagrant/ansible/prod3.yml \
-i /vagrant/ansible/hosts/prod
现在 Filebeat 已经在 prod 节点上运行,我们可以查看在 logging 服务器上运行的 LogStash 生成的日志。
docker -H tcp://logging:2375 \
logs logstash
docker logs 命令的最后几行如下:
...
{
"message" => "ttyS0 stop/pre-start, process 1301",
"@version" => "1",
"@timestamp" => "2016-02-02T14:50:45.557Z",
"beat" => {
"hostname" => "prod",
"name" => "prod"
},
"count" => 1,
"fields" => nil,
"input_type" => "log",
"offset" => 0,
"source" => "/var/log/upstart/ttyS0.log",
"type" => "log",
"host" => "prod"
}
FileBeat 将 prod 节点中 /var/log/ 目录下的所有日志条目发送到了 logging 服务器上运行的 LogStash。它毫不费力地完成了这一任务,结果是我们在 ElasticSearch 中存储了超过 350 条日志条目。好吧,350 条日志条目不算值得炫耀,但如果是 350000 条,它也能轻松处理。
让我们确认日志是否已到达 Kibana。请打开 http://10.100.198.202:5601/。如果没有看到任何条目,说明已经过去了超过十五分钟,您需要通过点击屏幕右上角的时间选择器按钮来增加时间。
注意
请注意,每次向 ElasticSearch 索引中添加新的字段类型时,我们都应该重新创建模式。可以通过进入设置屏幕并点击创建按钮来完成。
我们再次大大改进了解决方案。现在有一个中央位置处理日志的解析(LogStash)、存储(ElasticSearch)和查看(Kibana)。我们可以在每台服务器上运行 Filebeat,并将任意数量的服务器连接到其中。它将尾随日志并将其发送到 LogStash:

图 16-03 – 在单一服务器上运行 ELK 堆栈,并将 Filebeat 分布到整个集群
让我们稍微提高难度,将所学应用于 Docker 容器。由于我们需要更改 LogStash 配置,接下来我们通过移除正在运行的实例来结束本节:
docker -H
tcp://logging:2375 \
rm -f logstash
将 Docker 日志条目发送到中央 LogStash 实例
由于我们使用的是容器,因此可以通过共享服务写入日志的目录来运行容器。我们这样做吗?答案是否定的,这时你可能会觉得我在不断带领你走向错误的解决方案。其实,我真正想做的是一步步构建解决方案,同时向你展示你可能选择的不同路径。我的首选解决方案不一定要被你采用。你有更多的选择,才能做出更明智的决策。
让我们回到写日志到文件并将其发送到 LogStash 的话题。我的个人强烈观点是,不论我们如何打包服务,所有的日志都应该发送到标准输出或错误(stdout 或 stderr)。对于这个观点有很多实际原因,虽然如此,我不会展开讲解。我已经收到了不少邮件,很多人表示我的观点和做法过于激进(大部分邮件的回复是:世纪已经变过十五年以上了)。我只想避免在日志话题上引发更多争论,并跳过在容器内写日志到文件的原因。两个原因在大众中脱颖而出。首先,我们使用卷的次数越少,容器对其运行的主机依赖性就越小,迁移它们也会更容易(无论是出于故障恢复还是扩展目的)。第二个原因是 Docker 的日志驱动程序期望日志发送到 stdout 和 stderr。通过不将日志写入文件,我们避免了与服务器或特定日志技术的耦合。
如果你正打算给我发一封讨厌的邮件,声称日志文件是天赐的恩典,请注意,我指的是在容器内部生成时日志的输出目的地(尽管在开始使用它们之前,我就已经在遵守这一规则)。
曝露容器目录并将日志作为卷挂载的替代方案是什么?Docker 在其 1.6 版本中引入了日志驱动程序功能。虽然这一功能大多数时候未被注意到,但它是一个非常酷的能力,并且是朝着在 Docker 环境中创建全面日志处理方法迈出的重要一步。从那时起,除了默认的 json-file 驱动程序外,我们还拥有了 syslog、journald、gelf、fluentd、splunk 和 awslogs。等你读到这本书时,可能会有新的日志驱动程序出现。
现在我们决定使用 Docker 的日志驱动程序,问题就来了:选择哪个?GELF 驱动程序以 Greylog 扩展日志格式 编写消息,这个格式被 LogStash 支持。如果我们只需要存储容器生成的日志,这是一个不错的选择。另一方面,如果我们不仅希望获取容器内服务生成的日志,还想获取来自整个系统的日志,我们可以选择 JournalD 或 syslog。在这种情况下,我们能获得关于所有发生的事件的(几乎)完整信息,不仅仅是容器内的,还包括整个操作系统层面的。后一种选择(JournalD 或 syslog)在 ElasticSearch 可用内存充足的情况下更为理想(更多的日志意味着更多的内存消耗),这也是我们将深入探索的内容。不要被 ElasticSearch 对大量内存的需求吓到,通过巧妙地清理旧数据,这个问题是可以轻松缓解的。我们将跳过关于 JournalD 是否比 syslog 更好的争论,并选择后者。无论你的偏好是哪个,因为这两者适用相同的原则。
这次,我们将使用roles/logstash/files/syslog.conf文件作为 LogStash 配置。让我们逐一查看它的各个部分:
input {
syslog {
type => syslog
port => 25826
}
}
input部分应该不言自明。我们使用的是syslog 插件,并有两个设置。第一个设置为所有通过此输入处理的事件添加一个type字段。它将帮助我们区分来自syslog的日志和通过其他方法生成的日志。port设置指定 LogStash 应在25826端口监听 syslog 事件。
配置文件的过滤部分稍微复杂一些。我决定主要使用它来展示过滤器可以做的一小部分:
filter {
if "docker/" in [program] {
mutate {
add_field => {
"container_id" => "%{program}"
}
}
mutate {
gsub => [
"container_id", "docker/", ""
]
}
mutate {
update => [
"program", "docker"
]
}
}
if [container_id] == "nginx" {
grok {
match => [ "message" , "%{COMBINEDAPACHELOG} %{HOSTPORT:upstream_address} %{NOTSPACE:upstream_response_time}"]
}
mutate {
convert => ["upstream_response_time", "float"]
}
}
}
它从一个if语句开始。Docker 将以docker/[CONTAINER_ID]格式将日志发送到 syslog。我们利用这一点来区分来自 Docker 的日志条目和其他方式生成的日志。在if语句中,我们进行了一些变更。第一个变更是添加一个新的字段container_id,目前它的值与program字段相同。第二个变更是移除该值中的docker/部分,这样我们就只剩下容器 ID。最后,我们将program字段的值更改为docker。
变量及其值,变更前后的情况如下:
| 变量名称 | 变更前的值 | 变更后的值 |
|---|---|---|
program |
docker/[CONTAINER_ID] |
docker |
container_id |
/ |
[CONTAINER_ID] |
第二个条件语句通过检查container_id是否被设置为nginx来开始。如果是,它将使用我们之前看到的COMBINEDAPACHELOG模式来解析消息,并向其中添加两个新字段,分别为upstream_address和upstream_response_time。这两个字段也使用了预定义的 grok 模式HOSTPORT和NOTSPACE。如果你想深入了解并仔细查看这些模式,可以参考github.com/logstash-plugins/logstash-patterns-core/blob/master/patterns/grok-patterns这个仓库。如果你熟悉正则表达式,这应该容易理解(如果正则表达式能算得上容易的话)。
否则,你可能需要依赖声明的名称来找到你需要的表达式(至少在你学会正则表达式之前)。事实上,正则表达式是一种非常强大的文本解析语言,但与此同时,它也非常难以掌握:
我的妻子说,我的头发大约在我处理一个需要大量正则表达式的项目时开始变灰的。这是我们为数不多的共识之一。
最后,nginx条件中的变更将upstream_response_time字段从string(默认)转换为float。我们稍后会用到这些信息,因此需要将其转换为数字。
配置文件的第三个也是最后一个部分是output:
output {
stdout {
codec => rubydebug
}
elasticsearch {
hosts => db
}
}
这与之前的任务相同。我们将过滤后的日志条目发送到标准输出和 ElasticSearch。
现在我们已经理解了配置文件,或者至少假装我们理解了,我们可以通过 Ansible 剧本 elk.yml 再次部署 LogStash:
ansible-playbook /vagrant/ansible/elk.yml \
-i /vagrant/ansible/hosts/prod \
--extra-vars "logstash_config=syslog.conf"
现在我们已经启动并运行了 LogStash,并配置为使用 syslog 作为输入。让我们移除当前运行的 nginx 实例,并使用 Docker 日志驱动程序设置为 syslog 重新运行它。与此同时,我们还会用 syslog 配置 prod 节点。我们将使用的 prod4.yml 剧本如下:
- hosts: prod
remote_user: vagrant
serial: 1
vars:
- log_to_syslog: yes
roles:
- common
- docker
- docker-compose
- consul
- registrator
- consul-template
- nginx
- rsyslog
如你所见,这个剧本与我们用于配置 prod 服务器的其他剧本类似。不同之处在于 log_to_syslog 变量,以及 rsyslog 角色的添加。
在 roles/nginx/tasks/main.yml 文件中定义的与 nginx 相关的任务如下:
- name: Container is running
docker:
image: nginx
name: nginx
state: running
ports: "{{ ports }}"
volumes: "{{ volumes }}"
log_driver: syslog
log_opt:
syslog-tag: nginx
when: log_to_syslog is defined
tags: [nginx]
不同之处在于添加了 log_driver 和 log_opt 声明。第一个设置 Docker 日志驱动程序为 syslog。log_opt 可用于指定额外的日志选项,这取决于驱动程序。在这种情况下,我们指定了 tag。没有它,Docker 会使用容器 ID 来标识发送到 syslog 的日志。这样,当我们查询 ElasticSearch 时,将更容易找到 nginx 条目。
在 roles/rsyslog/tasks/main.yml 文件中定义的 rsyslog 任务如下:
- name: Packages are present
apt:
name: "{{ item }}"
state: latest
install_recommends: no
with_items:
- rsyslog
- logrotate
tags: [rsyslog]
- name: Config file is present
template:
src: 10-logstash.conf
dest: /etc/rsyslog.d/10-logstash.conf
register: config_result
tags: [rsyslog]
- name: Service is restarted
shell: service rsyslog restart
when: config_result.changed
tags: [rsyslog]
它将确保 rsyslog 和 logrotate 包已安装,复制 10-logstash.conf 配置文件,并重启服务。roles/rsyslog/templates/10-logstash.conf 模板如下:
*.* @@{{ elk_ip }}:25826
请注意,该文件是 Ansible 的模板,{{ elk_ip }} 将被替换为实际的 IP 地址。配置非常简单。所有发送到 syslog 的内容都会转发到指定 IP 和端口上运行的 LogStash。
现在我们准备好移除当前运行的 nginx 容器并运行剧本:
docker -H tcp://prod:2375 \
rm -f nginx
ansible-playbook /vagrant/ansible/prod4.yml \
-i /vagrant/ansible/hosts/prod
让我们看看发送到 LogStash 的内容:
docker -H tcp://logging:2375 \
logs logstash
你应该能看到系统生成的 syslog 条目,其中之一可能如下所示:
{
"message" => "[55784.504413] docker0: port 3(veth4024c56) entered forwarding state\n",
"@version" => "1",
"@timestamp" => "2016-02-02T21:58:23.000Z",
"type" => "syslog",
"host" => "10.100.198.201",
"priority" => 6,
"timestamp" => "Feb 2 21:58:23",
"logsource" => "prod",
"program" => "kernel",
"severity" => 6,
"facility" => 0,
"facility_label" => "kernel",
"severity_label" => "Informational"
}
我们还可以通过运行在 http://10.100.198.20:5601/ 上的 Kibana 来查看相同的数据。
让我们看看当我们将服务打包成容器并部署时会发生什么。首先,我们将进入 prod 节点,并在该节点上运行 books-ms 服务:
exit
vagrant ssh prod
git clone https://github.com/vfarcic/books-ms.git
cd books-ms
在我们部署 books-ms 服务之前,让我们快速看一下 docker-compose-logg-ing.yml 文件:
app:
image: 10.100.198.200:5000/books-ms
ports:
- 8080
links:
- db:db
environment:
- SERVICE_NAME=books-ms
log_driver: syslog
log_opt:
syslog-tag: books-ms
db:
image: mongo
log_driver: syslog
log_opt:
syslog-tag: books-ms
如你所见,它遵循与我们用 Ansible 配置 nginx 时相同的逻辑。唯一不同的是,在这种情况下,它是 Docker Compose 配置。它包含相同的 log_driver 和 log_opt 键。
现在我们理解了需要在 Docker Compose 配置中添加的更改,我们可以部署该服务:
docker-compose -p books-ms \
-f docker-compose-logging.yml \
up -d app
让我们通过列出并过滤 Docker 进程来再次检查它是否确实在运行:
docker ps --filter name=booksms
现在服务已启动并运行,且使用了 syslog 日志驱动程序,我们应该验证日志条目是否确实被发送到 LogStash:
docker -H tcp://logging:2375 \
logs logstash
部分输出如下:
{
"message" => "[INFO] [02/03/2016 13:28:35.869] [routingSystem-akka.actor.default-dispatcher-5] [akka://routingSystem/user/IO-HTTP/listener-0] Bound to /0.0.0.0:8080\n",
"@version" => "1",
"@timestamp" => "2016-02-03T13:28:35.000Z",
"type" => "syslog",
"host" => "10.100.198.201",
"priority" => 30,
"timestamp" => "Feb 3 13:28:35",
"logsource" => "prod",
"program" => "docker",
"pid" => "11677",
"severity" => 6,
"facility" => 3,
"facility_label" => "system",
"severity_label" => "Informational",
"container_id" => "books-ms"
}
服务日志确实已发送到 LogStash。请注意,LogStash 过滤器确实按照我们指示的方式执行。program字段从docker/books-ms转换为docker,并且创建了一个名为container_id的新字段。由于我们只在container_id为nginx时定义了解析message,所以它保持不变。
让我们确认message解析确实对来自nginx的日志条目有效。我们需要发出几个请求给代理,所以我们先来正确配置它:
cp nginx-includes.conf \
/data/nginx/includes/books-ms.conf
consul-template \
-consul localhost:8500 \
-template "nginx-upstreams.ctmpl:\
/data/nginx/upstreams/books-ms.conf:\
docker kill -s HUP nginx" \
-once
你已经使用了nginx配置和 Consul 模板,因此不需要再解释这些命令。
现在服务已经启动并运行,已集成并将日志发送到 LogStash,让我们通过发出几个请求来生成一些nginx日志条目:
curl -I localhost/api/v1/books
curl -H 'Content-Type: application/json' -X PUT -d \
"{\"_id\": 1,
\"title\": \"My First Book\",
\"author\": \"John Doe\",
\"description\": \"Not a very good book\"}" \
localhost/api/v1/books | jq '.'
curl http://prod/api/v1/books | jq '.'
让我们看看这次 LogStash 收到了什么:
docker -H tcp://logging:2375 \
logs logstash
docker logs命令的部分输出如下:
{
"message" => "172.17.0.1 - - [03/Feb/2016:13:37:12 +0000] \"GET /api/v1/books HTTP/1.1\" 200 269 \"-\" \"curl/7.35.0\" 10.100.198.201:32768 0.091 \n",
"@version" => "1",
"@timestamp" => "2016-02-03T13:37:12.000Z",
"type" => "syslog",
"host" => "10.100.198.201",
"priority" => 30,
"timestamp" => [
[0] "Feb 3 13:37:12",
[1] "03/Feb/2016:13:37:12 +0000"
],
"logsource" => "prod",
"program" => "docker",
"pid" => "11677",
"severity" => 6,
"facility" => 3,
"facility_label" => "system",
"severity_label" => "Informational",
"container_id" => "nginx",
"clientip" => "172.17.0.1",
"ident" => "-",
"auth" => "-",
"verb" => "GET",
"request" => "/api/v1/books",
"httpversion" => "1.1",
"response" => "200",
"bytes" => "269",
"referrer" => "\"-\"",
"agent" => "\"curl/7.35.0\"",
"upstream_address" => "10.100.198.201:32768",
"upstream_response_time" => 0.091
}
这一次,不仅存储了来自容器的日志,我们还对它们进行了解析。解析nginx日志的主要原因在于upstream_response_time字段。你能猜到为什么吗?在你思考该字段可能的用途时,让我们仔细看看 Kibana Discover 界面的一些功能。
我们已经生成了足够的日志,因此我们可能想开始使用 Kibana 过滤器。请打开http://10.10 0.198.202:5601/。请点击右上角的按钮,将时间更改为,比如说,24 小时。这将给我们足够的时间来操作我们创建的少量日志。在我们开始过滤之前,请进入设置界面,并点击创建。这将刷新我们的索引模式,添加新的字段。完成后,请返回到发现界面。
让我们从左侧菜单开始。它包含所有可用字段,这些字段出现在所有匹配给定时间段的日志中。点击任何一个字段都会显示该字段包含的值列表。例如,container_id包含books-ms和nginx。这些值旁边有一个放大镜图标。带有加号的图标可以用来仅过滤包含该值的条目。类似地,带有减号的图标可以用来排除记录。点击nginx旁边的加号图标。正如你所看到的,只有来自nginx的日志条目会显示出来。应用的过滤器结果位于上方的横条中。将鼠标悬停在其中一个过滤器(在此为container_id: "nginx")上,可以启用、禁用、固定、取消固定、反转、切换和移除该过滤器:

图 16-04 – Kibana Discover 界面,按 container_id nginx 过滤后的日志条目
在主框架的顶部有一个图表,显示指定时间段内的日志数量。其下方是一个包含日志条目的表格。默认情况下,它显示 时间 和 *_source* 列。请点击某一行左侧的箭头图标,它会展开该行,显示该日志条目中的所有字段。这些字段是 LogStash 生成的数据与我们通过其配置解析的数据的结合。每个字段都有与左侧菜单中相同的图标。
通过它们,我们可以 按值筛选或排除值。第三个按钮,图标像一个带有两列的单行表格,可以用来 切换表格中的列。由于默认列并不太有用,更不用说有些无聊了,请切换 logsource、request、verb、upstream_address 和 upstream_response_time。再次点击箭头,可以隐藏这些字段。我们就得到了一个漂亮的表格,显示了来自 nginx 的一些最重要的信息。我们可以看到请求所在的服务器(logsource),请求的地址(request),请求的类型(verb),接收响应所花费的时间(upstream_response_time),以及请求被代理到哪里(upstream_address)。如果你认为你创建的 搜索 有用,可以通过点击位于屏幕右上角的 保存搜索 按钮将其保存。
它旁边是 加载已保存搜索 按钮:

图 16-05 – Kibana Discover 屏幕,显示由 container_id nginx 和自定义列筛选的日志条目
稍后我们将稍微探索一下 可视化 和 仪表板 屏幕。
让我们总结一下目前的流程:
-
容器通过 Docker 的日志驱动程序设置为
syslog。在这种配置下,Docker 将所有发送到标准输出或错误(stdout/stderr)的内容重定向到syslog。 -
所有的日志条目,无论是来自容器还是通过其他方法部署的进程,都从 syslog 重定向到 LogStash。
-
LogStash 接收 syslog 事件,应用过滤器和转换规则,然后将其重新发送到 ElasticSearch。
-
大家都很高兴,因为找到特定的日志条目变得轻松,办公室工作时间也变得更容易应对。

图 16-06 – ELK 堆栈在单台服务器上运行,容器日志记录到 syslog
基于软件数据的自愈
让我们充分利用通过 nginx 记录的响应时间。由于数据存储在 ElasticSearch 中,我们可以做一些快速的 API 示例。我们可以,例如,检索存储在 logstash 索引中的所有条目:
curl 'http://logging:9200/logstash-*/_search' \
| jq '.'
ElasticSearch 返回了前十条记录(默认的页面大小),并附带了一些额外的信息,比如总记录数。检索所有条目没有太大意义,因此让我们尝试缩小范围。例如,我们可以请求所有container_id值为nginx的记录:
curl 'http://logging:9200/logstash-*/_search?q=container_id:nginx' \
| jq '.'
结果与我们从 LogStash 日志中观察到的三个条目相同。同样,这些条目没有太大用处。如果这是一个生产系统,我们将得到成千上万的结果(分布在多个页面中)。
这次,让我们尝试一些真正有用的东西。我们将分析数据,举个例子,从nginx日志中提取平均响应时间:
curl 'http://logging:9200/logstash-*/_search?q=container_id:nginx' \
-d '{
"size": 0,
"aggs": {
"average_response_time": {
"avg": {
"field": "upstream_response_time"
}
}
}
}' | jq '.'
上一条命令的输出如下:
{
"aggregations": {
"average_response_time": {
"value": 0.20166666666666666
}
},
"hits": {
"hits": [],
"max_score": 0,
"total": 3
},
"_shards": {
"failed": 0,
"successful": 10,
"total": 10
},
"timed_out": false,
"took": 26
}
通过类似的请求,我们可以扩展我们的自愈系统,例如,检索服务在过去一小时的平均响应时间。如果平均响应时间较慢,我们可以进行扩容。同样,如果响应较快,我们可以进行缩容。
让我们筛选结果,只返回那些由nginx生成的、请求路径为/api/v1/books(我们服务的地址)并且是在过去一小时内生成的记录。一旦数据被筛选,我们将汇总所有结果,得到upstream_response_time字段的平均值。
你发送请求到服务通过nginx时,可能已经过去了超过一个小时。如果是这种情况,结果值会是null,因为没有任何记录符合我们将要做的筛选条件。我们可以通过发送,比如说,一百个新的请求,轻松解决这个问题:
for i in {1..100}; do
curl http://prod/api/v1/books | jq '.'
done
现在我们有了最新的数据,可以请求 ElasticSearch 给我们返回平均响应时间:
curl 'http://logging:9200/logstash-*/_search' \
-d '{
"size": 0,
"aggs": { "last_hour": {
"filter": {
"bool": { "must": [ {
"query": { "match": {
"container_id": {
"query": "nginx",
"type": "phrase"
}
} }
}, {
"query": { "match": {
"request": {
"query": "/api/v1/books",
"type": "phrase"
}
} }
}, {
"range": { "@timestamp": {
"gte": "now-1h",
"lte": "now"
} }
} ] }
},
"aggs": {
"average_response_time": {
"avg": {
"field": "upstream_response_time"
}
}
}
} }
}' | jq '.'
ElasticSearch API 和后台使用的 Lucene 引擎庞大到需要一本书来描述,因此解释超出了本书的范围。你可以在www.elastic.co/guide/en/elasticsearch/reference/current/docs.html页面找到详细信息。
请求的输出会因情况而异。我的结果如下:
{
"aggregations": {
"last_hour": {
"average_response_time": {
"value": 0.005744897959183675
},
"doc_count": 98
}
},
"hits": {
"hits": [],
"max_score": 0,
"total": 413
},
"_shards": {
"failed": 0,
"successful": 10,
"total": 10
},
"timed_out": false,
"took": 11
}
我们现在可以根据响应时间,并依据设定的规则,进行扩容、缩容或不做任何操作。现在,我们已经具备了扩展自愈系统的所有要素。我们有存储响应时间到 ElasticSearch 的过程和用于分析数据的 API。我们可以创建一个新的 Consul watch,它会定期查询 API,如果需要采取行动,就向 Jenkins 发送请求,防止故障蔓延。我将这部分留给你作为练习。
注意
练习:如果响应时间过长,则扩容服务
创建一个新的 Consul 监视器,使用我们创建的 ElasticSearch 请求,并调用 Jenkins 任务,如果平均响应时间过长,则扩展服务。如果响应时间过短,并且运行了两个以上实例,则进行缩容(少于两个实例存在停机风险)。
在不引入更多复杂性的情况下,我们可以尝试其他类型的未来预测。例如,我们可以通过观察前一天的数据来预测未来。
注意
练习:通过观察过去来预测未来
重复上一个练习的过程,进行不同的分析。
变量:
-
T:当前时间
-
AVG1:前一天 T 到 T+1 小时之间的平均流量。
-
AVG2:前一天 T+1 小时到 T+2 小时之间的平均流量。
任务:
-
计算
AVG1和AVG2之间流量的增减。 -
决定是否进行扩容、缩容,或者什么都不做。
我们不需要仅仅依赖前一天的数据来进行分析。我们还可以评估前一周的同一天、上个月的同一天,甚至是去年的同一天。每个月的第一天我们的网站流量是否增加?去年圣诞节发生了什么?暑假过后,人们是否更少访问我们的商店?美妙之处在于,我们不仅拥有回答这些问题的数据,而且还可以将分析整合到系统中,并定期运行。
请记住,有些分析最好作为 Consul 监视器来运行,而其他则属于 Jenkins 任务。那些需要定期以相同频率运行的任务适合使用 Consul。虽然它们也可以轻松地从 Jenkins 运行,但 Consul 更轻量,资源占用更少。例如,每小时或每 5 分钟运行一次。另一方面,Consul 没有合适的调度程序。如果你希望在特定的时间点运行分析,Jenkins 及其类似 cron 的调度程序更为合适。例如,每天午夜、每个月的第一天、圣诞节前两周等等。你应该根据具体情况评估这两种工具,并选择更合适的一个。另一种选择是将所有此类分析都从 Jenkins 运行,这样你可以将一切集中在一个地方。或者,你可能会选择一整套不同的工具。我将这个选择留给你。重要的是理解整个过程以及我们想要实现的目标。
请注意,我提供了一个可以用作自愈过程的示例。响应时间分析不必是我们唯一要做的事情。看看你可以收集的数据,决定哪些是有用的,哪些没有用,然后进行其他类型的数据处理。收集你需要的一切,但不要过多。不要陷入存储所有能想到的数据而不使用它的陷阱。那样会浪费内存、CPU 和硬盘空间。别忘了设置一个定期清理数据的过程。你不需要一年前的所有日志。天哪,你可能连一个月前的日志都不需要。如果一个问题在三十天内没有被发现,那很可能是没有问题,甚至即使有,也是与不再运行的旧版本相关的。如果在读完这本书后,你的发布周期仍然持续几个月,而且你不打算缩短它,那我真是彻底失败了。请不要给我发电子邮件确认这点,这只会让我感到沮丧。
这只是从本章(日志记录与监控)主旨的一个短暂岔路。由于本书主要基于实践示例,我无法在没有数据的情况下解释基于历史响应时间的自愈。因此,这段讨论被加在这里。在本章的其余部分,将会有至少一次涉及可能本应属于第十五章,自愈系统章节的内容。现在,我们回到日志记录与监控的主题。
由于我们拥有表示集群过去和当前状态的所有信息,我们可以……在这一刻,我想象你,亲爱的读者,正在翻白眼并低声抱怨,认为软件日志并不能构成集群的完整信息。只有软件(日志)和硬件数据(指标)结合起来,才能接近关于集群的完整信息。再说一次,我的想象力可能并不(而且经常不)代表现实。你可能没有翻白眼,甚至没有注意到硬件数据的缺失。
如果是这样,你没有仔细阅读我写的内容,应该好好休息一下,或者,至少去喝杯咖啡。说实话,我们确实在 Consul 中有硬件信息,但那只是当前状态。我们无法分析这些数据,看到趋势,了解发生了什么,也无法预测未来。如果你还没入睡,我们来看看如何记录硬件状态。
在我们继续之前,我们将移除当前正在运行的 LogStash 实例,并退出生产节点:
docker -H tcp://logging:2375 \
rm -f logstash
exit
记录硬件状态
当你开始学习计算机操作时,最先教你的一件事就是软件运行在硬件上。没有硬件,软件无法运行;没有软件,硬件也没有用。由于它们相互依赖,任何收集系统信息的尝试都需要包括这两者。我们已经探索了一些收集软件数据的方法,接下来的步骤是尝试用硬件实现类似的结果。
我们需要一个工具来收集其运行系统的统计信息,并且具有将这些信息发送到 LogStash 的灵活性。一旦我们找到并部署了这样的工具,我们就可以开始使用它提供的统计数据,找出过去和现在的性能瓶颈,并预测未来的系统需求。由于 LogStash 将把从该工具接收到的信息发送到 ElasticSearch,我们可以创建公式,从而执行性能分析和容量规划。
其中一个工具是 CollectD。它是一个免费的开源项目,使用 C 语言编写,使其性能高效且具有很强的可移植性。它可以轻松处理成千上万的数据集,并且自带超过 90 个插件。
幸运的是,LogStash 有一个 CollectD 输入插件,我们可以使用它通过 UDP 端口接收事件。我们将使用(roles/logstash/files/syslog-collectd.conf)[github.com/vfarcic/ms-lifecycle/blob/master/ansible/roles/logstash/files/syslog-collectd.conf]文件来配置 LogStash 以接收CollectD输入。它是(roles/logstash/files/syslog.conf)[https://github.com/vfarcic/ms-lifecycle/blob/master/ansible/roles/logstash/files/syslog.conf]的副本,增加了一个输入定义。让我们来看一下它的input部分:
input {
syslog {
type => syslog
port => 25826
}
udp {
port => 25827
buffer_size => 1452
codec => collectd { }
type => collectd
}
}
正如你所见,我们所做的只是添加了一个新的输入,该输入监听 UDP 端口25827,设置了缓冲区大小,定义了应使用collectd编解码器,并添加了一个名为 type 的新字段。通过 type 字段的值,我们可以将syslog日志与来自collectd的日志区分开来。
让我们运行将配置logging服务器并安装 LogStash 的 playbook,并将其配置为接收syslog和collectd输入:
vagrant ssh cd
ansible-playbook /vagrant/ansible/elk.yml \
-i /vagrant/ansible/hosts/prod \
--extra-vars "logstash_config=syslog-collectd.conf restore_backup=true"
你可能已经注意到使用了restore_backup变量。kibana的任务之一是通过vfarcic/elastic-dump容器恢复一个 ElasticSearch 备份,该备份包含 Kibana 仪表板的定义,稍后将讨论。备份通过包含由taskrabbit开发的elasticsearch-dump工具的vfarcic/elastic-dump容器恢复。该工具可用于创建和恢复 ElasticSearch 备份。
现在 LogStash 已配置为接受 CollectD 输入,让我们将注意力转向 prod 服务器,并安装 CollectD。我们将使用 prod5.yml 剧本,该剧本除了我们之前使用的工具外,还包含了 collectd 角色。任务定义在(roles/collectd/tasks/main.yml)[github.com/vfarcic/ms-lifecycle/tree/master/ansible/roles/collectd/tasks/main.yml] 文件中。其内容如下:
- name: Packages are installed
apt:
name: "{{ item }}"
with_items: packages
tags: ["collectd"]
- name: Configuration is copied
template:
src: collectd.conf
dest: /etc/collectd/collectd.conf
register: config_result
tags: ["collectd"]
- name: Service is restarted
service:
name: collectd
state: restarted
when: config_result|changed
tags: ["collectd"]
到这时,你可能已经可以认为自己是 Ansible 的专家,不再需要解释角色了。唯一值得评论的是 roles/collectd/files/collectd.conf 模板,它代表了 CollectD 配置。我们快速看一下:
Hostname "{{ ansible_hostname }}"
FQDNLookup false
LoadPlugin cpu
LoadPlugin df
LoadPlugin interface
LoadPlugin network
LoadPlugin memory
LoadPlugin swap
<Plugin df>
Device "/dev/sda1"
MountPoint "/"
FSType "ext4"
ReportReserved "true"
</Plugin>
<Plugin interface>
Interface "eth1"
IgnoreSelected false
</Plugin>
<Plugin network>
Server "{{ elk_ip }}" "25827"
</Plugin>
<Include "/etc/collectd/collectd.conf.d">
Filter ".conf"
</Include>
它首先通过 Ansible 变量 ansible_hostname 定义主机名,接着加载我们将使用的插件。它们的名称应该是不言自明的。最后,一些插件有额外的配置。有关配置格式、可用插件及其设置的更多信息,请参阅 collectd.org/documentation.shtml 文档。
让我们运行剧本:
ansible-playbook /vagrant/ansible/prod5.yml \
-i /vagrant/ansible/hosts/prod
现在 CollectD 正在运行,我们可以稍等几秒钟,看看 LogStash 日志:
docker -H tcp://logging:2375 \
logs logstash
其中的一些条目如下:
{
"host" => "prod",
"@timestamp" => "2016-02-04T18:06:48.843Z",
"plugin" => "memory",
"collectd_type" => "memory",
"type_instance" => "used",
"value" => 356433920.0,
"@version" => "1",
"type" => "collectd"
}
{
"host" => "prod",
"@timestamp" => "2016-02-04T18:06:48.843Z",
"plugin" => "memory",
"collectd_type" => "memory",
"type_instance" => "buffered",
"value" => 31326208.0,
"@version" => "1",
"type" => "collectd"
}
{
"host" => "prod",
"@timestamp" => "2016-02-04T18:06:48.843Z",
"plugin" => "memory",
"collectd_type" => "memory",
"type_instance" => "cached",
"value" => 524840960.0,
"@version" => "1",
"type" => "collectd"
}
{
"host" => "prod",
"@timestamp" => "2016-02-04T18:06:48.843Z",
"plugin" => "memory",
"collectd_type" => "memory",
"type_instance" => "free",
"value" => 129638400.0,
"@version" => "1",
"type" => "collectd"
}
从输出中,我们可以看到 CollectD 发送了关于内存的信息。第一个条目包含 used,第二个包含 buffered,第三个包含 cached,最后,第四个代表 free 内存。从其他插件也可以看到类似的条目。CollectD 会定期重复这个过程,从而使我们能够分析历史和近乎实时的趋势和问题。
由于 CollectD 生成了新的字段,让我们通过打开 http://10.100.198.202:5601/,导航到 Settings 屏幕,并点击 Create 按钮,重新创建索引模式。
尽管有许多理由访问 Kibana 的 Discover 屏幕查看软件日志,但很少有理由使用它来查看 CollectD 指标,因此我们将重点关注仪表板。话虽如此,即使我们不打算在此屏幕上查看硬件数据,我们仍然需要创建用于可视化的搜索。以下是一个示例搜索,它将检索所有来自 prod 主机,通过 memory 插件生成的 collectd 记录:
type: "collectd" AND host: "prod" AND plugin: "memory"
该行可以写入(或粘贴)到 Discover 屏幕上的 search 字段中,它将返回与该过滤器和屏幕右上角设定的时间匹配的所有数据。我们恢复的备份已经包含了一些可以通过右上角的 Open Saved Search 按钮打开的保存搜索。通过这些搜索,我们可以继续进行可视化操作。例如,请打开 prod-df 保存的搜索。
Kibana 仪表板由一个或多个可视化图表组成。点击 Visualize 按钮可以访问它们。当你打开 Visualize 页面时,你将看到不同类型的图表,可以选择创建一个新的可视化图表。由于我们已经恢复了一个包含我准备的几个可视化图表的备份,你可以通过点击屏幕底部的 open a saved visualization 部分来加载其中之一。请注意,这个页面只会在第一次出现,从此以后,同样的操作可以通过屏幕右上角的 Load Saved Visualization 按钮完成。继续试试 Kibana 可视化图表吧。完成后,我们将继续进入仪表板:

图 16-07 – Kibana 磁盘使用情况可视化图
仪表板可以从顶部菜单打开。我们恢复的备份包含了一个仪表板,所以我们用它来查看 CollectD 的实际情况。请点击 Dashboard 按钮,然后点击 Load Saved Dashboard 图标,选择 prod 仪表板。它将展示 prod 虚拟机内的一个(也是唯一的)CPU(prod-cpu-0)、硬盘(prod-df)和 内存(prod-memory)的使用情况。CollectD 提供了比我们使用的更多插件。随着更多数据的输入,这个仪表板可以变得更加丰富多彩,甚至更有用。
然而,尽管我们创建的仪表板活动不多,你大概可以想象它如何转变为一个不可或缺的工具,用于监控集群状态。可以为每个服务器创建一个单独的仪表板,也可以有一个展示整个集群状态的仪表板,等等:

图 16-08 – Kibana 仪表板,展示 CPU、硬盘和内存使用情况随时间变化
这就是你未来硬件监控仪表板的基础。除了查看仪表板外,我们还可以用硬件信息做些什么呢?
基于硬件数据的自愈功能
使用硬件数据进行自愈与使用软件信息一样重要。现在我们有了这两者,我们可以扩展我们的系统。既然我们已经了解了构建这样一个系统所需的所有工具和实践,就不必在硬件上下文中再逐一讲解了。相反,我将为你提供一些思路。
Consul 已经在监控硬件利用率。通过 ElasticSearch 中的历史数据,我们不仅可以预测警告阈值的到达时间(例如 80%),还可以预测其何时变得危急(例如 90%)。我们可以分析数据,看到例如,在过去的 30 天里,磁盘利用率平均增加了 0.5%,这意味着我们还有二十天的时间,直到它达到临界状态。我们还可以得出一个结论,尽管警告阈值已经达到,但这只是一次性事件,剩余空间不再收缩。
我们可以结合软件和硬件指标。仅仅依靠软件数据,我们可能会得出结论:在高峰时段,随着流量增加,我们需要扩展服务。但通过增加硬件后,我们可能会改变这个看法,因为我们意识到问题其实出在网络上,无法承载如此大的负载。
我们可以创建的分析组合是无限的,我们将创建的公式数量将随着时间和经验的积累而增长。每当我们走过一扇门,另一扇门就会打开。
最后的思考
这是我最喜欢的一章。它将我们在全书中学到的大多数实践结合在一起,形成了一个盛大的结局。几乎所有发生在服务器上的事情,无论是软件还是硬件,系统程序还是我们部署的程序,都被发送到 LogStash,然后再传送到 ElasticSearch。而且这不仅仅是一个服务器。通过将简单的rsyslog和collectd配置应用到所有节点,整个集群几乎会发送所有的日志和事件。你将知道谁做了什么,哪些进程被启动,哪些被停止。你会知道什么被添加,什么被移除。当某台服务器 CPU 不足时,你会收到警报,哪台服务器的硬盘快满了,你也会知道。当你部署或移除服务时,你将获得相关信息。你将知道容器何时进行了扩展,又何时进行了缩减。
我们创建了一个日志记录和监控系统,可以通过以下图示来描述:

图 16-09 – Kibana 仪表板,显示了 CPU、硬盘和内存使用情况随时间的变化
了解一切是一个值得追求的目标,借助我们设计的系统,你离实现这一目标又近了一步。除了了解过去和现在的一切,你还迈出了了解未来的第一步。如果你将本章中的实践与我们在第十五章,自愈系统,中学到的内容相结合,你的系统将能够从故障中恢复,而且在许多情况下,能从根本上防止故障发生。
让我们结束时做一些清理工作:
exit
vagrant destroy -f
第十七章:再见
| 你不是通过遵循规则学会走路的,而是通过实践和摔倒学会的。 | ||
|---|---|---|
| --理查德·布兰森 |
传统上,书籍的结尾会做总结。我将打破这个传统,不写总结。我们已经经历了许多实践和工具,若要总结它们,将需要相当大的篇幅。毕竟,如果到现在为止,你仍然需要一个总结来回顾你学到的东西,那只意味着你并没有像我希望的那样学到足够的东西。因此,我会觉得自己失败了,感到沮丧,甚至可能再也不写书了。
本书从未打算成为一本全面的“食谱”。我没有解释你可以用 Docker 做的所有事情,也没有展示 Ansible 背后的所有强大功能。事实上,我并没有深入讲解任何一个工具。采用这种方法将需要为每个工具单独写一本书。世界上有很多“食谱”书。我想写点不同的东西。我想写一本连接不同实践和工具之间关系的书。我想向你展示我们应用的一些流程背后的逻辑。然而,由于我本身是一个非常实践的人,我学习逻辑和流程的方式涉及大量的实践和投入。因此,本书充满了许多动手实践的示例。我认为最好的学习方式就是通过实践。我希望我达成了目标。我希望我打开了一些你可能不知道的门,或者你不知道如何跨越那些门。
让我们不要在这里结束这段旅程。让我们以更直接的方式继续前行。如果你想讨论书中的任何部分,请使用 Disqus 频道 The DevOps 2.0 Toolkit。如果你在某个地方卡住了,没能理解某个内容,或者不同意我其中某个观点(甚至是全部观点),请直接在频道中发帖。我在今天写最后几句话时创建了这个频道。问题是,没有人愿意第一个在空旷的地方发帖。我鼓励你成为第一个发帖的人。其他人会从我们的讨论中受益并加入其中。另一方面,如果你更喜欢一对一的对话,欢迎给我发邮件(<viktor@farcic.com>),通过 HangOuts 或 Skype 与我联系(我的用户名是 vfarcic),或者来巴塞罗那请我喝一杯啤酒。
如果你想获取有关本书更新的通知,请订阅邮件列表 (technologyconversations.us13.list-manage1.com/subscribe?u=a7c76fdff8ed9499bd43a66cf&id=94858868c7)。
我将继续在我的博客 www.TechnologyConversations.com 上发布文章,直到有一天,我鼓起勇气开始写一本新书。也许它会是 The DevOps 3.0 Toolkit,或者,更有可能的是,它会是完全不同的东西。时间会告诉我们。
持续学习,持续探索,持续改进你的工作方式。这是我们这个行业唯一能够前进的道路。
晚安,祝好运。
维克托·法尔奇
巴塞罗那
附录 A. Docker Flow
Docker Flow 是一个旨在创建易于使用的持续部署流程的项目。它依赖于 Docker Engine、Docker Compose、Consul 和 Registrator。这些工具已被证明能够带来价值,并且建议在任何 Docker 部署中使用。如果你读完了整本书,你应该熟悉这些工具以及我们即将探讨的过程。
该项目的目标是为当前 Docker 生态系统中缺失的功能和流程提供补充。目前,该项目解决了蓝绿部署、相对扩展以及代理服务发现与重新配置的问题。未来会增加许多附加功能。
当前的功能列表如下:
-
蓝绿部署
-
相对扩展
-
代理重新配置
最新的发布版本可以在github.com/vfarcic/docker-flow/releases/tag/v1.0.2找到。
背景
在与不同客户合作以及为本书编写示例的过程中,我意识到我最终会编写不同版本的相同脚本。有些是用 Bash 编写的,有些是用 Jenkins Pipeline 编写的,还有一些是用 Go 编写的,等等。因此,当我完成这本书时,我决定启动一个项目,涵盖我们所探讨的许多实践。结果就是 Docker Flow 项目。
标准设置
我们将从探索典型的 Swarm 集群设置开始,并讨论在将其作为集群协调器时可能遇到的一些问题。如果你已经熟悉 Docker Swarm,可以跳过这一部分,直接进入“问题”部分。
至少,Swarm 集群中的每个节点必须安装 Docker Engine 并运行 Swarm 容器。后者容器应该充当节点。在集群之上,我们需要至少一个作为主节点运行的 Swarm 容器,所有 Swarm 节点应向它宣布其存在。
Swarm master 和节点的组合是最小化的设置,在大多数情况下,这远远不够。集群的最佳利用意味着我们不再掌控一切,Swarm 会。它将决定哪个节点是运行容器的最合适位置。这个选择可以像选择一个运行容器最少的节点那样简单,也可以基于一个更复杂的计算,涉及到可用 CPU 和内存的数量、硬盘类型、亲和性等。不管我们选择什么策略,事实是我们无法知道容器会在哪里运行。此外,我们不应指定我们的服务应该暴露的端口。"硬编码"端口会降低我们扩展服务的能力,并可能导致冲突。毕竟,两个独立的进程不能监听相同的端口。简而言之,一旦我们采用 Swarm,服务的 IP 和端口将变得不可知。因此,设置 Swarm 集群的下一步是创建一个机制,用来检测已部署的服务并将其信息存储在分布式注册表中,以便轻松获取。
Registrator 是我们可以用来监控 Docker 引擎事件并将已部署或已停止的容器信息发送到服务注册表的工具之一。虽然有许多不同的服务注册表可以使用,但目前证明 Consul 是最好的。有关更多信息,请阅读《服务发现:分布式服务的关键》一章。
使用Registrator和Consul,我们可以获取运行在 Swarm 集群内的任何服务的信息。我们讨论过的设置的示意图如下:

含基本服务发现的 Swarm 集群
请注意,除了小型集群外,其他集群将会有多个 Swarm master 和 Consul 实例,从而防止在其中一个出现故障时丢失信息或造成停机。
在这种设置下,部署容器的过程如下:
-
操作员向
Swarm master发送请求,部署一个由一个或多个容器组成的服务。此请求可以通过Docker CLI发送,并通过定义DOCKER_HOST环境变量,指定Swarm master的 IP 和端口。 -
根据请求中发送的标准(如 CPU、内存、亲和性等),
Swarm master决定容器运行的位置,并向选定的Swarm nodes发送请求。 -
Swarm node在接收到运行(或停止)容器的请求时,会调用本地的Docker Engine,后者运行(或停止)所需的容器,并将结果作为事件发布。 -
Registrator监控Docker Engine,并在检测到新事件时将信息发送到Consul。
-
任何对集群内运行的容器数据感兴趣的人都可以查询Consul。
虽然与我们过去操作集群的方式相比,这个过程是一次巨大的改进,但它仍然远远不完整,并且会产生一些应该解决的问题。
问题
在本章中,我将重点介绍之前描述的设置中缺失的三个主要问题,或者更准确地说,缺失的功能。
无停机部署
当拉取新版本时,运行 docker-compose up 会停止运行旧版本的容器,并用新版本替换它们。这个方法的问题在于停机时间。在停止旧版本和运行新版本之间,会有停机时间。无论是毫秒级别还是整整一分钟,新的容器都需要启动,并且其中的服务需要初始化。
我们可以通过设置带有健康检查的代理来解决这个问题。然而,这仍然需要运行多个服务实例(因为你绝对应该这么做)。过程是停止一个实例,并将新版本部署到其位置。在该实例的停机期间,代理会将请求重定向到其他实例中的一个。然后,当第一个实例运行新版本并且其中的服务初始化完成后,我们会继续对其他实例重复这一过程。这个过程可能会变得非常复杂,并且会阻止你使用 Docker Compose 的 scale 命令。
更好的解决方案是使用 蓝绿 部署过程来部署新版本。如果你不熟悉它,请阅读第十三章,蓝绿 部署。简而言之,过程是将新版本与旧版本并行部署。在整个过程中,代理应该继续将所有请求发送到旧版本。一旦部署完成并且容器内的服务初始化完成,代理应重新配置以将所有请求发送到新版本,而旧版本可以停止。通过这种方式,我们可以避免停机。问题是 Swarm 不支持 蓝绿 部署。
使用相对数字扩展容器
Docker Compose 使得将服务扩展到固定数量变得非常容易。我们可以指定要运行多少个容器实例,并观察魔法的展开。当与 Docker Swarm 结合使用时,结果是管理集群中容器的简单方法。根据当前正在运行的实例数量,Docker Compose 会增加(或减少)运行中的容器数量,以便实现预期结果。
问题在于 Docker Compose 总是期望参数是一个固定的数字。当处理生产环境部署时,这会非常有限制。在许多情况下,我们并不希望知道已经运行了多少实例,而是希望通过某种因素发出信号来增加(或减少)容量。例如,我们可能会遇到流量增加的情况,并希望将容量增加三个实例。类似地,如果某个服务的需求下降,我们可能希望减少运行的实例数量,并以此释放资源供其他服务和进程使用。当我们朝着自主和自动化的第十三章、自愈系统迈进时,这种需求更加明显,人工干预降到最低。
除了缺乏相对扩展外,Docker Compose 还不知道如何在部署新容器时保持相同数量的运行实例。
新版本测试后的代理重新配置
一旦我们采用微服务架构,代理的动态重新配置需求便变得显而易见。容器让我们能够将它们打包为不可变的实体,而 Swarm 使我们能够将它们部署到集群中。通过容器和像 Swarm 这样的集群编排工具实现不可变性,极大地推动了微服务的兴趣和采用,同时也增加了部署的频率。与强制我们不频繁部署的单体应用不同,现在我们可以更频繁地部署。即使你不采用持续部署(每次提交都会发布到生产环境),你也可能开始更频繁地部署你的微服务。可能是每周一次,每天一次,甚至一天多次。无论频率如何,每次发布新版本时,都需要重新配置代理。Swarm 会将容器部署到集群中的某个地方,代理需要重新配置以将请求重定向到所有新版本的实例。这种重新配置需要是动态的。这意味着必须有一个进程从服务注册表中获取信息,改变代理的配置,并最终重新加载它。
解决这个问题有几种常用的方法。
由于显而易见的原因,手动代理重新配置应该被摒弃。频繁的部署意味着操作员没有时间手动更改配置。即使时间不是关键,手动重新配置也会将“人为因素”引入到过程中,而我们都知道人类会犯错。
有许多工具可以监控 Docker 事件或注册表中的条目,并在新容器启动或旧容器停止时重新配置代理。这些工具的问题在于,它们没有给我们足够的时间来测试新版本。如果出现 bug 或功能尚未完全完成,用户将受到影响。代理的重新配置应该仅在一系列测试运行并验证新版本之后进行。
我们可以在部署脚本中使用 Consul Template 或 ConfD 等工具。它们都非常好用,效果也不错,但在完全融入部署过程中之前,需要做很多配置工作。
解决问题
Docker Flow 是解决我们讨论过的问题的项目。它的目标是提供当前 Docker 生态系统中尚不可用的功能。它并不取代生态系统中的任何功能,而是建立在其基础之上。
Docker Flow 使用指南
接下来的示例将使用 Vagrant 来模拟 Docker Swarm 集群。这并不意味着 Docker Flow 的使用仅限于 Vagrant。你可以在任何其他方式设置的单个 Docker 引擎或 Swarm 集群中使用它。
关于基于 Docker Machine 的类似示例(在 Linux 和 OS X 上测试过),请阅读项目(github.com/vfarcic/docker-flow)。
设置过程
在开始示例之前,请确保已安装 Vagrant。你不需要其他任何东西,因为我们即将运行的 Ansible playbooks 会确保所有工具都已正确配置。
请从 vfarcic/docker-flow 仓库克隆代码:
git clone https://github.com/vfarcic/docker-flow.git
cd docker-flow
下载代码后,我们可以运行 Vagrant 并创建本章中将使用的集群:
vagrant plugin install vagrant-cachier
vagrant up master node-1 node-2 proxy
一旦虚拟机(VM)创建并配置完成,设置过程将与本章中标准设置部分所述相同。master 服务器将包含 Swarm master,而节点 1 和 2 将组成集群。每个节点将有指向 proxy 服务器中运行的 Consul 实例的 Registrator:

通过 Vagrant 设置 Swarm 集群
请注意,这个设置仅用于演示目的。虽然在生产环境中应应用相同的原则,但你应该确保有多个 Swarm 主节点和 Consul 实例,以避免其中一个失败时可能带来的停机时间。
一旦 vagrant up 命令完成,我们可以进入 proxy 虚拟机,看到Docker Flow的实际运行:
vagrant ssh proxy
我们将从 proxy 机器运行所有示例。然而,在生产环境中,你应该从一台单独的机器(甚至是你的笔记本电脑)运行部署命令。
最新版本的 docker-flow 二进制文件已下载并准备好使用,且 /books-ms 目录包含了我们将在接下来的示例中使用的 docker-compose.yml 文件。
让我们进入目录:
cd /books-ms
部署后重新配置 Proxy
Docker Flow 需要 Consul 实例的地址以及代理正在(或将要)运行的节点信息。它提供了三种方式来提供必要的信息。我们可以在docker-flow.yml文件中定义参数、作为环境变量,或作为命令行参数。在这个示例中,我们将使用这三种输入方法,这样你可以熟悉它们并选择适合你需求的组合。
让我们从通过环境变量定义代理和 Consul 数据开始:
export FLOW_PROXY_HOST=proxy
export FLOW_CONSUL_ADDRESS=http://10.100.198.200:8500
export FLOW_PROXY_DOCKER_HOST=tcp://proxy:2375
export DOCKER_HOST=tcp://master:2375
export BOOKS_MS_VERSION=":latest"
FLOW_PROXY_HOST变量是代理运行所在主机的 IP,而FLOW_CONSUL_ADDRESS代表 Consul API 的完整地址。FLOW_PROXY_DOCKER_HOST是 Docker 引擎的主机,运行在代理容器(或将要运行的代理容器)所在的服务器上。最后一个变量(DOCKER_HOST)是Swarm master的地址。Docker Flow 旨在同时在多个服务器上执行操作,因此我们需要提供所有它所需的信息,以便它能够完成任务。在我们正在探索的示例中,它将部署容器到 Swarm 集群,使用 Consul 实例来存储和检索信息,并在每次部署新服务时重新配置代理。最后,我们将环境变量BOOKS_MS_VERSION设置为latest。docker-compose.yml使用它来确定我们要运行的版本。
现在我们准备部署我们示例服务的第一个版本:
docker-flow \
--blue-green \
--target=app \
--service-path="/api/v1/books" \
--side-target=db \
--flow=deploy --flow=proxy
我们指示docker-flow使用蓝绿部署流程,并且目标(在docker-compose.yml中定义)是app。我们还告诉它该服务在/api/v1/books地址上公开 API,并且它需要一个副目标db。最后,通过--flow参数,我们指定希望它部署目标并重新配置proxy。在那个单一的命令中发生了很多事情,所以我们将更详细地探讨结果。
让我们看看我们的服务器,看看发生了什么。我们从 Swarm 集群开始:
docker ps --format "table {{.Names}}\t{{.Image}}"
ps命令的输出如下:
NAMES IMAGE
node-2/dockerflow_app-blue_1 vfarcic/books-ms
node-1/books-ms-db mongo
...
Docker Flow 将我们的主要目标app与名为 books-ms-db 的副目标一起运行。两个目标都在docker-compose.yml中定义。容器的名称取决于许多不同的因素,其中一些因素是 Docker Compose 项目(默认使用当前目录,如app目标的情况)或可以通过docker-compose.yml中的container_name参数来指定(如db目标)。你会注意到的第一个区别是Docker Flow在容器名称中添加了blue。其背后的原因是--blue-green参数。如果存在,Docker Flow将使用blue-green流程来运行主要目标。由于这是第一次部署,Docker Flow决定它将被称为blue。如果你不熟悉这个过程,请阅读第十三章,以了解有关蓝绿部署的一般信息。
让我们也看一下 proxy 节点:
export DOCKER_HOST=tcp://proxy:2375
docker ps --format "table {{.Names}}\t{{.Image}}"
ps 命令的输出如下:
NAMES IMAGE
docker-flow-proxy vfarcic/docker-flow-proxy
consul progrium/consul
Docker Flow 检测到该节点上没有 proxy,并为我们运行了它。docker-flow-proxy 容器包含了 HAProxy,以及每次运行新服务时重新配置它的自定义代码。有关 Docker Flow: Proxy 的更多信息,请阅读该项目(github.com/vfarcic/docker-flow-proxy)。
由于我们指示 Swarm 在集群中的某个地方部署该服务,我们无法预先知道将选择哪个服务器。在这个特定的案例中,我们的服务最终运行在了 node-2 上。此外,为了避免潜在的冲突并允许更容易的扩展,我们没有指定服务应该暴露哪个端口。换句话说,服务的 IP 和端口在部署前没有定义。除此之外,Docker Flow 通过运行 Docker Flow: Proxy 并指示它在容器运行后使用收集到的信息重新配置自己来解决这个问题。我们可以通过向新部署的服务发送 HTTP 请求来确认代理的重新配置是否成功:
curl -I proxy/api/v1/books
curl 命令的输出如下:
HTTP/1.1 200 OK
Server: spray-can/1.3.1
Date: Thu, 07 Apr 2016 19:23:34 GMT
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=UTF-8
Content-Length: 2
事件流如下:
-
Docker Flow 检查了 Consul,以确定接下来应该部署哪个版本(蓝色或绿色)。由于这是第一次部署且没有版本在运行,因此决定将其部署为蓝色版本。
-
Docker Flow 向 Swarm Master 发送了部署蓝色发布的请求,Swarm Master 决定在 node-2 上运行容器。Registrator 检测到了 Docker 引擎创建的新事件,并在 Consul 中注册了服务信息。同样,部署侧目标数据库的请求也被发送。
-
Docker Flow 从 Consul 获取了服务信息。
-
Docker Flow 检查了应该托管代理的服务器,发现该服务器没有运行,然后进行了部署。
-
Docker Flow 更新了 HAProxy,并加入了服务信息。
![部署后重新配置代理]()
通过 Docker Flow 的第一次部署
尽管我们的服务运行在 Swarm 选择的服务器之一,并且暴露了一个随机端口,代理已经重新配置,我们的用户可以通过固定的 IP 访问它,而且不需要端口(更精确地说,通过标准的 HTTP 端口 80 或 HTTPS 端口 443)。

用户可以通过代理访问服务。
让我们看看当第二个版本被部署时会发生什么。
无停机时间的发布新版本
一段时间后,开发者将推送新的提交,我们将希望部署服务的新版本。为了避免停机,我们将继续使用 蓝绿 部署流程。由于当前版本是 蓝色,新的版本将命名为 绿色。通过使新版本(绿色)与旧版本(蓝色)并行运行,在新版本完全启动后,我们会重新配置代理,将所有请求发送到新版本。只有在代理重新配置完成后,我们才希望停止旧版本的运行并释放其占用的资源。我们可以通过运行相同的 docker-flow 命令来完成所有这些操作。不过,这次我们将利用已经包含一些之前使用过的参数的 docker-flow.yml 文件。
docker-flow.yml 的内容如下:
target: app
side_targets:
- db
blue_green: true
service_path:
- /api/v1/books
让我们运行新版本:
export DOCKER_HOST=tcp://master:2375
docker-flow \
--flow=deploy --flow=proxy --flow=stop-old
就像之前一样,让我们探索 Docker 进程并查看结果:
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
ps命令的输出如下:
NAMES IMAGE STATUS
node-1/booksms_app-green_1 vfarcic/books-ms Up 33 seconds
node-2/booksms_app-blue_1 vfarcic/books-ms Exited (137) 22 seconds ago
node-1/books-ms-db mongo Up 41 minutes
...
从输出中,我们可以看到新版本(绿色)正在运行,旧版本(蓝色)已停止。旧版本之所以仅被停止而不是完全移除,是因为在稍后发现问题时可能需要快速回滚。
让我们确认代理是否也已经重新配置:
curl -I proxy/api/v1/books
curl命令的输出如下:
HTTP/1.1 200 OK
Server: spray-can/1.3.1
Date: Thu, 07 Apr 2016 19:45:07 GMT
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=UTF-8
Content-Length: 2
事件的流程如下:
-
Docker Flow 检查了 Consul,以找出应该部署下一个版本(蓝色或绿色)。由于之前的版本是蓝色,它决定将其作为绿色版本进行部署。
-
Docker Flow 向 Swarm Master 发送请求部署绿色版本,Swarm Master 决定在 node-1 上运行该容器。Registrator 检测到 Docker Engine 创建的新事件,并将服务信息注册到 Consul 中。
-
Docker Flow 从 Consul 获取了服务信息。
-
Docker Flow 更新了 HAProxy 的服务信息。
-
Docker Flow 停止了旧版本。
![无停机时间部署新版本]()
通过 Docker Flow 进行的第二次部署
在流程的前三个步骤中,HAProxy 继续将所有请求发送到旧版本。因此,用户并未察觉到部署正在进行:

在部署过程中,用户继续与旧版本进行交互
只有在部署完成后,HAProxy 才被重新配置,用户才被重定向到新版本。因此,部署没有造成停机:

部署完成后,用户被重定向到新版本
现在我们有了一种安全的方式来部署新版本,让我们将注意力转向相对扩展。
扩展服务
Docker Compose 提供的一个巨大优势是可扩展性。我们可以使用它来扩展到任意数量的实例。然而,它只支持绝对扩展。我们无法指示 Docker Compose 执行相对扩展。这使得某些过程的自动化变得困难。例如,可能会有流量增加的情况,要求我们将实例数量增加两个。在这种情况下,自动化脚本需要获取当前运行的实例数量,进行简单的数学计算以得到所需的数量,并将结果传递给 Docker Compose。除此之外,代理仍然需要重新配置。Docker Flow 使这个过程变得更加容易。
让我们看看它是如何运作的:
docker-flow \
--scale="+2" \
--flow=scale --flow=proxy
通过列出当前运行的 Docker 进程,可以观察到扩展结果:
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
ps 命令的输出如下:
NAMES IMAGE STATUS
node-2/booksms_app-green_2 vfarcic/books-ms:latest Up 5 seconds
node-1/booksms_app-green_3 vfarcic/books-ms:latest Up 6 seconds
node-1/booksms_app-green_1 vfarcic/books-ms:latest Up 40 minutes
node-1/books-ms-db mongo Up 53 minutes
实例数量增加了两个。在之前只有一个实例运行的情况下,现在我们有三个实例。
同样,代理也进行了重新配置,从现在开始,它将在这三个实例之间进行负载均衡所有请求。
事件的流程如下:
-
Docker Flow 检查了 Consul,找出了当前正在运行的实例数量。
-
由于只有一个实例在运行,并且我们指定要将该数量增加两个,Docker Flow 向 Swarm Master 发送请求,将绿色发布扩展到三个实例,Swarm Master 决定在 node-1 上运行一个容器,在 node-2 上运行另一个容器。Registrator 检测到 Docker Engine 创建的新事件,并将两个新实例注册到 Consul 中。
-
Docker Flow 从 Consul 中获取了服务信息。
-
Docker Flow 更新了 HAProxy 的服务信息,并将其配置为在三个实例之间执行负载均衡。
![Scaling the service]()
通过 Docker Flow 实现相对扩展
从用户的角度来看,他们继续从当前版本接收响应,但这一次,他们的请求在所有服务实例之间进行负载均衡。因此,服务性能得到了提升:

用户请求在所有服务实例之间进行负载均衡。
我们可以使用相同的方法,通过在--scale参数值前加上减号(-)来减少实例数量。按照相同的例子,当流量恢复正常时,我们可以通过运行以下命令将实例数量恢复到原来的数量:
docker-flow \
--scale="-1" \
--flow=scale --flow=proxy
测试生产环境部署
到目前为止,我们运行的代理示例的主要缺点是无法在重新配置代理之前验证发布版本。理想情况下,我们应该使用 blue-green 过程,将新版本与旧版本并行部署,运行一系列验证一切正常的测试,最后只有在所有测试成功的情况下才重新配置代理。我们可以通过运行 docker-flow 两次轻松实现这一目标。
许多工具旨在提供零停机时间的部署,但只有少数工具(如果有的话)考虑到在重新配置代理之前应该运行一系列测试。
首先,我们应该部署新版本:
docker-flow \
--flow=deploy
列出 Docker 进程:
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
ps命令的输出如下:
node-1/booksms_app-blue_2 Up 8 minutes 10.100.192.201:32773->8080/tcp
node-2/booksms_app-blue_1 Up 8 minutes 10.100.192.202:32771->8080/tcp
node-2/booksms_app-green_2 Up About an hour 10.100.192.202:32770->8080/tcp
node-1/booksms_app-green_1 Up 2 hours 10.100.192.201:32771->8080/tcp
node-1/books-ms-db Up 2 hours 27017/tcp
此时,新版本(蓝色)与旧版本(绿色)并行运行。由于我们没有指定--flow=proxy参数,代理保持不变,仍然将请求重定向到旧版本的所有实例。这意味着我们的服务用户仍然看到旧版本,而我们则有机会进行测试。我们可以进行集成测试、功能测试或任何其他类型的测试,并验证新版本确实符合我们的预期。虽然在生产环境中进行测试并不排除在其他环境(例如预发布环境)中进行测试,但这种方法通过能够在用户将使用相同环境下验证软件,从而给予我们更高的信任,同时,在此过程中不影响用户(他们仍然不知道新版本的存在)。
注意
请注意,尽管我们没有指定应部署的实例数量,Docker Flow仍然部署了新版本并将其扩展到与之前相同的实例数量。
事件的流程如下:
-
Docker Flow 检查 Consul 以查找当前版本的颜色以及当前正在运行的实例数量。
-
由于旧版本(绿色)有两个实例在运行,并且我们没有指定要更改该数量,Docker Flow 向Swarm Master发送请求,部署新版本(蓝色)并将其扩展到两个实例。
![部署测试到生产环境]()
无需重新配置代理的部署
从用户的角度来看,由于我们没有指定要重新配置代理,他们继续收到旧版本的响应:

用户请求仍然被重定向到旧版本
从这一刻起,你可以在生产环境中对新版本进行测试。假设你没有过度负载服务器(例如压力测试),测试可以在任何时间段内运行而不影响用户。
测试执行完成后,我们有两条路径可选择。如果某个测试失败,我们可以停止新版本并修复问题。由于代理仍然将所有请求重定向到旧版本,用户不会受到影响,我们可以专注于解决问题。另一方面,如果所有测试都成功,我们可以运行剩余的flow,重新配置代理并停止旧版本:
docker-flow \
--flow=proxy --flow=stop-old
该命令重新配置了代理并停止了旧版本。
事件的流程如下:
-
Docker Flow 检查 Consul 以查找当前版本的颜色以及正在运行的实例数量。
-
Docker Flow 更新了代理服务的信息。
-
Docker Flow 停止了旧版本。
![测试部署到生产环境]()
代理重新配置而无需部署。
从用户的角度来看,所有新的请求都被重定向到了新版本:

用户请求被重定向到了新版本。
这就是对Docker Flow一些功能进行快速浏览的结尾。请查看使用部分以获取更多详细信息。











浙公网安备 33010602011771号