Java-开发者的-DevOps-工具-全-

Java 开发者的 DevOps 工具(全)

原文:zh.annas-archive.org/md5/a4bac036332585d145cfa15fc921b7c0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在这本书被写作的时候,世界因为一个世纪以来最大的大流行病而发生了巨大变化。然而,随着软件行业采用 DevOps 和云原生开发来处理加速的软件交付速度,本书的价值也变得前所未有。

我们按照生命周期、复杂性和成熟度的增量顺序组织了本书的主题。但是,DevOps 是一个足够广泛的旅程,您可能会发现某些章节对您的项目需求更为相关。因此,我们设计了章节,以便您可以任意顺序开始,并专注于您需要专业知识、示例和最佳实践的特定主题,以提升您的知识水平。

我们希望您享受阅读本书的乐趣,就像我们享受整理内容一样。我们唯一的要求是与朋友或同事分享您新发现的知识,以便我们所有人都能成为更好的开发者。

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序列表,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户需要按照字面意思键入的命令或其他文本。

常量宽度斜体

显示应由用户提供值或根据上下文确定值替换的文本。

Tip

这个元素表示一个提示或建议。

注意

这个元素表示一般注意事项。

警告

这个元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可以从https://github.com/devops-tools-for-java-developers下载。

如果您有技术问题或在使用示例代码时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书的目的是帮助您完成工作。一般来说,如果本书提供示例代码,您可以在自己的程序和文档中使用它们。除非您复制了大量代码,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。通过引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码合并到您产品的文档中需要许可。

我们感激您的认可,尽管通常并不要求署名。署名通常包括书名、作者、出版商和 ISBN。例如:“DevOps Tools for Java Developers by Stephen Chin, Melissa McKay, Ixchel Ruiz, and Baruch Sadogursky (O’Reilly)。版权 2022 Stephen Chin, Melissa McKay, Ixchel Ruiz, and Baruch Sadogursky,978-1-492-08402-0。”

如果您认为您对代码示例的使用超出了公平使用或上述许可,请随时通过 permissions@oreilly.com 与我们联系。

致谢

没有我们家人和朋友的支持,这本书将无法完成。当我们全情投入地编写这本书时,他们帮助照顾我们所爱的人和个人健康,以便我们能够提供涵盖整个 DevOps 和 Java 生态系统所需的广泛内容。

还要特别感谢 JFrog 的首席执行官 Shlomi Ben Haim。当其他公司收缩并紧缩预算时,Shlomi 个人支持了这本书,并赋予了我们专注于涵盖这一极其广泛主题的紧张任务的自由和灵活性。

特别感谢 Ana-Maria Mihalceanu,对第八章的贡献,以及 Sven Ruppert,对第七章的贡献。

我们要感谢我们的技术审阅者 Daniel Pittman,Cameron Pietrafeso,Sebastian Daschner 和 Kirk Pepperdine,为提升本书的准确性做出贡献。

最后,特别感谢您,读者,您已经采取了主动行动,提升了整个 DevOps 流水线的知识,并成为自动化、流程和文化改进的推动者。

第一章:DevOps 针对(或可能反对)开发人员

巴鲁克·萨多古斯基

当你在这里打呼噜时,

睁眼的阴谋占据他的时间。

如果你关心生活,

摆脱沉睡,小心:

醒来,醒来!

威廉·莎士比亚,《暴风雨》

有些人可能会问,DevOps 运动是否只是针对开发人员的运维方面的阴谋。大多数人(如果不是全部的话)这样做并不期望得到严肃的回答,至少因为他们把这个问题当作开玩笑。这也是因为——不管你的起源是开发还是运维方面——当有人谈论 DevOps 时,大约 60 秒后会有人询问,“但是 DevOps 真的是什么?”

而且你会想,自从这个术语被创造出来以来的 11 年时间内(在这个期间,行业专业人员已经讨论、辩论和喧嚷过它),我们都应该已经达成了一个标准、毫无废话、共同理解的定义。但事实并非如此。事实上,尽管企业对 DevOps 人员的需求呈指数级增长,但几乎可以肯定地说,随机选择的五个 DevOps 职称的员工中,没有一个人能精确地告诉你 DevOps 是什么。

因此,如果在谈到这个话题时你仍然感到困惑,不要感到尴尬。概念上,DevOps 可能不容易理解,但也不是不可能。

但无论我们如何讨论这个术语或者我们可能同意的定义是什么,有一件事,比其他一切都更重要,那就是必须牢记:DevOps 是一个完全虚构的概念,而发明者来自运维方面。

DevOps 是由运维方面创造的概念

我关于 DevOps 的假设可能会引起争议,但也可以证明。让我们从事实开始。

陈列品 1:《凤凰项目》

《凤凰项目》 由吉恩·金等人(IT 革命)出版近十年来成为经典之作。这不是一本传统意义上的操作手册。这是一部小说,讲述了一个问题重重的公司及其突然被分配任务的 IT 经理,要实施一个已经超出预算和拖延了数月的至关重要的公司倡议的故事。

如果你生活在软件领域,那么这本书的其他主要人物对你来说应该很熟悉。不过,现在让我们来看看他们的专业头衔:

  • IT 服务支持总监

  • 分布式技术总监

  • 零售销售经理

  • 主系统管理员

  • 首席信息安全官

  • 首席财务官

  • 首席执行官

注意它们之间的联系了吗?他们是有史以来关于 DevOps 最重要的书籍之一的主人公,而且其中一个也不是开发人员。即使开发人员确实出现在情节中,嗯...我们只能说他们的表现并不是特别抢眼。

当胜利到来时,故事的英雄(连同一个支持的董事成员)发明了 DevOps,拯救了项目的失败,扭转了公司的命运,并被提升为企业的首席信息官(CIO)。每个人都过得很幸福——即使不是永远,至少是在这种成功通常会给你带来两三年时间之前,重新证明自己的价值之前。

陈列 2:《DevOps 手册》

最好在 Gene Kim 等人的《DevOps 手册》(IT Revolution)之前阅读《凤凰项目》,因为前者可以将你置于一个高度真实的、人性化的场景中。你可以轻易地沉浸在人物类型、专业困境和人际关系中。DevOps 的实现方式和原因会像是对一系列情况的必然和理性回应,这些情况本可以导致业务崩溃。故事中的赌局、人物和他们所做的选择似乎都非常合理。可能很容易与你自己的经验进行类比。

《DevOps 手册》允许你更深入地探索 DevOps 原则和实践的各个概念部分。正如其副标题所示,这本书在解释“如何在技术组织中创建世界一流的灵活性、可靠性和安全性”方面走得很远。但这是否应该是关于开发的?是否应该或不应该可能存在争议。无可辩驳的是,这本书的作者们都是聪明、超级有才华的专业人士,可以说是 DevOps 的奠基人。然而,陈列 2 并非在这里赞扬他们,而是要仔细审视他们的背景。

让我们从吉恩·金(Gene Kim)开始。他创立了软件安全和数据完整性公司 Tripwire,并担任首席技术官(CTO)长达十多年。作为一名研究者,他致力于研究和理解大型复杂企业和机构中正在发生和已经发生的技术变革。除了合著《凤凰项目》外,他还于 2019 年合著了《独角兽项目》(稍后我会详细说说)。他的整个职业生涯都深深植根于运维。即使《独角兽》说它是“关于开发者”,它仍然是从运维人员的角度来看待开发者!

至于《手册》的其他三位作者:

  • 杰兹·汉布尔(Jez Humble)曾担任过包括站点可靠性工程师(SRE)、首席技术官(CTO)、交付架构和基础设施服务副主任以及开发者关系的职位。一个运维人员!尽管他的最后一个头衔提到开发,工作并不是关于那个。它是关于与开发者的关系。它是关于缩小开发与运维之间的鸿沟,关于这些他已经广泛地写作、教学和演讲。

  • 帕特里克·德博伊斯曾担任首席技术官、市场战略总监和 Dev♥Ops 关系总监(心形是他添加的)。他将自己描述为一个专业人士,通过在开发、项目管理和系统管理中使用敏捷技术来“弥合项目和运维之间的鸿沟”。这确实听起来像一个运维人员。

  • 截至目前为止,约翰·威利斯担任 DevOps 和数字实践副总裁。此前,他曾担任生态系统开发总监、解决方案副总裁,特别是在 Opscode(现在被称为 Progress Chef)担任过培训与服务副总裁。尽管约翰的职业生涯更深入地涉及开发,但他的大部分工作都与运维有关,特别是他将注意力集中在打破曾经将开发人员和运维人员分为独立阵营的壁垒上。

正如你所看到的,所有的作者都有运维背景。巧合吗?我认为 不是

还不确定 DevOps 是由运维驱动的?那我们来看看今天试图向我们推销 DevOps 的领导人吧。

谷歌搜索

截至目前为止,如果你在谷歌搜索中键入“什么是 DevOps?”只是想看看会出现什么,你的第一页结果很可能包括以下内容:

  • 敏捷管理,一个系统管理公司

  • Atlassian,其产品包括项目和问题跟踪、列表制作以及团队协作平台

  • 亚马逊网络服务(AWS)、微软 Azure 和 Rackspace Technology,它们都销售云运维基础设施

  • Logz.io,销售日志管理和分析服务

  • New Relic,其专业是应用程序监控

所有这些都非常关注运维。是的,第一页包含了一个稍微偏向开发的公司和另一个与搜索无直接关系的公司。重点是,当你试图寻找 DevOps 时,大部分内容都倾向于运维。

它是做什么的?

DevOps 一种事物!它 非常 受欢迎。因此,许多人会想要明确知道,DevOps 什么,它实质性地产生了什么。而不是深入探讨这一点,让我们从结构上看待它,将其概念化,就像你会看待侧向的、八字形的无限符号一样。在这种光线下,我们看到一个从编码到构建再到测试再到发布再到部署再到运维再到监控的流程循环,然后再回到开始计划新功能的过程,如 图 1-1 所示。

一个无限符号的图像,分为 8 个 DevOps 步骤

图 1-1. DevOps 无限循环

如果这对某些读者看起来很熟悉,那是因为它与敏捷开发周期有概念上的相似性(图 1-2)。

一个鸡块的图像,分为 6 个敏捷步骤

图 1-2. 敏捷开发周期

这两个永无止境的故事没有根本的差异,除了运维人员将自己嫁接到敏捷圈的旧世界之外,本质上将其分成两个圈子,并将其关注和痛苦塞入曾被认为只属于开发人员的领域。

行业现状

自 2014 年以来,进一步证明 DevOps 是一种由运维驱动的现象,已经打包成了一份易于阅读的年度摘要,其中包含了全球数万名行业专业人士和组织收集、分析和总结的数据。《加速:DevOps 现状》报告主要由 DevOps 研究与评估(DORA)完成,是软件行业中最重要的文献,用来衡量 DevOps 现状及其未来发展方向。例如,在2018 年版中,我们可以看到对以下问题的严肃关注:

  • 组织多频繁部署代码?

  • 从代码提交到成功运行生产环境通常需要多长时间?

  • 发生故障或停机时,通常需要多长时间恢复服务?

  • 部署变更的百分比导致服务降级或需要补救?

请注意,所有这些都是非常侧重于运维的关注点。

什么构成工作?

现在让我们来看一下《加速:DevOps 现状》报告和《凤凰项目》如何定义工作。首先,计划的工作侧重于业务项目和新功能,涵盖了运维和开发。内部项目包括服务器迁移、软件更新以及对已部署项目反馈驱动的变更可能是广泛的,并且可能或可能不会更多地倾向于 DevOps 等式的一侧。

那么,像支持升级和紧急停机这样的非计划活动呢?这些都非常侧重于运维,就像编写新功能、修复错误和重构一样——这一切都是如何通过开发人员包括在 DevOps 故事中来使运维生活变得更轻松。

如果我们的工作不是部署和运维,那么我们的工作是什么?

显然,DevOps 并不是开发人员(或者曾经)要求的任何东西。这是一种运维发明,目的是让其他人工作更加努力。假设这是真的,让我们思考一下,如果开发人员能够团结一致地说:“你们的运维问题是你们的,而不是我们的。”好吧。但是在这种情况下,询问反抗的开发人员他们对“完成”的定义将是完全正确和合理的。他们认为他们需要达到什么标准才能说:“我们的工作做得很好,我们的部分现在已经完成”?

这并不是一个轻率的问题,我们可以找到答案的来源。其中之一,尽管并不完美且经常受到批评,是软件工艺宣言,提出了四个应激发开发人员的基本价值观。让我们来考虑一下:

精心打造的软件

是的,质量确实很重要。

持续增加价值

没有异议。当然,我们希望提供人们需要、想要或愿意的服务和功能。

专业人士的社区

从宏观角度来看,谁会反对呢?在行业同行之间的友好相处只是职业上的邻里之间的亲切关系。

富有成效的合作伙伴关系

合作肯定是游戏的名字。开发人员并不反对质量保证(QA)、运维或产品本身。因此,在这种情况下,这只是与每个人友好相处的问题(只要其他团队不开始指定他们的工作职责)。

到底什么算是“完成”?

借助我们迄今为止建立的一切,我们可以安全地说,我们需要编写简单、可读、易理解和易部署的代码。我们必须确保满足非功能性需求(例如性能、吞吐量、内存占用、安全性、隐私等)。我们应该努力避免产生任何技术负担,并且如果幸运的话,沿途还能减轻一些负担。我们必须确保所有测试都通过。而且我们有责任与质量保证团队保持良好的关系(当他们满意时,我们也会很开心)。

有了高质量的代码,再加上积极的团队领导和同行评审,一切都应该顺利。通过产品团队为价值和附加值定义标准,可以牢固地建立基准。通过他们的反馈,产品所有者帮助确定这些基准是否(或是否未能)得到满足,以及到了什么程度。这是一个非常好的简略定义,说明了一个优秀的软件开发人员完成了他们需要做的事情。它还表明,“做得好”如果没有与运营人员的参与和清晰的沟通,是无法充分衡量(甚至无法知晓)的。

竞争?

所以是的,尽管可以证明 DevOps 真的不是开发人员迫切需要的东西,但同样可以证明它的无限实践对每个人都有好处。但仍然存在一些顽固分子;那些想象开发人员和例如质量保证(QA)测试人员之间存在竞争甚至敌意的人。开发人员努力工作来创建他们的作品,然后感觉 QA 团队几乎像是黑客,试图证明某些问题,不停地深挖。

这就是 DevOps 咨询的用武之地。每个尽责的开发者都希望为自己的工作感到自豪。发现缺陷可能看起来像是批评,但实际上只是来自另一个方向的尽责工作。开发人员和质量保证人员之间良好、清晰、开放和持续的沟通有助于强化 DevOps 的好处,但也清楚地表明每个人最终都在为同一个目标而努力。当质量保证人员发现缺陷时,他们所做的只是帮助他们的开发人员同事编写更好的代码,成为更好的开发人员。这种运作方式展示了运维方面与开发方面之间相互作用的例子,显示了这两个世界之间的区别和分离之间的有用模糊。他们的关系必然是共生的,并且再次沿着无穷无尽的活动连续体工作,一个方面为了所有人的共同利益而通知另一个方面。

前所未有的情况

对 DevOps 的需求增长不仅来自于软件公司内部,也同样来自于外部力量。这是因为我们的期望,所有我们的期望,作为生活在 21 世纪世界中的人,继续迅速变化。我们对不断改进的软件解决方案越来越依赖,我们就越没有时间浪费在信息和沟通差距以及开发人员与运营人员之间的延迟上。

以银行为例。十年前,大多数主要银行都有相当合适的网站。你可以登录查看你的账户、你的对账单和最近的交易。也许你甚至开始通过银行提供的电子服务进行电子支付。尽管这些服务很好,提供了一定程度的便利,但你可能仍然需要去(或者至少感觉更舒服去)你的当地分行处理银行事务。

今天的完全数字化体验是以前所没有的—配有移动应用程序、自动账户监控和警报,以及足够的服务,使得普通账户持有人在线上完成所有事情变得越来越普遍。你甚至可能是那些不仅不在乎是否再次进入实体分行,甚至不知道那个分行在哪里的人之一!而且,银行正通过整合和关闭实体分行,并为客户转移到在线领域提供激励措施,以应对这些迅速变化的银行习惯。这在 COVID-19 危机期间加速进行,当时分行的访问仅限预约服务、有限的步行进入以及更短的营业时间。

因此,如果您的银行网站在银行部署更好、更安全的网站时停机维护了 12 个小时,那么 10 年前您可能会轻松接受这一情况。如果这将带来更高质量的服务,那么 12 小时算什么呢?您并不需要全天 24/7 的在线银行服务,而且当地的分行也可以为您提供服务。然而,今天的情况已经完全不同了。半天的停机时间是不可接受的。实际上,您希望您的银行始终开放和可用。这是因为您(以及全世界)对质量的定义已经发生了变化。这种变化使得 DevOps 比以往任何时候都更为重要。

容量和速度

推动 DevOps 增长的另一个压力是正在存储和处理的数据量。这是完全合理的。如果我们日常生活中越来越多的依赖软件,那么它产生的数据量显然会大幅增加。到 2020 年,全球数据领域总量接近 10 泽字节。十年前,这个数字是 0.5 泽字节。预计到 2025 年,据合理估计,这个数字将以指数方式增长至超过 50 泽字节!

这不仅仅是像谷歌、Netflix、Facebook、Microsoft、Amazon、Twitter 等巨头变得越来越强大,因此需要处理更多的数据。这一预测确认了越来越多的公司将进入大数据的世界。随之而来的是对大量增加的数据负载的需求,以及远离提供给定生产环境精确副本的传统分阶段服务器环境。而这种转变是基于这样一个事实:维护这种一对一方案在规模或速度方面已不再可行。

乐观的日子已经一去不复返,以往一切都可以在投入生产之前进行测试,但现在不再可能。有些软件公司会发布一些他们并不完全信任的东西进入生产环境。这会导致我们恐慌吗?不会。需要快速发布并保持竞争力的必要性应该激发创新和创造力,以最佳方式执行受控的转换、测试程序以及更多的在-生产测试,现在被称为渐进式交付。这伴随着特性标志和可观察性工具,如分布式跟踪。

一些人把渐进式交付与爆炸装置的爆炸半径等同起来。这个想法是,当我们部署到生产环境时,爆炸是可以预料到的。因此,为了优化这样的部署,我们能期望的最好结果就是尽量减少伤亡,尽量减小爆炸半径的大小。通过改进服务器、服务和产品的质量来始终如一地实现这一点。如果我们认同质量是开发者关注的问题,并且其实现是开发者定义中“完成”的一部分,那么这意味着在开发者“完成”的那一刻和下一个运维生产的那一刻之间不可能有暂停或断开的时刻。因为这一切发生的时间不会超过我们重新回到开发阶段,就像修复了错误,由于故障而恢复了服务等等。

完成和完成

或许很明显的是,从运维环境中产生并继续生成的期望和需求的事实,驱动了推动到 DevOps。因此,开发者面临的期望和需求的增加,并不是来自运维人员对开发者同事的某种腐烂仇恨,也不是一种剥夺他们睡眠的阴谋。相反,所有这一切,DevOps 所代表的一切,都是对我们变化世界的实际政治业务响应,以及它们在软件行业整体上所强加的变化的回应。

事实是每个人都有新的责任,其中一些责任需要专业人士(当然是许多部门),随时准备回应任何时候的责任,因为我们的世界是不停止的。这里有另一种说法:我们对“完成”的旧定义已经完成了!

我们的新定义是站点可靠性工程SRE)。这个由 Google 创造的术语通过弥合任何可能存在的两者之间的悬殊感,永久将开发与运维结合在一起。虽然 SRE 的关注领域可能被开发人员或运维人员的人员所占据,但这些天,公司通常有专门的 SRE 团队,专门负责检查与性能、效率、应急响应、监控、容量规划等相关的问题。SRE 专业人员像软件工程师一样思考,为系统管理问题制定策略和解决方案。他们是越来越多地使自动化部署工作的人。

当 SRE 人员感到满意时,这意味着构建变得更加可靠、可重复和快速,特别是因为现在的情况是在无状态环境中运行的可扩展、向后和向前兼容代码,在一个不断扩展的服务器宇宙中发出事件流,以实现实时可观察性和在出现问题时发出警报。当新的构建发生时,它们需要快速启动(并且预计同样快速地死亡)。服务需要尽快恢复到完全功能状态。当功能失效时,我们必须通过 API 具有即时关闭它们的能力。当发布新软件并更新其客户端用户时,但遇到错误时,我们必须具有执行快速且无缝回滚的能力。旧客户端和旧服务器需要能够与新客户端进行通信。

当 SRE 评估和监控这些活动并制定战略响应时,所有这些领域的工作完全由开发人员来完成。因此,虽然开发人员正在执行,SRE 今天定义了完成

浮动如蝴蝶…

除了已提到的所有考虑因素之外,我们现代 DevOps(以及相关的 SRE)时代必须定义代码的一个基本特征是精益。而这里我们指的是节约成本。你可能会问,“但是,代码与节约成本有什么关系呢?”

嗯,一个例子可能是云服务提供商向公司收费的种种离散服务。这些成本中有一部分直接受到那些企业云服务订阅者输出的代码的影响。因此,成本的降低可以来自于创新开发工具的创建和使用,以及编写和部署更好的代码。

全球化、从不关闭的、由软件驱动的社会的本质,以及对更新、更好的软件功能和服务的持续需求,意味着 DevOps 不能只关注生产和部署。它必须还要关注业务本身的底线。尽管这可能看起来又是抛入混合中的另一个负担,但在老板说必须削减成本的下一次时,考虑一下这个。与其采取消极、膝跳反应的解决方案,比如裁员或减少工资和福利,需要的节约可能可以通过积极的、提高业务形象的举措来实现,比如转向无服务器架构和搬迁至云端。这样,没人会被解雇,休息室里的咖啡和甜甜圈依然是免费的!

精益不仅节省了金钱,还给公司提供了改善市场影响的机会。当公司能够在不裁员的情况下实现效率时,它们可以保持团队力量的最佳水平。当团队继续得到良好的报酬和关心时,他们会更有动力地提供他们可能的最佳工作。当该产出取得成功时,意味着客户很满意。只要客户继续因为更快的部署而获得良好运行的新功能,那么…他们会继续回来,并向他人传播这一消息。而是一个良性循环,意味着银行里有钱。

完整性、认证和可用性

与任何和所有的 DevOps 活动一起,一个永恒的问题是安全性。当然,有些人会选择通过雇佣一位首席信息安全官来解决这个问题。这是很好的,因为当出了问题时总会有一个可以责怪的人。一个更好的选择可能是在一个 DevOps 框架内实际分析,个体员工、工作团队和整个公司如何思考安全性以及如何加强安全性。

我们将在第十章中更详细地讨论这个问题,但现在可以考虑一下:违规行为、错误、结构化查询语言(SQL)注入、缓冲区溢出等并不是新鲜事。不同的是它们出现的速度越来越快,数量越来越多,以及恶意个体和实体行动的聪明程度。这并不奇怪。随着越来越多的代码发布,越来越多的问题将随之而来,而每种问题都需要不同的解决方案。

随着部署速度的加快,对风险和威胁的反应变得愈发重要。2018 年发现的熔断和幽灵安全漏洞清楚地表明,有些威胁是无法预防的。我们在一场比赛中,唯一要做的就是尽快部署修复措施

激烈的紧迫性

现在应该清楚了,DevOps 不是一个阴谋,而是对进化压力的回应。 这是一种手段,具有以下功能:

  • 提供更好的质量

  • 节省成本

  • 更快地部署特性

  • 加强安全性

无论谁喜欢与否,或者谁首先想出了这个想法,甚至它的原始意图都不重要。重要的是在下一节中涵盖的内容。

软件行业已完全拥抱 DevOps

到目前为止,每家公司都是一家 DevOps 公司。所以,加入进来吧…因为你别无选择。

如前所述,今天的 DevOps,DevOps 已经演变成的样子,是一个无限循环。这并不意味着团体和部门不再存在。这也不意味着每个人都要对自己关注的领域以及沿着这个连续体的每个人的领域负责。

确实意味着每个人都应该一起工作。确实意味着企业内的软件专业人员必须意识到并合理考虑所有其他同事正在做的工作。他们需要关心同行正面临的问题,这些问题如何以及会如何影响他们自己的工作,公司提供的产品和服务,以及这种整体性如何影响他们公司在市场上的声誉。

这就是为什么 DevOps 工程师 是一个毫无意义的术语,因为它暗示了存在可以全面和胜任地执行(或至少完全熟悉)DevOps 无限循环中发生的一切的人。这样的人不存在。他们永远也不会存在。事实上,甚至试图成为一个 DevOps 工程师也是一个错误,因为它完全违背了 DevOps 的本质,即消除代码开发人员与 QA 测试人员、发布人员等之间的隔离。

DevOps 是努力的汇聚,利益的结合和反馈,以不断创造、保障、部署和完善代码。DevOps 关乎协作。由于协作是有机的、沟通的努力,嗯……正如协作工程不是一回事一样,DevOps 工程也不是一回事(无论任何学院或大学可能承诺什么)。

使其具体化

知道 DevOps 是什么(以及不是什么)只是建立一个概念。问题是,如何在各个软件公司中明智有效地实施和持续发展它?最好的建议?看这里。

首先,你可以有 DevOps 推动者、DevOps 传道士、DevOps 顾问和教练(我知道 Scrum 已经糟糕透顶了所有这些术语,但没有更好的替代)。那没关系。但是 DevOps 不是一个工程学科。我们想要站点/服务可靠性工程师、生产工程师、基础设施工程师、QA 工程师等等。但一旦一家公司有了一个 DevOps 工程师,几乎可以保证接下来会有一个 DevOps 部门,这只会是另一个可能不过是重新包装的现有部门,以便看起来公司已经跟上了 DevOps 的浪潮。

DevOps 办公室并不是进步的标志。相反,它只是回到了未来。然后,接下来需要的是促进 Dev 和 DevOps 之间合作的方法,这将需要创造另一个术语。DevDevOps 怎么样?

其次,DevOps 关注细微之处和小事情。就像文化(特别是企业文化)一样,它关乎态度和关系。你可能无法明确定义这些文化,但它们依然存在。DevOps 也不仅仅是关于代码、工程实践或技术能力。没有你能购买的工具,没有逐步手册,也没有家庭版棋盘游戏可以帮助你在组织中创建 DevOps。

这关乎在公司中鼓励和培养的行为。而其中大部分只是关于如何对待普通员工,公司的结构以及人们担任的职位。关乎人们有多少机会聚在一起(特别是在非会议设置中),他们坐在哪里吃饭,交流工作和非工作内容,讲笑话等等。正是在这些地方而不是数据中心,文化形成、成长和改变。

最后,公司应该积极寻找并投资于 T 型人才(俄语读者可能建议的Ж型更好)。与专精于一领域的 I 型个体或对许多领域略知一二而无专业技能的全才相对,T 型人才在至少一方面拥有世界一流的专业知识。这是“T”形图中长垂直线的基础,坚定地根植于他们的深度知识和经验之中。这个“T”字上面横跨着在其他领域积累的广泛能力、专业知识和智慧。

总体来说,这样的人表现出了清晰而敏锐的适应环境、学习新技能和应对当今挑战的能力。事实上,这几乎是理想的 DevOps 员工的完美定义。T 型人才使企业能够有效地处理优先工作负载,而不仅仅是公司认为他们内部能力所能承受的。T 型人才能看到并对大局感兴趣。这使他们成为出色的合作伙伴,进而导致建立有权力的团队。

我们都收到了这个信息

好消息是,十年后,运维发明了 DevOps,他们完全明白这不仅仅是关于他们。这是关乎每个人。我们可以用自己的眼睛看到这种变化。例如,2019 年“加速:DevOps 现状”报告吸引了更多开发人员参与研究,而不是运维或 SRE 人员!要找到更深入的证据证明事情已经改变,我们回到了基因·金。同样在 2019 年,这位帮助将运维方程式搬上小说舞台的人发行了《独角兽项目》(IT 革命)。如果早期的书籍对开发人员短视,那么这里的英雄是麦克辛,她公司的首席开发人员(也是最终的救世主)。

DevOps 始于运维,毫无疑问。但其动机并非要压制开发人员,也不是运维专业人士的至高无上。它的起源和现在仍然是基于每个人都看到每个人,欣赏他们对企业价值和贡献的认识—这不仅仅是出于尊重或礼貌,而是出于个人自身利益以及企业的生存、竞争力和增长。

如果你担心 DevOps 会让你淹没在运维概念的海洋中,实际上情况可能恰恰相反。只需看看Google 定义的 SRE(这家公司发明了这一学科):

当你把运维视为软件问题时,就得到了 SRE。

所以,运维人员现在也想成为开发人员了?欢迎加入。所有软件专业人士随时都要面对软件问题。我们是解决问题的行业——这意味着每个人都有点像 SRE,有点像开发人员,有点涉及运维……因为这些都是相互交织的方面,使我们能够为当今的软件问题以及明天的个人和社会问题提出解决方案。

第二章:真相系统

斯蒂芬·钦

一个复杂的运行良好的系统,往往是从一个简单的运行良好的系统演变而来的。

约翰·高尔(高尔定律)

要构建有效的 DevOps 流水线,重要的是要有一个统一的真相系统,以了解哪些位和字节正在被部署到生产环境中。通常,这始于一个包含所有源代码的源代码管理系统,这些代码被编译和构建成生产部署。通过将生产部署追溯到源代码控制中的特定修订版本,您可以对错误、安全漏洞和性能问题进行根本原因分析。

源代码管理在软件交付生命周期中发挥了几个关键角色:

协作

大型团队在单个代码库上工作时,如果没有有效的源代码管理,将不断地被彼此阻塞,随着团队规模的增长,生产力将降低。

版本控制

源代码系统让您跟踪代码的版本,以确定部署到生产环境或发布给客户的内容。

历史

通过在开发软件时保留所有版本的时间记录,可以回滚到较旧的代码版本,或者确定导致回归的特定更改。

归属

知道谁在特定文件中进行了更改,可以帮助您确定所有权,评估领域专业知识,并在进行更改时评估风险。

依赖

源代码已成为项目的其他关键元数据的规范来源,比如对其他软件包的依赖关系。

质量

源代码管理系统允许在接受更改之前轻松进行同行审查,从而提高软件的整体质量。

由于源代码管理在软件开发中起着如此关键的作用,重要的是要了解它的工作原理,并选择一个最适合您的组织和期望的 DevOps 工作流程的系统。

源代码管理的三代

协作是软件开发的重要组成部分,随着团队规模的扩大,有效地在共享代码库上进行协作的能力通常成为开发人员生产力的瓶颈。此外,系统的复杂性往往会增加,因此,与管理十几个文件或少数模块不同,通常会看到需要大量更新的数千个源文件,以实现系统范围的更改和重构。

为了管理对代码库的协作需求,源代码管理(SCM)系统被创建了。第一代 SCM 系统通过文件锁定处理协作。其中的例子有 SCCS 和 RCS,需要您在编辑之前锁定文件,进行更改,然后释放锁定以便其他人贡献。这似乎消除了两位开发者做出冲突更改的可能性,但存在两个主要缺点:

  • 生产力仍然受到影响,因为在编辑之前你必须等待其他开发人员完成他们的更改。在文件较大的系统中,这实际上可能将并发性限制为一次只能一个开发人员。

  • 这并未解决跨文件的冲突问题。仍然可能存在两个开发人员修改具有相互依赖关系的不同文件,并通过引入冲突更改来创建错误或不稳定的系统。

第二代版本控制系统有了重大改进,始于由 Dick Grune 创建的 Concurrent Versions System(CVS)。CVS 在其处理文件锁定(或者说不处理)的方法上具有革命性。与其阻止您更改文件不同,它允许多个开发人员对同一文件进行同时(可能是冲突的)更改。后来通过文件合并解决了这一问题:通过差异(diff)算法分析冲突文件,并向用户展示需要解决的冲突更改。

通过延迟解决冲突更改到检入,CVS 允许多个开发人员自由修改和重构大型代码库而不会被同一文件的其他更改阻塞。这不仅提高了开发人员的生产力,还允许将大型功能分离并单独测试,然后将其合并到集成的代码库中。

目前最流行的第二代 SCM 是 Apache Subversion,它被设计为 CVS 的即插即用替代品。它相比 CVS 有几个优点,包括将提交追踪为单个版本,从而避免可能损坏 CVS 仓库状态的文件更新冲突。

第三代版本控制是分布式版本控制系统(DVCS)。在 DVCS 中,每个开发人员都有整个仓库的副本以及本地存储的完整历史记录。与第二代版本控制系统一样,你首先检出仓库的副本,进行更改,然后再次提交。然而,为了将这些更改与其他开发人员集成,你需要以点对点的方式同步整个仓库。

几种早期的 DVCS 系统存在,包括 GNU Arch、Monotone 和 Darcs,但 Git 和 Mercurial 使得 DVCS 变得流行起来。Git 是作为对 Linux 团队需求的直接响应而开发的,他们需要一个稳定可靠的版本控制系统,以支持开源操作系统开发的规模和要求,它已成为开源和商业版本控制系统使用的事实标准。

DVCS 相比基于服务器的版本控制系统提供了几个优点:

完全脱机工作。

由于你拥有仓库的本地副本,因此可以在没有网络连接的情况下进行代码的检入、检出、合并和分支管理。

没有单一故障点。

不像基于服务器的 SCM 那样,只有一个包含完整历史记录的存储库副本存在,DVCS 在每个开发者的机器上创建存储库的副本,增加了冗余性。

更快的本地操作

由于大多数版本控制操作都是在本地进行的,它们比网络速度或服务器负载不受影响,因此速度更快。

分散控制

由于同步代码涉及复制整个存储库,这使得分叉代码库变得更容易,在开源项目的情况下,当主项目停滞或走向不良时,这也使得启动独立努力变得更容易。

迁移的便利性

从大多数源代码管理工具转换到 Git 是一个相对简单的操作,并且你可以保留提交历史。

分布式版本控制也有一些缺点,包括以下几点:

较慢的初始存储库同步

初始同步包括复制整个存储库历史记录,这可能会慢得多。

更大的存储需求

由于每个人都拥有存储库的完整副本和所有历史记录,因此非常大和/或长期运行的项目可能需要大量的磁盘空间要求。

没有锁定文件的能力

基于服务器的版本控制系统在需要编辑不能合并的二进制文件时提供了一些锁定文件的支持。DVCS 的锁定机制不能被强制执行,这意味着只有可以合并的文件(例如文本)适合进行版本控制。

选择你的源代码控制

希望到目前为止,你已经确信使用现代分布式版本控制系统(DVCS)是正确的方式。它为任何规模的团队提供了最佳的本地和远程开发能力。

此外,在常用的版本控制系统中,Git 已经成为采纳的明显赢家。这在查看最常用的版本控制系统的 Google 趋势分析中清楚地显示出来,如图 2-1 所示。

线性图比较 Git、Mercurial、Subversion 和 CVS 的受欢迎程度

Git 已经成为开源社区中的事实标准,这意味着支持其使用的广泛基础以及丰富的生态系统存在。然而,有时候说服老板或同事采用新技术可能会很困难,特别是如果他们对传统的源代码控制技术有深入的投资。

这里有一些你可以用来说服老板升级到 Git 的理由:

可靠性

Git 的写法类似于文件系统,包括适当的文件系统检查工具(git fsck)和校验和以确保数据的可靠性。鉴于它是分布式版本控制系统(DVCS),你可能也将数据推送到多个外部存储库,从而创建数据的多个冗余备份。

性能

Git 并不是第一个分布式版本控制系统(DVCS),但它的性能非常出色。它从头开始设计,旨在支持 Linux 开发,能够处理极其庞大的代码库和数千名开发者。Git 仍然由一个庞大的开源社区积极开发和维护。

工具支持

Git 有超过 40 个前端界面,并且几乎在每个主要 IDE(JetBrains IntelliJ IDEA、Microsoft Visual Studio Code、Eclipse、Apache NetBeans 等)中都有完全支持,因此你不太可能找到一个不完全支持 Git 的开发平台。

集成

Git 与 IDE、问题跟踪器、消息平台、持续集成服务器、安全扫描工具、代码审查工具、依赖管理和云平台有着一流的集成。

升级工具

有迁移工具可简化从其他版本控制系统到 Git 的过渡,如支持从 Subversion 到 Git 的双向变更的 git-svn,或者为 Git 提供的 Team Foundation Version Control (TFVC) 仓库导入工具。

总之,升级到 Git 几乎没有什么损失,并且有很多额外的功能和集成可以开始利用。开始使用 Git 就像下载适用于你的开发机器的版本并创建本地仓库一样简单。

然而,真正的力量在于与你的团队协作,如果你有一个中央仓库来推动变更并进行协作,这将非常方便。几家公司提供商业 Git 仓库,你可以自行托管或在它们的云平台上运行。这些包括 AWS CodeCommit、Assembla、Azure DevOps、GitLab、SourceForge、GitHub、RhodeCode、Bitbucket、Gitcolony 等。根据 JetBrains 2020 年“开发者生态系统现状”报告中显示的数据(见图 2-2),这些基于 Git 的源代码管理系统占据了超过 96% 的商业源代码控制市场份额。

比较不同 CVS 系统采用情况的图表

图 2-2. JetBrains “开发者生态系统现状 2020” 报告关于版本控制服务使用情况的数据(来源:JetBrains CC BY 4.0

所有这些版本控制服务都提供基本版本控制之上的额外服务,包括以下功能:

  • 协作

    代码审查

    拥有有效的代码审查系统对于维护代码完整性、质量和标准至关重要。

    高级拉取请求/合并功能

    许多供应商在 Git 的基础上实现了高级功能,帮助多仓库和团队工作流更高效地进行变更请求管理。

    工作流自动化

    在大型组织中,批准流程可能既流畅又复杂,因此通过团队和公司工作流程的自动化可以提高效率。

    团队评论/讨论

    有效的团队互动和讨论可以与特定的拉取请求和代码更改相关联,有助于改善团队内外的沟通。

    在线编辑

    在浏览器中的集成开发环境允许在几乎任何设备上从任何地方进行源代码协作。GitHub 甚至最近发布了Codespaces,提供了由 GitHub 托管的完整功能的开发环境。

  • 合规/安全性

    追踪

    能够追踪代码历史是任何版本控制系统的核心特性,但通常还需要额外的合规检查和报告。

    审计变更

    出于控制和法规目的,通常需要审计代码库的变更,因此具备自动化工具是有帮助的。

    权限管理

    细粒度的角色和权限管理允许限制对敏感文件或代码库的访问。

    物料清单

    出于审计目的,通常需要一个完整的所有软件模块和依赖项的列表,并且可以从源代码生成。

    安全漏洞扫描

    许多常见的安全漏洞可以通过扫描代码库并查找用于利用已部署应用程序的常见模式来发现。在开发过程的早期阶段使用自动化漏洞扫描器可以帮助识别漏洞。

  • 集成

    问题追踪

    通过与问题追踪器的紧密集成,您可以将特定的变更集与软件缺陷关联起来,从而更容易地识别修复错误的版本并跟踪任何回归问题。

    CI/CD

    通常,持续集成服务器将用于构建检入源代码。紧密集成使得更容易启动构建、报告成功和测试结果,并自动推广和/或部署成功的构建。

    二进制包存储库

    从二进制存储库获取依赖项并存储构建结果提供了一个中心位置来查找构件和分阶段部署。

    消息集成

    团队协作对于成功的开发工作至关重要,并且通过像 Slack、Microsoft Teams、Element 等平台简化讨论源文件、检入和其他源代码控制事件的功能,可以简化沟通。

    客户端(桌面/IDE)

    许多免费客户端和各种 IDE 的插件允许您访问您的源代码控制系统,包括来自 GitHub、Bitbucket 等的开源客户端。

在选择版本控制服务时,重要的是确保它与团队的开发工作流程相匹配,与您已经使用的其他工具集成,并符合您公司的安全策略。通常公司会有一个在整个组织中标准化的版本控制系统,但是如果公司标准不是像 Git 这样的分布式版本控制系统,采用更现代的版本控制系统可能会有好处。

如何进行第一个拉取请求

为了感受版本控制的工作方式,我们将通过一个简单的练习来创建您的第一个拉取请求,以便将您的贡献加入 GitHub 上官方书籍存储库中的读者评论部分,让您可以与其他读者一起展示您掌握的现代 DevOps 最佳实践!

此练习不需要安装任何软件或使用命令行,因此完成此练习应该是非常简单和直接的。强烈推荐完成此练习,以便您了解我们稍后在本章更详细介绍的分布式版本控制的基本概念。

首先,您需要导航到书籍存储库。在这个练习中,您需要登录以便可以从 Web 用户界面创建拉取请求。如果您还没有 GitHub 帐户,注册并开始使用非常简单和免费。

显示 Java 开发人员的 DevOps 工具存储库 GitHub 页面如图 2-3 所示。GitHub UI 默认显示根文件和名为README.md的特殊文件的内容。我们将对 Markdown 语言的可视文本文件自述文件进行编辑。

由于我们仅具有对此存储库的读取访问权限,我们将创建一个称为分支的个人克隆存储库,可以自由编辑并提出更改。一旦您登录 GitHub,您可以通过单击右上角突出显示的“分支”按钮来启动此过程。

显示 GitHub 书籍存储库的网页

图 2-3. 包含本书示例的 GitHub 存储库

您的新分支将在 GitHub 的个人帐户下创建。一旦创建了您的分支,请完成以下步骤以打开基于 Web 的文本编辑器:

  1. 单击README.md文件以编辑详细页面以查看详细信息。

  2. 单击详细页面上的铅笔图标以编辑文件。

一旦单击铅笔图标,您将看到基于 Web 的文本编辑器,如图 2-4 所示。滚动到访客日志部分,并在末尾添加您自己的个人评论,让大家知道您已完成此练习。

显示 GitHub 文本文件编辑器的网页

图 2-4. 用于快速更改文件的 GitHub 基于 Web 的文本编辑器

访客日志条目的推荐格式如下:

*Name* (@*optional_twitter_handle*): *Visitor comment*

如果您想在 Twitter 句柄上装点门面并链接到您的个人资料,Twitter 链接的 Markdown 语法如下所示:

@*twitterhandle*

要查看您的更改,可以单击“预览更改”选项卡,在将其插入原始自述文件后显示渲染的输出。

当您满意您的更改后,请向下滚动到代码提交部分,如图 2-5 所示。输入有关更改的有用描述以解释您的更新。然后继续单击“提交更改”按钮。

对于本例,我们将简单地提交到主分支,默认情况下是这样的。但是,如果您在共享存储库中工作,您将把拉取请求提交到可以单独集成的功能分支。

显示代码提交表单的网站页面

图 2-5. 使用 GitHub UI 提交更改到您具有写入访问权限的存储库

在您对分叉存储库进行更改后,您可以将其作为对原始项目的拉取请求提交。这将通知项目维护者(在本例中是书籍作者),等待审查的建议更改,并让他们选择是否将其集成到原始项目中。

要做到这一点,请转到 GitHub 用户界面中的“拉取请求”选项卡。此屏幕上有一个创建“新拉取请求”的按钮,将为您提供要合并的“基础”和“头”存储库的选择,如 图 2-6 所示。

在这种情况下,由于只有一个更改,应正确选择默认的存储库。只需单击“创建拉取请求”按钮,即可提交针对原始存储库的新拉取请求以供审查。

显示 GitHub 拉取请求 UI 的网站页面

图 2-6. 从分叉存储库创建拉取请求的用户界面

这完成了您对拉取请求的提交!现在轮到原始存储库所有者审查、评论或接受/拒绝拉取请求了。虽然您没有写入原始存储库以查看其外观,但 图 2-7 显示了将呈现给存储库所有者的内容。

一旦存储库所有者接受您的拉取请求,您的自定义访客日志问候语将添加到官方书籍存储库中。

显示 GitHub 合并解决 UI 的网站页面

图 2-7. 用于合并生成的拉取请求的存储库所有者用户界面

这个工作流程是处理项目集成的分叉和拉取请求协作模型的一个示例。我们将稍微详细地讨论协作模式以及适合使用它们的项目和团队结构在 “Git 协作模式” 中。

Git 工具

在前一节中,我们展示了使用 GitHub UI 进行 Git 的整个基于 Web 的工作流程。然而,除了代码审查和存储库管理之外,大多数开发人员在 Git 的基于客户端的用户界面中度过了大部分时间。可用的客户端界面可以广泛分为以下几类:

命令行

官方的 Git 命令行客户端可能已安装在您的系统上,或者很容易添加。

GUI 客户端

官方的 Git 发行版附带了几个开源工具,可以更轻松地浏览您的修订历史或结构化提交。此外,还有几个第三方免费和开源的 Git 工具可以让您更轻松地使用您的仓库。

Git 的 IDE 插件

通常,您只需使用您喜爱的 IDE 就能够使用分布式源代码控制系统。许多主要的 IDE 都默认支持 Git,或者提供了一个良好支持的插件。

Git 命令行基础知识

Git 命令行是管理源代码控制系统的最强大接口,可以通过所有本地和远程选项来管理您的仓库。您可以在控制台上键入以下内容来检查是否已安装 Git 命令行:

git --version

如果您已安装 Git,命令将返回您使用的操作系统和版本,类似于此内容:

git version 2.26.2.windows.1

不过,如果您尚未安装 Git,以下是在各种平台上获取它的最简单方法:

  • Linux 发行版:

    • 基于 Debian: sudo apt install git-all

    • 基于 RPM: sudo dnf install git-all

  • macOS

    • 在 macOS 10.9 或更高版本上运行git将提示您安装它。

    • 另一个简单的选项是安装GitHub Desktop,它会安装并配置命令行工具。

  • Windows

    • 最简单的方法是简单地安装 GitHub Desktop,它会同时安装命令行工具。

    • 另一个选择是安装Git for Windows

无论您使用哪种方法来安装 Git,您最终都将获得相同的出色命令行工具,这些工具在所有桌面平台上得到了良好的支持。

要开始,了解基本的 Git 命令是很有帮助的。图 2-8 显示了一个典型的仓库层次结构,其中包含一个中央仓库和三个已经在本地克隆了它的客户端。请注意,每个客户端都有仓库的完整副本以及可以进行更改的工作副本。

显示远程和本地仓库以及 Git 工作副本关系的图表

图 2-8. 分布式版本控制协作的典型中央服务器模式

以下显示了一些 Git 命令,允许您在仓库和工作副本之间移动数据。现在让我们来看看一些最常用的命令,用于管理您的仓库和在 Git 中进行协作。

  • 仓库管理:

    clone

    在本地文件系统上创建与另一个本地或远程仓库的连接副本。对于那些从 CVS 或 Subversion 等并发版本控制系统过来的人来说,此命令的作用类似于checkout,但在语义上有所不同,因为它创建了远程仓库的完整副本。图中的所有客户端在开始时都会克隆中央服务器。所有的客户端都克隆了中央服务器。

    init

    创建一个新的空仓库。不过,大多数情况下,您会首先克隆一个现有的仓库。

  • 变更集管理:

    add

    将文件修订版添加到版本控制中,可以是新文件或对现有文件的修改。这与 CVS 或 Subversion 中的add命令不同,因为它不会跟踪文件,需要每次文件更改时调用。确保在提交之前调用add以添加所有新文件和修改文件。

    mv

    重命名或移动文件/目录,并更新下一个提交的版本控制记录。在使用上类似于 Unix 中的mv命令,应该使用它来代替文件系统命令以保持版本控制历史完整。

    restore

    允许您从 Git 索引中恢复文件,如果它们被删除或错误修改。

    rm

    移除文件或目录,并更新下一个提交的版本控制记录。在使用上类似于 Unix 中的rm命令,应该使用它来代替文件系统命令以保持版本控制历史完整。

  • 历史控制:

    branch

    如果没有参数,则列出本地仓库中的所有分支。也可用于创建新分支或删除分支。

    commit

    将工作副本中的更改保存到本地仓库。在运行commit之前,请确保通过调用addmvrm对已添加、修改、重命名或移动的文件进行注册。您还需要指定一个提交消息,可以在命令行上使用-m选项完成;如果省略,则会生成一个文本编辑器(如vi)来允许您输入消息。

    merge

    将命名提交中的更改合并到当前分支。如果合并历史已经是当前分支的后代,则使用“快进”来按顺序组合历史。否则,将创建一个合并,合并历史;用户将提示解决任何冲突。此命令也被git pull使用来集成来自远程仓库的更改。

    rebase

    在上游分支上重播当前分支的提交。与merge不同之处在于结果将是线性历史,而不是合并提交,这可以使修订历史更容易遵循。缺点是当移动历史时,rebase 会创建全新的提交,因此如果当前分支包含先前已推送的更改,则正在重写其他客户端可能依赖的历史。

    reset

    HEAD还原到先前状态,并具有几个实用用途,例如撤消add或撤消提交。但是,如果这些更改已经被推送到远程,这可能会导致与上游仓库的问题。请谨慎使用!

    switch

    切换工作副本中的分支。如果您在工作副本中有更改,则可能会导致三向合并,因此最好先提交或隐藏您的更改。使用-c选项,此命令将创建一个分支并立即切换到它。

    tag

    允许您在特定提交上创建一个由 PGP 签名的标签。这将使用默认电子邮件地址的 PGP 密钥。由于标签是经过加密签名和唯一的,因此在推送后不应该被重用或更改。此命令的其他选项允许删除、验证和列出标签。

    log

    以文本格式显示提交日志。它可用于快速查看最近的更改,并支持用于显示的历史子集和输出格式的高级选项。在本章的后面,我们还将介绍如何使用gitk等工具来可视化浏览历史记录。

  • 协作:

    fetch

    从远程仓库拉取历史记录到本地仓库,但不尝试将其与本地提交合并。这是一个安全的操作,可以在任何时候重复执行,而不会引起合并冲突或影响工作副本。

    pull

    等效于git fetch后跟git merge FETCH_HEAD。它方便了从远程仓库抓取最新更改并将其与您的工作副本集成的常见工作流程。然而,如果您有本地更改,pull可能会导致合并冲突,您将被迫解决。因此,通常更安全的做法是先fetch,然后决定是否仅需简单合并。

    push

    将本地仓库中的更改发送到上游远程仓库。在commit后使用此命令将您的更改推送到上游仓库,以便其他开发人员可以看到您的更改。

现在您已经对 Git 命令有了基本的了解,让我们将这些知识付诸实践。

Git 命令行教程

为了演示如何使用这些命令,我们将通过一个简单的示例来从头开始创建一个新的本地仓库。对于这个练习,我们假设您正在使用一个类似于 Bash 的命令行 shell 的系统。这是大多数 Linux 发行版以及 macOS 的默认设置。如果您使用的是 Windows,您可以通过 Windows PowerShell 来完成这个操作,它有足够的别名来模拟基本命令的 Bash。

如果这是您第一次使用 Git,建议您输入您的姓名和电子邮件,这将与您所有的版本控制操作相关联。您可以使用以下命令来实现这一点:

git config --global user.name *"Put Your Name Here"*

git config --global user.email *"your@email.address"*

配置个人信息后,转到适当的目录创建您的工作项目。首先,创建项目文件夹并初始化仓库:

mkdir tutorial

cd tutorial

git init

这将创建仓库并初始化,使您可以开始跟踪文件的修订版本。让我们创建一个可以添加到修订控制的新文件:

echo "This is a sample file" > sample.txt

要将此文件添加到修订控制中,请使用以下git add命令:

git add sample.txt

您可以使用git commit命令将此文件添加到版本控制中:

git commit sample.txt -m "First git commit!"

恭喜您使用 Git 进行了第一次命令行提交!您可以通过使用git log命令来双重检查确保您的文件正在被修订控制跟踪,它应该返回类似以下的输出:

commit 0da1bd4423503bba5ebf77db7675c1eb5def3960 (HEAD -> master)
Author: Stephen Chin <steveonjava@gmail.com>
Date:   Sat Mar 12 04:19:08 2022 -0700

    First git commit!

从这里,您可以看到 Git 存储库中存储的一些细节,包括分支信息(默认分支是master)和按全局唯一标识符(GUID)分类的修订。虽然您可以从命令行做更多事情,但通常更容易使用为您的工作流程构建的 Git 客户端或 IDE 集成,该工具专为开发人员工作流程设计。接下来的几节将介绍这些客户端选项。

Git 客户端

几个免费开源的客户端可供您使用,可使您更轻松地使用 Git 存储库,并针对不同的工作流程进行了优化。大多数客户端并不尝试做到一切,而是专注于为特定工作流程提供可视化和功能。

默认的 Git 安装附带了一些方便的可视化工具,使提交和查看历史更加容易。这些工具是用 Tcl/Tk 编写的,跨平台,并且可以轻松地从命令行启动,以补充 Git 命令行界面(CLI)。

第一个工具gitk提供了一个选择,用于浏览、查看和搜索本地存储库的 Git 历史,而不是使用命令行。显示 ScalaFX 开源项目历史记录的gitk用户界面显示在图 2-9 中。

用户界面通过分割窗格以图形和修订数据的形式可视化显示 Git 历史

图 2-9. 捆绑的 Git 历史查看器应用程序

gitk的顶部窗格显示具有分支信息的修订历史,以可视方式绘制,这对于解密复杂的分支历史非常有用。在此之下是可用于查找包含特定文本的提交的搜索过滤器。最后,对于所选更改集,您可以看到已更改的文件以及更改的文本差异,这也是可搜索的。

Git 随附的另一个工具是git-gui。与仅显示有关存储库历史的信息的gitk不同,git-gui允许您通过执行许多 Git 命令(包括commitpushbranchmerge等)来修改存储库。

图 2-10 显示了用于编辑本书源代码存储库的git-gui用户界面。在左侧,显示了所有工作副本的更改,未暂存的更改显示在顶部,下一个提交中将包含的文件显示在底部。所选文件的详细信息显示在右侧,其中包括新文件的完整文件内容,或者修改文件的差异。在右下角,提供了用于常见操作(如重新扫描、签名、提交和推送)的按钮。高级操作(如分支、合并和远程存储库管理)的其他命令可在菜单中找到。

用于审查和提交代码的捆绑式 Git UI 的屏幕截图

图 2-10. 捆绑的 Git 协作应用程序

git-gui是 Git 的一个以工作流驱动的用户界面的示例。它不公开命令行上可用的完整功能集,但对于常用的 Git 工作流程非常方便。

另一个以工作流驱动的用户界面的例子是GitHub Desktop。这是最受欢迎的第三方 GitHub 用户界面,正如前面提到的,它还方便地与命令行工具捆绑在一起,因此您可以将其用作 Git CLI 和前述捆绑 GUI 的安装程序。

GitHub Desktop 类似于git-gui,但经过了优化以与 GitHub 的服务集成,并且用户界面设计得非常易于遵循类似于 GitHub Flow 的工作流程。编辑源存储库的 GitHub Desktop 用户界面,另一本优秀书籍The Definitive Guide to Modern Java Clients with JavaFX,显示在图 2-11 中。

GitHub Desktop 用户界面的截图

图 2-11. GitHub 的开源桌面客户端

除了与git-gui具有相同类型的功能以查看更改、提交修订版本和拉取/推送代码之外,GitHub Desktop 还具有许多高级功能,可使管理代码变得更加容易:

  • 提交归因

  • 语法高亮差异

  • 图像差异支持

  • 编辑器和 shell 集成

  • 拉取请求的 CI 状态

GitHub Desktop 可以与任何 Git 存储库一起使用,但具有专门针对与 GitHub 托管存储库一起使用的功能。以下是一些其他受欢迎的 Git 工具:

Sourcetree

由 Atlassian 制作的免费但专有的 Git 客户端。它是 GitHub Desktop 的一个很好的替代品,并且只对 Atlassian 的 Git 服务 Bitbucket 有轻微偏见。

GitKraken 客户端

商业和功能丰富的 Git 客户端。对于开源开发者是免费的,但对于商业用途是付费的。

TortoiseGit

基于 TortoiseSVN 的自由 GNU 公共许可证(GPL)的 Git 客户端。唯一的缺点是它只支持 Windows。

其他

Git GUI 客户端的完整列表维护在Git 网站上。

Git 桌面客户端是您可以使用的可用源代码控制管理工具库的强大补充。然而,最有用的 Git 界面可能已经在您的 IDE 中就在您的指尖。

Git IDE 集成

许多集成开发环境(IDE)都包含 Git 支持,要么作为标准功能,要么作为一个得到很好支持的插件。你很可能不需要去找其他东西,只需在你喜欢的 IDE 中进行基本的版本控制操作,如添加、移动和删除文件,提交代码和将更改推送到上游存储库。

JetBrains IntelliJ IDEA 是最受欢迎的 Java IDE 之一。它有一个开源的社区版,也有一个商业版,提供了额外的功能,适用于企业开发者。IntelliJ 的 Git 支持功能齐全,能够同步远程仓库的更改,跟踪和提交在 IDE 中进行的更改,并集成上游更改。图中展示了 Git 更改集的集成提交选项卡 Figure 2-12。

IntelliJ 提交选项卡的截图

图 2-12. IntelliJ 用于管理工作副本更改的提交选项卡

IntelliJ 提供了丰富的功能集,您可以使用它来定制 Git 的行为以适应团队的工作流程。例如,如果您的团队喜欢 git-flow 或 GitHub Flow 的工作流程,您可以选择在更新时合并(有关 Git 工作流的更多细节请参见下一节)。然而,如果您的团队希望保持像 OneFlow 中规定的线性历史,您可以选择在更新时进行变基。IntelliJ 还支持本地凭据提供程序以及开源的 KeePass 密码管理器。

另一个提供出色 Git 支持的 IDE 是 Eclipse,这是一个完全开源的 IDE,拥有强大的社区支持,并由 Eclipse Foundation 运营。Eclipse 的 Git 支持由 EGit 项目提供,该项目基于 JGit,这是 Git 版本控制系统的纯 Java 实现。

由于与嵌入式 Java 实现的 Git 紧密集成,Eclipse 提供了最全面的 Git 支持。从 Eclipse 用户界面,您几乎可以完成从命令行执行的所有操作,包括变基、挑选、打标签、打补丁等。从偏好设置对话框中可以看到丰富的功能集,如 Figure 2-13 所示。该对话框有 12 个配置页面详细说明 Git 集成的工作,并支持一个长达 161 页的用户指南。

Eclipse 设置对话框中用于 Git 配置的截图

图 2-13. Eclipse 的 Git 配置偏好对话框

其他可以期待有很好 Git 支持的 Java IDE 包括以下几款:

NetBeans

提供了一个 Git 插件,完全支持从 IDE 进行的工作流程。

Visual Studio Code

支持 Git 以及其他开箱即用的版本控制系统。

BlueJ

由伦敦国王学院构建的受欢迎的学习 IDE 还支持其团队工作流中的 Git。

Oracle JDeveloper

虽然它不支持复杂的工作流程,JDeveloper 提供了对克隆、提交和推送到 Git 仓库的基本支持。

在本章至今,您已经向您的工具库中添加了一整套新的命令行、桌面和集成工具,用于处理 Git 存储库。 这一系列社区和行业支持的工具意味着,无论您的操作系统、项目工作流程甚至团队偏好如何,您都会发现完整的工具支持可以让您在源代码控制管理方面取得成功。 下一节将更详细地介绍由完整的 Git 工具范围支持的协作模式。

Git 协作模式

分布式版本控制系统已经被证明可以扩展到拥有数百名合作者的非常大的团队。 在这种规模下,需要就统一的协作模式达成一致,以帮助团队避免重复工作、避免大量且难以管理的合并,并减少在管理版本控制历史记录上的阻塞时间。

大多数项目遵循中央存储库模型:一个单一的存储库被指定为用于集成、构建和发布的官方存储库。 即使分布式版本控制系统允许非集中式的对等交换修订版,但最好将其保留给在少数开发人员之间进行短期努力的项目。 对于任何大型项目,具有单一真实性的系统是重要的,并且需要一个所有人都同意是官方代码线的存储库。

对于开源项目,常见的做法是一组有限的开发人员具有对中央存储库的写访问权限,而其他提交者则会fork该项目并发出拉取请求以包含他们的更改。 最佳实践是提出小型拉取请求,并且除了拉取请求创建者之外,还有其他人接受它们。 这对于拥有数千名贡献者的项目具有很好的扩展性,并且在代码库不被充分理解时允许核心团队进行审查和监督。

然而,对于大多数企业项目来说,首选的是具有单个主分支的共享存储库。 使用拉取请求相同的工作流程可以使中央或发布分支保持清洁,但这简化了贡献过程,并鼓励更频繁的集成,从而减少了合并更改的大小和难度。 对于有紧迫截止日期或遵循具有短周期迭代的敏捷过程的团队,这也减少了最后一刻集成失败的风险。

大多数团队采用的最后一个最佳实践是使用分支来处理功能,然后将其集成回主要代码线。 Git 使得创建短期分支成本低廉,因此常见的做法是为仅需几个小时的工作创建一个分支,然后将其合并回来。 创建长期功能分支的风险在于,如果它们与代码开发的主干分支相差太大,那么将它们集成回来就会变得困难。

遵循这些分布式版本控制的通用最佳实践,出现了几种协作模式。 它们有很多共同之处,主要在于它们对分支、历史管理和集成速度的处理方式上有所不同。

git-flow

Git-flow是最早的 Git 工作流之一,受到了 Vincent Driessen 的一篇博客文章的启发。它为后来的 Git 协作工作流(如 GitHub Flow)奠定了基础;然而,git-flow 比大多数项目需要的工作流更为复杂,可能会增加额外的分支管理和集成工作。

主要特点包括以下内容:

开发分支

每个特性都有一个分支

合并策略

不要快进合并

重置历史

不进行重置

发布策略

单独的发布分支

在 git-flow 中,有两个长期存在的分支:一个用于开发集成,称为develop,另一个用于最终发布,称为master。开发人员预计会在按照他们正在进行的特性命名的特性分支上进行所有编码,并在完成后将其与开发分支集成。当开发分支具有进行发布所需的特性时,将创建一个新的发布分支,用于通过补丁和错误修复稳定代码库。

一旦发布分支稳定并准备好发布,它就会被整合到主分支,并添加一个发布标签。一旦在主分支上,只能应用热修复,这是在专用分支上管理的小改动。这些热修复还需要应用到开发分支和任何其他需要相同修复的并发发布。图 2-14 展示了一个 git-flow 的示意图。

由于 git-flow 的设计决策,它往往会创建复杂的合并历史。通过不利用快速合并或重置,每次集成都会成为一个提交,即使使用可视工具也很难跟踪并发分支的数量。此外,复杂的规则和分支策略需要团队培训,并且难以用工具强制执行,通常需要通过命令行界面进行检查和集成。

小贴士

Git-flow 最适用于需要同时维护多个发布版的显式版本化项目。通常情况下,这对于只有一个最新版本并且可以通过单一发布分支管理的 Web 应用来说并不适用。

显示随时间变化的分支和集成的示意图

图 2-14. 使用 git-flow 管理分支和集成 (来源:Vincent Driessen,知识共享署名-相同方式共享)

如果你的项目正处于 git-flow 擅长的甜蜜点,那么它是一个非常经过深思熟虑的协作模型。否则,你可能会发现一个更简单的协作模型就足够了。

GitHub Flow

GitHub Flow是对 git-flow 复杂性的回应而推出的简化 Git 工作流,由 Scott Chacon 在另一篇著名的博客文章中提出。GitHub Flow 或类似的变种已被大多数开发团队采用,因为它在实践中更容易实现,处理了持续发布的 Web 开发的常见情况,并得到了良好的工具支持。

关键特点包括以下几点:

开发分支

特性分支

合并策略

无快速向前合并

重置历史

无重置

发布策略

没有单独的发布分支

GitHub 流采用简单的分支管理方法,将 master 作为主要代码线和发布分支。开发者在短暂的特性分支上完成所有工作,并在他们的代码通过测试和代码审查后立即将其集成回主分支。

提示

总的来说,GitHub 流通过简单的工作流程和简单的分支策略充分利用了现有的工具。因此,不熟悉团队流程或不熟悉命令行 Git 界面的开发者发现 GitHub 流易于使用。

GitHub 流协作模型非常适合服务器端和云部署应用程序,其中唯一有意义的版本是最新发布。事实上,GitHub 流建议团队持续部署到生产环境,以避免特性堆积,即单个发布构建中包含多个增加复杂性的特性,使得确定破坏性变更更加困难。然而,对于具有多个并发发布的更复杂工作流程,需要修改 GitHub 流以适应。

GitLab 流

GitLab 流 实际上是 GitHub 流的扩展,在 GitLab 的 网站 上有文档记录。它遵循相同的核心设计原则,使用主分支作为单个长期存在的分支,并在特性分支上进行大部分开发。然而,它添加了一些扩展以支持许多团队采用的发布分支和历史清理作为最佳实践。

关键特点包括以下几点:

开发分支

特性分支

合并策略

开放式

重置历史

可选

发布策略

单独的发布分支

GitHub 流和 GitLab 流之间的关键区别在于添加了发布分支。这是因为大多数团队并不像 GitHub 那样实践持续部署。拥有发布分支可以在推送到生产之前稳定代码;然而,GitLab 流建议在主分支上进行补丁,然后挑选它们进行发布,而不是像 git-flow 那样有额外的热修复分支。

另一个重要的区别是愿意使用 rebasesquash 来编辑历史。通过在提交到主分支之前清理历史,可以更轻松地回溯历史,发现关键变更或引入的错误。然而,这涉及重写本地历史,在已经推送到中央仓库时可能会很危险。

提示

GitLab 流是对 GitHub 流协作工作流理念的现代演绎,但最终你的团队必须根据项目需求决定特性和分支策略。

OneFlow

OneFlow,另一种基于 git-flow 的协作工作流,由亚当·鲁卡提出,并在详细的 博客 中介绍。OneFlow 与 GitHub/GitLab Flow 一样,在压缩独立的开发分支以支持特性分支和直接集成到主分支方面进行了相同的适应。然而,它保留了在 git-flow 中使用的发布和热修复分支。

Key attributes include the following:

Development branches

每个特性分支

合并策略

No fast-forward merges without rebase

Rebasing history

推荐使用 rebase

发布策略

单独的发布分支

OneFlow 的另一个重大偏差是,它非常倾向于修改历史以保持 Git 修订历史的可读性。它提供了三种合并策略,具有不同程度的修订清洁度和回滚友好性:

Rebase

这使得合并历史基本上是线性的并且易于跟踪。它有一个通常的警告,即推送到中央服务器的变更集不应该进行 rebase,并且使得回滚变得更加困难,因为它们不会捕获在一个单一提交中。

merge -no-ff

这与 git-flow 中使用的策略相同,并且其缺点是合并历史主要是非顺序的,难以跟踪。

rebase + merge -no-ff

这是一个重新基于 rebase 的解决方法,最后增加了额外的合并集成,以便可以作为一个单元回滚,尽管它仍然基本上是顺序的。

Tip

OneFlow 是一个经过深思熟虑的 Git 协作工作流,是根据大型企业项目开发人员的经验创建的。它可以看作是 git-flow 的现代变体,应该能够满足任何规模项目的需求。

Trunk-Based Development

所有上述方法都是特性分支开发模型的变种;所有活跃的开发都在分支上进行,然后合并到主分支或专用开发分支。它们充分利用了 Git 在分支管理方面的强大支持,但如果特性不够细粒度,就会遭受几十年来困扰团队的典型集成问题。特性分支在活跃开发越长,与主分支(或主干)同时进行的其他特性和维护发生冲突的可能性就越高。

基于主干的开发 通过建议所有开发都在主分支上进行,并且在测试通过时随时进行非常短的集成来解决这个问题,但不一定等待完整的特性完成。

Key attributes include the following:

开发分支

可选,但不能有长期存在的分支

Merge strategy

Only if using development branches

Rebasing history

推荐使用 rebase

发布策略

Separate release branches

Paul Hammant 是主张基于主干的开发的坚定支持者,他建立了一个完整的网站,并撰写了一本相关主题的书籍。尽管这并不是协作源代码管理系统中的新方法,但它已被证明是大团队敏捷开发的有效方法,无论是在经典的中央化 SCM 如 CVS 和 Subversion 上,还是现代的分布式版本控制系统如 Git 上同样适用。

总结

良好的源代码管理系统和实践为快速构建、发布和部署代码的稳健 DevOps 方法奠定了基础。在本章中,我们讨论了源代码管理系统的历史,并解释了为什么全球开始接受分布式版本控制。

这种整合建立了丰富的源代码控制服务器、开发工具和商业集成生态系统。最终,通过 DevOps 思想领袖对分布式版本控制的采纳,建立了可以遵循的最佳实践和协作工作流程,以帮助您的团队成功采用现代化的源代码管理系统。

在接下来的几章中,我们将深入探讨与您的源代码管理系统连接的系统,包括持续集成、包管理和安全扫描,这些系统能让您快速部署到传统或云原生环境中。您正在打造一个全面支持您需要满足质量和部署目标的工作流的 DevOps 平台。

第三章:容器简介

梅丽莎·麦凯

任何傻瓜都可以知道。关键在于理解。

阿尔伯特·爱因斯坦

如果你知道为什么,你可以任何怎样都行。

弗里德里希·尼采

在撰写本文时,生产和其他环境中使用容器的使用正在呈指数级增长,而围绕应用容器化的最佳实践仍在讨论和定义中。随着我们专注于效率提升并考虑具体用例,经验丰富的博客圈和专业实践者已经发展出了一些高度推荐的技术和模式。并且如预期的那样,已经发展出了相当一部分模式和常见用途,以及希望本章能帮助您识别和避免的反模式。

我自己对容器的试错式介绍感觉就像是搅动了一个黄蜂窝(哦,那些蛰伤!)。毫无疑问,我毫无准备。表面上看,容器化似乎简单得令人难以置信。现在我知道如何在 Java 生态系统中开发和部署容器,我希望以一种方式传授这些知识,帮助你避免类似的痛苦。本章概述了您成功容器化应用所需的基本概念,并讨论了为什么您甚至想要做这样的事情。

第四章讨论了微服务的更大图景,但在这里我们将从学习微服务部署的基本构建块开始,如果您尚未遇到的话,您无疑会遇到:容器。请注意,微服务的概念作为一种架构关注,并不意味着一定要使用容器;相反,特别是在云原生环境中部署这些服务通常是围绕容器化展开对话的关键。

让我们从考虑为什么我们会使用容器开始。做到这点的最佳方式是回过头来,了解我们是如何开始的。耐心是一种美德。如果你坚持不懈,通过这段历史课程将自然而然地使你更清楚地理解什么是容器。

理解问题的本质

我确信我不是唯一一个经历“房间里的大象”陪伴的人。尽管庞大的身影、震耳欲聋的噪音以及被忽视时可能带来的危险后果,这个象大小的主题却被允许自由漫游,毫无挑战地。我亲眼目睹过。我也有过这样的罪行。我甚至曾经有幸成为这只大象。

在容器化的背景下,我要提出这样一个论点,我们需要解决两只房间里的大象——以两个问题的形式:什么是容器?为什么我们会使用容器?听起来很简单。怎么可能有人会忽略这些基本的起点呢?

或许这是因为微服务运动现在比以往任何时候都更多地引入了有关部署容器的讨论,我们担心错过时机。也许这是因为容器实施在目前极为流行的 Kubernetes 潮流中被默认期望,而“我们的 K8s 集群”是我们对话中的新潮流。甚至可能仅仅是因为在 DevOps 生态系统中,我们面临如此多的新技术和工具的攻击,作为开发者(尤其是 Java 开发者),如果我们停下来问问题,我们就害怕被落下。无论原因如何,在我们甚至能够详细讨论如何构建和使用容器之前,这些什么为什么的问题必须先解决。

多年来,我有幸与不可思议的同事和导师们一起工作,对此深表感激。在职业生涯的初期,我经常回想起一些至理名言。它很简单;始终以一个不断重复的问题开始并继续进行任何项目的工作:你试图解决的问题是什么? 你解决方案的成功将取决于它如何满足这个要求——确实解决了最初的问题。

仔细考虑你是否从根本上解决了正确的问题。特别警惕拒绝那些实际上是实施指令的问题陈述,比如这样一个:通过将应用程序分解为容器化的微服务来提高其性能。你将更好地通过像这样一个问题陈述服务:为了减少客户完成目标所需的时间,将应用程序的性能提高 5%。请注意,后者包含一个具体的度量标准来衡量成功,并不限于微服务的实现。

这个原则同样适用于你日常选择使用的工具、选择编码的框架和语言、你如何设计系统,甚至如何打包和部署软件到生产环境。你所做的选择解决了什么问题?你如何知道你选择了最合适的工具?其中一种方法是了解特定工具旨在解决的问题。而了解其历史是做到这一点的最佳方式。这种做法应该适用于你使用的每一个工具。我保证,了解其历史后,你将能做出更好的决策,并从绕过已知的陷阱中受益,或者至少有理由接受任何不利因素并继续前进。

我的计划不是要完全无聊地向你讲述历史细节,但在你开始对每一行代码进行容器化之前,你应该了解一些基本信息和重要的里程碑。通过更多地了解原始问题及其解决方案,你将能够智能地解释为什么选择使用容器进行部署。

我不打算回溯到宇宙大爆炸,但我会回顾 50 多年前的情况,主要是为了表明虚拟化和容器化并不是新概念。事实上,这个概念已经经过半个多世纪的努力和改进。我挑选了一些重点来快速介绍,让我们跟上时代的步伐。这不是深入技术的手册,而是足够让你了解随着时间的推移取得的进展以及我们是如何达到今天的地步的一些材料。

让我们开始。

容器的历史

在 20 世纪 60 年代和 70 年代,计算资源一般极为有限且昂贵(按今天的标准)。进程完成需要很长时间(同样按今天的标准),通常一个计算机会长时间专门为单个用户的单个任务而运行。开始了改进计算资源共享和解决这些限制带来的瓶颈和低效的努力。但仅仅能够共享资源还不够。出现了一种需求,即在互相不干扰或者导致一个人无意间导致整个系统崩溃的情况下共享资源的方法。硬件和软件方面推进了虚拟化技术的发展。软件方面的一个发展是chroot,我们将从这里开始。

1979 年,在 Unix 第七版开发期间,开发了chroot,并在 1982 年加入了伯克利软件分发(BSD)。这个系统命令改变了进程及其子进程的根目录,导致文件系统的视图受限,以提供一个测试不同分发环境的环境,例如。尽管是朝着正确方向迈出的一步,但chroot只是提供我们今天所需应用隔离的开端。2000 年,FreeBSD 扩展了这个概念,并在 FreeBSD 4.0 版中引入了更复杂的jail命令和实用程序。其功能(在稍后的 5.1 和 7.2 版本中得到改进)有助于进一步隔离文件系统、用户和网络,并包括为每个jail分配 IP 地址的能力。

2004 年,Solaris 容器和区域使我们更进一步,通过给应用程序提供完整的用户、进程和文件系统空间以及系统硬件访问权限。 谷歌在 2006 年推出了其进程容器,后来改名为cgroups,它的核心是隔离和限制进程的资源使用。 2008 年,cgroups被合并到 Linux 内核中,随后,与 Linux 命名空间一起,IBM 开发了 Linux 容器(LXC)。

现在事情变得更加有趣。 Docker 在 2013 年成为开源项目。 同年,谷歌提供了其 Let Me Contain That For You(lmctfy)开源项目,该项目使应用程序能够创建和管理自己的子容器。 从那时起,我们看到了容器的使用激增——尤其是 Docker 容器。 最初,Docker 将 LXC 作为其默认的执行环境,但在 2014 年,Docker 选择将其用于启动容器的 LXC 工具集替换为libcontainer,这是一个用 Go 编写的本地解决方案。 不久之后,lmctfy 项目停止了活跃开发,并打算与 libcontainer 项目合作,并将核心概念迁移到 libcontainer 项目中。

在这段时间内发生了很多事情。 我故意跳过了关于其他项目、组织和规范的更多细节,因为我想要谈论的是 2015 年的一个特定事件。 这个事件尤其重要,因为它将让您对市场变化背后的一些活动和动机有所了解,特别是涉及 Docker 的情况。

2015 年 6 月 22 日,宣布成立了开放容器倡议组织(OCI)。 这是Linux 基金会旗下的一个组织,旨在为容器运行时和镜像规范创建开放标准。 Docker 是重要的贡献者,但 Docker 宣布这个新组织时列出了参与者,包括 Apcera,亚马逊网络服务(AWS),思科,CoreOS,EMC,富士通,谷歌,高盛,惠普,华为技术,IBM,英特尔,Joyent,Pivotal Software,Linux 基金会,Mesosphere,微软,Rancher Labs,红帽和 VMware。 显然,容器的发展及其周围的生态系统已经达到了一个引人注目的地步,并且发展到了确立一些共同基础对所有涉及方都有益处的地步。

在 OCI 成立时,Docker 还宣布了将捐赠其基础容器格式和运行时 runC 的意图。 紧随其后,runC 成为了OCI 运行时规范的参考实现,而 Docker v2 Schema 2 镜像格式,在 2016 年 4 月捐赠,成为了OCI 镜像格式规范的基础。 这些规范的版本 1.0都于 2017 年 7 月发布。

注意

runC是 libcontainer 的一个再打包,符合 OCI 运行时规范的要求。事实上,截至本文撰写时,runC 的源代码中包含一个名为libcontainer的目录。

随着容器生态系统的发展,这些系统的编排也在快速发展之中。2015 年 7 月 21 日,在 OCI 成立一个月后,Google 发布了 Kubernetes v1.0。与此同时,Cloud Native Computing Foundation (CNCF)与 Google 和 Linux 基金会合作成立。Google 在 2016 年 12 月发布的 Kubernetes v1.5 中另一个重要的进展是开发了容器运行时接口(CRI),这为 Kubernetes 的机器守护进程kubelet支持替代低级别容器运行时提供了必要的抽象层。2017 年 3 月,CNCF 的另一成员 Docker 贡献了其自己开发的与 CRI 兼容的运行时containerd,用于将 runC 整合到 Docker v1.11 中。

2021 年 2 月,Docker 向 CNCF 捐赠了另一个参考实现。此贡献集中于图像分发(推送和拉取容器镜像)。三个月后,即 2021 年 5 月,OCI 基于 Docker Registry HTTP API V2 协议发布了版本 1.0 的OCI 分发规范

如今,像 Kubernetes 这样的容器编排系统在云原生部署中非常普遍。容器在保持在各种主机中灵活部署方面起着重要作用,并在扩展分布式应用程序方面发挥了重要作用。包括 AWS、Google Cloud、Microsoft Azure 在内的云服务提供商正在不断增强其提供的共享基础设施和按使用量付费的存储。

恭喜你已经走完了那段历史!在几段文字中,我们跨越了 50 多年的发展和进步。你已经了解了一些已经发展成为我们解决方案的项目,以及容器及其部署背景中使用的一些常见术语。你还了解了 Docker 对今天容器状态的重大贡献——这正是我们深入了解容器生态系统、容器背后的技术细节以及实施组件的理想时机。

但等等!在我们深入讨论之前,让我们讨论第二只大象。你已经了解了发生了什么,但是为什么行业会以这种方式转变呢?

为什么要使用容器?

知道容器是什么以及如何描述它们还不够。要能够有条理地讨论它们,你应该理解为什么使用它们。使用容器的优势是什么?鉴于你现在对容器及其历史的了解,其中一些可能显而易见,但在激烈竞争之前深入探讨仍然是值得的。项目变更和任何新技术栈的引入都应该经过深思熟虑的成本效益分析。跟风并不是一个足够的理由。

你的第一个问题很可能是:为什么容器是开发者关注的事情?——确实是一个合理的问题。如果容器只是一种部署方法,似乎这应该是运维的责任范围。在这里,我们接近了开发和运维之间模糊的界线,这是支持 DevOps 思维方式的一个论据。将你的应用打包成容器,从开发者的角度来看,需要比你最初想象的更多的思考和远见。在学习了一些最佳实践和他人经验中遇到的问题后,你会在开发应用的同时考虑打包的问题。在这个过程中的某些方面将影响你关于应用或服务如何使用内存、文件系统的决策,如何插入可观察性钩子,如何允许不同的配置,以及如何与其他服务(如数据库)通信。这些只是几个例子。最终,这将取决于你的团队如何组织,但在一个 DevOps 团队中,作为开发者,掌握如何构建和维护容器镜像以及理解容器环境将是非常有价值的。

我最近有机会参加了“开发者大会”云与 DevOps 国际专场的座谈会,主题是“云计算的效率与简易性:未来将会带来什么?”作为讨论的一部分,我们谈论了当前可用的技术状态以及我们期望更多简化的领域。我在讨论中引入了以下问题/类比:如果我们期望自己制造汽车,今天有多少人会开车?在这个领域的许多技术仍处于非常早期阶段。市场上急需能够充分利用云计算提供的可伸缩性、可用性和弹性,并以减少复杂性为目标打包的全功能产品制造商。然而,我们仍然在设计用于构建这样东西的各个部件和零件之中。

容器在这方面是一个巨大的进步,提供了在打包应用程序和部署应用程序的基础设施之间提供有用的抽象级别。我预计有一天开发人员将不再需要涉及容器级别的细节,但目前,我们应该。至少,我们应该有一个位置来确保开发方面的问题在前进中得到解决。为了达到这个目的,以及消除你为什么甚至应该提出容器主题的任何剩余疑虑,让我们更多地了解一下。

想想打包、部署和运行你的 Java 应用程序需要做的一切。为了开始开发,你需要在开发机器上安装特定版本的 Java 开发工具包(JDK)。然后,你可能会安装诸如 Apache Maven 或 Gradle 之类的依赖管理器,以获取你选择在应用程序中使用的所有所需的第三方库,并将其打包成 WAR 或 JAR 文件。到这一步,它可能已经准备好部署到…… 某个地方

开始出现问题。在生产服务器上安装了什么——Java 运行时的哪个版本,什么应用服务器(例如,JBoss,Apache Tomcat,WildFly)?在生产服务器上是否运行了其他可能干扰应用程序性能的进程?你的应用程序是否因为任何原因需要 root 访问权限,并且你的应用程序用户是否以正确的权限设置适当地配置?你的应用程序是否需要访问外部服务,比如数据库或 API 进行存活或健康检查?在回答这些问题之前,你甚至是否有权限访问专用的生产服务器,还是需要开始请求为你的应用程序提供一个生产服务器?那么当你的应用程序受到大量活动的影响时会发生什么——你能够快速自动地扩展,还是必须重新开始配置过程?

考虑到这些问题,很容易理解为什么使用虚拟机(VM)的虚拟化变得如此有吸引力。虚拟机在隔离应用程序进程方面提供了更多的灵活性,而快照虚拟机可以在部署中提供一致性。然而,VM 映像很大,并且不容易移动,因为它们包含整个操作系统,这增加了它们的整体体积。

在向其他开发人员首次介绍容器时,我多次收到过这样的回答,“哦!所以容器就像虚拟机?”虽然将容器类比于虚拟机是方便的,但存在重要区别。虚拟机(VMware vSphere、Microsoft Hyper-V 等)是硬件的抽象,模拟完整的服务器。在某种意义上,整个操作系统都包含在虚拟机中。虚拟机由一个称为hypervisor的软件层管理,它根据需要将主机的资源划分和分配给虚拟机。

另一方面,容器并不像传统虚拟机那样重。例如,Linux 容器不包含整个操作系统,可以被视为共享主机操作系统的 Linux 发行版。正如在图 3-1 中所示,VM 和容器是不同的抽象级别,Java 虚拟机(JVM)也是如此。

JVM 在这一切中扮演了什么角色?当像虚拟机这样的术语被重载时会让人感到困惑。JVM 完全是一个不同的抽象,并且是一个进程虚拟机,与系统虚拟机形成对比。它的主要任务是为 Java 应用程序提供 Java 运行环境(或 JRE,即 JVM 的实现)。JVM 虚拟化主机的处理器,以便执行 Java 字节码。

dtjd 0301

图 3-1. 虚拟机与容器

容器是一个轻量级解决方案,承诺解决大部分围绕应用程序一致性、进程隔离和操作系统级别依赖的问题。这种打包服务或应用程序的方法可以利用缓存机制,大幅减少部署和启动应用程序所需的时间。与等待定制的供应和设置不同,容器可以部署到现有基础设施上——无论是现有的专用服务器、私人数据中心中的现有 VM,还是云资源。

即使您选择在生产环境中不使用容器,也强烈建议考虑一些围绕开发和测试环境的其他用例。

将新开发人员引入团队的一个重大挑战是设置他们的本地开发环境所花费的时间。一般来说,人们普遍认为,让开发人员达到能够贡献其第一个 bug 修复或改进的水平需要一些时间。虽然一些公司会规定开发工具(通常认为一致性可以提高支持工作的效率),但今天开发者比以往更有更多的选择。我认为,在开发人员已经习惯于不同工具时,强迫他们使用特定的工具集实际上会产生相反的效果。坦率地说,在许多情况下,这实际上已经不再必要——特别是现在我们可以利用容器。

容器有助于保持运行环境的一致性,并且在正确配置后,可以轻松在开发、测试或生产模式下启动。由于环境与应用程序一同打包在容器镜像中,因此由于缺少依赖项而导致服务或应用程序在这些环境中行为不同的风险大大降低。

这种可移植性提升了开发人员在本地环境中进行变更的理智测试能力,以及部署与生产环境中相同版本的代码以重现错误的能力。使用容器进行集成测试还带来了额外的好处,即尽可能地复现生产环境。例如,不再使用内存数据库进行集成测试,而是可以启动与生产中使用的数据库版本匹配的容器。像 TestContainers 这样的项目可以防止由于轻微的 SQL 语法或其他数据库软件版本之间的差异而导致的行为不规则。以这种方式使用容器可以通过避免在本地安装新软件或同一软件的多个版本而简化效率。

如果迄今为止我们对容器有什么了解,那就是它们以某种形式很可能会继续存在。本节从容器使用在过去几年中的指数增长开始,围绕容器生态系统不断开发和改进的工具集已经在开发和运营过程中获得了牢固的立足点。除了在完全不同方向上(请记住,容器已经有超过 50 年的历史了)可能会有巨大且目前未知的进展之外,你应该建议了解容器生态系统及如何充分利用这项技术。

容器解剖简介

作为开发人员,我第一次接触容器是通过一个由第三方承包商开发的项目,而现在这个项目由我的团队负责进一步开发和维护。除了将初始代码库引入我们内部的 GitHub 组织之外,还需要进行大量设置,以在项目周围建立我们的内部 DevOps 环境——设置我们的持续集成和部署(CI/CD)流水线,以及我们的开发和测试环境,当然还有我们的部署流程。

我将这种经历比作整理我的桌面(尤其是在几天疏忽之后)。这里我将完全过多地透露关于我的个人习惯的内容,但为了表达这一点,这是值得的。清理我的桌面最耗时的部分是一堆文件和邮件,它们总是长得足以倒塌。匆忙赶回家,将这些物品放在厨房柜台上,因为脑海中有其他紧急任务,常常放在已有的文件堆上……并且承诺稍后处理它们。问题是,我从不知道里面会有什么。这堆可能包含需要支付的账单、需要归档的重要文件,或者需要回复并在我们家庭日历上进行安排的邀请函或信件。我常常对预计要花费的时间感到害怕,这只会导致一堆被忽视的信件变得更大。

对于我们团队负责的项目,我的第一步是象征性地整理桌面。在源代码中找到的 Dockerfile 相当于解决了那些令人头疼的文件堆。尽管通过并学习这些概念是必要的,但我感觉自己被从手头任务上偏离了。在启动新项目时学习新技术有时并没有得到应有的时间规划,即使它会为项目进度表增加变数和固有风险。这并意味着新技术永远不应该引入。开发人员绝对需要学习行业的新变化和技术,但最好通过限制引入项目的新技术数量或在时间表的变化上坦率地面对来减少风险。

注意

Dockerfile 是一个包含提供容器蓝图指令的文本文件。这个文件通常命名为 Dockerfile,尽管最初是专门为 Docker 设计的,由于其广泛的使用,其他构建镜像工具也支持使用 Dockerfile 来构建容器镜像(如 Buildah、kaniko 和 BuildKit)。

这里提供的信息并非是对已有文档的简单复述(例如,在线Docker 入门指南非常出色)。相反,我希望像剥洋葱一样,以一种方式来介绍基础知识,并为您提供即时价值和足够的细节,以便更好地评估准备好自己的桌面并准备好开展业务所需的工作量。现在您已经掌握了关于容器及其产生过程的大量信息。接下来的部分涵盖了开发人员将接触到的术语和功能。

Docker 架构和容器运行时

就像 Kleenex 是面巾纸的品牌一样,Docker 是容器的品牌。Docker 公司围绕容器化开发了一整套技术栈。因此,即使Docker 容器Docker 镜像这些术语已经被泛化使用,但当你将 Docker Desktop 安装到你的开发机上时,你得到的不仅仅是运行容器的能力。你得到的是一个完整的容器平台,使得开发者能够轻松便捷地构建、运行和管理它们。

需要理解的是,安装 Docker 并非构建容器镜像或运行容器的必要条件。它只是一个被广泛使用且方便的工具而已。就像你可以在没有使用 Maven 或 Gradle 的情况下打包一个 Java 项目一样,你可以在没有使用 Docker 或 Dockerfile 的情况下构建一个容器镜像。我给新接触容器技术的开发者的建议是利用 Docker 提供的工具集,然后尝试其他选项或方法,以便对比和获得更好的使用体验。即使你选择使用 Docker 之外的其他工具或方法,花费在工程化良好的开发者体验上的时间和精力也足以给包含 Docker Desktop 在你的开发环境中带来很大的收益。

使用 Docker,你可以获得一个隔离的环境,用户/应用程序可以在其中操作,共享主机系统的操作系统/内核,而不会干扰同一系统上另一个隔离的环境(容器)的操作。Docker 使你能够做到以下几点:

  • 定义容器(一种镜像格式)

  • 构建容器镜像

  • 管理容器镜像

  • 分发/分享容器镜像

  • 创建容器环境

  • 启动/运行容器(容器运行时)

  • 管理容器实例的生命周期

容器领域远不止 Docker,但许多容器工具集的替代方案专注于这些项目的子集。从学习 Docker 如何运作开始,有助于理解和评估这些替代方案。

在线可以找到许多描述 Docker 架构的图片和图表。一个图片搜索很可能会得到一个版本的 图 3-2。这个图表相当好地展示了 Docker 在开发机上的工作原理 —— Docker CLI 是你可以使用的接口,用来向 Docker 守护进程发送命令来构建镜像,从外部仓库(默认是 Docker Hub)检索请求的镜像,在本地存储中管理这些镜像,然后使用这些镜像在你的机器上启动和运行容器。

Docker 架构

图 3-2. Docker 架构

当首次介绍这个领域时,其中一个更令人困惑的概念是对 Docker 生态系统的一个方面的关注:容器运行时。再强调一遍,这只是 Docker 提供的整个技术栈的一部分,但是因为编排框架如 Kubernetes 需要这部分功能来启动和运行容器,所以它通常被称为 Docker 的一个单独实体(在替代容器运行时的情况下,也是如此)。

关于容器运行时的主题值得单独列出这一节,因为对于新接触容器世界的人来说,这可能是最令人困惑的方面之一。更加令人困惑的是,容器运行时分为两种不同的类别,低级或高级,这取决于实现了哪些功能。而且为了让您保持警惕,这些功能集可能会有重叠。

这是一个展示容器运行时如何与您早前学到的 OCI 和诸如 containerd 和 runC 等项目结合在一起的可视化图表的好地方。图 3-3 说明了旧版和新版 Docker、高级和低级运行时之间的关系,以及 Kubernetes 的位置。

容器运行时

图 3-3. 容器生态系统中的运行时
提示

我遇到过的关于容器运行时的详细解释之一,并带有历史视角的最佳解释是由 Google 云平台团队的开发者倡导 Ian Lewis 撰写的 博客系列

在 2016 年发布的 1.11 版本之前,Docker 可以被描述为一个将整个运行时所需的功能集合以及其他管理工具封装在一起的单块应用程序。在过去的几年里,Docker 进行了大量的代码重组,开发了抽象化并提取了离散功能。Docker 贡献给 OCI 的 runC 项目就是出于这个努力。这是第一个,也是一段时间内唯一实现 OCI Runtime 规范的低级容器运行时的实现。

还有其他的运行时存在,并且截至目前,这是一个活跃的领域,因此请务必参考 OCI 维护的当前列表 以获取最新信息。值得注意的低级运行时项目包括 crun,由 Red Hat 领导的 C 实现;以及 railcar,由 Oracle 领导的 Rust 实现,尽管该项目现在已经存档。

制定规范是一项具有挑战性的任务,参与 OCI 运行时规范的协作同样具有挑战性。在发布 1.0 版本之前,花费了不少时间来确定边界——规范中应该包括什么内容和不应该包括什么内容。然而,显而易见的是,仅仅实现 OCI 运行时规范并不足以推动实施的采用。我们需要额外的功能来使低级运行时对开发者更加可用,因为我们关注的远不止容器的启动和运行。

这将我们引向更高级的运行时,如 containerdcri-o,这两个是当前的主要解决方案,涵盖了许多围绕容器编排的关注点,包括镜像管理和分发。这两个运行时都实现了 CRI(这简化了 Kubernetes 部署的路径),并将低级容器活动委托给符合 OCI 标准的低级运行时(例如 runC)。

您的机器上的 Docker

容器的第二个重要理解点是它们并非魔法。容器利用了现有的 Linux 特性(如本章开头所述)。容器的具体实现细节有所不同,但容器镜像本质上就是一个完整文件系统的 tar 压缩包,而运行中的容器则是一个受限的 Linux 进程,从而与主机上运行的其他进程隔离开来。例如,Docker 容器的实现主要涉及以下三个要素:

  • 命名空间

  • cgroups

  • 联合文件系统

但是容器在本地文件系统上是什么样子的呢?首先,让我们弄清楚 Docker 在开发机器上的存储位置。然后让我们来看一看从 Docker Hub 拉取的真实 Docker 镜像。

安装 Docker Desktop 后,从终端运行 docker info 命令将为您提供关于安装的详细信息。此输出包括关于镜像和容器存储位置的信息,标签为 Docker Root Dir。下面是示例输出(为简洁起见进行了截断),指示 Docker 根目录为 /var/lib/docker

$ docker info
Client:
 Context:    default
 Debug Mode: false
 Plugins:
  app: Docker App (Docker Inc., v0.9.1-beta3)
  buildx: Build with BuildKit (Docker Inc., v0.5.1-docker)
  compose: Docker Compose (Docker Inc., 2.0.0-beta.1)
  scan: Docker Scan (Docker Inc., v0.8.0)

Server:
 Containers: 5
  Running: 0
  Paused: 0
  Stopped: 5
 Images: 62
 Server Version: 20.10.6
 Storage Driver: overlay2
…
 Docker Root Dir: /var/lib/docker
…

这个结果来自 macOS Big Sur 上已有的 Docker Desktop(版本 3.3.3)安装。快速列出 /var/lib/docker 显示如下内容:

$ ls /var/lib/docker
ls: /var/lib/docker: No such file or directory

根据前面的输出,系统上有 5 个停止的容器和 62 个镜像,那么为什么这个目录不存在呢?输出有误吗?您可以查看另一个位置的镜像和容器存储位置,如 图 3-4,这是 Docker Desktop UI 的 macOS 版本中可用的“首选项”部分的截图。

然而,此位置完全不同。存在一个合理的解释,并且请注意,根据您的操作系统不同,您的安装可能略有不同。这个原因很重要,因为 Docker Desktop for Mac 需要在安装期间实例化一个 Linux 虚拟机来运行 Linux 容器。这意味着之前输出中提到的 Docker 根目录实际上是指向此 Linux 虚拟机内部的一个目录。

Docker Desktop Preferences

图 3-4. Docker Desktop Preferences

但等等……如果您在 Windows 上怎么办?因为容器共享主机的操作系统,所以基于 Windows 的容器需要在 Windows 环境中运行,而基于 Linux 的容器需要在 Linux 环境中运行。Docker Desktop(版本 3.3.3)相比早期版本(即 Docker Toolbox),无需安装额外的支持软件即可运行 Linux-based 容器是一大进步。在旧版本中,要在 Mac 上运行 Docker,您需要安装像 VirtualBox 和 boot2docker 这样的软件才能如预期地启动和运行。今天,Docker Desktop 在幕后处理所需的虚拟化。Docker Desktop 还通过 Windows 10 上的 Hyper-V 支持 Windows 容器,以及通过 Windows Subsystem for Linux 2(WSL 2)在 Windows 10 上支持 Linux 容器。然而,要在 macOS 上运行 Windows 容器,仍然需要 VirtualBox。

现在你知道我们需要访问 Linux 虚拟机才能进入这个 Docker 根目录,让我们使用命令 docker pull *IMAGE NAME* 拉取一个 Docker 镜像,并看看它在文件系统中的样子:

$ docker pull openjdk
Using default tag: latest
latest: Pulling from library/openjdk
5a581c13a8b9: Pull complete
26cd02acd9c2: Pull complete
66727af51578: Pull complete
Digest: sha256:05eee0694a2ecfc3e94d29d420bd8703fa9dcc64755962e267fd5dfc22f23664
Status: Downloaded newer image for openjdk:latest
docker.io/library/openjdk:latest

命令 docker images 列出了所有本地存储的镜像。从输出中可以看出,我们之前拉取的命令带来了带有标签 latestopenjdk 镜像。这是默认行为,但我们也可以指定特定的 openjdk 镜像版本,比如 docker pull openjdk:11-jre

$ docker images
REPOSITORY        TAG             IMAGE ID          CREATED        SIZE
...
openjdk           latest          de085dce79ff     10 days ago     467MB
openjdk           11-jre          b2552539e2dd     4 weeks ago     301MB
...

您可以通过使用 image ID 运行 **docker inspect** 命令来了解最新的 openjdk 镜像的更多详细信息:

$ docker inspect de085dce79ff
[
    {
        "Id": "sha256:de085dce79ff...",
        "RepoTags": [
            "openjdk:latest"
        ],
...
        "Architecture": "amd64",
        "Os": "linux",
        "Size": 467137618,
        "VirtualSize": 467137618,
        "GraphDriver": {
            "Data": {
                "LowerDir": "/var/lib/docker/overlay2/581137...ca8c47/diff:/var
                /lib/docker/overlay2/7f7929...8f8cb4/diff",
                "MergedDir": "/var/lib/docker/overlay2/693641...940d82/merged",
                "UpperDir": "/var/lib/docker/overlay2/693641...940d82/diff",
                "WorkDir": "/var/lib/docker/overlay2/693641...940d82/work"
            },
            "Name": "overlay2"
        },
        "RootFS": {
            "Type": "layers",
            "Layers": [
                "sha256:1a3adb4bd0a7...",
                "sha256:046fa1e6609c...",
                "sha256:a8a84740beab..."
            ]
        },
...

docker inspect 命令会输出大量有趣的信息。但我想在这里强调的是 GraphDriver 部分,其中包含属于此镜像的所有层所在的路径。

Docker 镜像由对应于 Dockerfile 中用于构建镜像的指令的层组成。这些层被转换为目录,并且可以在不同镜像之间共享以节省空间。

注意 LowerDirMergedDirUpperDir 部分。LowerDir 部分包含用于构建原始镜像的所有目录或层,这些层是只读的。UpperDir 目录包含容器运行时修改的所有内容。如果需要对 LowerDir 中的只读层进行修改,则会将该层复制到 UpperDir 中,然后可以对其进行写入操作。这称为写时复制操作。

重要的是要记住 UpperDir 中的数据是临时数据,仅在容器存在期间有效。事实上,如果您有意保留的数据,应该利用 Docker 的卷特性并挂载一个即使容器停止后仍然存在的位置。例如,运行在容器中的数据库驱动应用程序可能会利用挂载到容器的卷来存储数据库数据。

最后,MergedDir 部分有点像虚拟目录,它将 LowerDirUpperDir 中的所有内容合并在一起。联合文件系统的工作方式是,任何编辑后复制到 UpperDir 的层将覆盖 LowerDir 中的层。

警告

注意所有对 /var/lib/docker 目录的引用,这是 Docker 的根目录。如果您监控此目录的大小,会发现随着创建和运行的镜像和容器数量增加,该目录所需的存储空间会显著增加。考虑挂载一个专用驱动器,并确保定期清理未使用的镜像和容器。此外,确保容器化应用程序不会持续产生未管理的数据文件或其他工件。例如,可以使用日志传送或日志轮换来管理容器及其运行进程生成的日志。

可以使用相同的镜像启动任意数量的容器。每个容器将以镜像蓝图创建并独立运行。在 Java 的上下文中,将容器镜像视为 Java 类,将容器视为从该类实例化的 Java 对象。

可以停止并稍后重新启动容器,而无需重新创建。要列出系统上的容器,请使用 docker ps -a 命令。请注意,-a 标志将显示已停止的容器以及当前正在运行的容器:

$ docker ps -a
CONTAINER ID   IMAGE     COMMAND     STATUS                      NAMES
9668ba978683   openjdk   "tail -f"   Up 19 seconds               vibrant_jang
582ad818a57b   openjdk   "jshell"    Exited (0) 14 minutes ago   zealous_wilson

如果您导航到 Docker 的根目录,您会看到一个名为 containers 的子目录。在这个目录中,您会找到根据系统上每个容器的 container ID 命名的额外子目录。停止的容器将在这些目录中保留其状态和数据,以便在需要时可以重新启动。当使用 docker rm *CONTAINER NAME* 删除容器时,相应的目录将被删除。

警告

记得定期清理系统中未使用的容器(删除而不仅仅是停止)。我亲眼见证了缺少这个部署过程的情况。每次发布新镜像时,旧容器都会停止,基于新镜像启动新容器。这是一个疏忽,很快就会消耗硬盘空间,最终阻止新的部署。以下 Docker 命令可以批量清理未使用的容器:

docker container prune

docker-desktop:~# ls /var/lib/docker/
builder     containers  overlay2    swarm   volumes
buildkit    image       plugins     tmp
containerd  network     runtimes    trust

docker-desktop:~# ls /var/lib/docker/containers/
9668ba978683b37445defc292198bbc7958da593c6bb3cef6d7f8272bbae1490
582ad818a57b8d125903201e1bcc7693714f51a505747e4219c45b1e237e15cb
注意

如果你在 Mac 上进行开发,请记住你的容器运行在一个小型虚拟机中,你需要首先访问该虚拟机,然后才能查看 Docker 根目录的内容。例如,在 Mac 上,您可以通过以特权模式交互式运行一个已安装nsenter的容器来访问和导航到此目录(可能需要使用sudo运行):

docker run -it --privileged --pid=host debian \
nsenter -t 1 -m -u -n -i sh

较新的 Windows 版本(10+)现在具有使用 Windows Subsystem for Linux(WSL)原生运行 Linux 容器的功能。在文件资源管理器中可以找到 Windows 11 Home 的默认 Docker 根目录:

*\wsl.localhost\docker-desktop-data\version-pack-data* *community\docker*

基本标记和镜像版本管理

使用镜像一段时间后,您会发现其标识和版本管理与您对 Java 软件版本化的方式有所不同。使用像 Maven 这样的构建工具已经让大多数 Java 开发者习惯于标准的语义版本控制,并始终指定依赖版本(或者至少接受 Maven 在特定依赖树中选择的版本)。这些限制措施在其他包管理器(如 npm)中稍微放松,其中可以将依赖版本指定为范围,以便轻松和灵活地更新依赖项。

如果不理解,镜像版本管理可能会成为一个障碍。没有(至少不是 Java 开发者习惯的那种)限制措施。在标记镜像方面的灵活性优于任何强制执行的良好实践。然而,仅仅因为你做到,并不意味着你应该这样做,就像正确版本化 Java 库和包一样,最好从一开始就采用符合逻辑和遵循公认模式的命名和版本化方案。

容器镜像名称和版本遵循特定的格式,包括多个组件,这些在示例和教程中很少以完整形式出现。大多数在互联网上找到的示例代码和 Dockerfile 都使用缩写格式标识镜像。

将图像管理视为目录结构最为直观,其中图像的名称(例如openjdk)是包含此图像所有可用版本的目录。通常使用图像的名称和版本,称为标签来标识图像。但这两个组件由子组件组成,如果未指定,则具有默认值,并且通常在命令中甚至会省略标签。例如,拉取openjdk Docker 图像的最简单命令可能采用以下形式:

docker pull openjdk

这个命令实际上给我们带来了什么?openjdk图像有几个版本可供选择?确实有,如果您关注可重复构建,您会立即注意到此模糊性可能是一个问题。

第一步是在此命令中包含图像标签,表示一个版本。以下命令意味着我将拉取openjdk图像的版本 11:

docker pull openjdk:11

那么之前我拉取的是什么,如果不是 11?如果未指定标签,默认情况下会隐含使用特殊标签latest。此标签旨在指向可用图像的最新版本,但情况并非总是如此。任何时候,都可以更新标签以指向图像的不同版本,并且在某些情况下,您可能会发现标签latest根本未设置指向任何内容。

同样容易出错的是命名规则,特别是标签,在不同的上下文中可能意味着不同的东西。术语标签可以指代特定版本,也可以指代包括所有标识组件在内的完整图像标签,包括图像名称

下面是包含所有可能组件的 Docker 图像标签的完整格式:

[ *registry* [ :*port* ] / ] *name* [ :*tag* ]

唯一必需的组件是图像的名称,也称为图像仓库。如果未指定标签,则假定为latest。如果未指定注册表,则 Docker Hub 是默认注册表。以下命令是如何引用不同于 Docker Hub 上的注册表中的图像的示例:

docker pull artifactory-prod.jfrog.io/openjdk:11

图像和容器层

要构建高效的容器,深入理解层的重要性至关重要。构建容器源代码(即容器图像)的详细信息极大地影响其大小和性能,并且一些方法还涉及安全问题,使这一概念变得更加重要。

基本上,Docker 镜像是通过建立基础层,然后逐步进行小的更改,直到达到所需的最终状态而构建的。每个图层代表一组更改,包括但不限于创建用户及相关权限、修改配置或应用程序设置,以及更新现有软件包或添加/删除软件包。这些更改都是对最终文件系统中文件集的添加、修改或移除。图层叠加在彼此之上,每个图层都是从前一个图层的更改增量,并由其内容的 SHA-256 哈希摘要进行标识。如“你的机器上的 Docker”所讨论的那样,这些图层存储在根 Docker 目录中。

可视化图层

一个真正可视化图层的好方法是使用命令行工具dive,该工具可在GitHub上找到。图 3-5 展示了使用从 Docker Hub 拉取的官方最新openjdk镜像运行该工具的屏幕截图。左侧窗格显示了组成openjdk镜像的三个图层的详细信息。右侧窗格突出显示了每个图层对镜像文件系统应用的更改。

Dive OpenJDK

图 3-5. dive与 openjdk

dive工具在展示基于openjdk镜像启动容器时文件系统的外观时非常有用。随着你浏览每个后续图层,你可以看到对初始文件系统所做的更改。这里最重要的部分是,后续的图层可能会混淆前一个图层文件系统的部分(如果有文件的移动或删除),但原始图层仍以其原始形式存在。

利用层缓存

利用图像层可以加快图像请求、构建和推送速度。这是减少图像存储所需空间的巧妙方法。这种策略允许多个图像共享相同的图像层,并减少本地缓存或存储在注册表中的图像拉取或推送所需的时间和带宽。

如果你使用 Docker,系统将保留你从外部注册表请求或自行构建的所有图像的内部缓存。在推送和拉取新图像时,会在本地缓存和注册表之间比较每个图像层,并决定是推送还是拉取单个图层,以提高效率。

任何曾经与内部 Maven 仓库(我们都曾在某个时刻吧?)或任何缓存机制(如此类推)挣扎过的人都非常清楚,内部缓存提供的效率和性能改进也伴随着注意事项。有时候你在缓存中存储的并不是你想要使用的内容。在活跃的开发和本地测试中,如果不注意如何及何时使用本地镜像缓存,很容易出现使用过期缓存的情况。

例如,命令 docker run openjdkdocker pull openjdk 在涉及缓存时表现不同。前者在本地缓存中查找指定标签为 latest 的镜像。如果镜像存在,搜索将被视为满足,将启动一个基于缓存镜像的新容器。后者的命令将进一步更新来自远程注册表中存在更新的 openjdk 镜像。

另一个常见的错误是假设 Dockerfile 中的命令在重新构建镜像时会再次运行。这在 RUN 命令中尤为常见,如 RUN apt-get update。如果 Dockerfile 中的这行代码根本没有变化,比如你未指定包名及其具体版本,那么包含此命令的初始层将存在于缓存中,不会重新构建。这不是错误,而是缓存的一种特性,用于加快构建过程。如果确定层已构建,则不会重新构建该层。

为了避免过期缓存,你可能会试图在 Dockerfile 中将多个命令组合成一行(生成一个层),以便更容易识别和更频繁地执行更改。但这种方法的问题在于,如果把太多内容压缩成一个层,将完全失去缓存的好处。

提示

作为开发人员,要注意本地缓存。除了本地开发外,还要考虑持续集成、构建服务器和自动集成测试如何使用缓存。确保所有系统在这方面的一致性将有助于避免不明原因和间歇性的失败。

最佳镜像构建实践和容器注意事项

在构建和使用镜像时,你会发现即使是最基本的构建过程也可能在许多地方给自己制造麻烦。以下是一些在开始镜像构建旅程时要牢记的实践方法。你会发现更多内容,但这些是最重要的。

尊重 Docker 上下文和 .dockerignore 文件。

不希望将开发环境配置、密钥、.git目录或其他敏感隐藏目录包含在生产 Docker 镜像中。构建 Docker 镜像时,需提供上下文,即要在构建过程中提供文件的位置。

以下是一个虚构的 Dockerfile 示例:

FROM ubuntu

WORKDIR /myapp

COPY . /myapp

EXPOSE 8080

ENTRYPOINT ["start.sh"]

看到COPY指令了吗?取决于您作为上下文发送的内容,这可能会有问题。它可能会将所有工作目录中的内容复制到您构建的 Docker 映像中,并最终出现在从此映像启动的任何容器中。

确保使用.dockerignore文件来排除上下文中不希望无意中出现的文件。您可以使用它来避免意外添加任何本地存储的用户特定文件或机密信息。事实上,通过排除构建不需要访问的任何内容,您可以大大减少上下文的大小(以及构建所需的时间):

# Ignore these files in my project
**/*.md
!README.md
passwords.txt
.git
logs/
*/temp
**/test/

.dockerignore匹配格式遵循Go 的filepath.Match规则

使用可信的基础映像

无论您选择使用包含 OpenJDK、Oracle JDK、GraalVM 或其他包含 Web 服务器或数据库的映像,确保使用可信的映像作为父映像,或者从头开始创建您自己的映像。

Docker Hub 自称是全球最大的公共可用容器映像库,拥有来自软件供应商、开源项目和社区的超过 100,000 个映像。并非所有这些映像都应该信任用作基础映像。

Docker Hub 包含一组经策划的映像,标记为“Docker 官方映像”,适合用作基础映像(请注意,分发这些映像需要与 Docker 的协议达成一致)。这些详细信息来自官方映像的在线 Docker 文档

Docker, Inc.赞助了一个专门的团队,负责审查和发布 Docker 官方映像中的所有内容。该团队与上游软件维护者、安全专家以及更广泛的 Docker 社区合作。

与了解 Java 依赖项引入到您的项目及依赖树深度同样重要的是,了解您的基础映像在 Dockerfile 顶部的那一行FROM中带入了什么。Dockerfile 的继承结构很容易掩盖基础映像在其后带入的额外库和包,这些可能是您不需要的,甚至可能是恶意内容。

指定包版本并跟上更新

鉴于前面讨论过的有关缓存的警告以及保持可重复构建的愿望,像在 Java 项目中一样在您的 Dockerfile 中指定版本。避免由新版本或意外更新引起的构建失败和意外行为。

尽管如此,如果由于构建失败或测试而强迫您查看版本,很容易对更新版本变得漠不关心。定期审计项目以获取所需的更新,并使这些更新成为有意义的。这应该成为您常规项目规划的一部分。我建议将这种活动与任何其他特性开发或错误修复分开,以消除开发生命周期中无关的动态部分。

保持镜像小巧

镜像很容易变得非常大,速度很快。在自动化构建中监控大小的增加,并设置异常大小变化的通知。贪吃的磁盘存储包可以通过基础镜像的更新轻易悄然进入,或者无意中包含在COPY语句中。

利用多阶段构建保持您的镜像小巧。可以通过创建一个使用多个FROM语句的 Dockerfile 来设置多阶段构建,每个语句都使用不同的基础镜像开始构建阶段。通过使用多阶段构建,您可以避免在生产镜像中包含不需要的(实际上也不应该包含)构建工具或包管理器。例如,以下 Dockerfile 显示了一个两阶段构建。第一阶段使用包含 Maven 的基础镜像。在 Maven 构建完成后,所需的 JAR 文件被复制到第二阶段,该阶段使用不包含 Maven 的镜像:

###################
# First build stage
###################

FROM maven:3.8.4-openjdk-11-slim as build

COPY .mvn .mvn
COPY mvnw .
COPY pom.xml .
COPY src src

RUN ./mvnw package

####################
# Second build stage
####################

FROM openjdk:11-jre-slim-buster

COPY --from=build target/my-project-1.0.jar .

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "my-project-1.0.jar"]

这也是实现自定义distroless镜像的好方法,该镜像已经剥离了所有内容(包括 shell),只保留了运行应用程序的绝对必需品。

警惕外部资源

我经常看到在 Dockerfile 中请求外部资源的要求,形式为用于安装专有软件的wget命令,甚至用于执行自定义安装的 shell 脚本的外部请求。这使我感到恐惧。这不仅仅是普通的怀疑和偏执。即使外部资源是可信的,当你把构建的控制权交给外部方之后,你更可能遭遇无法解决的构建失败。

当我指出这一观察时,我经常得到的第一个反应是:“不用担心,因为一旦构建了您的镜像,它就被缓存或存储在基础镜像中,您将永远不必再次发出请求。”

这是绝对真实的。一旦您存储了基础镜像或您的镜像层被缓存,您就可以放心了。但是,当新的构建节点(没有缓存)投入使用时,或者当新的开发人员加入您的团队时,构建该镜像可能会失败。当您需要构建基础镜像的新版本时,您的构建可能会失败。为什么?因为一次又一次地,资源的外部管理者会移动它们,限制对它们的访问,或者简单地丢弃它们

保护你的机密

我包括这个因为除了一开始就不将机密移入您的映像之外,不要认为在 Dockerfile 中使用命令从基础映像或任何先前层中删除它们就足够好。我曾经见过这种情况,将其作为“修复”无法立即重建的基础层的黑客方式。

现在您了解了层次结构的工作原理,您知道后续层次删除项目并不实际从底层删除它们。如果您对基于该映像的运行容器执行exec,则看不到它们,但它们仍然存在。它们存在于存储映像的系统上,它们存在于启动基于该映像的容器的任何地方,它们还存在于您选择的映像注册表中,以供长期存储。这几乎相当于将您的密码检入源代码控制中。一开始就不要将机密放入映像中。

了解您的输出

多种因素可能导致容器在运行时持续增长。其中最常见的之一是未适当处理日志文件。确保您的应用程序记录到一个卷中,您可以实施日志轮换解决方案。考虑到容器的临时性质,将用于故障排除或合规性的日志存储在容器内部(在 Docker 主机上)是没有意义的。

总结

本章的大部分内容是关于探索 Docker 的。这是一个开始的绝佳地方。一旦您熟悉了映像和容器,您可以扩展到生态系统中提供的其他工具。根据您选择的操作系统和项目构建工具,诸如BuildahPodmanBazel这样的工具可能非常适合您。您还可以选择使用像Jib这样的 Maven 插件来构建您的容器映像。

有一个警告:无论选择哪种工具,请了解您的映像和容器是如何构建的,以免在准备部署时遭受臃肿和/或不安全的映像和容器的后果。

第四章:解构单体架构

伊什切尔·鲁伊兹

终极目标应该是通过数字创新改善人类生活的质量。

马化腾

通过历史,人类一直着迷于将想法和概念分解为简单或复合部分。通过分析和综合的结合,我们可以达到更高层次的理解。

亚里士多德称分析为“将每个复合物分解成组成这些合成物的要素。因为分析是综合的反面。综合是从原则到由原则派生的事物的道路,而分析是从终点返回到原则。”

软件开发遵循类似的方法:将系统分析为其组成部分,识别输入、期望输出和详细功能。在软件开发的分析过程中,我们意识到,非特定于业务的功能总是需要来处理输入,并通信或持久化输出。这使得明显的是,我们可以从可重复使用的、明确定义的、上下文绑定的原子功能中受益,这些功能可以被共享、消费或互连,以简化软件构建。

允许开发人员主要专注于实现业务逻辑,以满足客户/企业的明确定义的需求,满足一些潜在用户集的感知需求,或者使用功能来满足个人需求(自动化任务)一直是长期以来的愿望。每天都浪费太多时间在重新发明最常见的可靠样板代码。

近年来,微服务模式因承诺的优势而声名鹊起并获得动力。在实现这种架构模式的好处的同时,减少采用它的缺点,避免已知的反模式,采用最佳实践,并理解核心概念和定义至关重要。本章涵盖了微服务的反模式,并包含了使用 Spring Boot、Micronaut、Quarkus 和 Helidon 等流行微服务框架编写的代码示例。

传统上,单体架构提供或部署单个单元或系统,从单一源应用程序满足所有需求,可以识别出两个概念:单体应用程序单体架构

单体应用程序唯一的 部署实例,负责执行特定功能所需的所有步骤。这种应用程序的一个特征是独特的执行接口点。

单块架构指的是所有需求均由单一来源处理,并且所有部分作为一个单元交付的应用程序。组件可能被设计为限制与外部客户的交互,以显式限制私有功能的访问。单块中的组件可能是相互连接或相互依赖的,而不是松散耦合的。换句话说,从外部或用户的视角来看,对其他独立组件的定义、接口、数据和服务知之甚少。

粒度是一个组件向软件的其他外部合作或协作部分公开的聚合级别。软件的粒度水平取决于几个因素,例如必须在一系列组件内保持的机密级别,不可暴露或对其他消费者可用。

现代软件架构越来越专注于通过捆绑或组合来自不同来源的软件组件来提供功能,这导致或强调了详细级别上的更细粒度。因此,向不同组件、客户或消费者公开的功能要比单块应用程序更多。

要确定一个模块有多独立或可互换,我们应该仔细看以下特征:

  • 依赖的数量

  • 这些依赖的强度

  • 它所依赖的模块的稳定性

对前述特征赋予的任何高分应触发对模块建模和定义的第二次审查。

云计算

云计算有多个定义。彼得·梅尔(Peter Mell)和蒂姆·格兰斯(Tim Grance)将其定义为一种模型,用于实现对共享可配置计算资源池(如网络、服务器、存储、应用程序和服务)的无处不在、方便、按需网络访问,可以快速配置和释放,几乎不需要管理工作或与服务提供商的互动。

近年来,云计算有了显著增长。例如,2020 年第四季度云基础设施服务支出增长了 32%,达到了 399 亿美元。总支出比上一季度高出 30 亿美元,比 2019 年第四季度高出近 100 亿美元,据Canalys 数据显示。

存在多家提供商,但市场份额并不均匀分布。三家领先的服务提供商是亚马逊网络服务(AWS)、微软 Azure 和谷歌云。AWS 是领先的云服务提供商,在 2020 年第四季度占据了总支出的 31%。Azure 的增长率加快,增长了 50%,市场份额接近 20%,而谷歌云占据了总市场的 7%。

云计算服务的利用一直存在滞后。Cinar Kilcioglu 和 Aadharsh Kannan 在 2017 年在“第 26 届国际万维网会议”上报告,数据中心中云资源的使用显示出租户配置和支付的资源(租用 VM)与实际资源利用(CPU、内存等)之间存在显著差距。也许客户只是将他们的 VM 保持开启,但实际上并没有使用它们。

云服务根据用于不同类型计算的类别进行划分:

软件即服务(SaaS)

客户可以使用提供者在云基础设施上运行的应用程序。这些应用程序可以通过薄客户端接口(如 Web 浏览器)或程序接口从各种客户端设备访问。客户不管理或控制底层云基础设施,包括网络、服务器、操作系统、存储甚至单个应用程序功能,但可能有限制的特定于用户的应用程序配置设置。

平台即服务(PaaS)

客户可以将使用由提供者支持的编程语言、库、服务和工具创建的客户制作或购买的应用程序部署到云基础设施。消费者不管理或控制底层云基础设施,包括网络、服务器、操作系统或存储,但可以控制已部署的应用程序,可能还可以配置应用程序托管环境的设置。

基础设施即服务(IaaS)

客户能够配置处理、存储、网络和其他基本计算资源。他们可以部署和运行任意软件,其中包括操作系统和应用程序。客户不管理或控制底层云基础设施,但可以控制操作系统、存储和部署的应用程序,并可能对某些网络组件有限的控制。

微服务

微服务 这个术语并不是最近才出现的。彼得·罗杰斯在 2005 年提出了微网络服务 这个术语,同时倡导软件即微网络服务 这一概念。微服务架构 ——作为面向服务架构(SOA)的一种演变——将应用程序组织为一组相对轻量级的模块化服务。从技术上讲,微服务是 SOA 实现方法的一种特殊化。

微服务 是小型且松散耦合的组件。与单体应用程序相比,它们可以独立部署、扩展和测试,具有单一职责,由上下文界定,是自治的和分散的。它们通常围绕业务能力构建,易于理解,并可以使用不同的技术栈进行开发。

一个微服务应该有多小?它应该足够微小,以允许小型、自包含和严格执行的功能原子共存、发展或替换前一个版本,以适应业务需求。

每个组件或服务几乎不了解其他独立组件的定义,与服务的所有交互都通过其 API 进行,该 API 封装了其实现细节。这些微服务之间的消息传递使用简单的协议,通常不需要大量数据。

反模式

微服务模式导致了显著的复杂性,并非在所有情况下都是理想的。该系统由许多独立工作的部分组成,其本质使其更难以预测在现实世界中的表现如何。

这种增加的复杂性主要是由于(潜在的)成千上万的微服务在分布式计算机网络中异步运行。请记住,难以理解的程序也难以编写、修改、测试和衡量。所有这些问题都将增加团队理解、讨论、跟踪和测试接口和消息格式所需的时间。

关于这个特定主题有几本书籍、文章和论文可供参考。我推荐访问Microservices.io、马克·理查兹(Mark Richards)的报告Microservices AntiPatterns and Pitfalls(O’Reilly)以及 2018 年大卫德比(Davide Taibi)和瓦伦蒂娜·莱纳杜茨(Valentina Lenarduzz)在《IEEE 软件》上发表的“关于微服务坏味道定义”的论文。

一些最常见的反模式包括以下内容:

API 版本控制(静态协议陷阱

API 需要进行语义版本控制,以允许服务知道它们是否正在与正确版本的服务通信,或者是否需要调整其通信以适应新的协议。

不当的服务隐私依赖性

微服务需要其他服务的私密数据而不是处理自己的数据,这通常与数据建模问题有关。可以考虑的解决方案之一是合并这些微服务。

多用途巨型服务

几个业务功能被实现在同一个服务中。

记录

错误和微服务信息被隐藏在每个微服务容器内。在软件生命周期的各个阶段发现问题时,应优先采用分布式日志记录系统。

复杂的服务间或循环依赖

循环服务关系 被定义为两个或多个相互依赖的服务之间的关系。循环依赖可能会损害服务扩展或独立部署的能力,并违反无环依赖原则(ADP)。

缺失的 API 网关

当微服务直接相互通信,或者当服务消费者直接与每个微服务通信时,系统的复杂性增加,维护减少。在这种情况下的最佳实践是使用 API 网关。

一个 API 网关 接收来自客户端的所有 API 调用,然后通过请求路由、组合和协议转换将它们引导到适当的微服务。网关通常通过调用多个微服务并聚合结果来处理请求,以确定最佳路由。它还能够在内部使用之间进行 Web 协议和 Web 友好协议之间的转换。

应用程序可以使用 API 网关为移动客户端提供一个单一的端点,通过单个请求查询所有产品数据。API 网关整合了各种服务,如产品信息和评论,并将结果合并和公开。

API 网关是应用程序访问数据、业务逻辑或功能(RESTful API 或 WebSocket API)的门卫,允许实时双向通信应用程序。API 网关通常处理接受和处理多达数十万个并发 API 调用的所有任务,包括流量管理、跨源资源共享(CORS)支持、授权和访问控制、阻塞、管理和 API 版本控制。

过度共享

在分享足够的功能以避免重复自己与创建依赖混乱的纠结之间,有一条薄线阻止了服务变更分离。如果需要更改过度共享的服务,评估接口的建议变更最终会导致一个涉及更多开发团队的组织任务。

在某些时候,需要分析是否将冗余或库提取到新的共享服务中,这些相关的微服务可以独立安装和开发。

DevOps 和 微服务

微服务完美地符合 DevOps 理念,利用小团队逐步对企业服务进行功能更改——将大问题分解成小片段并系统化处理的理念。为了减少开发、测试和部署之间的摩擦,必须存在一系列持续交付管道,以保持这些阶段的稳定流动。

DevOps 是这种架构风格成功的关键因素,提供必要的组织变更,以最小化负责每个组件的团队之间的协调,并消除开发和运营团队之间有效互动的障碍。

警告

我强烈反对任何团队在没有健全的 CI/CD 基础设施或对流水线基本概念没有广泛理解的情况下采用微服务模式。

微服务框架

JVM 生态系统庞大且提供了许多特定用例的替代方案。提供了几十种微服务框架和库,以至于在候选项中选择优胜者可能有些棘手。

话虽如此,由于几个原因,某些候选框架已经获得了流行:开发者体验、上市时间、可扩展性、资源(CPU、内存)消耗、启动速度、故障恢复、文档、第三方集成等等。这些框架——Spring Boot、Micronaut、Quarkus 和 Helidon——在接下来的章节中进行了介绍。请注意,一些说明可能需要根据更新版本进行额外调整,因为其中一些技术正在快速发展。我强烈建议查阅每个框架的文档。

此外,这些示例需要至少 Java 11,并且尝试使用本地镜像还需要安装 GraalVM。有许多方法可以在您的环境中安装这些版本。我建议使用SDKMAN!来安装和管理它们。为简洁起见,我专注于生产代码——一个单一框架可以填写整本书!毫无疑问,您还应该关注测试。每个示例的目标是构建一个简单的“Hello World” REST 服务,该服务可以接受一个可选的名称参数并回复问候语。

如果您以前没有使用过 GraalVM,它是一个涵盖几种技术的综合项目,使以下功能成为可能:

  • 一个用 Java 编写的即时编译器(JIT),可以在运行时编译代码,将解释代码转换为可执行代码。Java 平台已经有过几个 JIT,大多数是用 C 和 C++组合编写的。Graal 碰巧是最现代的一个,用 Java 编写。

  • Substrate VM 是一个虚拟机,能够在 JVM 之上运行托管语言,如 Python、JavaScript 和 R,使得托管语言能够更紧密地集成 JVM 的能力和特性。

  • 本地镜像是一种依赖预编译(AOT)的实用工具,将字节码转换为机器可执行代码。所得的转换产生一个特定于平台的二进制可执行文件。

这里介绍的四个候选框架都以某种方式支持 GraalVM,主要依赖于 GraalVM Native Image 来生成特定于平台的二进制文件,旨在减少部署大小和内存消耗。请注意,在使用 Java 模式和 GraalVM Native Image 模式之间存在权衡。后者可以生成具有较小内存占用和更快启动时间的二进制文件,但需要较长的编译时间;长时间运行的 Java 代码最终会变得更加优化(这是 JVM 的关键特性之一),而原生二进制文件在运行时无法进行优化。开发体验也各不相同,您可能需要使用额外的工具进行调试、监控、测量等。

Spring Boot

Spring Boot 可能是这四个候选框架中最为人熟知的,因为它建立在 Spring Framework 所奠定的遗产之上。如果开发者调查结果可信,超过 60% 的 Java 开发者在与 Spring 相关的项目中有一定的经验,使 Spring Boot 成为最受欢迎的选择。

Spring 的方式允许您通过组合现有组件、定制其配置并承诺低成本的代码拥有权来组装应用程序(或在我们的情况下是微服务),因为您的自定义逻辑理论上应比框架提供的内容更小,对于大多数组织来说这是正确的。关键是找到一个可以在编写自己的组件之前进行调整和配置的现有组件。Spring Boot 团队着重于添加所需的多个有用集成,从数据库驱动程序到监控服务、日志记录、日志处理、批处理、报告生成等等。

启动 Spring Boot 项目的典型方式是浏览至 Spring Initializr,选择您在应用程序中需要的功能,然后单击生成按钮。此操作将创建一个 ZIP 文件,您可以将其下载到本地环境以开始使用。在 图 4-1 中,我选择了 Web 和 Spring Native 功能。第一个功能添加了组件,使您可以通过 REST API 公开数据;第二个功能增强了构建,使用 Graal 可以创建额外的打包机制,生成原生镜像。

在项目的根目录解压 ZIP 文件并运行./mvnw verify命令,确保一个健康的起点。如果您之前没有在目标环境上构建过 Spring Boot 应用程序,您会注意到该命令会下载一组依赖。这是正常的 Apache Maven 行为。除非在 pom.xml 文件中更新了依赖版本,否则下次调用 Maven 命令时不会再次下载这些依赖。

dtjd 0401

图 4-1. Spring Initializr

项目结构应该是这样的:

.
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── demo
    │   │               ├── DemoApplication.java
    │   │               ├── Greeting.java
    │   │               └── GreetingController.java
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test
        └── java

我们当前的任务需要两个未由 Spring Initializr 网站创建的附加源:Greeting.javaGreetingController.java。这两个文件可以使用您选择的文本编辑器或 IDE 创建。首先,Greeting.java 定义了一个数据对象,将用于将内容呈现为 JavaScript 对象表示法(JSON),这是一种通过 REST 公开数据的典型格式。还支持其他格式,但是 JSON 支持无需任何额外的依赖项即可直接使用。此文件应如下所示:

package com.example.demo;

public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

除了它是不可变的数据持有者之外,这个数据持有者没有什么特别之处;根据您的用例,您可能希望切换到可变的实现,但目前这样就足够了。接下来是 REST 端点本身,定义为 /greeting 路径上的一个GET调用。Spring Boot 更偏爱 controller 的原型来创建这种组件,毫无疑问是在回顾 Spring MVC(是的,那就是模型-视图-控制器)作为首选选项来创建 Web 应用程序的日子里。可以随意使用不同的文件名,但是组件的注解必须保持不变:

package com.example.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {
    private static final String template = "Hello, %s!";

    @GetMapping("/greeting")
    public Greeting greeting(@RequestParam(value = "name",
        defaultValue = "World") String name) {
        return new Greeting(String.format(template, name));
    }
}

控制器可以接受 name 参数作为输入,并在未提供此参数时使用值 World。请注意,映射方法的返回类型是一个普通的 Java 类型;这是我们在前一步中定义的数据类型。Spring Boot 将根据应用于控制器及其方法的注解和设置的合理默认值,自动将数据从 JSON 格式转换为 JSON 格式。如果我们保持代码不变,greeting() 方法的返回值将自动转换为 JSON 负载。这是 Spring Boot 开发经验的威力,依赖于可以根据需要进行微调的默认值和预定义的配置。

您可以通过调用 /.mvnw spring-boot:run 命令来运行应用程序,该命令将在构建过程中运行应用程序,也可以通过生成应用程序 JAR 并手动运行它来运行应用程序,即 ./mvnw package 后跟 java -jar target/demo-0.0.1.SNAPSHOT.jar。无论哪种方式,都会启动一个内嵌的 Web 服务器,监听 8080 端口; /greeting 路径将映射到 GreetingController 的一个实例。现在只剩下发出一些查询,比如以下内容:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

在运行应用程序时,请注意应用程序生成的输出。在我的本地环境中,它显示(平均)JVM 启动需要 1.6 秒,而应用程序初始化需要 600 毫秒。生成的 JAR 文件大小大约为 17 MB。您可能还想记录这个微不足道的应用程序的 CPU 和内存消耗。有人建议使用 GraalVM Native Image 可以减少启动时间和二进制文件大小。让我们看看如何在 Spring Boot 中实现这一点。

记得当项目创建时我们选择了 Spring Native 特性吗?不幸的是,到了 2.5.0 版本,生成的项目在pom.xml文件中并未包含所有必需的指令。我们需要进行一些调整。首先,由spring-boot-maven-plugin创建的 JAR 文件需要一个分类器;否则,生成的本地镜像可能无法正确创建。这是因为应用程序 JAR 文件已经包含了所有依赖项,位于 Spring Boot 特定路径下,这个路径并不被native-image-maven-plugin处理,我们也需要进行配置。更新后的pom.xml文件应该如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.0</version>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
        <spring-native.version>0.10.0-SNAPSHOT</spring-native.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.experimental</groupId>
            <artifactId>spring-native</artifactId>
            <version>${spring-native.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <classifier>exec</classifier>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.experimental</groupId>
                <artifactId>spring-aot-maven-plugin</artifactId>
                <version>${spring-native.version}</version>
                <executions>
                    <execution>
                        <id>test-generate</id>
                        <goals>
                            <goal>test-generate</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>generate</id>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
    <repositories>
        <repository>
            <id>spring-release</id>
            <name>Spring release</name>
            <url>https://repo.spring.io/release</url>
        </repository>
    </repositories>
    <pluginRepositories>
        <pluginRepository>
            <id>spring-release</id>
            <name>Spring release</name>
            <url>https://repo.spring.io/release</url>
        </pluginRepository>
    </pluginRepositories>

    <profiles>
        <profile>
            <id>native-image</id>
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.graalvm.nativeimage</groupId>
                        <artifactId>native-image-maven-plugin</artifactId>
                        <version>21.1.0</version>
                        <configuration>
                            <mainClass>
                                com.example.demo.DemoApplication
                            </mainClass>
                        </configuration>
                        <executions>
                            <execution>
                                <goals>
                                    <goal>native-image</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                    </plugin>
                </plugins>
            </build>
        </profile>
    </profiles>
</project>

在我们尝试之前还有一步:确保安装了与pom.xml文件中找到的native-image-maven-plugin版本接近的 GraalVM 版本作为您当前的 JDK。还必须在系统中安装native-image可执行文件;您可以通过调用gu install native-image来完成。gu命令由 GraalVM 安装提供。

当所有设置就绪后,我们可以通过调用./mvnw -Pnative-image package生成一个本地可执行文件。你会注意到屏幕上会有大量的文本输出,可能会下载新的依赖项,并且可能会有一些关于缺少类的警告——这是正常的。构建时间也比平时长,这就是这种打包解决方案的权衡所在:我们增加了开发时间以加快生产环境中的执行时间。一旦命令完成,您会注意到在target目录中出现了一个名为com.example.demo.demoapplication的新文件。这就是本地可执行文件。继续运行它吧。

你注意到启动速度有多快了吗?在我的环境中,平均启动时间为 0.06 秒,而应用程序初始化需要 30 毫秒。你可能还记得在 Java 模式下运行时,这些数字分别为 1.6 秒和 600 毫秒。这真是一个严重的速度提升!现在看看可执行文件的大小;在我的情况下,大约是 78 MB。哦,看起来有些事情变得更糟了,或者说没有?这个可执行文件是一个单一的二进制文件,包含了运行应用程序所需的一切,而我们之前使用的 JAR 文件则需要 Java 运行时才能运行。Java 运行时的大小通常在 200 MB 左右,并由多个文件和目录组成。当然,可以使用jlink创建较小的 Java 运行时,这样在构建过程中会增加另一个步骤。没有免费的午餐。

现在我们暂停使用 Spring Boot,记住,它的功能远不止这里展示的。接下来我们看看下一个框架。

Micronaut

Micronaut 于 2017 年诞生,是对 Grails 框架的一次现代化重新构想。Grails 是少数成功的 Ruby on Rails(RoR)框架“克隆”之一,利用 Groovy 编程语言。Grails 在几年间引起了轰动,直到 Spring Boot 的兴起使其失去了关注,促使 Grails 团队寻找替代方案,最终推出了 Micronaut。从表面上看,Micronaut 提供了与 Spring Boot 类似的用户体验,因为它也允许开发人员基于现有组件和合理的默认设置来构建应用程序。

Micronaut 的一个关键区别在于使用编译时依赖注入来组装应用程序,而不是运行时依赖注入,这与目前使用 Spring Boot 组装应用程序的首选方式不同。这一看似微不足道的改变让 Micronaut 在运行时提升了速度,因为应用程序在启动时花费的时间更少;这也可以减少内存消耗,并且减少对 Java 反射的依赖,后者在直接方法调用之前的历史上速度较慢。

有几种启动 Micronaut 项目的方式,但首选的方法是浏览到 Micronaut Launch,选择您想要添加到项目中的设置和功能。默认的应用程序类型定义了构建基于 REST 的应用程序所需的最小设置,例如我们将在几分钟内讲解的内容。一旦满意您的选择,请点击“生成项目”按钮,如 图 4-2 所示,这将生成一个 ZIP 文件,可以下载到您的本地开发环境中。

dtjd 0402

图 4-2. Micronaut 启动

与我们为 Spring Boot 所做的类似,解压 ZIP 文件并在项目根目录运行./mvnw verify命令会确保一个良好的起点。如果一切顺利,此命令会根据需要下载插件和依赖项;如果构建成功,几秒钟后应该看到这样的输出。添加一对额外的源文件后,项目结构应如下所示:

.
├── README.md
├── micronaut-cli.yml
├── mvnw
├── mvnw.bat
├── pom.xml
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── demo
        │               ├── Application.java
        │               ├── Greeting.java
        │               └── GreetingController.java
        └── resources
            ├── application.yml
            └── logback.xml

Application.java 源文件定义了入口点,暂时不需要更新。同样,我们也不需要更改 application.yml 资源文件;该资源提供的配置属性目前不需要更改。

我们需要另外两个源文件:由Greeting.java定义的数据对象,其责任是包含发送给消费者的消息,以及由GreetingController.java定义的实际 REST 端点。控制器原型追溯到 Grails 规范所制定的约定,并几乎被所有 RoR 克隆框架所遵循。您可以根据您的领域需求更改文件名,但必须保留@Controller注解。数据对象的源代码应如下所示:

package com.example.demo;

import io.micronaut.core.annotation.Introspected;

@Introspected
public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

对于这个类,我们再次依赖于不可变设计。请注意使用的@Introspected注解,这会告诉 Micronaut 在编译时检查类型并将其包含为依赖注入过程的一部分。通常情况下,可以省略此注解,因为 Micronaut 会自动识别出需要的类。但是,在使用 GraalVM Native Image 生成本地可执行文件时,它的使用是至关重要的;否则,可执行文件将无法完整生成。第二个文件应该是这样的:

package com.example.demo;

import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.QueryValue;

@Controller("/")
public class GreetingController {
    private static final String template = "Hello, %s!";

    @Get(uri = "/greeting")
    public Greeting greeting(@QueryValue(value = "name",
        defaultValue = "World") String name) {
        return new Greeting(String.format(template, name));
    }
}

我们可以看到控制器定义了一个映射到/greeting的单个端点,接受一个名为name的可选参数,并返回数据对象的一个实例。默认情况下,Micronaut 会将返回值解析为 JSON,因此无需额外配置。应用程序的运行可以通过两种方式完成。您可以调用./mvnw mn:run,将其作为构建过程的一部分运行应用程序,或者调用./mvnw package,它会在target目录中创建一个名为demo-0.1.jar的文件,可以像传统方式一样启动它,即java -jar target/demo-0.1.jar。调用 REST 端点进行一些查询可能会产生类似于以下输出:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

任何一个命令都能快速启动应用程序。在我的本地环境中,应用程序平均在 500 毫秒内准备好处理请求,这比 Spring Boot 的相同行为速度快三倍。JAR 文件的大小也小了一点,总共为 14 MB。尽管这些数字可能令人印象深刻,但如果使用 GraalVM Native Image 将应用程序转换为本地可执行文件,我们可以获得速度提升。幸运的是,Micronaut 的方式对这种设置更友好,生成的项目中已经配置了我们需要的一切。就这样。无需更新构建文件的其他设置,一切都已准备就绪。

您确实需要安装 GraalVM 及其native-image可执行文件,就像我们以前做的那样。创建本地可执行文件只需调用./mvnw -Dpackaging=native-image package,几分钟后,我们应该能得到一个名为demo的可执行文件(事实上,这是项目的artifactId,如果您想知道的话),位于target目录下。使用本地可执行文件启动应用程序平均启动时间为 20 毫秒,比 Spring Boot 快三分之一。可执行文件大小为 60 MB,与 JAR 文件的减小大小相对应。

让我们停止探索 Micronaut,并转向下一个框架:Quarkus。

Quarkus

尽管Quarkus在 2019 年初宣布,但其工作开始得早得多。Quarkus 与我们迄今看到的两个候选者有很多相似之处。它提供基于组件、约定大于配置和生产力工具的优秀开发体验。更重要的是,Quarkus 决定也采用像 Micronaut 一样的编译时依赖注入,使其能够获得同样的好处,如更小的二进制文件、更快的启动时间和更少的运行时魔法。同时,Quarkus 还添加了自己的风格和独特性,对某些开发人员来说可能最重要的是,Quarkus 更多地依赖于标准而不是其他两个候选者。Quarkus 实现了 MicroProfile 规范,这些规范来自于 JakartaEE(以前称为 JavaEE),并在 MicroProfile 项目的保护伞下开发了其他标准。

您可以访问Quarkus 配置您的应用程序页面开始使用 Quarkus,配置值并下载 ZIP 文件。此页面包含许多好东西,包括许多扩展选项,可用于配置特定的集成,如数据库、REST 能力、监控等。必须选择 RESTEasy Jackson 扩展,以便 Quarkus 能够无缝地将值编组为 JSON 并从 JSON 解组。单击“生成您的应用程序”按钮应提示您将 ZIP 文件保存到本地系统,其内容应与以下内容类似:

.
├── README.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── docker
    │   │   ├── Dockerfile.jvm
    │   │   ├── Dockerfile.legacy-jar
    │   │   ├── Dockerfile.native
    │   │   └── Dockerfile.native-distroless
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── demo
    │   │               ├── Greeting.java
    │   │               └── GreetingResource.java
    │   └── resources
    │       ├── META-INF
    │       │   └── resources
    │       │       └── index.html
    │       └── application.properties
    └── test
        └── java

我们可以看到,Quarkus 默认添加 Docker 配置文件,因为它旨在通过容器和 Kubernetes 解决云中的微服务架构问题。但随着时间的推移,它的范围已经扩展,通过支持额外的应用程序类型和架构。GreetingResource.java文件也是默认创建的,它是一个典型的 Jakarta RESTful Web Services(JAX-RS)资源。我们需要对该资源进行一些调整,以使其能够处理Greeting.java数据对象。以下是该资源的源代码:

package com.example.demo;

public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

代码与本章前面所见的基本相同。关于这个不可变数据对象没有什么新鲜或意外的。现在,在 JAX-RS 资源的情况下,事情看起来相似但又有所不同,因为我们寻求的行为与以前相同,只是我们指示框架执行其魔术的方式是通过 JAX-RS 注解。因此,代码如下:

package com.example.demo;

import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/greeting")
public class GreetingResource {
    private static final String template = "Hello, %s!";

    @GET
    public Greeting greeting(@QueryParam("name")
        @DefaultValue("World") String name) {
        return new Greeting(String.format(template, name));
    }
}

如果您熟悉 JAX-RS,这段代码对您来说应该不会感到意外。但是如果您不熟悉 JAX-RS 注解,我们在这里所做的是用我们想要响应的 REST 路径标记资源;我们还指示greeting()方法将处理GET调用,并且其name参数具有默认值。要指示 Quarkus 将返回值转换为 JSON,无需做任何其他操作,因为这将默认发生。

运行应用程序可以通过几种方式完成,其中一种是使用开发者模式作为构建的一部分。这是具有独特 Quarkus 风味的功能之一,它允许您在不手动重启应用程序的情况下自动运行应用程序并获取您所做的任何更改。您可以通过调用/.mvnw compile quarkus:dev来激活此模式。如果您对源文件进行任何更改,您会注意到构建将自动重新编译和加载应用程序。

您也可以像之前看到的那样使用java解释器运行应用程序,结果是一个命令,例如java -jar target/quarkus-app/quarkus-run.jar。请注意,我们使用了一个不同的 JAR,尽管demo-1.0.0-SNAPSHOT.jar确实存在于target目录中;之所以这样做是因为 Quarkus 应用了自定义逻辑以加速启动过程,即使在 Java 模式下也是如此。

运行应用程序应该会导致平均启动时间为 600 毫秒,这几乎接近 Micronaut 的情况。此外,完整应用程序的大小在 13 MB 左右。向应用程序发送一对GET请求,无论是否带有name参数,都会产生类似以下输出的结果:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

毫无疑问,Quarkus 也支持通过 GraalVM Native Image 生成本机可执行文件,因为它面向推荐使用小二进制大小的云环境。由于这个原因,Quarkus 自带电池,就像 Micronaut 一样,并且从一开始就生成您所需的一切。无需更新构建配置即可开始使用本机可执行文件。与其他示例一样,您必须确保当前 JDK 指向 GraalVM 分发,并且native-image可执行文件位于您的路径中。完成这一步后,剩下的就是通过调用./mvnw -Pnative package将应用程序打包为本机可执行文件。这会激活native配置文件,该配置文件指示 Quarkus 构建工具生成本机可执行文件。

几分钟后,构建应该已经生成了一个名为demo-1.0.0-SNAPSHOT-runner的可执行文件,该文件位于target目录内。运行这个可执行文件显示应用程序平均启动时间为 15 毫秒。可执行文件的大小接近 47 MB,这使得 Quarkus 在与以前的候选框架相比的启动速度和最小可执行文件大小方面表现最佳。

目前我们告别了 Quarkus,留下了第四个候选框架:Helidon。

Helidon

最后但并非最不重要的是,Helidon 是一个专门用于构建微服务的框架,有两种版本:SE 和 MP。MP 版本代表MicroProfile,通过利用标准的力量来构建应用程序;这个版本是 MicroProfile 规范的完整实现。另一方面,SE 版本虽然没有实现 MicroProfile,但使用不同的一组 API 提供了类似的功能。根据你想要与之交互的 API 和你对标准的偏好,选择一个版本;无论如何,Helidon 都能完成工作。

鉴于 Helidon 实现了 MicroProfile,我们可以使用另一个站点来启动 Helidon 项目。可以使用MicroProfile Starter site(见图 4-3)来为 MicroProfile 规范的所有支持实现创建项目版本。

dtjd 0403

图 4-3. MicroProfile Starter

浏览到该站点,选择您感兴趣的 MP 版本,选择 MP 实现(在我们的例子中是 Helidon),并可能定制一些可用的功能。然后点击下载按钮以下载包含生成项目的 ZIP 文件。ZIP 文件包含类似于以下结构的项目结构,当然,我已经使用两个文件更新了源文件,以使应用程序按照我们想要的方式工作:

.
├── pom.xml
├── readme.md
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── demo
        │               ├── Greeting.java
        │               └── GreetingResource.java
        └── resources
            ├── META-INF
            │   ├── beans.xml
            │   └── microprofile-config.properties
            ├── WEB
            │   └── index.html
            ├── logging.properties
            └── privateKey.pem

碰巧的是,源文件Greeting.javaGreetingResource.java与我们在 Quarkus 示例中看到的源文件完全相同。这是怎么可能的呢?首先因为代码显然是微不足道的,但更重要的是因为这两个框架都依赖于标准的力量。事实上,Greeting.java文件在所有框架中几乎完全相同,只有 Micronaut 需要额外的注解,但仅在您有兴趣生成本地可执行文件时才需要;否则,它是完全相同的。如果您决定在浏览其他内容之前直接跳到这部分,这是Greeting.java文件的样子:

package com.example.demo;

import io.helidon.common.Reflected;

@Reflected
public class Greeting {
    private final String content;

    public Greeting(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

它只是一个普通的不可变数据对象,只有一个访问器。定义应用程序所需的 REST 映射的Greeting​Re⁠source.java文件如下:

package com.example.demo;

import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/greeting")
public class GreetingResource {
    private static final String template = "Hello, %s!";

    @GET
    public Greeting greeting(@QueryParam("name")
        @DefaultValue("World") String name) {
        return new Greeting(String.format(template, name));
    }
}

我们很欣赏 JAX-RS 注解的使用,因为我们可以看到此时无需特定于 Helidon 的 API。运行 Helidon 应用程序的首选方法是将二进制文件打包并使用java解释器运行它们。也就是说,虽然我们在构建工具集成方面稍有损失(目前如此),但我们仍然可以使用命令行进行迭代开发。因此,调用mvn package后接着是java -jar/demo.jar编译、打包并运行应用程序,嵌入式 Web 服务器监听 8080 端口。我们可以向其发送一些查询,例如:

// using the default name parameter
$ curl http://localhost:8080/greeting
{"content":"Hello, World!"}

// using an explicit value for the name parameter
$ curl http://localhost:8080/greeting?name=Microservices
{"content":"Hello, Microservices!"}

如果你查看应用程序进程运行的输出,你会看到应用程序的平均启动时间为 2.3 秒,这使其成为迄今为止最慢的候选框架,而二进制文件的大小接近 15 MB,属于所有测量值的中间位置。但正如谚语所说,不能以貌取人。Helidon 提供了更多自动配置的开箱即用功能,这解释了额外的启动时间和更大的部署大小。

如果启动速度和部署大小是问题,你可以重新配置构建,移除可能不需要的功能,并切换到本地可执行模式。幸运的是,Helidon 团队也接受了 GraalVM Native Image,并且每个像我们自己一样启动的 Helidon 项目都配备了创建本地二进制文件所需的配置。如果遵循惯例,无需调整pom.xml文件。执行mvn -Pnative-image package命令,你会在target目录中找到一个名为demo的可执行二进制文件。这个可执行文件大约有 94 MB,迄今为止最大,平均启动时间为 50 毫秒,与之前的框架处于相同范围内。

到目前为止,我们已经初步了解了每个框架提供的功能,从基本特性到构建工具的集成。作为提醒,有几个理由可以选择一个候选框架而不是另一个。我鼓励你为每个影响你的开发需求的相关功能/方面编写一个矩阵,并对每个候选项进行评估。

无服务器

本章开始时讨论了通常由组件和层组合成的单片应用程序和架构。对特定部分的更改或更新需要更新和部署整个单体。某个特定位置的故障也可能导致整体崩溃。然后我们转向了微服务。将单体应用程序拆分为可以单独和独立更新和部署的更小块应该解决之前提到的问题,但微服务也带来了一系列其他问题。

以前,在大型服务器上托管的应用服务器内运行单体已经足够了,配备了少数副本和负载均衡器以作为补充。但这种设置存在可伸缩性问题。采用微服务的方法,我们可以根据负载的大小扩展或折叠服务的网格。这增强了弹性,但现在我们必须协调多个实例并提供运行时环境,负载均衡器变得必不可少,API 网关也是必需的,网络延迟也开始显现,我有提到分布式追踪吗?是的,这些都是需要注意和管理的许多事情。但如果不需要这些怎么办?如果有人可以处理基础设施、监控和其他运行大规模应用所需的“琐事”,那就是无服务器方法的用武之地:你可以专注于手头的业务逻辑,让无服务器提供者处理其他所有事务。

当将组件分解为较小的部分时,应该思考一件事:“我可以将这个组件转化为什么样的最小可重复使用的代码片段?” 如果你的答案是一个带有少量方法和一两个注入的协作者/服务的 Java 类,那么你已经接近了,但还不够。事实上,最小的可重复使用代码片段是一个单一的方法。想象一下,一个微服务定义为一个执行以下步骤的单一类:

  1. 读取输入参数并按照下一步所需的格式转换为可消耗的格式

  2. 执行服务所需的实际行为,例如向数据库发出查询、索引或记录日志

  3. 将处理后的数据转换为输出格式

现在,这些步骤中的每一个可能会被组织成单独的方法。你可能很快意识到,其中一些方法是可重复使用的或者可以参数化的。解决这个问题的典型方法是为微服务提供一个公共超类型。这会在类型之间创建强依赖关系,在某些使用情况下是可以接受的。但对于其他情况,共同代码的更新必须尽快进行,以版本化的方式进行,而不会中断当前正在运行的代码,所以我恐怕我们可能需要另一种方法。

考虑到这种情况,如果通用代码被提供为一组可以独立调用的方法,它们的输入和输出以这样一种方式组合,即您建立了一个数据转换的管道,则我们现在所知道的称为函数。像函数即服务(FaaS)这样的服务是无服务器提供者的一个常见主题。

总之,FaaS 是一种精致的方式,即您根据可能的最小部署单元组合应用程序,并让提供者为您解决所有基础设施细节。在接下来的章节中,我们将构建并部署一个简单的函数到云端。

设置中

如今,每个主要的云提供商都有一个可供使用的 FaaS 提供,其附加组件可以连接其他工具,用于监控、日志记录、灾难恢复等等;只需选择满足您需求的一个。在本章中,我们将选择 AWS Lambda,毕竟它是 FaaS 理念的发起者。我们还会选择 Quarkus 作为实现框架,因为它目前提供了最小的部署大小。请注意,这里展示的配置可能需要一些调整或可能完全过时;始终审查构建和运行代码所需工具的最新版本。目前我们将使用 Quarkus 1.13.7。

使用 Quarkus 和 AWS Lambda 设置函数需要一个 AWS 账号,在您的系统上安装 AWS CLI,以及如果您想要运行本地测试,还需要安装 AWS Serverless Application Model (SAM) CLI

一旦你搞定了这些,下一步就是启动项目,我们会倾向于像以前一样使用 Quarkus,但是函数项目需要不同的设置。所以最好切换到使用 Maven 原型:

mvn archetype:generate \
    -DarchetypeGroupId=io.quarkus \
    -DarchetypeArtifactId=quarkus-amazon-lambda-archetype \
    -DarchetypeVersion=1.13.7.Final

在交互模式下调用此命令将询问您一些问题,例如项目的组、artifact、版本(GAV)坐标和基础包。对于这个演示,让我们使用以下配置:

  • groupId: com.example.demo

  • artifactId: demo

  • version: 1.0-SNAPSHOT(默认值)

  • package: com.example.demo(与 groupId 相同)

这将导致一个适合构建、测试和部署到 AWS Lambda 的 Quarkus 项目的项目结构。原型创建了 Maven 和 Gradle 的构建文件,但是我们现在不需要后者;它还创建了三个函数类,但我们只需要一个。我们的目标是拥有类似于这个的文件结构:

.
├── payload.json
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── demo
    │   │               ├── GreetingLambda.java
    │   │               ├── InputObject.java
    │   │               ├── OutputObject.java
    │   │               └── ProcessingService.java
    │   └── resources
    │       └── application.properties
    └── test
        ├── java
        │   └── com
        │       └── example
        │           └── demo
        │               └── LambdaHandlerTest.java
        └── resources
            └── application.properties

函数的要点是使用 InputObject 类型捕获输入,使用 ProcessingService 类型处理它们,然后将结果转换为另一种类型(OutputObject)。GreetingLambda 类型将所有内容整合在一起。首先让我们先看一下输入和输出类型——毕竟,它们只是简单的数据类型,没有任何逻辑:

package com.example.demo;

public class InputObject {
    private String name;
    private String greeting;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getGreeting() {
        return greeting;
    }

    public void setGreeting(String greeting) {
        this.greeting = greeting;
    }
}

Lambda 函数期望接收两个输入值:问候语和姓名。我们马上会看到它们如何被处理服务转换:

package com.example.demo;

public class OutputObject {
    private String result;
    private String requestId;

    public String getResult() {
        return result;
    }

    public void setResult(String result) {
        this.result = result;
    }

    public String getRequestId() {
        return requestId;
    }

    public void setRequestId(String requestId) {
        this.requestId = requestId;
    }
}

输出对象包含转换后的数据和请求 ID 的引用。我们将使用这个字段来展示如何从运行上下文获取数据。

好了,接下来是处理服务;这个类负责将输入转换为输出。在我们的情况下,它将两个输入值连接成一个字符串,如下所示:

package com.example.demo;

import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class ProcessingService {
    public OutputObject process(InputObject input) {
        OutputObject output = new OutputObject();
        output.setResult(input.getGreeting() + " " + input.getName());
        return output;
    }
}

剩下的就是查看GreetingLambda,用于组装函数本身的类型。这个类需要实现由 Quarkus 提供的已知接口,其依赖应该已经在使用原型创建的pom.xml文件中配置好了。这个接口使用输入和输出类型参数化。幸运的是,我们已经有了这些。每个 Lambda 必须有一个唯一的名称,并且可以访问其运行上下文,如下所示:

package com.example.demo;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

import javax.inject.Inject;
import javax.inject.Named;

@Named("greeting")
public class GreetingLambda
    implements RequestHandler<InputObject, OutputObject> {
    @Inject
    ProcessingService service;

    @Override
    public OutputObject handleRequest(InputObject input, Context context) {
        OutputObject output = service.process(input);
        output.setRequestId(context.getAwsRequestId());
        return output;
    }
}

所有的部分都应该得到合理安排。Lambda 定义输入和输出类型并调用数据处理服务。为了演示目的,此示例展示了依赖注入的使用,但您可以通过将ProcessingService的行为移到GreetingLambda中来减少代码量。我们可以通过运行本地测试命令mvn test快速验证代码,或者如果您喜欢mvn verify,因为它还会打包函数。

请注意,当函数被打包时,附加文件被放置在target目录中,特别是一个名为manage.sh的脚本,该脚本依赖于 AWS CLI 工具,用于在与您的 AWS 帐户关联的目标位置创建、更新和删除函数。支持这些操作需要其他文件:

function.zip

包含二进制位的部署文件

sam.jvm.yaml

使用 AWS SAM CLI 进行本地测试(Java 模式)

sam.native.yaml

使用 AWS SAM CLI 进行本地测试(本地模式)

下一步需要您配置一个执行角色,最好参考 AWS Lambda 开发指南,以防流程已经更新。该指南将向您展示如何配置 AWS CLI(如果您尚未执行此操作)并创建一个必须添加为运行 shell 的环境变量的执行角色。例如:

LAMBDA_ROLE_ARN="arn:aws:iam::1234567890:role/lambda-ex"

在这种情况下,1234567890代表您的 AWS 帐户 ID,lambda-ex是您选择的角色名称。我们可以继续执行函数,对于这个函数,我们有两种模式(Java、本地)和两种执行环境(本地、生产);让我们首先处理两种环境的 Java 模式,然后再处理本地模式。

在本地环境中运行函数需要使用 Docker 守护程序,现在应该是开发人员工具箱中的常见组成部分;我们还需要使用 AWS SAM CLI 来驱动执行。记住在target目录中找到的附加文件集合吗?我们将使用sam.jvm.yaml文件以及在项目启动时由原型创建的另一个文件payload.json。位于目录根目录,其内容应如下所示:

{
  "name": "Bill",
  "greeting": "hello"
}

该文件为函数接受的输入定义了值。鉴于函数已经打包,我们只需调用它,如下所示:

$ sam local invoke --template target/sam.jvm.yaml --event payload.json
Invoking io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
(java11)
Decompressing /work/demo/target/function.zip
Skip pulling image and use local one:
amazon/aws-sam-cli-emulation-image-java11:rapid-1.24.1.

Mounting /private/var/folders/p_/3h19jd792gq0zr1ckqn9jb0m0000gn/T/tmppesjj0c8 as
/var/task:ro,delegated inside runtime container
START RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa Version: $LATEST
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
[io.quarkus] (main) quarkus-lambda 1.0-SNAPSHOT on
JVM (powered by Quarkus 1.13.7.Final) started in 2.680s.
[io.quarkus] (main) Profile prod activated.
[io.quarkus] (main) Installed features: [amazon-lambda, cdi]
END RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa
REPORT RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa	Init Duration: 1.79 ms
Duration: 3262.01 ms Billed Duration: 3300 ms
Memory Size: 256 MB	Max Memory Used: 256 MB
{"result":"hello Bill","requestId":"0b8cf3de-6d0a-4e72-bf36-232af46145fa"}

此命令将拉取一个适合运行该函数的 Docker 镜像。请注意报告的值,这可能会因您的设置而异。在我的本地环境中,此函数的执行将花费我 3.3 秒,并占用 256 MB 的内存。这可以让您了解在运行系统作为一组函数时,您将被计费多少。然而,本地环境与生产环境不同,因此让我们将该函数部署到真实环境中。我们将使用 manage.sh 脚本来完成这一壮举,通过调用以下命令:

$ sh target/manage.sh create
$ sh target/manage.sh invoke
Invoking function
++ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out
++ --function-name QuarkusLambda --payload file://payload.json
++ --log-type Tail --query LogResult
++ --output text base64 --decode
START RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7 Version: $LATEST
END RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7
REPORT RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7	Duration: 273.47 ms
Billed Duration: 274 ms	Memory Size: 256 MB
Max Memory Used: 123 MB	Init Duration: 1635.69 ms
{"result":"hello Bill","requestId":"df8d19ad-1e94-4bce-a54c-93b8c09361c7"}

正如您所见,计费时长和内存使用量都减少了,这对我们的钱包来说是好事,尽管初始化时长增加到了 1.6,这会延迟响应,增加整个系统的总执行时间。让我们看看当我们从 Java 模式切换到本机模式时,这些数字会如何变化。您可能还记得,Quarkus 允许您将项目打包为本机可执行文件,但请记住 Lambda 需要 Linux 可执行文件,因此如果您恰好在非 Linux 环境上运行,则需要调整打包命令。以下是需要完成的操作:

# for linux
$ mvn -Pnative package

# for non-linux
$ mvn package -Pnative -Dquarkus.native.container-build=true \
 -Dquarkus.native.container-runtime=docker

第二个命令在 Docker 容器内调用构建,并将生成的可执行文件放置在预期位置,而第一个命令则按原样执行构建。现在,有了本地可执行文件,我们可以在本地和生产环境中执行新函数。先看看本地环境:

$ sam local invoke --template target/sam.native.yaml --event payload.json
Invoking not.used.in.provided.runtime (provided)
Decompressing /work/demo/target/function.zip
Skip pulling image and use local one:
amazon/aws-sam-cli-emulation-image-provided:rapid-1.24.1.

Mounting /private/var/folders/p_/3h19jd792gq0zr1ckqn9jb0m0000gn/T/tmp1zgzkuhy as
/var/task:ro,delegated inside runtime container
START RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4 Version: $LATEST
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
[io.quarkus] (main) quarkus-lambda 1.0-SNAPSHOT native
(powered by Quarkus 1.13.7.Final) started in 0.115s.
[io.quarkus] (main) Profile prod activated.
[io.quarkus] (main) Installed features: [amazon-lambda, cdi]
END RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4
REPORT RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4	Init Duration: 0.13 ms
Duration: 218.76 ms	Billed Duration: 300 ms Memory Size: 128 MB
Max Memory Used: 128 MB
{"result":"hello Bill","requestId":"27531d6c-461b-45e6-92d3-644db6ec8df4"}

计费时长降低了一个数量级,从 3300 毫秒降至仅 300 毫秒,并且使用的内存减半;与其 Java 对应物相比,这看起来很有希望。在生产环境中运行时,我们会得到更好的数据吗?让我们来看看:

$ sh target/manage.sh native create
$ sh target/manage.sh native invoke
Invoking function
++ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out
++ --function-name QuarkusLambdaNative
++ --payload file://payload.json --log-type Tail --query LogResult --output text
++ base64 --decode
START RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5 Version: $LATEST
END RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5
REPORT RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5	Duration: 2.55 ms
Billed Duration: 187 ms Memory Size: 256 MB	Max Memory Used: 54 MB
Init Duration: 183.91 ms
{"result":"hello Bill","requestId":"19575cd3-3220-405b-afa0-76aa52e7a8b5"}

总计的计费时长结果提高了 30%,内存使用量不到以前的一半;但真正的赢家是初始化时间,大约是以前时间的 10%。在本机模式下运行您的函数会导致启动更快,各方面的数据更好。

现在轮到您决定哪种组合选项会给您带来最佳结果了。有时,即使是在生产环境中保持 Java 模式也足够好,或者一直采用本机模式可能会为您带来优势。无论哪种方式,测量都很关键—不要猜测!

总结

在本章中,我们涵盖了很多内容,从传统的单体架构开始,将其分解为具有可重用组件的较小部分,这些部分可以独立部署,称为微服务,一直到最小的部署单元:函数。沿途会出现权衡,因为微服务架构本质上更复杂,由于其由更多运动部分组成。网络延迟成为一个真正的问题,必须相应地加以解决。其他方面,如数据事务,由于其跨服务边界的跨度可能不同,取决于情况,会变得更加复杂。使用 Java 和本地可执行模式产生不同的结果,并且需要定制设置,每种都有其优缺点。我亲爱的读者,我的建议是评估、测量,然后选择一种组合;随时注意数字和服务级别协议(SLAs),因为您可能需要重新评估决策并进行调整。

表 4-1 总结了在我的本地环境和远程环境中以 Java 和本地映像模式运行示例应用程序时,每个候选框架的测量结果。大小列显示部署单元大小,而时间列描绘了从启动到第一个请求的时间。

表 4-1. 测量摘要

框架 Java - 大小 Java - 时间 本地 - 大小 本地 - 时间
Spring Boot 17 MB 2200 ms 78 MB 90 ms
Micronaut 14 MB 500 ms 60 MB 20 ms
Quarkus 13 MB 600 ms 47 MB 13 ms
Helidon 15 MB 2300 ms 94 MB 50 ms

作为提醒,鼓励您进行自己的测量。对于托管环境、JVM 版本和设置、框架版本、网络条件和其他环境特征的更改将产生不同的结果。显示的数字应该持保留态度,永远不要视为权威值。

第五章:持续集成

Melissa McKay

始终犯新错误。

Esther Dyson

回到第二章时,你学到了源代码控制和常见代码库的价值。在你组织和确定了源代码控制解决方案后,你需要采取更多步骤,以达到一个最终结果,让你的用户可以享受到你交付的软件的完美用户体验。

想象一下,作为个体开发者,你将如何推进软件通过整个软件开发生命周期。确定特定功能或软件 bug 修复的验收标准后,你会继续添加实际的代码行和相关的单元测试到代码库中。然后,你会编译并运行所有的单元测试,以确保你的新代码按照你的预期工作(或者至少是根据你的单元测试定义的),并且不会破坏已知的现有功能。在发现所有测试通过后,你将构建和打包应用程序,并在质量保证(QA)环境中进行集成测试的功能验证。最后,在测试套件给出绿灯的情况下,你将向生产环境交付和/或部署软件。

如果你有任何开发经验,你就像我一样知道,软件很少会如此顺利地落实。当你开始与开发团队一起工作的较大项目时,严格实施理想的工作流程会显得太过简单化。会引入多种复杂因素,可能会干扰软件交付生命周期的进程,打乱你的计划。本章讨论了持续集成及相关的最佳实践和工具集如何帮助你避免或减轻软件开发项目在交付过程中常遇到的最常见障碍和头痛问题。

采用持续集成

持续集成(CI)通常被描述为频繁地将多个贡献者的代码变更集成到项目的主源代码库中。实际上,这个定义本身有点模糊。在这个上下文中,“频繁”是指多频繁?集成的确切含义是什么?仅仅协调将代码变更推送到源代码库是否足够?最重要的是,这个过程解决了什么问题——你应该采纳这种实践有什么好处?

CI 的概念已经存在了相当长的时间了。根据马丁·福勒的说法持续集成 这个术语源自肯特·贝克的极限编程开发过程,作为其最初的 12 项实践之一。在 DevOps 社区中,这个术语现在和面包上的黄油一样普遍。但它的实施方式可能因团队和项目而异。如果不彻底理解原始意图或放弃最佳实践,其益处可能是时有时无。

看到我们对 CI 的理解随时间而变化是很有趣的。现在我们谈论它的方式与贝克最初引入它来解决并发开发问题时大不相同。我们今天面临的问题更多地是保持定期和频繁的构建的效率,同时尽量减少错误,而最初,CI 及其衍生的构建工具的普及更多地是为了在开发完成后使项目能够 完整 构建。而不是仅在团队完成所有编码之后才尝试组装项目,CI 要求改变思维方式——在开发过程中定期构建。

如今,CI 的目的是通过定期和频繁的构建尽快地识别开发周期中的错误和兼容性问题。CI 的基本前提是,如果开发人员经常集成变更,就可以更早地发现错误,并减少在寻找问题引入的时间和地点上的浪费。错误被发现的时间越长,它在周围代码库中变得根深蒂固的潜力就越大。

从开发的角度来看,找到、捕捉和修复错误比从已经移动到交付管道后阶段的代码层中提取错误要容易得多。直到最新的验收阶段甚至直接发布的错误,直接转化为更多的修复开销和较少用于新功能开发的时间。在生产环境中修复错误的情况下,在许多情况下,现在要求在包括和记录修复的新版本中补丁现有部署。这从本质上减少了团队用于开发新功能的可用时间。

重要的是要理解,实施持续集成解决方案并不等同于永远没有任何错误的软件。用这样一个明确的标准来判断持续集成的实施是否值得是愚蠢的。一个更有价值的指标可能是持续集成捕获的错误或兼容性问题的数量。就像疫苗在大规模人群中从未百分之百有效一样,持续集成只是在软件中提供了另一层保护,以便从发布中过滤掉最明显的错误。持续集成本身永远不会取代软件开发最佳实践的众所周知的好处,这些最佳实践属于最初的设计和开发步骤。然而,随着时间的推移,它将为软件提供一个更好的安全网,因为软件会被多个开发人员反复处理和调整。马丁·福勒这样描述:“持续集成并不能消除错误,但确实能够显著地帮助找到并移除它们。”

我在一家小公司实习时的第一次持续集成经验,该公司采用极限编程(XP)的软件开发方法,其中持续集成是一个重要方面。我们没有使用所有最新和最伟大的 DevOps 工具的非常花哨的系统。我们拥有的是一个共享的代码仓库,以及一个位于办公室小壁橱中的单一构建服务器。

当我首次加入开发团队时,并不知道构建服务器上设置了扬声器,如果从源代码控制进行的新检出导致构建失败或任何自动化测试失败,它就会发出紧急警报声。我们是一个相对年轻的团队,所以我们的持续集成的这一部分大多是戏谑,但猜猜谁学会了非常快速地在将代码推送到主代码仓库之前验证项目是否成功构建并通过单元测试?

直至今天,我感到非常幸运能以这种方式接触到这种实践。这种简单性突显了持续集成的最重要方面。我想指出这种简单设置的三个副产品:

代码集成通常是定期的,很少复杂化。

我的团队同意遵循 XP 实践,鼓励尽可能频繁地集成每隔几个小时。比特定时间间隔更重要的是在任何给定时间点需要集成的代码量。在计划和拆分实际开发工作时,我们专注于创建小而可完成的任务,始终从可能最简单的事情开始。通过可完成,我指的是开发任务完成后,可以将其集成到主代码仓库中,并期望该结果能够成功构建并通过所有单元测试。将代码更新组织成尽可能小的包的这种做法,使得定期和频繁地将代码集成到主源代码仓库成为一种正常且不值一提的活动。很少有大量时间花费在大规模集成工作上。

构建和测试失败相对较容易排查。

因为项目在常规间隔时间内构建并运行自动化测试,所以很容易看出从哪里开始排查任何失败。自上次成功构建以来,可能只有少量代码被修改,如果问题无法立即识别和解决,我们将从最新的合并开始,并按需向后工作以恢复干净的构建。

由集成引入的错误和兼容性问题,通过 CI 系统立即修复。

警报器发出的巨大声响让每个人都知道需要解决一个无法忽视的问题。因为我们的 CI 系统在构建或测试失败时会暂停进度,所以每个人都会全力以赴找出问题所在以及如何解决问题。团队的沟通、协调和合作都处于最佳状态,因为除非问题解决,否则没有人能够继续前进。大多数情况下,可以通过分析最近的合并简单地确定有问题的代码,并将修复责任分配给该开发者或开发者组。有时,由于多个最近的合并引发了兼容性问题,影响了看似无关的系统其他部分,因此需要与整个团队讨论。这些情况需要我们的团队从整体上重新评估正在进行的代码更改,然后共同决定最佳行动方案。

这三个因素是我们的 CI 解决方案成功的关键。您可能已经了解到这三个因素都暗示了健康的代码库和健康的开发团队的先决条件。没有这些因素,CI 解决方案的初始实施无疑会更加困难。然而,实施 CI 解决方案将反过来对代码库产生积极影响,并且采取第一步将带来一定程度的收益,这将非常值得努力。

一个有效的 CI 解决方案远不止是简单地协调代码对共享仓库的贡献,并遵循在约定频率上集成的命令。以下部分将为您介绍完整、实用的 CI 解决方案的基本要素,这将有助于减轻和加速软件开发过程。

声明式地编写您的构建

不 不论您的项目处于何种状态——无论是全新项目、遗留项目、小型个人库还是大型多模块项目——在实施 CI 解决方案时,您的第一个任务应该是编写构建脚本。拥有一致且可重复的流程,可以自动化的流程,将有助于避免由于依赖管理不当、在创建可分发包时忘记包含所需资源或无意间忽略构建步骤等导致的构建排列组合的痛苦。

编写构建脚本时,通过脚本化你的构建可以节省大量时间。你的项目构建生命周期(构建项目所需的所有离散步骤)随着时间的推移可以变得更加复杂,特别是当你消耗更多依赖、包含各种资源、添加模块和测试时。你可能还需要根据预期的部署环境不同方式构建项目。例如,你可能需要在开发或 QA 环境中启用调试能力,但在构建用于发布到生产环境的版本时禁用调试,并防止测试类被包含在可分发包中。手动执行构建 Java 项目所需的所有步骤,包括考虑每个环境的配置差异,是人为错误的温床。第一次忽略构建更新的依赖项等步骤,并因此不得不重复构建一个庞大的多模块项目以纠正你的错误时,你会意识到构建脚本的价值。

无论你选择哪种工具或框架来编写你的构建脚本,都要注意使用声明性方法而不是命令式方法。这里简要提醒一下这些术语的含义:

命令式的

定义一个包含实施细节的确切过程

声明性的

定义一个没有实施细节的动作

换句话说,保持你的构建脚本专注于需要做的事情,而不是如何做。这将有助于保持你的脚本易于理解、可维护、可测试和可扩展,鼓励在其他项目或模块中重复使用。为了实现这一点,你可能需要建立或遵循一个已知的约定,或者编写插件或其他从你的构建脚本引用的外部代码,提供实施细节。一些构建工具更倾向于采用声明性方法而不是其他方法。这通常是在灵活性与遵循约定之间权衡的结果。

Java 生态系统有几个成熟的构建工具可用,所以如果你目前手动使用javac编译你的项目并将类文件打包成 JAR 或其他包类型,我会感到惊讶。你很可能已经有了某种构建流程和脚本,但在极少数情况下,如果你没有,你正在启动一个全新的 Java 项目,或者你希望改进现有的脚本以利用最佳实践,本节总结了 Java 生态系统中几种最常见的构建工具/框架及其开箱即用的功能。

首先,重要的是绘制你的构建过程图,确定你需要从构建脚本中获得的东西以获得最大的效益。要构建一个 Java 项目,至少需要指定以下内容:

Java 版本

编译项目所需的 Java 版本

源代码目录路径

项目的源代码目录

目标目录路径

预期编译类文件放置的目录

需要的依赖项的名称、位置和版本

定位和收集项目所需的任何依赖项所需的元数据

有了这些信息,您应该能够执行以下步骤来执行最小的构建过程:

  1. 收集所有必需的依赖项。

  2. 编译代码。

  3. 运行测试。

  4. 打包您的应用程序。

展示如何将您的构建过程调整为构建脚本的最佳方式是通过示例。以下示例演示了使用三种最常见的构建工具来脚本化为简单的 Hello World Java 应用程序描述的最小构建过程。这些示例无论如何都不会探索这些工具中可用的所有功能。它们只是作为一个速成课程,帮助您开始理解现有的构建脚本或编写您的第一个构建脚本以从完整的 CI 解决方案中获益。

在评估构建工具时,请记住您的项目完成构建所需的实际过程。您的项目可能需要脚本化其他未在此处显示的步骤,并且一个构建工具可能比另一个更适合完成这些步骤。选择的工具应帮助您以编程方式定义和加速项目所需的构建过程,而不是随意迫使您修改流程以适应工具的要求。话虽如此,当您了解工具的功能时,请反思您的流程,并注意会为您的团队带来好处的变化。这在已建立的项目中尤为重要。不论出于何种善意,对流程的更改对开发团队来说可能是痛苦的。只有在有意识地、明确理解变更原因和显著好处的情况下才应该进行这些更改。

使用 Apache Ant 进行构建

Apache Ant 是一个由 Apache 软件基金会发布的开源项目,根据 Apache 许可证发布。根据Apache Ant 文档,其名称是 Another Neat Tool 的缩写,最初是 Tomcat 代码库的一部分,由 James Duncan Davidson 编写,用于构建 Tomcat。它的第一个初始版本发布于 2000 年。

Apache Ant 是一个用 Java 编写的构建工具,提供了在 XML 文件中描述构建过程的声明性步骤的方式。这是我在我的 Java 生涯中接触的第一个构建工具,尽管 Ant 今天面临激烈的竞争,但它仍然是一个活跃的项目,并经常与其他工具结合使用。

示例 5-1 是我创建并使用 Ant 1.10.8 执行的一个简单的 Ant 构建文件。

示例 5-1. Ant 构建脚本 (build.xml)
<project name="my-app" basedir="." default="package"> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)

    <property name="version" value="1.0-SNAPSHOT"/> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
    <property name="finalName" value="${ant.project.name}-${version}"/>
    <property name="src.dir" value="src/main/java"/>
    <property name="build.dir" value="target"/>
    <property name="output.dir" value="${build.dir}/classes"/>
    <property name="test.src.dir" value="src/test/java"/>
    <property name="test.output.dir" value="${build.dir}/test-classes"/>
    <property name="lib.dir" value="lib"/>

    <path id="classpath"> ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
        <fileset dir="${lib.dir}" includes="**/*.jar"/>
    </path>

    <target name="clean">
        <delete dir="${build.dir}"/>
    </target>

    <target name="compile" depends="clean"> ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)
        <mkdir dir="${output.dir}"/>
        <javac srcdir="${src.dir}"
               destdir="${output.dir}"
               target="11" source="11"
               classpathref="classpath"
               includeantruntime="false"/>
    </target>

    <target name="compile-test">
        <mkdir dir="${test.output.dir}"/>
        <javac srcdir="${test.src.dir}"
               destdir="${test.output.dir}"
               target="11" source="11"
               classpathref="classpath"
               includeantruntime="false"/>
    </target>

    <target name="test" depends="compile-test"> ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)
        <junit printsummary="yes" fork="true">
            <classpath>
                <path refid="classpath"/>
                <pathelement location="${output.dir}"/>
                <pathelement location="${test.output.dir}"/>
            </classpath>

            <batchtest>
                <fileset dir="${test.src.dir}" includes="**/*Test.java"/>
            </batchtest>
        </junit>
    </target>

    <target name="package" depends="compile,test"> ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/6.png)
        <mkdir dir="${build.dir}"/>
        <jar jarfile="${build.dir}/${finalName}.jar"
              basedir="${output.dir}"/>
    </target>

</project>

1

可以将项目的 default 属性值设置为在没有目标的情况下调用 Ant 时要运行的默认目标的名称。对于这个项目,不带任何参数运行 ant 命令将运行 package 目标。

2

Property 元素是硬编码的不可变值,可以在构建脚本的其余部分多次使用。使用它们有助于提高可读性和可维护性。

3

此路径元素是我选择用来管理此项目所需依赖项位置的方式。在这种情况下,junithamcrest-core JAR 包都手动放置在这里配置的目录中。这种技术意味着依赖项将与项目一起提交到源代码控制中。尽管这在本例中很简单,但这并不是推荐的做法。第六章详细讨论了包管理。

4

compile目标负责编译源代码(本项目指定 Java 11),并将生成的类文件放置在配置的位置。此目标依赖于clean目标,这意味着将首先运行 clean 目标,以确保编译的类文件是新鲜的,而不是旧构建中遗留下来的。

5

test目标配置了 JUnit Ant 任务,该任务将运行所有可用的单元测试并将结果打印到屏幕上。

6

package目标将组装并放置最终的 JAR 文件在配置的位置。

执行一行命令ant package将接管我们的 Java 项目,编译它,运行单元测试,然后为我们组装一个 JAR 文件。Ant 灵活且功能丰富,满足我们脚本化最小构建的目标。XML 配置文件是记录项目构建生命周期的清晰简洁方式。单独使用 Ant 在依赖管理方面存在不足。但是,像Apache Ivy这样的工具已被开发用来扩展此功能以适用于 Ant。

使用 Apache Maven 构建

根据Apache Maven 项目文档maven是意第绪语中的一个词,意为知识的积累者。与 Apache Ant 类似,Maven 也是 Apache 软件基金会的一个开源项目。它起初是对 Jakarta turbine 项目构建的改进,该项目为每个子项目使用了各种 Ant 配置。它于 2004 年首次正式发布。

与 Apache Ant 类似,Maven 使用 XML 文档(POM 文件)来描述和管理 Java 项目。该文档记录了项目的信息,包括项目的唯一标识符、所需的编译器版本、配置属性值以及所有必需依赖项及其版本的元数据。Maven 最强大的功能之一是其依赖管理和使用存储库与其他项目共享依赖项的能力。

Maven 在提供一种统一的项目管理和文档化方法上依赖于约定,这可以轻松地跨所有使用 Maven 的项目进行扩展。期望将项目以特定方式布置在文件系统上。为了保持脚本声明性,定制实现需要构建自定义插件。虽然可以对预期的默认值进行广泛定制,但如果符合预期的项目结构,Maven 就可以无需大量配置即可开箱即用。

示例 5-2 是我使用 Maven 3.6.3 配置的适用于我的 Java 11 环境的简单 POM 文件。

示例 5-2. Maven POM 文件(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation=
            "http://maven.apache.org/POM/4.0.0
            http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.mycompany.app</groupId> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
  <artifactId>my-app</artifactId>
  <version>1.0-SNAPSHOT</version>

  <name>my-app</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>11</maven.compiler.release>
  </properties>

  <dependencies> ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build> ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)
    <pluginManagement>
      <plugins>
        <plugin> ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

1

每个项目都由其配置的 groupIdartifactIdversion 唯一标识。

2

属性是硬编码的值,可能在 POM 文件中的多个地方使用。它们可以是自定义属性,也可以是插件或目标使用的内置属性。

3

dependencies 块中,识别项目的所有直接依赖项。该项目依赖于 JUnit 运行单元测试,因此在此处指定了 junit 依赖项。JUnit 本身又依赖于 hamcrest-core,但 Maven 足够智能,可以在此处包含它而无需在此处包含它。默认情况下,Maven 将从 Maven 中央拉取这些依赖项。

4

build 块是配置插件的地方。除非有配置要覆盖,否则不需要此块。

5

所有生命周期阶段都存在默认的插件绑定,但在这种情况下,我想要配置 maven-compiler-plugin 来使用 Java 版本 11,而不是默认版本。控制此插件的属性是 properties 块中的 maven​.com⁠piler.release。此配置可以放在 plugins 块中,但将其移动到 properties 块以更好地向文件顶部显示更有意义。此属性替换了在使用较旧版本的 Java 时通常看到的 maven.compiler.sourcemaven.compiler.target

警告

锁定所有 Maven 插件版本是个好主意,以避免使用 Maven 默认值。特别要注意当使用较旧版本的 Maven 和 Java 版本 9 或更高版本时,根据 Maven 的配置构建脚本的说明。您的 Maven 安装的默认插件版本可能与较新版本的 Java 不兼容。

由于对约定的强烈依赖,这个 Maven 构建脚本非常简洁。有了这个小小的 POM 文件,我可以执行 mvn package 来编译、运行测试并组装一个 JAR 文件,所有这些都使用默认设置。如果你花时间使用 Maven,你很快就会意识到它不仅仅是一个构建工具,而且充满了强大的功能。对于刚接触 Maven 的人来说,它的潜在复杂性可能会让人感到不知所措。此时,Apache Maven 项目 的文档包含了非常好的资源,包括 Maven in 5 Minutes 指南。如果你对 Maven 不熟悉,我强烈推荐从这些资源开始学习。

提示

尽管 Apache Maven Ant 插件 不再维护,但仍然可以从 Maven POM 文件生成一个 Ant 构建文件。这样做将帮助你体会 Maven 的约定和默认设置所带来的一切!在与你的 pom.xml 文件相同的目录中,使用 mvn ant:ant 命令调用 Maven 插件。

使用 Gradle 进行构建

Gradle 是一个根据 Apache 2.0 许可发布的开源构建工具。Gradle 的创始人 Hans Dockter 在 Gradle 论坛中解释,他最初的想法是将项目命名为带有 C 的 Cradle。最终他决定使用带有 G 的 Gradle,因为它使用 Apache Groovy 作为领域特定语言(DSL)。Gradle 1.0 于 2012 年发布,因此与 Apache Ant 和 Apache Maven 相比,Gradle 是新进者。

Gradle 与 Maven 和 Ant 最大的区别之一是,Gradle 构建脚本不是基于 XML 的。相反,Gradle 构建脚本可以用 Groovy 或 Kotlin DSL 编写。与 Maven 类似,Gradle 也利用约定,但比 Maven 更灵活。Gradle 文档 强调了该工具的灵活性,并包括了如何轻松定制你的构建的说明。

提示

Gradle 在 将 Maven 构建迁移到 Gradle 方面有广泛的在线文档。你可以从现有的 Maven POM 文件生成一个 Gradle 构建文件。

示例 5-3 是一个简单的 Gradle 构建文件,我从上一节的 示例 5-2 的内容生成的。

示例 5-3. Gradle 构建脚本(build.gradle)
/*
 * This file was generated by the Gradle 'init' task.
 */

plugins {
    id 'java' ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
    id 'maven-publish'
}

repositories { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
    mavenLocal()
    maven {
        url = uri('https://repo.maven.apache.org/maven2')
    }
}

dependencies {
    testImplementation 'junit:junit:4.11' ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
}

group = 'com.mycompany.app'
version = '1.0-SNAPSHOT'
description = 'my-app'
sourceCompatibility = '11' ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)

publishing {
    publications {
        maven(MavenPublication) {
            from(components.java)
        }
    }
}

tasks.withType(JavaCompile) { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)
    options.encoding = 'UTF-8'
}

1

Gradle 插件通过将它们的 插件 ID 添加到 plugins 块来应用。java 插件是 Gradle 核心插件,为 Java 项目提供编译、测试、打包以及其他功能。

2

依赖项的仓库在 repositories 块中提供。使用这些设置来解析依赖项。

3

Gradle 处理依赖项的方式类似于 Maven。我们的单元测试需要 JUnit 依赖项,因此它被包含在 dependencies 块中。

4

sourceCompatibility 配置设置由 java 插件提供,并映射到 javacsource 选项。还有一个 targetCompatibility 配置设置。其默认值是 sourceCompatibility 的值,因此没有必要将其添加到构建脚本中。

5

Gradle 的灵活性允许我为 Java 编译器添加显式编码设置。java 插件提供的一个任务称为 compileJava,其类型为 Java​Com⁠piler。此代码块在这个编译任务上设置了编码属性。

这个 Gradle 构建脚本允许我通过执行单个命令 gradle build 来编译、运行测试并组装项目的 JAR 文件。由于 Gradle 构建基于众所周知的约定,因此构建脚本仅包含区分构建所需的内容,有助于保持其小巧和可维护性。这个简单的脚本展示了 Gradle 的强大和灵活性,特别适用于具有较复杂构建过程的 Java 项目。在这种情况下,投入时间去理解用于定制的 Gradle DSL 是非常值得的。

这三种用于构建 Java 项目的工具各有优劣。选择一个工具应基于项目需求、团队经验和所需的灵活性。无论你如何选择和使用构建脚本,以及选择任何工具,都将极大地提高你的效率。构建 Java 项目是一个重复的过程,包含许多步骤,容易出现人为错误,但非常适合自动化。将项目构建简化为单个命令可节省新开发人员的上手时间,在本地开发环境中增加开发任务的效率,并为构建自动化铺平道路,这是有效 CI 解决方案的重要组成部分。

持续构建

代码集成失败最明显的迹象是构建失败。因此,项目应经常构建,以尽早检测和解决任何问题。事实上,对主线代码库的每一次贡献都应期望能够成功编译并通过所有单元测试

注意

当提及将代码合并到主干源代码库后构建项目时,我故意使用contribution一词,而不是commitcheck-in。这仅是因为您的开发团队可能已同意遵循多个开发流程(都是有效的),在其中一些流程中,contribution到主干可能是一个分支的合并或拉取请求的合并——两者都可能包含一个或多个提交。

以下是使用测试驱动开发的典型开发者工作流程:

  1. 从源代码控制中检出最新的代码到本地工作空间。

  2. 构建并运行项目的所有测试,以确保开始时处于干净状态。(应该有一个构建脚本来完成此操作。参见“声明式地编写您的构建”。)

  3. 编写新功能或错误修复的代码和相关单元测试。

  4. 运行新的单元测试,确保它们通过。

  5. 构建并运行项目的所有单元测试,确保新代码在与现有代码集成时不会产生负面影响。(再次强调,使用构建脚本完成此操作。)

  6. 将新代码与新测试一同提交到代码库。

此流程旨在在代码离开您的本地开发工作空间之前防止问题(包括引入错误或功能丢失)。但是,在此工作流程期间可能会出现问题,这些问题会在今后带来痛苦。一些问题是由于人类本性的现实而引起的,而另一些则是因为无论进行多少先进的规划努力,几乎不可能防止由并行开发引入的每一个潜在不兼容性。不要误会;我并不是说这个过程是错误的,应该完全丢弃。相反,本节解释了自动化的持续集成实施如何帮助减轻可能在此工作流程中出现的问题,并增强您作为开发者的效率和生产力。

对于开发人员来说,仅在自己的本地环境中成功构建项目是不够的。即使每个开发人员都严格遵守约定的流程,并且只有在所有测试通过后才提交代码更改,您也不应仅依赖于此。最简单的原因是,开发人员可能没有来自主干的最新更改(当许多开发人员在同一个代码库中工作时,这种情况更有可能发生)。这可能导致在代码合并后才发现的不兼容性。

有时,测试问题可能只在其他人尝试在他们自己的本地开发环境中构建或运行测试时才会显露出来。例如,不止一次,我尴尬地忘记了提交我创建的新文件或资源到代码库中。这意味着接下来收集这些更改的开发人员将遭受要么立即构建失败,要么测试失败的烦恼。我见过的另一个问题是,代码写成只在特定环境或特定操作系统中才能工作。

我们都会有不好的日子,即使在最理想的情况下,这些问题有时也会悄悄溜走。但与其让破碎的构建像病毒一样在团队中蔓延,不如采取策略来帮助减轻这类集成问题。最常见的方法是使用自动构建服务器或 CI 服务器。这些服务器由负责执行完整构建(包括运行测试)并在代码更改提交后报告构建结果的开发团队共享使用。

您可能熟悉的流行 CI 服务器包括 Jenkins、CircleCI、TeamCity、Bamboo 和 GitLab。还有一些新选项正在涌现,如 JFrog Pipelines,有些比其他服务器具有更多的功能和能力,但主要目标是通过经常构建并在出现问题时报告来为共享代码库中的代码更改建立一个裁判。利用 CI 服务器自动运行构建的目的是确保定期进行构建,早期发现任何集成问题。

自动化测试

除了在开发过程中运行单个测试(通常在 IDE 内部),开发人员在将新代码提交到代码库之前,应该有一种快速运行完整自动化测试套件的方法。

在“声明性地编写您的构建脚本”中概述的最小构建过程包括自动运行单元测试的步骤。每个构建脚本示例都包括这个单元测试步骤。这并非偶然。事实上,您构建的这一部分对于健康的 CI 解决方案至关重要,并值得花费相当多的时间和注意力。CI 的主要目的之一是尽可能早地在开发过程中捕捉集成问题的能力。

单元测试本身不会揭示每一个问题 —— 这是不现实的期望。但编写一组强大的单元测试是早期检测最明显问题的最佳预防性方法之一。因为甚至可以在正式质量保证的第一个阶段之前运行单元测试,所以它们是开发周期中非常有价值的一部分。它们是您可以采取的第一套安全措施,以确保您的软件在生产环境中的正确行为。

这一部分不会详细说明如何在 Java 中编写单元测试。我假设你理解并接受它们的重要性,你的项目有单元测试,并且你使用了一个能够自动运行它们的框架,比如 JUnit 或 TestNG。如果你没有,立即停下来为你的项目编写一个简单的单元测试,以便在构建过程中自动运行,以便在你的 CI 解决方案中扩展这一步骤。然后,安排时间与你的开发团队坐下来,策划你将如何编写和维护单元测试。

Java 生态系统中有许多测试工具可用,本节不旨在对它们进行详尽的比较或推荐其中一种。相反,我讨论测试自动化应该如何融入你的 CI 流程,你的测试套件应该追求的特性,以及在 CI 环境中如何避免常见的陷阱,这些陷阱会影响你的效率。

监控和维护测试

将最新的代码检出到你的本地开发工作空间是相当简单的。编译和运行所有的单元测试也很简单。但随着你添加更多的模块,你的项目变得越来越复杂,进行完整的构建并运行所有的测试将会花费更多的时间。你的开发过程越长,其他代码更改就越有可能在你之前引入到主干线上。

为了防止潜在的中断,你将不得不检出最新的更改并重新运行所有的测试——这不是一个非常高效的过程。沮丧可能会导致开发人员采取捷径,跳过运行测试的步骤,以便在主干代码被更改之前提交代码。显然,这是一个很容易导致主干代码更频繁出现问题的泥潭,从而减慢整个团队的速度。

维护测试需要时间,这个时间应该定期纳入到开发进度中。就像你的代码库的其余部分一样,测试需要随着时间的推移进行改进和调整。当它们变得过时时,它们应该被移除。当它们失败时,它们应该被修复。经常在浏览各种代码库时,我会遇到被注释掉的测试用例。这发生的原因有几种;没有一种是好的。有时只是因为一个团队时间紧迫,感到需要强行通过一个构建过程以满足截止日期,并承诺稍后再次审查测试。有时,是因为一个特定的测试用例不一致地失败,这被称为flaky测试。这可能是由于竞争条件或测试错误地期望为静态的动态值。

无论哪种情况,操控测试不运行都是一种危险的做法,表明开发团队存在更大的问题。需要重新审视优先事项。不处理过时或脆弱的测试,甚至更糟的是根本不编写测试,会削弱项目的防护措施,也违背了你精心设计的持续集成流程的初衷。

有时候测试不运行是因为确定它们耗时过长。利用你的持续集成服务器定期记录测试运行时间,并确定可接受的阈值。随着项目的增长,如果构建时间超出可接受的阈值,就要停下来检查你的测试。寻找过时的测试、重复的测试以及可以并行运行的测试。考虑你希望构建服务器多频繁运行(潜在地在每次代码变更后),每秒钟都很重要。

摘要

本章介绍了持续集成作为开发团队的一项基本实践。随着时间的推移,开发的工具不断发展,帮助我们提高了在构建软件项目方面的效率。自动触发构建以及自动运行测试有助于开发人员更好地专注于编码,并在开发过程中更早地捕获错误代码。人们很容易想当然地享受自动化节省的工作量,但理解底层的细节尤为重要,特别是在涉及到你的测试套件时。不要让维护不良的测试削弱持续集成系统带来的好处。

第六章:包管理

伊什切尔·鲁伊斯

当你阅读这句话时,世界的某个地方正在编写一行代码。这行代码最终将成为一个工件的一部分,该工件将成为组织内部一个或多个企业产品中使用的构建块,或者通过公共存储库(尤其是 Java 和 Kotlin 库的 Maven 中央库)共享。

当今比以往任何时候都提供了更多的库、二进制文件和工件,随着全球开发人员继续推进下一代产品和服务,这一收藏还将继续增长。处理和管理这些工件现在比以往任何时候都需要更多的努力——由于依赖项数量不断增加,形成了一个复杂的连接网络。使用错误版本的工件是一个容易陷入的陷阱,导致混乱和构建失败,最终阻碍精心计划的项目发布日期。

对于开发人员来说,现在比以往任何时候都更加重要的是不仅要理解他们面前的源代码的功能和特殊之处,还要了解他们的项目如何打包以及如何将构建块组装成最终产品。深入了解构建过程本身以及我们的自动构建工具在幕后如何运作,对于避免延误和几小时的不必要故障排除至关重要——更不用说防止大量的错误进入生产环境了。

访问提供常见编码问题解决方案的大量第三方资源可以帮助加速我们项目的开发,但也引入了错误或意外行为的风险。了解这些组件如何进入项目,以及它们来自何处,将有助于故障排除工作。确保我们对内部生产的工件负责任将使我们能够在决定缺陷修复和功能开发的优先级时提升我们的决策能力,并有助于铺平通向生产发布的道路。开发人员不再只精通于他们面前代码的语义,还要了解包管理的复杂性。

为什么“构建和交付”不再足够?

不久以前,软件开发人员认为构建一个工件是辛苦甚至有时史诗般的努力的顶点。有时候满足截止日期意味着采用捷径和文档不全的步骤。自那时以来,行业的要求已经发生了变化,以带来更快的交付周期、多样化的环境、定制的工件、爆炸性的代码库和存储库以及多模块包。今天,构建一个工件只是更大商业周期的一个步骤之一。

成功的领导者认识到,最好的创新源于试错。这就是为什么他们把测试、实验和失败作为他们生活和公司流程的一个必不可少的部分的原因。

一种创新、快速扩展、推出更多产品、改善应用程序或产品的质量或用户体验、推出新功能的方法是通过 A/B 测试。什么是 A/B 测试?根据在哥伦比亚大学创办应用分析项目的 Kaiser Fung 所说,A/B 测试 在其最基本的层面上是一种用于比较两个版本的东西以确定哪个表现更好的百年老方法。今天,几家初创公司、微软等大型公司以及其他几家领先公司—包括亚马逊、Booking.com、Facebook 和 Google—每年都在进行超过 10,000 次的在线控制实验

Booking.com 对其网站上的每个新功能进行比较测试,比较从照片和内容的选择到按钮颜色和位置的细节。通过将几个版本相互比较并跟踪客户反馈,该公司能够不断改善用户体验。

我们如何交付和部署由众多工件组成的多个软件版本?我们如何找到瓶颈?我们如何知道我们正朝着正确的方向发展?我们如何跟踪工作得很好或者对我们不利的情况?我们如何保持可重现的结果,但又具有丰富的谱系?这些问题的答案可以通过捕获和分析有关工作流程和工件的输入、输出和状态的相关、上下文、清晰和具体信息来找到。所有这些都是可能的,这要归功于元数据。

全都是关于元数据

正如 W. Edwards Deming 所说:“信仰上帝;所有其他人带来数据。” 元数据 被定义为相关信息的结构化键/值存储。换句话说,它是适用于特定实体的属性或特性的集合,在我们的案例中适用于工件和流程。

元数据使得能够发现相关性和因果关系,以及对组织行为和结果的洞察。因此,元数据可以显示组织是否对其利益相关者的目标保持敏感。

附加数据可以在后期用于提取或衍生更多信息。这些数据有助于扩展视角并创造更多故事或叙述。选择添加哪些属性、基数和值很重要—太多了会影响性能;太少了会错过信息。如果值太多,洞察力就会丧失。

一个好的起点是回答关于软件开发周期每个阶段的主要阶段的以下问题:谁?什么?如何?在哪里?何时?提出正确的问题只是一半的工作,然而,拥有清晰、相关、具体和清晰的答案,并能进行归一化或枚举,总是一个好的做法。

有见地的元数据的关键属性

有见地的数据应具备以下所有特点:

上下文化

所有数据都需要在参考框架内进行解释。为了提取和比较可能的场景,分析阶段非常重要。

相关

值的可变性对结果产生影响,或者描述结果或过程中的特定阶段或时间。

具体

值描述清晰的事件(即初始值、结束值)。

清晰

可能的值是众所周知或定义的,可计算且可比较的。

独特

具有单一、独特的值。

可扩展

因为人类知识的丰富程度在不断增加,所以数据需要定义机制,以便标准可以进化和扩展,以适应新属性。

一旦您已经定义了记录软件开发周期阶段、输入、输出和状态的内容的“什么”、“何时”、“为什么”和“如何”,您还需要牢记元数据子集的消费者。一方面,您可能会有一个中间的私有消费者,该消费者将以不同方式消费和响应一组值,从触发子流水线、推广构建、在不同环境中部署,或发布工件。另一方面,您可能会有最终的外部消费者,他们能够提取信息,并通过技能和经验将其转化为洞察力,以帮助实现组织的整体目标。

元数据考虑

以下是关于元数据的重要考虑因素:

隐私与安全

考虑清楚暴露值的后果。

可见性

并非所有消费者都对所有数据感兴趣。

格式和编码

一种特定的属性可能在不同阶段以不同格式暴露,但在命名、含义及可能的一般值上需要保持一致。

现在让我们转向使用构建工具生成和打包元数据。在 Java 生态系统中,构建工具众多,其中最流行的可能是 Apache Maven 和 Gradle;因此,深入讨论它们是有意义的。然而,如果您的构建依赖于不同的构建工具,本节提供的信息仍然可能很有用,因为一些收集和打包元数据的技术可能会被重复使用。

现在,在我们开始实际的代码片段之前,我们必须明确三个行动项:

  1. 确定应该与工件打包的元数据。

  2. 弄清楚在构建过程中如何获取元数据。

  3. 处理元数据,并以适当的格式或格式记录它。

以下子节涵盖了每个方面。

确定元数据

构建环境中有大量信息可以转换为元数据并与构件一起打包。一个很好的例子是构建时间戳,它标识了构建生成构件的时间和日期。可以遵循许多时间戳格式,但我建议使用ISO 8601,其使用java.text.SimpleDateformat格式化表示为yyyy-MM-dd'T'HH:mm:ssXXX——当捕获时间戳依赖于java.util.Date时非常有用。或者,如果捕获时间戳依赖于java.time.LocalDateTime,则可以使用java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME。构建的操作系统详细信息也可能是感兴趣的,以及 JDK 信息,如版本、ID 和供应商。幸运的是,这些信息由 JVM 捕获并通过System属性公开。

考虑包括构件的 ID 和版本(即使这些值通常编码在构件的文件名中),作为一种预防措施,以防构件在某些时候被重命名。版本控制信息也至关重要。从源代码控制中获取的有用信息包括提交哈希、标签和分支名称。此外,您可能希望捕获特定的构建信息,例如运行构建的用户、构建工具的名称、ID 和版本,以及构建机器的主机名和 IP 地址。这些键/值对可能是最重要和最常见的元数据。但是,您可能还需要选择其他工具和系统所需的额外键/值对,以消费生成的构件。

注意

强调检查团队和组织关于敏感数据访问和可见性的政策有多么重要是不够的。如果敏感数据键/值对被第三方或外部消费者访问,这些键/值对可能被视为安全风险,尽管它们对内部消费者非常重要。

获取元数据

在确定需要捕获哪些元数据之后,我们必须找到一种使用我们选择的构建工具来收集元数据的方法。某些键/值对可以直接从环境、系统设置和由 JVM 公开的命令标志(作为环境变量或System属性)中获取。额外的属性可能由构建工具本身公开,无论是作为额外的命令行参数还是作为工具配置设置中的配置元素。

让我们假设我们需要在某一时刻捕获以下键/值对:

  • JDK 信息,例如版本和供应商

  • 操作系统信息,如名称、架构和版本

  • 构建时间戳

  • 来自版本控制系统的当前提交哈希(假设为 Git)

这些值可以通过 Maven 捕获,通过使用前两个项目的System属性的组合和第三方插件来实现最后两个项目。当涉及到提供与 Git 集成的插件时,Maven 和 Gradle 都有丰富的选择;然而,我推荐选择Maven 的 git-commit-id 插件Gradle 的 versioning 插件,因为这些插件到目前为止是最通用的。

现在,Maven 允许以多种方式定义属性,最常见的是作为pom.xml构建文件中<properties>部分内的键/值对。每个键的值都是自由文本,尽管您可以使用简化表示法引用System属性或使用命名约定引用环境变量。假设您想访问在System属性中找到的java.version键的值。这可以通过使用${}简写表示法,如${java.version}来完成。相反,对于环境变量,您可以使用${env.*NAME*}表示法。例如,可以使用${env.TOKEN}表达式在pom.xml构建文件中访问名为TOKEN的环境变量的值。将git-commit-id插件和构建属性组合在一起可能会导致类似以下的pom.xml文件:

<project xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.acme</groupId>
  <artifactId>example</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <build.jdk>${java.version} (${java.vendor} ${java.vm.version})</build.jdk>
    <build.os>${os.name} ${os.arch} ${os.version}</build.os>
    <build.revision>${git.commit.id}</build.revision>
    <build.timestamp>${git.build.time}</build.timestamp>
  </properties>

  <build>
    <plugins>
      <plugin>
        <groupId>pl.project13.maven</groupId>
        <artifactId>git-commit-id-plugin</artifactId>
        <version>4.0.3</version>
        <executions>
          <execution>
            <id>resolve-git-properties</id>
            <goals>
              <goal>revision</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <verbose>false</verbose>
          <failOnNoGitDirectory>false</failOnNoGitDirectory>
          <generateGitPropertiesFile>true</generateGitPropertiesFile>
          <generateGitPropertiesFilename>
            ${project.build.directory}/git.properties
          </generateGitPropertiesFilename>
          <dateFormat>yyyy-MM-dd'T'HH:mm:ssXXX</dateFormat>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

注意到build.jdkbuild.os的值已经包含格式,因为它们是更简单值的组合,而build.revisionbuild.timestamp的值来自 Git 插件定义的属性。我们还未确定最终的格式和包含元数据的文件或文件,这就是为什么我们看到它在<properties>部分中定义的原因。这种设置允许这些值在需要时被其他插件重用和消费。选择这种设置的另一个原因是,外部工具(比如在构建流水线中找到的工具)可以更容易地读取这些值,因为它们位于特定部分,而不是在构建文件的多个位置。

还要注意所选择的版本值,1.0.0-SNAPSHOT。您可以根据需要使用任何字符组合作为版本。然而,习惯上至少使用定义两个数字的*major*.*minor*格式的字母数字序列。存在多种版本控制约定,各有优缺点。尽管如此,使用-SNAPSHOT标签具有特殊含义,因为它表示该构件尚未准备好投入生产。一些工具在检测到快照版本时会有不同的行为;例如,它们可以阻止构件发布到生产环境。

与 Maven 相比,Gradle 在定义和编写构建文件时没有短缺的选项。首先,自 Gradle 4 以来,您可以选择两种构建文件格式:Apache Groovy DSL 或 Kotlin DSL。无论您选择哪种,您很快会发现有更多选项可以捕获和格式化元数据。其中一些可能是习惯用法的,有些可能需要额外的插件,甚至有些可能被视为过时或已废弃。为了简单起见,我们将采用 Groovy 和小型习惯用法表达式。我们将类似于 Maven 一样捕获相同的元数据,前两个值来自System属性和由versioning Git 插件提供的提交哈希,但构建时间戳将通过使用自定义代码即时计算。以下代码片段显示了如何实现这一点:

plugins {
  id 'java-library'
  id 'net.nemerosa.versioning' version '2.14.0'
}

version = '1.0.0-SNAPSHOT'

ext {
  buildJdk = [
    System.properties['java.version'],
    '(' + System.properties['java.vendor'],
    System.properties['java.vm.version'] + ')'
  ].join(' ')
  buildOs = [
    System.properties['os.name'],
    System.properties['os.arch'],
    System.properties['os.version']
  ].join(' ')
  buildRevision = project.extensions.versioning.info.commit
  buildTimestamp = new Date().format("yyyy-MM-dd'T'HH:mm:ssXXX")
}

这些计算得出的数值将作为动态项目属性提供,稍后可以通过其他配置的元素(如扩展、任务、Groovy 的闭包、Groovy 和 Kotlin 的动作以及 DSL 公开的其他元素)在构建过程中消耗。现在只剩下将元数据记录在指定的格式中。

写入元数据

您可能需要记录多种格式或文件中的元数据。格式的选择取决于预期的消费者。有些消费者需要一种独特的格式,其他消费者无法读取,而其他人可能理解各种格式。请务必查阅特定消费者的文档,了解其支持的格式和选项,并检查您选择的构建工具是否提供了集成。您可能会发现,您的构建工具中提供了插件,可以简化您所需的元数据记录过程。为了演示目的,我们将使用两种流行格式记录元数据:Java 属性文件和 JAR 的清单。

我们可以利用 Maven 的资源过滤,这已经集成到资源插件中,是每个构建都可以访问的核心插件之一。为了使其工作,我们必须将以下代码片段添加到前面的pom.xml文件中的<build>部分:

<resources>
  <resource>
    <directory>src/main/resources</directory>
    <filtering>true</filtering>
  </resource>
</resources>

还需要一个位于src/main/resources的配套属性文件。我选择META-INF/metadata.properties作为位于工件 JAR 内部的属性文件的相对路径和名称。当然,您可以根据需要选择不同的命名约定。此文件依赖于变量占位符替换,这些变量将从项目属性中解析,例如我们在<properties>部分设置的那些。按照惯例,在构建文件中几乎不需要配置信息。属性文件看起来像这样:

build.jdk       = ${build.jdk}
build.os        = ${build.os}
build.revision  = ${build.revision}
build.timestamp = ${build.timestamp}

在 JAR 的清单中记录元数据需要调整 jar-maven-plugin 的配置,适用于构建文件。下面的片段必须包含在 <build> 部分中的 <plugins> 部分内。换句话说,它与本节前面看到的 git-commit-id 插件是同级的:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <version>3.2.0</version>
  <configuration>
    <archive>
      <manifestEntries>
        <Build-Jdk>${build.jdk}</Build-Jdk>
        <Build-OS>${build.os}</Build-OS>
        <Build-Revision>${build.revision}</Build-Revision>
        <Build-Timestamp>${build.timestamp}</Build-Timestamp>
      </manifestEntries>
    </archive>
  </configuration>
</plugin>

注意,即使此插件是核心插件集的一部分,也定义了特定的插件版本。背后的原因是必须声明所有插件版本以便实现可重复的构建。否则,你会发现构建可能会有所不同,因为根据运行构建所使用的 Maven 版本的不同,可能会解析不同的插件版本。清单中的每个条目由大写键和捕获值组成。使用 mvn package 运行构建会解析捕获的属性,将解析后的值复制到 target/classes 目录中的元数据属性文件中,该文件将被添加到最终 JAR 中,并且将元数据注入到 JAR 的清单中。我们可以通过检查生成的构件的内容来验证这一点:

$ mvn verify
$ jar tvf target/example-1.0.0-SNAPSHOT.jar
    0 Sun Jan 10 20:41 CET 2021 META-INF/
  131 Sun Jan 10 20:41 CET 2021 META-INF/MANIFEST.MF
  205 Sun Jan 10 20:41 CET 2021 META-INF/metadata.properties
    0 Sun Jan 10 20:41 CET 2021 META-INF/maven/
    0 Sun Jan 10 20:41 CET 2021 META-INF/maven/com.acme/
    0 Sun Jan 10 20:41 CET 2021 META-INF/maven/com.acme/example/
 1693 Sun Jan 10 19:13 CET 2021 META-INF/maven/com.acme/example/pom.xml
  109 Sun Jan 10 20:41 CET 2021 META-INF/maven/com.acme/example/pom.properties

两个文件如预期般在 JAR 文件中找到。提取 JAR 并查看属性文件和 JAR 清单的内容将得到以下结果:

build.jdk       = 11.0.9 (Azul Systems, Inc. 11.0.9+11-LTS)
build.os        = Mac OS X x86_64 10.15.7
build.revision  = 0ab9d51a3aaa17fca374d28be1e3f144801daa3b
build.timestamp = 2021-01-10T20:41:11+01:00
Manifest-Version: 1.0
Created-By: Maven Jar Plugin 3.2.0
Build-Jdk-Spec: 11
Build-Jdk: 11.0.9 (Azul Systems, Inc. 11.0.9+11-LTS)
Build-OS: Mac OS X x86_64 10.15.7
Build-Revision: 0ab9d51a3aaa17fca374d28be1e3f144801daa3b
Build-Timestamp: 2021-01-10T20:41:11+01:00

你已经看到如何使用 Maven 收集元数据。现在让我们看看通过另一个构建工具 Gradle 来记录元数据的相同方法。首先,我们将配置由我们应用于构建的 java-library 插件提供的标准 processResources 任务。额外的配置可以追加到之前展示的 Gradle 构建文件中,如下所示:

processResources {
  expand(
    'build_jdk'      : project.buildJdk,
    'build_os'       : project.buildOs,
    'build_revision' : project.buildRevision,
    'build_timestamp': project.buildTimestamp
  )
}

注意到键名中使用 _ 作为令牌分隔符,这是由 Gradle 默认的资源过滤机制所决定的。如果我们像之前 Maven 中那样使用 .,Gradle 将期望在资源过滤时找到具有匹配 jdkosrevisiontimestamp 属性的 build 对象。然而这个对象并不存在,会导致构建失败。改变令牌分隔符可以避免这个问题,但也迫使我们修改属性文件的内容如下:

build.jdk       = ${build_jdk}
build.os        = ${build_os}
build.revision  = ${build_revision}
build.timestamp = ${build_timestamp}

配置 JAR 清单是一个简单的操作,因为 jar 任务为此行为提供了一个入口点,如下片段所示,也可以追加到现有的 Gradle 构建文件中:

jar {
  manifest {
    attributes(
      'Build-Jdk'      : project.buildJdk,
      'Build-OS'       : project.buildOs,
      'Build-Revision' : project.buildRevision,
      'Build-Timestamp': project.buildTimestamp
    )
  }
}

正如之前看到的那样,每个清单条目使用大写键和相应的捕获值。使用 gradle jar 运行构建应该会产生类似 Maven 提供的结果:属性文件将被复制到目标位置,在最终 JAR 中包含其值占位符的替换值,并且 JAR 清单也将被丰富为包含元数据。检查 JAR 文件显示它包含了预期的文件:

$ gradle jar
$ jar tvf build/libs/example-1.0.0-SNAPSHOT.jar
     0 Sun Jan 10 21:08:22 CET 2021 META-INF/
    25 Sun Jan 10 21:08:22 CET 2021 META-INF/MANIFEST.MF
   165 Sun Jan 10 21:08:22 CET 2021 META-INF/metadata.properties

解压 JAR 并查看每个文件内部的结果如下:

build.jdk       = 11.0.9 (Azul Systems, Inc. 11.0.9+11-LTS)
build.os        = Mac OS X x86_64 10.15.7
build.revision  = 0ab9d51a3aaa17fca374d28be1e3f144801daa3b
build.timestamp = 2021-01-10T21:08:22+01:00
Manifest-Version: 1.0
Build-Jdk: 11.0.9 (Azul Systems, Inc. 11.0.9+11-LTS)
Build-OS: Mac OS X x86_64 10.15.7
Build-Revision: 0ab9d51a3aaa17fca374d28be1e3f144801daa3b
Build-Timestamp: 2021-01-10T21:08:22+01:00

完美!就是这样。让我鼓励你根据需要添加或删除键/值对,并配置其他插件(用于 Maven 和 Gradle)以公开附加元数据或提供其他处理和记录元数据到特定格式的方法。

Maven 和 Gradle 的依赖管理基础

依赖管理自 Maven 1.x 于 2002 年首次亮相以来,已成为 Java 项目的重要组成部分。该功能的核心是声明编译、测试和消费特定项目所需的构件,依赖于附加到构件的附加元数据,例如其组标识符、构件标识符、版本,有时还包括分类器。这些元数据通常以广为人知的文件格式公开:在 pom.xml 文件中表达的 Apache Maven POM。其他构建工具能够理解该格式,甚至可以生成和发布 pom.xml 文件,尽管使用完全不相关的格式声明构建方面,例如 Gradle 使用的 build.gradle(Groovy)或 build.gradle.kts(Kotlin)构建文件。

尽管自 Maven 早期以来便是提供的核心功能,并且也是 Gradle 的核心功能,但依赖管理和依赖解析仍然是许多人的绊脚石。尽管声明依赖项的规则并不复杂,但您可能会发现自己受到发布的具有无效、误导或缺失约束的元数据的影响。以下各小节是使用 Maven 和 Gradle 进行依赖管理的入门指南,但这绝不是详尽的解释——仅此话题就可以写一整本书。

换句话说,亲爱的读者,请小心,前方有龙。我将尽力指出最安全的路径。我们将从 Maven 开始,因为它是使用 pom.xml 文件格式定义构件元数据的构建工具。

使用 Apache Maven 进行依赖管理

你可能以前遇到过 POM 文件,毕竟它无处不在。具有模型版本 4.0.0 的 POM 文件负责定义产生和消费构件的方式。在 Maven 4 版本中,这两个功能已分开,尽管模型版本出于兼容性原因保持不变。预计当引入 Maven 5.0.0 版本时,模型格式将会发生变化,尽管目前写作时尚无详细信息。有一点可以确定:Maven 开发人员热衷于保持向后兼容性。让我们从基础知识开始。

依赖由三个必需元素标识:groupIdartifactIdversion。这些元素统称为 Maven 坐标GAV 坐标,其中 GAV 如你所料,代表 groupIdartifactIdversion。偶尔你会发现定义了名为 classifier 的第四个元素的依赖项。

让我们逐一分解它们。artifactIdversion 都很直接;前者定义了构件的“名称”,后者定义了版本号。同一个 artifactId 可能关联多个不同的版本。groupId 用于组合一组具有某种关系的构件,即它们都属于同一个项目或提供彼此相关的行为。classifier 添加了构件的另一个维度,尽管它是可选的。分类器通常用于区分特定设置下的构件,例如操作系统或 Java 版本。操作系统分类器的示例可以在 JavaFX 二进制文件中找到,例如 javafx-controls-15-win.jarjavafx-controls-15-mac.jarjavafx-controls-15-linux.jar,它们标识了可以与 Windows、macOS 和 Linux 平台一起使用的 JavaFX 控件二进制版本 15。

另一组常见的分类器是 sourcesjavadoc,它们标识包含源代码和生成文档(通过 Javadoc 工具)的 JAR 文件。GAV 坐标的组合必须是唯一的;否则,依赖解析机制将很难找到正确的依赖项使用。

POM 文件允许你在 <dependencies> 部分内定义依赖项,其中你会列出每个依赖项的 GAV 坐标。在其最简单的形式中,看起来像这样:

<?xml version="1.0" encoding="UTF-8"?>
<project
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd"
  xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.acme</groupId>
  <artifactId>example</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-collections4</artifactId>
      <version>4.4</version>
    </dependency>
  </dependencies>
</project>

以这种方式列出的依赖项称为 直接依赖项,因为它们在 POM 文件中是显式声明的。即使依赖项可能在标记为当前 POM 的父 POM 中声明,此分类也成立。什么是父 POM?它就像另一个 pom.xml 文件一样,只是你的 POM 通过使用 <parent> 部分将它标记为父/子关系。通过这种方式,父 POM 定义的配置可以被子 POM 继承。我们可以通过调用 mvn dependency:tree 命令来检查依赖图,该命令解析依赖图并将其打印出来:

$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< com.acme:example >--------------------------
[INFO] Building example 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ example ---
[INFO] com.acme:example:jar:1.0.0-SNAPSHOT
[INFO] \- org.apache.commons:commons-collections4:jar:4.4:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

这里我们可以看到当前 POM(通过其 GAV 坐标标识为 com.acme:example:1.0.0-SNAPSHOT)具有单个直接依赖项。在 commons-collections4 依赖项的输出中找到了两个额外的元素:第一个是 jar,它标识了构件的类型,第二个是 compile,它标识了依赖的范围。稍后我们会回到范围的问题,但可以简单地说,如果对于一个依赖项没有显式定义 <scope> 元素,那么它的默认范围就是 compile。现在,当消费包含直接依赖项的 POM 时,它会从消费 POM 的角度带来这些依赖项作为传递性。下一个示例展示了具体的设置:

<?xml version="1.0" encoding="UTF-8"?>
<project
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd"
  xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.acme</groupId>
  <artifactId>example</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <dependencies>
    <dependency>
      <groupId>commons-beanutils</groupId>
      <artifactId>commons-beanutils</artifactId>
      <version>1.9.4</version>
    </dependency>
  </dependencies>
</project>

使用与之前相同的命令解析和打印依赖图产生了以下结果:

$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< com.acme:example >--------------------------
[INFO] Building example 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ example ---
[INFO] com.acme:example:jar:1.0.0-SNAPSHOT
[INFO] \- commons-beanutils:commons-beanutils:jar:1.9.4:compile
[INFO]    +- commons-logging:commons-logging:jar:1.2:compile
[INFO]    \- commons-collections:commons-collections:jar:3.2.2:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

这告诉我们,commons-beanutils 组件有两个依赖关系设置在com⁠pile范围内,从com.acme:example:1.0.0-SNAPSHOT的角度来看,这两个传递依赖关系被视为传递的。这两个传递依赖关系似乎没有自己的直接依赖关系,因为它们的列表中没有任何内容。然而,如果你查看commons-logging的 POM 文件,你会发现以下依赖声明:

<dependencies>
  <dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>logkit</groupId>
    <artifactId>logkit</artifactId>
    <version>1.0.1</version>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>avalon-framework</groupId>
    <artifactId>avalon-framework</artifactId>
    <version>4.1.5</version>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>servlet-api</artifactId>
    <version>2.3</version>
    <scope>provided</scope>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>3.8.1</version>
    <scope>test</scope>
  </dependency>
</dependencies>

正如你所见,实际上有五个依赖关系!然而,其中四个定义了一个额外的<optional>元素,而另外两个定义了<scope>的不同值。标记为<optional>的依赖关系可能对编译和测试生产者(本例中的commons-logging)是必需的,但并不一定适用于消费者;这是根据具体情况确定的。

现在我们再次看到它们时,是时候讨论范围了。范围确定依赖项是否包括在类路径中,并限制其传递性。Maven 定义了六个范围,如下所示:

compile

默认的范围,如果未指定范围,则使用之前看到的范围。这个范围中的依赖项将用于项目中的所有类路径(compile、runtime、test),并将传播到消费项目中。

provided

类似于compile,但不影响运行时类路径,也不是传递的。在这个范围内设置的依赖项预期由托管环境提供,例如作为 WAR 包打包并在应用服务器中启动的 Web 应用程序。

runtime

这个范围指示依赖项不是编译所必需的,而是执行所必需的。运行时和测试类路径都包括在这个范围内设置的依赖项,而编译类路径则被忽略。

test

定义了编译和运行测试所需的依赖项。这个范围不是传递的。

system

类似于provided,但必须列出具有显式路径(相对或绝对)的依赖项。因此,这个范围被视为不良实践,应该尽量避免使用。对于少数用例,它可能会有所帮助,但你必须承担后果。最好情况下,它是留给专家的选择——换句话说,可以想象这个范围根本不存在。

import

仅适用于pom类型的依赖项(如果未指定,则默认为jar)。只能用于在<dependencyManagement>部分内声明的依赖项。在这个范围内的依赖项将被其自身<dependencyManagement>部分中的依赖项列表所替代。

<dependencyManagement>部分有三个目的:为传递依赖项提供版本提示,提供一个可以使用import范围导入的依赖项列表,并在父子 POM 组合中使用一组默认值。让我们看看第一个目的。假设在你的 POM 文件中定义了以下依赖项:

<?xml version="1.0" encoding="UTF-8"?>
<project
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd"
  xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.acme</groupId>
  <artifactId>example</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <dependencies>
    <dependency>
      <groupId>com.google.inject</groupId>
      <artifactId>guice</artifactId>
      <version>4.2.2</version>
    </dependency>
    <dependency>
      <groupId>com.google.truth</groupId>
      <artifactId>truth</artifactId>
      <version>1.0</version>
    </dependency>
  </dependencies>
</project>

guicetruth 的 artifact 都将 guava 定义为直接依赖。这意味着从消费者的角度来看,guava 被视为一个传递依赖。如果我们解析并打印出依赖图,我们会得到以下结果:

$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< com.acme:example >--------------------------
[INFO] Building example 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ example ---
[INFO] com.acme:example:jar:1.0.0-SNAPSHOT
[INFO] +- com.google.inject:guice:jar:4.2.2:compile
[INFO] |  +- javax.inject:javax.inject:jar:1:compile
[INFO] |  +- aopalliance:aopalliance:jar:1.0:compile
[INFO] |  \- com.google.guava:guava:jar:25.1-android:compile
[INFO] |     +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
[INFO] |     +- com.google.j2objc:j2objc-annotations:jar:1.1:compile
[INFO] |     \- org.codehaus.mojo:animal-sniffer-annotations:jar:1.14:compile
[INFO] \- com.google.truth:truth:jar:1.0:compile
[INFO]    +- org.checkerframework:checker-compat-qual:jar:2.5.5:compile
[INFO]    +- junit:junit:jar:4.12:compile
[INFO]    |  \- org.hamcrest:hamcrest-core:jar:1.3:compile
[INFO]    +- com.googlecode.java-diff-utils:diffutils:jar:1.3.0:compile
[INFO]    +- com.google.auto.value:auto-value-annotations:jar:1.6.3:compile
[INFO]    \- com.google.errorprone:error_prone_annotations:jar:2.3.1:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

guava 的解析版本结果是 25.1-android,因为这是在图中首先找到的版本。看看如果我们颠倒依赖项的顺序,然后再次解析图形会发生什么:

$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< com.acme:example >--------------------------
[INFO] Building example 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ example ---
[INFO] com.acme:example:jar:1.0.0-SNAPSHOT
[INFO] +- com.google.truth:truth:jar:1.0:compile
[INFO] |  +- com.google.guava:guava:jar:27.0.1-android:compile
[INFO] |  |  +- com.google.guava:failureaccess:jar:1.0.1:compile
[INFO] |  |  +- com.google.guava:listenablefuture:jar:
                        9999.0-empty-to-avoid-conflict
[INFO] |  |  +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
[INFO] |  |  +- com.google.j2objc:j2objc-annotations:jar:1.1:compile
[INFO] |  |  \- org.codehaus.mojo:animal-sniffer-annotations:jar:1.17:compile
[INFO] |  +- org.checkerframework:checker-compat-qual:jar:2.5.5:compile
[INFO] |  +- junit:junit:jar:4.12:compile
[INFO] |  |  \- org.hamcrest:hamcrest-core:jar:1.3:compile
[INFO] |  +- com.googlecode.java-diff-utils:diffutils:jar:1.3.0:compile
[INFO] |  +- com.google.auto.value:auto-value-annotations:jar:1.6.3:compile
[INFO] |  \- com.google.errorprone:error_prone_annotations:jar:2.3.1:compile
[INFO] \- com.google.inject:guice:jar:4.2.2:compile
[INFO]    +- javax.inject:javax.inject:jar:1:compile
[INFO]    \- aopalliance:aopalliance:jar:1.0:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

现在,guava 的解析版本恰好是 27.0.1-android,因为它是在图中首先找到的版本。这种特定行为常常让人不知所措和沮丧。作为开发者,我们习惯于版本控制规范,尤其是 语义化版本控制

语义化版本控制告诉我们,版本号标记(由点分隔)根据其位置具有特定含义。第一个标记标识主要发布版本,第二个标记标识次要发布版本,第三个标记标识构建/补丁/修订版本。通常情况下,版本 27.0.1 被视为比 25.1.0 更近,因为主要编号 27 大于 25。在我们的情况下,图中有两个 guava 版本,27.0.1-android25.1-android,并且两者在当前 POM 的同一级别——即传递图中仅下降一级处找到。

开发者通常会认为,因为我们知道语义化版本控制,并且可以清楚地确定哪个版本更新,所以 Maven 也能做到这一点,但现实并非如此! Maven 从不查看版本号,而只看图中的位置。这就是为什么如果我们改变依赖项的顺序,会得到不同的结果。我们可以使用 <dependencyManagement> 部分来解决这个问题。

<dependencyManagement> 部分定义的依赖通常具有三个主要的 GAV 坐标。当 Maven 解析依赖关系时,它会查看此部分中的定义,以确定是否有匹配的 groupIdartifactId,如果匹配,则使用关联的 version。不管依赖项在图中的深度如何,或者它在图中出现了多少次,只要匹配,就会选择明确指定的版本。我们可以通过向消费者 POM 添加类似于以下内容的 <dependencyManagement> 部分来验证这一点:

<?xml version="1.0" encoding="UTF-8"?>
<project
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd"
  xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.acme</groupId>
  <artifactId>example</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>29.0-jre</version>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>com.google.truth</groupId>
      <artifactId>truth</artifactId>
      <version>1.0</version>
    </dependency>
    <dependency>
      <groupId>com.google.inject</groupId>
      <artifactId>guice</artifactId>
      <version>4.2.2</version>
    </dependency>
  </dependencies>
</project>

我们可以看到 guava 的声明使用了 com.google.guava:guava:29.0-jre 坐标,这意味着如果一个传递依赖恰好匹配给定的 groupIdartifactId,将会使用版本 29.0-jre。我们知道这将在我们的消费者 POM 中发生两次。当解析并打印出依赖图时,我们得到以下结果:

$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< com.acme:example >--------------------------
[INFO] Building example 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ example ---
[INFO] com.acme:example:jar:1.0.0-SNAPSHOT
[INFO] +- com.google.truth:truth:jar:1.0:compile
[INFO] |  +- com.google.guava:guava:jar:29.0-jre:compile
[INFO] |  |  +- com.google.guava:failureaccess:jar:1.0.1:compile
[INFO] |  |  +- com.google.guava:listenablefuture:jar:
                        9999.0-empty-to-avoid-conflict
[INFO] |  |  +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
[INFO] |  |  +- org.checkerframework:checker-qual:jar:2.11.1:compile
[INFO] |  |  \- com.google.j2objc:j2objc-annotations:jar:1.3:compile
[INFO] |  +- org.checkerframework:checker-compat-qual:jar:2.5.5:compile
[INFO] |  +- junit:junit:jar:4.12:compile
[INFO] |  |  \- org.hamcrest:hamcrest-core:jar:1.3:compile
[INFO] |  +- com.googlecode.java-diff-utils:diffutils:jar:1.3.0:compile
[INFO] |  +- com.google.auto.value:auto-value-annotations:jar:1.6.3:compile
[INFO] |  \- com.google.errorprone:error_prone_annotations:jar:2.3.1:compile
[INFO] \- com.google.inject:guice:jar:4.2.2:compile
[INFO]    +- javax.inject:javax.inject:jar:1:compile
[INFO]    \- aopalliance:aopalliance:jar:1.0:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

注意 guava 的选择版本确实是 29.0-jre,而不是本章早些时候看到的旧版本,这证实了 <dependencyManagement> 部分按预期执行其工作。

<dependencyManagement> 的第二个目的是列出可能被导入的依赖项,通过使用 import 范围和类型为 pom 的依赖项来实现。这些类型的依赖通常定义了它们自己的 <dependencyManagement> 部分,虽然这些 POM 可以增加更多的部分。定义了 <dependencyManagement> 部分但没有 <dependencies> 部分的 POM 依赖项称为材料清单(BOM)。通常,BOM 依赖项定义了一组为特定目的归属在一起的构件。尽管在 Maven 文档中没有明确定义,但可以找到两种类型的 BOM 依赖项:

所有声明的依赖关系都属于同一个项目,即使它们可能具有不同的组 ID,甚至可能有不同的版本。例如,可以看到 helidon-bom,它将 Helidon 项目的所有构件分组在一起。

堆栈

依赖项按行为和它们带来的协同作用进行分组。依赖关系可能属于不同的项目。请参阅 helidon-dependencies 的示例,它将之前的 helidon-bom 与其他依赖项(如 Netty、日志记录等)分组在一起。

让我们将 helidon-dependencies 视为依赖项的源头。检查此 POM,我们发现在其 <dependencyManagement> 部分声明了几十个依赖项,以下摘录中仅显示了其中的几个:

<artifactId>helidon-dependencies</artifactId>
<packaging>pom</packaging>
<!-- additional elements elided -->
<dependencyManagement>
  <dependencies>
    <!-- more dependencies elided -->
    <dependency>
      <groupId>io.netty</groupId>
      <artifactId>netty-handler</artifactId>
      <version>4.1.51.Final</version>
    </dependency>
    <dependency>
      <groupId>io.netty</groupId>
      <artifactId>netty-handler-proxy</artifactId>
      <version>4.1.51.Final</version>
    </dependency>
    <dependency>
      <groupId>io.netty</groupId>
      <artifactId>netty-codec-http</artifactId>
      <version>4.1.51.Final</version>
    </dependency>
    <!-- more dependencies elided -->
  </dependencies>
</dependencyManagement>

在我们自己的 POM 中使用这个 BOM 依赖项需要再次使用 <dependencyManagement> 部分。我们还将为 netty-handler 定义一个显式依赖,就像我们在定义依赖时做过的那样,只是这次我们会省略 <version> 元素。POM 最终看起来像这样:

<?xml version="1.0" encoding="UTF-8"?>
<project
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd"
  xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.acme</groupId>
  <artifactId>example</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.helidon</groupId>
        <artifactId>helidon-dependencies</artifactId>
        <version>2.2.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>io.netty</groupId>
      <artifactId>netty-handler</artifactId>
    </dependency>
  </dependencies>
</project>

注意 helidon-dependencies 依赖项是如何导入的。必须定义一个关键元素 <type>,它必须设置为 pom。还记得本章前面提到的吗?如果未指定值,默认情况下依赖关系的类型为 jar。在这里,我们知道 helidon-dependencies 是一个 BOM;因此它没有与之关联的 JAR 文件。如果我们省略类型元素,Maven 将发出警告并且无法解析 netty-handler 的版本,所以务必确保正确设置这个元素。解析依赖关系图会产生以下结果:

$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< com.acme:example >--------------------------
[INFO] Building example 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ example ---
[INFO] com.acme:example:jar:1.0.0-SNAPSHOT
[INFO] \- io.netty:netty-handler:jar:4.1.51.Final:compile
[INFO]    +- io.netty:netty-common:jar:4.1.51.Final:compile
[INFO]    +- io.netty:netty-resolver:jar:4.1.51.Final:compile
[INFO]    +- io.netty:netty-buffer:jar:4.1.51.Final:compile
[INFO]    +- io.netty:netty-transport:jar:4.1.51.Final:compile
[INFO]    \- io.netty:netty-codec:jar:4.1.51.Final:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

我们可以看到选择了正确的版本,并且 netty-handler 的每个直接依赖关系也被解析为传递依赖关系。

<dependencyManagement> 部分的第三个和最后一个目的在于当 POM 之间存在父子关系时发挥作用。POM 格式定义了一个 <parent> 部分,任何 POM 都可以使用它来与另一个被视为父级的 POM 建立关联。父 POM 提供的配置可以被子 POM 继承,其中父 <dependencyManagement> 部分就是其中之一。Maven 沿着父链接向上进行,直到无法找到父定义,然后沿着链条向下处理解析配置,位于较低级别的 POM 覆盖由较高级别 POM 设置的配置。

这意味着子 POM 总是有选择权来覆盖父 POM 声明的配置。因此,父 POM 中找到的 <dependencyManagement> 部分将对子 POM 可见,就像它是在子 POM 上定义的一样。我们仍然可以从此部分前两个目的中获得相同的好处,这意味着我们可以为传递依赖项固定版本并导入 BOM 依赖项。以下是一个父 POM 的示例,它在自己的 <dependencyManagement> 部分中声明了 helidon-dependenciescommons-lang3

<?xml version="1.0" encoding="UTF-8"?>
<project
   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd"
   xmlns="http://maven.apache.org/POM/4.0.0"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.acme</groupId>
  <artifactId>parent</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.helidon</groupId>
        <artifactId>helidon-dependencies</artifactId>
        <version>2.2.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.11</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

鉴于此 POM 文件未关联任何 JAR 文件,我们还必须显式定义 <packaging> 元素的值为 pom。子 POM 需要使用 <parent> 元素来引用此 POM,如下例所示:

<?xml version="1.0" encoding="UTF-8"?>
<project
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 http://maven.apache.org/xsd/maven-4.0.0.xsd"
  xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>com.acme</groupId>
    <artifactId>parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
  </parent>
  <artifactId>example</artifactId>

  <dependencies>
    <dependency>
      <groupId>io.netty</groupId>
      <artifactId>netty-handler</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
    </dependency>
  </dependencies>
</project>

完美!准备好这个设置后,现在是时候再次解析依赖图并检查其内容了:

$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< com.acme:example >--------------------------
[INFO] Building example 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ example ---
[INFO] com.acme:example:jar:1.0.0-SNAPSHOT
[INFO] +- io.netty:netty-handler:jar:4.1.51.Final:compile
[INFO] |  +- io.netty:netty-common:jar:4.1.51.Final:compile
[INFO] |  +- io.netty:netty-resolver:jar:4.1.51.Final:compile
[INFO] |  +- io.netty:netty-buffer:jar:4.1.51.Final:compile
[INFO] |  +- io.netty:netty-transport:jar:4.1.51.Final:compile
[INFO] |  \- io.netty:netty-codec:jar:4.1.51.Final:compile
[INFO] \- org.apache.commons:commons-lang3:jar:3.11:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

正如预期的那样,我们有两个直接依赖项,具有正确的 GAV 坐标,以及前面看到的传递依赖项。还有一些与依赖管理和解析相关的附加项,如依赖项排除(通过其 GA 坐标排除传递依赖项)和在依赖冲突中失败构建(在图中找到相同 GA 坐标的不同版本)。然而,最好在这里停下来,看看 Gradle 在依赖管理方面提供了什么。

使用 Gradle 进行依赖管理

正如前面提到的,Gradle 建立在从 Maven 学到的教训之上,并理解 POM 格式,使其能够提供类似于 Maven 的依赖解析能力。Gradle 还提供了额外的能力和更精细的控制。本节涉及已经讨论过的主题,因此建议您首先阅读前面的部分,以防您跳过了它,或者如果您需要回顾 Maven 提供的依赖管理的内容。让我们看看 Gradle 提供了什么。

首先,必须选择用于编写构建文件的 DSL。您的选项是 Apache Groovy DSL 或 Kotlin DSL。我们将继续使用前者,因为在实际应用中有更多的示例。从 Groovy 转换到 Kotlin 比反向转换更容易,这意味着可以直接使用 Groovy 编写的代码片段(可能需要 IDE 建议的一些更改),而反向转换需要掌握两种 DSL。下一步是选择依赖项记录的格式,有多种常见格式,如单个 GAV 坐标文字,例如:

'org.apache.commons:commons-collections4:4.4'

和将每个 GAV 坐标成员分割为自己元素的 Map 文字,例如:

group: 'org.apache.commons', name: 'commons-collections4', version: '4.4'

注意,Gradle 选择使用group而不是groupId,以及name而不是artifactId,尽管语义相同。

接下来的任务是声明特定范围(在 Maven 术语中)的依赖项,虽然 Gradle 称其为配置,其行为超出了范围的能力。假设将java-library插件应用于 Gradle 构建文件,则默认情况下可以访问以下配置:

api

定义了编译生产代码所需的依赖项,并影响编译类路径。它等同于compile范围,因此在生成 POM 时被映射为这样。

implementation

定义了编译所需但被视为实现细节的依赖项;它们比api配置中的依赖项更灵活。此配置影响编译类路径,但在生成 POM 时将映射到runtime范围。

compileOnly

定义了编译所需但不执行的依赖项。此配置影响编译类路径,但这些依赖项不与其他类路径共享。此外,在生成的 POM 中它们也不被映射。

runtimeOnly

此配置中的依赖项仅用于执行,并仅影响运行时类路径。在生成 POM 时,它们映射到runtime范围。

testImplementation

定义了编译测试代码所需的依赖项,并影响testCompile类路径。在生成 POM 时,它们映射到test范围。

testCompileOnly

定义了编译测试代码但不执行的依赖项。此配置影响testCompile类路径,但这些依赖项不与testRuntime类路径共享。此外,在生成的 POM 中它们也不被映射。

testRuntimeOnly

具有此配置的依赖项用于执行测试代码,仅影响testRuntime类路径。在生成 POM 时,它们映射到test范围。

可能会根据使用的 Gradle 版本看到其他配置,包括以下已弃用的(在 Gradle 6 中已弃用,在 Gradle 7 中移除):

compile

此配置已拆分为apiimplementation

runtime

Deprecated in favor of runtimeOnly.

testCompile

Deprecated in favor of testImplementation to align with the implementation configuration name.

testRuntime

Deprecated in favor of testRuntimeOnly to be consistent with runtimeOnly.

Similarly to Maven, the classpaths follow a hierarchy. The compile classpath can be consumed by the runtime classpath, thus every dependency set in either the api or implementation configurations is also available for execution. This classpath can also be consumed by the test compile classpath, enabling production code to be seen by test code. The runtime and test classpaths are consumed by the test runtime classpath, allowing test execution access to all dependencies defined in all the configurations so far mentioned.

As with Maven, dependencies can be resolved from repositories. Unlike Maven, in which both Maven local and Maven Central repositories are always available, in Gradle we must explicitly define the repositories from which dependencies may be consumed. Gradle lets you define repositories that follow the standard Maven layout, the Ivy layout, and even local directories with a flat layout. It also provides conventional options to configure the most commonly known repository, Maven Central. We’ll use mavenCentral for now as our only repository. Putting together everything we have seen so far, we can produce a build file like the following:

plugins {
  id 'java-library'
}

repositories {
  mavenCentral()
}

dependencies {
  api 'org.apache.commons:commons-collections4:4.4'
}

We can print the resolved dependency graph by invoking the dependencies task. However, this will print the graph for every single configuration, so we’ll print only the resolved dependency graph for the compile classpath for the sake of keeping the output short, as well as to showcase an extra setting that can be defined for this task:

$ gradle dependencies --configuration compileClasspath

> Task :dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- org.apache.commons:commons-collections4:4.4

As you can see, only a single dependency is printed out, because commons-collections does not have any direct dependencies of its own that are visible to consumers.

Let’s see what happens when we configure another dependency that brings in additional transitive dependencies, but this time using the implementation configuration that will show that both api and implementation contribute to the compile classpath. The updated build file looks like this:

plugins {
  id 'java-library'
}

repositories {
  mavenCentral()
}

dependencies {
  api 'org.apache.commons:commons-collections4:4.4'
  implementation 'commons-beanutils:commons-beanutils:1.9.4'
}

Running the dependencies task with the same configuration as before now yields the following result:

$ gradle dependencies --configuration compileClasspath

> Task :dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-collections4:4.4
\--- commons-beanutils:commons-beanutils:1.9.4
     +--- commons-logging:commons-logging:1.2
     \--- commons-collections:commons-collections:3.2.2

This tells us our consumer project has two direct dependencies contributing to the compile classpath, and that one of those dependencies brings two additional dependencies seen as transitive from our consumer’s point of view. If for some reason you’d like to skip bringing those transitive dependencies into your dependency graph, you can add an extra block of configuration on the direct dependency that declares them, like this:

plugins {
  id 'java-library'
}

repositories {
  mavenCentral()
}

dependencies {
  api 'org.apache.commons:commons-collections4:4.4'
  implementation('commons-beanutils:commons-beanutils:1.9.4') {
    transitive = false
  }
}

Running the dependencies task once more now shows only the direct dependencies and no transitive dependencies:

$ gradle dependencies --configuration compileClasspath

> Task :dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-collections4:4.4
\--- commons-beanutils:commons-beanutils:1.9.4

在继续之前,我想要讨论的最后一个方面是,与 Maven 不同,Gradle 理解语义化版本,并将在依赖解析过程中相应地选择最高版本号。我们可以通过配置两个版本的相同依赖来验证这一点,无论它们是直接的还是传递的,如下面的片段所示:

plugins {
  id 'java-library'
}

repositories {
  mavenCentral()
}

dependencies {
  api 'org.apache.commons:commons-collections4:4.4'
  implementation 'commons-collections:commons-collections:3.2.1'
  implementation 'commons-beanutils:commons-beanutils:1.9.4'
}

在这种情况下,我们声明了对commons-collections版本3.2.1的直接依赖。我们从之前的运行中知道,commons-beanutils:1.9.4引入了commons-collections3.2.2版本。鉴于3.2.2被认为比3.2.1更新,我们期望3.2.2将被解析。调用dependencies任务将产生以下结果:

$ gradle dependencies --configuration compileClasspath

> Task :dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-collections4:4.4
+--- commons-collections:commons-collections:3.2.1 -> 3.2.2
\--- commons-beanutils:commons-beanutils:1.9.4
     +--- commons-logging:commons-logging:1.2
     \--- commons-collections:commons-collections:3.2.2

(*) - dependencies omitted (listed previously)

如预期的那样,选择了版本 3.2.2。输出甚至包含一个指示器,告诉我们何时将依赖版本设置为与请求的版本不同的值。版本也可以配置为固定的,而不考虑它们的语义化版本方案,甚至可以设置为较低的值。这是因为 Gradle 提供了更灵活的依赖解析策略选项。然而,这属于高级主题的范畴,与依赖锁定、严格与建议的版本、依赖重定位、平台和强制平台(Gradle 与 BOM 工件交互的方式)以及其他内容相关。

容器的依赖管理基础

在软件开发周期的进一步过程中,您可能会遇到一个步骤,需要将您的 Maven 和 Gradle 项目打包成容器镜像。就像项目中的其他依赖项一样,您的容器镜像也必须适当地管理,并与其他所需的工件协调一致。容器在第三章中有详细讨论,但本节主要关注容器镜像管理的一些微妙之处。与自动化构建工具 Maven 和 Gradle 中的依赖管理一样,可能还会遇到更多问题。

正如您在第三章中学到的那样,容器是使用容器镜像启动的,这些镜像通常使用 Dockerfile 定义。Dockerfile 的作用是定义镜像的每一层,这些层将用于构建运行的容器。从这个定义中,您将获得基础分发层、代码库和框架,以及运行软件所需的任何其他文件或工件。在这里,您还将定义任何必要的配置(例如开放端口、数据库凭据和消息服务器的引用),以及任何需要的用户和权限。

在本节中首先讨论的是 Dockerfile 的第 1 行,或者在多阶段构建的 Dockerfile 中,以 FROM 指令开头的行。与 Maven POM 类似,一个镜像可以从一个镜像构建,该父镜像可能来自另一个父镜像,一直到基础镜像,即最初的祖先。在这里,我们必须特别注意我们的镜像组合方式。

正如您可能还记得的来自 第三章 的内容,Docker 镜像的版本控制旨在在软件开发阶段提供灵活性,并在需要时使用最新的维护更新来增强信心。大多数情况下,这是通过引用特殊的镜像版本 latest(如果未指定版本,则为默认版本)来实现的,请求该版本将检索到被假定为正在开发中的镜像的最新版本。虽然不是完美的比较,但这最像使用 Java 依赖的 快照 版本。

在开发过程中一切正常,但是当在生产环境中出现新 bug 需要进行故障排除时,生产镜像工件中的这种版本控制可能会增加故障排除的难度。一旦一个镜像已经使用默认的 latest 版本构建完成,重现构建可能会很困难甚至不可能。就像您希望在生产发布中避免使用快照依赖一样,我建议锁定您的镜像版本并避免使用默认的 latest 版本,以限制不必要的变动部分。

仅仅锁定您的镜像版本并不足以确保安全。构建容器时,请只使用可信任的基础镜像。这个建议看起来可能很明显,但第三方注册表通常没有对其存储的镜像制定治理政策。了解哪些镜像可以在 Docker 主机上使用,了解它们的来源并审查其内容非常重要。您还应该为镜像验证启用 Docker 内容信任(DCT),并且只安装经过验证的软件包到镜像中。

使用尽可能少的基础镜像,不包含可能导致攻击面变大的不必要软件包。在您的容器中减少组件数量可以减少可用的攻击向量,而且最小化的镜像还能提升性能,因为磁盘上的字节数更少,拷贝镜像时网络流量也较少。BusyBox 和 Alpine 是构建最小基础镜像的两个选择。在您验证的基础镜像上构建任何额外的层时,一定要特别注意明确指定所有软件包的版本或者您拉取到镜像中的任何其他工件。

发布工件

到目前为止,我已经讨论了如何解析构件和依赖项,通常从称为仓库的位置获取,但仓库究竟是什么,以及如何向其发布构件?从最基本的角度来看,构件仓库是文件存储,用于跟踪构件。仓库收集每个发布构件的元数据,并利用该元数据提供额外功能,如搜索、归档、访问控制列表(ACL)等。工具可以利用这些元数据提供其他高级功能,如漏洞扫描、指标、分类等。

我们可以使用两种类型的仓库来管理 Maven 依赖项,这些依赖项可以通过 GAV 坐标解析:本地和远程。Maven 使用本地文件系统中的可配置目录来跟踪已解析的依赖项。这些依赖项可以从远程仓库下载,也可以直接由 Maven 工具放置在那里。这个目录通常被称为Maven 本地,其默认位置为当前用户的主目录下的.m2/repository。这个位置是可配置的。另一端是远程仓库,由仓库软件如 Sonatype Nexus Repository、JFrog Artifactory 等处理。最著名的远程仓库是 Maven 中央仓库,用于解析构件的标准仓库。

现在让我们讨论如何将构件发布到本地和远程仓库。

发布到 Maven 本地

Maven 提供了三种方式将构件发布到 Maven 本地仓库。其中两种是显式的,一种是隐式的。我们已经涵盖了隐式的方式——每当 Maven 从远程仓库解析依赖项时都会发生这种情况,结果是构件的副本及其元数据(相关的pom.xml)将被放置在 Maven 本地仓库中。这种行为默认发生,因为 Maven 使用 Maven 本地作为缓存,以避免再次通过网络请求构件。

发布构件到 Maven 本地的另外两种方式是显式地“安装”文件到仓库中。Maven 有一组生命周期阶段,其中install是其中之一。这个阶段对于 Java 开发者来说非常熟悉,因为它用于(有时滥用于)编译、测试、打包和将构件安装到 Maven 本地。Maven 生命周期阶段遵循预定的顺序:

Available lifecycle phases are: validate, initialize, generate-sources,
process-sources, generate-resources, process-resources, compile,
process-classes, generate-test-sources, process-test-sources,
generate-test-resources, process-test-resources, test-compile,
process-test-classes, test, prepare-package, package, pre-integration-test,
integration-test, post-integration-test, verify, install, deploy,
pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy.

阶段按顺序执行直至找到终端阶段。因此,通常调用install将导致几乎完整的构建(除了deploysite)。我提到install被滥用了,因为大多数情况下只需调用verify即可,后者位于install之前的阶段,它会强制编译、测试、打包和集成测试,但如果不需要,则不会将构件污染到 Maven 本地。这绝不是建议始终放弃install而选择verify,因为有时测试需要从 Maven 本地解析构件。关键是要意识到每个阶段的输入/输出及其后果。

回到安装。将构件安装到 Maven 本地的第一种方法是简单地调用install阶段,就像这样:

$ mvn install

这将会复制所有的pom.xml文件并按照约定重命名为*artifactId*-*version*.pom,同时将每个附加的构件放入 Maven 本地。附加的构件通常是构建生成的二进制 JAR 文件,也可以包括其他 JAR 文件,比如-sources-javadoc JAR 文件。第二种安装构件的方法是手动调用install:install-file目标并提供一组参数。假设你有一个 JAR 文件(artifact.jar)和一个匹配的 POM 文件(artifact.pom),安装它们可以这样做:

$ mvn install:install-file -Dfile=artifact.jar -DpomFile=artifact.pom

Maven 将读取 POM 文件中的元数据,并根据解析的 GAV 坐标将文件放置在对应的位置。可以覆盖 GAV 坐标,动态生成 POM,甚至可以省略显式的 POM 文件(如果 JAR 文件内部包含一份副本的话,这通常是 Maven 构建的情况;而 Gradle 默认情况下不包含 POM 文件)。

Gradle 有一种方式可以将构件发布到 Maven 本地,那就是应用maven-publish插件。该插件为项目添加了新的功能,比如publishToMavenLocal任务;正如其名称所示,它将构建的构件和生成的 POM 文件复制到 Maven 本地。与 Maven 不同,Gradle 不将 Maven 本地作为缓存,因为它有自己的缓存基础设施。因此,当 Gradle 解析依赖关系时,文件会被放置在另一个位置,通常位于当前用户的主目录下的.gradle/caches/modules-2/files-2.1

这就涵盖了发布到 Maven 本地的内容。现在让我们看一下远程仓库。

发布到 Maven 中央仓库

Maven Central 仓库是支撑 Java 项目日常构建的支柱。运行 Maven Central 的软件是 Sonatype Nexus Repository,这是由 Sonatype 提供的一个存储库。由于在 Java 生态系统中的重要角色,Maven Central 已经制定了一系列在发布构件时必须遵循的规则;Sonatype 已经发布了一份指南,详细解释了先决条件和规则。我强烈建议在阅读本书之后阅读该指南,以确保要求是否已自出版之日起更新。简而言之,您必须确保以下几点:

  • 您必须证明拥有目标 groupId 的反向域的所有权。如果您的 groupIdcom.acme.*,则您必须拥有 acme.com

  • 发布二进制 JAR 时,您还必须提供 -sourcesjavadoc JAR 文件,以及匹配的 POM 文件,即至少四个单独的文件。

  • 发布 POM 类型的构件时,只需要提供 POM 文件。

  • 所有构件的 PGP 签名文件也必须提交。用于签名的 PGP 密钥必须发布在公钥服务器上,以便 Maven Central 验证签名。

  • 可能在最初会让大多数人困惑的一点是:POM 文件必须符合最少的一组元素,如 <license><developers><scm> 等。这些元素在指南中有详细描述;如果省略任何一个元素,将导致在发布过程中失败,结果是构件将完全不会发布。

我们可以通过使用 PomChecker 项目来避免最后一个问题,或者至少在开发过程中更早地检测到它。PomChecker 可以以多种方式调用:作为独立的 CLI 工具、作为 Maven 插件或作为 Gradle 插件。这种灵活性使其非常适合在本地环境或 CI/CD 流水线中验证 POM。使用 CLI 验证 pom.xml 文件可以这样做:

$ pomchecker check-maven-central --pom-file=pom.xml

如果您的项目使用 Maven 构建,可以在不需要在 POM 中配置的情况下调用 PomChecker 插件,像这样:

$ mvn org.kordamp.maven:pomchecker-maven-plugin:check-maven-central

那个命令将解析 pomchecker-maven-plugin 的最新版本,并立即在现有项目上执行其 check-maven-central 目标。使用 Gradle 时,您必须显式配置 org.kordamp.gradle.pomchecker 插件,因为与 Maven 不同,Gradle 不提供调用内联插件的选项。

必须应用于构建的最后一部分配置是发布机制本身。如果您使用 Maven 构建,可以通过将以下内容添加到 pom.xml 中来完成:

<distributionManagement>
  <repository>
    <id>ossrh</id>
    <url>https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/</url>
  </repository>
  <snapshotRepository>
    <id>ossrh</id>
    <url>https://s01.oss.sonatype.org/content/repositories/snapshots</url>
  </snapshotRepository>
</distributionManagement>

<build>
  <plugins>
    <plugin>
     <groupId>org.apache.maven.plugins</groupId>
     <artifactId>maven-javadoc-plugin</artifactId>
     <version>3.2.0</version>
      cutions>
       cution>
       <id>attach-javadocs</id>
       <goals>
         <goal>jar</goal>
       </goals>
       <configuration>
         <attach>true</attach>
       </configuration>
      </execution>
     </executions>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-source-plugin</artifactId>
      <version>3.2.1</version>
      <executions>
        <execution>
          <id>attach-sources</id>
          <goals>
            <goal>jar</goal>
          </goals>
          <configuration>
            <attach>true</attach>
          </configuration>
        </execution>
      </executions>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-gpg-plugin</artifactId>
      <version>1.6</version>
      <executions>
        <execution>
          <goals>
            <goal>sign</goal>
          </goals>
          <phase>verify</phase>
          <configuration>
            <gpgArguments>
              <arg>--pinentry-mode</arg>
              <arg>loopback</arg>
            </gpgArguments>
          </configuration>
        </execution>
      </executions>
    </plugin>
    <plugin>
      <groupId>org.sonatype.plugins</groupId>
      <artifactId>nexus-staging-maven-plugin</artifactId>
      <version>1.6.8</version>
      <extensions>true</extensions>
      <configuration>
        <serverId>central</serverId>
        <nexusUrl>https://s01.oss.sonatype.org</nexusUrl>
        <autoReleaseAfterClose>true</autoReleaseAfterClose>
      </configuration>
    </plugin>
  </plugins>
</build>

注意,该配置生成 -sources-javadoc JAR 文件,用 PGP 签署所有附加的构件,并将所有构件上传到给定的 URL,这恰好是 Maven Central 支持的 URL 之一。<serverId> 元素标识了您必须在 settings.xml 文件中设置的凭据(否则上传将失败),或者您可以将凭据定义为命令行参数。

您可能希望将插件配置放在 <profile> 部分中,因为配置的插件提供的行为仅在发布时需要;在执行主要生命周期阶段序列期间生成额外的 JAR 没有必要。这样,您的构建将只执行最小的步骤,从而更快地执行。

另一方面,如果您使用 Gradle 进行发布,您必须配置一个能够发布到 Sonatype Nexus 仓库的插件,最新的此类插件是 io.github.gradle-nexus.publish-plugin。有多种配置 Gradle 完成工作的方式。惯用法变化比您必须在 Maven 中配置的更快。我建议您查阅官方 Gradle 指南,了解在这种情况下需要做什么。

发布到 Sonatype Nexus 仓库

您可能还记得 Maven Central 是使用 Sonatype Nexus 仓库运行的,因此不应对前面部分中显示的配置感到意外,因此您只需更改发布 URL 以匹配 Nexus 仓库。但是有一个警告:Maven Central 经常施加的严格验证规则通常不适用于自定义 Nexus 安装。也就是说,Nexus 有选项配置管理工件发布的规则。例如,这些规则在您组织内运行的 Nexus 实例中可能会放宽,或者在其他领域可能会更严格。最好查阅您组织的文档,了解有关向其自己的 Nexus 实例发布工件的信息。

有一件事很明确:如果您将工件发布到您组织的 Nexus 仓库,而这些工件最终必须发布到 Maven Central,从一开始遵循 Maven Central 的规则是个好主意——只要这些规则不与您组织的规则冲突。

发布到 JFrog Artifactory

JFrog Artifactory 是另一个流行的工件管理选项。它提供与 Sonatype Nexus 仓库类似的功能,同时添加了其他功能,包括与 JFrog 平台的其他产品集成,例如 Xray 和 Pipelines。我特别喜欢的一个功能是,发布之前不需要在源处对工件进行签名。Artifactory 可以使用您的 PGP 密钥或站点范围的 PGP 密钥执行签名。这使您无需在本地和 CI 环境上设置密钥,也减少了发布过程中的字节数传输。与之前一样,我们在 Maven Central 中看到的发布配置也适用于 Artifactory,只需更改发布 URL 以匹配 Artifactory 实例。

与 Nexus 类似,Artifactory 允许您将工件同步到 Maven Central,并且您必须再次遵循发布到 Maven Central 的规则。因此,从一开始就发布格式良好的 POM、源代码和 Javadoc JAR 是个好主意。

总结

在本章中,我们涵盖了许多概念,但主要的要点是,单靠工件本身是不足以在构建软件或领先竞争中取得最佳结果的。工件通常具有与之关联的元数据,例如它们的构建时间、依赖版本和环境。这些元数据可以用于追溯特定工件的起源,帮助将其转变为可重现的工件,或者启用生成软件材料清单(SBOM),这又是另一种元数据格式。此外,通过这些元数据,还可以极大地增强构建管道健康和稳定性方面的可观察性、监控以及其他关注点。

关于依赖性,我们看到了与流行的 Java 构建工具(如 Apache Maven 和 Gradle)一起进行依赖性解析的基础知识。当然,这一章节讨论的深度还远不够;这些主题确实可以单独填满一本书。请务必关注这一领域的改进,这些改进可能会由后续版本的这些构建工具提供。

最后,我们讨论了如何将 Java 工件发布到流行的 Maven 中央仓库,这要求遵循一组特定的准则以确保成功发布。Maven 中央仓库是官方仓库,但并非唯一选择。Sonatype 提供 Sonatype Nexus 仓库,而 JFrog 提供 JFrog Artifactory,这两者也是管理内部位置(例如您自己的组织或公司)的工件的流行选择。

第七章:保护您的二进制文件

Sven Ruppert

Stephen Chin

数据是信息时代的污染问题,保护隐私是环境挑战。

Bruce Schneier,《数据与歌利亚》

软件安全是全面的 DevOps 部署中的关键部分。过去一年中揭示的新漏洞引起了对软件安全薄弱后果的关注,并促使了新的政府安全法规的制定。满足这些新法规的影响跨越整个软件生命周期,从开发到生产。因此,DevSecOps 是每个软件开发人员和 DevOps 专业人员都需要理解的内容。

在本章中,您将学习如何评估产品和组织风险以寻找安全漏洞。我们还将介绍静态和动态的安全测试技术,以及用于风险评估的评分技术。

无论您的角色如何,您都将更好地准备好帮助保护组织的软件交付生命周期。但首先让我们深入了解如果不专注于安全性会发生什么,并采取措施来保护您的软件供应链。

供应链安全受损

2020 年 12 月初,FireEye 注意到自己成为了网络攻击的受害者,这非常引人注目,因为该公司本身专门从事检测和防范网络攻击。内部分析显示,攻击者成功窃取了 FireEye 的内部工具,这些工具用于检查客户的 IT 基础设施中的弱点。这些高度专业化的工具箱被优化用于入侵网络和 IT 系统,而在黑客手中则构成巨大风险。直到后来才发现这一被称为SolarWinds 攻击的巨大网络攻击与此次入侵有关联。(FireEye 后来通过合并成为 Trellix。)

SolarWinds 是一家位于美国的公司,专门管理复杂的 IT 网络结构。为此,该公司开发了 Orion 平台。公司本身有超过 30 万活跃客户在内部使用这款软件。管理网络组件的软件必须在 IT 系统内部配备充分的管理权限,以执行其任务,这也是黑客在策略中利用的关键点之一。花费了一些时间才意识到 FireEye 入侵与后来的大规模网络攻击之间的关联,因为效果链并不像以前的漏洞入侵那样直接。

由于 SolarWinds 漏洞被利用和漏洞被发现之间的长时间间隔,许多公司和政府组织最终受到了此次攻击的影响。在几周的时间内发生了 20,000 次成功攻击。由于攻击模式相似,安全研究人员能够确认这些攻击是相关的。其中一个共同特征是,所有遭受攻击的组织都使用 SolarWinds 软件管理其网络基础设施。

攻击者利用 FireEye 工具侵入 SolarWinds 网络。他们攻击了 CI 管道,负责为 Orion 软件平台创建二进制文件。软件交付生产线被修改,以便每次运行新版本时,生成的二进制文件都被黑客篡改并包含后门。Orion 平台被用作特洛伊木马,将被篡改的二进制文件传递给成千上万的网络。任何检查指纹的接收者都会看到一个有效的二进制文件,因为它由他们信任的供应商 SolarWinds 签名。而这种信任关系正是这次网络攻击利用的漏洞。

这次攻击的执行方式如下所述。公司 SolarWinds 更新了其软件,并通过自动更新过程向所有 30 万客户提供了这些二进制文件。几乎 20,000 客户在短时间内安装了此更新。受损软件在激活后约两周后开始在感染系统中传播。更糟糕的是,随着时间的推移,进一步的恶意软件动态加载,使得修复受损系统变得不可能而需要完全重建。

稍微退后一步,我们来区分 SolarWinds 公司的视角和受影响客户的视角。谁有责任减轻这种攻击,如果你自己受到影响,程序是什么样的?你可以使用哪些工具来识别和解决这个漏洞?谁可以采取行动来防止此类攻击,以及在漏洞时间轴的哪个时刻?

从供应商的视角看安全问题

首先,让我们从软件制造商的视角开始(在这个例子中是 SolarWinds),该制造商将软件分发给其客户。当进行供应链攻击时,你必须做好准备,因为你只是病毒软件的载体。与传统攻击相比,损害被放大,因为你让黑客在成千上万的客户中打开了一个安全漏洞。预防这种情况需要在软件开发和分发过程中采取严格的方法。

保护软件交付管道中使用的工具是最重要的方面之一,因为它们可以访问内部系统,并且可能恶意修改软件管道中的二进制文件。然而,这是一项挑战,因为软件交付生命周期中直接和间接使用的工具数量不断增加,扩大了攻击面。

从客户角度看安全性

作为 SolarWinds 等供应商的客户,必须考虑价值链中的所有元素,包括软件开发人员日常使用的所有工具。您还必须检查从 CI/CD 系统生成的二进制文件,以查看是否存在修改或漏洞注入的可能性。必须保持对所有使用组件的安全和可追溯的清单的概述。最终,只有将自己的产品分解为其组成部分,并对每个元素进行安全审查,才能有所帮助。

作为消费者,如何保护自己?价值链中的所有元素都必须接受严格审查的方法在这里同样适用。正如 SolarWinds 案例所示,单独的指纹和独占使用机密源并不能提供最佳的保护。使用的组件必须接受更深入的安全检查。

全面影响图

全面影响图代表受已知漏洞影响的应用程序所有领域。分析全面影响图需要工具来检查已知弱点。只有当这些工具能够识别和表示技术边界上的相互关系时,它们才能充分发挥作用。如果不考虑全面影响图,很容易只关注某一项技术,这可能迅速导致危险的伪安全。

举例来说,假设我们正在使用 Maven 构建一个 JAR 文件;这个 JAR 文件在 WAR 文件中使用,并部署在 Servlet 容器中。此外,将这个 JAR 文件打包进 Docker 镜像以部署到生产环境也是最佳实践。生产配置也存储在 Helm 图表中,用于组织 Docker 部署。假设我们可以识别出这个被入侵的 JAR 文件,它包含在由 Helm 图表部署的 Docker 镜像中,并且这个镜像是活跃生产环境的一部分。从 Helm 图表追溯到封装的 JAR 文件的漏洞需要了解全面影响图。

SolarWinds 黑客事件表明,为了发现供应链中的漏洞,需要分析全面影响图。如果在一个二进制文件中发现漏洞,这个漏洞的重要性取决于文件的使用方式。您需要知道这个文件在哪里使用,以及在操作环境中使用时可能带来的潜在风险。如果您没有在任何地方使用这个二进制文件,漏洞将不会造成任何损害;但是,如果在公司的关键区域中使用,就会产生重大风险。

假设我们只关注扫描 Docker 镜像。我们将获取 Docker 镜像中包含的漏洞信息,并可以缓解 Docker 镜像中的漏洞。但我们缺少有关此受感染二进制文件在其他所有使用中的信息。我们需要了解此二进制文件在所有不同层和技术中的使用情况。仅关注 Docker 镜像内部的使用可能会在二进制文件直接用于环境中的其他部分时导致开放的安全漏洞。

在 “通用漏洞评分系统” 中,我们将向您展示如何使用环境指标精确评估上下文,并利用这些信息进行更为详尽的风险评估。

保护您的 DevOps 基础设施

现在你了解了安全漏洞的影响,是时候看看我们可以利用的措施来改善完整软件开发生命周期的安全性了。首先,让我们对 DevOps 环境中使用的程序和角色进行一些介绍。

DevSecOps 的兴起

让我们简要地回顾一下开发和运维如何合并成为 DevOps,因为这在引入安全方面起着核心作用。DevOps 从最基本的认识开始,即开发人员和运维人员必须更紧密地合作以提高生产力。DevOps 的基本阶段直接映射到构建和交付软件到生产环境的过程中。

在 DevOps 出现之前,责任分工存在明显的差异,发布构建被用作各组之间的交接点。DevOps 改变了角色分工,使开发人员需要理解如何进行生产部署的复杂性,反之亦然。这种变化需要更先进的自动化工具和仓库,以及共享的知识和流程。

但是安全呢?安全不应该也永远不应该成为软件开发中的一个显式步骤。安全是一个贯穿整个生产到运营过程的横切问题。这反过来又带来了这样一个认识,即没有专门的安全官员可以独自完成这项工作。整个团队都要负责安全问题,就像他们对质量问题负责一样。

这一认识的结果是创造了 DevSecOps 这一术语。然而,这里有些微妙之处不容忽视。在生产链中,并非每个人都能同样出色地完成所有事情。每个人都有自己的特点,在某些领域更有效率。因此,即使在 DevSecOps 组织中,一些团队成员更关注开发领域,而其他人则在运维领域有自己的优势。

SRE 在安全中的角色

开发和运维专业化的一个例外是站点可靠性工程师(SRE)角色。该术语最初来自 Google,用于描述团队中处理服务可靠性的人员。SRE 工作的指标称为故障预算。假定软件会出现故障,并且这正是导致停机的原因。一个服务有一个特定的故障预算或停机预算。SRE 的目标是通过减少由于错误、损坏或网络攻击导致的停机时间来保持服务的正常运行时间在定义的预算范围内。为了实现这些目标,SRE 可以选择在升级期间投入停机时间,这可以用来为系统部署质量和安全改进。

因此,SRE 是一个团队成员,其角色是确保系统的稳健性和引入新功能之间的平衡。为此,SRE 将最多 50%的工作时间用于专注于运维任务和责任。应该利用这段时间自动化系统并提高质量和安全性。其余的 SRE 时间花在作为开发人员工作并参与实现新功能。现在我们来到一个激动人心的问题:SRE 是否也负责安全?

SRE 的这种角色可以处于 DevSecOps 结构的中间,因为工作时间和技能几乎平均分配在开发和运维领域,所以这两个概念可以在同一个组织内共存。

SRE(Site Reliability Engineer)通常是具有多年开发经验的开发人员,现在专门从事运维领域,或者是具有多年专业经验的管理员,现在有意进入软件开发领域。考虑到这一点,SRE 的位置是融合开发和运维策略以处理交叉问题的理想场所。

再次考虑 SolarWinds 的例子,一个问题是在价值链中谁拥有最大的影响力来针对漏洞采取行动。为此,我们将看看开发和运维这两个领域以及其中可用的选项。

静态和动态安全分析

安全分析存在两种主要类型:静态应用程序安全测试和动态应用程序安全测试。让我们看看这两个术语的含义以及这两种方法的区别。

静态应用程序安全测试

静态应用程序安全测试(SAST)在特定时间点分析应用程序。它是静态的。重点是识别和定位已知的漏洞。

SAST 是一种所谓的清晰测试过程,在这个过程中,您会查看系统内部来进行分析。为此,您需要访问要测试的应用程序的源代码。但是,不需要可操作的运行时环境。不需要执行应用程序进行此过程,这就是为什么也使用术语静态。使用 SAST 可以识别三种类型的安全威胁:

  • 源代码是否在功能区域存在漏洞,允许例如“被污染的代码”被走私? 这些行可能会后续渗透恶意软件。

  • 是否有源代码行允许您连接到文件或某些对象类? 重点还在于检测和防止恶意软件的引入。

  • 应用程序级别是否存在漏洞,使您可以在不被注意的情况下与其他程序进行交互?

但是,应注意源代码分析本身是一项复杂的工作。 静态安全分析领域还包括能够确定和评估所有包含的直接和间接依赖关系的工具。

通常情况下,各种 SAST 工具应定期检查源代码。 SAST 源代码扫描程序还必须根据您的组织需求进行调整,初始实施以调整扫描程序以适应您的各自领域。 开放式 Web 应用程序安全项目(OWASP)基金会提供帮助; 它不仅列出了典型的安全漏洞,还推荐了合适的 SAST 工具。

SAST 方法的优势

与软件交付过程中较晚阶段的安全测试相比,静态安全分析方法提供以下优势:

  • 因为漏洞检测测试是在开发阶段进行的,所以相比仅在运行时进行检测,消除弱点可以更加经济高效地进行。 通过访问源代码,您还可以了解这种漏洞的起因,并防止将来再次发生。 这些发现无法通过不透明测试过程获得。

  • 可以进行部分分析,这意味着甚至可以分析非可执行源文本。 开发人员自己可以进行静态安全分析,这显著减少了安全专家的需求。

在源代码级别进行系统的 100%分析也是可能的,这是动态方法无法保证的。 不透明测试系统只能执行渗透测试,这是一种间接分析。

SAST 方法的缺点

由于您从源代码开始,因此 SAST 似乎具有成为最全面的安全扫描方法的潜力。 但是,在实践中存在根本性问题:

  • 编程工作经常受到影响,这反过来体现为特定于域的错误。 开发人员过于关注安全测试及相关缺陷修复。

  • 工具可能存在问题。 特别是如果扫描程序未适应整个技术堆栈。 大多数系统如今都是多语言的。 要获得已知漏洞的完整列表,您需要支持所有直接或间接技术的工具。

  • SAST 通常完全取代了后续的安全测试。 但是,所有直接与应用程序运行相关的问题仍然未被检测到。

  • 仅关注源代码是不够的。静态扫描必须分析二进制文件,并在可能的情况下额外分析源代码。

在 “How Much Is Enough?” 中,我们将展示为什么应首先关注扫描二进制文件。

动态应用安全测试

动态应用安全测试(DAST)是对运行中应用程序(通常是运行中的 Web 应用程序)进行的安全分析。执行各种攻击场景以尽可能识别应用程序中的弱点。术语 动态 表示必须提供运行中的应用程序来执行测试。测试系统行为必须与生产环境相同至关重要。即使是轻微的变化也可能导致严重差异,包括不同的配置或上游负载均衡器和防火墙。

DAST 是一种不透明的测试过程,只从外部查看应用程序。所使用的技术在安全检查类型中不起作用,因为只是通用地和外部地访问应用程序。这意味着从源代码可以获得的所有信息对这种类型的测试来说是不可见的。因此,测试人员可以使用通用工具测试典型问题。OWASP 项目提供了合理的帮助,以选择适合自己项目的扫描器。这评估了各个工具在特定应用背景下的性能。

DAST 的优点

DAST 过程具有以下优点:

  • 安全分析以技术中立的方式进行。

  • 扫描器在运行环境中找到了错误。

  • 误报率低。

  • 工具可以在基本功能应用程序中找到错误的配置。例如,您可以识别其他扫描器无法识别的性能问题。

  • DAST 程序可以在开发的所有阶段以及后续运行中使用。

DAST 扫描器基于实际攻击者用于其恶意软件的相同概念。因此,它们能够可靠地反馈出弱点。测试一直显示,大多数 DAST 工具可以识别 OWASP 基金会列出的 十大常见威胁

DAST 的缺点

使用 DAST 工具存在一些缺点:

  • 扫描器被设计为对功能性 Web 应用程序进行特定攻击,并且通常只能由具备必要产品知识的安全专家进行调整。因此,它们对个体化扩展提供的空间有限。

  • DAST 工具速度慢,可能需要几天来完成分析。

  • DAST 工具在开发周期的后期发现了一些安全漏洞,这些漏洞本可以通过 SAST 早期发现。因此,修复相关问题的成本比预期的要高。

  • DAST 扫描基于已知漏洞。扫描新类型攻击需要相对较长的时间。因此,通常无法修改现有工具。如果可以修改,需要深入了解攻击向量本身以及如何在 DAST 工具内部实现它。

比较 SAST 和 DAST

表格 7-1 总结了静态应用安全测试(SAST)和动态应用安全测试(DAST)之间的差异。

表格 7-1. SAST 对比 DAST

SAST DAST
透明的安全测试• 测试人员可以访问底层框架、设计和实现。• 应用从内到外进行测试。• 这种测试代表了开发者的方法。 不透明的安全测试• 测试人员对应用程序构建的技术和框架一无所知。• 应用从外到内进行测试。• 这种测试代表了黑客的方法。
需要源代码• SAST 不需要已部署的应用程序。• 它分析源代码或二进制文件而不执行应用程序。 需要运行的应用程序• DAST 不需要源代码或二进制文件。• 它通过执行应用程序进行分析。
在 SDLC 早期发现漏洞• 一旦代码被视为功能完成,即可执行扫描。 在 SDLC 末尾发现漏洞• 漏洞可能在开发周期结束后被发现。
更少昂贵的漏洞修复• 由于漏洞在软件开发生命周期(SDLC)的早期被发现,修复起来更加容易和快速。• 发现的漏洞通常可以在代码进入 QA 周期之前修复。 更昂贵的漏洞修复• 由于漏洞在 SDLC 末尾被发现,修复往往推迟到下一个开发周期。• 关键漏洞可能需要紧急发布来修复。
无法发现运行时和环境问题• 由于工具扫描静态代码,无法发现运行时漏洞。 能够发现运行时和环境问题• 由于工具对运行中的应用进行动态分析,能够发现运行时漏洞。
通常支持各种软件• 例如,Web 应用程序、Web 服务和厚客户端。 通常只扫描 Web 应用程序和 Web 服务• DAST 对其他类型的软件无用。

如果你比较这两种安全测试的优缺点,你会发现它们并不是互斥的。相反,这些方法能够完美地互补。SAST 可用于识别已知漏洞。DAST 则可用于发现尚未知晓的漏洞。这主要是在新攻击基于常见漏洞模式时的情况。如果在生产系统上进行这些测试,你还能获取有关整体系统的知识。但是,一旦在测试系统上运行 DAST,你将再次失去这些功能。

交互式应用安全测试

交互式应用安全测试(IAST)使用软件工具评估应用程序性能并识别漏洞。IAST 采用一种“代理式”方法;代理和传感器运行以持续分析应用程序功能,在自动化测试、手动测试或两者混合测试期间。

在集成开发环境(IDE)、持续集成(CI)或质量保证(QA)环境中,或者在生产过程中,过程和反馈是实时发生的。传感器可以访问以下内容:

  • 所有源代码

  • 数据和控制流

  • 系统配置数据

  • Web 组件

  • 后端连接数据

IAST、SAST 和 DAST 之间的主要区别在于 IAST 在应用程序内部运行。访问所有静态组件以及运行时信息使得可以获得全面的图像。它是静态和动态分析的结合体。但是,动态分析部分并不是纯粹的不透明测试,因为它是在 DAST 实施的。

IAST 有助于更早地识别潜在问题,因此减少了消除潜在成本和延迟的成本。这归功于“向左转移”的方法,意味着它在项目生命周期的早期阶段执行。类似于 SAST,IAST 分析提供了完整的数据丰富的代码行,以便安全团队可以立即查找特定的错误。由于工具可以访问丰富的信息,因此可以精确定位漏洞的来源。与其他动态软件测试不同,IAST 可以轻松集成到 CI/CD 流水线中。评估是在生产环境中实时进行的。

另一方面,IAST 工具可能会减慢应用程序的操作。这是因为代理会修改字节码本身。这导致整个系统性能下降。修改本身也可能导致生产环境中的问题。使用代理代表了潜在的危险源,因为这些代理也可能会像 SolarWinds 黑客事件中那样受到威胁。

运行时应用自我保护(Runtime Application Self-Protection,RASP)

运行时应用自我保护(RASP)是从内部保护应用程序的方法。检查是在运行时进行的,通常包括在执行时查找可疑命令。

使用 RASP 方法,您可以实时检查生产环境中的整个应用程序上下文。这里会检查所有处理的命令,以寻找可能的攻击模式。因此,该过程旨在识别现有的安全漏洞和攻击模式,以及尚未知晓的攻击模式。在这里,明显涉及到 AI 和机器学习(ML)技术的使用。

RASP 工具通常可以在两种操作模式下使用。第一种操作模式(监控)仅限于观察和报告可能的攻击。第二种操作模式(保护)则包括在实时和直接在生产环境中实施防御措施。RASP 旨在弥补应用程序安全测试和网络边界控制留下的空白。SAST 和 DAST 对于实时数据和事件流的可见性不足,无法阻止漏洞在验证过程中被忽视或阻止被忽略的新威胁。

RASP 类似于 IAST。主要区别在于,IAST 专注于识别应用程序中的漏洞,而 RASP 专注于保护免受利用这些漏洞或其他攻击向量的网络安全攻击。

RASP 技术具有以下优势:

  • RASP 在应用程序启动后(通常在生产中)提供了 SAST 和 DAST 之外的额外保护层。

  • RASP 可以在更快的开发周期中轻松应用。

  • RASP 检查并识别意外的条目。

  • RASP 使您能够通过提供全面的分析和可能漏洞的信息,快速应对攻击。

然而,由于 RASP 工具位于应用服务器上,它们可能会对应用程序的性能产生不利影响。此外,RASP 技术可能不符合法规或内部指导方针,因为它允许安装其他软件或自动阻止服务。使用这种技术还可能产生一种虚假的安全感,并且不能替代应用程序安全测试,因为它无法提供全面的保护。最后,应用程序在排除漏洞之前也必须切换至脱机状态。

虽然 RASP 和 IAST 具有相似的方法和用途,但 RASP 并不执行广泛的扫描,而是作为应用程序的一部分运行,以检查流量和活动。两者在攻击发生时立即报告;IAST 在测试时进行,而 RASP 则在生产环境的运行时进行。

SAST、DAST、IAST 和 RASP 总结

所有方法都提供了多种选择,以防范已知和未知的安全漏洞。在选择方法时,调和个人需求和公司需求至关重要。

使用 RASP,应用程序可以在运行时保护自身免受攻击。对活动和传输到应用程序的数据的永久监控使得可以基于运行时环境进行分析。在这里,您可以选择纯监控或警报以及主动自我保护。然而,RASP 方法将软件组件添加到运行时环境中以独立操纵系统。这会对性能产生影响。采用这种方法,RASP 专注于检测和防御当前的网络攻击。因此,它分析数据和用户行为以识别可疑活动。

IAST 方法结合了 SAST 和 DAST 方法,并已在 SDLC 中使用——即在开发过程中使用。这意味着与 RASP 工具相比,IAST 工具已经更进一步“向左”。与 RASP 工具的另一个不同之处在于,IAST 包括静态、动态和手动测试。在这里也清楚地表明,IAST 更多地处于开发阶段。动态、静态和手动测试的结合承诺提供全面的安全解决方案。然而,我们不应低估手动和动态安全测试在这一点上的复杂性。

DAST 方法侧重于黑客如何接近系统。整个系统被视为不透明,攻击在不知道所使用的技术的情况下进行。关键在于加固生产系统,以抵御最常见的漏洞。然而,我们在这一点上不能忽视的是,这种技术只能在生产周期的最后阶段使用。

如果您可以访问所有系统组件,则可以有效地使用 SAST 方法来对抗已知的安全漏洞和许可问题。这种程序是可以直接控制整个技术栈的唯一保证。SAST 方法的重点是静态语义,反过来完全盲目于动态上下文中的安全漏洞。其巨大优势在于,该方法可以与源代码的第一行一起使用。

依据我的经验,如果您开始进行 DevSecOps 或 IT 安全工作,SAST 方法是最有意义的。在这里,可以用最小的努力消除最大的潜在威胁。这也是一个可以在生产线的所有步骤中使用的过程。只有在系统的所有组件都受到已知安全漏洞的保护之后,接下来的方法才显示出其最高潜力。引入 SAST 后,我会使用 IAST 方法,最后使用 RASP 方法。这也确保了相应的团队可以与任务一同成长,生产中不会出现障碍或延迟。

公共漏洞评分系统

通用漏洞评分系统(CVSS)背后的基本思想是提供对安全漏洞严重性的通用分类。评估发现的弱点从各种角度进行评估。这些元素相互权衡,以获得从 0 到 10 的标准化数字。

评分系统(如 CVSS)允许我们抽象评估各种弱点,并从中推导后续操作。重点是标准化处理这些弱点。因此,您可以根据数值范围定义操作。

从原则上讲,CVSS 可以描述为使用预定义的因素关联概率和最大可能损害。其基本公式为风险 = 发生概率 × 损害。

这些 CVSS 指标分为三个正交区域,它们的权重不同,称为基本指标、时态指标和环境指标。在每个区域中查询不同的方面,必须分配一个单一值。三个组值的权重和随后的组合形成最终结果。下一节详细探讨了这些指标。

CVSS 基本指标

基本指标 构成了 CVSS 评分系统的基础。在这一领域查询方面的目的是记录漏洞的技术细节,这些细节不会随时间改变,因此评估独立于其他变化的元素。不同的方面可以进行基值的计算。可以由发现者、涉及项目或产品的制造商,或者负责消除这一弱点的计算机应急响应团队(CERT)来执行。我们可以想象,基于这一初步决定,值本身将因各个个体团体追求不同的目标而有所不同。

基础值评估通过此安全漏洞进行成功攻击所需的先决条件。这是区分目标系统上是否必须有用户帐户,还是可以在不了解系统用户的情况下妥协系统。这些先决条件在确定系统是否通过互联网易受攻击或是否需要对受影响组件进行物理访问方面起着重要作用。

攻击的基础值还应反映出进行攻击的复杂性。在这种情况下,复杂性涉及到必要的技术步骤,并包括评估是否与常规用户的互动至关重要。是否足以鼓励任何用户进行互动,还是这个用户必须属于特定的系统组(例如管理员)?正确的分类并不是一个琐碎的过程;评估新的漏洞需要对漏洞和相关系统的确切知识。

基本指标还考虑了此攻击可能对受影响组件造成的损害。关注的三个领域如下:

机密性

从系统中提取数据的可能性

完整性

操纵系统的可能性

可用性

完全阻止系统使用

但是,您必须注意关注这些关注领域的权重。在一个案例中,窃取数据可能比更改数据更糟糕。在另一种情况下,组件的不可用可能是被假设的最严重的损害。

范围指标 自 CVSS 3.0 版本以来也可用。此指标考虑受影响组件对其他系统组件的影响。例如,在虚拟化环境中的受损元素允许访问载体系统。成功更改此范围表示对整个系统的更大风险,因此也使用此因素进行评估。这表明,值的解释也需要根据自己的情况进行调整,这将带我们到临时和环境指标。

CVSS 临时指标

漏洞评估的时间依赖组件汇总在临时指标组中。

随时间变化的元素影响这些临时指标。例如,支持利用漏洞的工具的可用性可能会改变。这些可以是漏洞利用代码或逐步说明书。必须区分漏洞是理论上的还是制造商已正式确认的。所有这些事件都会改变基础值。

临时指标独特之处在于基础值只能减少,而不能增加。初始评级旨在代表最坏情况。这在你考虑到漏洞的初始评估时既有优势也有劣势,因为各方的利益在竞争。

对初始评估的影响是通过外部框架条件引起的。这些条件在未定义的时间框架内发生,并且与实际基础评估无关。即使在基础值调查期间已经存在漏洞利用,这种知识也不会包含在初步评估中。但是,基础值只能通过临时指标减少。

这就是冲突产生的地方。发现安全漏洞的个人或团体试图尽可能地提高基础值。高严重性的漏洞会以更高的价格出售并获得更多的媒体关注。发现此漏洞的个人或团体的声誉因此增加。受影响的公司或受影响的项目对确切相反的评估感兴趣。因此,这取决于谁发现了安全漏洞,评审过程应如何进行,以及由哪个机构进行首次评估。此值还通过环境指标进一步调整。

CVSS 环境指标

对于环境度量,您自己的系统景观被用于评估安全漏洞的风险。根据实际情况调整评估。与时间度量相比,环境度量可以在两个方向上修正基本值。因此,环境可能导致更高的分类,并且也必须不断适应您自己环境的变化。

让我们以一个安全漏洞的例子为例,该漏洞已经有了制造商提供的补丁。这种修改的存在仅仅导致时间度量中的总值降低。然而,只要这个补丁还没有在您自己的系统中激活,总体价值就必须通过环境度量被大幅上调。这是因为一旦有了补丁,就可以更好地理解安全漏洞及其影响。攻击者有更详细的信息可供利用,这降低了尚未硬化的系统的抵抗力。

在评估结束时,得到最终分数,该分数是从前面提到的三个值计算出来的。然后将结果值分配给一个值组。但往往会忽视一个方面。在许多情况下,最终得分仅仅是通过环境得分进行简单转移,而没有利用环境分数进行个别调整。这种行为导致了对相关整个系统的不正确评估。

实践中的 CVSS

通过 CVSS,我们有了一个用于评估和评定软件安全漏洞的系统。由于没有替代方案,CVSS 已成为事实上的标准;该系统已在全球使用了超过 10 年,并不断发展。评估由三个组成部分组成。

首先,基本分数描述了纯粹的技术最坏情况。第二个组成部分是基于外部影响的时间依赖性修正的评估,包括对于这个安全漏洞的进一步发现、工具或补丁,可以用来降低值。评估的第三个组成部分是您自己的系统环境与这种漏洞有关。考虑到这一点,安全漏洞将根据现场实际情况进行调整。最后,从这三个值中得出一个总体评估,结果是一个从 0.0 到 10.0 的数字。

这个最终值可以用来控制您自己组织对抗安全漏洞的响应。乍一看,一切都感觉非常抽象,因此需要一些练习来感受 CVSS 的应用,这可以通过与您自己的系统的经验来发展。

安全分析范围

一谈到安全,总是会出现以下问题:付出多少努力才够,应该从哪里开始,以及第一次获得结果需要多快?在本节中,我们将讨论如何迈出这些第一步。为此,我们将研究两个概念并考虑相关的影响。

上市时间

您可能已经听说过“上市时间”的术语,但这与安全有何关系?一般来说,这个表达意味着希望的功能尽快从构思、开发到生产环境中转移。这使得客户可以开始从新功能中受益,从而增加业务价值。

乍一看,上市时间似乎只关注业务用例,但当应用于安全修复时同样相关。尽快激活对整个系统所需的修改也是最优的。简而言之,“上市时间”是安全实施的一个常见且值得追求的目标。

业务用例的过程应与修复安全漏洞的过程相同。它们都需要尽可能多的自动化,并且所有人类互动必须尽可能短暂。所有浪费时间的互动增加了漏洞可能被用于生产系统的风险。

制造或购买

在云原生堆栈的所有层次中,大多数软件和技术都是购买或获取而非制造的。我们将在图 7-1 中讨论的各层中讨论每个软件组成部分。

DevSecOps 实现架构图

图 7-1. 您可以选择构建或购买的 DevSecOps 组件

第一层是应用程序的开发本身。假设我们使用 Java 并使用 Maven 作为依赖管理器,与我们自己编写的代码相比,我们很可能间接添加更多的代码行作为依赖项。依赖项是更突出的部分,由第三方开发。我们必须小心,检查这些外部二进制文件是否存在已知的漏洞是个好建议。在合规性和许可使用方面,我们应该保持相同的行为。

下一层是操作系统,通常是 Linux。再次添加配置文件,其余部分是现有的二进制文件。结果是在操作系统内运行的应用程序,这是基于我们配置的外部二进制文件的组合。

Docker 和 Kubernetes 两个层次带我们达到同样的结果。到目前为止,我们还没有看生产线工具栈本身。所有直接或间接在 DevSecOps 下使用的程序和实用工具都会创建依赖关系。所有层次的依赖关系是远远最重要的部分。检查这些二进制文件是否存在已知的漏洞是第一个逻辑步骤。

一次性和定期工作

比较针对已知漏洞和合规问题的扫描工作,我们可以看到一些差异。让我们从合规问题开始。

合规问题

首先,在范围合规方面的第一步是定义在生产线的哪个部分允许使用哪些许可证。允许许可证的定义包括开发过程中的依赖关系以及工具和运行环境的使用。应通过专门的合规流程来检查定义的非关键许可证类型。有了允许的许可证类型清单,我们可以开始使用构建自动化定期扫描完整的工具堆栈。在机器发现违规后,我们必须移除这个元素,并用具有许可证的另一个元素替换。

漏洞

持续扫描漏洞的工作量与修复漏洞所需的工作量相比较低。处理已发现的漏洞需要稍有不同的工作流程。通过更大的准备工作,构建自动化也可以定期完成这项工作。漏洞的发现将触发包括人类交互在内的工作流程。漏洞必须在内部进行分类,这将决定下一步的行动。

够不够?

那么让我们回到本节的初始问题。扫描多少次足够?没有改变太小,因为所有涉及添加或更改依赖关系的更改都会导致您重新评估安全性并运行新的扫描。通过自动化可以有效地检查已知漏洞或正在使用的许可证。

另一个不应低估的观点是,在此时进行此类检查的质量是恒定的,因为此时没有人参与。如果价值链的速度不会因持续检查所有依赖关系而减慢,这是一项值得投资的工作。

合规与漏洞

合规问题与漏洞之间还存在另一个差异。如果存在合规问题,则它是整体环境中的一个特定点。仅此单一部分是一个缺陷,不会影响环境的其他元素,如图 7-2 所示。

圆形图显示应用程序单个层中的合规问题

图 7-2. 应用程序的层次,可以发现合规问题

漏洞可以组合成不同的攻击向量

漏洞有所不同。它们存在的点不仅仅是它们被定位的地方。此外,它们可以与环境的任何其他现有漏洞结合,如图 7-3 所示。漏洞可以组合成不同的攻击向量。每个可能的攻击向量本身都必须被看作并评估。在应用程序不同层的一组次要漏洞可以组合成高度关键的风险。

圆形图显示跨应用程序多个层的攻击向量

图 7-3. 应用程序多层次的漏洞

漏洞:从起始到生产修复的时间轴

我们再次在 IT 新闻中读到有关被利用的安全漏洞的内容。这种漏洞被分类的越严重,这些信息在一般媒体中得到的关注就越多。大多数情况下,我们听不到也不会读到所有发现的安全漏洞,这些漏洞没有像 SolarWinds 攻击那样为人所知。漏洞的典型时间轴如图 7-4 所示。

漏洞生命周期时间轴

图 7-4. 漏洞的时间轴

创建漏洞

让我们从漏洞的产生开始说起。这可以通过两种方式完成。一方面,任何有不幸源代码组合而造成安全漏洞的开发人员都可能遭受此种对准。另一方面,这也可以基于有目的的操控。然而,这对安全漏洞生命线的进一步进程几乎没有影响。接下来,我们假设安全漏洞已经被创造,并且现在在某些软件中处于活动状态。这些可以是作为依赖项集成到其他软件项目中的可执行程序或库。

安全漏洞的发现

在大多数情况下,我们无法准确理解安全漏洞何时创建,但让我们假设存在安全漏洞,并且在某些时刻将被发现。根据谁首先发现安全漏洞,可能会出现几种不同的情景。

如果一个恶意行为者发现了安全漏洞,他们可能会试图保密以从中获利。获利的两种方式是利用安全漏洞本身或者将关于安全漏洞的信息出售给感兴趣的第三方。无论哪种情况,他们越快从安全漏洞中获利,安全漏洞被发现和修补的可能性就越小。

相反,如果安全漏洞被道德攻击者发现,他们会首先验证是否可以利用安全漏洞而不造成任何损害,然后将其披露给受影响的各方。通常也会存在金融动机。这些动机可以由漏洞赏金和愿意为了将漏洞披露给公司而不是攻击者而支付的奖励来驱动。此外,维护漏洞数据库的公司有动机发现安全漏洞并在公开之前向其客户群披露。

另一种可能性是公司自行发现安全漏洞。在这种情况下,公司可能倾向于隐藏漏洞或宣称其无害。然而,最好的方法是尽快修复漏洞,因为恶意行为者可能很快发现漏洞,或者已经知道并等待利用它。

不管知识通过何种途径传递到漏洞数据库,只有当信息达到其中一个点时,我们才能假设这些知识随时间对公众可用。

漏洞的公开可用性

每个安全漏洞提供商都有公开披露的漏洞子集。要获得更全面的漏洞集合,需要整合多个信息源。此外,由于漏洞数据库不断更新,这必须是一个自动化的过程。

安全漏洞的处理至关重要,需确保机器能进一步处理。关键的元信息如 CVE 或 CVSS 值需要包含在内。例如,CVSS 值可在 CI 环境中使用,达到特定阈值时中断进一步处理。

作为最终用户,在这里真正只有一种方法。与直接联系提供商不同,您应依赖集成各种信息源并提供处理和合并数据库的服务。由于信息通常代表相当大的财务价值,这类数据集的商业提供者投入了大量资源以确保其准确性和最新性。

在生产环境中修复漏洞

一旦信息公开披露并通过众多安全提供商之一提供给您,您可以开始采取行动。关键因素是组织识别和缓解安全漏洞所需的时间。

第一步是从您选择的安全提供商那里获取漏洞信息。希望这完全可以通过 API 自动化进行,您可以使用这些 API 消费漏洞,安全扫描仪持续扫描您的生产部署,并且报告能够快速通知您任何新的漏洞。

下一步是开发、测试和部署修复安全漏洞的解决方案。只有那些实施了高度自动化的人才能在交付过程中实现短响应时间。如果相关团队能够轻松做出必要的决策,这也是一个优势。在这一点上,冗长的批准过程是逆生产力的,也可能对公司造成广泛的损害。

另一个可以提高响应时间的点是在开发的早期阶段捕捉安全漏洞。通过在所有生产阶段提供安全信息,可以更早地捕捉漏洞,降低缓解成本。我们将在 “Shift Security Left” 中详细讨论这一点。

测试覆盖率是您的安全保障

即使拥有安全漏洞的最佳知识也是无用的,如果无法利用这些知识。但在软件开发中,您有哪些工具可以有效地应对已知的安全漏洞呢?我特别想强调一个度量标准:您自己源代码部分的测试覆盖率。如果有强大的测试覆盖率,您可以对系统进行更改并依赖于测试套件。如果所有受影响的系统组件的顺利测试已经完成,从技术上讲,可以使软件变得可用。

但让我们更仔细地看看情况。在大多数情况下,通过更改相同依赖的使用版本来消除已知安全漏洞。因此,高效的版本管理使您能够迅速做出反应。在极少数情况下,受影响的组件必须被其他制造商的语义等效物替换。为了将相同组件版本的新组合分类为有效,需要强大的测试覆盖率。手动测试将远远超出时间框架,并且不能在每次运行中以相同的质量进行。

要全面了解基于所有已知漏洞的全面影响图,理解包括依赖的所有包管理器至关重要。仅关注技术堆栈中的一个层面远远不够。像 Artifactory 这样的包管理器提供包括供应商特定元数据在内的信息。这可以通过像 JFrog Xray 这样的安全扫描工具进行增强,它们可以扫描由您的包管理器管理的所有存储库中托管的所有二进制文件。

质量门控方法论

就安全响应而言,IT 项目的成功取决于尽早参与和涉及最终用户、高级管理支持以及明确的业务目标的制定。通过管理这些因素,软件项目可以快速解决安全漏洞并减轻对公司的风险。

高级管理全面支持的需求,通过使用标准及时系统地控制 IT 项目的质量和进展,以便干预。通过指定标准,管理层有两种控制软件开发过程的方式:

  • 标准是项目管理的规范,开发人员必须遵守。

  • 在发生与定义目标偏差的情况下,项目管理可以进行干预。

负责制定和执行这些标准的团队可能因管理系统不同而异。角色的分配也一再引起争议。然而,结果表明,所有团队成员更广泛地参与会带来动态和成功的结构。

在项目控制的背景下,可以采取措施来对抗项目内的不良发展。对项目参与者来说,理想情况是安全风险不会影响项目的继续进行。然而,在极端情况下,也可能取消项目。及时性意味着在可能造成重大财务损失之前能够采取行动。

与此同时,这也预设了必须有相关且可衡量的结果可供有效的项目控制。项目内活动结束时是这样做的合适时间,因为可以检查到可用的结果。然而,由于项目内的活动数量众多,项目管理团队频繁检查会减慢项目的进展。此外,对于许多并行项目(所有这些项目都必须监控),项目管理的负担将更大。

一个折中的办法是在每个项目中建立在特定重要点上的控制和引导作为约束。为此,质量门提供了一个机会,以检查各个质量目标的达成程度。质量门是项目中的一个特殊时间点,在这个时间点上,基于对与质量相关的标准的正式检查,做出项目继续或终止的决定。

比喻而言,质量门是项目各个过程步骤之间的屏障:一旦达到了质量门,只有在满足所有标准,或至少足够多的标准时,项目才能继续进行。这确保了在质量门时项目的所有结果足够好,可以继续使用它们。通过质量门的标准,可以确定项目结果的结果一方面和结果的质量要求另一方面。然后可以用它们来定义单个项目阶段之间的接口。为了建立质量门,需要特定的结构、活动、角色、文件和资源,这些内容被总结在一个质量门参考流程中。

公司质量门参考流程的精确设计基于公司的需求。质量门的起源可以追溯到汽车开发和技术产品的生产,但它们越来越多地进入了系统开发项目,最近甚至进入了纯软件开发项目。

在系列生产中,质量门依赖于统计确定的值,这些值可以作为未来项目控制活动的目标。由于软件开发项目高度个性化,所以在软件开发中不存在这样的起始位置。因此,在装配线生产中实践的质量门参考流程只能在有限的范围内转移到软件开发中。然而,可以考虑使用其他领域的质量门参考流程,因为它们经过多年的开发和优化。

质量门控策略

使用质量门时,已经确定了两种基本策略。根据目标,公司可以选择其中一种策略来设计质量门参考流程,下文描述。

质量门作为统一的质量指南

在第一种方法中,每个项目必须经历相同的质量门,并根据相同的标准进行衡量。允许在遵循这一策略的质量门参考流程中适度(如果可能的话)进行调整。目标是至少在每个项目中达到相同的质量水平;因此为每个项目建立了一个质量指南。

因此,质量门可以作为进展的统一衡量标准。我们可以通过检查哪些任务已经通过了特定的质量门,哪些没有,来比较项目之间的进展。管理层可以轻松地识别一个项目在质量上落后于另一个项目,并相应地采取行动。因此,质量门可以轻松地被用作多项目管理的工具。

质量门作为一种灵活的质量策略

在第二种方法中,可以根据项目的需求调整质量门或标准的数量、排列和选择。因此,质量门和标准可以更精确地适应项目的质量要求,从而提高结果的质量。然而,这使得比较多个项目更加困难。幸运的是,类似的项目将具有可比较的质量门,并可以根据类似的标准进行衡量。

在互联网和文献(如论文、标准作品和会议文集)上研究质量门的主题,可以发现许多术语。因为许多地方使用了同义词,所以质量门经常被错误地等同于各种其他概念。例如,审查里程碑不应与质量门等同起来。

与项目管理程序的契合

现在的问题是,这种方法是否可以应用于其他项目管理过程。答案是肯定的。质量门控方法可以集成到循环和非循环项目方法中。在这一点上,时间顺序是无关紧要的,因此也可以在经典的瀑布项目中的里程碑级别使用。

重要优势在于,该方法在项目管理的范式转变时仍然可用。团队中积累的知识可以继续使用,并且不会失去其价值。这意味着无论当前项目实施如何,这里描述的措施都可以引入和使用。

使用质量门控方法实施安全

我们将引入、定义和使用一个极大简化的方法来整合安全的横向问题。在接下来的内容中,我们假设质量门控方法适合于实施任何横向主题。时间组成部分也是无关紧要的,因此可以在任何周期性项目管理方法中使用。因此,这种方法非常适合集成到 DevSecOps 项目组织方法中。

DevOps 过程分为阶段。各个阶段之间无缝连接。在这些点安装会干扰整个过程的东西是没有意义的。但是,也有更好的地方可以找到横切问题。我们讨论的是自动化流程推导,可以在 CI 路由中找到。假设必要的流程步骤通过质量门控可以完全自动化执行,那么 CI 路由非常适合定期执行这些工作。

假设 CI 线执行了自动化流程步骤,可能会出现两种结果。

绿色:质量门控已通过

此处理步骤的一个可能结果是,所有检查都已成功通过。处理可以在此点继续无中断。为确保完整文档记录,仅做了少量日志条目。

红色:未通过质量门控

另一个可能的结果是,检查发现了某些表明失败的东西。这中断了过程,必须找出失败的原因以及补救的方法。自动化流程通常在此时结束,并被手动流程替代。

质量门控中的风险管理

由于质量门控被识别为一个缺陷而被阻止,有人需要负责以下步骤:

  • 风险评估(风险识别、分析、评估和优先级确定)

  • 设计和启动对策

  • 在项目过程中跟踪风险

风险确定已在创建标准并根据风险权衡要求的操作化过程中完成。这发生在门控审查本身时。

对策的构思和启动是门户审查的重要活动,至少在项目在进入生产之前没有被推迟或取消的情况下是如此。主要采取的对策主要是针对未达标准而产生的风险。

风险管理的对策可以分为预防措施和紧急措施。预防措施 包括尽快达到标准。如果这不可能,必须设计适当的对策。对策的设计是一个创造性的行为;它取决于风险、其评估以及可能的替代方案。

对策的有效性必须进行跟踪,以确保其成功。这涵盖了项目的所有阶段,并且对于确保安全漏洞在流程早期被发现和解决至关重要。

质量管理的实际应用

让我们通过软件发布的背景中质量管理的一个实际例子来看一下。为此,生成并收集所有必需的组件到存储库中,每个二进制文件都有一个身份和版本。在成功创建这些元素后,将所有发布所需的元素组合成一个部署包。在这种情况下,一个发布是不同二进制文件的组合,它们分别具有不同的版本。技术在这里起到次要作用,因为最多样化的物件可以在一个发布中聚集在一起。

您还可以想象到这个时候所有关键文件都是这个汇编的一部分。这可能包括发行说明和构建信息等文件,提供有关制造过程本身的信息,例如在哪个平台上使用了哪个 JDK 等等。此时可以自动整理的所有信息都会增加可追溯性和再现质量,如果需要进行事后分析的话。

我们现在已经准备好了一切,并且希望开始提供这些物件。我们在这里讨论的是推广二进制文件。这可以在您自己的存储库中完成,或者在通用的全球性存储库中完成。现在是您仍然可以进行更改的最后时间。

我们在谈论一个作为推广门户的安全检查。此处使用的工具最终应检查两件事。首先,需要删除二进制文件中已知的漏洞。其次,所有包含的物件中使用的许可证必须适合该目的。这里立即变得清晰的是需要独立于所使用的技术进行检查。这将我们带回到完整的影响图。在这一点上,我们必须获取完整的影响图,以便能够达到高质量的结果。负责提供所有依赖物件的存储库管理器必须与二进制扫描器无缝集成。一个例子是 Artifactory 和 Xray 的组合。

但是安全检查是否是尽早推广二进制文件的门槛?你可以从哪里开始?现在我们来到向左移动的概念。

向左转移安全性

长期以来,敏捷开发、DevOps 和安全实施一直被认为是互斥的。经典的开发工作总是面临这样一个问题,即软件产品的安全性无法被充分定义为最终的、静态的最终状态。这就是软件开发中的安全悖论

看起来敏捷开发过于动态,无法在每个开发周期中对待要开发的软件产品进行详细的安全分析。事实恰恰相反,因为敏捷和安全开发技术非常好地互补。敏捷开发的关键点之一是能够在短期内实施变更以及在短时间内对需求进行变更。

在过去,安全往往被视为一个静态过程。因此,必须将敏捷概念应用到安全领域。安全需求的一般处理必须适应这一发展,以便能够高效地实施。然而,我们必须注意,敏捷开发是面向特性的。安全需求大多来自非功能特性类别,因此在大多数情况下只以隐含的形式出现。这种情况的后果,结合错误的安全需求工程结果,是开发周期计算错误,时间压力增加;冲刺被取消,因为预算计算不正确,技术债务增加,代码库中持久的弱点或特定的安全漏洞。

现在让我们关注如何在敏捷开发团队中创造必要条件,尽早提高代码库的安全级别。无论使用的具体项目管理方法是什么,以下方法在其有效性上没有限制。

设置安全级别是至关重要的,以便在每次产品增量中开发团队都能实现安全增长。具有内在和显著安全关注的团队可以立即获得比没有这种关注的团队更高级别的安全性。无论每个团队的经验如何,都必须定义和遵守一个通用的最低标准。

OWASP 十大安全风险 是一个开发人员可以通过简单措施避免的常见安全漏洞列表。因此,它们作为该主题的介绍应该是每个开发人员安全基本技能的一部分。然而,代码审查经常表明团队没有充分考虑前十名,因此这是一个团队改进的良好领域。

还应该认识到开发人员可以在其领域内做得很好,但并非安全专家。除了不同层次的经验外,开发人员和安全专家在对待问题的方式和思考方式上也有所不同,这对各自的任务至关重要。因此,开发团队必须意识到他们在评估攻击方法和安全方面的限制。在开发关键组件或出现问题时,应预先确定调用安全专家的组织选项。然而,开发人员通常应能够评估典型的安全因素,并采取简单的措施来提高代码的安全性。

理想情况下,每个团队都有一名既有开发又有详细安全知识的成员。在支持项目的背景下,相关员工被称为安全经理(SecMs)。他们监控所开发代码部分的安全方面,在每个开发周期中定义攻击面和攻击向量,支持评估用户故事的工作量,并实施缓解策略。

为了全局了解代码库及其安全水平,目标是在涉及的团队的 SecMs 之间定期交流是有意义的。由于全公司范围内同步开发周期阶段是不现实的,因此 SecMs 应定期固定时间会面。在小公司或同步冲刺的情况下,团队特别受益于在开发周期规划期间的交流。通过这种方式,可以评估跨组件的安全方面以及开发周期对产品增量安全性的影响。目前后期测试是唯一可以实现这一点的方式。根据开发周期回顾,在实施新组件后也应该进行 SecM 会议。在为下一个冲刺做准备时,参与者根据增量评估安全水平。

OWASP 安全冠军的实施方式各不相同。这些通常是开发人员,可能是初级开发人员,他们通过经验可以获得非常领域特定的额外安全知识。与 SecMs 的概念有重叠之处;然而,一个关键的区别在于 SecM 是一位具有开发经验的全面安全专家,其行动与高级开发人员相当。然而,在实施安全软件时,关键是考虑实施决策的安全相关影响和跨主题专业知识。

无论团队是否能创建专门的角色,都应采取基本措施来支持开发安全软件的过程。以下是最佳实践建议和经验数值。

并非所有的清洁代码都是安全代码。

《干净的代码》(Pearson)由罗伯特·马丁(也被称为乌克尔·鲍勃)创造了干净的代码一词。然而,决策者之间的一个常见误解是干净的代码也涵盖了代码的安全性。

安全和干净的代码有重叠之处,但并不完全相同。干净的代码促进代码的可理解性、可维护性和可重用性。另一方面,安全的代码还需要预定义的规范并遵守它们。然而,干净的代码通常是安全代码的前提条件。代码可以干净地编写而没有任何安全特性。然而,只有干净的实现才能充分发挥安全措施的潜力。

写得好的代码也更容易保护,因为组件和功能之间的关系是清晰定义和界定的。任何寻找理由推广干净代码原则并在决策者面前以经济上的成本和时间节省来解释代码安全性的开发团队,都会找到充分的论据。

调度影响

总体而言,特别是在敏捷开发中,团队在规划下一个版本时往往没有足够的时间来改进代码库。在冲刺规划中,对努力评估的关注主要集中在开发新功能所需的时间上。只有在存在特殊要求时,才会明确考虑硬化工作。

团队实现功能安全所需的时间取决于功能、产品增量的状态、现有的技术债务以及开发者的先验知识。然而,按照敏捷开发的意图,团队应该估计实际所需的时间。特别是在开始阶段,可以预期会出现误算,因此相比于之前的冲刺,减少采纳的用户故事数量是有意义的。

正确的联系人

每个团队必须能够接触到安全专家,但在大型组织中找到合适的联系人可能会很困难。IT 安全被分为许多有时非常具体和复杂的子领域,这些领域需要全职的安全专家负责。优秀的程序员是全职开发人员,即使经过 IT 安全培训,也无法取代专门的安全专家。

在项目管理中,确保团队在需要时能够迅速利用技术专业知识并在评估过程中承担责任是必要的。在大多数组织中,默认情况下并不是这样。

处理技术债务

技术债务是开发过程中不可或缺的一部分,项目所有者应该将其视为时间和预算的一部分。技术债务对代码库的可维护性、开发性能和安全性有负面影响。这意味着个别(新)实施成本显著增加,并通过将开发者在个别项目中长时间阻塞来持续减慢总体生产速度。因此,每个相关方,特别是管理层,都有兴趣将代码库的技术债务保持在较低水平,并持续减少它。

或者,替代策略是将预估的项目时间的固定部分用于处理技术债务。这种方法是次要的,因为存在团队可能利用处理技术债务的时间来实施功能,而在开发周期压力下误判技术债务的程度的风险。

安全编码高级培训

存在一个误解,即安全性可以顺便学习,每个人都可以访问必要的材料。通常,安全编码指南列表在某个公共文件夹中。此外,OWASP 前 10 名通常向公众发布。然而,员工通常不会阅读这些文件,或者最多只会粗略浏览。通常,过一段时间后,团队不再知道这些文件在哪里,更不用说应该从中获得什么用途。鼓励阅读指南的告诫如果公司无法创造额外时间来专注于安全编码,则帮助不大。

质量里程碑

开发中的质量门控帮助检查质量要求的符合性。类似于定义完成(DoD),团队广泛定义了任务何时可以视为完成,质量门控不应仅以静态纸质形式存在。理想情况下,自动化检查应通过静态代码分析(SAST)或评估所有依赖项集成到 CI/CD 流水线中。

对开发者来说,接收编程期间来自 IDE 插件以及 CI/CD 流水线反馈的反馈可能很有帮助。语言和平台相关的 IDE 插件和独立代码分析工具都可用,例如 FindBugs/SpotBugs,Checkstyle 和 PMD。使用 JFrog Xray 时,IDE 插件可用于更轻松地与已知的漏洞和合规问题进行比较。

一个额外的上游过程用于在 IDE 中检查代码,旨在在开发过程中使开发人员熟悉安全方面。因此,由插件识别的点以及整个代码中的代码安全性得到改善,因为开发人员得到了安全方向。另一个副作用是在构建服务器上虚警的数量减少。对于安全质量门,后者异常高,因为代码中的安全漏洞通常依赖于上下文并需要手动验证,这导致开发工作量的显著增加。

攻击者的视角

邪恶用户故事(也称为不良用户故事)从攻击者的视角呈现所需的功能。类似于用户故事,它们的设计使它们的重点不是技术实现。因此,即使是在 IT 安全方面技术背景有限的人也可以编写不良用户故事。然而,这增加了从可能不明确(糟糕的)用户故事生成任务所需的工作量。

理想情况下,不良用户故事试图描绘攻击面。它们使开发团队能够在熟悉的工作流中处理已识别的攻击方法。这增强了可能攻击向量的意识,但这些向量是有限的。邪恶用户故事不仅受到其各自作者的知识和经验以及他们的想象力的限制,而且还受到开发人员在冲刺上下文中抵御攻击向量的能力的限制。这不仅仅是开发人员是否开发了正确的损害控制策略,还包括在代码中正确和全面地识别用例。

与传统用户故事类似,邪恶变体并不总是易于编写。特别是在开发安全软件方面经验有限的团队可能会在创建有意义的恶意用户故事时遇到困难。如果团队中有安全专家(SecM),那么应由该人承担该任务或提供支持。没有安全专家的团队应该寻找外部技术专家或计划一个结构化的过程来创建邪恶用户故事。

评估方法

要在敏捷开发中确立安全作为一个过程,必须进行定期的代码审查,重点是代码的安全级别,无论是逐个组件还是跨段落。理想情况下,可以在 CI/CD 管道中通过质量门和自动化测试识别和纠正易于避免但可能导致安全漏洞的错误。在这种情况下,逐个组件的测试主要关注对相应组件的攻击面和攻击向量的减弱的调查。关于分析攻击面的备忘单可以在OWASP Cheat Sheet Series on GitHub找到。

团队必须定期重新定义攻击面,因为它可能随着每个开发周期而变化。跨组件检查用于监视整体产品的攻击面,因为它也可能随着每个开发周期而变化。最终,只有跨组件视图才能搜索由组件之间或甚至依赖关系之间的交互引起的攻击向量。

如果没有安全专家团队,可以通过结构化的方法和团队联合培训进行安全评估。OWASP Cornucopia 卡牌游戏 可以促进这样的方法,其中玩家试图将卡牌上描述的攻击场景应用到团队事先选择的领域,或者必要时仅应用到个别方法,例如代码库。然后,团队必须决定播放的卡牌的攻击场景是否可行。因此,重点是识别攻击向量;由于时间限制,减轻策略应该在其他地方讨论。卡牌游戏的赢家是成功播放最困难的卡牌的人。团队必须在最后记录产生的安全分析。

Cornucopia 的一个好处是增加了团队对代码漏洞的意识。该游戏还提高了开发人员在 IT 安全方面的专业知识。重点在于开发人员的能力,并因此反映了敏捷指南。Cornucopia 会议后,生成邪恶用户故事是一个极好的工具。

Cornucopia 会议的问题在于它们给尤其是经验不足的团队带来了一个陡峭的学习曲线。团队还存在一个风险,即团队可能错误地丢弃一个潜在的攻击向量。如果准备工作不足(例如,组件太大,或者团队对可能的攻击向量没有足够的技术知识),Cornucopia 在时间上可能效率低下。因此,特别是在最初的几次会议中,建议检查小型独立的组件,并在必要时请教安全专家。

要意识到责任

总的来说,开发人员不应该允许代码安全的权杖从他们手中被夺走。理想情况下,团队应该共同坚持有足够的时间和财务资源来实现基本的安全方面。

当前的开发人员将在未来多年中主要定义和塑造这个世界。由于预期的数字化和网络化,安全性不能成为预算和时间限制的牺牲品。根据敏捷宣言,代码库仍然是负责结果的团队的产品。

总结

随着供应链攻击在行业中的大量出现,解决安全问题对于项目和组织的成功比以往任何时候都更为关键。尽快减轻漏洞的最佳方法是向左转移,并从每个软件开发项目的第一天开始将安全作为主要关注点。本章介绍了安全的基础知识,包括各种分析方法,如 SAST、DAST、IAST 和 RASP。您还了解了基本的评分系统,如 CVSS。有了这些知识,您将能够在将来参与的每个项目中制定正确的质量门和标准,以提高安全性。

第八章:开发者部署

安娜-玛丽亚·米哈尔切亚努

不管战略多么美丽,偶尔你也应该看看结果。

温斯顿·丘吉尔爵士

当计算机非常庞大且昂贵时,制造商经常将软件与硬件捆绑在一起。随着大众市场软件的发展,这种操作方式变得耗时,出现了新形式的软件分发。如今的开发过程专注于解耦构建和部署活动,以便快速进行软件分发和团队内的并行活动。

应用程序的部署代表了将该软件从打包的工件转化为运行状态的过程。现代开发日要求这种转化尽可能快地发生,以便快速获取关于系统运行状态的反馈。

作为开发者,你的重点主要是编写高效的应用程序代码。然而,DevOps 是以协作为中心,你的工作应无缝地融入基础设施中。在审视你的部署过程时,你应不断地问自己,“机器需要执行我设想的部署的哪些指令?”并将这些分享给负责基础设施和自动化的同事或专家。在规划部署过程时,你可以制定一个愿望清单,稍后可以将其扩展到分布式系统的更多组件:

  • 为了逐步扩展系统的功能,经常进行小规模的部署。采用这种方法,你可以在失败时轻松回滚到先前的工作状态。

  • 隔离每个微服务的部署,这样你就能够单独扩展或替换它。

  • 你应该能够在另一个环境中重复使用已部署的微服务。

  • 自动化基础设施部署,并随着应用程序功能的演变而进行更新。

无论在哪个容器编排平台上部署你的任何微服务,你可能都会从打包应用程序开始,然后继续进行以下操作:

  1. 构建和推送容器镜像

  2. 选择和实施部署策略

随着应用程序部署在各种阶段或环境中的推进,你可能会涉及以下内容:

工作负载管理

优化健康检查、CPU 和内存使用量,以避免功能缓慢或无响应。

观察性方面

使用度量、日志和跟踪来查看你的分布式系统内部,并测量其输出。

本章将带您完成这些活动,并探讨它们在规模上的影响。

构建和推送容器镜像

将应用程序部署到容器需要创建 Java 应用程序工件并构建容器镜像。通过利用第六章中分享的推荐工件格式和实践,我们可以专注于生成容器镜像。

自 2013 年 Docker 出现以来,使用 Dockerfile 构建容器镜像变得流行起来。Dockerfile是一种标准化的镜像格式,包含基本操作系统、要添加的应用程序构件以及所需的运行时配置。本质上,这个文件是描述未来容器行为方式的蓝图。正如在第三章中所解释的,除了 Docker,您还可以使用工具如PodmanBuildahkaniko来构建容器镜像。

由于 DevOps 方法依赖于应用程序开发人员和基础设施工程师之间的良好沟通,一些团队认为最好将 Dockerfiles 保留在仓库根目录。此外,在为容器镜像构建工具化或管道化时,可以进一步使用该位置。除了编写您的 Dockerfile 外,Java 特定的选项可以帮助您将容器镜像作为标准构建过程的一部分,例如 Eclipse JKube 或 Jib。

提示

使用 Java 特定工具生成和推送容器镜像可能会诱使您从应用程序代码控制整个运行时。为了避免基础设施与应用程序代码之间的紧密耦合,您应该使用可以在构建或运行时重写的参数配置这些工具。现代 Java 框架提供了在src/main/resources下自定义配置文件的功能。本章的示例使用项目配置文件中的参数展示了这种方法。

使用 Jib 管理容器镜像

Google 的Jib是您可以使用的一种工具,用于将 Java 应用程序容器化,而无需编写 Dockerfile。它提供了一个 Java 库,以及用于创建 OCI 兼容容器镜像的 Maven 和 Gradle 插件。此外,该工具不需要在本地运行 Docker 守护程序即可生成容器镜像。

Jib 利用镜像分层和注册表缓存实现快速、增量构建。只要输入保持不变,该工具可以创建可重现的构建镜像。

要开始在您的 Maven 项目中使用 Jib,请通过以下任一方式设置目标容器注册表的身份验证方法:

  • 系统属性jib.to.auth.usernamejib.to.auth.password

  • 在插件配置中的<to>部分,包含usernamepassword元素

  • ~/.m2/settings.xml中的<server>配置

  • 先前使用 Docker 登录到注册表(凭据在凭据助手或~/.docker/config.json中)

注意

如果您使用特定的基础镜像注册表,可以通过插件配置中的<from>部分或jib.from.auth.usernamejib.from.auth.password系统属性设置其凭据。

接下来,在您的pom.xml中配置 Maven 插件:

<project>
    ...
    <build>
        <plugins>
            ...
            <plugin>
                <groupId>com.google.cloud.tools</groupId>
                <artifactId>jib-maven-plugin</artifactId>
                <version>3.1.4</version>
                <configuration>
                    <to>
                        <image>${pathTo.image}</image>
                    </to>
                </configuration>
            </plugin>
            ...
        </plugins>
    </build>
    ...
</project>

镜像标签配置是强制的,并且是容器注册表中的目标路径。现在,您可以使用单个命令将镜像构建到容器注册表:

mvn compile jib:build -DpathTo.image=registry.hub.docker.com/myuser/repo

如果希望使用 Gradle 构建和推送容器镜像,可以通过以下任一方式配置认证:

  • build.gradle 的插件配置中使用 tofrom 部分。

  • 使用 Docker 登录命令连接到注册表(将凭据存储在凭据助手或 ~/.docker/config.json 中)。

接下来,在您的 build.gradle 中添加插件:

plugins {
  id 'com.google.cloud.tools.jib' version '3.1.4'
}

并在终端窗口中调用以下命令:

gradle jib --image=registry.hub.docker.com/myuser/repo

为了简化使用 Jib 时的容器镜像定制,一些框架已将插件集成为依赖库。例如,Quarkus 提供了 quarkus-container-image-jib 扩展来个性化容器镜像构建过程。使用此扩展,我们可以重新访问 第四章 中的 Quarkus 示例,并使用以下 Maven 命令添加它:

mvn quarkus:add-extension -Dextensions="io.quarkus:quarkus-container-image-jib"

此外,您可以在 build.gradle 中的插件配置的 src/main/resources/application.properties 中自定义镜像详细信息。

quarkus.container-image.builder=jib ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)

quarkus.container-image.registry=quay.io ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
quarkus.container-image.group=repo ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
quarkus.container-image.name=demo ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)
quarkus.container-image.tag=1.0.0-SNAPSHOT ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)

1:#co_deploying_for_developers_CO1-1

用于构建(和推送)容器镜像的扩展。

2:#co_deploying_for_developers_CO1-2

要使用的容器注册表。

3:#co_deploying_for_developers_CO1-3

容器镜像将作为此组的一部分。

4:#co_deploying_for_developers_CO1-4

容器镜像的名称是可选的;如果未设置,则默认为应用程序名称。

5:#co_deploying_for_developers_CO1-5

容器镜像的标签也是可选的;如果未设置,则默认为应用程序版本。

最后,您可以构建并推送容器镜像:

mvn package -Dquarkus.container-image.push=true

在 第三章 中,您了解到保持容器镜像的小型化。容器镜像的大小影响编排平台从注册表中拉取镜像所需的时间。通常情况下,FROM 指令使用的基础镜像大小会影响您的容器镜像大小,而 Jib 允许您通过更改 baseImage 配置来控制它。此外,在使用 Jib 时,您还可以控制要暴露的端口或容器镜像的入口点。

提示

在执行 JDK 升级时,更改 JVM 基础镜像也很有帮助。此外,Quarkus 扩展支持自定义 JVM 基础镜像(quarkus.jib.base-jvm-image)和用于本机二进制构建的本机基础镜像(quarkus.jib.base-native-image)。

此节引用的代码示例可在 GitHub 上找到。

使用 Eclipse JKube 构建容器镜像

一种 Java 开发者可以使用的替代工具来将 Java 应用程序容器化,而无需编写 Dockerfile,即 Eclipse JKube。这个由 Eclipse Foundation 和 Red Hat 支持的社区项目可以帮助您构建容器镜像并与 Kubernetes 协作。该项目包含一个 Maven 插件,这是 Fabric8 Maven 插件 的重构和重新品牌版本。在撰写本章时,Gradle 插件可以进行技术预览,并计划未来提供支持。

要在项目中开始使用 Eclipse JKube Maven 插件,请将 Kubernetes Maven 插件添加到您的 pom.xml 中:

<plugin>
    <groupId>org.eclipse.jkube</groupId>
    <artifactId>kubernetes-maven-plugin</artifactId>
    <version>${jkube.version}</version>
</plugin>

让我们在来自 第四章 的示例 Spring Boot 应用程序中添加这个片段。

示例 8-1. Spring Boot 项目的 pom.xml 配置文件示例
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
    https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.0</version>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
        <spring-native.version>0.10.5</spring-native.version>
        <jkube.version>1.5.1</jkube.version>
        <jkube.docker.registry>registry.hub.docker.com</jkube.docker.registry> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
        <repository>myuser</repository> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
        <tag>${project.version}</tag> ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
        <jkube.generator.name> ${jkube.docker.registry}/${repository}/${project.name}:${tag} </jkube.generator.name>
    </properties>
    <dependencies> ... </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.eclipse.jkube</groupId>
                <artifactId>kubernetes-maven-plugin</artifactId>
                <version>${jkube.version}</version>
            </plugin> ... </plugins>
    </build> ... </project>

1

您可以为容器注册表属性提供默认值,并在构建时进行覆盖。

2

您可以为仓库属性提供默认值,并在构建时进行覆盖。

3

您可以为标签属性提供默认值,并在构建时进行覆盖。默认图像名称将是项目名称。

要为该应用程序生成一个容器镜像,请在命令行中运行以下命令:

mvn k8s:build

根据您使用的技术栈类型,JKube 会选择主观的默认设置,如基础镜像和手工制作的启动脚本。在这种情况下,JKube 使用当前本地 Docker 构建上下文来拉取和推送容器镜像。

此外,镜像名称由 Maven 属性 ${jkube.docker.registry}${repository}${project.name}${tag} 的值连接而成:registry.hub.docker.com/myuser/demo:0.0.1-SNAPSHOT

然而,为了将开发部分与操作方面分开,我们将定制这些详细信息,并在构建时进行覆盖。通过自定义属性 jkube.generator.name,您可以包含远程注册表、仓库、镜像名称和选择的标签:

<jkube.generator.name>
    ${jkube.docker.registry}/${repository}/${project.name}:${tag}
</jkube.generator.name>

现在,我们可以通过以下命令为远程容器注册表构建镜像:

mvn k8s:build -Djkube.docker.registry=quay.io -Drepository=repo -Dtag=0.0.1

如果您想要构建并推送图像到远程容器注册表,可以使用以下命令:

mvn k8s:build k8s:push -Djkube.docker.registry=quay.io \
      -Drepository=repo -Dtag=0.0.1

此命令构建图像 quay.io/repo/demo:0.0.1 并将其推送到相应的远程注册表。

注意

使用远程注册表时,您需要提供凭据。Eclipse JKube 将搜索这些位置以获取凭据:

  • 系统属性 jkube.docker.usernamejkube.docker.password

  • 插件配置中的 <authConfig> 部分,包含 <username><password> 元素

  • ~/.m2/settings.xml 中的 <server> 配置

  • 先前使用 Docker 登录到注册表(凭据存储在凭据助手或 ~/.docker/config.json 中)

  • OpenShift 配置位于 ~/.config/kube

您可以使用 Eclipse JKube Kubernetes Gradle 插件 的相同步骤构建和推送容器镜像。在这种情况下,您应在 build.gradle 中配置插件:

plugins {
  id 'org.eclipse.jkube.kubernetes' version '1.5.1'
}

在命令行上,您可以使用 gradle k8sBuild 构建容器镜像,并使用 gradle k8sPush 推送结果。

提示

您可以在插件配置中添加 k8s:watch 目标,以便在代码更改时自动重新创建镜像或将新的构件复制到正在运行的容器中。

部署到 Kubernetes

通过深入理解构建和推送容器镜像,您可以专注于运行容器。在处理分布式系统时,容器帮助您实现部署的独立性,并且可以将应用程序代码与故障隔离开来。

因为分布式系统可能会有多个微服务,您需要找出如何使用容器管理这些微服务。编排工具可以帮助您管理大量容器,通常提供以下功能:

  • 声明式系统配置

  • 容器供给和发现

  • 系统监控和崩溃恢复

  • 用于定义容器放置和性能规则的工具。

Kubernetes 是一个开源平台,自动化部署、扩展和管理容器化工作负载。使用 Kubernetes,您可以组织部署以便平台可以根据负载需求增加或删除实例。此外,当节点失败时,Kubernetes 可以替换和重新调度容器。

Kubernetes 的可移植性和可扩展性特性增加了其受欢迎程度,促进了社区贡献和供应商的支持。Kubernetes 成功支持越来越复杂的应用类别,继续推动企业向混合云和微服务的过渡。

使用 Kubernetes,您可以根据负载情况部署应用程序,如果负载增加,Kubernetes 将运行更多服务实例;如果负载减少,Kubernetes 将停止实例;如果现有实例失败,Kubernetes 将启动新实例。作为开发者,在部署到 Kubernetes 时,您需要访问 Kubernetes 集群。Kubernetes 集群由运行容器化应用程序的一组节点组成,如 图 8-1 所示。

Kubernetes 集群的组件

图 8-1. Kubernetes 组件(图片来源于 Kubernetes 文档

每个集群至少有一个工作节点,每个工作节点托管 Pod。在集群内,命名空间用于隔离资源组(包括 Pod)。Pod 是直接与正在运行的容器交互的组件,这些容器是从之前构建并推送到容器注册表的容器镜像实例化而来。

当您使用 Kubernetes 时,您使用一组对象,这些对象由系统验证并接受。要处理 Kubernetes 对象,您需要使用 Kubernetes API。现在可以通过使用可视化辅助工具、命令行界面或像 Dekorate 和 JKube 这样的 Java 插件来生成和部署 Kubernetes 清单来实现 Kubernetes 部署。

用于部署的本地设置

作为开发人员,您习惯于配置本地设置以实现应用程序功能。通常,此本地设置包括访问版本控制系统,并安装和配置以下内容:

  • 一个 JDK

  • Maven 或 Gradle

  • 如 IntelliJ IDEA、Eclipse 或 Visual Studio Code 等 IDE

  • 可选地,与您的代码集成的数据库或中间件

  • 用于构建、运行和推送容器镜像的一个或多个工具:Docker、Podman、Buildah、Jib、JKube 等。

  • Kubernetes 开发集群:minikubekindRed Hat CodeReady Containers。对于开发目的,Docker Desktop 还提供在 Docker 实例中本地运行的单节点 Kubernetes 集群。Rancher Desktop 是另一个可以帮助您在本地管理容器并运行 Kubernetes 的优秀工具。如果运行本地开发集群消耗过多资源,您可能更喜欢在远程 Kubernetes 集群中具有开发命名空间,或者使用像 Developer Sandbox for Red Hat OpenShift 这样已经预配好的集群。

在创建任何 Kubernetes 资源之前,让我们总结一些 Kubernetes 概念:

集群

一组节点,您可以指示 Kubernetes 在其中部署容器。

命名空间

一个 Kubernetes 对象,根据不同的权限负责隔离组的资源。

用户

与 Kubernetes API 的交互需要通过用户管理的认证形式。

上下文

包含 Kubernetes 集群、用户和命名空间的特定组合。

Kubelet

在每个集群节点上运行的主要代理程序,根据 pod 规范确保容器正在运行且健康。

Deployment

一个资源,指示 Kubernetes 创建或修改带有容器化应用程序的 pod 实例。

ReplicaSet

每次 Kubernetes 创建一个部署时,此资源会实例化一个 ReplicaSet 并委托给它计算 pod 数。

Service

一种将多个实例中不同的 pod 中的应用程序公开为网络服务的方式。

牢记这些概念,让我们看看如何生成 Kubernetes 对象并部署它们。

使用 Dekorate 生成 Kubernetes 清单

Dekorate 可以在编译时使用 Java 注解和标准 Java 框架配置机制生成 Kubernetes 清单。Table 8-1 显示了适用于 Quarkus、Spring Boot 或通用 Java 项目的 Dekorate Maven 依赖项。

表 8-1. 使用 Dekorate Maven 依赖项

框架 依赖项
Quarkus
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-kubernetes</artifactId>
</dependency>

|

Spring Boot
<dependency>
  <groupId>io.dekorate</groupId>
  <artifactId>kubernetes-spring-starter</artifactId>
  <version>2.7.0</version>
</dependency>

|

通用 Java 应用程序
<dependency>
    <groupId>io.dekorate</groupId>
    <artifactId>kubernetes-annotations</artifactId>
    <version>2.7.0</version>
</dependency>

|

让我们通过将 Dekorate 添加到 示例 8-1 来创建一些 Kubernetes 资源:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.0</version>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
        <spring-native.version>0.10.5</spring-native.version>
        <kubernetes-spring-starter.version>
              2.7.0
        </kubernetes-spring-starter.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>io.dekorate</groupId>
            <artifactId>kubernetes-spring-starter</artifactId>
            <version>${kubernetes-spring-starter.version}</version>
        </dependency>
        ...
    </dependencies>
    ...
</project>

如果未提供任何配置,Dekorate 将在 target/classes/META-INF/dekorate 下创建的清单中生成 Deployment 和 Service 资源。生成的 Service 类型为 ClusterIP,仅在 Kubernetes 集群内部提供应用程序。如果要使用云提供商的负载均衡器将服务外部公开,可以使用类型为 LoadBalancer 的 Service 资源,如 Kubernetes 文档 中所述。

当使用 Dekorate 时,您可以通过以下方式自定义生成的 Kubernetes 资源:

  • application.properies 中指定配置

  • @KubernetesApplication 注解添加到 DemoApplication 类中

为了避免基础设施和应用代码之间的紧耦合,我们通过以下方式自定义 src/main/resources/application.properties 中的 Service 资源:

dekorate.kubernetes.serviceType=LoadBalancer

要生成 Kubernetes 对象,可以如此打包应用程序:

mvn clean package

打包应用程序后,您会注意到在 target/classes/META-INF/dekorate 目录中创建的其他文件中,有两个文件命名为 kubernetes.jsonkubernetes.yml。这些清单中的任一个都可以用来部署到 Kubernetes:

---
apiVersion: apps/v1
kind: Deployment ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
metadata:
  annotations:
    app.dekorate.io/vcs-url: <<unknown>>
  labels:
    app.kubernetes.io/version: 0.0.1-SNAPSHOT ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
    app.kubernetes.io/name: demo ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
  name: demo ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/version: 0.0.1-SNAPSHOT ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
      app.kubernetes.io/name: demo ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
  template:
    metadata:
      annotations:
        app.dekorate.io/vcs-url: <<unknown>>
      labels:
        app.kubernetes.io/version: 0.0.1-SNAPSHOT ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
        app.kubernetes.io/name: demo ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
    spec:
      containers:
        - env:
            - name: KUBERNETES_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          image: repo/demo:0.0.1-SNAPSHOT ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)
          imagePullPolicy: IfNotPresent
          name: demo
          ports:
            - containerPort: 8080 ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)
              name: http
              protocol: TCP
---
apiVersion: v1
kind: Service ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/6.png)
metadata:
  annotations:
    app.dekorate.io/vcs-url: <<unknown>>
  labels:
    app.kubernetes.io/name: demo ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
    app.kubernetes.io/version: 0.0.1-SNAPSHOT ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
  name: demo
spec:
  ports:
    - name: http
      port: 80 ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/7.png)
      targetPort: 8080 ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)
  selector:
    app.kubernetes.io/name: demo
    app.kubernetes.io/version: 0.0.1-SNAPSHOT
  type: LoadBalancer ![8](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/8.png)

1

Deployment 为 Pods 和 ReplicaSets 提供声明性更新。

2

标签被选择器用来将 Service 与 Pods 连接,同时将 Deployment 的规范与 ReplicaSets 和 Pods 对齐。

3

Deployment 对象的名称。

4

Deployment 使用的容器镜像。

5

容器暴露的端口,服务所指向的端口。

6

Service 将运行在一组 Pods 上的应用程序作为网络服务公开。

7

用于服务传入流量的端口。

8

使用云提供商的负载均衡器将服务外部公开。

假设您以前已经登录到 Kubernetes 集群,您可以使用命令行界面进行部署:

kubectl apply -f target/classes/META-INF/dekorate/kubernetes.yml

因此,在应用清单之后,您可以通过 Kubernetes 使用外部 IP (LoadBalancer Ingress) 和端口访问应用程序。

使用 Eclipse JKube 生成和部署 Kubernetes 清单

Eclipse JKube 还可以在编译时生成和部署 Kubernetes/OpenShift 清单。除了创建 Kubernetes 描述符(YAML 文件)外,还可以通过以下方式调整输出:

  • XML 插件配置内的内联配置

  • 外部配置模板的部署描述符

“使用 Eclipse JKube 构建容器镜像” 探讨了使用 JKube 和 Docker 守护程序集成来构建容器镜像。我们将重用 Quarkus 示例代码从 “使用 Jib 管理容器镜像” 开始,通过 Eclipse JKube 和 Jib 生成和部署 Kubernetes 资源。

示例 8-2. Quarkus 项目的 pom.xml 配置文件
<?xml version="1.0"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example.demo</groupId>
    <artifactId>demo</artifactId>
    <name>demo</name>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <compiler-plugin.version>3.8.1</compiler-plugin.version>
        <maven.compiler.parameters>true</maven.compiler.parameters>
        <maven.compiler.target>11</maven.compiler.target>
        <maven.compiler.source>11</maven.compiler.source>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <quarkus-plugin.version>2.5.0.Final</quarkus-plugin.version>
        <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
        <quarkus.platform.group-id>io.quarkus</quarkus.platform.group-id>
        <quarkus.platform.version>2.5.0.Final</quarkus.platform.version>
        <surefire-plugin.version>3.0.0-M5</surefire-plugin.version>
        <jkube.version>1.5.1</jkube.version>
        <jkube.generator.name> ${quarkus.container-image.registry}/${quarkus.container-image.group}
            /${quarkus.container-image.name}:${quarkus.container-image.tag} ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
        </jkube.generator.name>
        <jkube.enricher.jkube-service.type> NodePort </jkube.enricher.jkube-service.type> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>${quarkus.platform.artifact-id}</artifactId>
                <version>${quarkus.platform.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-container-image-jib</artifactId>
        </dependency> ... </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.eclipse.jkube</groupId>
                <artifactId>kubernetes-maven-plugin</artifactId>
                <version>${jkube.version}</version>
                <configuration>
                    <buildStrategy>jib</buildStrategy> ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
                </configuration>
            </plugin> ... </plugins>
    </build> ... </project>

1

为了保持一致性,您可以在 JKube 镜像名称中重复使用 Quarkus 扩展属性。

2

在每个节点的 IP 上使用静态端口(NodePort)公开服务。

3

指定构建策略为 Jib。

现在我们可以调用容器镜像构建(k8s:build)并在一个命令中创建 Kubernetes 资源(k8s:resource):

mvn package k8s:build k8s:resource \
    -Dquarkus.container-image.registry=quay.io \
    -Dquarkus.container-image.group=repo \
    -Dquarkus.container-image.name=demo \
    -Dquarkus.container-image.tag=1.0.0-SNAPSHOT

下面的结构将出现在 target/classes/META-INF/jkube 下:

|-- kubernetes
|   |-- demo-deployment.yml
|   `-- demo-service.yml
`-- kubernetes.yml

kubernetes.yml 包含了部署和服务资源定义,而在 kubernetes 文件夹中,您可以将它们分开存放在两个不同的文件中。

正如我们为 Dekorate 清单所做的那样,我们可以通过命令行界面部署 kubernetes.yml

kubectl apply -f target/classes/META-INF/jkube/kubernetes.yml

或者您可以使用 JKube 插件的 k8s:apply Maven 目标来实现相同的结果:

mvn k8s:apply

此目标将搜索之前生成的文件并将其应用到连接的 Kubernetes 集群中。应用将通过集群 IP 和分配的节点端口可达。

此外,您可以通过更多的插件目标来模拟生成资源与 Kubernetes 集群之间的交互。表 8-2 列出了 Kubernetes Maven 插件中可用的其他目标。

表 8-2. Eclipse JKube 额外目标

目标 描述

|

k8s:log

从运行中的 Kubernetes 容器获取日志

|

k8s:debug

打开调试端口,以便您可以从 IDE 调试 Kubernetes 中部署的应用程序

|

k8s:watch

通过监视应用程序上下文自动部署您的应用程序

|

k8s:deploy

分叉安装目标并将生成的清单应用于 Kubernetes 集群

|

k8s:undeploy

删除使用 k8s:apply 应用的所有资源

现在您已经看到如何部署到 Kubernetes,让我们看看如何通过选择和实施部署策略来优化这一过程。

选择和实施部署策略

在 Kubernetes 中部署单个应用程序可以是一个简单的任务,当使用合适的工具时。作为开发者,我们还应该提前考虑并决定如何在不停机的情况下用新版本的微服务替换旧版本。

当您选择一个部署策略到 Kubernetes 时,您需要考虑建立这些配额:

  • 应用程序所需实例数

  • 最小健康运行实例

  • 最大实例数

理想情况是在尽可能短的时间内拥有所需的运行实例数量,同时使用最少的资源(CPU、内存)。但让我们尝试已经建立的方法并比较它们的性能。

使用Recreate策略进行一体化部署是在使用 Kubernetes 部署对象时最简单的方法:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: demo
  name: demo
spec:
  strategy:
    type: Recreate ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
  revisionHistoryLimit: 15 ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
  replicas: 4
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
      - image: quay.io/repo/demo:1.0.0-SNAPSHOT
        imagePullPolicy: IfNotPresent
        name: quarkus
        ports:
        - containerPort: 8080
          name: http
          protocol: TCP

1

部署策略是Recreate

2

您可以设置revisionHistoryLimit来指定您希望保留的此部署的旧 ReplicaSet 数量。默认情况下,Kubernetes 会存储最后 10 个 ReplicaSet。

每当在集群中应用前述规范时,Kubernetes 将关闭所有当前运行的 Pod 实例,一旦它们终止,将启动新的实例。我们不需要设置最小和最大实例数量,只需设置所需实例数(4)。

在这个例子中,Kubernetes 在执行更新后不会立即删除先前的 ReplicaSet。相反,它会保留带有副本计数为 0 的 ReplicaSet。如果部署引入了破坏系统稳定性的更改,我们可以通过选择旧的 ReplicaSets 中的一个回滚到以前的工作版本。

您可以通过运行以下命令了解之前的修订版本:

 kubectl rollout history deployment/demo

并通过以下方式回滚到先前的版本:

 kubectl rollout undo deployment/demo --to-revision=[revision-number]

尽管这种策略在内存和 CPU 消耗量方面是高效的,但在微服务不可用时引入了时间间隙。

另一个 Kubernetes 内置策略是RollingUpdate,其中当前运行的实例逐步被新实例替换:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: demo
  name: demo
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1 ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
      maxSurge: 3 ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
  replicas: 4 ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
      - image: quay.io/repo/demo:1.0.0-SNAPSHOT
        imagePullPolicy: IfNotPresent
        name: quarkus
        ports:
        - containerPort: 8080
          name: http
          protocol: TCP

1

执行部署时可以不可用的最大 Pod 数量

2

可以在期望的 Pod 数量上创建的最大 Pod 数量

3

Pod 的期望数量

通过关注不可用 Pod 的最大数量,这种策略可以安全地升级您的部署,而无需任何停机时间。但是,根据您的微服务启动时间,完全过渡到部署的新版本可能需要更长的时间。如果部署引入了破坏系统稳定性的更改,Kubernetes 将更新部署模板但将保留先前运行的 Pod。

注意

如果在对象spec中不填写策略,则滚动部署是 Kubernetes 的标准默认部署。

如果你的应用程序正在使用数据库,你应该考虑同时运行两个应用程序版本的影响。使用这种策略的另一个缺点是,在升级期间,将会存在新旧版本的应用程序混合。如果你想保持零停机并避免在生产中混合应用程序版本,请看一下蓝/绿部署技术。

蓝/绿部署是一种通过运行两个相同(生产)环境命名为蓝色和绿色来减少停机时间和失败风险的策略;参见图 8-2。当使用此部署策略时,新实例将不会为用户请求提供服务,直到所有实例都可用为止;在那一刻,所有旧实例立即变为不可用。你可以通过编排服务和路由请求来实现这一点。

蓝/绿部署策略

图 8-2. 蓝/绿策略

让我们看看如何使用标准 Kubernetes 对象来实现蓝/绿部署:

  1. 应用具有标签version: blue的微服务的蓝版本。我们将通过使用标签version的值来关联蓝部署的惯例:

    kubectl apply -f blue_deployment_sample.yml
    
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      creationTimestamp: null
      labels:
        app: demo
        version: blue
      name: demo-blue
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: demo
          version: blue
      template:
        metadata:
          creationTimestamp: null
          labels:
            app: demo
            version: blue
        spec:
          containers:
            - image: nginx:1.14.2
              name: nginx-demo
              imagePullPolicy: IfNotPresent
              ports:
                - containerPort: 80
              resources: {}
    
  2. 通过使用 Kubernetes Service 暴露此部署。之后,流量将从蓝版本提供:

    kubectl expose deployment demo-blue --selector="version=blue"
            --type=LoadBalancer
    
  3. 应用一个具有标签version: green的微服务的绿部署

    kubectl apply -f green_deployment_sample.yml
    
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      creationTimestamp: null
      labels:
        app: demo
        version: green
      name: demo-green
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: demo
          version: green
      template:
        metadata:
          creationTimestamp: null
          labels:
            app: demo
            version: green
        spec:
          containers:
            - image: nginx:1.14.2
              name: nginx-demo
              imagePullPolicy: IfNotPresent
              ports:
                - containerPort: 80
              resources: {}
    
  4. 通过修改 Service 对象将流量从蓝部署切换到绿部署:

    kubectl patch svc/demo -p '{"spec":{"selector":{"version":"green"}}}'
    
  5. 如果不再需要蓝部署,可以使用kubectl delete将其删除。

尽管这种部署策略更复杂,需要更多资源,但你可以缩短软件开发和用户反馈之间的时间。这种方法对于试验功能来说更少干扰;如果在部署后出现任何问题,你可以快速切换到之前的稳定版本。

提示

你可以使用更多与 Kubernetes 兼容的云原生工具来探索蓝/绿部署策略,比如IstioKnative

我们将看一下的最后一种策略是金丝雀部署。这是一种通过向小部分用户发布软件来减少风险并验证新系统功能的方法。执行金丝雀部署允许你尝试新版本的微服务,与应用程序的现有实例不发生任何替换的小用户群体。为了评估部署的行为(金丝雀和现有的),你应该在服务实例之上实现负载均衡器配置,并添加加权路由来选择将流量路由到每个资源的比例。

目前,通过添加额外的工具层(图 8-3)可以实现金丝雀策略。带有加权路由支持的 API 网关可以帮助你管理 API 端点,并决定向它们路由的流量量。像 Istio 这样的服务网格控制平面是与 Kubernetes 兼容的解决方案,可以帮助你控制网络上服务之间的通信和用户流量的百分比。

在 Istio 中加权流量路由

图 8-3. 在 Istio 中使用加权流量路由的金丝雀策略

如果你仍然在为选择部署机制而苦苦挣扎,请查看表 8-3,它总结了先前讨论的策略的特性。

表 8-3. 部署策略的特性

重新创建 滚动更新 蓝绿部署 金丝雀发布
Kubernetes 自带
发生停机时间
回滚流程 手动回滚到先前版本 停止部署并保留先前版本 切换流量到先前版本 删除金丝雀实例
流量控制
流量同时发送到旧版本和新版本

在 Kubernetes 中管理工作负载

运行在 Kubernetes 上的应用程序是一个工作负载。在集群中,你的工作负载将在一个或多个具有定义生命周期的 Pod 上运行。为了简化 Pod 的生命周期管理,Kubernetes 提供了几种内置的工作负载资源:

Deployment 和 ReplicaSet

帮助管理无状态应用程序的工作负载。

StatefulSet

可以让你以单个实例或复制集的形式运行有状态应用程序。

Job 和 CronJob

定义运行完成后停止的任务。在执行批处理活动时,这些类型的资源非常有用。作业是一次性任务,而 CronJob 按计划运行。

DaemonSet

可以帮助你定义影响整个节点的功能性 Pod。使用这种类型的资源调度工作负载是罕见的。

早期,我们生成并部署了包含 Deployment 规范的 Kubernetes 清单,通常这些微服务是无状态应用程序。但是如何预防依赖外部服务或将其数据持久化在数据库中的微服务失败?此外,随着微服务代码库的演进,如何使用公平份额的内存和 CPU?

设置健康检查

与分布式系统和云中工作的另一个好处是,微服务的独立性通常会刺激自动化部署。由于自动化部署可能每天多次发生,且在多个实例上,因此您需要一种验证应用程序是否按预期可用和运行的方法。系统中组件数量的增加会增加故障的可能性:死锁、主机不可用、硬件故障等。为了在问题扩散为停机之前检测问题,我们可以通过健康检查验证微服务的状态。

健康检查应跨整个系统,从应用程序代码到基础设施。基础设施可以使用应用程序健康检查来确定何时使用就绪探针提供流量或通过存活探针重新启动容器。您应该知道,存活探针并不总是在就绪探针成功后执行。当您的应用程序需要额外的时间来初始化时,您可以定义等待多少秒后执行探针,或使用启动探针来检查容器是否已启动。

在 Kubernetes 层面,kubelet 是使用存活性、就绪性和启动探针来评估容器状态的组件。 kubelet 使用就绪探针来检查容器何时准备好接受流量,并使用存活探针来知道何时重新启动它。您可以使用这三种机制之一来实现存活性、就绪性或启动探针:

  • 打开 TCP 套接字与容器的连接

  • 向暴露 API 端点的容器化应用程序发出 HTTP 请求

  • 如果你的应用程序使用不同于 HTTP 或 TCP 的协议,则在容器内运行命令

注意

使用 Kubernetes v1.23,gRPC 健康探针机制作为一项 alpha 功能可用。请注意关注 Kubernetes 健康探针文档 的演变。

实施健康检查的最简单方法是定期评估正在运行的应用程序,通过向其一些 API 端点发送请求来确定系统的健康状况。通常,这些健康端点是 HTTP GET 或 HEAD 请求,不会改变系统状态并执行轻量级任务。您可以在 RESTful API 中定义一个 /health 端点来检查微服务的内部状态,或者您可以使用框架兼容的依赖项。

执行器 模块为运行在 Spring 环境中的应用程序提供有用的见解。执行器具有健康检查和通过 HTTP 和 Java 管理扩展(JMX)暴露多个端点来收集指标的功能。

您可以将执行器模块作为 Maven 或 Gradle 依赖项添加到您的 Spring Boot 项目中(参见 表 8-4),并可以访问 /actuator/health 的默认健康端点。

表 8-4. Actuator 作为 Maven 或 Gradle 依赖项示例

构建工具 定义
Maven
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

|

Gradle
dependencies {
  compile("org.springframework.boot:spring-boot-starter-actuator")
}

|

使用 Actuator,你可以使用健康指标检查各个组件的健康状态,或者使用组合健康贡献者进行组合健康检查。你可以使用多个预定义的健康指标,包括 DataSourceHealthIndicatorMongoHealthIndicatorRedisHealthIndicatorCassandraHealthIndicator。这些实现了 HealthIndicator 接口,使你能够检查该组件的健康状态。例如,如果你的应用程序使用数据库来持久化数据,当 Spring Boot 检测到数据源时,数据库健康指标将会自动添加。健康检查包括创建到数据库的连接以执行简单查询。

虽然使用内置的健康指标可以节省开发时间,但有时候你需要调查整合在一起的依赖系统的健康情况。Spring Boot 将在应用程序上下文中的 /actuator/health 端点下汇总所有找到的健康指标。然而,如果某个依赖系统的健康检查失败,组合探针将会失败。对于这种情况,你应该考虑在一个 Spring bean 中实现 CompositeHealthContributor 接口,或者通过提供回退响应来处理潜在的失败。

MicroProfile Health 模块允许服务报告其健康状态,并将整体健康状态发布到定义的端点。Quarkus 应用程序可以使用 SmallRye Health 扩展,这是 Eclipse MicroProfile 健康检查规范的实现。你可以通过 表 8-5 中的片段将该扩展添加到你的 Maven 或 Gradle 配置中。

表 8-5. SmallRye Health 作为 Maven 或 Gradle 依赖项示例

构建工具 定义
Maven
<dependency>
   <groupId>io.quarkus</groupId>
   <artifactId>quarkus-smallrye-health</artifactId>
</dependency>

|

Gradle
dependencies {
    implementation 'io.quarkus:quarkus-smallrye-health'
}

|

应用程序中的所有健康检查过程都积累在 /q/health REST 端点中。一些 Quarkus 扩展提供默认的健康检查。这意味着扩展可以自动注册其健康检查。

例如,在使用 Quarkus 数据源时,quarkus-agroal 扩展会自动注册一个就绪状态健康检查来验证数据源。你可以通过属性 quarkus.health.extensions.enabled 禁用扩展健康检查的自动注册。

当您调查依赖系统的健康状况时,您可以通过实现 org.eclipse.microprofile.health.HealthCheck 来定义自己的健康检查,并使用 @Liveness@Readiness@Startup 来区分每个检查的角色。 复合健康检查会检查聚合在一起的依赖系统的条件。 然而,如果其中一个依赖系统失败,这种方法是适得其反的。 更积极的策略涉及提供备用响应并监控显示应用程序健康状况的一组指标。 这些更有用,因为它们提供关于系统健康恶化的早期通知,为我们提供采取缓解措施的时间。

提示

除了在添加特定扩展时的自动就绪探针外,Quarkus 还为您提供了一些用于检查各种组件状态的健康检查实现:

  • SocketHealthCheck 使用 socket 检查主机是否可达。

  • UrlHealthCheck 使用 HTTP URL 连接检查主机是否可达。

  • InetAddressHealthCheck 使用 InetAddress.isReachable 方法检查主机是否可达。

在应用级别使用 REST 端点实现健康检查时,您可能会通过探针使用 HTTP 请求调用这些端点。 Kubernetes 探针会查阅这些端点以确定容器的健康状况。 探针具有配置参数来控制其行为,包括以下内容:

  • 多久执行一次探测(periodSeconds)。

  • 启动容器后等待多少秒后开始探测(initialDelaySeconds)。

  • 探针被视为失败的秒数(timeoutSeconds)之后的秒数。

  • 探针可以放弃之前失败的次数(failureThreshold)。

  • 探针被视为成功后的最小连续成功次数(successThreshold)。

我们先前用于生成 Kubernetes 清单的工具(Dekorate 和 Eclipse JKube)可以帮助您开始使用健康探针。 例如,让我们将 Actuator 依赖项添加到 Spring Boot 项目中,并使用以下方式打包应用程序:

mvn clean package

来自 target/classes/dekorate/ 的 Kubernetes 清单文件将包含健康探针的规范:

---
apiVersion: v1
kind: Service
#[...]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    app.dekorate.io/vcs-url: <<unknown>>
  labels:
    app.kubernetes.io/version: 0.0.1-SNAPSHOT
    app.kubernetes.io/name: demo
  name: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/version: 0.0.1-SNAPSHOT
      app.kubernetes.io/name: demo
  template:
    metadata:
      annotations:
        app.dekorate.io/vcs-url: <<unknown>>
      labels:
        app.kubernetes.io/version: 0.0.1-SNAPSHOT
        app.kubernetes.io/name: demo
    spec:
      containers:
        - env:
            - name: KUBERNETES_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          image: repo/demo:0.0.1-SNAPSHOT
          imagePullPolicy: IfNotPresent
          livenessProbe: ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
            failureThreshold: 3 ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
            httpGet: ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
              path: /actuator/info
              port: 8080
              scheme: HTTP
            initialDelaySeconds: 0 ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)
            periodSeconds: 30 ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)
            successThreshold: 1 ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/6.png)
            timeoutSeconds: 10 ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/7.png)
          name: demo
          ports:
            - containerPort: 8080
              name: http
              protocol: TCP
          readinessProbe: ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
            failureThreshold: 3 ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
            httpGet: ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)
              path: /actuator/health
              port: 8080
              scheme: HTTP
            initialDelaySeconds: 0 ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)
            periodSeconds: 30 ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/5.png)
            successThreshold: 1 ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/6.png)
            timeoutSeconds: 10 ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/7.png)

1

就容器规范而言,声明就绪和存活探针。

2

探针在放弃之前可以失败三次。

3

探针应该对容器进行 HTTP GET 请求。

4

等待容器启动后 0 秒开始探测。

5

每 30 秒执行一次探测。

6

在失败后,探针被视为成功的最小连续成功次数。

7

10 秒后,探测被视为失败。

提示

根据您使用的技术堆栈不同,Eclipse JKube 提供了一系列增强器,可帮助您调整健康检查。

现在您已经使用应用程序健康检查确保系统正常运行,我们可以进一步调整容器化应用程序的资源配额。

调整资源配额

一种常见做法是让多个用户或团队共享一个具有固定节点数量的集群。为了为每个部署的应用程序提供公平的资源份额,集群管理员会建立一个 ResourceQuota 对象。此对象提供了限制命名空间内资源消耗的约束。

当您为 Pod 定义规范时,可以指定每个容器需要的资源量。请求(Requests)定义容器需要的最小资源量,而限制(Limits)定义容器可以消耗的最大资源量。Kubelet 会强制执行这些限制,以确保运行中的容器遵循这些规定。

对于容器来说,常见的资源是 CPU 和内存。对于 Pod 的每个容器,您可以按以下方式定义它们:

  • spec.containers[].resources.limits.cpu

  • spec.containers[].resources.limits.memory

  • spec.containers[].resources.requests.cpu

  • spec.containers[].resources.requests.memory

在 Kubernetes 中,CPU 以毫核(millicores 或 millicpu)的值分配,而内存以字节为单位。Kubelet 会从您的 Pod 中收集诸如 CPU 和内存之类的指标,并可以使用 Metrics Server 进行检查。

当您的容器开始竞争资源时,应根据限制和请求仔细划分 CPU 和内存。为了实现这一点,您需要以下内容:

  • 一种用于为应用程序生成流量的工具或实践。对于本地开发目的,您可以从诸如 heyApache JMeter 这样的工具开始。

  • 一种收集指标并决定如何为 CPU 和内存设置请求和限制的工具或实践。例如,在本地的 Minikube 安装中,您可以启用 metrics-server add-on

接下来,您可以将资源限制和请求添加到现有容器规范中。如果您使用 Dekorate 并在应用程序配置级别定义它们,则可以生成它们。例如,在 Quarkus 的情况下,您可以添加包含 Dekorate 的 Kubernetes 扩展:

mvn quarkus:add-extension -Dextensions="io.quarkus:quarkus-kubernetes"

并在 src/main/resources/application.properties 中进行配置:

quarkus.kubernetes.resources.limits.cpu=200m
quarkus.kubernetes.resources.limits.memory=230Mi
quarkus.kubernetes.resources.requests.cpu=100m
quarkus.kubernetes.resources.requests.memory=115Mi

这些配置可以在打包应用程序时进行定制。在运行 mvn clean package 后,请注意新生成的 Deployment 对象包含资源规格:

apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    app.quarkus.io/build-timestamp: 2021-12-11 - 16:51:44 +0000
  labels:
    app.kubernetes.io/version: 1.0.0-SNAPSHOT
    app.kubernetes.io/name: demo
  name: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/version: 1.0.0-SNAPSHOT
      app.kubernetes.io/name: demo
  template:
    metadata:
      annotations:
        app.quarkus.io/build-timestamp: 2021-12-11 - 16:51:44 +0000
      labels:
        app.kubernetes.io/version: 1.0.0-SNAPSHOT
        app.kubernetes.io/name: demo
    spec:
      containers:
        - env:
            - name: KUBERNETES_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          image: quay.io/repo/demo:1.0.0-SNAPSHOT
          imagePullPolicy: Always
          name: demo
          resources:
            limits:
              cpu: 200m ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
              memory: 230Mi ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
            requests:
              cpu: 100m ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
              memory: 115Mi ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)

1

容器的最大资源限制为 200 毫核(m)和 230 Mebibytes(MiB)。

2

容器可以请求最低 100 m 和 115 MiB。

注意

如果容器指定了内存限制但未指定内存请求,则 Kubernetes 会自动分配与限制匹配的内存请求。如果容器指定了 CPU 限制但未指定 CPU 请求,则 Kubernetes 会自动分配与限制匹配的 CPU 请求。

与持久化数据集合一起工作

微服务的一个基本原则是每个服务管理自己的数据。如果服务共享相同的底层数据模式,则服务之间可能会发生意外耦合,从而危及独立部署。

如果你正在使用 NoSQL 数据库,比如 CouchDB 或 MongoDB,请不要担心数据库的变化,因为可以从应用程序代码中进行数据结构的修改。

另一方面,如果您正在使用标准的 SQL 数据库,您可以使用类似于 FlywayLiquibase 的工具来处理模式更改。这些工具可以帮助您生成迁移脚本,并跟踪哪些脚本在数据库中运行,哪些尚未应用。当调用任何这些迁移工具时,它将扫描可用的迁移脚本,识别尚未在特定数据库上运行的脚本,然后执行这些脚本。

在从 “选择并实施部署策略” 中探索选项时,您应该考虑以下内容:

  • 部署阶段使用的应用程序版本必须与数据库模式版本相匹配。

  • 确保您的容器化应用程序的前一个工作版本与之前的模式兼容。

  • 更改列的数据类型需要转换按照旧列定义存储的所有值。

  • 重命名列、表或视图是不兼容的操作,除非使用触发器或编程化迁移脚本。

通过将应用程序部署与应用迁移脚本分离,您可以独立管理您的微服务。大多数情况下,云服务提供商作为其云服务的一部分提供几种数据源。如果您正在寻找无需管理和维护底层层的数据库解决方案,这些类型的提供可能是您的工作负载的正确选择。然而,还要考虑在使用托管数据库服务时如何保护、管理和安全敏感数据。

数据库是否应该在 Kubernetes 中运行?这个问题的答案取决于 Kubernetes 的管理工作负载和流量的方式与维护数据库的操作步骤如何一致。因为维护数据库需要更复杂的操作序列,Kubernetes 社区通过实现运算符来解决这些挑战,这些运算符包含在 Kubernetes 中运行数据库所需的逻辑域和操作运行簿。OperatorHub.io 提供了一个庞大的运算符列表。

监控、日志记录和跟踪的最佳实践

到目前为止,我们一直致力于使容器化应用程序运行。在您的本地计算机上,您是您工作的唯一终端用户,但您的应用程序将在生产环境中面对世界的其余部分。为了使您的应用程序与所有终端用户的期望保持一致,您应该观察其在不同条件和环境实例下的演变。

近年来,“可观测性”这个术语在 IT 行业变得流行,但很有可能您已经在开发“可观测”的 Java 应用程序。可观测性是基于系统生成的遥测数据(如日志、度量和跟踪)来衡量系统当前状态的能力。如果您已经实施了审计、异常处理或事件日志记录,那么您已经开始观察应用程序的行为。此外,为了为您的分布式系统构建可观测性,您可能会使用不同的工具来实现监控、日志记录和跟踪实践。

注意

无论使用何种工具实现,您和您的团队负责的应用程序、网络和基础设施都应该进行观察。

应用程序和底层基础设施可以生成有用的度量、日志和跟踪信息,以正确观察系统。如 图 8-4 所示,收集这些遥测数据有助于可视化系统状态,并在系统的某一部分表现不佳时触发通知。

可观测性概览

图 8-4. 从应用程序和基础设施中收集度量、日志和跟踪信息

警报帮助您确认意外情况,并在意外情况再次发生时实施恢复机制。您可以使用通知的分发来识别系统正常工作流程中的模式。这种模式进一步帮助您自动化恢复机制,并在收到警报时使用它。

由于可观测性可以衡量分布式系统的状态,您可以将其作为修复微服务故障状态的输入;参见 图 8-5。 Kubernetes 具有内置的自我修复机制,其中包括重新启动失败的容器,处置不健康的容器,或者不将流量路由到尚未准备好提供流量的 Pod。在节点级别,控制平面监视工作节点的状态。一些自动化恢复机制的做法涉及通过利用 Job 和 DaemonSet 资源扩展 Kubernetes 自我修复机制。例如,您可以使用 DaemonSet 在每个工作节点上运行一个节点监控守护程序,而 Job 则创建一个或多个 Pod,并重试执行这些 Pod 直到指定数量成功终止。

观察到自动恢复

图 8-5. 从观察到自动恢复的改进

当流量激增时,可观性还帮助您测量系统状态。响应延迟的应用程序会引起终端用户的不满。在这种情况下,您应该调查如何扩展您的容器化应用程序。此外,自动缩放消除了手动响应需要新资源和实例的流量激增的必要性,它会自动更改它们的活动数量。

在 Kubernetes 中,HorizontalPodAutoscaler (HPA) 资源会自动更新工作负载资源,如 Deployment,以自动缩放工作负载以匹配需求。HorizontalPodAutoscaler 资源通过部署更多的 Pods 来响应增加的负载。如果负载减少且 Pod 数量高于最小配置,则 HorizontalPodAutoscaler 要求 Deployment 资源进行缩减。

正如 Kubernetes 文档 中所解释的,HorizontalPodAutoscaler 算法使用期望度量值与当前度量值的比率:

wantedReplicas = ceil[currentReplicas * (currentMetricValue / wantedMetricValue)]

为了演示在设置 HorizontalPodAutoscaler 资源时前述算法的工作原理,让我们重用来自 “调整资源配额” 的示例,在该示例中我们调整了资源配额:

quarkus.kubernetes.resources.limits.cpu=200m
quarkus.kubernetes.resources.limits.memory=230Mi
quarkus.kubernetes.resources.requests.cpu=100m
quarkus.kubernetes.resources.requests.memory=115Mi

每个使用上述配置的 Pod 可以请求最小 100 m 的 CPU。您可以使用以下命令设置 HorizontalPodAutoscaler,以保持此部署中所有 Pods 的平均 CPU 利用率为 80%:

kubectl autoscale deployment demo --cpu-percent=80 --min=1 --max=10

假设当前 CPU 的度量值为 320 m,期望值为 160 m,所需的副本数为 320 / 160 = 2.0。基于 HorizontalPodAutoscaler 配置,Deployment 更新 ReplicaSet,然后 ReplicaSet 添加 Pod 以匹配工作负载需求。如果当前 CPU 的度量值降至 120 m,则所需的副本数将为 120 / 160 = 0.75,并且会逐渐缩减到一个副本。

使用 Kubernetes 进行缩放的另一种选项是使用垂直缩放,这意味着通过为已运行的 Pods 分配更多资源来匹配工作负载。VerticalPodAutoscaler (VPA) 需要被安装并启用以进一步使用其策略。为了避免在您的 Pods 上出现未定义的行为,请不要同时使用 VerticalPodAutoscaler 和 HorizontalPodAutoscaler 来调整资源的 CPU 或内存。

让我们来看一些监控、日志记录和跟踪建议,以更好地理解在部署、扩展和维护容器化应用程序时的可观性。

监控

您可以使用监控来实时观察系统。通常,这种做法涉及设置一个技术解决方案,该解决方案可以收集日志和预定义的一组指标,如 图 8-6 所示。

拉取和查询指标

图 8-6. 拉取和查询指标

度量指标 是系统属性随时间变化的数值,例如可用的最大 Java 堆内存或发生的垃圾收集总数。表 8-6 显示了在监视系统时哪些指标可以帮助您。

表 8-6. 指标的一般类型

Name Description
计数器 基于递增整数的累计值
计时器 测量定时事件的计数和所有定时事件的总时间
计量表 一个可以任意上下波动的单一数值
直方图 测量数据流中值的分布
计量器 表示事件集发生速率

一些流行的 Java 库用于处理指标,包括 MicroProfile Metrics、Spring Boot Actuator 和 Micrometer。为了更好地了解系统行为,您可以使用诸如Prometheus之类的工具收集和查询这些指标。

为了举例说明,我们将重新使用示例 8-1,将其指标暴露在/actuator/prometheus下,并通过使用 Eclipse JKube 生成容器镜像和 Kubernetes 资源将其发送到 Prometheus。

让我们从添加 Micrometer 注册表依赖项开始,特别是启用 Prometheus 支持:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
    <scope>runtime</scope>
</dependency>

接下来,您需要通过将此行添加到src/main/resources/application.properties来指示 Spring Boot 的 Actuator 应该公开哪些端点:

management.endpoints.web.exposure.include=health,info,prometheus

Spring Boot 应用程序在/actuator/prometheus下公开指标。与 JVM 相关的指标也可以在/actuator/prometheus下找到,例如jvm.gc.pause,用于测量垃圾收集暂停时间。为了进一步在容器和 Kubernetes 资源级别公开这些指标,我们可以通过以下方式自定义 Eclipse JKube 设置:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
    https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <!--[...]-->
    <build>
        <plugins>
            <plugin>
                <groupId>org.eclipse.jkube</groupId>
                <artifactId>kubernetes-maven-plugin</artifactId>
                <version>${jkube.version}</version>
                <executions>
                    <execution>
                        <id>resources</id>
                        <phase>process-resources</phase>
                        <goals>
                            <goal>resource</goal> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <generator> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
                        <config>
                            <spring-boot>
                                <prometheusPort> ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png) 9779 </prometheusPort>
                            </spring-boot>
                        </config>
                    </generator>
                    <enricher> ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/4.png)
                        <config>
                            <jkube-prometheus>
                                <prometheusPort> ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png) 9779 </prometheusPort>
                            </jkube-prometheus>
                        </config>
                    </enricher>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

1

使用k8s:resource目标执行此配置。

2

调整生成的 Docker 镜像以公开 Prometheus 端口。

3

在容器镜像级别公开 9779 端口,并在 Kubernetes 资源注释中进行配置。

4

生成适用于 Spring Boot 应用程序的 Kubernetes 资源。

要构建容器镜像并生成 Kubernetes 资源,请运行以下命令:

mvn clean package k8s:build k8s:resource \
    -Djkube.docker.registry=quay.io \
    -Drepository=repo \
    -Dtag=0.0.1

生成的 Kubernetes 资源位于target/classes/META-INF/jkube/kubernetes.yml,其中包含控制度量收集过程的 Prometheus 注释:

apiVersion: v1
kind: List
items:
- apiVersion: v1
  kind: Service
  metadata:
    annotations:
      prometheus.io/path: /metrics
      prometheus.io/port: "9779"
      prometheus.io/scrape: "true"

一旦部署生成的资源,您可以使用自定义 Prometheus 查询(PromQL)来查询不同的指标。例如,您可以选择jvm.gc.pause指标并运行以下 PromQL 查询来检查垃圾收集的平均停顿时间:

avg(rate(jvm_gc_pause_seconds_sum[1m])) by (cause)

在生成和捕获指标时,应遵循几个最佳实践:

  • 由于指标可以在应用程序和基础设施级别定义,请让团队成员共同定义这些指标。

  • 始终公开内部 JVM 指标,例如线程数、CPU 使用率、垃圾收集器运行频率、堆和非堆内存使用情况。

  • 努力为影响非功能需求的应用程序特定实现创建指标。例如,缓存统计信息如大小、命中次数和条目生存时间可以在评估功能性能时提供洞察力。

  • 定制可以支持业务人员使用的关键绩效指标(KPI)的指标。例如,使用新功能的终端用户数量是可以通过软件指标证实的 KPI。

  • 测量并公开系统内发生的错误和异常的详细信息。稍后可以使用这些详细信息来建立错误模式,从而进行改进。

记录

在 Java 应用程序级别,开发人员使用日志记录异常情况。日志有助于获取具有附加上下文信息的洞察力,并可以补充现有的指标。在记录日志时,有三种格式可用:纯文本、JSON 或 XML 以及二进制。

除了 Java 语言内置的日志之外,还有几个日志框架可以帮助您完成此任务:Simple Logging Facade for Java (SLF4J)Apache Log4j 2。一些日志记录最佳实践包括以下内容:

  • 谨慎行事;只记录与系统特定功能相关的详细信息。

  • 在日志消息中写入有意义的信息,以帮助您和同事排除未来的问题。

  • 使用正确的日志级别:TRACE用于捕获精细化洞察、DEBUG用于故障排除时有用的语句、INFO用于一般信息、WARNERROR用于标识可能需要采取行动的事件。

  • 确保使用守卫子句或 Lambda 表达式在相应的日志级别已启用时记录消息。

  • 通过可以在容器运行时设置的变量来自定义日志级别。

  • 设置适当的权限,以确定日志文件所在的位置。

  • 自定义日志布局以具有区域特定的格式。

  • 在记录日志时保护敏感数据。例如,记录个人可识别信息(PII)不仅可能导致合规性违规,还可能导致安全漏洞。

  • 定期旋转日志以防止日志文件过大或自动丢弃它们。容器和 Pod 日志默认是瞬时的。这意味着当 Pod 被删除、崩溃或在不同节点上调度时,容器日志就会消失。但是您可以异步地将日志流式传输到集中存储或服务,并在本地保留一定数量的旋转日志文件。

追踪

在分布式系统中,一个请求遍历多个组件。追踪帮助您捕获关于请求流程中的元数据和时序详细信息,以便识别慢速交易或故障发生的位置。

对于开发者来说,找到合适的工具来捕获追踪数据可能是具有挑战性的。专有代理可以帮助您完成这项工作,但是您应该考虑与供应商中立、开放标准如OpenCensusOpenTracing对齐的解决方案。许多开发者发现选择最适合应用程序并在各种供应商和项目中运行的选项很困难,因此 OpenTracing 和 OpenCensus 项目合并并形成了另一个CNCF孵化项目,名为OpenTelemetry。这一集合工具、API 和 SDK 标准化了收集和传输指标、日志和追踪数据的方式。OpenTelemetry 追踪规范定义了以下术语:

追踪

单个事务请求在通过分布式系统时使用其他服务和资源。

跨度

代表工作流片段的命名的定时操作。一个追踪包含多个跨度。

属性

可以用来查询、过滤和理解追踪数据的键/值对。

行李项

跨越进程边界的键/值对。

上下文传播

追踪、指标和行李共享的通用子系统。开发者可以通过使用属性、日志和行李项向一个跨度传递额外的上下文信息。

图 8-7 说明了一个从微服务蓝开始并遍历微服务紫和绿的事务追踪。该追踪包含三个跨度,并在紫色和绿色跨度上设置了属性。

分布式追踪示例

图 8-7. 分布式追踪示例

为了举例说明如何同时包含指标和追踪数据,我们将通过追踪对/greeting端点的请求并检测返回响应所花费的时间来增强示例 8-2。

接下来,让我们将指标导出到 Prometheus,并通过添加以下 Quarkus 扩展来进一步支持 OpenTelemetry:

mvn quarkus:add-extension \
    -Dextensions="quarkus-micrometer-registry-prometheus,
 quarkus-opentelemetry-exporter-otlp"

接下来,让我们通过添加以下内容来自定义发送跨度的端点:

custom.host = ${exporter.host:localhost} ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
quarkus.kubernetes.env.vars.otlp-exporter=${custom.host:localhost} ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
quarkus.opentelemetry.tracer.exporter.otlp.endpoint=http://${custom.host}:4317 ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/3.png)

1

将主机定义为可以参数化的配置。端点的主机的默认值为localhost,但您可以通过-Dexporter.host进行覆盖:mvn package -Dexporter.host=myhost

2

在编译时,已经存在于项目中的quarkus-kubernetes扩展将考虑这个环境变量,并自动生成 Kubernetes 资源的配置。配置重用custom.host的值。

3

gRPC 端点用于发送跨度,重用先前的主机定义。配置重用custom.host的值。

要测量发送到/greeting端点的请求持续时间,我们将用@Timed进行注释,并通过定制Span来进行其追踪,带有两个属性:

package com.example.demo;

import io.micrometer.core.annotation.Timed;
import io.opentelemetry.api.trace.*;
import io.opentelemetry.context.Context;

import javax.ws.rs.*;
import java.util.logging.Logger;
import javax.ws.rs.core.MediaType;

@Path("/greeting")
public class GreetingResource {
    private static final String template = "Hello, %s!";

    private final static Logger log;

    static {
        log = Logger.getLogger(GreetingResource.class.getName());
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Timed(value="custom")
    public Greeting greeting(@QueryParam("name") String name) {
        pause();
        return new Greeting(String.format(template, name));
    }

    private void pause() {
        Span span = Span.fromContext(Context.current())
                .setAttribute("pause", "start"); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/1.png)
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            span.setStatus(StatusCode.ERROR, "Execution was interrupted");
            span.setAttribute("unexpected.pause", "exception");
            span.recordException(e); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/dop-tl-java-dev/img/2.png)
            log.severe("Thread interrupted");
        }
    }
}

1

属性设置为在逻辑开始时跟踪。

2

记录异常后,设置属性以跟踪异常情况。

鉴于引入的更改,您可以重新构建和推送容器映像,并使用编译时生成的 Kubernetes 资源进行部署,如下所示:

mvn package -Dquarkus.container-image.build=true \
    -Dquarkus.container-image.push=true \
    -Dquarkus.kubernetes.deploy=true

要进行端到端分布式追踪,您可以使用像Jaeger这样的工具(图 8-8)。这个顶级CNCF 项目可以轻松集成 Kubernetes。您可以通过 Jaeger 端点设置quarkus​.opentele⁠metry.tracer.exporter.otlp.endpoint的值。在 Jaeger UI 中,您可以使用pause标签搜索追踪。

使用 Jaeger 按标签过滤追踪

图 8-8. 使用 Jaeger 按标签过滤追踪

此外,您还可以观察生成异常的请求,如下所示:

  1. 在 Jaeger UI 中搜索具有error=trueunexpec⁠ted​.pause=exception标签的追踪。

  2. 在 Prometheus 查询中使用名为customTimer,如下所示:

    avg(rate(custom_seconds_sum[1m])) by (exception)
    
  3. 检查日志以获取消息Thread interrupted

这里有一些追踪的推荐实践:

  • 对追踪进行端到端的仪表化,意味着将追踪头部转发到所有下游服务、数据存储或中间件,这些都是系统的一部分。

  • 报告与请求速率、错误及其持续时间相关的度量。在 SRE 领域,使用率、错误、持续时间(RED)方法很受欢迎,并侧重于仪表化:请求吞吐量、请求错误率、延迟或响应时间。

  • 如果您为自定义追踪跨度添加仪表数据,请避免使用大量元数据。

  • 当寻找适用于 Java 的追踪解决方案时,请查看Java 中的 OpenTelemetry 实现的语言特定实现。

在设计可观察性系统时,请记住您的度量和日志应可供以后分析。因此,无论部署在何处,始终要有能够可靠捕获和存储度量和日志数据的工具和实践。

高可用性和地理分布

在开发软件系统时,您可能收到了一个非功能性要求,指出您的应用程序应该全天候可用。在行业文献中,可用性指的是系统在给定时间内运行的概率;这通常以每年的正常运行时间百分比表示。

高可用性(HA)是系统在规定时间内连续工作而无故障的能力。作为开发人员,我们创建软件的目的是始终为最终用户提供可用性,但是诸如停电、网络故障和配置不足的环境等外部因素可能影响消费者接收到的服务质量。

小型容器镜像和成功部署到 Kubernetes 是将应用程序可用于 Kubernetes 的第一步。例如,假设您必须将工作节点升级到较新的 Kubernetes 版本。此操作包括节点必须在使用最新 Kubernetes 版本之前拉取所有容器。每个节点拉取容器所需的时间越长,集群按预期工作的时间就越长。

“选择和实施部署策略” 中解释了不同的部署策略,因为停机时间、流量在部署版本之间的路由以及回滚过程会影响可用性。在部署失败时,快速回滚过程可以避免用户的不适、节省时间和计算资源。此外,您希望您的系统具有高度可用的状态,您为容器化应用程序定义健康检查和调整资源的方式会影响其在规定时间内连续工作而无故障的性能。最终,通过观察系统的行为,例如日志、度量和跟踪,您可以通过调整健康检查、资源消耗和部署来优化您的系统。

可用性通常定义为一年中正常运行时间的百分比。表 8-7 显示了给定可用性百分比与每年停机时间的对应关系。该表使用一年 365 天,为保持一致性,所有时间都四舍五入到两位小数。

表 8-7. 将某个可用性百分比与每年停机时间连接起来

可用性百分比 年度停机时间
90% 36.5 天
95% 18.25 天
99% 3.65 天
99.9% 8.76 小时
99.95% 4.38 小时
99.99% 52.56 分钟
99.999% 5.25 分钟
99.9999% 31.53 秒

如今,服务提供商使用服务级别指标(SLIs)来衡量由服务级别目标(SLO)设定的目标。SLO 是服务提供商向客户作出的个别承诺。您可以通过将 表 8-7 中的百分比作为 SLO 的一部分来设置可用性值。工具如 PrometheusGrafana 可以通过整合 SLO、查询度量和在目标受到威胁时发出警报来帮助您计算应用程序的性能。

要创建高可用系统,可靠性工程提供了系统设计的三个原则:

  • 消除应用程序、网络和基础设施级别的单点故障。因为甚至您内部编写应用程序的方式有时也可能产生故障,所以您应该正确测试每个软件组件。可观测性和出色的部署策略有助于消除系统中可能的故障。

  • 当系统出现关键情况时,监控和警报有助于发现故障。

  • 当发生故障时,确保可以从一个组件有效过渡到另一个组件。在部署问题时进行高效的回滚流程,Kubernetes 自我修复机制以及 Kubernetes 资源之间的平滑流量路由都有助于解决此问题。

应对故障的良好计划包括遵循前述原则,并使用几种最佳实践来实施:

  • 执行数据备份、恢复和复制。

  • 设置网络负载均衡以在接收到应用程序关键功能的增加工作负载时有效分发流量。负载平衡帮助您消除应用程序级别的单点故障,同时利用可用的网络和基础设施。

  • 针对可能影响系统的自然灾害,将系统部署在多个地理位置可以防止服务故障。在每个位置运行独立的应用堆栈是至关重要的,这样如果一个地方发生故障,其他地方仍然可以继续运行。理想情况下,这些位置应全球分布,而不是局限于特定地区。

  • 如果担心 Kubernetes 群集性能在组件或其控制平面节点宕机时,应选择具有高可用性的 Kubernetes 群集。 Kubernetes 的高可用性是指具有多个控制平面设置,行为类似于统一数据中心。由多个控制平面组成的设置可保护系统免受工作节点损失控制平面节点 etcd 的故障。管理 Kubernetes 群集并非易事,但您应了解,在为您设置群集时,广泛的云提供商将共享这种类型的配置。

  • 根据您的要求,维护多地区 Kubernetes 群集可能是不合理的。但您仍然可以设置多个命名空间,以确保在同一群集中的可用性。

由于先前的实践之一涉及多区域部署,您应该知道,通过使用此技术,可以通过为全球分布的用户基础保持低延迟来改善最终用户体验。您的应用程序架构可以实现低延迟,因为它会使数据靠近全球分布的最终用户。

在具有地理分布式应用程序时,还需考虑遵守数据隐私法律和法规的能力。随着越来越多的社会和经济活动在线进行,隐私和数据保护的重要性越来越被认可。在一些国家,未经消费者通知或同意向不同方收集、使用和分享个人信息被视为非法行为。根据联合国贸易和发展会议(UNCTAD),194 个国家中有 128 个国家已经制定了保护数据和隐私的立法。

当您开始了解如何确保分布式系统高可用性的要求时,让我们探索一下可以帮助您实现这一目标的云模型。

混合和多云架构

是一组技术,用于解决可用性、扩展性、安全性和韧性等挑战。它可以存在于本地、Kubernetes 分发版或公共基础设施中。通常情况下,您会看到术语混合云多云被用来表示类似的概念。对于多云架构的最直观定义是,这种类型的架构至少需要一个公共云。

混合云架构与多云架构的区别在于包含私有云基础设施组件以及至少一个公共云(参见图 8-9](#multi-cloud-hybrid-cloud))。因此,当混合云架构具有多个公共提供时,该架构可以同时是多云架构。

多云和混合云

图 8-9. 多云和混合云

在部署在混合或多云基础设施上时,您应考虑以下跨团队的方面:

  • 对于您部署的内容及其位置,需要统一的视图。

  • 替换提供商特定的 SaaS 和 IaaS 服务。

  • 遵循统一的方法来减少跨云安全漏洞。

  • 无缝扩展和配置新资源。

  • 在跨云端口应用程序时,需要避免服务断开。在将工作负载在不同基础设施之间移动时会有恢复时间,但可以通过适当的网络配置和部署策略为最终用户提供无缝过渡。

  • 在如此大规模的情况下,自动化在编排过程时非常有帮助。此外,针对容器化应用的编排平台,您和您的团队可能会额外添加一层工具和流程来管理工作负载。

从开发者的角度来看,您可以通过关注这些要素来为混合或多云策略做出贡献:

  • 您的应用程序代码库在任何环境(命名空间)下都应保持一致。

  • 当其他同事尝试使用您的代码时,您的本地构建和部署实践应该是可复制的。

  • 避免在您的代码或容器镜像构建中引用本地依赖项。

  • 可能的话,通过构建时变量或环境变量参数化容器镜像。

  • 如果您需要支持环境定制,请通过编排平台的环境变量将它们传播到容器/应用程序代码参数。

  • 使用您的组织之前验证为可信来源的存储库和注册表中的依赖关系和镜像。

  • 更喜欢使用卷在容器之间共享信息。

当努力朝向混合或多云架构时,请始终问自己和您的同事如何演进当前构建的软件部件。一个先进的软件架构始于具有前瞻性的开发者思维方式。

摘要

本章涵盖了可能涉及 Java 开发人员的部署方面。尽管典型的 Java 开发人员角色不涉及基础设施管理,但您可以通过以下方式影响应用程序的运行阶段和流程:

  • 使用像 Jib 和 Eclipse JKube 这样基于 Java 的工具构建和推送容器镜像到容器镜像注册表。

  • 使用 Dekorate 和 Eclipse JKube 生成和部署 Kubernetes 清单。

  • 在基础设施级别实现健康检查并协调它们的执行。

  • 观察分布式系统的行为,以便了解何时引入更改以及调整哪些资源

  • 将部署方面与高可用性、混合云和多云架构相关联。

既然您对部署应用程序有了很好的理解,下一章将探讨移动软件的 DevOps 工作流程。

第九章:移动工作流

Stephen Chin

程序测试可以非常有效地显示存在的 bug,但却无法有效地证明其不存在。

Edsger Dijkstra

DevOps 的覆盖范围如果不包括移动开发和智能手机的话,就不完整了,后者是计算机拥有量增长最快的领域。过去十年间,智能手机的使用量飙升,全球拥有数以十亿计的智能手机,详见图 9-1。

由于像印度和中国等大国的智能手机拥有率不到 70%,预计智能手机的拥有量将继续上升。如今全球拥有 36 亿部智能手机,到 2023 年预计将达到 43 亿部,这是一个不容忽视的市场和用户群体。

智能手机还具有另一个性质,使得 DevOps 成为一种必不可少的实践:它们属于一类需要连续更新的互联网连接设备,这是因为它们面向的是不那么技术熟练的消费者,需要以最少的用户参与来维护设备。这种趋势受到围绕智能手机建立的应用程序生态系统的推动,使得下载新软件以及接收软件更新对终端用户来说变得轻松且风险相对较低。

显示从 2012 年到 2023 年全球智能手机拥有量增长情况的图表

图 9-1 2012 年至 2023 年间全球智能手机用户数,数据来自Statista(2023 年的预测标有*)

由于多种功能原因,你可能希望更新你的应用程序:

为用户添加新功能

大多数应用程序快速发布,具备最小可行特性集,以缩短上市时间。这使得可以频繁发布小的功能更新,以增加终端用户的实用功能。

修复 bug 并提高应用程序的稳定性

成熟的应用程序经常有很多修复小 bug、提高稳定性和改进用户体验的更新。这些改动通常较小,可以频繁发布。

修补安全漏洞或利用

移动应用程序通常具有广泛的攻击面,包括本地安装的应用程序、提供数据的后端以及用于应用程序和云服务登录的用户认证工作流程。

此外,许多应用程序更新驱动于增加市场份额和改善与用户的互动。一些增加应用市场份额的更新的例子包括以下内容:

与主要平台发布保持一致

每当主要平台发布新版本时,经过认证并更新以利用新功能的应用程序将看到下载量的增加。

提升应用在商店中的可见性

应用商店奖励那些频繁更新的应用程序,通过保留用户评分和突出新发布的方式。发布说明还为您在商店中增加可搜索内容提供了机会。相反,如果您的应用停滞不前没有更新,其在搜索引擎优化中的排名自然会下降。

提醒当前用户关于您的应用程序,以增加使用率

移动平台会提示用户更新其现有应用程序,并有时显示徽章或其他提醒,以增加用户参与度。

应用商店中排名靠前的应用程序知道持续更新的重要性并经常更新。根据Appbot的数据,排名前 200 的免费应用中,自上次更新以来的中位时间为 7.8 天!在这种更新速度下,如果您不使用持续发布流程,将无法跟上步伐。

Java 开发者在构建移动应用程序时有很好的选择。这些选择包括专注于移动端的 Web 开发,使用响应式 Web 应用程序适配受限设备。其他选项包括专门为 Android 设备编写的 Java 移动应用程序。最后,还有几个跨平台的选项可用于在 Android 和 iOS 设备上构建应用程序,包括 Gluon Mobile 和 Electron。

本章主要关注 Android 应用程序开发。然而,所有这些基于 Java 的移动 DevOps 技术和考虑因素同样适用于这些平台。

移动端快速 DevOps 工作流

以下是投资于移动 DevOps 将实现的一些业务收益:

更好的客户体验

由于应用商店中易用且便捷的评分系统,客户体验至关重要。能够快速响应客户问题并在多种设备上进行测试,将确保最佳的客户体验。

更快的创新

通过持续发布到生产环境,您将能够以比竞争对手更高的速度将新功能和能力提供给客户。

更高的软件质量

鉴于 Android 设备的数量众多且碎片化严重,通过手动彻底测试您的应用程序是不可能的。但通过自动化的移动测试策略,覆盖用户群体的关键设备特性,您将能够减少最终用户报告的问题数量。

降低风险

现代应用程序中大多数可执行代码都有开源依赖项,这些依赖项会暴露您面临的已知安全漏洞。通过具备移动 DevOps 管道,允许您测试新版本的依赖项并频繁更新,您将能够在这些漏洞被利用之前快速修复应用程序中的问题。

本书其余部分提到的原则和最佳实践同样适用于移动应用程序开发,但由于市场的规模和期望,这些因素会放大 10 倍。在为 Android 设备规划移动 DevOps 管道时,以下是您需要考虑的阶段:

  1. 构建。

    Android 构建脚本通常使用 Gradle 编写。因此,您可以使用您选择的任何持续集成服务器,包括 Jenkins、CircleCI、Travis CI 或 JFrog Pipelines。

  2. 测试。

    单元测试

    Android 单元测试通常使用 JUnit 编写,可以轻松自动化。更高级别的 Android 单元测试通常使用一些 UI 测试框架,如 Espresso、Appium、Calabash 或 Robotium。

    集成测试

    除了测试您自己的应用程序外,还重要测试应用程序之间的交互,使用诸如 UI Automator 之类的工具进行集成测试,并可以跨多个 Android 应用程序进行测试。

    功能测试

    整体应用程序验证很重要。您可以手动执行此操作,但是自动化工具可以模拟用户输入,例如先前提到的 UI 自动化工具。另一种选择是运行像 Google 的 App 爬虫这样的机器人爬虫工具,以检查您的应用程序的用户界面并自动发出用户操作。

  3. 打包。

    在打包步骤中,您需要聚合部署所需的所有脚本、配置文件和二进制文件。通过使用像 Artifactory 这样的软件包管理工具,您可以保留所有构建和测试信息,并轻松跟踪依赖关系以进行可追溯性和调试。

  4. 发布。

    移动应用开发最好的部分之一是发布移动应用程序以结束应用商店提交; 最终部署到设备的管理由 Google Play 基础设施管理。具有挑战性的部分是您必须准备您的构建以确保应用商店提交成功,并且如果您没有完全自动化提交过程,构建、测试和打包中的任何错误都会导致延迟而受到惩罚。

正如您所看到的,Android 开发中 DevOps 的最大区别在于测试。对 Android 应用程序的 UI 测试框架进行了大量投资,因为自动化测试是解决在高度碎片化的设备生态系统中进行测试问题的唯一解决方案。我们将在下一节中了解到 Android 设备碎片化有多严重,并在本章后面讨论缓解这一问题的方法。

Android 设备碎片化

iOS 生态系统受到 Apple 严格控制,限制了可用的硬件型号数量、屏幕大小的变化以及手机上的硬件传感器和功能集。自 2007 年首款 iPhone 推出以来,仅生产了 29 种不同的设备,其中仅有 7 种目前在售。

相比之下,Android 生态系统向众多设备制造商开放,他们定制从屏幕大小和分辨率到处理器和硬件传感器,甚至生产像折叠屏这样的独特形态因素的一切。来自 1,300 个不同制造商的超过 24,000 种不同设备,这比 iOS 设备的碎片化高出 1,000 倍。这使得为 Android 平台进行测试变得更加困难。

在碎片化方面,几个关键差异使得统一测试不同 Android 设备变得困难:

Android 版本

Android 设备制造商并不总是为旧设备提供最新的 Android 版本更新,因此用户可能会被困在旧的 Android 操作系统版本上,直到购买新设备。旧 Android 版本的使用减少是逐渐的,目前仍在运行 Android 4.x 版本的活跃设备超过七年,包括 Jelly Bean 和 KitKat。

屏幕尺寸和分辨率

Android 设备涵盖广泛的形态因素和硬件配置,趋势是向更大和像素密度更高的显示器发展。一个设计良好的应用程序需要能够在各种屏幕尺寸和分辨率下良好地扩展。

3D 支持

特别是对于游戏而言,了解设备在 API 和性能方面的 3D 支持水平至关重要。

硬件特性

大多数 Android 设备配备基本的硬件传感器(摄像头、加速计、GPS),但对于新的硬件 API(如近场通信(NFC)、气压计、磁力计、接近传感器、压力传感器、温度计等),支持有所不同。

Android 操作系统碎片化

Android 版本碎片化影响设备测试的两个层面。首先是主要的 Android 版本,它决定了您需要构建和测试的 Android API 版本数量。第二个是由原始设备制造商(OEM)进行的操作系统定制,以支持特定的硬件配置。

对于 iOS 而言,由于苹果控制硬件和操作系统,它能够同时为所有支持的设备推送更新。这保持了针对性能和安全修复的小版本更新的高采纳率。苹果还在主要发布中投入了许多功能和市场推广,以快速推动用户群体升级到最新版本。因此,仅在初始发布后的七个月内,苹果就能够实现 iOS 14 的 86% 采纳率

Android 市场的复杂性显著增加,因为 OEM 修改并测试其设备的定制 Android OS 版本。此外,他们依赖于系统芯片(SoC)制造商为不同的硬件组件提供代码更新。这意味着主要供应商创建的设备可能仅会收到几个主要操作系统版本的更新,而小型供应商的设备即使在支持期内也可能永远不会看到操作系统升级。

为了帮助您决定支持不同 Android OS 版本的范围,Google 在 Android Studio 中提供了有关设备按 API 级别采用情况的信息。截至 2021 年 8 月的用户分布如图 9-2 所示。要实现与最新 iOS 版本相当的> 86%采用率,您需要至少支持 Android 5.1 Lollipop,这是 2014 年发布的一个版本。即使如此,您仍然错过了仍在使用基于 Android 4 的设备的超过 5%的用户。

带有使用和功能信息的 Android 版本列表

图 9-2. Android Studio 显示 Android 平台不同版本用户的分布(Android 11 采用率<1%)

更进一步复杂化情况的是,每个 OEM 都会修改其提供给设备的 Android OS,因此仅仅测试每个主要 Android 版本的一个设备是不够的。这是 Android 使用 Linux 内核访问硬件设备的方式导致的结果。

Linux 内核是操作系统的核心,并提供用于访问摄像头、加速计、显示器和设备上其他硬件的低级设备驱动程序代码。在 Google 基于的 Linux 内核中,Google 添加了 Android 特定的功能和补丁,SoC 供应商添加了硬件特定的支持,OEM 进一步为其特定设备修改了它。因此,每个设备在性能、安全性和可能影响应用程序的潜在错误方面都存在一定的变化范围,当用户在新设备上运行时可能会受到影响。

Google 致力于改善这种情况,通过 Android 8.0 Oreo,其中包括一个新的硬件抽象层,允许设备特定的代码在内核外运行。这使得 OEM 厂商可以在没有等待 SoC 供应商的设备驱动程序更新的情况下,从 Google 更新到新的 Android 内核版本,从而减少了操作系统升级所需的重新开发和测试的数量。然而,除了 Google 负责 OS 更新的 Pixel 设备外,大多数 Android 设备升级仍由 OEM 厂商掌控,它们仍然很慢地升级到新的 Android 版本。

构建不同屏幕尺寸的应用

鉴于在硬件制造商和超过 24,000 个型号中的多样性,如前一节所讨论的,屏幕尺寸和分辨率也存在巨大差异是毫不奇怪的。不断推出新的屏幕尺寸,例如使用 21.5 英寸触摸屏的巨大 HP Slate 21,以及带有垂直 1680 × 720 盖板显示器,打开后显示 2152 × 1536 分辨率的双宽内部显示器的三星 Galaxy Fold。

除了屏幕尺寸的巨大变化外,还存在着不断追求更高像素密度的战斗。更高的像素密度可以实现更清晰的文本和更锐利的图形,从而提供更好的视觉体验。

当前像素密度的领先者是 Sony Xperia XZ,在仅 5.2 英寸对角线的屏幕上配备了 3840 × 2160 的 UHS-1 显示屏。这使得像素密度达到 806.93 像素每英寸(PPI),接近人眼可区分的最大分辨率。

应用材料是 LCD 和 OLED 显示器的主要制造商之一,对手持显示器上的像素密度的人类感知进行了研究。它发现,在距离眼睛 4 英寸的距离上,20/20 视力的人能够区分 876 PPI。因此,智能手机显示屏迅速接近像素密度的理论极限;然而,其他形式因素如虚拟现实头显可能会进一步推动密度。

为了处理像素密度的变化,Android 将屏幕分类为以下像素密度范围:

ldpi,约 120 dpi(0.75 倍比例)

仅用于少数非常低分辨率设备,如 HTC Tattoo,Motorola Flipout 和 Sony X10 Mini,它们的屏幕分辨率为 240 × 320 像素。

mdpi,约 160 dpi(1 倍比例)

这是像 HTC Hero 和 Motorola Droid 这样的 Android 设备的原始屏幕分辨率。

tvdpi,约 213 dpi(1.33 倍比例)

电视分辨率,如 Google Nexus 7,但不被视为“主要”密度组。

hdpi,约 240 dpi(1.5 倍比例)

第二代手机,如 HTC Nexus One 和 Samsung Galaxy Ace,将分辨率提高了 50%。

xhdpi,约 320 dpi(2 倍比例)

第一批使用这种 2 倍分辨率的手机之一是 Sony Xperia S,随后是 Samsung Galaxy S III 和 HTC One 等手机。

xxhdpi,约 480 dpi(3 倍比例)

第一款 xxhdpi 设备是 Google 的 Nexus 10,它只有 300 dpi,但因为是平板形式,需要大图标。

xxxhdpi,约 640 dpi(4 倍比例)

这是像 Nexus 6 和 Samsung Galaxy S6 Edge 这样的设备目前使用的最高分辨率。

随着显示器像素密度的持续增加,Google 可能希望选择比仅添加更多x更好的高分辨率显示约定!

为了给最终用户提供最佳的用户体验,重要的是确保您的应用程序在所有可用分辨率下看起来和行为一致。考虑到各种屏幕分辨率的广泛变化,简单地为每种分辨率硬编码是不够的。

这里有一些最佳实践,确保您的应用程序在各种分辨率下都能正常工作:

  • 始终使用密度独立和可伸缩像素:

    密度独立像素(dp)

    根据设备分辨率调整的像素单位。对于 mdpi 屏幕,1 像素(px)= 1 dp。对于其他屏幕分辨率,px = dp ×(dpi / 160)。

    可伸缩像素(sp)

    用于文本或其他用户可调整大小元素的可伸缩像素单位。它以 1 sp = 1 dp 开始,并根据用户定义的文本缩放值进行调整。

  • 为所有可用分辨率提供备用位图:

    • Android 允许您通过将其放入名为 drawable-?dpi 的子文件夹中来为不同分辨率提供备用位图,其中 ?dpi 是支持的密度范围之一。

    • 对于应用程序图标也是如此,不过您应该使用名为 mipmap-?dpi 的子文件夹,以便在构建特定密度的 APK 时资源不被移除,因为应用程序图标通常会放大超出设备分辨率。

  • 更好的做法是尽可能使用矢量图形:

    • Android Studio 提供了一个名为 Vector Asset Studio 的工具,允许您将 SVG 或 PSD 文件转换为 Android 矢量文件,可以作为应用程序资源使用,如图 9-3 所示。

Android Studio 中用于将 SVG 和 PSD 文件转换为矢量资源的对话框

图 9-3. 将 SVG 文件转换为 Android 矢量格式

开发能够清晰适配不同屏幕尺寸和分辨率的应用程序是非常复杂的,需要仔细测试不同分辨率的设备。为了帮助集中测试工作,Google 提供了用户提取的数据,显示了不同设备分辨率的使用情况,如表 9-1 所示。

表 9-1. Android 屏幕大小和密度分布

ldpi mdpi tvdpi hdpi xdpi xxhdpi 总计
小型 0.1% 0.1% 0.2%
普通 0.3% 0.3% 14.8% 41.3% 26.1% 82.8%
大型 1.7% 2.2% 0.8% 3.2% 2.0% 9.9%
超大 4.2% 0.2% 2.3% 0.4% 7.1%
总计 0.1% 6.2% 2.7% 17.9% 45.0% 28.1%

如您所见,某些分辨率并不常见,除非您的应用程序针对这些用户或遗留设备类型,否则可以从设备测试矩阵中删除它们。ldpi 分辨率仅用于 Android 设备的一小部分,并且市场份额仅为 0.1% —— 很少有应用程序针对这种非常小的分辨率屏幕进行优化。此外,tvdpi 是一种使用率仅为 2.7% 的小众屏幕分辨率,可以安全地忽略,因为 Android 将自动缩放 hdpi 资源以适应此屏幕分辨率。

这仍然需要您支持五种设备密度,并且可能需要测试无数种屏幕分辨率和宽高比。我稍后会讨论测试策略,但您可能需要同时使用模拟设备和物理设备,以确保在碎片化的 Android 生态系统中提供最佳用户体验。

硬件和 3D 支持

第一台 Android 设备是 HTC Dream(又称 T-Mobile G1),如图 9-4 所示。它具有中密度触摸屏,分辨率为 320 × 480 像素,硬件键盘、扬声器、麦克风、五个按钮、可点击的轨迹球和后置摄像头。虽然在现代智能手机标准下算是原始,但它是推出 Android 的良好平台,当时尚未支持软键盘。

与现代智能手机标准相比,这套硬件设备显得比较适中。驱动 HTC Dream 的高通 MSM7201A 处理器是一款 528 MHz Arm11 处理器,仅支持 OpenGL ES 1.1。相比之下,三星 Galaxy S21 Ultra 5G 配备 3200 × 1440 分辨率屏幕和以下传感器:

  • 2.9 GHz 8 核处理器

  • Arm Mali-G78 MP14 GPU,支持 Vulkan 1.1、OpenGL ES 3.2 和 OpenCL 2.0

  • 五个摄像头(一个前置,四个后置)

  • 三个麦克风(一个底部,两个顶部)

  • 立体声扬声器

  • 超声波指纹识别器

  • 加速度计

  • 气压计

  • 陀螺仪传感器(陀螺仪)

  • 地磁传感器(磁力计)

  • Hall 传感器

  • 接近传感器

  • 环境光传感器

  • NFC

显示智能手机的照片,显示屏幕已收起,显示出硬件键盘。

图 9-4. T-Mobile G1(又名 HTC Dream),这是第一款运行 Android 操作系统的智能手机(照片在创意共享许可下使用)

三星旗舰手机在硬件支持方面处于高端,包括几乎所有支持的传感器类型。面向大众市场的手机可能选择使用性能较低的芯片组,并省略一些传感器以降低成本。Android 使用可用物理传感器的数据还在软件中创建“虚拟”传感器,应用程序使用这些传感器:

游戏旋转向量

由加速度计和陀螺仪数据的组合

重力

由加速度计和陀螺仪(如果没有陀螺仪,则为磁力计)数据的组合

地磁旋转向量

由加速度计和磁力计数据的组合

线性加速度

由加速度计和陀螺仪(如果没有陀螺仪,则为磁力计)数据的组合

旋转向量

由加速度计、磁力计和陀螺仪数据的组合

显著动作

由加速度计数据(在低功耗模式下可能替代其他传感器数据)

步数检测器/计数器

由加速度计数据(在低功耗模式下可能替代其他传感器数据)

这些虚拟传感器仅在足够的物理传感器集合存在时才可用。大多数手机包含加速度计,但可能选择省略陀螺仪或磁力计或两者,从而降低运动检测的精度并禁用某些虚拟传感器。

硬件传感器可以模拟,但要模拟测试真实世界条件则更加困难。此外,硬件芯片组和 SoC 供应商驱动程序的实现有很大差异,需要大量测试矩阵以验证应用程序在各种设备上的运行。

对于游戏开发人员尤为重要的硬件另一个方面,但越来越成为基本图形堆栈和应用程序预期性能的一部分,是 3D API 支持。几乎所有移动处理器都支持一些基本的 3D API,包括第一款 Android 手机,支持 OpenGL ES 1.1,这是 OpenGL 3D 标准的移动特定版本。现代手机支持 OpenGL ES 标准的较新版本,包括 OpenGL ES 2.0、3.0、3.1,以及现在的 3.2 版本。

OpenGL ES 2.0 引入了编程模型的显著转变,从功能管线转变为可编程管线,通过着色器的使用允许更直接的控制来创建复杂效果。OpenGL ES 3.0 通过支持顶点数组对象、实例化渲染和设备独立压缩格式(ETC2/EAC)等功能,进一步提高了 3D 图形的性能和硬件独立性。

OpenGL ES 的采用速度相当快,所有现代设备至少支持 OpenGL ES 2.0。根据 Google 在图 9-5 中展示的设备数据,大多数设备(67.54%)支持 OpenGL ES 3.2,这是 2015 年 8 月发布的标准的最新版本。

显示 OpenGL ES 版本采用的饼状图

图 9-5. Android 设备采用不同版本 OpenGL ES 的百分比,来自Google 的分布仪表板

Vulkan 是一种现代图形芯片组支持的新的图形 API。它具有在桌面和移动设备之间可移植性的优势,随着计算平台继续融合,更容易移植桌面代码。此外,它允许更精细的线程和内存管理控制,以及用于跨多个线程缓冲和排序命令的异步 API,更好地利用多核处理器和高端硬件。

由于 Vulkan 是一种较新的 API,其采用速度不及 OpenGL ES 快;但是,64%的 Android 设备具有某种程度的 Vulkan 支持。根据 Google 在图 9-6 中可视化的设备统计数据,这分为支持 42%设备的 Vulkan 1.1 版本,以及仅支持 Vulkan 1.0.3 API 级别的剩余 22%设备。

与硬件传感器测试类似,许多不同制造商实现了大量的 3D 芯片组。因此,在您的应用程序中可靠地测试错误和性能问题的唯一方法是在不同手机型号上执行设备测试,将在下一节中详述。

显示 Vulkan 版本采用的饼状图

图 9-6. Android 设备采用不同版本 Vulkan 的百分比,来自Google 的分布仪表板

在并行设备上进行持续测试

前面的部分讨论了 Android 设备生态系统中的大量碎片化。这是由技术因素(如 Android 操作系统架构)以及 OEM 和 SoC 供应商复杂的生态系统所迫使的。另外,Android 平台的普及度极高,有 1,300 多家制造商生产超过 24,000 种设备,这造成了持续的测试和部署挑战。

设备仿真器适用于开发和应用程序的基本测试,但无法模拟独特硬件配置、设备驱动程序、定制内核和真实传感器行为的复杂交互。因此,需要在设备上进行高水平的手动和自动化测试,以确保终端用户获得良好的体验。

在硬件规模测试中有两种基本方法。第一种是建立共享设备的设备实验室。这是一个实用的方法来开始测试,因为您可能已经有大量可用的 Android 设备,通过适当的基础设施和自动化可以更好地利用这些设备。然而,根据您想支持的设备配置数量,这可能是一个相当大而昂贵的事业。此外,对于一个大型设备农场的持续维护和维护在材料和劳动力方面都是昂贵的。

第二个选项是将设备测试外包给云服务。考虑到远程控制 Android 设备的进展以及平台的稳定性,能够选择设备矩阵并在云中触发自动化测试是很方便的。大多数云服务提供详细的屏幕截图和诊断日志,可以用于跟踪构建失败,并且可以手动控制设备进行调试。

建设设备农场

即使只是小规模地建立自己的设备农场,也是充分利用您已有的 Android 设备并增加其在整个组织中效用的好方法。在大规模运作时,设备农场可以显著降低 Android 开发的运行成本,一旦您投入了硬件的前期投资。但请记住,管理一个大型设备实验室是一项全职工作,并且有持续的成本需要考虑。

用于管理 Android 设备的流行开源库是 Device Farmer(前身为 Open STF)。Device Farmer 允许您通过 Web 浏览器远程控制 Android 设备,实时查看设备屏幕,如 图 9-7 所示。对于手动测试,您可以从桌面键盘输入,并使用鼠标输入单点触摸或多点触控手势。对于自动化测试,REST API 允许您使用像 Appium 这样的测试自动化框架。

在会议上建立的志愿者设备实验室的图片

图 9-7. Device Farmer 的 用户界面(照片使用 知识共享 许可)

Device Farmer 还帮助你管理设备清单。它会显示已连接的设备、每个设备的使用者以及你设备的硬件规格,并协助在大型实验室中物理定位设备。

最后,Device Farmer 还有一个预订和分区设备组的系统。你可以将你的设备清单分成具有所有者和相关属性的不同组。这些组可以永久分配给项目或组织,也可以预订一段特定的时间。

要建立一个设备实验室,你还需要硬件来支持这些设备。基本的硬件设置包括以下内容:

驱动计算机

即使 Device Farmer 可以在任何操作系统上运行,但建议在基于 Linux 的主机上运行,以便管理和最佳稳定性。一个很好的开始选项是像 Intel NUC 这样的小巧但功能强大的计算机。

USB 集线器

为了设备的连接和稳定供电,建议使用一个带电源的 USB 集线器。获得可靠的 USB 集线器很重要,因为这会影响到你实验室的稳定性。

无线路由器

设备将通过无线路由器获取网络连接,因此这是设备设置中的一个重要部分。为你的设备拥有一个专用的网络将增加可靠性,并减少与网络上其他设备的争用。

安卓设备

当然,最重要的部分是有大量的安卓设备进行测试。从与你的目标用户群最常见和最受欢迎的设备开始,并根据前面部分讨论的 Android 操作系统版本、屏幕尺寸和硬件支持来添加设备,以达到所需的测试矩阵。

大量的电缆

你需要比平常更长的电缆来有效管理设备与 USB 集线器的连接。在设备和硬件组件之间留出足够的空间以避免过热是很重要的。

通过一点点工作,你将能够创建一个类似于图 9-8 的完全自动化设备实验室,这是世界上第一个在德国杜塞尔多夫举行的 beyond tellerrand 大会上展示的设备实验室。

会议上自愿建造的设备实验室的图片

图 9-8. 开放设备实验室 在德国杜塞尔多夫举行的 beyond tellerrand 大会上(照片使用知识共享许可

设备农民被分为微服务,以支持平台的可伸缩性,可扩展到数千台设备。直接使用,它轻松支持 15 台设备,之后您将遇到 Android 调试桥(ADB)的端口限制。可以通过运行多个 Device Farmer ADB 和 Provider 服务的实例来扩展到您的机器支持的 USB 设备数量限制。对于 Intel 架构,这是 96 个端点(包括其他外围设备),对于 AMD,可以达到 254 个 USB 端点。通过使用多个 Device Farmer 服务器,您可以扩展到数千台设备,这应该足以支持企业 Android 应用的移动测试和验证。

一个大规模移动设备实验室的示例是 Facebook 在其俄勒冈州 Prineville 数据中心的移动设备实验室,如图 9-9 所示。该公司构建了一个客户服务器机架封装来容纳移动设备,设计用于阻挡 WiFi 信号,以防止数据中心设备之间的干扰。每个封装可以支持 32 台设备,由 4 台 OCP Leopard 服务器供电,并连接到设备。这提供了一个稳定且可扩展的硬件设置,使公司能够达到其 2000 台设备的目标设备农场规模。

一张数据中心中装有机架式设备的照片。

图 9-9. Facebook 在其 Prineville 数据中心的移动设备实验室(Antoine Reversat 拍摄的照片

运行大规模设备实验室存在一些挑战:

设备维护

安卓设备不适合全天候自动化测试。因此,您可能会经历比正常情况更高的设备故障,并且每一两年需要更换电池或整个设备。合理间隔设备并保持良好的冷却有助于解决这个问题。

WiFi 干扰/连接性

WiFi 网络,尤其是面向消费者的 WiFi 路由器,稳定性不高,特别是在设备数量较多的情况下。降低 WiFi 路由器的广播信号功率,并确保它们处于不竞争的网络频段,可以减少干扰。

网线布线

在所有设备和 USB 集线器或计算机之间布线可能会造成一团乱麻。除了难以维护外,这也可能导致连接和充电问题。确保移除所有电缆中的环圈,并根据需要使用屏蔽电缆和铁氧体磁芯以减少电磁干扰。

设备可靠性

在消费设备上运行设备实验室有普遍风险,因为消费设备不够可靠。将自动化测试运行限制在有限的时间段内有助于防止测试因设备无响应而被阻塞。在测试之间,进行一些清理工作以删除数据并释放内存有助于提升性能和可靠性。最后,需要定期重新启动安卓设备及其运行它们的服务器。

小贴士

从您已拥有的设备开始,逐步扩展规模,并能够跨范围设备进行测试并并行启动自动化测试。在大规模上,这是解决跨碎片化的 Android 生态系统进行测试的有效解决方案,但需要高昂的前期成本及持续的支持和维护。

接下来的部分讨论了可以立即在简单的按需付费基础上开始使用的设备实验室。

云中的移动管道

如果搭建自己的设备实验室似乎令人望而却步,那么一个简单且低成本的方法是使用运行在公共云基础设施上的设备农场,以跨大范围设备进行测试。移动设备云的优势在于易于开始和对最终用户无需维护。您只需选择要运行测试的设备,并对您的应用程序针对设备池进行手动或自动化测试。

一些移动设备云还支持自动化机器人测试,这些测试将尝试操作应用程序的所有可见 UI 元素,以识别应用程序的性能或稳定性问题。测试运行后,您将获得任何失败的完整报告、用于调试的设备日志以及用于追踪问题的屏幕截图。

许多移动设备云可供选择,其中一些可以追溯到功能手机时代。然而,最受欢迎和现代化的设备云已经与三大主要云服务提供商——亚马逊、谷歌和微软——达成了一致。它们都在移动测试基础设施上投入了大量资金,您可以以合理的价格尝试并使用大量模拟和真实设备进行测试。

AWS 设备农场

亚马逊提供了作为其公共云服务一部分的移动设备云。通过使用 AWS 设备农场,您可以在各种真实设备上使用您的 AWS 账户运行自动化测试。

创建新的 AWS 设备农场测试的步骤如下:

  1. 上传您的 APK 文件:首先,上传您编译的 APK 文件或从最近更新的文件中选择。

  2. 配置您的测试自动化:AWS 设备农场支持多种测试框架,包括使用 Java、Python、Node.js 或 Ruby 编写的 Appium 测试,以及 Calabash、Espresso、Robotium 或 UI Automator。如果您没有自动化测试,AWS 提供了两个名为 Fuzz 和 Explorer 的机器人应用测试工具。

  3. 选择要运行的设备:从用户创建的设备池或默认的五款最流行设备池中选择要在其上运行测试的设备,如图 9-10 所示。

  4. 设置设备状态:在开始测试之前设置设备状态,您可以指定要安装的数据或其他依赖应用程序,设置无线电状态(WiFi、蓝牙、GPS 和 NFC),更改 GPS 坐标、更改语言环境并设置网络配置文件。

  5. 运行你的测试: 最后,你可以在所选设备上运行测试,每台设备的执行超时时间可长达 150 分钟。如果你的测试执行速度更快,测试将提前结束,但同时也设置了测试运行成本的最大上限。

在 AWS Device Farm 上创建新运行的截图。

图 9-10. 在 AWS Device Farm 向导中选择要运行的设备

AWS Device Farm 为个人开发者提供免费的配额以启动测试自动化,额外设备测试按分钟计费,还有月度计划可同时在多台设备上进行并行测试。所有这些计划都基于共享设备池运行,截至撰写本文时,共有 91 台设备,其中 54 台为安卓设备,如图 9-11 所示。然而,大多数这些设备都具有高可用性,这意味着它们有大量相同的设备可供测试。这意味着你不太可能在队列中被阻塞,或者需要测试的设备不可用。

设备表格的截图。

图 9-11. AWS Device Farm 中可用设备的列表

最后,AWS Device Farm 提供了几种集成方式来运行自动化测试。从 Android Studio 内部,你可以使用其 Gradle 插件在 AWS Device Farm 上运行测试。如果你希望从持续集成系统启动 AWS Device Farm 测试,亚马逊提供了一个 Jenkins 插件,你可以在本地构建和自动化测试完成后立即使用它来启动设备测试。

Google Firebase Test Lab

在 Google 收购 Firebase 后,其一直在不断扩展和改进其提供的功能。Firebase Test Lab 是其移动设备测试平台,提供与 AWS Device Farm 类似的功能。开始时,Google 为开发者提供免费的配额,每天运行有限数量的测试。超出此配额后,你可以升级到按设备小时计费的按需付费计划。

Firebase Test Lab 提供了几种可以启动服务上的测试的方式:

Android Studio

Firebase Test Lab 已集成在 Android Studio 中,使你能够在其移动设备云中轻松运行测试,就像在本地设备上运行一样。

Firebase Web UI

从 Firebase 网页控制台,你可以上传你的 APK,并将从运行你的第一个应用程序开始进行自动化的 Robo 测试,如图 9-12 所示。此外,你可以使用 Espresso、Robotium 或 UI Automator 运行你自己的自动化测试。游戏开发者可以选择运行模拟用户场景的集成游戏循环。

自动化命令行脚本

你可以通过使用其命令行 API 将 Firebase Test Lab 轻松集成到你的 CI 系统中。这使你能够与 Jenkins、CircleCI、JFrog Pipelines 或你喜欢的 CI/CD 系统集成。

Firebase 网页控制台测试应用程序的截图。

图 9-12. Firebase web 用户界面运行自动化 Robo 测试

在撰写本文时,Firebase 测试实验室提供了比 AWS 设备农场更多的 Android 设备,支持 109 种设备,并支持流行设备的多个 API 级别。鉴于与 Google 的 Android 工具集成紧密以及个人免费配额的慷慨,这是一个让您的开发团队开始构建测试自动化的简单方法。

Microsoft Visual Studio App Center

Microsoft Visual Studio App Center,前身为 Xamarin 测试云,在所有云服务中提供了最令人印象深刻的设备列表,拥有 349 种 Android 设备类型供您进行测试,如图 9-13 所示。然而,与 AWS 设备农场和 Firebase 测试实验室不同,Microsoft 没有为开发者提供免费的使用服务层。Microsoft 确实为其服务提供了 30 天的试用期,用于使用单个物理设备进行测试,并且有按并发设备数量付费的计划,这对大型企业是合理的选择。

VS App Center 中设备列表的屏幕截图。

图 9-13. Visual Studio App Center 设备选择屏幕

Visual Studio App Center 也缺少一些用户友好的功能,比如机器人测试和通过 Web 控制台简单执行测试。相反,它专注于与 App Center CLI 的命令行集成。从 App Center CLI,您可以轻松地使用 Appium、Calabash、Espresso 或 XamarinUITest 启动自动化测试。此外,这使得与 CI/CD 工具的集成变得简单直接。

总体而言,Visual Studio App Center 在设备覆盖率上胜出,并且明确专注于企业移动设备测试。然而,对于独立开发者或较小团队来说,它不太易接近,并且前期成本较高,但在扩展过程中将表现良好。

设备测试策略的规划

现在您已经了解了设置自己的设备实验室并利用云基础设施的基础知识,您应该更清楚这些如何映射到您的移动设备测试需求。

这些是选择云服务的优势:

初创成本低

云计划通常为开发者提供有限数量的免费设备测试,并提供基于利用率的定价来进行设备测试。在开始设备测试时,这是开始探索手动和自动化设备测试的最简单和成本最低的方法。

大量设备选择

由于云测试提供商支持大量的客户安装基地,他们拥有大量的当前和传统的手机库存进行测试。这使得能够精确地针对您的用户最有可能拥有的设备类型、配置和配置文件进行测试成为可能。

快速扩展

应用开发主要是关于病毒营销和快速扩展。与其一开始投资昂贵的基础设施,云服务允许您根据应用程序的大小和流行度需求扩展测试,需要更大的设备测试矩阵。

降低资本支出

建立一个大型设备实验室是一项高昂的前期资本支出。通过按需支付云基础设施费用,您可以延迟成本,最大化您的资本效率。

全球访问

随着远程和分布式团队成为常态,云服务的设计允许您的整个团队轻松访问,无论他们位于何处。

然而,即使有了所有这些好处,建立设备实验室的传统方法也具有独特的优势。以下是您可能希望建立自己设备实验室的一些原因:

规模化降低成本

运行和维护规模化设备实验室的总拥有成本远低于设备可用寿命期间从云服务提供商处支付的总月费用。对于小团队来说,这个门槛很难达到,但如果你是一个大型移动公司,这将是显著的节省。

快速和可预测的周期时间

控制设备农场,您可以保证测试并行运行,并在可预测的时间范围内完成,以支持响应性构建。云服务提供商的设备可用性有限,并且流行配置的排队等待时间可能会限制您快速迭代的能力。

无会话限制

设备云通常会对其服务设置硬编码会话限制,以防止因测试或设备故障而导致测试挂起。随着测试套件复杂性的增加,30 分钟的硬限制可能会成为完成复杂用户流程测试的障碍。

法规要求

在金融和国防等特定受监管行业,安全要求可能会限制或禁止在企业防火墙之外部署应用程序和执行测试的能力。这类公司需要一个本地设备实验室设置。

物联网设备集成

如果您的用例要求将移动设备与物联网设备和传感器集成,这不是云服务提供商能够直接提供的配置。您可能最好创建一个设备实验室,配置最适合您实际场景的物联网和移动配置。

提示

在某些情况下,同时进行云测试和本地设备实验室测试也是有意义的。根据您对周期时间、维护成本、设备扩展和法规要求的具体需求,这可以让您同时获得两种测试方法的最佳效果。

总结

Android 是全球最流行的移动平台,因其庞大的制造商和应用开发者生态系统。然而,这也是 Android 开发面临的挑战之一:一个拥有数千家制造商生产数万款设备的极度分散的设备市场。鉴于这种规模的碎片化和设备不一致性,对于移动开发的成功来说,拥有完全自动化的移动开发运维管道是必不可少的。

在 Web 应用程序开发中,类似于 DevOps 的等价物是,如果不是三种主要的浏览器,而是成千上万种独特的浏览器类型。你将被迫自动化以获得任何质量保证水平,这正是为什么在移动领域如此关注在真实设备上运行的 UI 测试自动化的原因。

使用本章学到的工具和技术,结合整体的 DevOps 知识,包括源代码控制、构建推广和安全性,你应该领先于你的移动 DevOps 同行,以面对全球数百万设备的持续部署挑战。

第十章:持续部署模式和反模式

史蒂芬·钱

巴鲁克·萨多古尔斯基

从他人的错误中学习。你活不到足够长的时间来犯所有错误。

埃莉诺·罗斯福

在本章中,我们将向您介绍持续部署的模式,这些模式对于在组织中成功实施 DevOps 最佳实践至关重要。了解持续更新的理由对于能够说服组织中的其他人进行必要的变革以改善部署过程至关重要。

我们还将给您介绍来自未能采纳持续更新最佳实践的公司的大量反模式。从他人的失败中学习是很好的,高科技行业中存在许多最近的例子,告诉我们不应该做什么,以及忽视最佳实践的后果。

完成本章后,您将掌握七个持续更新的最佳实践,您可以立即开始使用,以加入软件行业的顶级 26% 的 DevOps “精英表现者”。

为什么每个人都需要持续更新

持续更新不再是软件开发的可选部分,而是任何重要项目都应该采纳的最佳实践。规划持续交付更新与项目的功能需求一样重要,并且需要高水平的自动化来可靠执行。

并不总是这样的。从历史上看,软件交付的频率要低得多,只收到关键更新。此外,更新的安装通常是一个手动且容易出错的过程,涉及脚本调整、数据迁移和显著的停机时间。

在过去的十年中,这一切都发生了变化。现在,最终用户期望不断添加新功能,这是由他们对消费设备和持续更新应用程序的体验驱动的。此外,推迟关键更新所带来的业务风险很大,因为安全研究人员不断发现可以用来 compromise 你的系统的新漏洞,除非进行修补。最后,在云时代,不断更新的软件已经成为业务的期望,因为整个基础设施栈都在不断更新以提高安全性,通常要求你也更新你的应用程序。

并非所有软件项目都能迅速采纳持续更新策略,特别是在习惯较长技术采纳周期的行业。然而,常见硬件架构和开源技术的广泛使用意味着这些项目同样面临关键漏洞的风险。一旦曝光,这可能导致难以或无法恢复的灾难性故障。像任何其他软件一样,开源项目存在漏洞和安全性问题,这些问题被修复和补丁化速度要比专有项目快,但如果组织不进行更新,这些补丁又有何作用呢?

在接下来的几节中,我们将更详细地探讨持续更新的动机。如果您尚未拥有持续更新策略,本章节的内容将帮助您说服组织中的其他人采纳这一策略。如果您已经采用了持续更新,您将具备知识来获得比竞争对手更优越的基础设施和 DevOps 流程所带来的商业利益。

对持续更新的用户期望

最近十年间,用户对新功能发布节奏的期望发生了显著变化。这是由消费设备上功能和更新交付方式的改变驱动的,但也反映在其他软件平台上,甚至包括企业级。强迫用户等待漫长的发布周期或进行昂贵的迁移以利用新功能,将导致用户不满,并使您处于竞争劣势。

用户期望的变化可以在几个消费行业中看到,包括手机行业。当移动通信开始流行时,诺基亚是 2G 手机的主要硬件制造商之一。尽管按照今天的标准来看有些原始,这些手机具有出色的硬件设计,声音质量好,有触感按钮和坚固的设计。

诺基亚 6110 等小型移动设备加速了蜂窝技术的采用,但这些设备上的软件及用户更新能力极为不足。这是早期消费设备公司的共同问题,它们首先将自己视为硬件公司,而在软件开发上采用现代实践的步伐较慢。

与许多新兴技术一样,诺基亚手机附带的软件非常基础且存在缺陷,需要补丁和更新才能保持可用。虽然诺基亚提供了数据线,但其功能仅限于从设备传输联系人到计算机等基本操作,并不允许像执行固件更新这样的维护特性。要在手机上获得包含重要补丁和关键功能(如“贪吃蛇”游戏)的功能更新,您需要将手机带到服务中心进行设备更新。

直到2007 年iPhone 推出,手机行业才开始采用软件优先的移动手机设计方法。有了更新固件和整个操作系统的能力,从附加的计算机和后来的空中更新,Apple 能够迅速为现有设备部署新功能。

2008 年,Apple 宣布推出 App Store,创建了一个充满活力的应用生态系统,并为现代商店功能如安全沙箱和自动应用程序更新奠定了基础,我们将在本章的后续内容中以更长的案例研究回顾。随着2011 年iOS 5 的发布,Apple 采纳了空中更新;你甚至不再需要计算机来安装操作系统的最新版本。

现在,手机上软件更新的过程已经无缝自动化,以至于大多数消费者都不知道他们运行的操作系统或个别应用程序的版本。作为一个行业,我们已经教育普通大众,连续更新不仅是期望的,而且对功能、生产力和安全性都是必需的。

连续更新的这种模式已经成为包括智能电视、家庭助手甚至更新自身的最新路由器在内的所有类型消费设备的标准。尽管汽车行业在采用连续更新策略方面进展缓慢,但特斯拉通过每两周在你家庭网络上更新车辆正在推动这一行业。你再也不需要驾车去车辆服务中心进行召回或关键软件更新。

安全漏洞现在是新的油污泄漏

过去 50 年来,石油泄漏对环境造成了极大的破坏,并且仍然是持续发生的危机。当油钻平台正常运行时,它们带来巨大的利润,但是当事故或自然灾害发生时(尤其是在海上,环境破坏程度被放大时),成本可能是巨大的。对于像 BP 这样的大公司来说,他们能够支付或预留数百亿美元用于罚款、法律和清理工作,石油泄漏只是业务成本的一部分。然而,对于由较小公司运营的钻井作业来说,单一的石油泄漏可能意味着财务灾难,并且没有任何手段来解决事后的问题。

对 Taylor Energy 来说也是如此,他们在2004 年由于Hurricane Ivan失去了路易斯安那州海岸的一个石油平台,并且每天泄漏300 to 700 barrels。这场灾难继续困扰着 Taylor Energy,成为围绕石油泄漏和持续防止努力的多起诉讼的原告和被告。Taylor Energy 已经花费了 4.35 亿美元来减少这场已成为美国历史上最长的石油泄漏的石油泄漏,未来一个世纪仍有可能继续泄漏。

这类似于软件漏洞对技术行业构成的风险。软件系统变得日益复杂,这意味着更多依赖于开源软件和第三方库,这本身是件好事。问题在于,老式的安全审计方法已经不再有效,几乎无法保证系统没有安全漏洞。

根据2021 年 Synopsis 发布的《开源安全与风险分析报告》,开源软件在 99%的企业项目中被使用,其中 84%的项目至少包含一个公共漏洞,平均每个代码库发现 158 个漏洞。

那么这些困扰商业代码库的漏洞到底有多糟糕呢?前十大漏洞使攻击者可以获取敏感信息,如认证令牌和用户会话 Cookie,执行客户端浏览器中的任意代码,并触发拒绝服务条件。

对安全漏洞的组织反应可以分为三个离散的步骤,这些步骤必须按顺序进行以作出响应:

  1. 辨识:首先,组织必须意识到存在安全问题,并且这些问题当前或潜在地可以被攻击者利用。

  2. 修复:一旦确认存在安全问题,开发团队必须提出软件修复程序来修补问题。

  3. 部署:最后一步是部署解决安全问题的软件修复程序,通常是针对受漏洞影响的大量终端用户或目标设备。

回到泰勒能源油污泄漏事件,您可以看到这些步骤在现实世界中有多困难:

  1. 辨识—六年

    飓风发生在 2004 年,但直到六年后的 2010 年,研究人员在泰勒地点观察到持续存在的油污迹象,并引起了公众的关注。

  2. 修复—八年

    Couvillion 集团在 2018 年赢得了一个遏制系统的投标。

  3. 部署—五个月

    2019 年 4 月,Couvillion 集团部署了一个浅层 200 吨钢箱遏制系统。虽然不是永久性解决方案,但这个遏制系统每天收集约 1000 加仑可以重新销售的油,并减少了海洋表面可见的污染物。

与物理灾难如油污泄漏相比,您可能认为安全漏洞相对容易识别、修复和部署。然而,正如我们将在以下案例研究中看到的那样,软件漏洞可能同样具有破坏性和经济成本高昂,并且远比物理灾害更为常见。

英国医院勒索软件

让我们再看另一个安全漏洞。2017 年,发生了一次全球性的网络攻击,这次攻击加密了被黑客攻击的计算机,并要求支付比特币“赎金”以恢复数据。这次攻击利用了美国国家安全局(NSA)一年前泄露的 Windows Server Message Block(SMB)服务上的 EternalBlue 漏洞。

一旦感染,病毒会尝试在网络上复制自身并加密关键文件,阻止其访问,并显示勒索屏幕。微软已经发布了针对此漏洞影响的较旧 Windows 版本的补丁,但由于维护不当或需要 24/7 运行的要求,许多系统没有更新。

受这次勒索软件攻击严重影响的一个组织是英国国家医疗服务体系(NHS)医院系统。其网络上的多达70,000 台设备,包括计算机、MRI 扫描仪、血液存储冰箱和其他关键系统,都受到了病毒的影响。这还涉及将紧急救护服务转移到医院以及至少 139 名因癌症急诊转诊被取消的患者。

WannaCry 勒索软件攻击导致估计19,000 个取消预约,并造成大约 1900 万英镑的产出损失和 7300 万英镑的 IT 成本,用于在攻击后几周内恢复系统和数据。所有受影响的系统都在运行一个未打补丁或不支持的 Windows 版本上,这使得这些系统容易受到勒索软件的攻击。大多数受影响的系统在 Windows 7 上,但也有很多在 2014 年停止支持的 Windows XP 上——比攻击发生前整整三年。

如果我们用我们的漏洞缓解步骤来框定这个问题,我们可以得到以下的时间表和影响:

  1. 辨识——一年

    漏洞的存在和可用的补丁在事件发生前一年就已经存在。英国国家医疗服务体系(NHS)的 IT 工作人员直到世界范围的攻击对 NHS 造成影响后才意识到其存在。

  2. 修复——现有

    由于修复方法只需升级或打补丁系统以应用现有的修复程序,一旦漏洞被识别出来,修复措施就立即可用。

  3. 部署——多年

    虽然关键系统很快恢复在线,但受影响的系统足够多,以至于英国国家医疗服务体系(NHS)花了几年时间才完全升级和打补丁受影响的系统,并且进行了多次失败的安全审计。

在这种情况下,安全漏洞发生在操作系统层面。假设您遵循行业最佳实践,并保持操作系统的维护和持续打补丁,您可能认为自己是安全的。但是关于应用程序级别的安全漏洞呢?这是目前最常见的安全漏洞类型,同样容易被攻击者利用——正如发生在 Equifax 的情况一样。

Equifax 安全漏洞

Equifax 安全泄露是一个应用级安全漏洞给高科技公司造成巨大财务损失的典型案例。2017 年 3 月至 7 月期间,黑客无限制地访问了 Equifax 的内部系统,并能够提取美国总人口一半,即 1.43 亿消费者的个人信用信息。

这可能导致大规模身份盗窃,但没有任何被盗的 Equifax 个人数据出现在暗网上,这是最直接的货币化策略。相反,据信数据被中国政府用于国际间谍活动。2020 年 2 月,四名支持的军事黑客因涉及 Equifax 安全泄露事件而被起诉。

对于一个信用评级机构来说,有这样一个规模的安全漏洞,其品牌和声誉的损害是无法计算的。然而,已知 Equifax 在清理成本上花费了 14 亿美元,并额外支付了 13.8 亿美元来解决消费者索赔。此外,在事件发生后,Equifax 的所有高级管理人员都很快被替换。

多个复合安全漏洞导致了这次数据泄露。最初且最为严重的是 Apache Struts 中一个未打补丁的安全漏洞,允许黑客访问 Equifax 的争议门户。从这里,他们进入了多个其他内部服务器,访问了包含数亿人信息的数据库。

第二个重要的安全漏洞是一个已过期的公钥证书,阻碍了内部系统检查 Equifax 网络出口的加密流量。证书在泄露事件发生前大约 10 个月已经过期,直到 7 月 29 日才更新,Equifax 才立即意识到攻击者正在使用混淆的有效载荷提取敏感数据。以下是 Equifax 的时间轴:

  1. 识别——五个月

    初始安全漏洞发生在 3 月 10 日,虽然攻击者直到 5 月 13 日才开始积极利用这个安全漏洞,但他们在 Equifax 察觉数据外泄之前就已经进入了系统,几乎有五个月的时间。直到 7 月 29 日,Equifax 修复了其流量监控系统,才意识到这次泄露事件。

  2. 修复——现有

    Apache Struts 安全漏洞 (CVE-2017-5638) 于 2017 年 3 月 10 日发布,由 Apache Struts 2.3.32 在 CVE 公开披露前四天的 3 月 6 日发布修复。

  3. 部署——一天

    漏洞修补是在 7 月 30 日进行的,即 Equifax 意识到泄露事件的第二天。

Equifax 的数据泄露尤其令人担忧,因为它始于一个广泛使用的 Java 库中的漏洞,影响了 Web 上的许多系统。即使在识别出安全漏洞一年后,SANS 互联网风暴中心的研究人员 发现了针对未打补丁服务器或未进行安全保护的新部署的攻击尝试。持续更新可以有所帮助。

广泛存在的芯片组漏洞

即使您在应用程序和操作系统级别上跟上了安全漏洞,另一类漏洞也可能影响您的芯片组和硬件水平。最近最普遍的例子就是由 Google 安全研究人员发现的 Meltdown 和 Spectre 漏洞

这些缺陷对我们用来运行从云工作负载到移动设备的硬件平台如此基本,以至于安全研究人员将其称为灾难性。这两种利用都利用了在推测执行和缓存如何交互以获取应该受保护的数据的相同根本漏洞。

在 Meltdown 的情况下,恶意程序可以访问机器上不应该访问的数据,包括具有管理权限的进程。这是一种更容易利用的攻击,因为它不需要了解您试图攻击的程序,但在操作系统级别进行补丁也更容易。

在公布 Meltdown 漏洞后,最新版本的 Linux、Windows 和 Mac OS X 都发布了安全补丁,以防止 Meltdown 被利用,但会有一定的性能损失。2018 年 10 月,英特尔宣布为其更新的芯片(包括 Coffee Lake Refresh、Cascade Lake 和 Whiskey Lake)提供了硬件修复,以解决 Meltdown 的各种变体。

相比之下,利用 Spectre 漏洞需要关于正在攻击的进程的具体信息,使其成为更难以利用的漏洞。然而,补丁也更加棘手,这意味着基于这一漏洞的新攻击持续被发现。它在使用虚拟机的云计算应用中更加危险,因为它可以用来诱使超级管理程序向在其上运行的客户操作系统提供特权数据。

结果是,Meltdown 和特别是 Spectre 打开了一个新的安全漏洞类别,违反了软件安全原则。人们曾经认为,如果您建立了一个拥有适当安全保护措施并且能够完全验证源代码和依赖库正确性的系统,那么该系统应该是安全的。这些利用漏洞通过暴露隐藏在 CPU 和底层硬件中需要进一步分析和软件和/或硬件修复的侧信道攻击,打破了这一假设。

所以回到我们对芯片组侧信道攻击一般类别的分析,这是时间线:

  1. 尽快识别

    虽然对于 Meltdown 和 Spectre 有通用的修复方法,但基于您的应用程序架构,漏洞可能随时发生。

  2. 尽快修复

    Spectre 的软件修复通常涉及特别设计的代码,以避免在误执行中访问或泄漏信息。

  3. 尽快部署

    尽快将修复措施投入生产是减少损害的唯一方法。

在这三个变量中,最容易缩短的是部署时间。如果您尚未制定持续更新的策略,创建一个策略将有望促使您开始计划更快速和更频繁的部署。

引导用户进行更新

现在我们希望已经说服您,无论从功能/竞争的角度还是从安全漏洞的缓解角度来看,持续更新都是一件好事。然而,即使您进行频繁的更新,最终用户是否会接受并安装它们呢?

图 10-1 模拟了决定接受或拒绝更新的用户流程。

显示用户更新流程的流程图

图 10-1. 更新接受用户模型

对于用户来说,第一个问题是基于功能和/或安全修复是否真的需要更新。有时,更新接受的模型不是一个二进制决策,因为可以选择在具有安全补丁的维护线上延迟主要升级,但是延迟提供更大功能但风险更高的主要升级。这是 Canonical 为 Ubuntu 发布使用的模型:长期支持(LTS)版本每两年发布一次,并获得五年公共支持。如果您喜欢更频繁但风险更高的更新,则每六个月会有中间版本发布,但支持周期较短。

第二个问题是,更新有多大风险?对于安全补丁或小的升级,答案通常是低风险,可以在进行最小测试后放入生产环境。通常这些变更很小,专门设计为不触及任何外部甚至内部 API,并经过测试以确保它们解决了安全问题,并且在发布之前不会产生不良副作用。可以执行本地回滚(本章后面将更详细介绍)以减少风险。

当发布方验证升级是安全的时,升级也可能是安全的,就像图 10-1 的第三个决策框中所示那样。这是操作系统升级的模型,例如 iOS,在这种情况下,不能单独验证重大变更是否不会破坏性地影响系统。操作系统供应商必须花费大量时间测试硬件组合,与应用程序供应商合作解决兼容性问题或帮助他们升级其应用程序,并进行用户试验以查看升级过程中可能出现的问题。

最后,如果它既是有风险的,且发布方无法验证其安全性,那么就由升级的接收方进行验证测试。除非可以完全自动化,否则这几乎总是一个难以实施且昂贵的过程。如果无法证明升级是安全且无错误的,那么发布可能会被延迟或仅仅被跳过,希望稍后的发布版本会更加稳定。

让我们看一些真实的用例,并了解它们的持续更新策略。

案例研究:Java 六个月发布节奏

Java 在历史上拥有非常长的主要版本之间的发布周期,平均从一到三年不等。然而,发布频率一直不稳定且经常延迟,例如 Java 7 几乎花了五年时间才发布。随着平台的增长,发布节奏继续下降,由于诸如安全问题、运行和自动化验收测试的难度等多种因素。

从 2017 年 9 月 Java 9 开始,Oracle 进行了戏剧性的转变,转向了六个月的特性发布周期。这些发布可以包含新特性并删除不推荐使用的特性,但总体创新步伐旨在保持恒定。这意味着每个随后的发布应该包含更少的特性和更低的风险,从而更容易被采纳。每个 JDK 发布的实际采纳数据显示在图 10-2 中。

鉴于 67%的 Java 开发者从未超越 2014 年发布的 Java 8,新的发布模型显然存在问题!然而,在这些数据下隐藏着一些问题。

首先,Java 生态系统无法应对六个月的发布周期。正如我们在第六章中了解到的那样,几乎所有 Java 项目都依赖于庞大的库和依赖项生态系统。为了升级到新的 Java 发布版本,所有这些依赖项都需要更新并针对新的 Java 发布版本进行测试。对于大型开源库和复杂应用服务器来说,几乎不可能在六个月的时间内完成这项工作。

显示每个 Java 发布采用率的图表

图 10-2. 开发者对最近的 Java 发布的采纳情况¹

更甚的是,OpenJDK 支持模型仅为每个 Java 发布提供六个月的公共支持,直到下一个特性发布出现。即使您每六个月升级一次,您仍会缺少关键的支持和安全补丁,详细信息请参阅Stephen Colebourne 的博客

唯一的例外是从 Java 11 开始每三年发布的 LTS 版本。这些版本将获得来自商业 JDK 供应商(如 Oracle、Red Hat、Azul、BellSoft、SAP 等)的安全补丁和支持。像 AdoptOpenJDK 和 Amazon Corretto 这样的免费分发承诺提供 Java 发布和安全补丁,无需支付任何费用。这就是为什么 Java 11 是 Java 8 之后最流行的版本,并且其他六个月发布的版本都没有获得任何市场份额。

然而,与 Java 8 相比,Java 11 并未获得显著的市场份额。在 Java 11 自 2018 年 9 月发布两年后,使用 Java 11 的开发者比例为 25%。相比之下,Java 8 发布两年后的采用率为 64%,如 Figure 10-3 所示。这种比较也偏向于 Java 11,因为任何采用了 Java 9 或 10 的人可能已经升级到 Java 11,并提供了三年的采用增长。

显示 Java 8 采用率增长的图表

Figure 10-3. Java 8 发布两年后的开发者采用²

这将我们带到 Java 9 及以后版本采用率低的第二个原因,即价值成本比较差。Java 9 的主要特性是引入了一个新的模块化系统。Java 平台模块化的想法最早由 Mark Reinhold 在 2008 年提出,并在 Java 9 的发布中经历了九年的完善。

由于这一变化的复杂性和颠覆性,它被推迟了多次,错过了 Java 7 和 Java 8 最初的目标。此外,Java 9 在发布时非常有争议,因为它最初与由 Eclipse Foundation 发布的竞争模块系统 OSGi 不兼容,该系统针对企业应用。

但或许模块化的更大问题在于,实际上没有人真正需要它。模块化有许多好处,包括更好的库封装、更容易的依赖管理和更小的打包应用程序。然而,要完全实现这些好处,您需要花费大量的工作来重写您的应用程序以完全模块化。其次,您需要将所有依赖项打包为模块,这在开源项目中采用了一段时间。最后,对于大多数企业应用程序来说,实际好处有限,因此即使在升级到支持模块的版本后,常见的做法是禁用模块化并返回到 Java 8 和之前的类路径模型。Figure 10-4 显示了升级至 Java 9 及更高版本时简化的开发者思维过程。

显示 Java 开发者更新流程的流程图

Figure 10-4. Java 发布接受用户模型

显然,选择是否升级最终取决于您在模块化或其他新引入功能中的价值比较,与升级后测试应用程序的难度成本密切相关,这也引出了我们的第一个持续更新最佳实践。

案例研究:iOS App Store

自 1990 年以来,我们的内容更新模型与 Tim Berners-Lee 创建的第一个 Web 浏览器 WorldWideWeb 有着很大不同。使用客户端-服务器模型,内容可以动态检索并持续更新。随着 JavaScript 和 CSS 技术的成熟,这变成了一个可行的应用程序交付平台,用于持续更新的应用程序。

相比之下,虽然桌面客户端应用程序在用户界面上相对复杂且丰富,但更新不频繁且手动。这在 2000 年代中期之前,要么选择难以在现场更新的丰富客户端应用程序,要么选择可以持续更新以添加新功能或修补安全漏洞的简单 Web 应用程序。如果您现在是一个持续更新的支持者(而你现在应该是),您就知道哪个是赢家。

然而,2008 年 iPhone 上的 App Store 彻底改变了这一切,这对于在手机和其他设备上部署丰富客户端应用程序来说是一个重大的变革。以下是 App Store 提供的功能:

一键更新

更新桌面应用程序需要退出运行的版本,按照某种引导向导浏览众多选择(例如桌面快捷方式、开始菜单、可选包),通常在安装后重新启动计算机。Apple 简化了这一过程,只需点击一个按钮进行更新,对于多个更新,还提供了一键批量更新选项。应用程序更新下载完成后,您的移动应用程序退出,并且在后台安装,完全无需用户干预。

只有一个版本:最新版

您知道您正在运行哪个版本的 Microsoft Office 吗?直到 2011 年 Office 365 发布之前,您必须知道,并且可能在过去三到五年(或更长时间内)没有进行过升级。Apple 通过在应用商店中提供仅有的最新版本来改变了这一切,因此您无需选择升级到哪个版本。此外,您甚至无法提供版本号以供参考,所以您唯一知道的就是您正在使用最新版本,并且开发人员提供了一些关于您将得到什么的笔记。最后,一旦拥有应用程序,升级是没有任何费用的,因此完全消除了有偿桌面应用程序升级的财务劣势。

内置安全性

虽然安全漏洞是安装补丁的头等大事,但安全问题也是不升级的头等理由。如果新软件中发现了漏洞,第一个升级的风险就会增加,这就是为什么企业 IT 政策通常禁止终端用户在一定时间内升级他们的桌面应用程序。然而,苹果通过集成一个沙箱模型来解决这个问题,其中安装的应用程序在未经明确许可的情况下受到数据、联系人、照片、位置、相机和许多其他功能的限制。这与苹果为开发者在应用商店提交的应用程序实行的严格审查流程相结合,将恶意软件和应用程序病毒减少到了几乎不再是消费者升级应用程序时的一个问题。

可靠的简单升级选项使升级决策变得简单。加上发布由可信的机构验证这一事实,用户几乎总是会选择升级,正如图 10-5 所示。

展示消费者应用更新流程的流程图

图 10-5. iOS 应用更新接受的用户模型

苹果应用商店模型不仅在移动设备上广泛存在,也在桌面应用程序安装中占据了一席之地。Google 在 2008 年推出了类似的模型,其 Android 操作系统也提供了类似功能,而苹果和微软则在 2011 年推出了桌面应用商店。许多这些应用商店不仅使升级到最新版本变得简单,还提供了自动升级的选项。

因此,自更新应用程序现在在移动设备上已成为常态,并在桌面计算机上也因为一些基本的连续更新最佳实践而得到复兴。

连续运行时间

在云时代,企业成功的一个重要指标是服务的运行时间。许多公司不仅仅提供软件,而是转向软件即服务(SaaS)模型,他们还负责软件运行的基础设施。服务意外中断不仅可能违反服务级别协议,还可能影响客户的满意度和保留率。

尽管对于所有企业提供的互联网服务来说,持续运行时间都很重要,但在那些构建和支持互联网所依赖的基础设施的公司中,持续运行时间的重要性无与伦比。让我们深入研究一下全球基础设施中运行的互联网超级巨头之一,该公司支持全球超过 10%的网站以及我们日常依赖的大量应用和服务。

案例研究:Cloudflare

随着互联网使用量的激增,对于高度可靠、全球分布和集中管理的基础设施,如内容分发网络(CDN)的需求也在增加。Cloudflare 的业务是向全球企业提供高度可靠的内容传递基础设施,并承诺可以比您自己的基础设施或云计算服务器更快、更可靠地传递内容。这也意味着 Cloudflare 有一个任务,就是永远不会宕机。

虽然 Cloudflare 多年来出现过许多生产问题,涉及 DNS 宕机、缓冲区溢出数据泄漏和安全漏洞,但随着其业务的增长,问题的规模和造成的损害也在增加。其中五次宕机发生在全球范围内,导致了互联网的日益大规模中断。虽然许多人可能暗自高兴能够在持续的更新失败中(之后我们迅速在 Twitter 上抱怨),享受 30 分钟的互联网休息,但失去对全球数亿台服务器的访问权限可能会对企业造成严重的干扰和巨大的财务损失。

我们将关注 Cloudflare 最近三次全球宕机事件,发生了什么以及如何通过持续更新最佳实践来防止这些事件。

2013 年 Cloudflare 路由器规则宕机

2013 年,Cloudflare 在 14 个国家的 23 个数据中心为 785,000 个网站和每月 1000 亿次页面浏览提供服务。在 UTC 时间 3 月 3 日 9:47 时,Cloudflare 发生了系统范围的宕机,影响了其所有数据中心,使其从互联网上消失。

宕机发生后,诊断问题大约需要 30 分钟,并在 UTC 时间 10:49 时全部服务恢复。宕机是由一个部署到所有数据中心边缘的 Juniper 路由器上的错误规则引起的,如示例 10-1 所示。它旨在阻止一个具有异常大数据包范围(99,971 到 99,985 字节)的持续分布式拒绝服务(DDos)攻击。从技术上讲,这些数据包会在击中网络后被丢弃,因为允许的最大数据包大小为 4,470,但此规则旨在在影响其他服务之前在边缘停止攻击。

示例 10-1. 导致 Cloudflare 路由器崩溃的规则
+    route 173.X.X.X/32-DNS-DROP {
+        match {
+            destination 173.X.X.X/32;
+            port 53;
+            packet-length [ 99971 99985 ];
+        }
+        then discard;
+    }

这一规则导致了 Juniper 边缘路由器消耗所有 RAM 直到崩溃。移除有问题的规则解决了问题,但许多路由器处于无法自动重启并需要手动电源循环的状态。

虽然 Cloudflare 指责 Juniper 网络及其 FlowSpec 系统,该系统在大型路由器群集上部署规则,但 Cloudflare 是一家在硬件上部署未经测试规则的公司,在失败情况下无法进行故障转移或回滚。

2019 年 Cloudflare 正则表达式宕机

到 2019 年,Cloudflare 已经成长为托管 1600 万个互联网属性、服务 10 亿个 IP 地址,并且总体上支持 10%的财富 1000 强企业。该公司在未出现全球性中断的六年中表现非常出色,直到 UTC 时间 7 月 2 日 13:42 时,Cloudflare 代理的域名开始返回 502 Bad Gateway 错误,并持续停机了 27 分钟。

这次的根本原因是一个错误的正则表达式(regex),如例子 10-2 所示。当这个新规则被部署到 Cloudflare 的 Web 应用防火墙(WAF)时,导致处理全球 HTTP/HTTPS 流量的所有核心的 CPU 使用率飙升。

例子 10-2. 导致 Cloudflare 停机的正则表达式
(?:(?:\"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)
|\`|\-|\+)+[)]*;?((?:\s|-|~|!|{}|\|\||\+)*.*(?:.*=.*)))

就像任何一个好的正则表达式一样,没有人能够阅读和理解一系列不可理解的符号,当然也没有机会通过视觉来验证其正确性。回顾起来,明显的是正则表达式中错误的部分是.\\*(?:.*=.\*)。由于部分是非捕获组,在这个 bug 的情况下,它可以简化为.*.\*=.*。使用双重、非可选的通配符(.*)在正则表达式中被认为是性能问题,因为它们必须执行回溯,而随着要匹配的输入长度的增加,回溯变得超线性地更加困难。

鉴于手动验证部署到全球基础设施的错误的困难程度,你会认为 Cloudflare 已经从其 2013 年的中断中吸取了教训并实施了渐进式交付。事实上,它自那时以来已经实施了一个复杂的渐进式交付系统,涉及三个阶段:

DOG 存在点

新变更的第一道防线仅由 Cloudflare 员工使用。变更首先在此处部署,以便员工在其进入真实世界之前能够检测到问题。

PIG 存在点

一个 Cloudflare 环境,用于一小部分客户流量;可以在不影响付费客户的情况下测试新代码。

金丝雀存在点

作为变更全球部署的最后一道防线,有三个全球性金丝雀环境会收到一个子集的全球流量。

不幸的是,WAF 主要用于快速威胁响应,因此绕过了所有这些金丝雀环境(如金丝雀发布设计模式中定义的那样),直接进入了生产环境。在这种情况下,正则表达式仅通过一系列单元测试,这些测试未检查 CPU 耗尽,然后被推送到了生产环境。这个特定的变更不是紧急修复,因此可以按照前述过程进行分阶段部署。

问题及其后续修复的确切时间轴如下:

  1. 13:31—同行评审的正则表达式代码检查。

  2. 13:37—CI 服务器构建了代码并运行了测试,测试通过了。嗯,显然这些并不怎么样。¯\_(ツ)_/¯

  3. 13:42—错误的正则表达式被部署到了生产环境的 WAF 中。

  4. 14:00—排除了攻击者的可能性,WAF 被确定为根本原因。

  5. 14:02—决定进行全球 WAF 关闭。

  6. 14:07—在访问内部系统的延迟后,最终执行了关闭操作。

  7. 14:09—为客户恢复了服务。

总结一下,让我们回顾一下可能帮助 Cloudflare 避免另一场全球宕机的持续更新最佳实践。

2020 年 Cloudflare 骨干网宕机

在上一次 Cloudflare 宕机一年后,作者 Stephen 坐下来写关于 2019 年宕机的文章,这时发生了两件奇怪的事情:

  1. 大约下午 2:12(PST,21:12 UTC),家庭 Discord 频道因 Cloudflare 宕机停止,随后我变得非常高效。

  2. 几小时后,我关于 Cloudflare 宕机的信息搜索开始出现关于最近 DNS 问题的信息,而不是去年的文章。

Cloudflare 的好心人显然认识到好的案例研究通常是三的倍数,并为本章提供了另一个反模式。2020 年 7 月 18 日,Cloudflare 又发生了一次 27 分钟的生产宕机,影响了其总网络的 50%。

这次问题出在 Cloudflare 的骨干网上,用于在其网络中在主要地理位置之间路由大部分流量。要了解骨干网是如何工作的,了解互联网的拓扑结构是有帮助的。互联网不是点对点的,而是依赖于一个复杂的互联数据中心网络来传输信息。

Cloudflare 在旧金山、亚特兰大、法兰克福、巴黎、圣保罗和世界其他城市运营多个数据中心。这些数据中心通过全球骨干网的直连高速连接相互连接,使其能够绕过互联网拥堵,并提高主要市场之间的服务质量。

这次宕机的原因是 Cloudflare 的骨干网。该骨干网设计为抗故障,例如 20:25 UTC 发生在纽瓦克和芝加哥之间的故障。然而,这次宕机导致亚特兰大和华盛顿之间的拥堵加剧。尝试的解决办法是通过在示例 10-3 中执行路由更改,将部分流量从亚特兰大移除。

示例 10-3。导致 Cloudflare 网络宕机的路由更改
{master}[edit]
atl01# show | compare
[edit policy-options policy-statement 6-BBONE-OUT term 6-SITE-LOCAL from]
!       inactive: prefix-list 6-SITE-LOCAL { ... }

此次路由更改使一个术语脚本的线路失效,如示例 10-4 所示。

示例 10-4。进行更改的完整术语
from {
    prefix-list 6-SITE-LOCAL;
}
then {
    local-preference 200;
    community add SITE-LOCAL-ROUTE;
    community add ATL01;
    community add NORTH-AMERICA;
    accept;
}

正确的更改应当是使整个术语失效。然而,通过移除prefix-list行,结果是将该路由发送到所有其他骨干路由器。这将local-preference更改为 200,使亚特兰大优先于其他路由,其他路由的优先级设为 100。结果是,亚特兰大不仅未减少流量,反而开始吸引骨干网中的流量,增加了网络拥堵,导致 Cloudflare 网络的互联网服务中断了一半。

有很多关于配置更改可能摧毁整个业务的内容。问题的核心在于 Cloudflare 没有将骨干路由器的配置视为需要进行适当的对等审查、单元测试和金丝雀部署的代码。

手动更新的隐藏成本

实施持续更新的最佳实践并不是免费的,而且往往看起来推迟自动化和持续手动流程更具成本效益。特别是,进行自动化测试、将配置视为代码以及自动化部署都很重要,但也很昂贵。

然而,自动化部署的隐藏成本是什么?手动部署充满错误和失误,需要花费时间和精力来排除故障,并在对客户产生负面影响时造成业务损失。在员工被调去现场系统解决问题的几小时内,生产错误的成本是多少?

在 Knight Capital 的案例中,结果是每分钟系统故障损失 1000 万美元,你会信任手动更新吗?

案例研究:Knight Capital

Knight Capital 是一个软件缺陷未被检测的极端案例,导致生产问题,并造成巨额财务损失。然而,有趣的是,这个错误的核心问题是在部署过程中犯下的错误,这个过程既不频繁也是手动的。如果 Knight Capital 进行持续部署,就能避免一个错误,这个错误导致了其损失了 4.4 亿美元和对公司的控制。

Knight Capital Group 是一家专门从事高交易量交易的做市商,2011 年和 2012 年期间,其美国股票交易占据了市场交易量的约 10%。该公司有几个内部系统来处理交易处理,其中之一称为智能市场访问路由系统(SMARS)。SMARS 作为经纪商,从其他内部系统接收交易请求,并在市场上执行这些请求。

为了支持于 2012 年 8 月 1 日启动的新零售流动性计划(RLP),Knight Capital 升级了其 SMARS 系统以添加新的交易功能。它决定重用一个废弃的名为 Power Peg 的函数的 API 标志,该函数仅用于内部测试。据信,在 RLP 推出前的一周,这个变更已成功部署到了所有八台生产服务器上。

在美东时间上午 8:01 开始,8 月 1 日早晨以一些可疑但遗憾地被忽略的电子邮件警告开头,这些警告涉及前市交易订单中的错误,引用了 SMARS 并警告“Power Peg 已禁用”。一旦美东时间上午 9:30 开始交易,SMARS 立即开始执行大量可疑交易,将重复以高价买入(在报价处)和低价卖出(在买价处),立即在价差上亏损。这些交易以 10 毫秒的间隔排队,因此即使金额很小(每对交易 15 美分),损失也迅速堆积

在一个每秒都可能成本高昂,每分钟可能抹去数周收入,每小时都是一生的业务中,Knight Capital 缺乏紧急响应计划。在这 45 分钟内,它执行了 400 万笔订单,交易了 1.54 亿股 154 只股票。这使得公司形成了 34 亿美元的净多头头寸和 31.5 亿美元的净空头头寸。在将 154 只股票中的 6 只股票逆转并出售剩余头寸后,公司最终遭受了约 4.68 亿美元的净损失。这对 Knight Capital 来说是一个非常艰难的时期。

回溯到问题的根本原因,八台生产服务器中只有七台正确升级了新的 RLP 代码。最后一台服务器仍然启用了旧的 Power Peg 逻辑,并且在同一个 API 标志上,这解释了早上早些时候的警告邮件。每次请求命中这第八台服务器时,都会运行设计用于内部测试的算法,执行数百万次低效交易,旨在迅速提高股票价格。

然而,在解决这个问题时,技术团队错误地认为新部署的 RLP 逻辑存在 bug,并将其他七台服务器上的代码还原,实质上破坏了 100%的交易,加剧了问题。

虽然 Knight Capital 并没有因此完全破产,但它不得不出售公司 70%的控制权,以获得 4 亿美元的公司救助。年底之前,这转变为被竞争对手 Getco LLC 收购,并导致 CEO 托马斯·乔伊斯辞职。

所以,Knight Capital 发生了什么,你又该如何避免这样的灾难?请参阅下一个侧边栏,了解一些额外的持续更新最佳实践。

持续更新最佳实践

现在你已经看到了不采纳各种技术行业不同领域公司的持续更新最佳实践的危险,显而易见为什么你应该开始实施或继续改进你的持续部署基础设施。

以下是所有持续更新最佳实践的列表,以及更详细介绍它们的案例研究:

  • 频繁更新

    • 要变得擅长更新,唯一的方法就是经常这么做。

    • 案例研究:iOS 应用商店,Knight Capital。

  • 自动更新

    • 如果你频繁更新,自动化将变得更便宜且错误更少。

    • 案例研究:iOS 应用商店。

  • 自动化测试

    • 确保部署质量的唯一方法是在每次更改时对所有内容进行测试。

    • 案例研究:Java 六个月发布周期,2020 Cloudflare 骨干网络故障。

  • 渐进式交付

    • 通过向生产的一小部分部署并制定回滚计划来避免灾难性故障。

    • 案例研究:2013 Cloudflare 路由器规则故障,2019 Cloudflare 正则表达式故障。

  • 状态感知

    • 不要假设只有代码需要测试;状态的存在可能在生产环境中造成严重破坏。

    • 案例研究:Knight Capital。

  • 可观测性

    • 不要让客户成为通知您服务中断的人!

    • 案例研究:2019 Cloudflare 正则表达式故障,2020 Cloudflare 骨干网络故障。

  • 本地回滚

    • 边缘设备通常数量众多,一旦受到糟糕更新的影响,难以修复,因此始终设计具备本地回滚能力。

    • 案例研究:2013 Cloudflare 路由器规则故障。

现在您已经掌握了知识,是时候开始说服您的同事在今天采纳最佳实践,而不是成为高科技行业的下一个 Knight Capital 的“Knightmare”头条新闻。成为 DevOps 行业的精英表现者,而不是出现在注册表的头版。不要试图一口吃掉整个大海,但小而持续的改进举措最终会使您的组织实现持续更新。祝您好运!

¹ Brian Vermeer,“2020 年 JVM 生态系统报告”,Snyk,2020 年,https://oreil.ly/4fN74

² Eugen Paraschiv,“2016 年 3 月 Java 8 采用情况”,最后修改于 2022 年 3 月 11 日,https://oreil.ly/ab5Vv

posted @ 2024-06-15 12:22  绝不原创的飞龙  阅读(39)  评论(0编辑  收藏  举报