Java-微服务与-SRE-全-

Java 微服务与 SRE(全)

原文:zh.annas-archive.org/md5/5840805588a9d2f06db4b8012a31e970

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书提出了一种分阶段的方法来构建和部署更可靠的 Java 微服务。每一章介绍的功能都应该按顺序执行,每个功能都是建立在前面章节功能基础之上的。在这个旅程中有五个阶段:

  1. 为可用性测量和监控你的服务。

  2. 添加调试信号,让你能够询问关于不可用期间的问题。

  3. 改进你的软件交付流水线,以减少引入更多故障的机会。

  4. 构建能力,观察部署资产的状态一直到源代码。

  5. 添加足够的流量管理,将你的服务提升到你满意的可用性水平。

我们的目标不是建立一个完美的系统,消除所有的故障。我们的目标是最终拥有一个高度可靠的系统,并避免在递减回报的空间中花费时间。

避免递减回报是我们将花费如此多时间谈论有效的测量和监控的原因,以及为什么这个学科在所有其他学科之前。

如果你是一名工程管理人员,第一章就是你的使命宣言:建立一个以可靠性和有效的平台工程团队文化而闻名的应用平台,该团队可以将这些能力传递给更广泛的工程组织。

接下来的章节包含了实现这一使命的蓝图,针对工程师进行了定位。这本书故意将范围限定在Java微服务上,以便我可以提供关于如何做到这一点的详细建议,包括针对 Java 微服务经过战斗考验的具体测量数据、代码示例和其他像依赖管理这样的与 Java 虚拟机(JVM)独有的特殊问题。它的重点是立即可操作的。

我的旅程

我在软件工程领域的职业旅程形成了一条弧线,最终导致我写下了这本书:

  • 一个积极进取的自定义软件初创公司

  • 一个名为密苏里的 Shelter Insurance 的传统保险公司

  • 硅谷的 Netflix

  • 一位远程工作的 Spring 团队工程师

  • 一位 Gradle 工程师

当我离开 Shelter Insurance 时,尽管我努力过,我并不理解公共云。在那里的近七年中,我与同一组命名的虚拟机(实际上是裸金属,最初)进行了互动。我习惯于季度发布周期和发布前广泛的手动测试。我觉得领导强调并再次强调我们对代码冻结的期望有多“艰难”,以及在发布后代码冻结不如我们希望的那样严格,等等。我从未经历过应用程序的生产监控——这是网络运营中心的责任,我的门禁卡不需要我知道那里发生了什么。从大多数标准来看,这个组织是成功的。自那时以来,在某些方面它发生了重大变化,而在其他方面几乎没有变化。我很感谢在那里有机会向一些了不起的工程师学习。

在 Netflix,我学到了有关工程和文化的宝贵经验。一段时间后,我带着对能够将这些实践应用于像 Shelter Insurance 这样的公司的希望加入了 Spring 团队。当我创建开源度量库 Micrometer 时,我深深感激于这样一个事实:组织是一个旅程。Micrometer 的前五个监控系统实现中包含三个我知道仍然广泛使用的传统监控系统,而不仅仅是支持当今最佳的监控系统。

数年来,与各种规模的企业合作并为其提供关于应用程序监控和交付自动化的建议,使我了解了组织动态的多样性及其共性。我理解到这些共性,即每个企业都能从中受益的实践和技术,构成了本书的实质内容。每个企业的 Java 组织都可以在一定的时间和实践下应用这些技术。这包括你的组织。

本书中使用的约定

本书中使用以下排版约定:

斜体

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

固定宽度

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

固定宽度粗体

显示用户应直接输入的命令或其他文本。

固定宽度斜体

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

提示

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

注意

这个元素表示一般注释。

警告

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

致谢

Olga Kundzich

在写这本书之前,我不知道一个作者同事圈子的声音会如何渗入书中。当然这完全说得通。我们通过共同工作相互影响!Olga 在广泛话题上的深刻见解,在过去几年对我的思维产生了最大的单一影响,她的声音无处不在这本书中(至少是我能够代表的最好的近似)。你会在书中找到关于“应用平台”、“持续交付”(不,不是持续部署——我总是混淆这两个词)、资产清单、监控以及流量管理的想法,这些都深受她的影响。谢谢 Olga 为这本书投入如此多的精力。

Troy Gaines

对于 Troy,我要感谢他最初介绍我依赖管理、构建自动化、持续集成、单元测试等许多其他重要技能。他是我作为软件开发人员成长的早期和重要影响,我知道他也对许多其他人有着同样的影响。谢谢你,老朋友,花时间审阅这份工作。

Tommy Ludwig

Tommy 是少有的既对分布式跟踪又对聚合度量技术做出贡献的遥测专家之一。在可观察性领域,贡献者通常会过于专注于某个领域,而 Tommy 是少数几个在它们之间游走的人之一。委婉地说,我害怕 Tommy 对第三章的审阅,但很高兴发现我们在这方面的共同点比我预期的要多。感谢指出分布式跟踪标签基数的更微妙观点,这些观点已经进入第三章。

Sam Snyder

我还不认识 Sam 多久,但我很快就明白 Sam 是一个优秀的导师和耐心的老师。谢谢 Sam 同意承担审阅技术书籍这一艰巨任务,并留下了许多积极和鼓励的反馈。

Mike McGarr

我在 2014 年收到了 Mike 的一封突然的电子邮件,不久之后,我就收拾了一切,搬到了加利福尼亚。那封电子邮件使我走上了一条改变一切的道路。我认识了 Netflix 的许多专家,因为 Mike 给了我一个机会,加速了我的学习过程。这彻底改变了我对软件开发和运维的看法。Mike 也是一个了不起的人——一个友善好奇的朋友和领导。谢谢,Mike。

Josh Long

书中有一次,我引用了 Josh Long 关于“没有像”产品的典型说法。我以为我很机智,很有趣。然后 Josh 写了一篇以巴斯光年为特色的序言……Josh 是一股不可阻挡的能量。谢谢 Josh,因为你为这项工作注入了一些那种能量。

第一章:应用平台

马丁·福勒和詹姆斯·刘易斯最初在他们的 博客文章 中定义了微服务这一架构术语:

…作为一种特定的软件应用设计方式,由一套独立可部署的服务组成。虽然对于这种架构风格没有精确的定义,但围绕业务能力的组织、自动化部署、端点智能以及语言和数据的分散控制是其共同特征。

采用微服务承诺通过将应用程序分割为由独立团队开发和部署的组件来加速软件开发。它减少了协调和规划大规模软件发布的需求。每个微服务由独立团队构建,以满足特定的业务需求(用于内部或外部客户)。微服务以冗余、水平扩展的方式部署在不同的云资源上,并使用不同的协议通过网络相互通信。

由于这种架构,出现了一些挑战,这些挑战在以前的单体应用中没有出现过。单体应用过去主要部署在同一服务器上,并且很少作为一个精心协调的事件发布。软件发布过程是系统中变化和不稳定性的主要源泉。在微服务中,通信和数据传输成本引入了额外的延迟和可能降低最终用户体验的潜力。数十甚至数百个微服务现在共同工作来创建这种体验。微服务独立发布,但每一个微服务都可能无意中影响其他微服务,从而影响最终用户体验。

管理这些类型的分布式系统需要新的实践、工具和工程文化。加速软件发布不需要以稳定性和安全性为代价。事实上,它们是相辅相成的。本章介绍了高效平台工程团队的文化,并描述了可靠系统的基本构建块。

平台工程文化

要管理微服务,一个组织需要标准化特定的通信协议和支持框架。如果每个团队都需要维护自己的全栈开发,或者在与分布式应用的其他部分通信时存在摩擦,那么就会产生许多低效。实践中,标准化导致一个专注于为其他团队提供这些服务的平台团队,而这些团队又专注于开发满足业务需求的软件。

我们希望提供的是防护栏,而不是门。

Netflix 的工程工具总监戴安娜·马什

不是建造门,而是允许团队首先构建适合他们的解决方案,从中学习,并推广到整个组织。

设计系统的组织被限制为产生与这些组织的通信结构的副本相符的设计。

康威定律

图 1-1 显示了围绕专业领域构建的工程组织。一个团队专门负责用户界面和体验设计,另一个团队负责构建后端服务,另一个团队负责管理数据库,另一个团队负责业务流程自动化,另一个团队负责管理网络资源。

srej 0101

图 1-1. 围绕技术专业领域构建的组织

康威定律常常被解读为,跨职能团队,就像图 1-2 中所示的那样,可以更快地迭代。毕竟,当团队结构与技术专业化相一致时,任何新的业务需求都需要跨越所有这些专业领域进行协调。

srej 0102

图 1-2. 跨职能团队

显然,这个系统中也存在浪费,具体来说,每个团队的专家都在独立开发能力。Netflix 没有为每个团队配备专门的站点可靠性工程师,就像 Google 在 Site Reliability Engineering 中所推广的那样,该书由 Betsy Beyer 等人(O'Reilly)编辑。也许是因为产品团队编写的软件类型更加同质化(主要是 Java,主要是无状态的横向扩展的微服务),产品工程功能的集中化更有效率。你的组织更像是 Google,致力于从自动驾驶汽车到搜索再到移动硬件再到浏览器等非常不同类型的产品吗?还是更像 Netflix,由一系列以少数几种语言编写、在有限种类平台上运行的业务应用程序组成?

跨职能团队和完全孤立的团队只是一个谱的两端。有效的平台工程可以减少团队专家对某些问题的需求。一个拥有专门平台工程的组织更像是一个混合体,就像图 1-3 中所示的那样。当中央平台工程团队把产品团队视为需要不断赢得的客户,并且几乎不对其客户的行为进行控制时,中央平台工程团队的实力最强。

srej 0103

图 1-3. 产品团队配备了专门的平台工程

例如,当监控工具分布在整个组织中作为每个微服务中的常见库时,它共享了已知广泛适用的可用性指标的宝贵知识。每个产品团队只需花费一点时间添加适合其业务领域的可用性指标。根据需要,它可以与中央监控团队沟通获取信息和建议,以建立有效的信号。

在 Netflix,最强大的文化潮流是“自由和责任”,这在 2001 年的某个文化宣言中有所定义。我是工程工具团队的一员,但我们不能要求其他人采用特定的构建工具。一小部分工程师团队管理着许多产品团队的 Cassandra 集群。这种集中构建工具或 Cassandra 技能的效率,形成了一个自然的沟通枢纽,通过它,这些产品的不同问题得以解决,教训传递给以产品为中心的团队。

在 Netflix,构建工具团队在其最小点时只有两名工程师,为大约 700 名其他工程师服务,同时在推荐的构建工具(从 Ant 到 Gradle)之间进行过渡,并进行了两次重要的 Java 升级(从 Java 6 到 7,然后从 Java 7 到 8),以及其他日常例行事务。每个产品团队完全拥有自己的构建。由于“自由和责任”的原则,我们无法设定一个硬性日期来完全淘汰基于 Ant 的构建工具。我们也无法设定一个硬性日期来要求每个团队升级其 Java 版本(除非新的 Oracle 许可模型为我们做了这件事)。文化驱使我们如此专注于开发者体验,以至于产品团队愿意与我们一起迁移。这需要一种努力和共情,只有绝对阻止我们设定硬性要求才能保证。

当像我这样的平台工程师为许多不同的产品团队服务,专注于构建工具这样的技术专业领域时,不可避免地会出现模式。我的团队看到相同的脚本一次又一次地播放,涉及二进制依赖问题、插件版本控制、发布工作流问题等。我们最初致力于自动发现这些模式并在构建输出中发出警告。如果没有自由和责任的文化,也许我们会跳过警告,直接失败构建,要求产品团队修复问题。对构建工具团队来说,这会令人满意——我们不必对我们试图警告团队的失败相关问题负责。但从产品团队的角度来看,构建工具团队学到的每一个“教训”都可能在他们的时间表上随机出现,并且特别是在他们有更紧迫(即使是暂时的)的优先事项时尤为具有破坏性。

更为柔和、不会失败的警告方法出人意料地没有效果。团队几乎从不关注成功构建日志,无论发出多少警告。即使看到了警告,尝试修复它们也存在风险:带有警告的工作构建要比没有警告但行为不端的构建更好。因此,精心制作的弃用警告可能被忽视数月甚至数年。

“护栏而非大门”的方法要求我们的构建工具团队考虑如何以对产品团队可见、需要少量时间和精力行动,并减少随我们一起走的风险的方式分享知识。从中产生的工具几乎过于关注开发人员体验。

首先,我们编写了工具,可以重写 Gradle 构建的 Groovy 代码以自动修复常见模式。这比仅在日志中发出警告要困难得多。它需要对命令式构建逻辑进行保持缩进的抽象语法树修改,这是一般情况下无法解决的问题,但在特定情况下效果显著。自动修复是选择加入的,通过产品团队可以运行的简单命令来接受建议。

接下来,我们编写了监控仪器,报告了潜在可纠正但产品团队未接受建议的模式。我们可以随时间监控组织中的每种有害模式,看到随着团队接受纠正措施其影响逐渐减少。当我们到达一小部分坚持不接受的团队时,我们知道他们是谁,因此我们可以走到他们的桌子前,与他们一对一地合作,倾听他们的顾虑并帮助他们向前迈进。(我做了足够多的这种工作,开始随身携带自己的鼠标。Netflix 工程师使用轨迹球的和那些长期拒绝接受纠正措施的 Netflix 工程师之间有一个可疑的相关性。)最终,这种积极的沟通建立了信任的纽带,使我们未来的建议显得不那么冒险。

我们采取了相当极端的措施来提高建议的可见性,而不会通过破坏构建来引起开发人员的注意。构建输出被精心着色和设计,有时还带有视觉指示,如难以忽视的 Unicode 勾号和X标记。建议总是出现在构建的最后,因为我们知道它们是终端上最后输出的内容,我们的 CI 工具默认在工程师检查构建输出时滚动到日志输出的末尾。我们教会了 Jenkins 如何伪装成 TTY 终端来给构建输出着色,但忽略光标移动的转义序列以便依然串行化构建任务进度。

制定这种类型的经验在技术上成本高昂,但与两个选项相比:

自由和责任文化

带领我们通过监控建立了自助自动修复功能,帮助我们理解和与困难团队沟通。

集中控制文化

如果我们“拥有”构建体验,我们可能会急于分解构建。团队将因为要满足我们对一致构建体验的渴望而分心于其他优先事项。由于缺乏自动修复功能,每次变更都会导致更多问题向构建工具团队提出。每次变更的操作量都会大大增加。

一个有效的平台工程团队对开发者体验非常关注,这是至少与产品团队对客户体验的关注同等重要的一个焦点。这应该不足为奇:在一个良好校准的平台工程组织中,开发者就是客户!拥有健康的产品管理学科,专业的用户体验设计师以及深入关注他们工艺的 UI 工程师和设计师,这些都应该是平台工程团队为了他们的开发者客户利益而对齐的指标。

关于团队结构的更多细节超出了本书的范围,请参考《团队拓扑》(Matthew Skelton 和 Manuel Pais 著,IT Revolution Press 出版)以深入了解该主题。

一旦团队在文化上达到一致,问题就变成了如何优先考虑平台工程团队可以向其客户群体提供的能力。本书的其余部分呼吁行动,按照(我认为)最重要到不那么重要的顺序列出能力。

监控

监控应用基础架构需要最少的组织承诺,是通向更加弹性系统的旅程中所有阶段中最小的组织承诺。正如我们将在后续章节中展示的那样,框架级别的监控仪器化已经发展到了只需打开开关并开始利用的程度。成本效益比已经严重倾向于利益,如果在本书中没有做任何其他事情,请立即开始监控您的生产应用。第二章 将讨论指标构建块,而第四章 将提供您可以使用的具体图表和警报,主要基于 Java 框架提供的仪器化,无需额外工作。

指标、日志和分布式追踪是三种可观察性形式,可用于测量服务可用性并帮助调试复杂的分布式系统问题。在深入研究它们的工作原理之前,了解它们各自能够提供的能力非常有用。

可用性监控

可用性信号衡量系统的整体状态以及系统是否按预期运行。它由 服务水平指标(SLIs)量化。这些指标包括系统健康的信号(例如资源消耗)和业务指标,如销售的三明治数量或每秒开始的流媒体视频。SLIs 根据称为 服务水平目标(SLO)的阈值进行测试,该阈值设置 SLI 范围的上限或下限。SLO 比与业务合作伙伴达成的阈值稍微更为严格或保守,用于预期提供的服务水平,即所谓的 服务水平协议(SLA)。其思想是 SLO 应提供某种程度的预警,提示即将违反 SLA 的情况,这样您就不会真正达到违反 SLA 的地步。

指标是衡量可用性的主要可观测工具。它们是 SLI 的一种度量。指标是最常见的可用性信号,因为它们代表系统中所有活动的聚合。它们足够廉价,不需要采样(丢弃部分数据以限制开销),这样可以避免丢弃重要的不可用性指标。

指标是按时间序列排列的数值,表示特定时间的样本或发生在间隔内的个体事件的聚合:

指标

指标 应该 具有固定的成本,无论吞吐量如何。例如,一个计算特定代码块执行次数的指标应该仅在一个时间段内发送观察到的执行次数,而不管有多少个执行。我的意思是,一个指标应该在发布时发送“观察到 N 次请求”,而不是在发布间隔期间多次观察到请求 N 次。

指标数据

指标数据不能用于推理任何单个请求的性能或功能。指标遥测权衡了跨所有请求的应用程序行为来推理单个请求的能力。

要有效监控 Java 微服务的可用性,需要监控各种可用性信号。通常信号在第四章中有详细描述,但通常可分为四类,称为 L-USE 方法:¹

延迟

这是衡量执行代码块所花费时间的一种方法。对于常见的基于 REST 的微服务,REST 终端点延迟是衡量应用程序可用性的有用指标,特别是最大延迟。这将在“延迟”中进一步讨论。

利用率

衡量有限资源消耗的程度。处理器利用率是常见的利用率指标。参见 “CPU 利用率”。

饱和度

饱和度是无法提供服务的额外工作的测量。“垃圾收集暂停时间” 展示了如何测量 Java 堆,这在内存压力过大时导致无法完成的工作积累。监视数据库连接池、请求池等池也是常见的做法。

错误

除了纯粹关注性能相关问题外,还必须找到一种方法来量化错误比率与总吞吐量的关系。错误的测量包括在服务端点上产生未预期异常导致的不成功的 HTTP 响应(参见 “错误”),以及更间接的措施,如尝试请求与开启断路器的比率(参见 “断路器”)。

利用率和饱和度可能起初看起来相似,而深入理解它们的区别将影响如何考虑资源的图表化和警报。一个很好的例子是 JVM 内存。您可以通过报告每个内存空间中消耗的字节数来将 JVM 内存作为利用率指标进行测量。您还可以通过垃圾回收所占时间与其他操作相比的比例来测量 JVM 内存的饱和度。在大多数情况下,当利用率和饱和度两种测量方法都可行时,饱和度指标通常会导致定义更明确的警报阈值。当内存利用率超过空间的 95%时很难发出警报(因为垃圾收集将使该利用率降回到该阈值以下),但是如果内存利用率经常超过 95%,垃圾收集器将更频繁地启动,比例上花费的时间将更多地用于垃圾收集,从而饱和度测量将更高。

一些常见的可用性信号列在 表 1-1 中。

表 1-1. 可用性信号示例

SLI SLO L-USE 标准
进程 CPU 使用率 小于 80% 饱和度
堆利用率 小于可用堆空间的 80% 饱和度
REST 端点的错误比率 小于端点总请求数的 1% 错误
REST 端点的最大延迟 小于 100 毫秒 延迟

Google 对于如何使用 SLOs 有更为详细的观点。

Google 对 SLOs 的方法

Site Reliability Engineering》由 Betsy Beyer 等人(O’Reilly 出版)将服务可用性呈现为竞争组织目标之间的紧张关系:提供新功能和可靠运行现有功能集。它建议产品团队和专门的站点可靠性工程师达成一致,制定一个错误预算,为允许服务在给定时间窗口内不可靠的程度提供可衡量的目标。超出此目标应该让团队将重点转向可靠性而不是功能开发,直到达到目标为止。

Google 对于服务水平目标(SLOs)的观点在《Site Reliability Workbook》的“Alerting on SLOs”章节中有详细解释(由 Betsy Beyer 等人编辑,O’Reilly 出版)。基本上,Google 工程师根据错误预算在任何给定的时间段内的消耗概率进行警报,并在必要时通过从功能开发向可靠性的工程资源转移来组织反应。在这种情况下,“错误”一词指的是超出任何 SLO 的情况。这可能意味着在 RESTful 微服务中超出服务器失败结果的可接受比例,但也可能意味着超过可接受的延迟阈值,接近操作系统底层的文件描述符过载,或任何其他测量的组合。根据这个定义,在指定时间窗口内服务不可靠的时间是不满足一个或多个 SLO 的比例。

对于错误预算的有用概念,你的组织不需要为产品工程师和站点可靠性工程师分开设立功能。即使是一个单独工作且完全负责其操作的产品工程师也可以从考虑在何处暂停功能开发以改善可靠性,反之亦然中获益。

我认为 Google 错误预算方案的额外开销对许多组织来说过多了。开始测量,发现警报功能如何适应你独特的组织,并一旦习惯于测量,考虑是否要全面采用 Google 的流程。

收集、可视化和对应用程序指标进行警报是不断测试服务可用性的过程。有时候,警报本身包含足够的上下文数据,你就知道如何解决问题。在其他情况下,你可能需要在生产环境中隔离一个失败的实例(例如,将其从负载均衡器中移出),并应用进一步的调试技术来发现问题。其他形式的遥测用于这一目的。

SLO 的一种非正式方法

对于 Netflix 来说,一个不太正式的系统效果很好,其中个别工程团队负责其服务的可用性,在个别产品团队中不存在 SRE/产品工程师的责任分离,并且在组织之间没有对错误预算的如此正式的反应。这两种系统都没有对错;找到一个适合您的系统。

为了本书的目的,我们将以更简单的术语谈论如何衡量可用性:作为错误率或错误比率的测试,延迟、饱和度和利用率指标。我们不会将这些测试的违规行为视为特定的可靠性“错误”,从而从一段时间的错误预算中扣除。如果您想要利用这些测量结果,并将 Google 的 SRE 文化的错误预算和组织动态应用到您的组织中,您可以按照 Google 在这个主题上的指导进行操作。

监控作为调试工具

日志和分布式跟踪,在第 3 章中详细介绍,主要用于故障排除,一旦您意识到某段时间不可用。性能分析工具也是调试信号。

对于组织来说,将其整个性能管理投资集中在调试工具周围是非常普遍的(也很容易,因为市场混乱)。应用性能管理 (APM) 供应商有时会将自己销售为一站式解决方案,但其核心技术完全建立在跟踪或日志记录的基础上,并通过聚合这些调试信号提供可用性信号。

为了不特指任何特定的供应商,请考虑YourKit,这是一个很有价值的性能分析(调试)工具,能够很好地完成这项任务,而不会将自己销售得更多。YourKit 擅长于突出 Java 代码中的计算和内存密集型热点,并且看起来像图 1-4。一些流行的商业 APM 解决方案也有类似的关注点,虽然有用,但并不能替代聚焦于可用性信号。

特征性性能分析工具的图片

图 1-4. YourKit 在性能分析方面表现出色

这些解决方案更为细粒度,以不同的方式记录了特定交互过程中发生的情况。随着粒度的增加,成本也在增加,并且这种成本经常通过降低采样率甚至完全关闭这些信号来缓解。

试图从日志或跟踪信号中测量可用性通常会迫使您在准确性和成本之间进行权衡,而且两者都无法优化。这种权衡存在于跟踪中,因为它们通常是抽样的。跟踪的存储占用比指标高。

学会预期失败

如果你还没有以用户为中心的方式监控应用程序,一旦开始,你很可能会面对你的软件目前的现实。你的第一反应可能会是逃避。现实往往是丑陋的。

在一家中型财产保险公司,我们为公司的保险代理使用的主要业务应用程序增加了监控。尽管有严格的发布流程和相对健康的测试文化,该应用程序每分钟表现出大约 1,000 次请求中超过 5 次故障。从某种角度来看,这只是一个 0.5% 的错误比率(可能可以接受,也可能不可以),但故障率对于认为其服务经过了充分测试的公司来说仍然是一个震惊。

认识到系统不会完美的事实,将注意力从追求完美转向监控、警报和快速解决系统遇到的问题。无论控制变化速率的过程多么严格,都不会产生完美的结果。

在进一步发展交付和发布流程之前,通向具有弹性软件的第一步是在当前发布的软件中添加监控。

随着微服务的推广以及应用程序实践和基础设施的变化,监控变得更加重要。许多组件不直接受组织控制。例如,网络层、基础设施和第三方组件和服务的故障可能导致延迟和错误。每个开发微服务的团队都有可能对其他不受其直接控制的系统部分产生负面影响。

软件的最终用户也不期望完美,但希望他们的服务提供商能有效地解决问题。这就是所谓的服务恢复悖论,即服务的用户在服务失败后会比之前更信任该服务。

企业需要理解并捕捉他们想要为最终用户提供的用户体验——哪种系统行为会对业务造成问题,哪种行为是用户可以接受的。Site Reliability EngineeringThe Site Reliability Workbook 中有更多关于如何为您的业务选择这些的内容。

一旦确定并衡量,您可以采纳 Google 风格,如在 “Google 的 SLO 方法” 中所见,或 Netflix 更不正式的 “上下文和防护栏” 风格,或介于两者之间的任何风格,以帮助您思考您的软件或下一步的行动。在 David N. Blank-Edelman(O'Reilly)的 Seeking SRE 书中的 Netflix 第一章,了解更多关于上下文和防护栏的信息。无论您选择遵循 Google 的实践还是更简单的实践,都取决于您的组织、您开发的软件类型以及您希望推广的工程文化。

将“永不失败”的目标替换为能够满足 SLA 的目标后,工程可以开始为系统构建多层次的弹性,最小化故障对最终用户体验的影响。

有效的监控建立信任。

在某些企业中,工程仍然被视为服务组织,而不是核心业务能力。在每分钟五次故障率的保险公司中,这是主流观念。在工程部门为公司的保险代理人提供服务的许多情况下,他们之间的主要互动是通过呼叫中心报告和跟踪软件问题。

工程部门定期根据从呼叫中心获得的缺陷信息优先处理 Bug 解决方案,同时对新功能请求也采取了一些措施,每次软件发布都这样做。我想知道现场代理人是否仅仅因为日益增长的 Bug 积压表明这不是他们时间的有效利用,或者因为问题有一个足够好的解决方法而没有报告问题。通过呼叫中心主要了解问题的问题在于它使得关系完全单向化。业务合作伙伴报告问题,工程部门做出响应(最终)。

用户中心的监控文化使得这种关系更具双向性。警报可能提供足够的上下文信息,以识别今天在某些地区代理人所服务的某一类车辆的评级失败。工程部门有机会主动与代理人联系,并提供足够的上下文信息解释问题已经被认识到。

交付

改进软件交付流程可以减少引入更多故障到现有系统的机会(或者至少帮助您快速识别并回滚这些变更)。事实证明,良好的监控是演变为安全和有效的交付实践的一个非显而易见的先决条件。

持续集成(CI)和持续交付(CD)之间的划分常常因团队频繁地编写部署自动化脚本并将这些脚本作为持续集成构建的一部分而变得模糊。可以很容易地重新利用 CI 系统作为灵活的通用工作流自动化工具。为了在概念上清晰地划分这两者,无论自动化运行在何处,我们将说持续集成在将微服务构件发布到构件存储库时结束,并从那一点开始交付。在图 1-5 中,软件交付生命周期被描述为从代码提交到部署的事件序列。

srej 0105

图 1-5. 持续集成与交付之间的边界

各个步骤受不同频率和组织需求的控制措施影响。它们也有根本不同的目标。持续集成的目标是通过自动化测试加速开发者反馈,快速失败,并鼓励及早合并以防止混合集成。交付自动化的目标是加速发布周期,确保满足安全和合规措施,提供安全可扩展的部署实践,并有助于理解部署景观以监控已部署的资产。

最佳的交付平台还充当当前部署资产清单,进一步放大良好监控的效果:它们帮助将监控转化为行动。在第六章,我们将讨论如何构建端到端资产清单,最终以已部署资产清单结束,使您能够推理代码的最小细节直至已部署的资产(即容器、虚拟机和函数)。

持续交付不一定意味着持续部署

真正的持续部署(每次提交通过自动化检查后自动进入生产环境)可能或可能不是您组织的目标。一切相等的情况下,较紧密的反馈循环优于较长的反馈循环,但它伴随着技术、运营和文化成本。本书中讨论的任何交付主题适用于总体上的持续交付,以及特别的持续部署。

一旦有效的监控就位,并且通过对代码的进一步更改引入的系统故障较少,我们可以专注于通过进化的流量管理实践为运行中的系统增加更多的可靠性。

流量管理

分布式系统的可靠性很大程度上基于对失败的预期和补偿。可用性监控揭示了这些实际的失败点,调试能力监控帮助理解这些点,交付自动化则有助于在任何增量发布中不引入更多这些问题。流量管理模式将帮助现有实例应对失败的现实。

在第七章,我们将介绍涉及负载均衡(平台、网关和客户端)以及调用弹性模式(重试、速率限制器、防火墙和断路器)的特定缓解策略,这些策略为运行中的系统提供了安全保障。

之所以放在最后,是因为它需要在每个项目基础上进行最高程度的手工编码工作,并且因为你在工作中投入的投资可以通过从前面步骤中学到的知识来指导。

未涵盖的能力

平台工程团队通常关注的某些能力未包含在本书中。我想特别提到两个,即测试和配置管理,并解释原因。

测试自动化

我对测试的看法是,开源测试自动化工具能够帮助你走出第一步。然而,进一步的投入可能会遭遇收益递减。以下是一些已经很好解决的问题:

  • 单元测试

  • 模拟/存根

  • 基本的集成测试,包括测试容器

  • 合约测试

  • 构建工具有助于将计算昂贵和廉价的测试套件分开

除非你确实有大量资源(包括计算资源和工程时间)可供使用,否则建议避免另外几个问题。合约测试是一个覆盖这两者一部分内容的技术示例,但成本远远低于其它方法:

  • 下游测试(即,每当对库进行提交时,构建所有直接或间接依赖于此库的其他项目,以确定更改是否会导致下游失败)

  • 微服务套件的端到端集成测试

我非常支持各种自动化测试,但对整个企业的测试活动持怀疑态度。有时,感受到周围测试爱好者的社会压力,我可能会在一段时间内追随当时的测试潮流:100%测试覆盖率、行为驱动开发、努力吸引非工程师业务伙伴参与测试规范制定、Spock 等等。在开源 Java 生态系统中,一些最聪明的工程工作已经在这个领域进行:考虑 Spock 对字节码操作的创造性运用,实现数据表等功能。

传统上,与单片应用程序一起工作时,软件发布被视为系统变化的主要来源,因此也是潜在的失败来源。重点放在确保软件发布过程不失败上。为了验证待发布的软件稳定性,投入了大量精力确保较低级别的环境与生产环境一致。一旦部署并稳定,就假设系统会保持稳定。

现实情况并非如此。工程团队采用并加倍投入自动化测试实践来解决失败问题,结果失败问题依然顽固地存在。管理层本来就对测试持怀疑态度。当测试未能捕捉问题时,他们原本的信任也会荡然无存。生产环境有一种顽固的习惯,会在细微的、看似总是灾难性的方式上偏离测试环境。在这一点上,如果你逼我在接近 100%的测试覆盖率和一个发展完善的生产监控系统之间做选择,我会毫不犹豫地选择监控系统。这不是因为我对测试持有贬低的看法,而是因为即使在那些业务实践不快速改变的传统企业中,接近 100%的测试覆盖率也是虚幻的。生产环境会表现得完全不同。就像 Josh Long 所说:“没有什么地方能像它一样。”

有效的监控可以警告我们系统由于我们可以预料到的条件(例如硬件故障或下游服务不可用)而无法正常工作。它还不断增加我们对系统的了解,这实际上可以导致测试覆盖我们以前未曾想象的情况。

测试实践的层层堆叠可以限制失败的发生,但永远不可能完全消除,即使在执行最严格质量控制实践的行业中也是如此。在生产中积极测量结果可以降低发现时间,最终解决失败的时间。测试和监控共同是互补的实践,减少最终用户经历失败的次数。在最佳状态下,测试可以防止整类回归问题,而监控则可以迅速识别那些不可避免地存在的问题。

我们的自动化测试套件证明了(在它们自身没有逻辑错误的情况下)我们对系统的了解。生产监控则展示了实际发生了什么。接受自动化测试无法覆盖一切应该是一种巨大的解脱。

因为应用代码始终会存在因未预料到的交互、资源约束等环境因素以及不完美的测试而导致的缺陷,对于任何生产应用程序来说,有效的监控可能被认为比测试更为必要。测试证明了我们认为会发生的事情。监控则展示了正在发生的事情。

混沌工程与持续验证

有一个完整的学科围绕着持续验证软件是否如预期运行,通过引入受控故障(混沌实验)和验证来进行。因为分布式系统很复杂,我们无法预料到它们所有的各种互动,这种形式的测试有助于展现复杂系统的意外出现的属性。

混沌工程的整体学科非常广泛,由 Casey Rosenthal 和 Nora Jones(O'Reilly)详细介绍在Chaos Engineering中,我不会在这本书中详细讨论。

配置即代码

12-Factor App教导我们配置应该与代码分离。这个概念的基本形式,即配置存储为环境变量或在启动时从类似 Spring Cloud Config Server 的集中式配置服务器获取,我认为足够直接,不需要在这里解释。

更复杂的情况涉及动态配置,即对中央配置源的更改传播到运行实例,影响它们的行为,在实践中极其危险,必须小心处理。与开源 Netflix Archaius配置客户端配对(它存在于 Spring Cloud Netflix 依赖项和其他地方),还有一个专有的 Archaius 服务器用于此目的。由于动态配置传播到运行实例导致了多个生产事故,这些事故的规模如此之大,以至于交付工程师编写了一个完整的金丝雀分析流程,用于范围界定和逐步推出动态配置更改,借鉴了他们从不同版本代码的自动金丝雀分析中学到的经验教训。这超出了本书的范围,因为许多组织将永远不会从代码更改的自动金丝雀分析中获得足够的实质性好处,以使这种努力值得。

声明式交付是另一种完全不同的配置即代码形式,再次由 Kubernetes 及其 YAML 清单的兴起推广。我的早期职业生涯让我对仅声明式解决方案的完整性产生了永久的怀疑。我认为既有命令式配置又有声明式配置的地方总是存在的。我曾经为一家保险公司的政策管理系统工作过,该系统由一个返回 XML 响应的后端 API 和将这些 API 响应进行 XSLT 转换生成静态 HTML/JavaScript 以在浏览器中呈现的前端组成。

这是一种奇特的模板化方案。其支持者认为 XSLT 赋予了每个页面呈现一种声明性的特性。然而,事实证明,XSLT 本身是图灵完备的,具有令人信服的存在证明。声明性定义的典型优点是简单性,有利于像静态分析和修复这样的自动化。但就像 XSLT 案例一样,这些技术似乎不可避免地向图灵完备演化。JSON(Jsonnet)和 Kubernetes(Kustomize)也受到相同的力量影响。这些技术无疑是有用的,但我不能再加入呼吁纯粹声明性配置的合唱队伍。除非提到这一点,否则我认为这本书没有太多可添加的内容。

封装能力

尽管面向对象编程(OOP)如今备受争议,但其基本概念之一是封装。在 OOP 中,封装意味着将状态和行为捆绑在某个单元内,例如 Java 中的类。一个关键思想是隐藏对象的状态,称为信息隐藏。在某些方面,平台工程团队的任务类似于为其客户开发团队执行类似的封装任务,用于可靠性最佳实践,不是为了控制信息,而是为了减轻他们处理信息的责任。也许中心团队从产品工程师那里收到的最高赞扬就是“我不必关心你们在做什么”。

接下来的章节将介绍一系列我理解的最佳实践。作为平台工程师,您面临的挑战是以最小干扰的方式将它们传递给您的组织,构建“护栏而非大门”。阅读时,请思考如何封装那些适用于每个业务应用的宝贵知识,并且如何将其传递给您的组织。

如果计划涉及从足够强大的高管获得批准,并向整个组织发送电子邮件要求在某个日期之前采纳,那就是一个大门。您仍然希望领导层的支持,但您需要以更像护栏而非大门的方式提供通用功能:

显式的运行时依赖项

如果您有一个核心库,每个微服务都作为运行时依赖项包含其中,这几乎可以肯定是您的交付机制。开启关键指标,添加常见的遥测标签,配置跟踪,添加流量管理模式等。如果您大量使用 Spring,请使用自动配置类。如果您使用 Java EE,您也可以类似地条件化配置 CDI。

服务客户作为依赖项

特别是在流量管理模式(回退、重试逻辑等)方面,考虑由生产服务的团队来负责制作一个客户端与服务交互。毕竟,生产和运营团队比任何人都更了解其弱点和潜在故障点。这些工程师很可能是将这些知识形式化为客户端依赖关系的最佳人选,以便服务的每个消费者能够以最可靠的方式使用它。

注入运行时依赖

如果部署过程相对标准化,您有机会在部署环境中注入运行时依赖项。这是 Cloud Foundry 构建包团队采用的方法,用于向在 Cloud Foundry 上运行的 Spring Boot 应用程序注入平台指标实现。您可以采取类似的方法。

在过早封装之前,找到几个团队并在几个应用程序中明确地在代码中实践这一纪律。总结您所学到的东西。

服务网格

作为最后的手段,在应用程序旁边(或容器中)封装常见平台功能,与管理它们的控制平面配对,这被称为服务网格

服务网格是应用程序代码之外的基础架构层,用于管理微服务之间的交互。今天最具代表性的实现之一是Istio。这些边车执行诸如流量管理、服务发现和监控等功能,代表应用程序进程操作,使应用程序无需关注这些问题。在最佳情况下,这简化了应用程序开发,但增加了部署和运行服务的复杂性和成本。

长期来看,软件工程的趋势通常是循环的。在可靠性领域,责任的摆动从增加应用程序和开发者责任(例如 Netflix OSS、DevOps)到集中运维团队的责任。服务网格的兴起代表着责任再次回归到集中运维团队手中。

Istio 提倡通过其集中控制平面管理和传播跨一组微服务的策略,这是组织集中的专业团队的要求,他们专门负责理解这些策略的后果。

可敬的 Netflix OSS 套件(其重要部分有 Resilience4j 用于流量管理、HashiCorp Consul 用于发现、Micrometer 用于度量仪器等替代版本)已经考虑了这些应用程序问题。尽管如此,应用程序代码的影响主要是添加一个或多个二进制依赖项,此时某种形式的自动配置接管并装饰否则不受影响的应用程序逻辑。这种方法的明显缺点是语言支持,每种站点可靠性模式的支持都要求组织在其使用的每种语言/框架中实现库。

图 1-6 展示了这种工程周期对衍生价值的乐观看法。幸运的是,在每次从分散化到集中化再到分散化的过渡中,我们都从之前周期中学到并完全封装了其好处。例如,Istio 可能完全封装了 Netflix OSS 堆栈的好处,只为了下一个分散化推动释放出在 Istio 实现中无法实现的潜力。例如,Resilience4j 已经在进行中,讨论如何响应应用程序特定指标的自适应形式的 bulkheads 等模式。

srej 0106

图 1-6。软件工程的循环性质,应用于流量管理

鉴于缺乏特定领域知识,边车的大小也很棘手。边车如何知道应用程序进程将每秒消耗 10,000 个请求,还是仅为 1 个?总体来看,我们如何在不知道最终会存在多少边车的情况下预先确定边车控制平面的大小?

边车限于最低公共知识点

边车代理将永远在应用程序特定知识对提高容错性至关重要的下一步方面最弱。按定义,边车与应用程序分离,无法编码任何这个特定于应用程序的领域知识,而不需要应用程序和边车之间的协调。这很可能至少与通过应用程序可包含的语言特定库实现边车提供的功能同样困难。

我相信开源的测试自动化工具能帮助你达到一定程度。超出此范围的任何投资可能会出现收益递减的情况,正如在"服务网格跟踪"中讨论的那样,并反对使用 Sidecar 进行流量管理,就像在"服务网格中的实现"中所述,尽管这些观点可能不受欢迎。与通过显式包含或注入运行时的二进制依赖相比,这些实现是有损的,后者可以增加更多功能,只有在需要支持大量不同语言时才可能成本过高(即便如此,我仍未被说服)。

概要

在本章中,我们将平台工程定义为至少是可靠性工程功能的占位符短语,我们将在本书的其余部分中讨论这些功能。只有在以客户为导向的情况下(其中客户是组织中的其他开发人员),平台工程团队才能发挥最佳效果,而不是控制的一种。测试工具、这些工具的采用路径以及针对“护栏而非门”的规则开发的任何过程。

最终,设计你的平台部分也是设计你的组织。你想因什么而闻名?

¹ 我最初是通过 Brendan Gregg 对他的方法进行 Unix 系统监控来了解 USE 标准。在那种情况下,延迟测量不像精细,因此缺少L

第二章:应用程序指标

由于由许多通信微服务组成的分布式系统的复杂性,能够观察系统状态变得尤为重要。变化速率很高,包括新代码发布、独立扩展事件随着负载变化、基础设施更改(云提供商更改)以及动态配置更改在系统中传播。在本章中,我们将重点讨论如何测量和对分布式系统性能进行警报,以及采用的一些行业最佳实践。

组织至少必须致力于一个或多个监控解决方案。可以选择多种选择,包括开源、商业本地部署和 SaaS 提供的解决方案,具有广泛的能力范围。市场已经足够成熟,以至于任何规模和复杂性的组织都可以找到适合其需求的解决方案。

监控系统的选择对保持指标数据的固定成本特性至关重要。例如,StatsD 协议要求应用程序在每个事件基础上向 StatsD 代理发出发射。即使此代理作为同一主机上的旁路进程运行,应用程序仍会承担每个事件基础上创建有效负载的分配成本,因此,此协议至少破坏了指标遥测的这一优势。这并非总是(甚至通常不是)灾难性的,但请注意此成本。

黑盒与白盒监控比较

指标收集方法可以根据其能够观察的内容进行分类:

黑盒监控

收集器可以观察输入和输出(例如,系统中的 HTTP 请求和响应),但操作的机制对收集器是未知的。黑盒收集器通过某种方式拦截或包装被观察的进程以进行测量。

白盒

收集器可以观察输入和输出以及操作的内部机制。白盒收集器在应用程序代码中执行此操作。

许多监控系统供应商提供可以附加到应用程序进程的代理,并提供黑盒监控。有时,这些代理收集器可以深入到众所周知的应用程序框架中,以至于在某些方面开始类似于白盒收集器。尽管如此,以任何形式的黑盒监控都受制于代理编写者能够概括所有可能应用代理的应用程序的内容的限制。例如,代理可能能够拦截并计时 Spring Boot 用于数据库事务的机制。代理永远无法推断某个类中的java.util.Map字段代表近缓存的形式并作为此类仪表进行操作。

基于服务网格的仪表化也是黑盒的,并且通常比代理功能更弱。虽然代理可以观察和装饰单个方法调用,但服务网格的最细粒度观察是在 RPC 级别。

另一方面,白盒收集听起来像是很多工作。一些有用的度量标准确实可以跨应用程序进行泛化(例如,HTTP 请求时间,CPU 利用率),并且通过黑盒方法进行了很好的仪表化。当一个白盒仪表化库与一个应用程序自动配置机制配对时,其中一些概括化部分类似于黑盒方法。自动配置的白盒仪表化需要与黑盒仪表化相同的开发人员工作水平:具体来说是

优秀的白盒度量收集器应该捕获与黑盒收集器相同的所有内容,但还应支持捕获更多黑盒收集器根据定义无法捕获的内部细节。对于你的工程实践来说,这两者之间的区别是微不足道的。对于黑盒代理,你必须修改你的交付实践以打包和配置代理(或者与运行时平台集成以替代这个过程)。对于自动配置的白盒度量收集,它捕获了相同的细节集,你必须在构建时包含一个二进制依赖项。

供应商特定的仪表化库倾向于不具有白盒方法的黑盒感,因为框架和库的作者不倾向于添加各种专有仪表化客户端,即使作为可选依赖项,并且在其代码中多次进行仪表化。像 Micrometer 这样的供应商中立的仪表化外观具有“写一次,随处发布”的优势,供框架和库的作者使用。

当然,黑盒和白盒收集器可以互补,即使它们之间存在一些重叠。没有普遍要求选择其中一个而不是另一个。

维度度量

大多数现代监控系统采用了由度量名称和一系列键值标签组成的维度命名方案。

虽然监控系统的存储机制在很大程度上有所不同,但总的来说,每个唯一的名称和标签组合都表示为存储中的一个独特条目或行。因此,度量标准的存储成本是其标签集的基数的乘积(即唯一键值标签对的总数)。

例如,一个应用程序范围的计数器指标,名为 http.server.requests,包含一个标签,用于观察到的 HTTP 方法,其中仅观察到 GET 和 POST 两种方法,一个服务返回三种状态代码中的一种,以及两个应用程序中的一个 URI,导致最多 2 3 2 = 12 个不同的时间序列被发送到监控系统并存储。在此示例中,此指标在存储中的表示大致如 表 2-1 所示。例如,协调标签,例如仅端点 /a1 将具有 GET 方法,仅 /a2 将具有 POST 方法,可以将唯一时间序列的总数限制在理论最大值以下,在此示例中仅为六行。在许多维度时间序列数据库中,对于每一行代表的唯一名称和标签集,将有一个值环形缓冲区,用于在定义的时间段内保存此指标的样本。当系统包含类似这样的有界环形缓冲区时,您的指标的总成本固定为唯一指标名称/标签的排列数乘以环形缓冲区的大小。

表 2-1. 维度指标的存储

指标名称和标签
http.server.requests [10,11,10,10]
http.server.requests [1,0,0,0]
http.server.requests [0,0,0,4]
http.server.requests [10,11,10,10]
http.server.requests [0,0,0,1]
http.server.requests [1,1,1,1]

在某些情况下,指标会定期移动到长期存储。在这一点上,有机会压缩或丢弃标签,以减少存储成本,尽管会牺牲一些维度的粒度。

层次指标

在维度指标系统变得流行之前,许多监控系统采用了层次结构方案。在这些系统中,指标仅通过名称定义,没有键值标签对。标签非常有用,以至于出现了一种约定,将类似标签的数据附加到指标名称中,例如用点分隔符。因此,维度系统中具有 method 标签为 GET 的维度指标 httpServerRequests,可能在层次结构系统中表示为 httpServerRequests.method.GET。由此产生了查询功能,如通配符运算符,允许跨“标签”进行简单聚合,如表 2-2 所示。

表 2-2. 使用通配符聚合层次指标

指标查询
httpServerRequests.method.GET 10
httpServerRequests.method.POST 20
httpServerRequests.method.* 30

然而,在分层系统中,标签并不是一等公民,像这样的通配符会失效。特别是当组织决定像httpServerRequests这样在整个堆栈中的许多应用程序中通用的度量标签应该接收一个新标签时,它有可能破坏现有的查询。在表 2-3 中,独立于方法的请求的真实数量是 40,但由于堆栈中的某些应用程序在度量名称中引入了一个新的状态标签,它不再包含在聚合中。即使我们可以作为整个组织同意标准化使用这个新标签,我们的通配符查询(以及任何基于它们构建的仪表板或警报)也会误代表自标签首次在第一个应用程序中引入直到完全在代码库中传播并重新部署的时间内的系统状态。

表 2-3. 使用通配符的分层度量聚合失败

Metric query Value
httpServerRequests.method.GET 10
httpServerRequests.method.POST 20
httpServerRequests.status.200.method.GET 10
httpServerRequests.method.* 30 (!!)

事实上,层次方法迫使标签在它们实际上是独立的键值对时强加了一个顺序。

如果您现在开始进行实时应用程序监控,您应该使用维度监控系统。这意味着您还必须使用维度度量仪表化库以便以充分利用使这些系统如此强大的名称/标签组合的方式记录度量。如果您已经有一些使用分层收集器的仪表化,其中最流行的是 Dropwizard Metrics,您最终将不得不重写此仪表化。通过开发某种方式遍历所有标签并将它们与度量名称组合的命名约定,可以将维度度量转换为分层度量。反向操作很难泛化,因为命名方案的不一致使得将分层名称拆分为维度度量变得困难。

从这一点开始,我们将仅研究维度度量指标的仪表化。

Micrometer Meter Registries

本章的其余部分将使用Micrometer,这是一个支持市场上大多数流行监控系统的 Java 维度度量仪表化库。现在只有两个 Micrometer 的主要替代品可用:

监控系统供应商通常会提供 Java API 客户端。

尽管这些适用于应用程序级别的白盒子仪器,但很少有可能会有整个 Java 生态系统,特别是第三方开源库,会采用特定供应商的仪器客户端进行指标收集。迄今为止,我们可能最接近的是在 Prometheus 客户端的一些开源库中的零星采用。

OpenTelemetry

OpenTelemetry 是一个混合度量和跟踪库。在撰写本文时,OpenTelemetry 还没有 1.0 发布,其关注点显然更多地集中在跟踪而不是度量上,因此度量支持要简单得多。

虽然从一个维度的度量仪器库到另一个维度的仪器库的功能可能会有所不同,但描述的大多数关键概念都适用于它们中的每一个,或者至少你应该开发出对替代方案预期成熟度的理解。

在 Micrometer 中,Meter是收集关于您的应用程序的一组测量(我们称之为度量)的接口。

计量器是从并保存在MeterRegistry中创建的。每个支持的监控系统都有一个MeterRegistry的实现。如何创建注册表因每个实现而异。

Micrometer 项目支持的每个MeterRegistry实现都发布了一个库到 Maven Central 和 JCenter(例如,io.micrometer:micrometer-registry-prometheusio.micrometer:micrometer-registry-atlas):

MeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);

更多选项的MeterRegistry实现同样包含流畅构建器,例如在示例 2-1 中显示的 InfluxDB 注册表。

示例 2-1. Influx 流畅构建器
MeterRegistry registry = InfluxMeterRegistry.builder(InfluxConfig.DEFAULT)
  .httpClient(myCustomizedHttpClient)
  .build();

可以使用CompositeMeterRegistry同时将度量发布到多个监控系统。

在示例 2-2 中,创建了一个将指标发送到 Prometheus 和 Atlas 的复合注册表。应该使用复合体创建计量器。

示例 2-2. 将指标发送到 Prometheus 和 Atlas 的复合计量器注册表
MeterRegistry prometheusMeterRegistry = new PrometheusMeterRegistry(
  PrometheusConfig.DEFAULT);
MeterRegistry atlasMeterRegistry = new AtlasMeterRegistry(AtlasConfig.DEFAULT);

MeterRegistry registry = new CompositeMeterRegistry();
registry.add(prometheusMeterRegistry);
registry.add(atlasMeterRegistry);

// Create meters like counters against the composite,
// not the individual registries that make up the composite
registry.counter("my.counter");

Micrometer 包含一个全局静态的CompositeMeterRegistry,可以类似于使用 SLF4J 的LoggerFactory那样使用。这个静态注册表的目的是允许在不能通过 API 依赖泄漏 Micrometer 的组件中进行仪表化,通过提供一种依赖注入MeterRegistry的方式。示例 2-3 展示了全局静态注册表的使用方式与我们从日志库(如 SLF4J)中习惯的方式的相似性。

示例 2-3. 使用全局静态注册表
class MyComponent {
  Timer timer = Timer.builder("time.something")
    .description("time some operation")
    .register(Metrics.globalRegistry);

  Logger logger = LoggerFactory.getLogger(MyComponent.class);

  public void something() {
    timer.record(() -> {
      // Do something
      logger.info("I did something");
    });
  }
}

通过将你在应用程序中引入的任何 MeterRegistry 实现添加到全局静态注册表,任何使用全局注册表的底层库都会将指标注册到你的实现中。复合注册表可以添加到其他复合注册表中。在 Figure 2-1 中,我们在应用程序中创建了一个复合注册表,它同时向 Prometheus 和 Stackdriver 发布指标(即我们对 Prometheus 和 Stackdriver 注册表都调用了 CompositeMeterRegistry#add(MeterRegistry))。然后我们将该复合注册表添加到全局静态复合中。你创建的复合注册表可以通过 Spring、CDI 或 Guice 等框架在整个应用程序中进行依赖注入,以便你的组件向其注册指标。但其他库通常不在此依赖注入的上下文中,因为它们不希望 Micrometer 通过其 API 签名泄露,所以它们会向静态全局注册表注册。最终,指标注册沿着注册表层次结构向下流动。因此,库指标从全局复合流向你的应用程序复合,再流向各个注册表。应用程序指标从应用程序复合流向各个 Prometheus 和 Stackdriver 注册表。

srej 0201

图 2-1. 全局静态注册表与应用程序注册表的关系

Spring Boot 自动配置 MeterRegistry

Spring Boot 会自动配置一个复合注册表,并为在类路径上找到的每个支持的实现添加注册表。在你的运行时类路径上依赖micrometer-registry-{system}以及该系统的任何必需配置,将导致 Spring Boot 配置注册表。Spring Boot 还会将任何作为@Bean的 MeterRegistry 添加到全局静态复合中。通过这种方式,你添加到应用程序中的任何提供 Micrometer 仪表盘的库将自动将它们的指标发送到监控系统!这就是通过白盒仪表化实现黑盒般的体验。作为开发者,你无需显式注册这些指标;它们存在于你的应用程序中就能工作。

创建 Meter

对于每种支持的 Meter 类型,Micrometer 提供了两种样式来注册指标,具体取决于您需要多少选项。如示例 2-4(part0006_split_006.html#meter_fluent_builder)所示,流畅构建器提供了最多的选项。通常,核心库应该使用流畅构建器,因为为了为所有用户提供健壮的描述和基本单位详细信息,额外的冗长性增加了价值。在具有少量工程师的特定微服务的仪器化中,选择更紧凑的代码和更少的详细信息是可以接受的。一些监控系统支持附加描述文本和基本单位到指标,对于这些系统,Micrometer 将发布这些数据。此外,一些监控系统将使用指标的基本单位信息自动缩放和标记图表的 y 轴,使其以人类可读的方式。因此,如果您发布带有“bytes”基本单位的指标,复杂的监控系统将识别此并将 y 轴缩放为兆字节或千兆字节,或者任何对于此指标范围最为人类可读的值。阅读“2 GB”比“2147483648 bytes”要容易得多。即使对于那些从根本上不支持基本单位的监控系统,诸如 Grafana 这样的图表用户界面也允许您手动指定图表的单位,而 Grafana 将为您执行这种智能的人类可读缩放。

示例 2-4. Meter 流畅构建器
Counter counter = Counter.builder("requests") // Name
  .tag("status", "200")
  .tags("method", "GET", "outcome", "SUCCESS") // Multiple tags
  .description("http requests")
  .baseUnit("requests")
  .register(registry);

MeterRegistry 包含方便的方法,用于使用较短的形式构造 Meter 实例,如 示例 2-5 所示。

示例 2-5. Meter 构造便利方法
Counter counter = registry.counter("requests",
  "status", "200", "method", "GET", "outcome", "SUCCESS");

无论您使用哪种方法来构造计量器,您都必须决定其名称以及应用哪些标签。

指标命名

要充分利用指标,它们需要以一种结构化的方式进行组织,以便仅选择名称并对所有标签进行聚合能够产生一个有意义的(尽管不一定总是有用的)值。例如,如果一个指标命名为 http.server.requests,那么标签可以识别应用程序、区域(按照公共云的概念)、API 终端、HTTP 方法、响应状态码等。对此指标的所有唯一标签组合进行的聚合测量将为您的应用程序栈中的许多应用程序的每次交互产生吞吐量的测量。在此名称上切换到各种标签的能力使其变得有用。我们可以按区域将此指标在度量上扩展,并观察区域性故障或详细查看特定应用程序,例如,成功响应特定 API 终端以推理通过该关键终端的吞吐量。

假设许多应用程序都使用某种度量标准,如http.server.requests,当在http.server.requests上构建可视化时,监控系统将显示所有应用程序、地区等的http.server.requests性能的聚合,直到您决定对某些内容进行维度钻取。

然而,并非所有的都应该是标签。假设我们试图分别测量 HTTP 请求的数量和数据库调用的数量。

Micrometer 使用一个命名约定,用.(点)字符分隔小写单词。示例 2-6 中显示的命名提供了足够的上下文,以便如果只选择名称,则值至少在潜在上是有意义的。例如,如果我们选择database.queries,我们可以看到对所有数据库的调用总数。然后我们可以按数据库分组或选择以进一步进行钻取或执行对每个数据库调用贡献的比较分析。

示例 2-6. 推荐方法
registry.counter("database.queries", "db", "users")
registry.counter("http.requests", "uri", "/api/users")

使用 示例 2-7 中展示的方法,如果我们选择度量calls,我们将得到一个聚合值,该值是数据库调用和 HTTP 请求的总数。如果没有进一步进行维度钻取,这个值是没有用处的。

示例 2-7. 不良实践:在应该有不同计量名称的地方使用类型标签
registry.counter("calls",
    "type", "database",
    "db", "users");

registry.counter("calls",
    "type", "http",
    "uri", "/api/users");

图 2-2 显示了这种错误命名的影响。假设每个 HTTP 请求您都进行了 10 次数据库调用。如果只绘制calls,您将得到约 11,000 次的顶线速率。但是 11,000 是两种类型调用的笨拙总和,其频率始终相差一个数量级。要从中获得任何实用性,我们需要按维度分解,此时我们发现了数据库调用与 HTTP 请求之间的 10 倍关系。必须立即进行维度钻取才能构建可理解的图表,这表明度量命名方案存在问题。

srej 0202

图 2-2. 错误命名对图表可用性的影响

将相关数据分组是一个良好的做法,比如通过为度量名称添加像“jvm”或“db”这样的命名空间前缀。例如,与 JVM 垃圾回收相关的一组指标可以用jvm.gc作为前缀:

jvm.gc.live.data.size
jvm.gc.memory.promoted
jvm.gc.memory.allocated

这种命名空间不仅有助于在许多监控系统 UI 和仪表板工具中按字母顺序分组相关指标,还可以用于通过MeterFilter一次性影响一组指标。例如,要禁用所有jvm.gc指标,我们可以在这个名称上应用一个拒绝MeterFilter

MeterRegistry registry = ...;
registry.config().meterFilter(MeterFilter.denyNameStartsWith("jvm.gc"));

不同的监控系统对命名约定有不同的建议,有些命名约定可能对某一系统不兼容。请记住,Micrometer 使用一个命名约定,用.(点)字符分隔小写单词。每个用于监控系统的 Micrometer 实现都有一个命名约定,将小写点表示法名称转换为该监控系统推荐的命名约定。

另外,此命名约定还会清理度量名称和标签中的特殊字符,这些字符是监控系统禁止使用的。该约定事实证明不仅仅是为了看起来更惯用。如果以这种形式发货而没有任何命名约定规范化,两个度量,http.server.requestshttp.client.requests,将会破坏 Elasticsearch 索引,因为 Elasticsearch 将点视为用于索引的层次结构形式。如果这些度量没有以点分隔符的形式发送到 SignalFx,我们将无法利用 SignalFx 中用点分隔符对度量进行层次化的 UI 呈现功能。这两种关于点字符的不同观点是互斥的。通过命名约定规范化,这些度量被发送到 Elastic 作为httpServerRequestshttpClientRequests,并且带有点符号发送到 SignalFx。因此,应用程序代码在不更改工具的情况下保持最大的可移植性。使用 Micrometer 时,米器名称和标签键应遵循这些准则:

  • 始终使用点来分隔名称的各个部分。

  • 避免在米器名称中添加单位名称或诸如total等字词。

因此,选择jvm.gc.memory.promoted而不是jvmGcMemoryPromotedjvm_gc_memory_promoted。如果您喜欢后者之一(或者如果您的监控系统要求这样做),请在注册表上配置命名约定以进行此转换。但是,在整个软件堆栈中使用点分隔符作为度量名称,可以为各种监控系统提供一致的结果。

对于某些监控系统,单位名称等的存在是惯用命名方案的一部分。再次强调,命名约定可以在适当的情况下添加这些部分。例如,Prometheus 的命名约定在计数器的后缀上添加了_total,在定时器的末尾添加了_seconds。此外,基本时间单位根据监控系统而异。使用 Micrometer 的Timer,您可以以任何粒度记录,并且时间值在发布时进行了缩放。即使您总是以特定粒度记录,包含单位名称在米器名称中也是不准确的。例如,示例 2-8 在 Prometheus 中显示为requests_millis_seconds,这显得很尴尬。

示例 2-8. 不良做法:在米器名称中添加单位
registry.timer("requests.millis")
  .record(responseTime, TimeUnit.MILLISECONDS);

可以用自定义命名约定重写 MeterRegistry 的默认命名约定,该命名约定可以建立在 NamingConvention 接口提供的一些基本构建块上,如示例 2-9 所示。

示例 2-9. 一个自定义的命名约定,将基本单位作为后缀添加
registry.config()
  .namingConvention(new NamingConvention() { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
      @Override
      public String name(String name, Meter.Type type, String baseUnit) {
          String camelCased = NamingConvention.snakeCase.name(name, type, baseUnit);
          return baseUnit == null ? camelCased :
                  camelCased + "_" + baseUnit;
      }
  });

1

NamingConvention 是一个函数式接口,因此这可以简化为一个 lambda,但为了清晰起见,我们在这里保留了匿名类。

如“维度度量”中所述,度量的总存储成本是其每个标记的值集的基数的乘积。选择有助于识别软件故障模式的标记名称。例如,如果监控汽车保险评级应用程序,则将策略度量标记为车辆类别比将其标记为唯一车辆识别号更有用。由于错误或下游服务中断,某一类车辆(如经典卡车)可能开始失败。在超过预定阈值的策略评级错误比率警报上作出响应时,工程师可能会快速确定经典卡车处理存在问题,这是基于车辆类别中前三个评级失败的情况。

限制总唯一标记值以控制存储成本

要注意来自用户提供的来源的标记值可能导致度量的基数增加。您应该始终仔细规范化和限制用户提供的输入。有时候原因很隐蔽。考虑记录服务端点上的 HTTP 请求时的 URI 标记。如果我们不将 404s 限制为像“NOT_FOUND”这样的值,那么度量的维度将随着每个无法找到的资源而增长。更加棘手的是,一个将所有非经过身份验证的请求重定向到登录端点的应用程序可能会为最终将在经过身份验证后找不到的资源返回 403,并且因此,403 的一个合理的 URI 标记可能是“REDIRECTION”。允许标记值集合无限增长可能会导致监控系统中的存储溢出,增加成本并可能使您的可观察性堆栈的核心部分不稳定化。

一般来说,避免在像用户 ID 这样的唯一值上记录标记,除非已知该人口规模很小。

标记值必须是非空的,最好是非空白的。尽管 Micrometer 在技术上支持有限情况下的空白标记值,比如对于 Datadog 注册表实现,但空白的标记值在其他不支持它们的监控系统中不具备可移植性。

限制总唯一标记值以控制查询成本

除了随着唯一标记值数量的增加而增加存储成本外,查询成本(包括时间和资源)也会随着需要在查询结果中聚合更多时间序列而增加。

常见标记

当低级别库提供常见的仪表化时(Micrometer 提供开箱即用的计量器绑定器——参见“计量器绑定器”),它们无法知道此仪表化将在何种应用程序上收集。应用程序是在私有数据中心运行,还是在一组名为的小型 VM 之一上运行,而这些名称从不改变?是在基础设施即服务的公共云资源上?在 Kubernetes 中?几乎每种情况下,特定应用程序的多个运行副本都可能输出指标,即使在生产环境中只有一个副本,在低级测试环境中也只有一个。如果我们能以某种方式对这些不同应用程序实例输出的指标进行分区,以允许我们通过某些维度将行为归因于特定实例,那将非常有用。

计量过滤器(详细介绍请参阅“计量过滤器”)允许您添加常见标签来实现这一点,从而丰富从应用程序发布的每个指标。选择帮助将您的指标数据转化为行动的常见标签。以下是一些始终有用的常见标签:

应用程序名称

考虑到一些指标,比如框架提供的 HTTP 请求指标,将在各种应用程序中具有相同的名称,例如,应用程序服务的http.server.requests和向其他服务发出的http.client.requests的出站请求。通过应用程序名称进行标记,您可以例如推断出关于跨多个调用者的某个特定服务端点的所有出站请求的情况。

集群和服务器组名称

在“交付管道”中,我们更多地讨论了集群和服务器组的正式定义。如果您拥有这样的拓扑结构,使用集群和服务器组的标签总是有益的。有些组织并不具备这种复杂程度,这也是可以接受的。

实例名称

在某些情况下,这可能是机器的主机名,但并非总是如此(这有助于解释为什么 Micrometer 不会预先使用诸如主机名之类的标签,因为它确实取决于部署的环境)。在公共云环境中,主机名可能不是正确的标签。例如,AWS EC2 具有与实例 ID 不同的本地和外部主机名。实例 ID 实际上是这三个中最容易在 AWS 控制台中找到特定实例的标签,而且确实能唯一标识该实例。因此,在这种情况下,实例 ID 是比主机名更好的标签。在 Kubernetes 中,Pod ID 是正确的实例级标签。

在这个背景下,“堆栈”意味着开发与生产。您可能有多个非生产环境的级别。Shelter Insurance 曾经有“devl”,“test”,“func”和“stage”等多个非生产环境,每个环境都有自己的用途(我可能忘记了其中一个或两个)。在不稳定的低级环境上实践监控是件好事,这样您可以基准您对代码性能和错误数量的预期,随着其在推广路径上向生产线路前进。

在 Table 2-4 中还包括了针对不同部署环境的标签的其他想法。

表 2-4. 云提供商的通用标签

Provider 通用标签
AWS 实例 ID,ASG 名称,区域,区域,AMI ID,账户
Kubernetes Pod ID, namespace, cluster name, Deployment/StatefulSet name, ReplicaSet name
Cloud Foundry CF 应用名称(可能与应用程序名称不同),组织名称,空间名称,实例序数,基金会名称

此表说明了为什么 Micrometer 默认不添加这些标签。在这些云提供商中,“命名空间”这一单一概念在 AWS 和 Kubernetes 中有三个不同的名称:区域,命名空间和组织/空间。CloudFoundry 有两个:组织和空间!

应用通用标签是作为平台工程组织开始思考如何为您的组织封装和标准化的好地方。

Example 2-10 展示了在 Spring Boot 中如何通过基于属性的配置应用通用标签。

示例 2-10. 在 Spring Boot 中通过属性添加通用标签
management.metrics.tags:
  application: ${spring.application.name}
  region: us-east-1
  stack: prod

或者,您可以在自动配置的@Configuration类中应用标签,就像 Example 2-11 中所示。

示例 2-11. 在 Spring Boot 中以编程方式添加通用标签
@Configuration
public class MetricsConfiguration {
  @Bean
  MeterFilter commonTags(@Value("${spring.application.name}") String appName) {
    return MeterFilter.commonTags(
      "application", appName,
      "region", "us-east-1", ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
      "stack", "prod"
    )
  }
}

1

这也应该来自环境。

如果您有某些中央动态配置服务器,例如 Spring Cloud Config Server,应用在启动时会查询属性,基于属性的配置允许您立即在整个应用堆栈中交付这些通用标签意见,而无需进行任何代码更改或每个应用的依赖要求。

程序化形式可以通过每个应用的显式运行时二进制依赖项或将依赖项注入应用或运行其容器的形式(如 Tomcat)来交付。

米(Meters)的类

许多度量收集器提供几类计量器,每种计量器可能会发出一个或多个度量或统计数据。最常见的是规模、计数器和计时器/摘要。更专业的计量器包括长任务计时器、时间规模和间隔计数器。努力使用最适合任务的最专业的计量器。例如,计时器总是发出一个计数以衡量吞吐量。对于计数特定代码块的执行次数而言,测量其时间会生成相同的计数统计信息,但还会提供关于该代码块延迟分布更丰富的信息,这几乎没有任何优势。

在每种类型的介绍后,选择内置计量器类型的决策指南如 “选择正确的计量器类型” 所述。

规模

规模是随时间增加和减少的瞬时值的测量。规模的时间序列图是在应用程序发布度量时瞬时值的样本集合。由于它们是采样的瞬时值,因此可能甚至很可能在不同时间点进行采样时,值会更高或更低。

车辆上的速度表和油量表是规模的经典示例。当您沿着道路行驶时,您会定期瞥一眼速度表(希望如此)。周期性地看到您的速度的瞬时测量足以控制速度,但仍然是真实的,您错过了在看的时候发生的速度变化。

在应用程序中,规模的典型示例可以是集合或映射的大小,或者是运行状态下的线程数。内存和 CPU 的测量也使用规模。图 2-3 展示了单个指标 jvm.memory.used 的规模时间序列,该指标标记了几个维度,包括内存空间。这个堆叠图表展示了将内存消耗这样的单一概念维度化如何丰富其在图表中的表示。

JVM 堆使用情况图像,一个具有多个标签的计量器在堆叠图表中表示

图 2-3. 堆内存使用情况

Micrometer 认为规模应该被采样,而不是被设置,因此没有关于在采样之间可能发生的情况的信息。毕竟,任何在规模值报告给度量后端之前设置的中间值都会丢失,因此似乎在第一次设置这些中间值时几乎没有任何价值。

将规模视为仅在观察时才会更改的计量器。其他所有类型的计量器都会累积中间计数,直到将数据发送到度量后端为止。

MeterRegistry 接口包含方法,其中一些显示在 示例 2-12 中,用于构建观察数值、函数、集合和映射的规模。

Example 2-12. 创建规模
List<String> list = registry.gauge(
  "listGauge", Collections.emptyList(),
  new ArrayList<>(), List::size);

List<String> list2 = registry.gaugeCollectionSize(
  "listSize2", Tags.empty(),
  new ArrayList<>());

Map<String, Integer> map = registry.gaugeMapSize(
  "mapGauge", Tags.empty(), new HashMap<>());

在第一种情况下,稍微常见的规模形式是监视某些非数值对象的规模。最后一个参数确定观察规模时用于确定规模值的函数。Micrometer 提供了便利方法来监视映射和集合的大小,因为这些情况非常常见。

大多数形式的规模创建仅保持对被观察对象的弱引用,以免阻止对象的垃圾回收。您有责任保持对状态对象的强引用,该对象由规模测量。Micrometer 谨慎地不会创建对本应被垃圾回收的对象的强引用。一旦被测量的对象取消引用并被垃圾回收,Micrometer 将开始报告一个 NaN 或空值,具体取决于注册表的实现。

通常返回的Gauge实例除了在测试中不实用外,在注册时已设置好以自动跟踪值。

使用直接从MeterRegistry创建规模的快捷方法之外,Micrometer 还提供了一个规模流畅构建器(参见 Example 2-13),它具有更多选项。注意strongReference选项,它与默认行为相反,防止监视对象被垃圾回收。

Example 2-13. 规模的流畅构建器
Gauge gauge = Gauge
    .builder("gauge", myObj, myObj::gaugeValue)
    .description("a description of what this gauge does") // Optional
    .baseUnit("speed")
    .tags("region", "test") // Optional
    .strongReference(IS_STRONG) // Optional
    .register(registry);

Micrometer 具有包括几个规模在内的内置度量标准。一些示例列在 Table 2-5 中。

表 2-5. Micrometer 内置仪表规的示例

指标名称 描述
jvm.threads.live 当前活动线程数,包括守护线程和非守护线程
jvm.memory.used 使用的内存量(以字节为单位)
db.table.size 数据库表中行数的总数
jetty.requests.active 当前活动请求数

特殊类型的Gauge称为TimeGauge,专门用于测量时间值(参见 Table 2-6)。像Gauge一样,不需要设置TimeGauge,因为其值在观察时会改变。它们之间唯一的区别在于,TimeGauge的值将按监视系统的基本时间单位进行缩放,并在发布时显示。在其他情况下,请遵循值应该以自然的基本单位来测量的一般规则(例如,存储的字节,连接池利用率的连接)。监控系统只在描述时间的基本单位时有所不同的期望。

表 2-6. Micrometer 内置仪表中时间规模的示例

指标名称 描述
process.uptime Java 虚拟机的正常运行时间,由 Java 的 Runtime MXBean 报告
kafka.consumer.fetch.latency.avg 由 Kafka Java 客户端计算和报告的组同步平均时间

Kafka 消费者获取延迟平均值是一个例子,有时 Java 客户端库只提供粗略的统计数据,比如平均值,如果我们可以直接影响 Kafka 客户端代码,那么计时器可能更合适。除了查看平均值外,我们还可以得到有关衰减最大延迟、百分位等的信息。

最后一种特殊类型的 GaugeMultiGauge,用于管理增长或缩减的一组标准的测量。通常在我们想要从像 SQL 查询之类的东西中选择一组受界限但略有变化的标准时,会使用此功能,并为每行报告某些指标作为 Gauge。当然,不一定要从数据库获取数据。该测量器可以构建在内存中类似于映射的结构上,或任何其他行数包含至少一个数值列的结构上。Example 2-14 展示了如何创建 MultiGauge

Example 2-14. 创建多测量器
// SELECT count(*), city from customers group by city WHERE country = 'US'
MultiGauge statuses = MultiGauge.builder("customers")
        .tag("country", "US")
        .description("The number of customers by city")
        .baseUnit("customers")
        .register(registry);

...

// Run this periodically whenever you rerun your query
statuses.register(
  resultSet.stream().map(result ->
    Row.of(
      Tags.of("city", result.getAsString("city")),
      result.getAsInt("count")
    )
  )
);

在尝试构建报告应用程序中某个事件发生速率的测量器之前,请考虑使用计数器,这更适合此目的。

应该使用计数器还是测量器?

永远不要对可以计数的事物进行测量。

计数器

计数器报告单个指标,即计数。Counter接口允许您按固定数量增加,此数量必须为正数。

可能,尽管罕见,可以通过分数增加计数器。例如,您可以计算像美元这样的基本单位的总和,这些自然有分数金额(尽管将销售计数为另一种计量类型可能更有用,如 “分布摘要” 所示)。

MeterRegistry 接口包含便利方法,用于创建计数器,如 Example 2-15 中所示。

Example 2-15. 创建计数器
// No tags Counter counter = registry.counter("bean.counter");

// Adding tags in key-value pairs with varargs Counter counter = registry.counter("bean.counter", "region", "us-east-1");

// Explicit tag list creation ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
Counter counter = registry.counter("bean.counter", Tags.of("region", "us-east-1"));

// Adding tags to some other precreated set of tags. Iterable<Tag> predeterminedTags = Tags.of("region", "us-east-1");
Counter counter = registry.counter("bean.counter",
  Tags.concat(
    predeterminedTags,
    "stack", "prod"
  )
);

1

这并非专属于计数器 —— 在其他计量类型上我们将看到类似的 API。

Counter 流畅构建器,如 Example 2-16 所示,包含更多选项。

Example 2-16. 计数器的流畅构建器
Counter counter = Counter
    .builder("bean.counter")
    .description("a description of what this counter does") // Optional
    .baseUnit("beans")
    .tags("region", "us-east-1") // Optional
    .register(registry);

Micrometer 包含多个内置指标,其中包括多个计数器。一些示例见 Table 2-7。

表格 2-7. Micrometer 内置仪表中计数器的示例

指标名称 描述
jetty.async.requests 异步请求总数
postgres.transactions 执行的事务总数(提交 + 回滚)
jvm.classes.loaded 当前加载到 Java 虚拟机中的类数
jvm.gc.memory.promoted 老年代内存池在 GC 前后正增长大小的计数

在构建图表和警报时,通常应该最关注某个事件在给定时间间隔内发生的速率。考虑一个简单的队列。计数器可用于测量诸如插入和删除项目的速率。

当计数器测量事件发生时,它测量的是吞吐量。

当我们谈论事件发生率时,概念上我们在谈论吞吐量。在图表上显示计数器作为速率时,我们显示的是吞吐量的度量,即增加计数器的快速程度。当您有机会为每个单独事件操作添加指标时,几乎总是应该使用计时器(参见“计时器”),它们不仅提供吞吐量的度量,还提供其他有用的统计信息。

起初,您可能希望构想可视化绝对计数而不是速率,但绝对计数通常是某些东西使用的快速程度和应用程序实例在仪表化下的寿命的函数。构建仪表板和警报以某个时间间隔内的计数器速率来忽略应用程序的寿命,使您能够在应用程序启动后长时间查看异常行为。

在许多情况下,当我们深入探讨工程师试图可视化绝对计数时,这是为了展示某些真实的与业务相关的数字(销售数量,收入等)。请记住,指标仪表化是为了信号可用性而优化的,因此其实现自然会在持久性和性能之间进行权衡。任何给定的指标发布间隔都可能因为诸如(物理或虚拟)机器故障或应用程序与指标后端之间的网络问题而失败,并且不会重试,因为假设是您会在下一个间隔上追赶。即使是累积计数器,如果关闭前的最终值未能传送到后端,它也会丢失。对于像法定报告所需的关键计数,应该使用其他耐久性存储而不是仅仅作为指标发布(或者也可以额外作为指标发布)。

对于某些监控系统,例如 Atlas,计数器以 Micrometer 的速率发布。在 Atlas 中查询并绘制计数器,如示例 2-17 所示,显示的图表其y轴是速率。

示例 2-17. Atlas 计数器速率
name,queue.insert,:eq

然而,一些监控系统期望计数器以累积统计数据的形式发布。只有在查询时,计数器才会转换为速率以供显示,例如 Example 2-18。在这种情况下,我们需要使用特定的 rate 函数将累积统计转换为速率。在几乎所有监控场景中,访问计数器时都会使用这个 rate 函数。

示例 2-18. Prometheus 计数器速率
rate(queue_insert_sum[2m])

很容易统计从特定方法发出的错误数量,或者方法的成功调用总数,但最好还是用计时器记录这些事件,因为它们包括计数和关于操作延迟的其他有用信息。

我应该使用计数器、计时器(或分布摘要)吗?

永远不要统计可以计时的事物。如果基本单位不是时间单位,那么推论是,尴尬地说:永远不要统计可以用分布摘要记录的事物。

计时器

计时器用于测量短时延迟和这类事件的频率。所有 Timer 的实现至少报告几个单独的统计数据:

计数

衡量此计时器的个别记录数量。对于测量 API 端点的 Timer,此计数是发送到 API 的请求次数。计数是吞吐量的度量。

总和

满足所有请求所花费的时间总和。因此,如果有三个请求发送到一个 API 端点,分别耗时 5 毫秒、10 毫秒和 15 毫秒,那么总和为 5 + 10 + 15 = 30 m s 。这个总和可能直接展示为 30 毫秒,或者根据监控系统的设置以速率的形式展示。我们将很快讨论如何解释这一点。

最大值

单个计时器中最长的计时,但是在一定时间间隔内衰减。Micrometer 在一个环形缓冲区中维护一系列重叠的时间间隔,并在每个时间间隔内跟踪最大值。这个最大值在某种意义上是有些粘性的。需要牢记的重要一点是,这不是从应用程序启动以来看到的所有样本的最大值(这并不是非常有用),而是最近看到的最大值。可以配置最近性来使这个值的衰减速度更快或更慢。

此外,计时器还可以选择性地输出其他统计数据:

服务水平目标(SLO)边界

观察到小于或等于特定边界值的请求总数。例如,到 API 端点的请求中有多少请求花费时间少于 100 毫秒。由于这是一个计数,可以通过总体计数来计算达到服务水平目标的百分比,而且计算起来非常便宜,只要预先知道你想要设置的目标。

百分位数

预计算的百分位数无法与其他标签的百分位数结合使用(例如,集群中几个实例的百分位数无法结合)。

直方图

类似于 SLO 边界,直方图由一系列存储桶的计数组成。直方图可以跨维度求和(例如,跨多个实例对相似存储桶的计数求和),并且可以通过某些监控系统创建百分位数近似值。我们将在 “直方图” 中详细讨论直方图。

让我们通过几个例子来看看这些统计数据可能如何使用及其之间的关系。

“Count” 意味着 “吞吐量”

计时器的计数统计量是单独有用的。它是吞吐量的衡量标准,即定时操作发生的速率。在计时 API 端点时,它是对该端点的请求次数。在衡量队列上的消息时,它是放入队列的消息数量。

计数统计应完全按照 “计数器” 中描述的方式使用,作为速率。根据监控系统的不同,这个统计量将作为累积计数或从 Micrometer 发送时的速率。

“Count” 和 “Sum” 一起意味着 “可聚合平均”

除了我们马上要讨论的一个例外,总和本身并不真正有意义。如果不考虑操作发生的速率,总和就没有任何意义。对于一个面向用户的 API 端点的单个请求的 1 秒总和可能不好,但是 1,000 个每个 1 毫秒的请求,总和为 1 秒听起来相当不错!

总和和计数可以一起用来创建可聚合平均值。如果我们直接发布计时器的平均值,它就不能与其他维度的平均数据(例如其他实例)结合起来推断整体平均值。

考虑 图 2-4 中描述的场景,负载均衡器已将七个请求分发给四个应用程序实例。其中三个应用程序实例位于 Region 1,一个实例位于 Region 2。

srej 0204

图 2-4. 发送到假设应用程序的请求时间

假设我们已为每个实例的计时器指标打了标签,包括实例 ID 和区域。然后监控系统将看到带有四种不同标签组合的计时器时间序列:

  • Instance=1, Region=1

  • Instance=2, Region=1

  • Instance=3, Region=1

  • Instance=4, Region=2

每个计时器的计数和总和将有时间序列。在 表 2-8 中,这七个请求发生后,累积监控系统将具有相应标签的总和和计数的值。同时还包括该实例的平均值。

表 2-8. 每个计时器的累积和与计数

实例 区域 计数(操作) 总计(秒) 平均(秒/操作)
1 1 2 0.022 0.011
2 1 2 0.018 0.009
3 1 2 0.020 0.010
4 2 1 0.100 0.100

要找出此应用程序的所有实例和区域中此计时器的平均延迟,我们将总和除以计数的总和(参见方程 2-1)。

方程 2-1. 计算集群平均

0.022+0.018+0.020+0.100 2+2+2+1 = 0 . 017 s e c o n d s / o p = 17 m i l l i s e c o n d s / o p

如果 Micrometer 只从每个实例发送平均值,我们将无法轻松计算相同的值。像方程 2-2 中显示的对平均值的平均化是不正确的。这里的“平均值”太高了。与区域 2 相比,区域 1 的请求要多得多,并且区域 1 提供的响应速度要快得多。

方程 2-2. 集群平均值的不正确计算

0.011+0.009+0.010+0.100 4instances = 0 . 032 s e c o n d s / r e q u e s t = 32 m i l l i s e c o n d s / r e q u e s t

这里展示的集群平均值演示假设 Micrometer 以累计值形式传输 sum 和 count。如果 Micrometer 改为传输速率会怎样?表 2-9 展示了标准化为速率的值,例如传输到 Atlas 的值。在本表中,假设在一分钟内发生的七个请求与 图 2-4 中显示的间隔对齐,并且此间隔与我们推送指标到 Atlas 的间隔一致。

表 2-9. 每个计时器的速率标准化的总和和计数

实例 区域 计数(请求/秒) 总计(无单位) 平均(秒/请求)
1 1 0.033 0.00037 0.011
2 1 0.033 0.00030 0.009
3 1 0.033 0.00033 0.010
4 2 0.017 0.00167 0.100

现在的计数列单位是“每秒请求数”而不仅仅是“请求”。无论发布间隔是什么,都将是每秒请求数。在本例中,我们每分钟发布一次;因此,由于我们看到向 Instance 1 的两个请求,我们得出结论:向该实例的请求/秒速率是方程 2-3。

方程 2-3. 向实例 1 的吞吐量速率

2 r e q u e s t s / m i n u t e = 2 r e q u e s t s / 60 s e c o n d s = 0 . 033 r e q u e s t s / s e c o n d

现在的总计列是无单位的,而不再是秒。这是因为速率的分子和分母都是秒,这些单位会相互抵消。因此,对于实例 1,总计是方程 2-4。在速率标准化系统中,总计的无单位性质强调了其无意义性,独立于与计数(或其他有尺寸的值)的组合。

方程 2-4. 实例 1 的速率标准化总和

22 m i l l i s e c o n d s / m i n u t e = 0 . 022 s e c o n d s / 60 s e c o n d s = 0 . 00037

由于单位的抵消作用,每个实例的平均值与累计表中的平均值相同。

对于平均值而言,间隔是什么并不重要。如果间隔是两分钟而不是一分钟,我们对吞吐量的理解会发生变化(即,它正好减半),但额外的一分钟在平均计算中会被抵消。在 Instance 1 的情况下,请求/秒的计数是 Equation 2-5。

Equation 2-5. Instance 1 在两分钟间隔内的吞吐率

2 r e q u e s t s / 2 m i n u t e s = 2 r e q u e s t s / 120 s e c o n d s = 0 . 01667 r e q u e s t s / s e c o n d

总和是 Equation 2-6。但是当我们进行除法时,平均值仍然相同。在这个除法中,你可以从分子和分母中本质上因子出 2

Equation 2-6. Instance 1 在两分钟间隔内的率标准化总和

22 m i l l i s e c o n d s / 2 m i n u t e s = 0 . 022 s e c o n d s / 120 s e c o n d s = 0 . 00018

平均值对于监控可用性来说并不理想。一般而言,接受略差一些的平均值,而更好的最坏情况(例如,大于第 99 百分位数的性能)会更好,因为最坏情况通常发生的频率远远超过我们的直觉。尽管如此,通过总和除以计数来计算是简单的,并且几乎所有监控系统都可以实现,即使是没有更复杂数学运算的系统也能如此。因此,平均值至少是跨一系列截然不同的监控系统中的某种基线。如果可能的话,最好根本不要使用平均值。

平均值:一个随机数,落在最大值和中位数的 1/2 之间。最常用于忽略现实。

Gil Tene

相反,对于可用性,查看最大值或高百分位数的统计数据更为有用,如我们将在 “Timers” 中详细讨论的那样。

最大值是一个衰减信号,不与推送间隔对齐

Micrometer 将最大值衰减而不是将其与发布间隔对齐,就像它对总和和计数所做的那样。如果我们完美地将最大时间的视图与推送间隔对齐,那么丢失的度量负载意味着我们可能错过看到特别高的最大值(因为在下一个间隔中,我们只会考虑发生在那个间隔内的样本)。

对于像计数这样的其他统计数据,错过发布间隔通常不会有问题,因为在度量负载被丢弃的期间,计数器仍然会累积,并且下一个成功的负载将显示它。

实际上,有很多原因可以解释为何高最大延迟和丢失的度量负载会相关联。例如,如果应用程序受到严重资源压力的影响(比如饱和的网络接口),在同一时间,对于正在计时的 API 端点的用户响应(并且正在跟踪最大值)可能非常高,同时监控系统的度量值发送请求由于读取超时而失败。但这样的条件可能是(而且经常是)暂时的。

也许您有一个客户端负载均衡策略,该策略意识到(从客户端的角度)API 的延迟在承受资源压力的实例中急剧上升,并开始优先考虑其他实例。通过减轻该实例的压力,它得以恢复。

在随后的某个时间段,实例恢复之后,能够推送在这段困难时期中看到的最大延迟是很好的,否则这些延迟会被跳过。事实上,正是这些困难时期我们最关心的,而不是在晴天条件下的最大延迟!

虽然这种衰减的效应是最大值会在实际发生之后的一段时间内“持续存在”。在 图 2-5 中,我们可以看到定时操作的最大值约为 30 毫秒。这个 30 毫秒的操作发生在度量发布间隔之前的某个时刻,当线条从 0(大约在 19:15)首次上升时。

srej 0205

图 2-5. 衰减的最大值在图表上持续存在一段时间

这个定时器被配置为在两分钟内衰减最大值。因此,它会持续到大约 19:17。由于这个定时器在看到 30 毫秒的时间后没有看到任何操作,最大值衰减后时间序列消失。

Micrometer 通过在 环形缓冲区 中跟踪最大值来实现这种衰减行为。环形缓冲区在 Timer.Builder 上有 distributionStatisticsBufferLengthdistributionStatisticExpiry 的配置选项,您可以使用它们来进行更长时间的衰减,就像 示例 2-20 中所示的那样。默认情况下,Micrometer 使用长度为 3 的环形缓冲区构建定时器,并且指针将每 2 分钟前进一次。

图 2-6 是一个包含三个元素的环形缓冲区的示意图。这个环形缓冲区只是一个带有指向特定元素的指针的数组,在我们发布指标时将从中轮询最大值。每经过 distributionStatisticExpiry,指针就会前进到环形缓冲区中的下一个元素。在这个缓冲区中,索引为零的元素没有样本。第一个和第二个索引元素存储着它们自上次重置以来看到的最大样本的状态,为 10 毫秒。第一个索引周围的阴影环表示正在从中轮询的元素。

srej 0206

图 2-6. 一个包含三个元素的环形缓冲区

图 2-7 展示了一个包含三个元素和两分钟过期时间的定时器环形缓冲区,在八分钟的时间段内发生变化。图下方是关于值如何变化的每分钟描述,其中 t 是分钟数。

srej 0207

图 2-7. 长度为 3 的定时器最大环形缓冲区

t=0

这 这是初始状态。每个环形缓冲区元素都为空。没有观察到计时器记录。在 t=0 和 t=1 之间,观察到两个计时器记录:一个在 10 毫秒,另一个在 8 毫秒。

t=1

由于看到的两个记录中 10 毫秒是较大的,并且每个环形缓冲区元素之前都为空,现在所有元素都在跟踪 10 毫秒作为最大值。如果我们在 t=1 时发布度量,最大值将为 10 毫秒。在 t=1 和 t=2 之间,我们观察到了 7 毫秒的计时,但它没有超过任何环形缓冲区元素中的样本。

t=2

第零个环形缓冲区元素被重置,因为已达到到期时间,并且指针移动到索引 1。在 t=2 和 t=3 之间,我们看到了一个 6 毫秒的计时记录。由于第零个元素已经被清除,它现在跟踪 6 毫秒作为其最大值。轮询最大值为 10 毫秒。

t=3

最旧的两个环形缓冲区元素仍然将 10 毫秒作为最大值,而第零个元素跟踪 6 毫秒。轮询最大值仍然为 10 毫秒,因为指针在索引 1 上。

t=4

索引 1 被重置,并且指针被移到索引 2。索引 2 仍然跟踪 10 毫秒,因此轮询最大值为 10 毫秒。

t=5

没有任何变化。轮询最大值为 10 毫秒。请注意,在此时计时器将报告计数和总和为 0,最大为 10 毫秒!这就是所谓的最大值不像计数和总和那样与发布间隔对齐的含义。

t=6

索引 2 被重置,指针循环回到索引 0,它仍然将其在 t=2 和 t=3 之间观察到的 6 毫秒样本作为最大值。轮询最大值为 6 毫秒。在 t=6 和 t=7 之间,观察到了一个 12 毫秒的样本,它成为环形缓冲区中的最大值。

t=7

轮询最大值为 12 毫秒,观察到的时间在 t=7 之前不久。

t=8

第零个环形缓冲区元素被重置,并且指针移动到索引 1。

在时间间隔内总和的总和

没有几种情况下总和的总和是有用的。事实上,我只遇到过一种情况,在 “垃圾收集占用时间的比例” 中我们稍后将会看到,其中垃圾收集(GC)花费的时间总和被分为正在进行垃圾收集的时间间隔的总时间(例如,总共花了多少时间在 GC 中)。即使在这种情况下,如果 JVM 在每次垃圾收集事件发生时为我们提供离散的时间,我们可能也可以开发出更好的垃圾收集警报信号。如果是这样,我们可能会查看高百分位数的 GC 时间或按原因查看最大 GC 时间。

时间的基本单位

计时器的适当基本单位因监控系统而异。例如,Prometheus 期望浮点秒精度数据,因为从概念上讲,秒是时间的基本单位。Atlas 期望纳秒精度数据,因为它可以接受并存储整数值。由于无法测量纳秒的一个子分区(在许多情况下甚至无法真正实现纳秒精度),后端利用此优化。无论如何,Micrometer 会自动将定时器按照每个监控系统预期的基本单位进行缩放。

这些基本单位都没有对错之分,也没有更少精确的说法。这只是每个约定的问题。时间的基本单位不影响图表的精度。例如,尽管 Micrometer 将时间以秒为单位发送到 Prometheus,但例如用于监视用户界面 API 端点的常见计时器,图表通常仍会以毫秒显示,如图 2-8。

srej 0208

图 2-8. 一个计时器,以为基本单位显示为毫秒

我们将在第四章中更详细地讨论图表,但现在只需知道,这种方式的缩放通常由图表界面自动完成。我们只需要告诉它如何解释统计数据,即告诉它显示的时间序列是以秒为单位的,如图 2-9。

srej 0209

图 2-9. 告知图表库如何解释计时器基本单位

在这种情况下,Grafana 聪明地知道人们更容易读取毫秒而不是秒的小分数,因此它将秒数据缩小到毫秒。类似地,如果在特定监控系统中以纳秒表示时间是规范的,图表库将执行相反的数学操作将值放大到毫秒。

常见基本单位不限制您查看数据的方式

对于像时间和数据大小(例如字节)这样的常见单位,您不应该关心您打算查看数据以决定记录的比例。通常最好在所有地方都使用一致的基本单位,并允许图表库以后将这种自动缩放到人类可读格式。与其将响应体的有效载荷大小记录为字节(因为它通常很小)和堆大小记录为兆字节(因为它通常很大),不如将它们都记录为字节。稍后在查看时,它们都将被缩放到合理的值。

对于没有明确时间基准偏好的监控系统,Micrometer 会选择一个;通常不可配置,因为在不牺牲精度的情况下,保持所有应用程序的一致性比更改时间基准更重要。

使用定时器

MeterRegistry 接口包含方便的方法用于创建定时器,如 示例 2-19 所示。

示例 2-19. 创建定时器
// No tags
Timer  timer = registry.timer("execution.time");

// Adding tags in key-value pairs with varargs
Timer timer = registry.timer("execution.time", "region", "us-east-1");

// Explicit tag list creation
Timer timer = registry.timer("execution.time", Tags.of("region", "us-east-1"));

Timer 流畅构建器(参见 示例 2-20)包含更多选项。大多数情况下,您不会使用所有这些选项。

示例 2-20. 定时器的流畅构建器
Timer timer = Timer
    .builder("execution.time")
    .description("a description of what this timer does")
    .distributionStatisticExpiry(Duration.ofMinutes(2))
    .distributionStatisticBufferLength(3)
    .serviceLevelObjectives(Duration.ofMillis(100), Duration.ofSeconds(1))
    .publishPercentiles(0.95, 0.99)
    .publishPercentileHistogram()
    .tags("region", "us-east-1")
    .register(registry);

Timer 接口提供了几个方便的重载以内联记录计时,例如在 示例 2-21 中。此外,可以用仪表包装 RunnableCallable 并返回以供以后使用。

示例 2-21. 使用定时器记录执行
timer.record(() -> dontCareAboutReturnValue());
timer.recordCallable(() -> returnValue());

Runnable r = timer.wrap(() -> dontCareAboutReturnValue());
Callable c = timer.wrap(() -> returnValue());

定时器与分布摘要

Timers 实际上只是分布摘要的一种专门形式(参见 “分布摘要”)。它们知道如何将持续时间缩放到每个监控系统的基本时间单位,并具有自动确定的基本单位。几乎每种需要测量时间的情况下,都应使用 Timer 而不是 DistributionSummary。唯一的例外是在短时间内记录许多长持续时间事件时,纳秒精度的 Timer 在单个间隔内会溢出约 290 年(因为 Java 的长整型最多可以有效存储 9.22e9 秒)。

您还可以在样本实例中存储起始状态,稍后可以停止。样本根据注册表的时钟记录开始时间。开始样本后,执行要计时的代码,并在样本上调用 stop(Timer) 完成操作。

注意在 示例 2-22 中,样本累积到的定时器直到停止样本时才确定。这允许从我们正在计时的操作的最终状态动态确定一些标签。当我们处理具有监听器模式的某些事件驱动接口时,使用 Timer.Sample 特别常见。该示例是 Micrometer 的 JOOQ 执行监听器的简化形式。

示例 2-22. 用于事件驱动模式的定时器样本的使用
class JooqExecuteListener extends DefaultExecuteListener {
  private final Map<ExecuteContext, Timer.Sample> sampleByExecuteContext =
    new ConcurrentHashMap<>();

  @Override
  public void start(ExecuteContext ctx) {
    Timer.Sample sample = Timer.start(registry);
    sampleByExecuteContext.put(ctx, sample);
  }

  @Override
  public void end(ExecuteContext ctx) {
    Timer.Sample sample = sampleByExecuteContext.remove(sample);

    sample.stop(registry.timer("jooq.query", ...)); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  }
}

1

我们通常会基于在 ExecuteContext 中找到的数据元素结果添加一些标签。

还有一种AutoCloseable形式的计时器示例,用于计时包含已检查异常处理的代码块,如示例 2-23 所示。该模式需要嵌套的try语句,这有点不寻常。如果您对此模式感到不舒服,完全可以坚持使用简单的Timer.Sample

示例 2-23. 使用计时器示例
try (Timer.ResourceSample sample = Timer.resource(registry, "requests")
        .tag("method", request.getMethod()) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
        .description("This is an operation")
        .publishPercentileHistogram()) {
    try { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
        if (outcome.equals("error")) {
            throw new IllegalArgumentException("boom");
        }
        sample.tag("outcome", "success"); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
    } catch (Throwable t) {
        sample.tag("outcome", "error");
    }
}

1

此标签将适用于两个结果。描述文本和百分位直方图也将适用于两个结果。

2

此嵌套的 try 语句使得可以在 catch 块中访问Timer.ResourceSample,以添加错误标签。

3

我们可以在try/catch块的每个分支点添加标签,记录有关结果的信息。

Micrometer 具有包括多个计时器在内的内置度量指标。一些示例见表 2-10。

表 2-10. Micrometer 内置工具中计时器的示例

指标名称 描述
http.server.requests Spring Boot 记录 WebMVC 和 WebFlux 请求处理程序执行的时间。
jvm.gc.pause 花费在 GC 暂停上的时间。
mongodb.driver.commands 花费在 MongoDB 操作上的时间。

计时器是分布式跟踪的度量补充(在第三章中深入讨论),因为跟踪跨度和计时器可以在相同的代码中进行工具化,如示例 2-24 中所示。

追踪和度量的交集

分布式跟踪和度量之间的重叠严格限于计时。

示例代码使用了 Zipkin 的 Brave 工具包,稍后我们会再次见到它。

示例 2-24. 跟踪和计时同一段代码
// Start a new trace ScopedSpan span = tracer.startScopedSpan("encode"); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
try (Timer.ResourceSample sample = Timer.resource(registry, "encode")) {
    try {
      encoder.encode();
      sample.stop(registry.timer("encode", "result", "success"));
    } catch (RuntimeException | Error e) {
      span.error(e);
      sample.stop(registry.timer("encode", "result", "failure"));
      throw e;
    } finally {
      span.finish();
    }
}

1

Brave 没有像 Micrometer 那样的 AutoCloseable 结构,因此仪表化看起来有些不对称。

假设一个特定的代码块,在类似的输入条件下执行的时间大致相同,这是很自然的。我们对此的直觉可能是误导性的。

延迟分布的共同特征

了解 Java 应用程序中时间特性的一些常见特征是很重要的。由于输入参数的变化、下游系统、堆的状态以及许多其他变量的影响,同一段代码块在每次执行时不会以相同的时间执行。尽管如此,许多具有类似输入的请求通常会在相似的时间内得到满足。

直觉可能会让人相信,时间大致上是正态分布的,即围绕着平均值有一个中央的峰,对于更快和更慢的响应时间有更低概率的尾部,就像图2-10中所示的那样。

srej 0210

2-10。正态分布

在真实世界的案例中,时间几乎总是多峰的,意味着在延迟范围内有多个“峰”,或者时间的分组。最常见的情况是,Java 的时间是双峰的(两个峰,如图2-11所示),其中较小的、最右边的峰代表了诸如垃圾收集和 VM 暂停之类的事件。第二个峰也包括了下游服务中多峰性的涟漪效应。

srej 0211

2-11。Java 延迟的典型双峰分布

奇怪的是,最大的、最左边的峰通常(当然不总是)非常窄,含有超过99%的时间。因此,在许多情况下,99分位数(参见“百分位数/分位数”)会低于平均值,平均值被第二个峰的增高所偏移。

标准差

一些度量仪器库和系统会出厂并显示标准差。这个指标只在正态分布的情况下有意义,但我们看到这基本上从未发生过。标准差对于 Java 执行的实际时间不是一个有意义的统计量。忽略它!同时也忽略平均值。

百分位数/分位数

我提到过不要使用平均延迟吗?在这一点上,你可能已经看到,我不会错过抨击平均延迟的机会。

平均值:一个介于最大值和中位数的1/2之间的随机数。通常用于忽略现实。

吉尔·特纳

平均延迟是评估延迟行为的一个糟糕的度量标准,最大值是一个有用的警戒阈值,但可能会有尖峰。对于比较性能,我们可以看高百分位数的值,这些值不太尖锐。高最大值尖峰在性能较差的代码中肯定会更普遍,但在任何情况下,它们发生的时间都不在你的控制之下,这使得在两个不同实例上运行的相同代码块之间进行比较变得困难。

根据监控系统的不同,术语百分位数分位数用于描述与值的排名顺序相关的样本集中的一点。因此,中间分位数,也称为中位数或第50百分位数,是一组样本中从最小到最大排列的中间值。

中位数与平均值

平均值很少对监控时间有用。从统计学角度来看,平均值是所有样本之和除以总样本数。平均值只是与中位数不同的一个中心度量,一般而言没有更好或更差的代表中心性。即使它是一种“完美”的中心度量,为了证明我们的系统可靠,我们更关心分布的最差一半,而不是最好的一半。

百分位数是一种特殊类型的分位数,相对于 100%描述。在从最小到最大排序的 100 个样本列表中,第 99 百分位数(P99)是排序中的第 99 个样本。在 1,000 个样本列表中,第 99.9 百分位数是第 999 个样本。

根据这个定义,百分位数,特别是高百分位数,对确定大多数用户正在经历的情况非常有用(即 P99 是 99 个用户中有一个经历的最差延迟)。对于时间,百分位数有效地削减了虚拟机或垃圾收集暂停的突发行为,同时仍保留了大多数用户的体验。

监控高百分位值(如第 99 百分位)并感到放心,你的用户体验响应时间良好,这种诱惑很大。不幸的是,这些统计数据让我们的直觉误入歧途。前 1%通常隐藏着比 P99 大一到两个数量级的延迟。

任何单个请求避开了前 1%的情况恰好有 99%的时间。在考虑N个请求时,至少有一个请求处于前 1%的概率为( 1 - 0 . 99 N ) 100 %(当然,假设这些概率是独立的)。惊人的是,只需很少的请求就有超过半数的概率其中一个请求将达到前 1%。对于 100 个单独的请求,这种机会是( 1 - 0 . 99 100 ) 100 % = 63 . 3 %

考虑到用户与您的系统的交互很可能涉及许多资源交互(UI、API 网关、多个微服务调用、一些数据库交互等)。任何单个的端到端用户交互在满足其请求的事件链中体验到顶部 1% 的延迟的机会实际上要高于 1%。我们可以将这个机会近似为( 1 - 0 . 99 N ) * 100 %。如果微服务链中的单个请求体验到顶部 1% 的延迟,那么整个用户体验都会受到影响,尤其是考虑到顶部 1% 的性能往往比第 99 百分位以下的请求差一个数量级或更多。

样本的时间(反)相关性

给出用于确定体验顶部 1% 延迟的机会的公式只是近似值。实际上,高/低延迟的时间相关性会导致机会降低,而反相关性会导致机会增加。换句话说,每个请求的概率并不真正独立。我们几乎从不了解这种相关性的信息,因此这些近似值对于您如何推理您的系统非常有用。

Micrometer 支持两种计算计时器的百分位数的方式:

  • 预先计算百分位数值,并直接发送到监控系统。

  • 将计时分组到离散的延迟桶中,并将这些桶集合一起发送到监控系统(见“直方图”)。然后监控系统负责从直方图计算百分位数。

预先计算百分位数值是最具可移植性的方法,因为许多监控系统不支持基于直方图的百分位数近似,但它只在一组狭窄的情况下有用(稍后在本节中我们将看到为什么)。预先计算的百分位数可以通过几种不同的方式添加到计时器中。

Timer流式构建器支持在构建Timer时直接添加百分位数,如示例 2-25 所示。

例 2-25. 通过构建器向计时器添加百分位数
Timer requestsTimer = Timer.builder("requests")
  .publishPercentiles(0.99, 0.999)
  .register(registry);

示例 2-26 展示了如何使用MeterFilter添加百分位数。

例 2-26. 通过 MeterFilter 向计时器添加百分位数
registry.config().meterFilter(new MeterFilter() {
  @Override
  public DistributionStatisticConfig configure(Meter.Id id,
      DistributionStatisticConfig config) {
    if (id.getName().equals("requests")) {
      DistributionStatisticConfig.builder()
        .publishPercentiles(0.99, 0.999)
        .build()
        .merge(config);
    }
    return config;
  }
});

...

// The filter will apply to this timer as it is created
Timer requestsTimer = registry.timer("requests");

最后,像 Spring Boot 这样的框架提供了基于属性驱动的MeterFilter等效项,允许您声明性地向Timers添加百分位数。在示例 2-27 中显示的配置将百分位数添加到任何以名称requests为前缀的计时器。

例 2-27. 在 Spring Boot 中为以“请求”为前缀的度量添加百分位数
management.metrics.distribution.percentiles.requests=0.99,0.999

通过MeterFilter添加百分位支持,允许您不仅在应用代码中创建Timers,还在您的应用中包含的其他库中包含 Micrometer 仪表化。

向库代码添加计时器

如果您正在编写一个包含计时代码的库,请不要预先配置计时器,例如百分位数、直方图和 SLO 边界。即使性能成本很低,这些功能也会有一定的性能成本。允许您的库的消费者确定计时是否足够重要以便于这些统计信息的额外开销。特别是,最终用户将希望在打算将计时用作比较措施的情况下打开直方图,如在“自动金丝雀分析”中所示。在需要时,用户可以使用MeterFilter配置这些统计信息。

每当一个计时器指标具有多个标签的总唯一组合超过几个时,预先计算的百分位数就无法使用,因为它们无法进行组合或聚合。在两个实例的集群中,如果请求端点的第 90 百分位延迟在一个应用实例上是 100 毫秒,而在另一个实例上是 200 毫秒,我们无法简单地将这两个值平均,以得出集群范围的 90 百分位延迟为 150 毫秒。

表 2-11 解释了为什么要使用中位数(50th 百分位数)作为例子。由于参与计算此百分位数的单个样本已被丢弃,在监控系统中无法重建它们以推导出集群范围的百分位数。

表 2-11. 两个实例集群中的 P50(中位数)请求延迟

实例 单个延迟(ms) P50 延迟(ms)
1 [100,110,125] 110
2 [125,130,140] 130
整个集群 [100,110,125,125,130,140] 125

用预先计算的百分位数值,我们可以简单地绘制所有数值并查找异常值,如来自 Prometheus 查询requests_second{quantile=0.99}的图 2-12 所示。

srej 0212

图 2-12. 单个应用实例的第 99 百分位计时器

这个问题存在其局限性;例如,当实例数量增加(设想我们有一个包含 100 个实例的集群!)时,可视化很快就会变得拥挤。试图限制显示的行数以选择仅显示前 N 个最差的延迟可能导致情况,其中图例仍然充满了各个实例的 ID。这是因为,正如我们在图 2-13 中看到的,我们使用 Prometheus 查询topk(3, requests_second{quantile=0.99})选择了前三个最差的延迟实例,第三最差的实例几乎每个间隔都会变化。

srej 0213

图 2-13. 单个应用实例的前三个最差的第 99 分位计时器

由于预先计算分位数的限制,如果您使用支持直方图的监控系统,永远使用它们,如下一节所述。

直方图

指标总是以聚合形式呈现给监控系统。我们表示为代码块延迟的个体计时不会传送到监控系统。如果这样做的话,指标成本就不再固定,无论吞吐量如何。

我们可以发送一个近似于个体计时的直方图。在直方图中,可能计时范围被划分为一系列桶。对于每个桶(也称为间隔区间),直方图会记录有多少个体计时落入该桶中。这些桶是连续且不重叠的。它们通常不是等大小的,因为通常对于我们关心某些部分的粒度比其他部分更详细。例如,对于 API 端点延迟直方图,我们更关心 1、10 和 100 毫秒延迟之间的区别,而不太关心 40 秒和 41 秒的延迟。延迟桶会更加细分在期望值周围而不是远离期望值。

重要的是,通过将所有个体计时累积到桶中,并控制桶的数量,我们可以保留分布的形状同时保持固定成本。

监控系统的存储中,直方图被表示为一系列计数器。在 Prometheus 的情况下,正如表 2-12 中所示,这些计数器具有一个特殊的标记le,表示该指标是所有样本少于或等于该标记值(以秒计)的计数。

表 2-12. 直方图桶在时间序列数据库(Prometheus)中的存储方式

指标名称
http_server_requests_seconds_bucket [10,10,12,15]
http_server_requests_seconds_bucket [20,20,24,26]
http_server_requests_seconds_bucket [30,30,40,67]
http_server_requests_seconds_bucket [1,1,2,5]
http_server_requests_seconds_bucket [1,1,2,6]
http_server_requests_seconds_bucket [1,1,2,6]

根据监控系统的不同,直方图可能会显示为正常或累计直方图。这两种类型的直方图之间的视觉区别如图 2-14 所示。累计直方图桶表示所有小于或等于其边界的计时的计数。请注意,正常直方图中计时器的计数等于所有桶的总和。

srej 0214

图 2-14. 累计与正常直方图

与添加任何其他标记类似,向计时器添加直方图数据会将所需存储总量增加至分隔范围中桶数的倍数。在此示例中,由于有三个桶(0.1 秒、0.2 秒和 0.5 秒),将存储其他标记时间序列的排列组合的三倍。

此直方图每个间隔都会发布到监控系统。每个间隔的直方图可以组合成热图。图 2-15 展示了 API 端点延迟的热图。大多数请求在约 1 毫秒内完成,但每个间隔的延迟尾部延伸至超过 100 毫秒。

请求延迟的热图

图 2-15. 延迟热图

直方图数据还可用于执行百分位数的近似,并且直方图数据的这种常见用法反映在 Micrometer 的选项中,即启用直方图 publishPercentileHistogram。在执行应用程序性能的比较测量时(例如比较应用程序两个版本的性能在“自动金丝雀分析”中的相对性能),高百分位数特别有用。默认情况下不启用直方图,因为这会增加监控系统的额外存储成本和应用程序的堆使用量。Netflix 在实践中使用了一种分桶函数,以生成这些近似的合理低误差边界。

直方图发布可以通过几种方式启用计时器。

Timer 流畅构建器支持在构建 Timer 时直接添加直方图,如 示例 2-28 所示。

示例 2-28. 通过构建器向计时器添加直方图
Timer requestsTimer = Timer.builder("requests")
  .publishPercentileHistogram()
  .register(registry);

可以通过 MeterFilter 后期添加直方图支持,如 示例 2-29 所示。这种能力对于将应用程序层层堆叠以实现有效监控至关重要。除了特定的业务逻辑外,应用程序几乎总是包含丰富的二进制依赖层次结构。例如,像 HikariCP 连接池或 RabbitMQ Java 客户端这样的常见依赖的作者可能希望在其代码中包含涉及计时器的仪表化。但是,对于 RabbitMQ Java 客户端的作者来说,他们无法知道 RabbitMQ 交互在您的应用程序中是否足够重要,以至于要承担像百分位直方图这样的分布统计额外成本(无论其优化程度如何)。通过 MeterFilter 允许应用程序开发人员打开额外的分布统计功能,使 RabbitMQ Java 客户端作者可以在其代码中使用最小的计时器。

示例 2-29. 通过 MeterFilter 向计时器添加直方图
registry.config().meterFilter(new MeterFilter() {
  @Override
  public DistributionStatisticConfig configure(Meter.Id id,
      DistributionStatisticConfig config) {
    if (id.getName().equals("requests")) {
      DistributionStatisticConfig.builder()
        .publishPercentileHistogram()
        .build()
        .merge(config); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
    }
    return config;
  }
});

...

// The filter will apply to this timer as it is created Timer requestsTimer = registry.timer("requests");

1

将百分位直方图发布与默认情况下配置的任何其他分布统计数据(或其他MeterFilter配置)结合起来。

最后,像 Spring Boot 这样的框架提供了基于属性驱动的MeterFilter等效项,允许您以声明方式将直方图添加到任何以requests命名前缀的计时器中。在 Example 2-30 中显示的配置为任何以requests命名前缀的计时器添加直方图支持。

Example 2-30. 在 Spring Boot 中为以“requests”为前缀的指标添加百分位直方图
management.metrics.distribution.percentiles-histogram.requests=true

直方图仅被发送到支持基于直方图数据的百分位数近似的监控系统。

对于 Atlas,请使用:percentiles函数,例如 Example 2-31 中所示。

Example 2-31. Atlas 百分位数函数
name,http.server.requests,:eq,
(,99,99.9,),:percentiles

对于 Prometheus,请使用histogram_quantile函数,例如 Example 2-32 中所示。请从“百分位数/分位数”中回忆起,百分位数只是分位数的一种特殊类型。Prometheus 直方图包含一个称为Inf的特殊存储桶,用于捕获超过您(或 Micrometer)定义的最大存储桶的所有样本。请注意,计时器的计数等于Inf存储桶中的计数。

Example 2-32. Prometheus 直方图分位数函数
histogram_quantile(
  0.99,
  rate(http_server_requests_seconds_bucket[2m])
)

服务水平目标边界

与百分位数和直方图类似,服务水平目标(SLO)边界可以通过Timer流畅构建器或MeterFilter添加。您可能会注意到我说的是“边界”而不是您可能期望的“边界”。在许多情况下,将您的目标层叠是合理的。Gil Tene 在他 2013 年关于监控延迟的演讲中谈到了建立 SLO 需求的内容,我在这里进行了 paraphrase,因为这是解释层叠 SLOs 需求的一个非常有用的框架。SLO 需求访谈内容记录在 Example 2-33 中。

Example 2-33. SLO 需求访谈
Q: What are your service level objectives?
A: We need an average response of 10 milliseconds ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
Q: What is the worst-case requirement?
A: We don't have one.
Q: So it’s OK for some things to take more than 5 hours?
A: No!
Q: So we think that the worst-case requirement is 5 hours.
A: No! Let's make it 100 milliseconds.
Q: Are you sure? Even if the worst case only happens two times a day?
A: OK, make it 2 seconds.
Q: How often is it OK to have a 1-second response?
A: (Annoyed) I thought you said only a couple of times a day!
Q: That was for the worst case. If half the results are better than
   10 milliseconds, is it OK for every other request other than max
   to be just shy of 2 seconds?
A: (More specific requirements...)

1

哦不,您说了“平均”这个词……我们将假装我们没有听到这个。

此访谈最终产生了一组 SLOs。

  • 90%优于 20 毫秒

  • 99.99%优于 100 毫秒

  • 100%优于 2 秒

我们将配置 Micrometer 以发布 20 毫秒、100 毫秒和 2 秒的 SLO 计数。例如,我们可以简单地比较小于 20 毫秒的请求占总请求数的比率;如果这个比率小于 90%,则发出警报。

Micrometer 将为每个边界发布一个计数,指示未超过该边界的请求数量。一组 SLO 边界共同形成一个粗略直方图,如图 2-16 所示,其中延迟域被划分为从零到最低 SLO 的各个桶,依此类推。

srej 0216

图 2-16. SLO 边界直方图

发布 SLO 边界确实会影响监控系统中的总存储和应用程序的内存消耗。然而,因为通常只发布一小组边界,与百分位直方图相比,成本相对较低。

百分位直方图和 SLO 可以共同使用。添加 SLO 边界只是比仅使用百分位直方图增加更多的桶。当 SLO 边界除了百分位直方图外也被发布时,所发布的直方图包含 Micrometer 认为为了得到合理的百分位近似所必需的桶,加上任何 SLO 边界,如图 2-17 所示(part0007_split_008.html#mixed_percentiles_slos)。

srej 0217

图 2-17. 服务水平目标边界和百分位直方图桶边界的混合直方图

发布 SLO 边界是测试第N个百分位是否超过特定值的一种更便宜(和准确)的方式。例如,如果确定 SLO 是 99%的请求低于 100 毫秒,则发布 100 毫秒的 SLO 边界。要设置对 SLO 边界违规的警报,只需确定低于边界的请求与总请求的比率是否低于 99%。

当监控系统期望普通的直方图(如 Atlas)时,这有点不方便,因为你必须选择并求和所有小于 SLO 边界的桶。在示例 2-34 中,我们想测试是否有 99%的请求少于 100 毫秒(0.1 秒);但由于无法将标签值视为数值,我们无法使用像:le这样的操作符选择所有小于 0.1 秒的时间序列。因此,我们必须使用像:re这样的正则表达式运算符执行数值比较。

示例 2-34. 使用 SLO 边界的 Atlas 警报条件
name,http.server.requests,:eq,
:dup,
slo,0.(0\d+|1),:re,
:div,
0.99,:lt,
uri,_API_ENDPOINT,:eq,:cq

在这里,Prometheus 具有优势,因为其直方图是累积表达的。也就是说,所有低于 100 毫秒的样本都累积到所有低于 100 毫秒的边界,包括这个边界。

警报条件显示在示例 2-35 中。

示例 2-35. 使用 SLO 边界的 Prometheus 警报条件
http_server_requests_seconds_bucket{le="0.1", uri="/API_ENDPOINT"}
/ ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
http_server_requests_seconds_count{uri="/API_ENDPOINT"} < 0.99

1

除法符号

这两个查询的视觉效果可以在图 2-18 中看到。

srej 0218

图 2-18. SLO 边界警报查询:Atlas vs Prometheus

可以通过几种方式为计时器启用 SLO 发布。

Timer 流畅构建器支持在创建 Timer 时直接添加 SLO,如 示例 2-36 所示。

示例 2-36. 通过构建器向计时器添加 SLO 边界
Timer requestsTimer = Timer.builder("requests")
  .slo(Duration.ofMillis(100), Duration.ofSeconds(1))
  .register(registry);

示例 2-37 展示了如何通过 MeterFilter 添加 SLO 边界。

示例 2-37. 通过 MeterFilter 向计时器添加 SLO 边界
registry.config().meterFilter(new MeterFilter() {
  @Override
  public DistributionStatisticConfig configure(Meter.Id id,
      DistributionStatisticConfig config) {
    if (id.getName().equals("requests")) {
      DistributionStatisticConfig.builder()
        .slo(Duration.ofMillis(100), Duration.ofSeconds(1))
        .build()
        .merge(config);
    }
    return config;
  }
});

...

Timer requestsTimer = registry.timer("requests"); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)

1

创建时此计时器将应用过滤器。

下一个计量器类型与计时器非常相似。

分布摘要

示例 2-38 展示了分布摘要的使用来追踪事件的分布。在结构上类似于计时器,但记录的值并不代表时间单位。例如,可以使用分布摘要来测量命中服务器的请求的有效载荷大小。

示例 2-38. 创建分布摘要
DistributionSummary summary = registry.summary("response.size");

Micrometer 还提供了一个流畅构建器,如 示例 2-39 所示,用于分布摘要。为了最大可移植性,添加基本单位,因为它们是某些监控系统命名约定的一部分。可选地,您可以提供一个倍增因子,每个记录的样本将在记录时乘以该因子。

示例 2-39. 分布摘要流畅构建器
DistributionSummary summary = DistributionSummary
    .builder("response.size")
    .description("a description of what this summary does")
    .baseUnit("bytes")
    .tags("region", "test")
    .scale(100)
    .register(registry);

分布摘要具有与计时器相同的百分位数、直方图和 SLO 选项。计时器只是专用于测量时间的特殊分布摘要。SLO 是定义为固定值而不是持续时间(例如,1000 而不是 Duration.ofMillis(1000),其中 1000 根据为分布摘要分配的基本单位而有所不同),并为代码块计时提供方便的方法。这是可用选项的唯一差异。

一个常见的分布摘要示例是以字节为单位测量的有效载荷大小。

与计时器类似,在许多实际情况下,分布很少是正态的。曾经我测量过字节的有效载荷大小,这在很大程度上是正态的,但左侧分布有明显的降低,因为包括请求头在内的请求有效载荷大小。因此,在具有一定请求头集合的情况下,请求有效载荷小于某个大小的情况是零。

因为分布摘要可以追踪任何计量单位,并且测量值的分布通常不像计时器那样普遍为所知,因此警报分布摘要的最佳方式具有一些微妙之处:

  • 当分布是多模式时,就像计时器一样,最好设置在最大值上的警报,以便可以跟踪“最坏情况”所在。

  • 在大多数情况下,使用高百分位数如第 99 百分位数进行比较分析仍然是有意义的(参见“自动金丝雀分析”)。

长任务计时器

长任务计时器是一种特殊类型的计时器,允许您在事件正在运行时测量时间。计时器直到任务完成后才记录持续时间。长任务计时器提供了几个统计信息:

活动中

当前正在进行的执行次数。

总持续时间

正在测量的代码块的所有正在进行的执行时间总和。

最大

最长的进行中计时。最大值表示最老的仍在运行中的执行时间的总计执行时间。

直方图

用于正在进行的任务的一组离散化桶。

百分位数

预计算的正在进行的执行时间的百分位数。

图 2-19 展示了长任务计时器与普通计时器的根本区别。一旦操作完成,它就不再对总持续时间做出贡献。相比之下,使用计时器进行操作直到完成时才会报告。请注意,在时间 t=3 时,总持续时间增加了 2 而不仅仅是 1。这是因为我们有两个任务在 t=2 开始执行,所以它们在继续运行时每个时间间隔都贡献了 1。在 t=4 时,两个任务都停止了,因此总持续时间降至零,同时活动计数也降至零。

长任务计时器的平均值与计时器的平均值有不同的含义。它是截至目前为止正在运行的活动操作的平均时间。类似地,最大值表示到目前为止运行时间最长的任务,以类似计时器最大值的方式衰减。

srej 0219

图 2-19. 两个任务的长任务计时器活动和总持续时间

MeterRegistry接口包含用于创建长任务计时器的便捷方法,如示例 2-40 所示。

示例 2-40. 创建长任务计时器
// No tags
LongTaskTimer  timer = registry.more()
  .longTaskTimer("execution.time");

// Adding tags in key-value pairs with varargs
LongTaskTimer timer = registry.more()
  .longTaskTimer("execution.time", "region", "us-east-1");

// Explicit tag list creation
LongTaskTimer timer = registry.more()
  .longTaskTimer("execution.time", Tags.of("region", "us-east-1"));

LongTaskTimer流畅构建器包含更多选项,如示例 2-41 所示。

示例 2-41. 长任务计时器的流畅构建器
LongTaskTimer timer = LongTaskTimer
    .builder("execution.time")
    .description("a description of what this timer does") // Optional
    .tags("region", "us-east-1") // Optional
    .register(registry);

长任务计时器具有一个记录方法,返回一个Sample,稍后可以停止,并提供了方便的方法来记录用 Lambda 包装的代码块,如示例 2-42 所示。

示例 2-42. 使用长任务计时器记录执行
longTaskTimer.record(() -> dontCareAboutReturnValue());
longTaskTimer.recordCallable(() -> returnValue());

LongTaskTimer.Sample sample = longTaskTimer.start();
// Do something...
sample.stop(longTaskTimer);

一个好的长任务计时器示例在Edda中,它缓存 AWS 资源,如实例、卷和自动缩放组。通常,所有数据可以在几分钟内刷新。如果 AWS 服务的执行速度比平时慢,可能需要更长时间。可以使用长任务计时器来跟踪刷新元数据的总时间。

在应用程序代码中,通常会使用类似 Spring Boot 的@Scheduled来实现此类长时间运行的进程,如示例 2-43 所示。

示例 2-43. 明确记录的长任务定时器用于计划操作
@Scheduled(fixedDelay = 360000)
void scrapeResources() {
  LongTaskTimer.builder("aws.scrape")
    .description("Time it takes to find instances, volumes, etc.")
    .register(registry)
    .record(() => {
      // Find instances, volumes, autoscaling groups, etc...
    });
}

一些框架如 Spring Boot 还会响应@Timed注解,当设置longTask属性为true时创建长任务计时器,如示例 2-44 所示。

示例 2-44. 基于注解的长任务定时器用于计划操作
@Timed(name = "aws.scrape", longTask = true)
@Scheduled(fixedDelay = 360000)
void scrapeResources() {
  // Find instances, volumes, autoscaling groups, etc...
}

如果我们想在此过程超过阈值时发出警报,使用长任务计时器,我们将在超过阈值后的第一个报告间隔收到警报。使用常规计时器,我们要等到进程完成后的第一个报告间隔,这可能需要一个小时后!

选择正确的计量器类型

在分层指标系统的仪表化库中(参见“分层指标”),通常仅支持量规和计数器。这导致预先计算统计数据(最常见的是请求吞吐量等比率)并将其呈现给监控系统作为可以上下波动的量规的习惯。由于 Micrometer 始终以一种让您可以在查询时推导出速率的方式公开计数器,因此在应用程序代码中没有必要手动执行此计算。

TimerDistributionSummary始终发布事件计数,除其他测量外。永远不应该统计代码块的执行次数,而应该计时。

选择哪种计量器类型?

永远不要度量您可以计数的东西,也不要计数您可以计时的东西。

一般来说,当计时超过两分钟、需要监视飞行请求时,尤其是当操作的失败可能使预期时间从几分钟增加到几分钟或几小时时,应选择LongTaskTimer

控制成本

随着您为应用程序的更多部分进行仪表化,指标的成本会增加。也许最初您只是开始传输基本的统计数据,比如内存和处理器利用率,并逐渐扩展到额外的领域,如 HTTP 请求监控、缓存性能、数据库交互和连接池饱和度。实际上,许多这些基本用例将会越来越多地由 Spring Boot 等框架自动进行仪表化。

扩展仪表化到额外的组件并非遥测成本高昂的主要来源。要检查单个指标的成本,请考虑指标名称及其所有键-值标签组合的唯一排列。这些形成后端的时间序列集合,我们将其称为指标的基数

通过考虑可能导致大量唯一值的所有可能的键-值标签组合,仔细地界定度量的基数是至关重要的。度量的基数是每个标签键的唯一标签值的乘积。你在标签基数上设置的合理限制因监控系统而异,但通常保持在数千个是一个合理的上限。在 Netflix,一般建议将度量的基数保持在一百万时间序列以下。这可能是大多数组织认为负责任的极限。

记住,度量旨在呈现指标的汇总视图,不应用于尝试检查事件级或请求级性能。其他形式的遥测,如分布式跟踪或日志记录,更适合个体事件级的遥测。

框架提供的遥测通常会精心设计以限制标签基数。例如,Spring Boot 对 Spring WebMVC 和 WebFlux 的时间测量会添加少量标签,但会精确地限制每个标签如何对度量的基数做出贡献。下面的列表解释了每个标签键的含义:

方法

基于 HTTP 规范,HTTP 方法有少量可能的取值,例如,GETPOST

状态

HTTP 状态码来自 HTTP 规范,因此在可能的取值方面自然是有限的。此外,大多数 API 端点实际上只会返回少数几个值:200–202 成功/创建/已接受,304 未修改,400 错误请求,500 服务器内部错误,也许还有 403 禁止。大多数情况下,不会返回这么多的变化。

结果

状态码的总结,例如,SUCCESSCLIENT_ERRORSERVER_ERROR

统一资源标识符(URI)

此标签很好地演示了标签基数可能迅速失控的情况。对于状态码为 200 至 400 的情况,URI 将是请求映射到的路径。但是当端点包含路径变量或请求参数时,框架会小心地在此处使用未替换路径,而不是请求的原始路径,例如,/api/customer/{id} 而不是 /api/customer/123/api/customer/456。这不仅有助于限制指标的基数,而且对于总体上关于/api/customer/{id}所有请求的性能进行推理也更为有用,而不是针对检索特定客户 ID 123456 的请求组。除了路径替换之外,在服务器最终将返回 404 的情况下,还应进一步约束 URI。否则,每个打错的 URI /api/doesntexist/1/api/doesntexistagain 等都会导致一个新的标签。因此,Spring Boot 在状态码为 404 时使用了 URI 标签值NOT_FOUND。类似地,当状态码为 403 时,它使用REDIRECT值,因为服务器可能总是将未经身份验证的请求重定向到身份验证机制,即使请求的路径不存在。

异常

当请求导致 500 内部服务器错误时,此标签包含异常类名,这只是一种简单的方法来对失败的一般类进行分组,例如,空指针异常与下游服务请求的连接超时。同样,基于端点实现,通常只有几种可能的失败类别,因此这是自然而然地受到限制的。

对于 HTTP 服务器请求指标,总基数可能类似于 Equation 2-7。

方程式 2-7. 单个端点的 HTTP 服务器请求指标的基数

2 m e t h o d s × 4 s t a t u s e s × 2 o u t c o m e s × 1 U R I × 3 e x c e p t i o n s = 48 t a g v a l u e s

不要为了成本而过度优化。特别是,没有必要试图限制零值的发布,因为在理论上的某些应用状态下,特定发布间隔内的所有指标都可能是非零的。正是在这种饱和度最高的时刻,您将发布最多的非零指标,这些指标将主导您的成本,而不是在低点,您可以通过排除零值来发布一些较少的指标。此外,报告零值是一个有用的信号,表明某些事情并未发生,而不是服务根本未报告。

在某些情况下,如果标签的基数不能轻松地通过开箱即用的仪器来限制,你会发现一组指标的配置会让你负责定义如何负责任地限制标签。一个很好的例子是 Micrometer 为 Jetty HttpClient 进行仪器化。使用 Jetty HttpClient 进行典型请求看起来像 例子 2-45。HttpClient API 不提供向 POST 调用提供路径变量和稍后替换的变量值的机制,因此当 Micrometer 的 Jetty Request.Listener 拦截请求时,路径变量已经被不可逆转地替换了。

例子 2-45。Jersey HTTP 客户端调用带有路径变量字符串连接
Request post = httpClient.POST("https://customerservice/api/customer/" + customerId);
post.content(new StringContentProvider("{\\"detail\\": \\"all\\"}"));
ContentResponse response = post.send();

Jetty 客户端指标应该使用未替换的路径来标记 uri 标签,原因与 Spring Boot 对 WebMVC 和 WebFlux 进行仪表化时 URI 标签基于未替换的值相同。将如何为 Jetty HttpClient 标记变量路径的责任交给了使用 Jetty HttpClient 的工程师,如 例子 2-46 所示。

例子 2-46。Jersey HTTP 客户端指标配置
HttpClient httpClient = new HttpClient();
httpClient.getRequestListeners().add(
  JettyClientMetrics
    .builder(
      registry,
      result -> {
        String path = result.getRequest().getURI().getPath();
        if(path.startsWith("/api/customer/")) {
          return "/api/customer/{id}";
        }
        ...
      }
    )
    .build()
);

协调遗漏

我高中的第一份工作是做快餐店的快餐工人。驶入式快餐窗口为我提供了一些早期的第一手经验,了解如何用统计数据撒谎。

我们作为一家商店的表现是根据客户在菜单上下订单到离开店铺的平均持续时间来评估的。我们店通常人手不足,所以有时平均时间会超过我们的目标。我们只需等到活动停顿,然后用我们的一辆车在建筑周围绕圈。几十个三到四秒的服务时间将迅速降低平均值(再次强调,平均值往往是一个有问题的统计指标)!我们可以让我们的服务时间看起来任意好。在某个时候,总部将最短服务时间添加到他们评估的平均统计数据中,我们的作弊就结束了。

在一个奇怪的案例中,一辆公共汽车开进了驶入式快餐窗口,车上每个窗口都点了餐。我们的服务时间仅由菜单附近的压力板控制,服务窗口后,我们无法感知每个订单的服务时间,只能感知每辆车的服务时间。显然,我们没有像对待一辆普通轿车那样快速为公共汽车提供订单,公共汽车对其他服务时间的连锁反应说明了一个称为协调遗漏的概念,即如果我们不小心,我们会监视某种定义的服务时间,但排除等待时间。在这个公交事件中,有两个协调遗漏的例子,如 图 2-20 所示。

srej 0220

图 2-20. 在驶入式快餐窗口中由一辆公共汽车引起的协调遗漏
  • 在公交车在窗口接收订单时,只有其他三辆车的服务时间正在记录(已激活菜单上的压力板的三辆车)。菜单后面的两辆车并未被系统观察到。公交车阻塞的实际影响是对其他五名顾客,但我们只会看到三个顾客的服务时间影响。

  • 假设服务时间不是通过菜单到离开订单窗口来决定,而是通过仅监控在订单窗口处所用的时间作为服务时间。那么,平均服务时间只受仅仅为公交车服务所需的时间影响,而不受其对后面车辆的复合效应影响。

这些效果类似于线程池对请求时间的影响。如果响应时间是基于请求处理程序开始处理请求时计算的,并在响应提交时结束,那么没有计算请求在队列中等待可用请求处理程序线程开始处理它的时间。

此示例所示的协调省略的另一个后果是,阻塞驶入通道会阻止餐厅被稳定的顾客订单完全压倒。事实上,驶入通道处长队可能已经阻止潜在顾客尝试下单。线程池也可能产生这种效果。协调省略源于几个方面:

无服务器函数

从服务器无服务器函数的执行角度来衡量其执行时间,当然不会记录启动函数所需的时间。

暂停

暂停有很多形式,例如,由于垃圾收集而导致的 JVM 暂停,重新索引的数据库短暂地失去响应性,以及缓存缓冲区刷新到磁盘。执行暂停被报告为飞行时间的较高延迟,但尚未开始计时的操作将报告不现实的低延迟。

负载测试人员

传统的负载测试工具在真正使服务饱和之前会在它们自己的线程池中备份。

对准确的负载测试的需求是如此普遍,以至于我们将会稍微详细地讨论它们。

负载测试

一些传统的负载测试工具如 Apache Bench 以特定速率生成请求。从所有响应时间集合生成聚合统计信息。当响应不符合收集桶间隔时,下一个请求将延迟。

这种方式,一个变得过度饱和的服务不会因为无意间协调长时间响应而被推到边缘,导致负载测试退却。这种协调来自于这些类型的测试使用的阻塞请求模型,有效地限制并发量小于或等于运行测试的机器上的核心数。

真正的用户没有这种协调能力。 他们独立于彼此与您的服务进行交互,因此可以更大程度地使服务饱和。 模拟用户行为的一种有效方法是使用非阻塞负载测试使您的服务饱和。 Gatling 和 JMeter 都是这样操作的(但 Apache Bench 不是)。 但是,为了说明有效的负载测试应该如何工作,我们可以使用 Spring 的非阻塞WebClient和 Project Reactor 创建一个简单的非阻塞负载测试。 结果如 示例 2-47 所示。 实际上,现在构建这些非阻塞负载测试是如此容易,以至于也许不值得使用专用工具如 JMeter 和 Gatling 的额外认知负担。 您将不得不为自己和您的团队决定这一点。

示例 2-47. 使用 WebClient 和 Project Reactor 的非阻塞负载测试
public class LoadTest {
  public static void main(String[] args) {
    MeterRegistry meterRegistry = ...; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
    Counter counter = meterRegistry.counter("load.test.requests");

    WebClient client = WebClient.builder()
      .baseUrl("http://" + args[0])
      .build();

    Flux
      .generate(AtomicLong::new, (state, sink) -> { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
        long i = state.getAndIncrement();
        sink.next(i);
        return state;
      })
      .limitRate(1) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
      .flatMap(n -> client.get().uri("/api/endpoint").exchange())
      .doOnNext(resp -> {
        if (resp.statusCode().is2xxSuccessful())
          counter.increment();
      })
      .blockLast();
  }
}

1

配置一个注册表,将负载测试的视角的度量发送到所选择的监控系统。

2

生成一个无限流,或者如果您想要为特定数量的请求运行此操作,可以使用 Flux.range(0, MAX_REQUESTS)

3

限制您希望测试发送请求到服务的速率。

这样的反应式负载测试与传统负载测试之间的差异是显著的,如 图 2-21 所示。 传统负载测试(因为它们是阻塞的,因此并发级别只能等于或小于运行测试的机器上的核心数)显示的最大延迟小于 10 毫秒。 非阻塞的反应式负载测试显示了一堆延迟,直到大于 200 毫秒,以及一个强大的大于 200 毫秒的频带,当应用程序变得饱和时。

srej 0221

图 2-21. 阻塞负载测试与非阻塞(反应式)负载测试之间的效果

如预期的那样,对“延迟”建议的警报条件的影响(如 图 2-22 所示)是明显的。

srej 0222

图 2-22. 最大延迟的差异

这种效果也会在 图 2-23 中显示出 99 百分位延迟指标上,因此它将出现在我们用于比较同一微服务两个版本的响应时间性能的关键指标上(参见“自动金丝雀分析”)。

srej 0223

图 2-23. 99 百分位延迟的差异

该示例还说明了为什么服务器对自身吞吐量的视图可能具有误导性。吞吐量显示在图 2-24 中,仅在两个测试之间从大约每秒 1 次操作增加到每秒 3 次操作,但这代表有多少请求被完成。事实上,在反应式负载测试期间,服务超负荷的情况下,更多的请求被排队在服务的 Tomcat 线程池上,并没有及时处理。

如果出于生产警报标准而监控吞吐量(和延迟),最好在可能的情况下从客户端的角度监控,正如“延迟”中讨论的那样。

srej 0224

图 2-24. 吞吐量差异

最后,平均延迟显示在图 2-25 中。尽管两个测试之间的平均值提高了一个数量级,但平均值仍然在约 60 毫秒左右,这可能看起来并不那么糟糕。平均值隐藏了很多实际发生的情况,因此并不是一个有用的度量标准。

srej 0225

图 2-25. 平均延迟差异

现在我们已经看到了许多计量器构建模块以及它们的一些使用方式,让我们把注意力转向应用程序范围内的度量标准如何进行自定义。

Meter Filters

随着在 Java 堆栈的各个部分添加更多的仪表,有必要在某种程度上控制发布哪些指标以及以何种精度。我首次意识到这一需求是在与 Netflix 评分团队中的一名工程师聊天时(这些工程师控制 Netflix UI 上的点赞/星级评分系统)。他向我展示了一个特定指标的情况,该指标被核心平台库中的大多数用户面向微服务所记录,每个 API 端点大约产生了大约 70,000 个时间序列!这是非常浪费的,因为这些时间序列中绝大多数对于许多产品团队在仪表板和警报中并不实用。仪表化已经为最高可能的精度用例开发。这突显了核心库生产者实际上有多么少的选择权。他们需要为高精度案例进行仪表化,并依赖于他们之后的某些人来调整这种精度,使其对他们有用。正是基于这种经验,Micrometer 计量器过滤器诞生了。

每个注册表都可以配置计量器过滤器,这些过滤器允许对注册和统计信息的时间和方式进行高度控制。计量器过滤器具有三个基本功能:

  1. Deny(或接受)注册的计量器。

  2. Transform 计量器 ID(例如,更改名称、添加或删除标签、更改描述或基本单位)。

  3. Configure 为某些计量器类型配置分布统计信息。

MeterFilter的实现以编程方式添加到注册表中,例如在示例 2-48 中。

示例 2-48. 应用计量器过滤器
registry.config()
    .meterFilter(MeterFilter.ignoreTags("too.much.information"))
    .meterFilter(MeterFilter.denyNameStartsWith("jvm"));

计量器过滤器按顺序应用,并且变换或配置计量器的结果会被链接。

在应用程序生命周期的早期应用计量器过滤器

出于性能原因,计量器过滤器仅影响在过滤器之后注册的计量器。

拒绝/接受计量器

接受/拒绝过滤器的冗长形式显示在示例 2-49 中。

示例 2-49. 接受/拒绝过滤器的最冗长形式
MeterFilter filter = new MeterFilter() {
  @Override
  public MeterFilterReply accept(Meter.Id id) {
    if(id.getName().contains("test")) {
       return MeterFilterReply.DENY;
    }
    return MeterFilterReply.NEUTRAL;
  }
}

MeterFilterReply有三种可能的状态:

DENY

不允许注册此计量器。当您尝试将计量器注册到注册表并且过滤器返回DENY时,注册表将返回该计量器的 NOOP 版本(例如NoopCounterNoopTimer)。您的代码可以继续与 NOOP 计量器交互,但其中记录的任何内容都将立即丢弃,且开销最小。

NEUTRAL

如果没有其他计量器过滤器返回DENY,则计量器的注册将正常进行。

ACCEPT

如果过滤器返回ACCEPT,则计量器将立即注册,而不会进一步查询任何其他过滤器的接受方法。

MeterFilter提供了几个方便的静态构建器用于拒绝/接受类型的过滤器:

accept()

接受每个计量器,覆盖随后任何过滤器的决策。

accept(Predicate<Meter.Id>)

接受与谓词匹配的任何计量器。

acceptNameStartsWith(String)

接受与特定前缀匹配的每个计量器。

deny()

拒绝每个计量器,覆盖随后任何过滤器的决策。

denyNameStartsWith(String)

拒绝所有名称以匹配前缀开头的计量器。Micrometer 提供的所有开箱即用的MeterBinder实现都具有通用前缀的名称,以便在用户界面中轻松进行分组可视化,同时也可以通过前缀轻松禁用/启用它们。例如,您可以使用MeterFilter.denyNameStartsWith("jvm")来拒绝所有 JVM 指标。

deny(Predicate<Meter.Id>)

拒绝任何与谓词匹配的计量器。

maximumAllowableMetrics(int)

在注册表达到一定数量的计量器后拒绝任何计量器。

maximumAllowableTags(String meterNamePrefix, String tagKey, int maximumTagValues, MeterFilter onMaxReached)

对匹配系列产生的标签数量设置上限。

Allowlisting只有一组特定的指标是监控系统中的一种常见情况,这些系统可能会很昂贵。可以通过静态方法实现这一点:

denyUnless(Predicate<Meter.Id>)

拒绝所有不符合谓词的计量器。

米特过滤器按照它们在注册表上配置的顺序应用,因此可以堆叠拒绝/接受过滤器以实现更复杂的规则。在示例 2-50 中,我们明确地接受任何以 http 开头的度量标准,并拒绝一切其他情况。因为第一个过滤器对于像 http.server.requests 这样的米特给出了接受决策,通用的拒绝过滤器就不再需要提供意见。

示例 2-50. 仅接受 HTTP 指标,拒绝一切其他情况
registry.config()
    .meterFilter(MeterFilter.acceptNameStartsWith("http"))
    .meterFilter(MeterFilter.deny());

转换度量标准

米特过滤器还可以转换米特的名称、标签、描述和基本单位。转换度量标准最常见的应用之一是添加通用标签。像 RabbitMQ Java 客户端这样的常见 Java 库的作者不可能猜测到您希望通过应用程序、部署环境、代码运行的实例、应用程序版本等标识 RabbitMQ 指标到达监控系统。将通用标签应用于从应用程序流出的所有度量意味着低级库作者可以保持他们的仪器简单,仅添加与他们正在仪器化的部分相关的标签,例如 RabbitMQ 指标的队列名称。然后,应用程序开发人员可以使用其他识别信息丰富此仪器化。

在示例 2-51 中显示了一个转换过滤器。此过滤器为以名称“test”开头的米特添加名称前缀和额外标签。

示例 2-51. 转换米特过滤器
MeterFilter filter = new MeterFilter() {
    @Override
    public Meter.Id map(Meter.Id id) {
       if(id.getName().startsWith("test")) {
          return id.withName("extra." + id.getName()).withTag("extra.tag", "value");
       }
       return id;
    }
}

MeterFilter 为许多常见转换情况提供了方便的构建器:

commonTags(Iterable<Tag>)

为所有米特添加一组标签。添加应用名称、主机、区域等常见标签是一种强烈推荐的做法。

ignoreTags(String...)

从每个米特中删除匹配的标签键。当标签的基数可能变得过高并开始对监控系统造成压力或成本过高时,这是特别有用的。但您无法快速更改所有仪器点时,可以使用此方法。

replaceTagValues(String tagKey, Function<String, String> replacement, String... exceptions)

根据提供的映射替换所有匹配标签键的标签值。这可用于通过将某些标签值映射到其他值来减少标签的总基数。

renameTag(String meterNamePrefix, String fromTagKey, String toTagKey)

为以给定前缀开头的每个米特重命名标签键。

在 Netflix 核心平台仪器化的开始部分提到忽略这些标签中的一个或多个,这导致每个 API 端点生成数万个标签,可以显著降低成本。

配置分布统计

TimerLongTaskTimerDistributionSummary除了基本的计数、总数和最大值外,还包含一组可选的分布统计,可以通过过滤器进行配置。这些分布统计包括预先计算的“百分位数/分位数”、“服务水平目标边界”和“直方图”。可以通过MeterFilter进行分布统计的配置,如示例 2-52 所示。

示例 2-52. 配置分布统计
new MeterFilter() {
    @Override
    public DistributionStatisticConfig configure(Meter.Id id,
          DistributionStatisticConfig config) {
        if (id.getName().startsWith(prefix)) {
            return DistributionStatisticConfig.builder()
                    .publishPercentiles(0.9, 0.95)
                    .build()
                    .merge(config);
        }
        return config;
    }
};

通常,您应创建一个仅包含您希望配置的部分的新DistributionStatisticConfig,然后与输入配置进行merge。这允许您降低注册表提供的分布统计的默认值,并将多个过滤器链在一起,每个过滤器配置分布统计的一部分(例如,您可能希望为所有 HTTP 请求设置 100 毫秒的 SLO,但仅在几个关键端点上设置百分位直方图)。

MeterFilter为以下提供了方便的构建器:

maxExpected(Duration/long)

管理从计时器或摘要发送的百分位直方图桶的上限。

minExpected(Duration/long)

管理从计时器或摘要发送的百分位直方图桶的下限。

Spring Boot 提供了基于属性的过滤器,用于按名称前缀配置 SLO、百分位数和百分位直方图,如下列表所示:

management.metrics.distribution.percentiles-histogram

是否发布适合计算可聚合(跨维度)百分位数近似的直方图。

management.metrics.distribution.minimum-expected-value

通过夹紧期望值的范围来发布较少的直方图桶。

management.metrics.distribution.maximum-expected-value

通过夹紧期望值的范围来发布较少的直方图桶。

management.metrics.distribution.percentiles

发布在您的应用程序中计算的百分位值。

management.metrics.distribution.sla

发布一个累积直方图,其桶由您的 SLA 定义。

计量器过滤器显示了组织文化如何推动甚至是软件配置的最低层次。例如,几个组织使用它们来分离平台和应用程序指标。

分离平台和应用指标

康威定律表明“您发布您的组织结构”。这大致意味着您的系统编写、部署和运行的方式与您的组织有些相似。

我发现了一个常见的模式,我认为这是对这一原则的一个良好的积极说明。在 Netflix,运维工程组织(本书中我们称之为平台工程)构建了解决个体微服务中否则不同的问题的工具。这个组织非常关注客户工程,但它对个体团队的做法没有任何监督或控制,这是因为全面的“自由和责任”文化。但在许多组织中(我不认为这种情况是更好或更差的),平台团队确实代表产品团队集中行事。因此,在 Netflix,单个微服务的监控完全是产品团队的责任(中央团队提供根据需要的建议和协助),而在许多组织中,监控应用程序的责任可能完全由中央平台团队承担。

在其他情况下,责任被分割开来,中央平台团队负责监控某些类型的信号(通常是资源指标,如处理器、内存,以及可能更接近业务的指标,如 API 错误比率),而应用程序团队则负责任何自定义指标。您可能会注意到,在这种情况下,责任基本上按照黑盒和白盒仪器的优点分配(平台团队监控黑盒信号,产品团队监控白盒信号)。这很好地说明了基于代理的仪器装置如何为特定的 SaaS 产品服务平台团队的需求,而产品团队则使用完全不同的监控系统来处理其他信号——即这些类型的仪器装置如何是互补而不是竞争关系的好例子。

让我们考虑这样一个组织,并看看这对应用程序中的度量遥测配置产生的影响。为了使这个概念具体化,假设这个组织正在使用 Prometheus 作为其监控系统。在这种情况下,平台工程师和产品工程师有不同的目标:

平台工程师

平台工程师希望以同样的方式监控组织中的所有微服务。这意味着每个微服务都应该发布一组度量标准,以供平台团队监控。这些度量标准应该有一种一致的方式来确定公共标签(例如,用于堆栈的测试/dev/prod、区域、集群、服务器组名称、应用程序版本和实例标识符)。对于平台团队来说,超出他们打算监控的度量标准集的任何内容都没有价值。这组度量标准随着时间的推移不太可能发生显著变化,因为按照平台团队的责任性质,它们代表了对所有应用程序有用而不与特定业务功能或特性相关联的一般指标。

产品工程师

产品工程师希望仅监视他们自己的微服务。适用于平台工程师的相同一组常用标签对产品工程师也可能有利,但可能有额外的常用标签,进一步区分个体实例的层次,这对平台团队并不重要。例如,应用团队可能部署其微服务的几个集群,这些集群可能包含相同的应用代码,但为其终端用户服务的段不同(例如,内部用户与外部用户)。他们可能也希望将用于区分这种差异的常用标签添加到他们的度量中,因为不同的终端用户群体可能有不同的 SLO。产品工程师应该关注的度量应该更具体于用户体验。它们也可能是以功能为重点的。随着新功能的变化,度量集合也会发生变化。

如果微服务的度量通过单个 MeterRegistry 发布,产品工程师对注册表的自定义可能会影响平台团队的微服务可观察性。根据产品团队如何聚合度量标签以供平台团队显示,平台工程师添加额外的常用标签可能会影响产品工程师的警报和仪表板。

因为工程组织是按这种方式结构化的,跨团队边界分工,所以应该想出一种方法,将度量的发布分成不同的仪表注册表,以最好地服务于平台和产品团队的个别职责。“你出货你的组织图。”

由于这种责任划分相当普遍,让我们考虑如何实现这一点。首先,平台团队负责发布一个通用库,一个 JAR 二进制依赖项,每个微服务都可以包含,其中包含这个通用配置。因为微服务团队可能处于不同的发布周期,相对于任何单个微服务的变化速度,平台团队自然需要慢慢发展这个通用配置。在这个通用平台 JAR 中,我们期望看到像在 示例 2-53 中的自动配置。

示例 2-53. 平台团队自动配置与产品团队共享的度量
@Configuration
public class PlatformMetricsAutoConfiguration {
  private final Logger logger = LoggerFactory.getLogger(
    PlatformMetricsAutoConfiguration.class);

  private final PrometheusMeterRegistry prometheusMeterRegistry =
    new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)

  @Value("${spring.application.name:unknown}")
  private String appName;

  @Value("${HOSTNAME:unknown}")
  private String host;

  public PlatformMetricsAutoConfiguration() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
    new JvmGcMetrics().bindTo(prometheusMeterRegistry);
    new JvmHeapPressureMetrics().bindTo(prometheusMeterRegistry);
    new JvmMemoryMetrics().bindTo(prometheusMeterRegistry);
    new ProcessorMetrics().bindTo(prometheusMeterRegistry);
    new FileDescriptorMetrics().bindTo(prometheusMeterRegistry);
  }

  @Bean
  @ConditionalOnBean(KubernetesClient.class)
  MeterFilter kubernetesMeterFilter(KubernetesClient k8sClient) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
    MeterFilter k8sMeterFilter = new KubernetesCommonTags();
    prometheusMeterRegistry.config().meterFilter(k8sMeterFilter);
    return k8sMeterFilter;
  }

  @Bean
  @ConditionalOnMissingBean(KubernetesClient.class)
  MeterFilter appAndHostTagsMeterFilter() {
    MeterFilter appAndHostMeterFilter = MeterFilter.commonTags(
      Tags.of("app", appName, "host", host));
    prometheusMeterRegistry.config().meterFilter(appAndHostMeterFilter);
    return appAndHostMeterFilter;
  }

  @RestController
  class PlatformMetricsEndpoint {
    @GetMapping(path = "/platform/metrics", produces = TextFormat.CONTENT_TYPE_004)
    String platformMetrics() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png)
      return prometheusMeterRegistry.scrape();
    }
  }
}

1

在平台团队的配置中,可以私下创建一个 Prometheus 仪表注册表,而不将其添加到 Spring 应用程序上下文中,因此不受 MeterFilterMeterBinder 和产品团队可能通过应用程序上下文配置的其他自定义影响。

2

平台团队关心的度量直接添加到配置的私有注册表中。

3

平台团队为在 Kubernetes 中运行的每个微服务提供了一个通用标签计量过滤器。这种做法关注所有微服务团队,并且他们都从相同的一组通用标签中受益(尽管他们可能额外添加自己的)。计量过滤器在构建后立即应用于私有平台计量注册表。提供了一组备用的通用标签,以处理应用程序不在 Kubernetes 中运行的情况。

4

平台团队为自身设置了一个 API 端点,对每个应用程序都是通用的,与 Spring 自动配置的典型 /actuator/prometheus 端点不同,后者完全由产品团队控制以满足其自身需求。

Kubernetes 通用标签的实现,显示在例 2-54,适用于 Spinnaker 的 Kubernetes 实现配置要放置在 Pod 上的注释类型。Spinnaker 将在第五章中详细讨论。

例 2-54 Kubernetes 通用标签,假设服务由 Spinnaker 部署。
public class KubernetesCommonTags implements MeterFilter {
  private final Function<Meter.Id, Meter.Id> idMapper;

  public KubernetesCommonTags(KubernetesClient k8sClient) {
    try {
      Map<String, String> annotations = k8sClient.pods()
        .withName(host)
        .get()
        .getMetadata()
        .getAnnotations();

      for (Map.Entry<String, String> annotation : annotations.entrySet()) {
        logger.info("Kubernetes pod annotation <" + annotation.getKey() +
          "=" + annotation.getValue() + ">");
      }

      idMapper = id -> id.withTags(Tags.of(
        "revision",
          annotations.getOrDefault("deployment.kubernetes.io/revision", "unknown"),
        "app",
          annotations.getOrDefault("moniker.spinnaker.io/application", appName),
        "cluster",
          stream(annotations
            .getOrDefault("moniker.spinnaker.io/cluster", "unknown")
            .split(" ")
          ).reduce((first, second) -> second).orElse("unknown"),
        "location",
          annotations.getOrDefault("artifact.spinnaker.io/location", "unknown"),
        "host", host
      ));
    } catch (KubernetesClientException e) {
      logger.warn("Unable to apply kubernetes tags", e);
      idMapper = id -> id.withTags(Tags.of(
        "app", appName,
        "host", host,
        "cluster", "unknown")
      );
    }
  }

  @Override
  public Meter.Id map(Meter.Id id) {
    return idMapper.apply(id);
  }
}

计量过滤器的另一个用例涉及向发布的指标本身添加一层弹性。

按监控系统划分指标

假设您的组织选择了 Prometheus 作为其主要监控系统,并且专门的平台工程团队正在操作一系列 Prometheus 实例,这些实例应该从公司库存中的每个应用程序中拉取指标。也许这个平台团队甚至为监控某些通用指标做出贡献,代表产品团队,这些指标被广泛应用于组织中运行的每个 Java 微服务。

这些工程师如何证明他们认为自己有效操作的 Prometheus 实例实际上确实成功地从所有部署的资产中获取指标?存在不同的失败模式。在某些情况下,Prometheus 可能会在尝试从特定应用程序获取指标时超时。这可以从 Prometheus 对自身的监控中看到。但另一种失败模式可能是,我们配置了 Prometheus,以至于它甚至不会尝试从此应用程序获取指标。在这种情况下,Prometheus 会尽职地从所有它知道的应用程序中获取指标,并且对于它不知道的应用程序不报告任何内容。

假设你的组织也在 AWS 上运行其基础架构。我们可以选择将度量指标双重发布,既发送到我们的主要监控系统 Prometheus,也发送到 AWS Cloudwatch。一些调查显示,Cloudwatch(像其他公共云提供商托管的监控解决方案一样)是非常昂贵的,按发送的时间序列数量计费。但我们实际上只想使用 Cloudwatch 来验证 Prometheus 是否按预期工作。

整个过程看起来像图 2-26 所示。Prometheus 团队定期查询部署资产清单的状态(可能通过像第五章描述的有状态交付自动化解决方案)。对于列出的每个应用程序,团队可以检查 Micrometer 维护的 Prometheus 抓取尝试的计数器。未被抓取的应用程序将具有零或空计数器。

srej 0226

图 2-26. 向 Prometheus 和 Cloudwatch 发送度量指标的过程

示例 2-55 展示了如何使用接受和拒绝的 MeterFilter 对来成本有效地仅将对帮助平台团队确定 Prometheus 抓取配置是否按预期工作有用的 Prometheus 度量指标发布到 Cloudwatch。它使用了 Spring Boot 的一个特性称为 MeterRegistryCustomizer,允许我们将过滤器和其他注册自定义添加到特定的注册类型,而不是添加到所有注册类型中。

示例 2-55. 使用 Cloudwatch MeterRegistryCustomizer
@Bean
MeterRegistryCustomizer<CloudwatchMeterRegistry> cloudwatchCustomizations() {
    return registry -> registry.config()
            .meterFilter(MeterFilter.acceptNameStartsWith("prometheus"))
            .meterFilter(MeterFilter.deny());
}

与度量指标组织相关的最后一个概念是有关的。

仪表绑定器

在许多情况下,监控某些子系统或库涉及不止一个仪表。Micrometer 提供了一个简单的功能接口称为 MeterBinder,旨在将一组仪表封装在一起。Spring Boot 自动将任何配置为应用上下文的 MeterBinder bean 中的指标注册到度量指标中(即 @Bean MeterBinder ...)。示例 2-56 展示了一个简单的仪表绑定器,用于围绕车辆类型封装一些度量指标。

示例 2-56. 仪表绑定器实现
public class VehicleMeterBinder implements MeterBinder {
  private final Vehicle vehicle;

  public VehicleMeterBinder(Vehicle vehicle) {
    this.vehicle = vehicle;
  }

  @Override
  public void bindTo(MeterRegistry registry) {
    Gauge.builder("vehicle.speed", vehicle, Vehicle::getSpeed)
      .baseUnit("km/h")
      .description("Current vehicle speed")
      .register(registry);

    FunctionCounter.builder("vehicle.odometer", vehicle, Vehicle::readOdometer())
      .baseUnit("kilometers")
      .description("The amount of distance this vehicle has traveled")
      .register(registry);
  }
}

当一个仪表绑定器中注册的所有度量指标共享一些公共前缀时最好(在本例中为“vehicle”),特别是当该绑定器被打包并作为广泛应用程序配置的默认配置时。一些团队可能认为这些度量指标在其特定情况下无用,并将其过滤掉以节省成本。具有公共前缀使得通过公共前缀应用拒绝仪表过滤器变得容易,如示例 2-57 所示。通过这种方式,您可以随时间添加和删除仪表绑定器中的度量指标,而过滤逻辑仍然能够广泛包括或排除此仪表绑定器产生的度量指标。

示例 2-57. 用于来自车辆计量绑定器的度量的基于属性的拒绝过滤器
management.metrics.enable.vehicle: false

总结

在本章中,你已经学习了如何使用维度指标来衡量应用程序的各个部分。我们还没有具体讨论如何使用这些数据。在第四章中,我们将会回到指标这个话题,介绍每个 Java 微服务的有效指标,以及如何构建有效的图表和警报。

您签署的组织承诺是为了利用所有这些维度指标数据,涉及选择一个或多个目标维度监控系统,可以选择一个 SaaS 提供或在本地搭建一个可用的开源系统。对您的代码的影响有限。当使用像 Spring Boot 这样的现代 Java Web 框架时,预打包的仪器将提供大量详细信息,而无需编写任何自定义仪器代码。您只需添加对您选择的监控系统实现的依赖,并提供一些配置以将指标发送到该系统。

在下一章中,我们将看到指标仪表化与分布式跟踪和日志等调试信号的比较。阅读时,请记住,我们刚刚讨论过的指标仪表化旨在提供固定成本的遥测,帮助您了解系统在整体上发生了什么。而这些其他遥测来源将提供关于每个单独事件(或请求)级别发生情况的详细信息。

第三章:使用可观察性进行调试

如在 第二章 开头提到的,可观察性信号可以根据它们带来的价值大致分为两类:可用性和调试性。聚合应用程序度量提供了最佳的可用性信号。在本章中,我们将讨论另外两个主要信号,即分布式追踪和日志。

我们将展示使用仅开源工具的方法来关联度量和跟踪的一种方法。一些商业供应商也致力于提供这种统一体验。就像在 第二章 中一样,展示特定方法的目的是为了开发对你的可观察性堆栈在完全组装时应具备的最低期望水平。

最后,分布式追踪仪表化,因其需要在微服务层次结构中传播上下文,可以成为系统更深层行为管理的有效场所。我们将讨论一个假设的故障注入测试功能作为其可能性的例子。

可观察性的三大支柱……还是两大支柱?

正如 《分布式系统可观察性》 一书中 Cindy Sridharan(O’Reilly)所述,三种不同类型的遥测形成了“可观察性的三大支柱”:日志、分布式追踪和度量。这种三支柱的分类非常普遍,以至于很难准确指出其起源。

虽然日志、分布式追踪和度量是三种具有独特特性的遥测形式,它们大致上有两个目的:证明可用性和用于根本原因诊断的调试。

除非以某种方式减少数据量,否则维护所有这些遥测数据的操作成本将非常高昂。显然,我们只能维护一定时间的遥测数据,因此需要其他的减少策略。

聚合

例如,可以将定时器数据(参见 “定时器”)的预计算统计量呈现为总和、计数和一些有限的分布统计信息。

采样

仅保留某些测量数据。

聚合有效地以请求级粒度的代价压缩了表示,而采样则以系统性能整体视图的代价保留了请求级粒度。除低吞吐量系统外,既保持全请求级粒度又全面表示所有请求的成本都太高。

使用可观察性工具进行调试的重点章节中,保留一些信息的完整粒度对于调试至关重要。从指标中派生的可用性信号将指向问题所在。通过对数据进行维度探索,在某些情况下足以识别问题的根本原因。例如,将特定信号按实例分解成各个信号可能会揭示特定实例的故障。可能出现整个区域故障或应用程序版本故障的情况。在模式不明显的罕见情况下,分布式跟踪或日志中的代表性故障将是确定根本原因的关键。

考虑每个“三支柱”的特征表明,作为事件级遥测的日志和跟踪用于调试,而指标用于证明可用性。

日志

日志在软件堆栈中无处不在。无论其结构及最终存储位置如何,日志都具有一些定义特征。

日志与系统吞吐量成正比增长。每次执行记录日志的代码路径,都会产生更多日志数据。即使对日志数据进行抽样,其大小仍然保持这种比例关系。

日志的上下文范围限定在事件中。日志数据提供了关于特定交互执行行为的上下文。当从多个独立的日志事件中聚合数据以推断系统的整体性能时,聚合效果实际上就是一个指标。

显然,日志主要用于调试。先进的日志分析包能够通过聚合日志数据来证明可用性。执行此聚合操作、持久化受聚合影响的数据以及分配已持久化的有效载荷都需要成本。

分布式跟踪

跟踪遥测与日志类似,记录每个已接入执行(即事件驱动),但会因果关联地跨系统的不同部分链接个别事件。分布式跟踪系统可以完整地推断出整个系统中用户交互的端到端情况。因此,对于已知存在一些下降情况的请求,用户请求满意度的这种端到端视图显示出系统中哪个部分出现了下降。

跟踪遥测比日志更常见地进行抽样。然而,跟踪数据与日志数据一样,仍然与系统吞吐量成正比增长。

将跟踪集成到现有系统中可能很困难,因为端到端流程中的每个协作者都必须配置为向前传播跟踪上下文。

分布式追踪特别适用于特定类型的性能问题,其中整个系统比应有的速度慢,但没有明显的热点可以快速优化。 有时,您只需看到许多子系统对整个系统性能的贡献,才能意识到需要可视化的系统性“死亡方式”,以便建立解决这种问题的组织意愿,因此需投入时间和资源的关注。

“很慢”是您要调试的最困难的问题。 “很慢”可能意味着执行用户请求所涉及的多个系统之一速度慢。 它可能意味着跨许多计算机的转换管道的部分之一速度慢。 “很慢”很难,部分原因是问题陈述并未提供有关缺陷位置的许多线索。 部分故障隐藏在黑暗的角落。 并且,直到退化变得非常明显,您才会获得足够的资源(时间、金钱和工具)来解决它。 Dapper 和 Zipkin 的建立是有原因的。

杰夫·霍奇斯

在拥有大量微服务的组织中,分布式追踪有助于理解参与处理特定类型请求的服务图(服务之间的依赖关系)。 当然,这假设图中的每个服务都以某种形式进行追踪仪器化。 从最狭义的意义上讲,服务图的最后一层可以是未经仪器化的,但如果由客户端的调用包装的跨度命名,则仍会出现在服务图中。

分布式追踪与日志一样,本质上是事件驱动的,因此最适合作为调试信号,但是除了标签之外,它还承载着重要的服务间关系上下文。

度量

日志和分布式追踪在某种程度上比起详细讨论的度量更加相似,因为它们都是经过抽样以控制成本。度量是以聚合形式呈现的,用于全面了解某种服务水平指标(SLI),而不是提供有关构成 SLI 的单个交互的详细信息。

使用度量为现有代码库添加度量部分是手动工作,部分是源于通用框架和库的改进,这些框架和库越来越多地配备了仪器化功能。

度量 SLI 是有目的地收集以针对服务水平目标进行测试的,因此它们旨在证明可用性。

适用哪种遥测?

考虑到每种可见性形式的预期用途,请考虑它们的重叠部分。 它们重叠的地方,我们应该强调哪种形式而不是另一种形式?

追踪和日志记录都是调试信号的概念表明它们可能是多余的,尽管不相等。在检索和搜索它们方面一切相等的情况下,具有有效标签和元数据的追踪比日志行更优秀,因为它还提供了有关导致该追踪的调用链的有用上下文(并进一步传播此上下文)。

追踪仪器存在于与度量计时器完全相同的逻辑位置。请注意,分布式追踪仅测量执行。在涉及执行时间的情况下,度量和追踪仪器都可能适用,因为它们互补。度量提供了对代码片段的所有执行的聚合视图(且没有调用者上下文),而分布式追踪提供了单个执行的采样示例。除了计时执行外,度量还计数和测量事物。这些信号没有追踪等效信号。

为了使这更具体,让我们看一下来自示例 3-1 中的典型应用程序日志摘录。这个日志摘录的开始部分包含了一次性事件的信息,说明了配置了哪些组件和启用了哪些功能。这些信息可能对理解为什么应用程序无法按预期运行很重要(例如,如果预期应该配置组件但未配置),但它们不适合作为度量标准,因为它们不是需要随时间聚合以了解系统整体性能的重复事件。它们也不适合作为分布式追踪,因为这些事件特定于此服务的状态,并且与在多个微服务之间协调满足最终用户请求无关。

有其他日志行可以用追踪或度量替换,如在例子后的调用中所述。

示例 3-1。展示遥测选择的典型应用程序日志
.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot ::       (v...RELEASE)

:56:56 main INFO c.m.MySampleService - Starting MySampleService on
 HOST with PID 12624
:56:56 main INFO c.m.MySampleService - The following profiles are active: logging
:56:56 main INFO o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refresh
  org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplication
  Context@2a5c8d3f: startup date [Tue Sep 17 14:56:56 CDT]; root of context
:56:57 background-preinit INFO o.h.v.i.util.Version - HV000001: Hibernate Validator
  5.3.6.Final
:57:02 main INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized
  with port(s): 8080 (http)
:57:03 localhost-startStop-1 INFO i.m.c.i.l.LoggingMeterRegistry - publishing
  metrics to logs every 10s
:57:07 localhost-startStop-1 INFO o.s.b.a.e.m.EndpointHandlerMapping - Mapped
  "{[/env/{name:.*}],methods=[GET],produces=[application/
  vnd.spring-boot.actuator.v1+json || application/json]}" onto public
  java.lang.Object org.springframework.boot.actuate.endpoint.mvc.
  EnvironmentMvcEndpoint.value(java.lang.String)
:57:07 localhost-startStop-1 INFO o.s.b.w.s.FilterRegistrationBean - Mapping filter:
 'metricsFilter' to: [/*]
:57:11 main INFO o.mongodb.driver.cluster - Cluster created with settings
 {hosts=[localhost:27017], mode=SINGLE, requiredClusterType=UNKNOWN,
 serverSelectionTimeout='30000 ms', maxWaitQueueSize=500}
:57:12 main INFO o.s.b.a.e.j.EndpointMBeanExporter - Registering beans for JMX
 exposure on startup
:57:12 main INFO o.s.b.a.e.j.EndpointMBeanExporter - Located managed bean
  'healthEndpoint': registering with JMX server as MBean
  [org.springframework.boot:type=Endpoint,name=healthEndpoint]
:57:12 main INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on
  port(s): 8080 (http)
:57:13 cluster-ClusterId{value='5d813a970df1cb31507adbc2', description='null'}-
  localhost:27017 INFO o.mongodb.driver.cluster - Exception in monitor thread
  while connecting to server localhost:27017
com.mongodb.MongoSocketOpenException: Exception opening socket ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  at c.m.c.SocketStream.open(SocketStream.java:63)
  at c.m.c.InternalStreamConnection.open(InternalStreamConnection.java:115)
  at c.m.c.DefaultServerMonitor$ServerMonitorRunnable.run(
    DefaultServerMonitor.java:113)
  at java.lang.Thread.run(Thread.java:748)
Caused by: j.n.ConnectException: Connection refused: connect
  at j.n.DualStackPlainSocketImpl.waitForConnect(Native Method)
  at j.n.DualStackPlainSocketImpl.socketConnect(
    DualStackPlainSocketImpl.java:85)
  at j.n.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
  at j.n.AbstractPlainSocketImpl.connectToAddress(
    AbstractPlainSocketImpl.java:206)
  at j.n.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
  at j.n.PlainSocketImpl.connect(PlainSocketImpl.java:172)
  at j.n.SocksSocketImpl.connect(SocksSocketImpl.java:392)
  at j.n.Socket.connect(Socket.java:589)
  at c.m.c.SocketStreamHelper.initialize(SocketStreamHelper.java:57)
  at c.m.c.SocketStream.open(SocketStream.java:58)
  ... 3 common frames omitted
:57:13 main INFO c.m.PaymentsController - [GET] Payment 123456 retrieved in 37ms. ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
:57:13 main INFO c.m.PaymentsController - [GET] Payment 789654 retrieved in 38ms
... (hundreds of other payments retrieved in <40ms)
:57:13 main INFO c.m.PaymentsController - [GET] Payment 567533 retrieved in 342ms.
:58.00 main INFO c.m.PaymentsController - Payment near cache contains 2 entries. ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)

1

既没有度量也没有日志记录,只有追踪。Mongo 套接字连接尝试可以很容易地通过度量进行计时,其中标签指示成功/失败,并带有类似exception=ConnectException的摘要异常标签。这种摘要标签可能足以在查看整个堆栈跟踪之前理解问题。在其他情况下,如果摘要异常标签是类似exception=NullPointerException的内容,则在监控系统提醒我们一组异常未能达到已建立的服务水平目标时,记录堆栈跟踪有助于识别具体问题。

2

既有跟踪又有度量,没有日志。 代码中的日志语句可以完全删除。度量和分布式跟踪以一种允许我们综合理解所有支付检索及个别支付的代表性检索的方式捕获所有有趣信息。例如,度量将显示,虽然大多数支付在不到 40 毫秒内检索,但有些支付可能需要一个数量级更长的时间来检索。

3

度量,没有跟踪或日志。 一个近缓存的频繁检索支付可以严格通过一个度量器来监控。在跟踪仪器中没有等效的度量器,并且记录这一点是多余的。

你应该选择哪个可观察性工具?

如果可能的话,跟踪比日志记录更可取,因为它可以包含相同的信息,但上下文更丰富。在跟踪和度量重叠的地方,应该从度量开始,因为第一个任务应该是知道某个系统不可用。稍后可以添加额外的遥测来帮助解决问题。当你添加跟踪时,从存在定时度量仪器的地方开始,因为很可能也值得使用相同标签的超集进行跟踪。

假设你已经准备好添加分布式跟踪,接下来让我们考虑一下什么构成了一个跟踪,以及它如何被可视化。

分布式跟踪的组成部分

一个完整的分布式跟踪是一组单独的span的集合,这些 span 包含每个端到端用户请求满意度中每个接触点的性能信息。这些 span 可以组装成一个“冰柱”图,显示每个服务中花费的时间相对多少,如图 3-1 所示。

srej 0301

图 3-1. Zipkin 冰柱图

Span 包含一个名称和一组键值对标签,很像度量仪器那样。我们在“命名度量”中讨论的许多原则同样适用于分布式跟踪。所以如果一个跟踪 span 被命名为http.server.requests,那么标签可以标识区域(以公共云的概念来说)、API 端点、HTTP 方法、响应状态码等。保持度量和跟踪命名的一致性是允许进行遥测相关性的关键(见“遥测相关性”)。

不像度量中的情况,Zipkin span 数据模型包含用于服务名称的特殊字段(用于 Zipkin Dependencies 视图,显示服务图)。这相当于将度量与应用程序名称标记在一起,大多数度量后端不为这个概念设置一个保留标签名。Span 名称也是 Zipkin 数据模型的一个定义字段。两者都被索引以进行查找,因此应避免在 span 和服务名称上设置无界值集的基数。

与度量不同的是,在每种情况下都不必控制跟踪的标签基数。这与跟踪的存储方式有关。表格 2-1 展示了度量如何通过唯一 ID(名称和键/值标签的组合)逻辑地存储在行中。额外的测量数据存储为现有行中的样本。度量的成本是总 ID 数和每个 ID 维护的样本量的乘积。分布式跟踪跨度单独存储,不考虑其他跨度是否具有相同的名称和标签。分布式跟踪的成本是系统吞吐量和采样率的乘积(视为百分比)。

尽管标签基数不会影响分布式跟踪系统的存储成本,但确实会影响查找成本。在跟踪系统中,标签可以由跟踪后端标记为可索引(并且在 Zipkin UI 中可以自动完成)。显然,这些标签值集应限制在索引性能范围内。

最好在度量和跟踪之间尽可能重叠标签,以便以后可以进行关联。您还应该使用额外的高基数标签标记分布式跟踪,这些标签可以用于定位来自特定用户或交互的请求,如表 3-1](part0008_split_007.html#overlap_in_trace_metrics_tagging)中所示。努力使值在标签键匹配的地方匹配。

表 3-1. 分布式跟踪和度量标记的重叠

度量标签键 跟踪标签键
应用程序 应用程序 payments
方法 方法 GET
状态 状态 200
URI URI /api/payment/
详细的链接 /api/payment/abc123
用户 user123456

到目前为止,应该清楚地知道,追踪旨在让您了解请求的端到端性能。因此,不应感到意外,Zipkin UI 专注于根据一组参数搜索追踪,如图 3-2 所示。这种列表交换了对端到端性能整体分布的理解,以匹配特定参数集的一组追踪。建立整体分布与此视图之间的关联是“遥测相关性”的主题。

srej 0302

图 3-2. 在 Zipkin Lens UI 中搜索追踪

与度量类似,添加分布式跟踪到您的应用程序有多种方式。让我们考虑每种方式的一些优势。

分布式追踪仪器类型

与度量有关的所有讨论,都适用于分布式追踪仪表化“黑盒与白盒监控”。追踪仪表化在各种架构层面上可用(从基础设施到应用程序的各个组件)。

手动追踪

类似 Zipkin 的 Brave 或 OpenTelemetry 的库允许您在代码中显式地为应用程序添加仪表化。在理想情况下被追踪的分布式系统中,一定程度的手动追踪肯定是存在的。通过它,可以向追踪中添加关键的业务特定上下文,而其他形式的预打包仪表化则无法意识到。

代理追踪

就像使用度量一样,代理(通常由供应商提供)可以在不进行代码更改的情况下自动添加追踪仪表化。连接代理是应用程序交付流水线的一个变更,这种复杂性成本不应被忽视。

无论您的平台在哪个抽象级别运作,这种成本都是真实的:

  • 对于像亚马逊 EC2 这样的基础设施即服务平台,您将不得不将代理及其配置添加到基础 Amazon Machine Image 中。

  • 对于一个容器即服务(CaaS)平台,您需要在类似 openjdk:jre-alpine 的基础镜像和您的应用程序之间再加一层容器级别。这种影响可能泄漏到您的构建中。如果您正在使用 Gradle 的 com.bmuschko.docker-spring-boot-application 插件将 Spring Boot 应用程序打包用于 CaaS 的部署,现在需要用包含代理的镜像覆盖默认的容器镜像。此外,每当包含代理的容器镜像的基础镜像(很可能是 com.bmuschko.docker-spring-boot-application 的默认镜像)更新时,您都需要发布新镜像。

  • 对于像 Cloud Foundry 或 Heroku 这样的平台即服务(PaaS),除非特定支持代理的集成已被 PaaS 供应商支持,否则您必须使用自定义基础。

框架追踪

框架也可以自带遥测功能。由于框架作为二进制依赖项包含在应用程序中,这种形式的遥测在技术上是黑盒解决方案。当框架级仪表化允许用户提供自定义到其自动仪表化触点时,框架级仪表化可以有白盒的感觉。

框架了解其自身的实现特异性,因此可以提供丰富的上下文信息作为标签。

举个例子,为了一个 HTTP 请求处理程序的框架仪表化,可以用参数化的请求 URI 标记 span(例如,/api/customers/(id)/api/customers/1)。代理仪表化必须意识到并切换所有支持的框架,以提供相同的丰富度,并跟上各框架的变更。

另一个复杂性来自于现代编程范式中日益普遍的异步工作流程,例如响应式编程。适当的跟踪实现需要进程内传播,在响应式上下文中可能会有些棘手,因为您不能简单地将上下文信息放入 ThreadLocal 中。此外,在同样的上下文中处理 映射诊断上下文 以关联日志和跟踪可能也会有些棘手。

在现有应用中添加框架级别的仪表化可能是比较轻量级的。例如,Spring Cloud Sleuth 为基于 Spring Cloud 的现有应用添加追踪遥测。您只需像 示例 3-2 中那样增加一个额外的依赖项,以及像 示例 3-3 中的少量配置,后者可以在跨组织使用像 Spring Cloud Config Server 这样的集中式动态配置服务器时进行配置。

示例 3-2. 在 Gradle 构建中 Sleuth 运行时依赖
dependencies {
    runtimeOnly("org.springframework.cloud:spring-cloud-starter-zipkin") ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
}

1

请注意,io.spring.dependency-management 插件负责将版本添加到此依赖项规范中。

示例 3-3. Spring Boot 的应用程序配置在 application.yml 中的 Sleuth 配置
spring.zipkin.baseUrl: http://YOUR_ZIPKIN_HOST:9411/

服务网格跟踪

服务网格是应用代码之外的基础设施层,负责管理微服务之间的交互。许多实现方式通过与应用程序进程关联的 Sidecar 代理来完成这一点。

在某些方面,这种仪表化形式与框架可能实现的方式并没有太大区别,但不要被误导以为它们是相同的。它们在仪表化点上是相似的(装饰 RPC 调用)。框架肯定会比服务网格拥有更多信息。例如,对于 REST 端点跟踪,框架可以访问以一种有损映射到少量 HTTP 状态码之一的方式映射的异常细节。服务网格只能访问状态码。框架可以访问端点的未替换路径(例如 /api/person/{id} 而不是 /api/person/1)。

与 Sidecar 相比,代理还具有更丰富的潜力,因为它们可以深入到单个方法调用,比 RPC 调用的粒度更细。

添加服务网格不仅会改变交付流水线,还会增加在管理 Sidecar 和它们的控制平面时的额外资源和复杂性成本。

然而,在服务网格层进行仪器化意味着你不必为现有应用程序添加类似 Spring Cloud Sleuth 的框架仪器化,也不必像使用代理仪器化那样更改基础镜像,或者进行手动仪器化。由于服务网格相对于框架而言缺乏信息,引入服务网格主要是为了实现遥测仪器化,这将产生维护网格所需的重大成本,而遥测数据相对较少。例如,网格将观察到对 /api/customers/1 的请求,但不会像框架那样具有上下文,即这是对 /api/customers/(id) 的请求。因此,从基于网格的仪器化产生的遥测数据将更难按参数化 URI 进行分组。最终,添加运行时依赖可能会更加容易。

混合跟踪

白盒(或因自动配置而“感觉像”白盒的框架遥测)和黑盒选项并不是互斥的。事实上,它们可以相互补充得很好。考虑一下 示例 3-4 中的 REST 控制器。Spring Cloud Sleuth 被设计为在请求处理器 findCustomerById 周围自动创建一个 span,并标记它与相关信息。通过注入一个 Tracer,你可以在仅涉及数据库访问时添加一个更精细的 span。这将用户交互端到端分解成了一个更细粒度的跟踪。现在,我们可以确定数据库在特定微服务中导致请求满意度降低的原因所在。

示例 3-4. 混合了黑盒和白盒跟踪仪器化
@RestController
public class CustomerController {
  private final Tracer tracer;

  public CustomerController(Tracer tracer) {
    this.tracer = tracer;
  }

  @GetMapping("/customer/{id}") ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  public Customer findCustomerById(@PathVariable String id) {
    Span span = tracer.nextSpan().name("findCustomer"); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
    try (Tracer.SpanInScope ignored = tracer.withSpanInScope(span.start())) {
        Customer customer = ... // Database access to lookup customer
        span.tag("country", customer.getAddress().getCountry()); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
        return customer;
    }
    finally {
        span.finish(); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png)
    }
  }
}

1

Spring Cloud Sleuth 将自动为此端点添加仪器化,并使用诸如 http.uri 的有用上下文进行标记。

2

开始一个新的 span 将为跟踪冰柱图添加另一个不同的元素。现在,我们可以在整个端到端用户交互的上下文中推断出仅此方法 findCustomerById 的成本。

3

添加业务特定的上下文,黑盒仪器化可能缺乏。也许你的公司最近在新的国家推出了一项服务,作为全球扩张的一部分,由于其新近性,该国家的客户缺乏与你的产品的长期活动历史。看到老客户和新客户之间查找时间的惊人差异可能表明在这种情况下如何加载活动历史的变化。

4

整个数据访问操作都是使用白盒工具手动进行的。

假设数据库访问是跨应用程序堆栈进行追踪的(可能通过将数据库访问的包装与跟踪仪器封装成一个通用库并在整个组织中共享),则可以有效地追踪数据库,而无需向数据库层本身添加任何形式的白盒或黑盒监控。从调用者的角度来仪器化像 IBM DB2 数据库运行在 z/OS 主机上的情况使用 Zipkin Brave 似乎是一个不可能的任务,但从调用者的角度来看可以完成。

有效地跟踪所有对子系统的调用实际上是在跟踪子系统。

通过追踪所有对子系统的调用,实际上就像对其自身进行仪器化一样覆盖了子系统。许多组件框架(数据库、缓存、消息队列)提供了某种事件系统,供您挂接。在许多情况下,涂层所有调用者以便仪器化可以简化为确保所有调用应用程序具有能够自动将事件处理程序注入到要仪器化的组件框架的运行时的二进制依赖关系。

从调用者的角度来进行跟踪的另一个影响是它包括两个系统之间的延迟(例如,网络开销)。在一个场景中,一系列调用者正在向服务下游执行请求,服务以固定的最大并发级别提供请求服务(例如,线程池),直到请求开始处理,服务可能甚至没有意识到请求的存在。从调用者的角度仪器化包括请求在等待被下游处理之前在队列中停留的时间。

所有这些上下文信息可能非常昂贵。为了控制成本,在某些时候我们必须对跟踪遥测数据进行采样。

采样

如 “可观察性的三大支柱…还是两大支柱?” 所述,一般来说,跟踪数据通常必须进行采样以控制成本,这意味着某些跟踪信息发布到跟踪后端,而其他跟踪信息则不会。

无论采样策略多么聪明,重要的是要记住数据是被丢弃的。无论最终得到的一系列追踪信息如何,它们总是会在某种程度上有所偏差。当您将分布式追踪数据与指标数据配对时,这是完全可以接受的。指标应该在异常情况下提醒您,而需要深入调试时则使用追踪信息。

采样策略可以分为几个基本类别,从完全不进行采样到从边缘传播采样决策。

无采样

可以保留每一个跟踪样本。一些组织甚至在大规模上做到了这一点,通常付出了巨大的代价。对于 Spring Cloud Sleuth,请通过 bean 定义配置 Sampler,如 示例 3-5 所示。

示例 3-5. 配置 Spring Cloud Sleuth 以始终进行采样
@Bean
public Sampler defaultSampler() {
  return Sampler.ALWAYS_SAMPLE;
}

速率限制采样器

默认情况下,Spring Cloud Sleuth 保留每秒前 10 个样本(可配置的速率限制阈值),然后以概率方式进行降采样。由于在 Sleuth 中默认使用速率限制抽样,因此速率限制可以通过属性设置,如示例 3-6 所示。

示例 3-6. 配置 Spring Cloud Sleuth 以保留每秒前 2,000 个样本
spring.sleuth.sampler.rate: 2000

其背后的逻辑是,对于某些吞吐量,不丢弃任何东西在成本上是合理划算的。这在很大程度上将由您业务的性质和应用程序的吞吐量来决定。一个地区性的财产和意外保险公司每分钟通过其旗舰应用程序接收 5,000 个请求,大约由 3,500 名外出的保险代理生成的交互产生。由于保险代理的群体不会在一夜之间突然增长一个数量级,因此,为接受此系统的 100%追踪的追踪系统制定稳定的容量计划是可以确定的。

即使您的组织像这家保险公司一样稳定,也要牢记在应用程序可观察性上进一步投资的位置,通常在具有显著规模的技术公司的开源项目和监控系统供应商中,不能假设其客户都有如此稳定的容量计划。考虑到服务端点的延迟高百分位数计算,从分桶直方图中利用高百分位数的近似值仍然比尝试从追踪数据中计算精确百分位数更有意义,即使数学上可以实现使用 100%数据。

关键是要避免在可操作范围内计算分布统计数据的新方法,当已有类似的方法可从专为大规模操作设计的指标遥测中获取时。

基于速率的抽样的一个挑战是存在空洞。当您在调用链中有几个微服务,每个独立地决定是否保留追踪时,会在给定请求的端到端图像中产生空洞。换句话说,基于速率的抽样器在给定追踪 ID 时不能做出一致的抽样决策。当任何个体子系统超过速率阈值时,涉及该子系统的追踪中就会出现空洞。

基于速率抽样器进行容量规划决策时,要注意这些速率是每个实例的基础。

概率抽样器

概率抽样器计算,以确定需要保留的 100 个追踪中有多少个。它们保证如果选择了 10%的概率,则会保留 100 个追踪中的 10 个,但可能不会是前 10 个或最后 10 个。

在存在概率属性的情况下,Spring Cloud Sleuth 会配置概率采样器而不是速率限制采样器,如示例 3-7 中所示。

示例 3-7. 配置 Spring Cloud Sleuth 以保留 10%的跟踪
spring.sleuth.sampler.probability: 0.1

几个原因使得概率采样器很少是正确的选择:

成本

无论您选择什么概率,您的跟踪成本都会与流量成正比线性增长。也许您从未预料到 API 端点会收到超过每秒 100 个请求,因此您抽样了 10%。如果流量突然增加到每秒 10,000 个请求,您将突然之间将要发送每秒 1,000 个跟踪,而不是 10 个。速率限制采样器通过一种方式限制成本,无论吞吐量如何,都将成本上限固定在一个值上。

像基于速率的采样器一样,概率采样器不查看跟踪 ID 和头部来做出其采样决策。在端到端图像中将会出现洞。对于相对低吞吐量系统,基于速率的采样器可能实际上没有洞,因为没有单个子系统超过速率阈值,但是概率采样器在单位吞吐量上有均匀的洞存在概率,因此即使对于低吞吐量系统,洞可能也会存在。

边界采样

边界采样器是概率采样器的一种变体,通过仅在边缘(与您的系统的第一个交互)进行一次采样决策,并将该采样决策传播到其他服务和组件来解决洞的问题。每个组件中的跟踪上下文包含一个采样决策,该决策作为 HTTP 头添加,并由下游组件提取为跟踪上下文,如图 3-3 所示。

srej 0303

图 3-3. B3 跟踪头将采样决策传播到下游组件

采样对异常检测的影响

让我们具体考虑概率采样对异常检测的影响。实际上,任何采样策略都会产生类似的影响,但我们将使用概率采样来具体描述这一点。

建立在采样跟踪上的异常检测系统通常是错误的,除非你的组织承担了 100% 采样的成本。为了说明这一点,让我们考虑一种假设的采样策略,根据每个请求开始时的加权随机数做出关于是否保留追踪的决定(就像 Google 的 Dapper 最初所做的那样)。如果我们对请求进行 1% 的采样,那么一个超出第 99 百分位数的异常值,像其他所有请求一样,有 1% 的机会在采样中存活。看到任何这些个别的异常值的机会是 0.01%。即使在每秒 1,000 个请求的情况下,你可能每秒都会发生 10 个异常值,但在追踪数据中只会每 5 分钟看到一个,如图 3-4 所示(这是一个( 1 - 0 . 99 N ) * 100 %的图)。

srej 0304

图 3-4. 随时间变化的追踪数据中看到异常值的机会

在第 99 百分位数以上可能存在显著范围的异常值,如图 4-20 所示。你可能每秒都会出现一个巨大的业务关键异常值(高于 P99.9),但在任何给定的小时内追踪数据中只会看到一次!出于调试目的,让一个或一小组异常值在一定时期内存活是可以接受的——我们仍然可以详细检查异常情况发生的性质。

分布式追踪与单体应用

别被分布式追踪的名字所迷惑。在单体架构中使用这种形式的可观察性是完全合理的。在最纯粹的微服务架构中,围绕 RPC 调用进行追踪可以在框架级别(比如 Spring)或者在诸如服务网格技术中找到的边车中以黑匣子的方式实现。考虑到微服务架构的单一责任性质,追踪 RPC 实际上可以为你提供关于正在发生的事情的相当多的信息;即,微服务边界实际上也是业务逻辑功能边界。

在一个接收单个端用户请求并执行许多任务以满足该请求的单体应用程序内部,框架级别的仪器化当然价值降低,但你仍然可以在单体应用程序内部的关键功能边界处编写追踪仪器化,方式与编写日志语句相同。通过这种方式,你将能够选择特定的标签,这些标签允许你搜索具有业务上下文的跨度,而框架或服务网格仪器化肯定会缺少这些标签。

实际上,具有业务特定标记的白盒仪器在纯微服务架构中变得至关重要。在许多情况下,我们的关键业务功能在生产环境中并非完全失效,而是在特定(通常是不寻常的)业务特定故障线路上出现问题。也许一个保险公司的政策管理系统无法对肯塔基州的某个县的经典车辆进行定价。在度量和跟踪遥测中同时拥有车辆类别、县和州的信息,使工程师可以在已知故障维度上进行维度钻取,并找到问题区域,然后跳转到跟踪和日志以查看示例故障。

业务上下文使得白盒跟踪在单体应用中与分布式系统一样重要

在业务功能边界上的白盒分布式跟踪仪器密度在微服务或单体架构中应大致相同,因为黑盒仪器不会使用帮助后续查找的业务特定上下文标记跨度。

因此,微服务和单体应用之间唯一的区别在于您将更多业务功能边界打包到一个进程中。随着每个额外的业务功能的增加,支持其存在的一切都会随之而来。可观察性并不是例外。

此外,即使是负责单一责任的微服务也可以执行一些任务,包括数据访问,以满足用户请求。

遥测数据的关联

由于度量数据是强有力的可用性信号,跟踪和日志数据对调试非常有用,我们可以做的一切都是将它们链接在一起,使得从警报指示可用性缺失到最佳识别潜在问题的调试信息的过渡更加顺畅。在延迟情况下,我们将在仪表板上绘制图表,并在一个衰减的最大值上设置警报。将延迟分布的视图呈现为延迟直方图的热图是一种信息密集的有趣可视化,但我们无法在其上绘制警报阈值。

度量到跟踪的相关性

我们可以在热图上绘制示例跟踪(样本),如图 3-5 所示,并通过将热图单元格转换为链接使热图更加交互化,直接跳转到跟踪界面,从中可以查看与这些标准匹配的一组跟踪。因此,负责系统的工程师收到延迟条件的警报后,查看此应用程序的延迟图表集,并可以立即点击跳转到分布式跟踪系统。

srej 0305

图 3-5. 在 Grafana 中以热图形式呈现的 Zipkin 追踪数据叠加在 Prometheus 直方图上

这种相关性绘图使得指标和追踪一起变得更有价值。通过聚合,我们通常会失去对特定情况下发生了什么的理解,而查看指标数据。另一方面,追踪则缺乏指标提供的整体情况理解。

另外,由于确实可能发生了追踪抽样(再次为了控制成本),已经丢弃了所有匹配特定延迟桶的追踪,即使我们无法深入了解追踪细节,我们仍然能够了解最终用户经历了什么样的延迟。

此可视化是通过独立的 Prometheus 和 Zipkin 查询的组合构建的,如 图 3-6 所示。请注意,指标和追踪工具之间的标签不一定严格对应。Micrometer 的 Timer 被称为 http.server.requests(在打开直方图时,Prometheus 中称为 http_server_requests_second_bucket),使用一个名为 uri 的标签进行收集。Spring Cloud Sleuth 以类似的方式仪表化 Spring,但使用 http.uri 标记跟踪。这些当然在逻辑上是等价的。

srej 0306

图 3-6. 独立的 Prometheus 和 Zipkin 查询形成了联合追踪示例热图

然而,应当明确的是,即使标签键(甚至值)不必完全相同,如果您想要将热图过滤到在追踪数据中没有逻辑等价物的指标标签,那么将无法准确地找到与热图上所见内容匹配的样本(会有一些误报)。例如,Spring Cloud Sleuth 最初没有使用 HTTP 状态码或结果标记跟踪,而 Spring 的 Micrometer 工具包则有。通常我们希望将延迟可视化限制在成功或失败的结果之一,因为它们的延迟特性可能会有很大不同(例如,失败由于外部资源不可用而异常快速或由于超时而异常缓慢)。

到目前为止,我们对分布式追踪的探讨严格限于可观察性,但它可以用于影响或管理流量处理的其他目的。

使用追踪上下文进行失败注入和实验

在早期讨论分布式追踪的抽样方法时,我们涵盖了边界抽样(参见 “边界抽样”)。在这种方法中,抽样决策是事先做出的(即在边缘处),并且此决策向下游微服务传播,这些微服务参与满足请求。有一个有趣的机会可以做其他事先决策,并利用跟踪上下文将与抽样决策无关的其他信息传递给下游服务。

这其中一个著名的例子是故障注入测试(FIT),这是混沌工程的一种特定形式。混沌工程的整体学科是广泛的,并且在混沌工程中有详细介绍。

API 网关可以在与由中央 FIT 服务提供的规则协调的前提下,前置添加故障注入决策,并作为跟踪标签向下游传播。稍后,执行路径中的微服务可以使用有关故障测试的信息,以某种方式非自然地使请求失败。图 3-7 显示了整个过程,端到端。

srej 0307

图 3-7. 从用户请求到故障的故障注入测试过程

将这种决策附加到遥测数据中的一个附加好处是,任何作为故障注入一部分的抽样跟踪都会被标记为这样,因此当稍后查看遥测数据时,您可以区分真实故障和故意故障。示例 3-8 显示了一个简化的 Spring Cloud Gateway 应用程序示例(同时应用了 Spring Cloud Sleuth Starter),查找并将 FIT 决策作为 "baggage" 添加到跟踪上下文,这可以通过设置属性 spring.sleuth.baggage.tag-fields=failure.injection 自动转换为跟踪标签。

示例 3-8. Spring Cloud Gateway 将故障注入测试数据添加到跟踪上下文
@SpringBootApplication
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

@RestController
class GatewayController {
    private static final String FAILURE_INJECTION_BAGGAGE = "failure.injection";

    @Value("${remote.home}")
    private URI home;

    @Bean
    BaggagePropagationCustomizer baggagePropagationCustomizer() {
        return builder -> builder.add(BaggagePropagationConfig.SingleBaggageField
                .remote(BaggageField.create(FAILURE_INJECTION_BAGGAGE)));
    }

    @GetMapping("/proxy/path/**")
    public Mono<ResponseEntity<byte[]>> proxyPath(ProxyExchange<byte[]> proxy) {
        String serviceToFail = "";
        if (serviceToFail != null) {
            BaggageField.getByName(FAILURE_INJECTION_BAGGAGE)
              .updateValue(serviceToFail);
        }

        String path = proxy.path("/proxy/path/");
        return proxy.uri(home.toString() + "/foos/" + path).get();
    }
}

然后,将一个入站请求过滤器(在这种情况下是一个 WebFlux WebFilter)添加到可能参与故障注入测试的所有微服务中,如 示例 3-9 所示。

示例 3-9. WebFlux WebFilter 用于故障注入测试
@Component
public class FailureInjectionTestingHandlerFilterFunction implements WebFilter {
    @Value("${spring.application.name}")
    private String serviceName;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        if (serviceName.equals(BaggageField.getByName("failure.injection")
              .getValue())) {
            exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
            return Mono.empty();
        }

        return chain.filter(exchange);
    }
}

我们还可以将故障注入测试决策作为 HTTP 客户端指标的一个标签添加,如 示例 3-10 所示。将故障注入测试从我们对与下游服务的 HTTP 客户端交互的错误比率的概念中过滤掉可能是有用的。或者,它们被保留下来以提醒标准来验证对意外故障的警觉和响应的工程学纪律,但数据仍然存在,以便调查工程师可以维度下钻以确定警报是由故障注入引起还是由真实问题引起。

示例 3-10. 将故障注入测试决策作为 Micrometer 标签添加
@Component
public class FailureInjectionWebfluxTags extends DefaultWebFluxTagsProvider {
    @Value("${spring.application.name}")
    private String serviceName;

    @Override
    public Iterable<Tag> httpRequestTags(ServerWebExchange exchange, Throwable ex) {
        return Tags.concat(
                super.httpRequestTags(exchange, ex),
                "failure.injection",
                serviceName.equals(BaggageField
                  .getByName("failure.injection").getValue()) ? "true" : "false"
        );
    }
}

当然,这只是一个草图。由你决定如何定义故障注入服务,以及在什么条件下选择注入故障的请求。对于一组简单的规则,这种服务甚至可以成为你网关应用的一个组成部分。

除了故障注入外,跟踪 "baggage" 还可以用于传播有关请求是否参与 A/B 实验的决策。

总结

在本章中,我们展示了监控可用性与监控调试之间的区别。调试信号的事件驱动特性意味着它们倾向于随系统吞吐量的增加而成比例增长,因此需要一种控制成本的限制措施。讨论了控制成本的不同采样方法。调试信号通常被采样的事实应该使我们对试图围绕它们构建聚合操作产生疑虑,因为每种采样形式都会丢弃某些分布的部分,从而使聚合结果产生某种形式的偏差。

最后,我们展示了除了在发布调试信息方面的主要功能外,我们还可以利用跟踪上下文传播来在深度微服务调用链中传播行为。

在接下来的章节中,我们将回到指标的话题,展示你应该从哪些可用性信号开始,这对于几乎每个 Java 微服务都是基础。

第四章:绘图和告警

监控并不一定要全面投入。如果你只在你没有监控(或只有 CPU/内存利用率等资源监控)的终端用户交互中添加一个错误比率的度量,那么在理解你的软件方面,你已经迈出了一大步。毕竟,CPU 和内存可能看起来不错,但用户接口的 API 在所有请求中失败了 5%,失败率在工程组织和业务合作伙伴之间沟通起来要容易得多。

虽然第 2 和 3 章节涵盖了不同形式的监控仪器化,但在这里我们提出了如何有效地利用这些数据通过告警和可视化来促进行动。本章涵盖了三个主要主题。

首先,我们应该思考一个好的 SLI 可视化是什么样的。我们只会展示来自常用的Grafana绘图和告警工具的图表,因为它是一个免费提供的开源工具,支持许多不同监控系统的数据源插件(因此从一个监控系统到另一个监控系统学习一些 Grafana 是一个非常可转移的技能)。许多相同的建议也适用于集成到供应商产品中的绘图解决方案。

接下来,我们将讨论生成最大价值的测量数据的具体内容,以及如何对它们进行可视化和告警。将其视为你可以逐步添加的 SLI(服务级别指标)清单。逐步增加可能甚至优于一次性实施它们,因为逐个添加指标,你可以真正研究并理解它在你的业务背景下的含义,并进行微小调整以为你带来最大的价值。如果我走进一个保险公司的网络操作中心,我会更加放心地看到只有关于保单评级和提交错误比率的指标,而不是看到一百个低级信号和没有业务表现度量。

引入告警的增量方法也是一个建立信任的重要过程。过快引入过多的告警会导致工程师不堪重负,产生“告警疲劳”。你希望工程师能舒适地订阅更多的告警,而不是将它们静音!如果你还不习惯于值班流程,逐一培训工程师如何应对一个告警条件,有助于团队建立对如何处理异常的知识储备。

因此,本章的重点将是提供关于那些与业务绩效(例如 API 失败率和用户看到的响应时间)尽可能接近的 SLI 的建议,而不与任何特定业务联系起来。在我们涵盖像堆使用或文件描述符之类的内容时,它们将是最有可能直接导致业务绩效下降的一组选择性指标。

重新创建 NASA 的任务控制中心(图 4-1)不应该是良好监控的分布式系统的最终结果。尽管在墙上排列屏幕并填充它们与仪表盘可能看起来很震撼,但屏幕不是行动。他们需要有人关注以响应问题的视觉指示器。当你监控一个火箭的单个实例,成本高昂,人命关天时,这是有道理的。当然,您的 API 请求没有相同的重要性。

srej 0401

图 4-1. 这不是一个好的榜样!

几乎每个指标收集器都会在任何给定时间收集比您发现有用的更多的数据。虽然每个指标在某些情况下可能有用,但绘制每个指标并不有助于。然而,几个指标(例如最大延迟、错误比率、资源利用率)对于几乎每个 Java 微服务都是强大的可靠性信号(通过调整警报阈值)。这些是我们将重点关注的内容。

最后,市场渴望将人工智能方法应用于监控数据,以自动提供对系统的洞察,而无需过多理解警报标准和关键绩效指标。在本章中,我们将在应用监控的背景下调查几种传统统计方法和人工智能方法。您应该对每种方法的优势和劣势有扎实的了解,以便能够洞悉市场宣传,并为您的需求应用最佳方法。

在进一步之前,值得考虑市场上监控系统的广泛变化以及这对于如何仪器化代码并将数据传递给这些系统的决策的影响。

监控系统的差异

在这里讨论监控系统的差异的要点在于,我们将看到如何使用 Prometheus 进行图表绘制和警报的具体内容。像 Datadog 这样的产品与 Prometheus 的查询系统非常不同。两者都很有用。未来将会出现更多具有我们尚未想象到的功能的产品。理想情况下,我们希望我们的监控工具(我们将放入应用程序中的内容)在这些监控系统中是可移植的,不需要更改应用程序代码(除了新的二进制依赖项和一些全局配置)。

分布式跟踪后端系统接收数据的方式比指标系统更具一致性。分布式跟踪仪表化库可能具有不同的传播格式,需要在整个堆栈中选择一致的仪表化库,但数据本身在后端之间基本相似。这在直观上是有道理的,因为数据本质上是分布式跟踪事件的时间信息(通过跟踪 ID 在上下文中粘合在一起)。

指标系统不仅可能表示聚合的计时信息,还可能表示仪表、计数器、直方图数据、百分位数等。它们对于数据聚合的方式并不一致。它们在查询时执行进一步聚合或计算的能力也不同。仪表化库需要发布的时间序列数量与特定指标后端的查询能力之间存在反向关系,如图 4-2 所示。

srej 0402

图 4-2. 发布时间序列与查询能力之间的反向关系

当初开发 Dropwizard Metrics 时,流行的监控系统是 Graphite,它不像 Prometheus 这样的现代监控系统具有速率计算功能。因此,当发布计数器时,Dropwizard 必须发布累计计数、1 分钟速率、5 分钟速率、15 分钟速率等。因为如果你从不需要查看速率,这样做就显得效率低下,所以仪表化库本身区分了@Counted@Metered。仪表化 API 的设计考虑了当代监控系统的能力。

快进到今天,一个意图发布到多个目标指标系统的指标仪表化库需要意识到这些微妙之处。Micrometer 的 Counter 将以累计计数和几个移动速率的形式呈现给 Graphite,但对于 Prometheus,仅作为累计计数,因为这些速率可以在查询时使用 PromQL 的 rate 函数计算。

对于任何仪表化库的 API 设计而言,今天并不简单地提升早期实现中找到的所有概念,而是要考虑这些结构在当时存在的历史背景。图 4-3 显示 Micrometer 在与 Dropwizard 和 Prometheus 简单客户端前身的重叠以及超出其前身能力的扩展能力之处。显著的是,某些概念已被舍弃,认识到监控空间的进化。在某些情况下,这种差异是微妙的。Micrometer 将直方图作为普通 Timer(或 DistributionSummary)的特性整合进去。在一个库深处进行仪表化的时候,很难清楚地知道应用是否将这个操作视为足够关键,值得支付额外费用来传送直方图数据。(因此,这个决定应留给下游应用程序的作者,而不是库的作者。)

srej 0403

图 4-3. 指标仪表化能力重叠

类似地,在 Dropwizard Metrics 时代,监控系统并不包括可帮助推理计时数据的查询功能(无百分位近似,无延迟热力图等)。因此,“不要衡量可以计数的东西,不要计数可以计时的东西”这一概念尚不适用。将 @Counted 添加到方法中并不罕见,而现在 @Counted 几乎从不是方法的正确选择(方法本质上是可以计时的,并且计时器始终以计数方式发布)。

虽然在撰写本文时 OpenTelemetry 的指标 API 仍处于 beta 阶段,但在过去几年里它并未发生实质性变化,而且看起来仪表基元无法足够有效地构建用于计时和计数的可用抽象。示例 4-1 展示了一个带有不同标签的 Micrometer Timer,取决于操作的结果(这是 Micrometer 中计时器最详细的描述方式)。

示例 4-1. 带有可变结果标签的 Micrometer 计时器
public class MyService {
  MeterRegistry registry;

  public void call() {
    try (Timer.ResourceSample t = Timer.resource(registry, "calls")
        .description("calls to something")
        .publishPercentileHistogram()
        .serviceLevelObjectives(Duration.ofSeconds(1))
        .tags("service", "hi")) {
      try {
        // Do something
        t.tag("outcome", "success");
      } catch (Exception e) {
        t.tags("outcome", "error", "exception", e.getClass().getName());
      }
    }
  }
}

即使尝试使用 OpenTelemetry 指标 API 接近此功能也很困难,如 示例 4-2 所示。尚未尝试记录类似百分位直方图或 Micrometer 等效中的 SLO 边界计数。这显然会大大增加此实现的冗长性,而实现已经变得相当冗长。

示例 4-2. 使用可变结果标签的 OpenTelemetry 定时
public class MyService {
  Meter meter = OpenTelemetry.getMeter("registry");
  Map<String, AtomicLong> callSum = Map.of(
      "success", new AtomicLong(0),
      "failure", new AtomicLong(0)
  );

  public MyService() {
    registerCallSum("success");
    registerCallSum("failure");
  }

  private void registerCallSum(String outcome) {
    meter.doubleSumObserverBuilder("calls.sum")
        .setDescription("calls to something")
        .setConstantLabels(Map.of("service", "hi"))
        .build()
        .setCallback(result -> result.observe(
            (double) callSum.get(outcome).get() / 1e9,
            "outcome", outcome));
  }

  public void call() {
    DoubleCounter.Builder callCounter = meter
        .doubleCounterBuilder("calls.count")
        .setDescription("calls to something")
        .setConstantLabels(Map.of("service", "hi"))
        .setUnit("requests");

    long start = System.nanoTime();
    try {
      // Do something
      callCounter.build().add(1, "outcome", "success");
      callSum.get("success").addAndGet(System.nanoTime() - start);
    } catch (Exception e) {
      callCounter.build().add(1, "outcome", "failure",
          "exception", e.getClass().getName());
      callSum.get("failure").addAndGet(System.nanoTime() - start);
    }
  }
}

我认为 OpenTelemetry 的问题在于强调多语言支持,这自然会给项目带来压力,希望为“double sum observer”或“double counter”等计量原语定义一致的数据结构。这对最终用户的 API 造成的影响迫使他们从低级构建块组合成高级抽象的组成部分,比如 Micrometer 的Timer。这不仅导致仪器化代码异常冗长,还导致仪器化针对特定监控系统的问题。例如,如果我们试图将计数器发布到旧的监控系统(如 Graphite),而逐步迁移到 Prometheus,则需要显式计算每个间隔的移动速率并进行传送。然而,“double counter”数据结构无法支持这一点。反向问题也存在,即为了满足最广泛的监控系统,需要在 OpenTelemetry 数据结构中包含“double counter”的所有可能可用统计数据的联合,尽管将这些额外的数据发送到现代指标后端是纯粹的浪费。

当你开始探索图表和警报时,你可能想尝试不同的后端。根据你目前的知识做出选择时,也许一年后你会更有经验。确保你的指标仪器化允许你在不同的监控系统之间流畅切换(甚至在过渡期间同时发布到两者)。

在我们深入讨论任何特定的 SLI 之前,让我们先来看看什么样的图表才能有效。

服务水平指标的有效可视化

这里提供的建议自然是主观的。我倾向于更加粗线和图表上较少的“墨水”,这两者都偏离了 Grafana 的默认设置。老实说,我有点尴尬提出这些建议,因为我不想假定我的审美感比 Grafana 设计团队的优秀设计更高一些。

我将提供的风格感知源于我过去几年工作中的两个重要影响:

看工程师盯着图表凝视和皱眉

当工程师看着图表皱着眉头时,我会感到担忧。尤其担心的是,他们从过于复杂的可视化中得出的教训是监控本身很复杂,也许对他们来说太复杂了。当这些指标在正确呈现时,大多数其实是非常简单的。它们应该给人一种这样的感觉。

定量信息的视觉展示

曾经有一段时间,我问我遇到的每一位专注于运维工程和开发者体验的用户体验设计师同行同一个问题:哪本书对他们影响最大?《定量信息的视觉显示》(Edward Tufte,Graphics Press)总是他们的答案之一。来自这本书对时间序列可视化最相关的想法之一是“数据墨比率”,特别是尽可能增加它。如果图表上的“墨水”(或像素)并未传达信息,则传达的是复杂性。复杂性导致眯眼看。眯眼看会让我担心。

让我们从这个角度来考虑,数据-墨比率需要增加。接下来的具体建议改变了 Grafana 的默认样式,以最大化这一比率。

线宽和阴影风格

Grafana 的默认图表包含 1 像素的实线、线下 10% 的透明填充以及时间片段之间的插值。为了提高可读性,将实线宽度增加到 2 像素并移除填充。填充降低了图表的数据-墨比率,多个图表线条的填充颜色重叠会使人迷失方向。插值有些误导,因为它向普通观察者暗示值可能在两个时间片段之间的对角线上短暂存在。在 Grafana 的选项中,插值的相反称为“步进”。图 4-4 顶部的图表使用默认选项,底部的图表根据这些建议进行了调整。

srej 0404

第 4-4 图。Grafana 图表样式的默认与推荐

在图表编辑器的“可视化”选项卡中更改选项,如图 4-5 所示。

srej 0405

第 4-5 图。Grafana 线宽选项

错误与成功

对于计时器,绘制成功和错误等结果的堆叠表示非常常见,我们将在“Errors”中看到,并且在其他场景中也会出现。当我们将成功和错误视为颜色时,许多人会立即想到绿色和红色:交通灯颜色。不幸的是,大部分人口中存在色盲,影响他们区分颜色的能力。对于最常见的变色性视觉障碍,绿色和红色之间的差异很难或根本无法区分!那些受单色视觉障碍影响的人根本无法区分颜色,只能区分亮度。由于本书是单色印刷,我们所有人都可以在短暂的时间内体验一下堆叠的错误和成功图表,见图 4-6。

srej 0406

图 4-6. 使用不同的线条样式显示辅助功能中的错误

我们需要提供某种错误与成功的视觉指示器,而不仅仅是严格的颜色。在这种情况下,我们选择将“成功”结果绘制为堆叠线,将错误绘制在这些结果上方作为粗点以使其突出显示。

此外,Grafana 没有提供指定时间序列在堆叠表示中出现顺序(即“成功”在堆叠底部或顶部)的选项,即使是针对一组可能值的有限集合也是如此。我们可以通过选择每个值在单独的查询中,并对查询本身进行排序来强制对它们进行排序,如图 4-7 所示。

srej 0407

图 4-7. 在 Grafana 堆叠表示中排序结果

最后,我们可以覆盖每个单独查询的样式,如图 4-8 所示。

srej 0408

图 4-8. 为每个结果覆盖线条样式

“Top k” 可视化

在许多情况下,我们希望按某个类别显示一些“最差”表现的指示器。许多监控系统提供某种查询功能,以选择某些标准的“top k”时间序列。然而,选择“top 3”最差表现者并不意味着图表上会有最多三条线,因为这场“到底”的竞赛是永无止境的,而且最差表现者在图表可视化的时间间隔内可能会发生变化。在最坏的情况下,您将在特定可视化中显示N个数据点,并且将显示 3**N*个不同的时间序列!如果您在图 4-9 的任何部分绘制垂直线,并计算它所相交的唯一颜色数量,它将始终小于或等于三,因为此图是使用“top 3”查询构建的。但是图例中有六个项目。

srej 0409

图 4-9. 具有超过 k 个不同时间序列的 top k 可视化

它可以很容易地变得比这更加繁忙。考虑图 4-10,它显示了一段时间内前五个最长的 Gradle 构建任务时间。由于显示的时间片段内运行的构建任务集合会快速变化,所以图例中填充的值会比简单的五个值多得多。

srej 0410

图 4-10. Top k 仍然可以在图例中产生比 k 更多的项目

在这种情况下,图例被标签压倒,以至于无法辨认。使用 Grafana 选项将图例移到右侧的表格,并添加一个“最大值”之类的摘要统计数据,如图 4-11 所示。然后,您可以点击表格中的摘要统计数据,按此统计数据对图例作为表格进行排序。现在,当我们查看图表时,我们可以快速看出在我们查看的时间范围内哪些表现最差。

srej 0411

图 4-11. 为每个结果覆盖线条样式

Prometheus 速率间隔选择

在本章中,我们将看到使用范围向量的 Prometheus 查询。我强烈建议使用至少是抓取间隔的两倍长的范围向量(默认为一分钟)。否则,由于抓取时间的轻微变化可能导致相邻数据点间隔略大于抓取间隔,您可能会错过数据点。类似地,如果服务重新启动且数据点丢失,速率函数将无法在间隙或下一个数据点之间进行速率计算,直到间隔包含至少两个点。使用更长的间隔可避免这些问题。由于应用程序的启动可能比抓取间隔长,具体取决于您的应用程序,如果完全避免间隙对您很重要,您可以选择比两倍抓取间隔更长的范围向量(实际上更接近应用程序启动加两个间隔的任何内容)。

范围向量在 Prometheus 中是一个相对独特的概念,但在其他监控系统中的其他上下文中也适用相同的原理。例如,如果您在警报上设置了最小阈值,并且由于应用程序重新启动而可能出现间隙,则需要构建“间隔内的最小值”类型的查询以进行补偿。

计量器

计量器的时间序列表示比即时计量器提供更多关于信息的紧凑表示。当线路穿过警报阈值时,它同样明显,并且有关计量器先前值的历史信息提供了有用的上下文。因此,在图 4-12 中,底部图表更可取。

srej 0412

图 4-12. 更倾向于使用线图而不是即时计量器

计量器往往呈现尖峰。线程池可能出现短时间接近枯竭的情况,然后恢复。队列会变满然后清空。在 Java 中,内存利用尤其棘手,因为短期分配可能迅速填满分配的大部分空间,但垃圾收集可能会清除大部分消耗。

限制警报频繁性的最有效方法之一是使用滚动计数功能,其结果显示在图 4-13 中(part0009_split_007.html#rolling_count)。通过这种方式,我们可以定义一个只有在过去五个间隔中超过三次超过阈值时才触发警报的警报,或者某种频率和回顾间隔数量的组合。回顾的时间越长,警报首次触发前的时间就越长,因此在寻找关键指标时,不要回顾得太久。

只有当问题明显发展时,警报才会触发。

图 4-13. 滚动计数以限制警报频繁性

作为瞬时值,计量器基本上只是在每个监控系统上直接绘制成图形。计数器稍微复杂一些。

计数器

计数器经常针对最大(或较少时是最小)阈值进行测试。对阈值进行测试的需要强化了计数器应被视为速率而不是累积统计数据的想法,无论统计数据如何在监控系统中存储。

图 4-14 显示了一个 HTTP 端点的请求吞吐量作为速率(黄色实线),还显示了自应用进程启动以来对该端点的所有请求的累积计数(绿色点)。图表还显示了在该端点吞吐量上设置的固定最小阈值警报(红色线和区域),阈值设定为每秒 1,000 次请求。这个阈值在相对于速率表示的吞吐量时是有意义的(在此窗口中速率在每秒 1,500 到 2,000 次请求之间变化)。但对累积计数来说意义不大,因为累积计数实际上是吞吐率和进程的长期性的度量。进程的长期性对这个警报来说是不相关的。

警报阈值只在考虑速率时才有意义。

图 4-14. 具有速率最小阈值警报的计数器,并显示累积计数

有时固定阈值很难事先确定。此外,事件发生的速率可能会周期性地波动,例如根据高峰和低峰的业务时间。这在像每秒请求数这样的吞吐量测量中特别常见,如图 4-15 所示(part0009_split_008.html#counter_dynamic_threshold)。如果我们在此服务上设置一个固定阈值,以便检测到流量突然未达到服务的情况(最小阈值),我们将不得不将其设置在此服务看到的最低吞吐量 40 RPS 以下的某个位置。假设最小阈值设置为 30 RPS。这个警报将在业务低峰期流量低于预期值的 75% 时触发,但只有在业务高峰期流量低于预期值的 10% 时才触发!在所有时期,警报阈值的价值并不相等。

在高峰时段,固定阈值无法快速检测到请求每秒的突然变化。

图 4-15. 根据一天中的时间增加流量的服务

在这些情况下,考虑以寻找速率的急剧增加或减少的方式设置警报。一个很好的一般方法是在 图 4-16 中看到的,即取计数器速率,应用平滑函数,并将平滑函数乘以某个因子(例如例子中的 85%)。因为平滑函数自然需要一点时间来对速率的突然变化做出响应,所以检测计数器速率是否低于平滑线,可以在完全不知道预期速率的情况下检测到突然变化。关于动态警报使用平滑统计方法的更详细解释,请参见 “使用预测方法构建警报”。

当吞吐量突然下降时触发警报。

图 4-16. 具有双指数平滑阈值的计数器,形成动态警报阈值

在 Micrometer 中,将数据发送到您选择的监控系统是其责任,以便您可以在图表中绘制计数器的速率表示。在 Atlas 的情况下,计数器已经以速率标准化的方式发送,因此对计数器的查询已经返回可以直接绘制的速率值,如 示例 4-3 所示。

示例 4-3. Atlas 计数器已经是速率,因此选择它们可以绘制速率
name,cache.gets,:eq,

其他监控系统期望将累积值发送到监控系统,并在查询时包含某种速率函数。 示例 4-4 将显示与 Atlas 相似的速率线,具体取决于您选择的范围向量([] 中的时间段)。

示例 4-4. Prometheus 计数器是累积的,因此我们需要显式将其转换为速率
rate(cache_gets[2m])

Prometheus 速率函数存在一个问题:当图表时间域内快速添加新的标签值时,Prometheus 速率函数可能生成 NaN 值,而不是零。在 图 4-17 中,我们绘制了随时间变化的 Gradle 构建任务吞吐量。由于在此窗口中,构建任务由项目和任务名称唯一描述,并且一旦任务完成,它就不会再递增,因此在图表选择的时间域内会产生几个新的时间序列。

srej 0417

图 4-17. 当图表时间域内快速添加新的标签值时,填充 Prometheus 计数器速率为零

在 示例 4-5 中的查询显示了我们可以用来填补间隙的方法。

示例 4-5. 将 Prometheus 计数器速率填零的查询
sum(gradle_task_seconds_count) by (gradle_root_project_name) -
(
  sum(gradle_task_seconds_count offset 10s) by (gradle_root_project_name) > 0 or
  (
    (sum(gradle_task_seconds_count) by (gradle_root_project_name)) * 0
  )
)

如何绘制计数器图表在不同的监控系统中略有不同。有时我们必须明确创建速率,有时计数器从一开始就存储为速率。计时器甚至有更多选项。

计时器

Timer Micrometer 米生成各种不同的时间序列,只需一个操作即可。用计时器包装代码块(timer.record(() -> { ... }))就足以收集有关此块的吞吐量数据,最大延迟(随时间衰减),总延迟总和,以及可选的其他分布统计数据,如直方图、百分位数和 SLO 边界。

在仪表板上,延迟是最重要的要查看的,因为它与用户体验直接相关。毕竟,用户主要关心他们的个别请求的性能。他们对系统能够达到的总吞吐量几乎没有关心,除非在某种程度上,某个吞吐量水平会影响其响应时间。

次要地,如果预期流量有一定的形状(可能是基于业务时间、客户时区等的周期性流量),可以包括吞吐量。例如,在预期高峰期间吞吐量的急剧下降可能是系统问题的强有力指标,即本应到达系统的流量未到达系统。

对于许多情况,最好将警报设置在最大延迟上(在这种情况下,意味着每个间隔的最大观察值),并使用高百分位数近似值进行比较分析(见“自动金丝雀分析”)。

在最大延迟上设置计时器警报

Java 应用程序中,最大延迟常常比第 99 百分位差一个数量级。最好将警报设置在最大延迟上。

直到我离开 Netflix 并由 Gil Tene 引入了一个有力的论点,我才发现甚至衡量最大延迟的重要性。他对最坏情况做了一个特别深刻的观点,类比起搏器的性能,并强调“‘你的心脏将在 99.9%的时间内保持跳动’并不令人放心”。作为一个喜欢有理有据的论点的人,我及时在 2017 年的 SpringOne 会议上将最大延迟作为由 Micrometer TimerDistributionSummary实现的关键统计数据推出。在那里,我遇到了一个来自 Netflix 的前同事,并羞怯地提出了这个新想法,意识到 Netflix 实际上并没有监控最大延迟。他立即笑掉,走了一场演讲,让我有些泄气。不久之后,我收到了他的消息,附上了图表,显示了一项关键内部 Netflix 服务上最大延迟比 P99 延迟差一个数量级(他仅仅为了测试这一假设而将最大延迟添加到了这个服务中)。

srej 0418

图 4-18. Netflix 日志服务中的最大与 P99 延迟(单位:纳秒)

更令人惊讶的是,Netflix 最近经历了一次架构转变,使得 P99 稍微好了一点,但最大延迟显著恶化!很容易辩论说实际上这次变动使事情变得更糟。我珍视这段互动的记忆,因为它生动地说明了每个组织都有可以从其他地方学习的东西:在这种情况下,Netflix 的高度复杂的监控文化从 Domo 那里学到了一个技巧,而 Domo 则是从 Azul Systems 那里学到的。

在 图 4-19 中,我们看到了最大和第 99 百分位数之间数量级的差异。响应延迟通常紧密围绕第 99 百分位数,至少有一个独立的分组接近最大值,反映了垃圾收集、虚拟机暂停等。

最大延迟和 P99 延迟的比较

图 4-19. 最大与 P99 延迟

在 图 4-20 中,一个真实的服务展示了一个特征,即平均值浮动在第 99 百分位数之上,因为请求在第 99 百分位数周围非常密集。

srej 0420

图 4-20. 平均延迟对比 P99 延迟

尽管这个前 1%看起来微不足道,但实际用户会受到这些延迟的影响,因此重要的是要认识到这个边界,并在需要时进行补偿。识别限制前 1%效果的一种认可方法是一种称为“对冲请求”的客户端负载平衡策略(参见“对冲请求”)。

对最大延迟设置警报至关重要(我们将在“延迟”中更详细地讨论原因)。但是,一旦工程师收到问题的警报,他们用于开始理解问题的仪表板不一定需要有这个指标。看到延迟分布作为热度图会更有用(如图 4-21 所示),其中包括导致警报的最大值所在的非零桶,以了解问题相对于该时间系统中传递的规范请求的重要程度。在热度图可视化中,每个垂直列代表一个特定时间切片上的直方图(参考“直方图”的定义)。彩色方框表示在y轴上定义的时间范围内的延迟频率。因此,终端用户正在经历的规范延迟应该看起来“热”,而异常值则看起来较“冷”。

srej 0421

图 4-21. 计时器热度图

大多数请求是否接近最大值而失败,还是只有一个或几个偏离值?这个问题的答案可能会影响警报工程师升级问题并寻求他人帮助的速度。不需要在诊断仪表板上绘制最大值和热度图,如图 4-22 所示。只需包含热度图即可。

srej 0422

图 4-22. 最大延迟与延迟分布热度图

延迟热度图的绘制也很昂贵,因为它涉及检索每个时间切片上可能的几十个或几百个桶(这些桶是监控系统中的单独时间序列),总量通常达到成千上万个时间序列。这强调了在墙上醒目的显示屏上自动更新此图表没有理由的观点。允许警报系统完成其工作,并根据需要查看仪表板,以限制对监控系统的负载。

有用表示工具箱现在已经发展到需要谨慎使用的程度。

何时停止创建仪表板

我在 2019 年拜访了一位曾经的同事,他现在是 Datadog 的运营副总裁。他感叹道,具有讽刺意味的是,由客户构建的仪表板缺乏健康的调节是 Datadog 面临的关键容量问题之一。想象一下,世界各地布满了计算机屏幕和电视显示器,每个都自动以规定的间隔刷新一系列漂亮的图表。我发现这是一个非常迷人的业务问题,因为显然,大量显示 Datadog 品牌的电视显示器可以提高产品的可见性和吸引力,同时又给 SaaS 产品带来了运营噩梦。

我总是觉得“任务控制”仪表板视图有点奇怪。毕竟,图表上的什么视觉指示我存在问题?如果是急剧的尖峰、深谷,或者简单地超出所有合理预期的数值,那么可以创建一个警报阈值来定义不可接受的点,可以自动监视指标(全天候)。

作为值班工程师,收到带有即时指标可视化的警报(或指向其的链接)是件好事。最终,当我们打开警报时,我们希望深入挖掘信息,找出根本原因(或有时确定警报不值得关注)。如果警报链接到一个仪表板,理想情况下,该仪表板应配置成允许立即展开或探索维度。换句话说,电视显示仪表板把人类视为一种低注意力跨度、众所周知不可靠的警报系统。

用于警报的可视化可能在仪表板上一点用也没有,并非所有仪表板上的图表都可以构建警报。例如,图 4-22 展示了同一个计时器的两种表示方式:衰减的最大值和热图。警报系统将观察最大值,但当工程师被警报到异常情况时,看到该时间点周围延迟的分布更有用,以了解影响的严重程度(最大值应该被捕获在热图上可见的延迟桶中)。

不过,要注意构建这些查询的方式!如果你仔细观察,你会发现热图周围没有 15 毫秒的延迟。在这种情况下,Prometheus 的范围向量太接近抓取间隔,结果是图表中短暂的不可见间隙隐藏了 15 毫秒的延迟!由于 Micrometer 在最大图表上衰减,我们仍然可以在最大图表上看到它。

热图的计算成本要比简单的最大线条高得多。对于一个图表来说这没问题,但是在大型组织的各个业务单位中多次展示,这可能会对监控系统本身造成负担。

图表不能替代警报。首先专注于在超出可接受水平时将其作为警报交付给合适的人员,而不是匆忙设置监视器。

提示

人类不断地盯着监视器,这只是一个昂贵的视觉警报系统,用来视觉检测不可接受的水平。

警报应该以一种方式传递给值班人员,使他们能够快速跳转到仪表板,并开始针对失败的指标维度进行深入分析,找出问题所在。

并非每一个 SLO 的警报或违反情况都需要被视为停机紧急情况。

每个 Java 微服务的服务水平指标

现在我们已经知道了如何在图表上直观地呈现 SLI,我们将把注意力转向您可以添加的指标。它们以大致的重要性顺序呈现。因此,如果您正在遵循逐步添加图表和警报的方法,请按顺序实施这些操作。

错误

在计时一段代码块时,区分成功和不成功的操作是有两个原因的。

首先,我们可以直接使用失败与总计时的比率作为系统中错误发生频率的衡量标准。

此外,成功和失败的结果在响应时间上可能有根本不同的差异,这取决于失败模式。例如,由于对请求输入中某些数据的存在做出了错误假设而导致的NullPointerException可能会在请求处理程序中早期失败。然后,它并没有足够的进展来调用其他下游服务,与数据库交互等等,而当请求成功时,系统的大部分时间都会花在这些地方。在这种情况下,以这种方式失败的不成功请求将扭曲我们对系统延迟的看法。延迟实际上会显得比实际情况要好!另一方面,使对另一个微服务进行阻塞下游请求的请求处理程序最终超时的微服务可能表现出比正常情况高得多的延迟(接近进行调用的 HTTP 客户端的超时)。通过不区分错误,我们呈现了对系统延迟的过度悲观的看法。

状态标签(参见“命名指标”)在大多数情况下应该分两个级别添加到计时器中。

状态

提供失败模式的详细错误代码、异常名称或其他特定指标的标签

结果

提供更粗粒度的错误类别的标签,将成功、用户引起的错误和服务引起的错误分开

在编写警报时,与其尝试通过匹配状态代码模式(例如,使用 Prometheus 的非正则表达式标签选择器进行status !~"2..")来选择标签,不如在结果标签上执行精确匹配(outcome="SERVER_ERROR")。通过选择“非 2xx”,我们将服务器错误(如常见的 HTTP 500 内部服务器错误)与用户引起的错误(如 HTTP 400 错误的请求或 HTTP 403 禁止)分组。HTTP 400 的高速率可能表明您最近发布的代码包含 API 中的意外向后不兼容性,或者可能表明新的终端用户(例如,其他上游微服务)正在尝试开始使用您的服务并且尚未正确传递有效载荷。

Panera 面临的唠叨警报未能区分客户端和服务器错误

Panera Bread, Inc. 面临其监控系统供应商实现的异常检测器的过度活跃警报,用于 HTTP 错误。因为单个用户五次提供错误密码,导致一天中收到多封电子邮件警报。工程师们发现异常检测器未区分客户端和服务器错误比率!客户端错误比率的警报可能对入侵检测很有用,但阈值会比服务器错误比率高得多(当然比短时间内的五次错误高得多)。

HTTP 500 几乎总是服务所有者的责任,需要关注。在最好的情况下,HTTP 500 表明哪里可以进行更多的前端验证,而不是直接给终端用户一个有用的 HTTP 400。我认为“HTTP 500—Internal Server Error”太被动了。类似“HTTP 500—对不起,这是我的错”听起来更好。

当你编写自己的定时器时,常见的模式涉及使用 Timer 示例,并推迟标签的确定,直到已知请求是否成功或失败,例如在 Example 4-6 中。该示例保存了操作开始的时间状态。

Example 4-6. 根据操作结果动态确定错误和结果标签
Timer.Sample sample = Timer.start();
try {
  // Some operation that might fail... 
  sample.stop(
    registry.timer(
      "my.operation",
      Tags.of(
        "exception", "none", ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
        "outcome", "success"
      )
    )
  );
} catch(Exception e) {
  sample.stop(
    registry.timer(
      "my.operation",
      Tags.of(
        "exception", e.getClass().getName(), ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
        "outcome", "failure"
      )
    )
  );
}

1

一些监控系统(如 Prometheus)期望在具有相同名称的度量标准上出现一致的标签键集。因此,即使在这里没有异常,我们也应该使用一些占位符值(如“none”),以反映在失败情况下存在的标签。

2

或许你有更好的方法来更好地分类失败条件,并提供一个更具描述性的标签值,但即使添加异常类名也可以大大增加对失败类型的理解。NullPointerException和调用下游服务时出现的连接超时处理不当是两种非常不同的异常类型。当错误比例上升时,能够深入了解异常名称对于快速了解错误非常有用。通过异常名称,你可以快速转到调试和观测工具,如日志,并搜索在警报条件发生时异常名称的出现情况。

在使用 Class.getSimpleName() 等作为标签值时要小心

要注意,Class.getSimpleName()Class.getCanonicalName() 可能返回 null 或空值,例如匿名类实例的情况。如果你将它们之一作为标签值使用,至少要对空值进行检查,并回退到使用 Class.getName()

对于 HTTP 请求指标,例如,Spring Boot 会自动使用status标签表示 HTTP 状态码,并且使用outcome标签表示SUCCESSCLIENT_ERRORSERVER_ERROR之一。

基于此标签,可以绘制每个时间间隔的错误 。错误率在相同的失败条件下可能会剧烈波动,具体取决于系统的流量量。

对于 Atlas,请使用:and运算符仅选择SERVER_ERROR结果,如示例 4-7 所示。

示例 4-7. Atlas 中 HTTP 服务器请求的错误率
# don't do this because it fluctuates with throughput!
name,http.server.requests,:eq,
outcome,SERVER_ERROR,:eq,
:and,
uri,$ENDPOINT,:eq,:cq

对于 Prometheus,请使用标签选择器,如示例 4-8 所示。

示例 4-8. Prometheus 中 HTTP 服务器请求的错误率
# don't do this because it fluctuates with throughput!
sum(
  rate(
    http_server_requests_seconds_count{outcome="SERVER_ERROR", uri="$ENDPOINT"}[2m]
  )
)

如果每 10 个请求中有一个失败,并且系统每秒处理 100 个请求,则错误率为每秒 10 次失败。如果系统每秒处理 1,000 个请求,则错误率上升到每秒 100 次失败!在这两种情况下,相对于吞吐量的错误 比率 为 10%。此错误比率对速率进行了标准化,并且易于设置固定阈值。在图 4-23 中,尽管吞吐量和因此错误率激增,错误比率仍在 10-15%左右。

srej 0423

图 4-23. 错误比率与错误率

粗粒度的 outcome 标签用于构建代表定时操作错误比率的查询。对于http.server.requests,这是SERVER_ERROR与总请求数的比率。

对于 Atlas,请使用:div函数,将SERVER_ERROR结果按所有请求的总数进行划分,如示例 4-9 所示。

示例 4-9. Atlas 中 HTTP 服务器请求的错误比率
name,http.server.requests,:eq,
:dup,
outcome,SERVER_ERROR,:eq,
:div,
uri,$ENDPOINT,:eq,:cq

对于 Prometheus,请类似地使用/运算符,如示例 4-10 所示。

示例 4-10. Prometheus 中 HTTP 服务器请求的错误比率
sum(
  rate(
    http_server_requests_seconds_count{outcome="SERVER_ERROR", uri="$ENDPOINT"}[2m]
  )
) /
sum(
  rate(
    http_server_requests_seconds_count{uri="$ENDPOINT"}[2m]
  )
)

对于低吞吐量服务,错误率比错误比率更好

通常情况下,除非端点的吞吐量非常低,否则更倾向于使用错误比率,除非。在这种情况下,即使错误微小差异也可能导致错误比率发生剧烈变化。在这些情况下,选择一个固定的错误率阈值更为合适。

错误率和比率只是计时器的一种视图。延迟是另一个重要视角。

延迟

在此情况下,对最大延迟(每个间隔内观察到的最大值)进行警报,并使用高百分位数(例如第 99 百分位数)进行比较分析,如在 “自动金丝雀分析” 中所示。流行的 Java Web 框架作为其“白盒”(参见 “黑盒与白盒监控”)自动配置指标的一部分,通过丰富的标签对入站和出站请求进行仪表化。我将介绍 Spring Boot 对请求的自动仪表化的详细信息,但大多数其他流行的 Java Web 框架与 Micrometer 类似做了某种非常类似的事情。

服务器(入站)请求

Spring Boot 自动配置了一个名为 http.server.requests 的计时器指标,用于阻塞和响应式 REST 端点。如果特定端点的延迟是应用性能的关键指标,并且还将用于比较分析,则可以将 management.metrics.distribution.percentiles-histogram.http.server.requests=true 属性添加到您的 application.properties 中,以从您的应用程序导出百分位直方图。要更精细地启用特定一组 API 端点的百分位直方图,您可以像在 示例 4-11 中那样,在 Spring Boot 中添加 @Timed 注解。

示例 4-11. 使用 @Timed 为单个端点添加直方图
@Timed(histogram = true)
@GetMapping("/api/something")
Something getSomething() {
  ...
}

或者,您可以添加一个响应标签的 MeterFilter,如 示例 4-12 所示。

示例 4-12. 一个 MeterFilter,为特定端点添加百分位直方图
@Bean
MeterFilter histogramsForSomethingEndpoints() {
  return new MeterFilter() {
    @Override
    public DistributionStatisticConfig configure(Meter.Id id,
        DistributionStatisticConfig config) {
      if(id.getName().equals("http.server.requests") &&
          id.getTag("uri").startsWith("/api/something")) {
        return DistributionStatisticConfig.builder()
            .percentilesHistogram(true)
            .build()
            .merge(config);
      }
      return config;
    }
  };
}

对于 Atlas,示例 4-13 展示了如何将最大延迟与预定的阈值进行比较。

示例 4-13. Atlas 最大 API 延迟
name,http.server.requests,:eq,
statistic,max,:eq,
:and,
$THRESHOLD,
:gt

对于 Prometheus,示例 4-14 是一个简单的比较。

示例 4-14. Prometheus 最大 API 延迟
http_server_requests_seconds_max > $THRESHOLD

可以自定义添加到 http.server.requests 的标签。对于阻塞的 Spring WebMVC 模型,请使用 WebMvcTagsProvider。例如,我们可以从“User-Agent”请求头中提取有关浏览器及其版本的信息,如 示例 4-15 所示。此示例使用了 MIT 许可的 Browscap 库来从用户代理标头中提取浏览器信息。

示例 4-15. 将浏览器标签添加到 Spring WebMVC 指标中
@Configuration
public class MetricsConfiguration {
  @Bean
  WebMvcTagsProvider customizeRestMetrics() throws IOException, ParseException {
    UserAgentParser userAgentParser = new UserAgentService().loadParser();

    return new DefaultWebMvcTagsProvider() {
      @Override
      public Iterable<Tag> getTags(HttpServletRequest request,
        HttpServletResponse response, Object handler, Throwable exception) {

        Capabilities capabilities = userAgentParser.parse(request
          .getHeader("User-Agent"));

        return Tags
          .concat(
            super.getTags(request, response, handler, exception),
            "browser", capabilities.getBrowser(),
            "browser.version", capabilities.getBrowserMajorVersion()
          );
      }
    };
  }
}

对于 Spring WebFlux(非阻塞响应式模型),可以类似地配置 WebFluxTagsProvider,如 示例 4-16 所示。

示例 4-16. 将浏览器标签添加到 Spring WebFlux 指标中
@Configuration
public class MetricsConfiguration {
  @Bean
  WebFluxTagsProvider customizeRestMetrics() throws IOException, ParseException {
      UserAgentParser userAgentParser = new UserAgentService().loadParser();

      return new DefaultWebFluxTagsProvider() {
          @Override
          public Iterable<Tag> httpRequestTags(ServerWebExchange exchange,
              Throwable exception) {

            Capabilities capabilities = userAgentParser.parse(exchange.getRequest()
              .getHeaders().getFirst("User-Agent"));

            return Tags
              .concat(
                super.httpRequestTags(exchange, exception),
                "browser", capabilities.getBrowser(),
                "browser.version", capabilities.getBrowserMajorVersion()
              );
          }
      };
  }
}

请注意,http.server.requests 计时器仅在服务处理请求时开始计时。如果请求线程池经常处于容量限制状态,则用户的请求会在线程池中等待处理,这段时间对于等待响应的用户来说是非常真实的。http.server.requests 中缺失的信息是 Gil Tene 首次描述的一个更大问题的示例,称为协调省略(见“协调省略”),还有其他几种形式。

从调用方(客户端)的视角监控延迟也很有用。在这种情况下,我通常指的是服务对服务的调用者,而不是人类消费者对您的 API 网关或第一个服务交互。服务对其自身延迟的视图不包括网络延迟或线程池争用的影响(例如 Tomcat 的请求线程池或像 Nginx 这样的代理的线程池)。

客户端(出站)请求

Spring Boot 还会为阻塞和响应式 出站 调用自动配置一个名为 http.client.requests 的计时器指标。这使您可以从所有调用者的视角监控服务的延迟,只要它们每个都对所调用服务的名称达成相同的结论。图 4-24 显示了三个服务实例调用同一服务的情况。

srej 0424

图 4-24. 多个调用方的 HTTP 客户端指标

通过选择 uriserviceName 标签,我们可以确定被调服务特定端点的性能。通过对所有其他标签进行聚合,我们可以查看端点在所有调用者之间的性能。通过 clientName 标签进行维度下钻,可以显示服务从某个客户端的视角看到的性能。即使被调服务每次请求都以相同的时间处理,客户端视角也可能不同(例如,如果一个客户端部署在不同的区域或地域)。在客户端之间存在这种差异可能性的情况下,您可以使用类似 Prometheus 的 topk 查询来与警报阈值进行比较,以确保所有客户端对端点性能的整体体验不会因某些特定客户端的异常情况而被淹没,如示例 4-17 所示。

示例 4-17. 按客户端名称的最大出站请求延迟
topk(
  1,
  sum(
    rate(
      http_client_requests_seconds_max{serviceName="CALLED", uri="/api/..."}[2m]
    )
  ) by (clientName)
) > $THRESHOLD

要为 Spring 的 RestTemplate(阻塞)和 WebClient(非阻塞)接口自动配置 HTTP 客户端仪表化,您需要以特定的方式处理路径变量和请求参数。具体来说,您必须让实现为您执行路径变量和请求参数的替换,而不是使用字符串串联或类似技术来构建路径,如示例 4-18 所示。

示例 4-18. 允许 RestTemplate 处理路径变量替换
@RestController
public class CustomerController { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  private final RestTemplate client;

  public CustomerController(RestTemplate client) {
    this.client = client;
  }

  @GetMapping("/customers")
  public Customer findCustomer(@RequestParam String q) {
    String customerId;
    // ... Look up customer ID according to 'q' 
    return client.getForEntity(
      "http://customerService/customer/{id}?detail={detail}",
      Customer.class,
      customerId,
      "no-address"
    );
  }
}

...

@Configuration
public class RestTemplateConfiguration {
  @Bean
  RestTemplateBuilder restTemplateBuilder() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
    return new RestTemplateBuilder()
      .addAdditionalInterceptors(..)
      .build();
  }
}

1

听起来很险恶?

2

要利用 Spring Boot 自动配置的 RestTemplate 指标,确保为 RestTemplateBuilder 创建任何自定义 bean 绑定,而不是 RestTemplate(请注意,Spring 也通过自动配置为您提供了 RestTemplateBuilder 的默认实例)。Spring Boot 会向它发现的任何这类 bean 附加额外的指标拦截器。一旦创建了 RestTemplate,对此配置的应用就太晚了。

思路是 uri 标签仍应包含带有路径变量的请求路径 预替换,这样您就可以理解到达该端点的请求总数和延迟,而不管正在查找的特定值是什么。此外,这对于控制 http.client.requests 指标包含的标签总数至关重要。允许唯一标签的不受限增长最终会超出监控系统的能力(或者如果监控系统供应商按时间序列计费,这会变得非常昂贵)。

非阻塞 WebClient 的等效操作在 示例 4-19 中展示。

示例 4-19. 允许 WebClient 处理路径变量替换
@RestController
public class CustomerController { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  private final WebClient client;

  public CustomerController(WebClient client) {
    this.client = client;
  }

  @GetMapping("/customers")
  public Mono<Customer> findCustomer(@RequestParam String q) {
    Mono<String> customerId;
    // ... Look up customer ID according to 'q', hopefully in a non-blocking way 
    return customerId
      .flatMap(id -> webClient
          .get()
          .uri(
            "http://customerService/customer/{id}?detail={detail}",
            id,
            "no-address"
          )
          .retrieve()
          .bodyToMono(Customer.class)
      );
  }
}

...

@Configuration
public class WebClientConfiguration {
  @Bean
  WebClient.Builder webClientBuilder() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
    return WebClient
      .builder();
  }
}

1

听起来很险恶?

2

确保为 WebClient.Builder 创建 bean 绑定,而不是 WebClient。Spring Boot 将附加额外的度量 WebClientCustomizer 到构建器上,而不是完成的 WebClient 实例。

尽管 Spring Boot 添加到客户端指标的默认标签集合相当完整,但是它是可自定义的。特别常见的是将指标与某些请求头(或响应头)的值进行标记。在添加标记自定义时,请确保可能的标签值总数是有界的。不应为诸如唯一客户 ID(当您可能有超过 1,000 个客户时)、随机生成的请求 ID 等添加标签。记住,指标的目的是了解聚合性能,而不是某个单独请求的性能。

作为与我们之前在 http.server.requests 标签自定义中使用的稍微不同的示例,我们还可以按订阅级别对客户的检索进行标记,其中订阅级别是在按 ID 检索客户时的响应头。这样,我们可以分别绘制高级客户和基础客户的检索延迟和错误比例。也许业务对向高级客户发送请求的可靠性或性能有更高的期望,这体现在基于这个自定义标签的更紧密的服务水平协议中。

要自定义 RestTemplate 的标签,添加你自己的 @Bean RestTemplateExchangeTagsProvider,如 示例 4-20 所示。

示例 4-20. 允许 RestTemplate 处理路径变量替换
@Configuration
public class MetricsConfiguration {
  @Bean
  RestTemplateExchangeTagsProvider customizeRestTemplateMetrics() {
    return new DefaultRestTemplateExchangeTagsProvider() {
      @Override
      public Iterable<Tag> getTags(String urlTemplate,
        HttpRequest request, ClientHttpResponse response) {

        return Tags.concat(
          super.getTags(urlTemplate, request, response),
          "subscription.level",
          Optional
            .ofNullable(response.getHeaders().getFirst("subscription")) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
            .orElse("basic")
        );
      }
    };
  }
}

1

注意 response.getHeaders().get("subscription") 可能会返回 null!所以无论我们使用 get 还是 getFirst,我们都需要进行 null 检查。

要自定义 WebClient 的标签,添加你自己的 @Bean WebClientExchangeTagsProvider,如 示例 4-21 所示。

示例 4-21. 允许 WebClient 处理路径变量替换
@Configuration
public class MetricsConfiguration {
  @Bean
  WebClientExchangeTagsProvider webClientExchangeTagsProvider() {
    return new DefaultWebClientExchangeTagsProvider() {
      @Override
      public Iterable<Tag> tags(ClientRequest request,
        ClientResponse response, Throwable throwable) {

        return Tags.concat(
          super.tags(request, response, throwable),
          "subscription.level",
          response.headers().header("subscription").stream()
            .findFirst()
            .orElse("basic")
        );
      }
    };
  }
}

到目前为止,我们一直关注延迟和错误。现在让我们考虑与内存消耗相关的常见饱和度测量。

垃圾收集暂停时间

垃圾收集(GC)暂停通常会延迟响应用户请求的交付,它们可能是即将发生的“内存不足”应用程序故障的信号。有几种方法可以查看这个指标。

最大暂停时间

为你认为可接受的最大 GC 暂停时间设置一个固定的警报阈值(知道垃圾收集暂停直接影响到最终用户的响应时间),可能为小型和大型 GC 类型选择不同的阈值。绘制 jvm.gc.pause 定时器的最大值以设置你的阈值,如 图 4-25 所示。如果你的应用程序经常暂停,并且你想了解随时间变化的典型行为,暂停时间的热图也可能很有趣。

srej 0425

图 4-25. 最大垃圾收集暂停时间

垃圾收集中花费的时间比例

由于 jvm.gc.pause 是一个定时器,我们可以单独查看其总和。具体来说,我们可以在一个时间间隔内将其增加的值加起来,然后除以该时间间隔,来确定 CPU 在进行垃圾收集时花费了多少时间。由于在这些时间段内我们的 Java 进程什么都不做,当垃圾收集所花费的时间比例足够大时,应该发出警报。示例 4-22 显示了这个技术的 Prometheus 查询。

示例 4-22. 根据原因查询 Prometheus 中的垃圾收集时间
sum( ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  sum_over_time( ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
    sum(increase(jvm_gc_pause_seconds_sum[2m])[1m:] ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
  )
) / 60 ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png)

1

对所有单独的原因进行求和,比如“小 GC 结束”。

2

在过去一分钟内在个别原因中所花费的总时间。

3

这是我们第一次看到 Prometheus 的 子查询。它允许我们将两个指标的操作视为 sum_over_time 的输入的范围向量。

4

由于 jvm_gc_pause_seconds_sum 的单位是秒(因此总和也是秒),我们已经对一个 1 分钟的时间段进行了求和,将其除以 60 秒,得到在最近一分钟内我们在 GC 中所花费的时间的百分比,其值在 [0, 1] 范围内。

这种技术是灵活的。您可以使用标签选择特定的 GC 原因并评估,例如,仅评估在主要 GC 事件中所占时间的比例。或者,就像我们在这里所做的那样,您可以简单地对所有原因进行求和,并评估给定时间间隔内的总体 GC 时间。很可能,您会发现,如果按原因分别计算这些总和,小 GC 事件对 GC 时间所占比例的贡献并不显著。在 图 4-26 中监控的应用程序每分钟进行一次小集合,毫不奇怪,它在与 GC 相关的活动中仅花费了 0.0182% 的时间。

srej 0426

图 4-26. 在小 GC 事件中所占时间的比例

如果您没有使用提供像sum_over_time这样的聚合函数的监控系统,Micrometer 提供了一个称为JvmHeapPressureMetrics的计量器绑定器,如 示例 4-23 所示,预先计算了这种 GC 开销,并提供了一个称为jvm.gc.overhead的仪表,其值为 [0, 1] 范围内的百分比,您可以设置一个固定的阈值警报。在 Spring Boot 应用中,您只需将一个JvmHeapPressureMetrics实例添加为@Bean,它将自动绑定到您的计量注册表。

示例 4-23. 配置 JVM 堆压力计量器绑定器
MeterRegistry registry = ...

new JvmHeapPressureMetrics(
  Tags.empty(),
  Duration.ofMinutes(1), ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  Duration.ofSeconds(30)
).register(meterRegistry);

1

控制回顾窗口。

任何庞大分配的存在

除了选择上述一种形式来监视 GC 中花费的时间外,还最好在 G1 收集器中设置一个警报,以便在 G1 收集器中引起巨大分配时发出警报,因为这表明在代码中的某个地方你正在分配一个 >50% 的总 Eden 空间大小的对象!很可能,有一种方法可以重构应用程序以避免这样的分配,比如分块或流式处理数据。巨大的分配可能发生在诸如解析输入或从尚未达到应用程序可能看到的大小的数据存储中检索对象等操作中,并且更大的对象很可能会使应用程序崩溃。

对于这一点,具体来说,你要查找 jvm.gc.pause 计数不为零的地方,其中 cause 标签等于 G1 Humongous Allocation

在“可用性监控”中我们提到,当你在这两者之间有选择时,饱和度指标通常优于利用率指标。对于内存消耗来说,这当然是正确的。将垃圾回收中花费的时间视为内存资源问题的衡量标准更容易做到。如果我们小心的话,利用率测量也可以做一些有趣的事情。

堆利用率

Java 堆被分为多个池,每个池都有一个定义的大小。Java 对象实例是在堆空间中创建的。堆的最重要部分如下:

Eden 空间(年轻代)

所有的新对象都在这里分配。当这个空间填满时,会发生一次次要的垃圾回收事件。

幸存者空间

当发生次要的垃圾回收时,任何活动对象(可以明确证明仍有引用因此不能被收集的对象)都会被复制到幸存者空间。进入幸存者空间的对象会增加它们的年龄,并且在达到年龄阈值后,会提升到老年代。如果幸存者空间无法容纳年轻代中的所有活动对象(对象跳过幸存者空间直接进入老年代),提升可能会过早发生。这个事实将是我们如何衡量危险分配压力的关键。

老年代

这是存储长期存活对象的地方。当对象存储在 Eden 空间时,会设置该对象的年龄;当达到该年龄时,该对象将被移到老年代。

根本上,我们想知道这些空间中的一个或多个何时变得过于“满”,并且保持太“满”。这是一个棘手的监控问题,因为 JVM 垃圾回收设计上会在空间变满时启动。因此,空间填满本身并不是问题的指标。令人担忧的是它保持满的情况。

Micrometer 的 JvmMemoryMetrics 米勒绑定器会自动收集 JVM 内存池使用情况,以及当前的总最大堆大小(因为这可以在运行时增加和减少)。大多数 Java Web 框架会自动配置此绑定器。

在 图 4-27 中绘制了几个指标。衡量堆压力的最直接的想法是使用简单的固定阈值,比如总堆消耗的百分比。正如我们所看到的,固定阈值警报会触发得太频繁。最早的警报在 11:44 触发,远早于在这个应用程序中出现内存泄漏的迹象。即使堆暂时超过我们设置的总堆百分比阈值,垃圾收集事件通常会将总消耗带回阈值以下。

在 图 4-27 中:

  • 实心垂直条一起是一个按空间消耗堆叠的内存图。

  • 细线在 30.0 M 级别周围是允许的最大堆空间。请注意,随着 JVM 尝试在进程的初始堆大小(-Xms)和最大堆大小(-Xmx)之间选择合适的值,这会波动。

  • 粗体线在 24.0 M 级别周围代表了最大允许内存的固定百分比。这是阈值。它是相对于最大值的固定阈值,但在某种意义上是动态的,因为它是最大值的百分比,而最大值本身可能会波动。

  • 较浅的条代表实际堆利用率(堆栈图的顶部)超过阈值的点。这是“警报条件”。

警报频繁触发

图 4-27. 使用固定阈值对内存利用率发出警报

因此,这种简单的固定阈值不起作用。根据目标监控系统的功能,有更好的选择可用。

堆空间填充次数滚动计数

通过在 Atlas 中使用类似滚动计数功能,我们只有在堆超过阈值时才会发出警报——比如,在过去五个间隔中有三个超过阈值——这表明尽管垃圾收集器尽力了,堆消耗仍然是一个问题(见 图 4-28)。

不幸的是,没有多少监控系统具有类似 Atlas 的滚动计数功能。Prometheus 可以通过其 count_over_time 操作做类似的事情,但要实现类似“五个中有三个”的动态是有技巧的。

只有在明显出现问题时才触发警报

图 4-28. 使用滚动计数限制警报冗余

还有一种替代方法也很有效。

收集后低池内存

Micrometer 的 JvmHeapPressureMetrics 为最后一次垃圾收集事件后使用的 Old Generation 堆的百分比添加了一个计量器 jvm.memory.usage.after.gc

jvm.memory.usage.after.gc 是一个在范围 [0, 1] 中表示的百分比。当它很高时(一个良好的起始警报阈值大于 90%),垃圾回收无法清理掉太多的垃圾。因此,当老年代被清理时会发生长期暂停事件,这些频繁的长期暂停显著降低了应用程序的性能,并最终导致致命的 OutOfMemoryException 错误。

在收集后测量低内存池的微妙变化也是有效的。

低总内存

这种技术涉及混合堆使用量和垃圾回收活动的指标。当它们超过阈值时,就会指示出问题:

jvm.gc.overhead > 50%

注意,这比“垃圾回收暂停时间”建议的相同指标更低的警报阈值(我们建议为 90%)。我们可以更积极地对待这个指标,因为我们将其与利用率指标配对使用。

jvm.memory.used/jvm.memory.max 在过去 5 分钟的任何时间都 > 90%

现在我们知道 GC 开销正在上升,因为一个或多个池继续填满。如果您的应用程序在正常情况下会生成大量短期垃圾,您也可以将其限制为仅老年代池。

GC 开销指标的警报条件是针对测量值的简单测试。

总内存使用的查询略微不太明显。Prometheus 查询显示在示例 4-24 中。

示例 4-24. 近五分钟内使用的最大内存的 Prometheus 查询
max_over_time(
  (
    jvm_memory_used_bytes{id="G1 Old Gen"} /
    jvm_memory_committed_bytes{id="G1 Old Gen"}
  )[5m:]
)

要更好地理解 max_over_time 的作用,图 4-29 显示了在几个时间点消耗的伊甸空间总量(在本例中为 jvm.memory.used{id="G1 Eden Space"} 的点)及应用一分钟 max_over_time 查询到相同查询的结果(实线)。它是一个在指定间隔内的移动最大窗口。

只要堆使用量上升(并且在回看窗口中的当前值之下),max_over_time就会精确跟踪它。一旦发生垃圾回收事件,当前的使用视图下降,而max_over_time会在回看窗口的较高值上“粘滞”。

srej 0429

图 4-29. Prometheus 的 max_over_time 查看一个一分钟回溯窗口中最大的伊甸空间使用量

这也是我们第一次考虑基于多个条件的警报。警报系统通常允许多个标准的布尔组合。在图 4-30 中,假设 jvm.gc.overhead 指标表示查询 A,使用指标表示查询 B,则可以在 Grafana 中对它们一起配置警报。

srej 0430

图 4-30. 根据两个指标配置 Grafana 警报以用于低总内存

另一个常见的利用率测量是 CPU,它没有一个简单的饱和度类比。

CPU 利用率

CPU 使用率是一个常见的利用率警报设置,但由于下文描述的不同编程模型的存在,很难建立一个健康的 CPU 使用量的一般规则——这将不得不针对每个应用程序根据其特性进行确定。

例如,运行在 Tomcat 上并使用阻塞 Servlet 模型提供请求的典型 Java 微服务在通常情况下会在耗尽 Tomcat 线程池中的可用线程之前过度利用 CPU。在这些类型的应用程序中,高内存饱和度更为常见(例如,在处理每个请求或大请求/响应主体时创建大量垃圾)。

运行在 Netty 上并使用响应式编程模型的 Java 微服务每个实例都能接受更高的吞吐量,因此 CPU 利用率往往更高。事实上,更好地饱和可用 CPU 资源通常被引述为响应式编程模型的优势!

在某些平台上,在调整实例大小之前,请综合考虑 CPU 和内存利用率。

平台即服务的一个常见特性是将实例大小简化为所需的 CPU 或内存量,而随着您增加大小,另一个变量会按比例增加。在 Cloud Foundry 的情况下,CPU 和内存之间的这种比例关系是在一个几乎普遍使用阻塞请求处理模型(如 Tomcat)的时代决定的。正如前面提到的,CPU 在这种模型中往往被低估使用。我曾在一家公司做过咨询,他们采用了非阻塞的响应式模型用于应用程序,注意到内存被显著低估利用,我降低了公司的 Cloud Foundry 实例以减少对内存的消耗。但在这个平台上,CPU 根据请求的内存量分配给实例。通过选择较低的内存要求,公司还意外地使其响应式应用程序失去了它本来可以高效饱和的 CPU!

Micrometer 导出了用于 CPU 监控的两个关键指标,这些指标在 表 4-1 中列出。这两个指标都是从 Java 的操作系统 MXBean (ManagementFactory.getOperatingSystemMXBean()) 报告的。

表 4-1. Micrometer 报告的处理器指标

指标 类型 描述
system.cpu.usage Gauge 整个系统的最近 CPU 使用率
process.cpu.usage Gauge Java 虚拟机进程的最近 CPY 使用率

对于企业中应用程序通过阻塞 Servlet 模型提供请求的最常见情况,对 80%的固定阈值进行测试是合理的。反应式应用程序需要通过实验确定它们适当的饱和点。

对于 Atlas,使用:gt函数,如示例 4-25 中所示。

示例 4-25. Atlas CPU 警报阈值
name,process.cpu.usage,:eq,
0.8,
:gt

对于 Prometheus,示例 4-26 只是一个比较表达式。

示例 4-26. Prometheus CPU 警报阈值
process_cpu_usage > 0.8

进程 CPU 使用率应该绘制为百分比(监控系统应该期望在 0–1 的范围内输入以适当绘制y轴)。请注意图 4-31 中的y轴应该是什么样子。

srej 0431

图 4-31. 进程 CPU 使用率作为百分比

在 Grafana 中,“percent”是“可视化”选项卡中可选择的单位之一。请确保选择“percent (0.0-1.0)”选项,如图 4-32 所示。

srej 0432

图 4-32. Grafana 百分比单位

还有一个与文件描述符相关的资源指标您应该在每个应用程序上测量。

文件描述符

“ulimits” Unix 特性限制单个用户可以使用的资源数量,包括同时打开的文件描述符。文件描述符不仅仅用于文件访问,还用于网络连接、数据库连接等。

您可以使用ulimit -a命令查看您的 shell 当前的 ulimits。输出如示例 4-27 所示。在许多操作系统上,1,024 是打开文件描述符的默认限制。像每个服务请求都需要访问读或写文件的情况,其中并发线程数可能会超过操作系统限制,这些情况容易受到这个问题的影响。对于现代微服务,特别是非阻塞微服务,同时数以千计的请求吞吐量并不是不合理的。

示例 4-27. 在 Unix shell 中运行 ulimit -a 的输出
$ ulimit -a
...
open files (-n) 1024 ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
...
cpu time (seconds, -t) unlimited
max user processes (-u) 63796
virtual memory (kbytes, -v) unlimited

1

这表示允许打开文件的数量,而不是当前打开文件的数量。

这个问题不一定常见,但达到文件描述符限制的影响可能是致命的,可能导致应用程序完全停止响应,具体取决于文件描述符的使用方式。与内存不足错误或致命异常不同,通常应用程序可能会简单地阻塞,但仍然显示为在服务中,因此这个问题特别棘手。由于监控文件描述符利用率非常廉价,在每个应用程序上都应发出警报。使用常见技术和 Web 框架的应用程序可能永远不会超过 5%的文件描述符利用率(有时更低),但一旦问题悄然而至,就会带来麻烦。

在编写本书时遇到文件描述符问题

我很久以来一直在监控这个问题,但直到写这本书之前从未亲身经历过问题。一个涉及构建 Grafana 的 Go 构建步骤反复挂起,永远无法完成。显然,Go 依赖解析机制没有仔细限制开放文件描述符的数量!

应用程序可能对数百个调用方开放套接字、向下游服务的 HTTP 连接、连接到数据源和打开的数据文件进行监控,可能会达到文件描述符的限制。当一个进程耗尽文件描述符时,通常情况并不好。你可能会在日志中看到类似 4-28 和 4-29 的错误。

示例 4-28. Tomcat 因接受新的 HTTP 连接而耗尽文件描述符
java.net.SocketException: Too many open files
  at java.net.PlainSocketImpl.socketAccept(Native Method)
  at java.net.AbstractPlainSocketImpl.accept(AbstractPlainSocketImpl.java:398)
示例 4-29. 当文件描述符耗尽时 Java 无法打开文件
java.io.FileNotFoundException: /myfile (Too many open files)
  at java.io.FileInputStream.open(Native Method)

Micrometer 报告了两个在 表 4-2 中显示的指标,用于提醒您应用程序中的文件描述符问题。

表 4-2. Micrometer 报告的文件描述符指标

Metric Type 描述
process.max.fds Gauge 最大允许的开放文件描述符,对应于 ulimit -a 输出
process.open.fds Gauge 开放文件描述符数量

通常情况下,开放文件描述符应保持在最大值以下,因此对于像 80% 这样的固定阈值进行测试是预示即将发生问题的良好指标。此警报应设置在每个应用程序上,因为文件限制是一个普遍适用的硬限制,可能会使您的应用程序停止服务。

对于 Atlas,使用 :div:gt 函数,如示例 4-30 所示。

示例 4-30. Atlas 文件描述符警戒阈值
name,process.open.fds,:eq,
name,process.max.fds,:eq,
:div,
0.8,
:gt

对于 Prometheus,4-31 看起来更加简单。

示例 4-31. Prometheus 文件描述符警戒阈值
process_open_fds / process_max_fds > 0.8

到此为止,我们已经覆盖了适用于大多数 Java 微服务的信号。接下来的信号通常有用,但不是普遍存在的。

可疑流量

另一个简单的指标,可以从类似 http.server.requests 的指标中推导出来,涉及观察异常状态码的出现。快速连续出现的 HTTP 403 Forbidden(以及类似的)或 HTTP 404 Not Found 可能表明有入侵尝试。

不同于绘制错误,监控可疑状态码的总出现次数应作为速率,而不是相对于总吞吐量的比率。可以说,每秒出现 10,000 次 HTTP 403 状态码同样可疑,无论系统正常处理每秒 15,000 请求还是 15 百万请求,因此不要让整体吞吐量掩盖异常。

在示例 4-32 中的 Atlas 查询,类似于我们之前讨论的错误率查询,但查看 status 标签比 outcome 标签具有更精细的粒度。

示例 4-32. Atlas 中 HTTP 服务器请求中的可疑 403 错误
name,http.server.requests,:eq,
status,403,:eq,
:and,
uri,$ENDPOINT,:eq,:cq

在 Prometheus 中使用 rate 函数可以实现相同的结果,如在示例 4-33 中所示。

示例 4-33. Prometheus 中 HTTP 服务器请求中的可疑 403 错误
sum(
  rate(
    http_server_requests_seconds_count{status="403", uri="$ENDPOINT"}[2m]
  )
)

下一个指标是专门针对特定类型的应用程序,但仍然常见到足以包含在内。

批处理运行或其他长时间运行任务

任何长时间运行任务的最大风险之一是其运行时间显著超过预期。在我早期的职业生涯中,我经常需要为生产部署值班,这些部署通常在一系列午夜批处理运行后执行。在正常情况下,批处理序列应该在凌晨 1 点左右完成。部署时间表是基于这一假设构建的。因此,网络管理员需要在 1 点准备好在计算机上手动上传已部署的工件(这是在第五章之前),以执行该任务。作为产品工程团队的代表,我需要在凌晨 1:15 左右进行简短的烟雾测试,并随时准备帮助解决任何出现的问题。那时,我住在一个没有互联网接入的农村地区,因此我沿着州际公路向人口中心前进,直到我能够获得足够可靠的手机信号来连接到我的电话和 VPN。当批处理过程没有在合理的时间内完成时,有时我会在一些乡村道路上坐上几个小时等待它们完成。在没有生产部署的日子里,也许直到下一个工作日才会知道批处理周期失败了。

如果我们将一个长时间运行的任务包装在 Micrometer 的 Timer 中,我们在任务实际完成之前不会知道是否超出了 SLO。因此,如果任务应该不超过 1 小时,但实际运行了 16 小时,那么我们直到第一个发布间隔 之后 16 小时才会在监控图表上看到这一情况记录到定时器中。

要监控长时间运行的任务,最好查看正在运行的或活动任务的运行时间。LongTaskTimer 执行这种类型的测量。我们可以像在示例 4-34 中那样,向潜在长时间运行的任务中添加这种定时。

示例 4-34. 基于注解的计划操作的长任务定时器
@Timed(name = "policy.renewal.batch", longTask = true)
@Scheduled(fixedRateString = "P1D")
void renewPolicies() {
  // Bill and renew insurance policies that are beginning new terms today
}

长任务定时器提供几种分布统计数据:活动任务计数、最长的请求持续时间、所有请求持续时间的总和,以及关于请求持续时间的百分位数和直方图信息(可选)。

对于 Atlas,根据我们对一小时的纳秒的预期进行测试,如示例 4-35 所示。

示例 4-35. Atlas 长任务定时器最大警报阈值
name,policy.renewal.batch.max,:eq,
3.6e12, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
:gt

1

一小时的纳秒

对于 Prometheus,示例 4-36 被测试为一小时的秒数。

示例 4-36. Prometheus 长任务定时器最大警报阈值
policy_renewal_batch_max_seconds > 3600

我们已经看到了一些有效的指标示例,希望您现在已经在仪表板上绘制了一个或多个指标,并且能够看到一些有意义的见解。接下来,我们将转向如何在这些指标出现异常时自动发出警报,以便您不必一直观察您的仪表板就知道有什么不对劲。

使用预测方法构建警报

固定的警报阈值通常很难事先确定,并且由于系统性能随时间漂移,可能需要不断进行调整。如果随着时间的推移,性能倾向于下降(但以一种仍在可接受水平内的方式),那么固定的警报阈值很容易变得太啰嗦。如果性能倾向于改善,那么该阈值将不再是可靠的预期性能度量,除非进行调整。

机器学习是一个颇受吹捧的话题,监控系统将自动确定警报阈值,但它并没有产生承诺的结果。对于时间序列数据,更简单的经典统计方法仍然非常强大。令人惊讶的是,S. Makridakis 等人的论文 “Statistical and Machine Learning Forecasting Methods: Concerns and Ways Forward” 表明,统计方法的预测误差比机器学习方法低(如图 4-33 所示)。

srej 0433

图 4-33. 统计学与机器学习技术的一步预测误差

让我们简要介绍一些统计方法,从最不具预测性的朴素方法开始,该方法可与任何监控系统一起使用。随后的方法由于其数学复杂性需要内置的查询功能,因此在监控系统中的通用支持较少。

朴素方法

朴素方法 是一个简单的启发式方法,根据最后观察到的值来预测下一个值:

y ^ T+1|T = α y T

可以通过将时间序列偏移乘以某个因子来确定朴素方法的动态警报阈值。然后我们可以测试真实线是否曾经低于(如果乘数大于一则超过)预测线。例如,如果真实线是通过系统的吞吐量测量的,则吞吐量突然大幅下降可能表明发生了故障。

Atlas 的警戒标准是每当示例 4-37 返回 1 时。该查询设计针对 Atlas 的测试数据集,因此您可以轻松测试并尝试不同的乘数以观察效果。

示例 4-37. Atlas 天真预测方法的警戒标准
name,requestsPerSecond,:eq,
:dup,
0.5,:mul, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
1m,:offset, ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
:rot,
:lt

1

通过这个因子来设置阈值的紧密度。

2

“回溯”到预测的某个前期间隔。

天真方法的影响可以在 图 4-34 中看到。乘法因子(例如查询中的 0.5)控制我们希望将阈值设置多接近真实值,同时也以同样的量减少预测的尖锐度(即阈值越宽松,预测的尖锐度越低)。由于该方法的平滑程度与拟合松紧成正比,所以即使我们允许 50%的“正常”漂移,警戒阈值在这个时间窗口内仍会触发四次(在图表中间垂直条指示)。

srej 0434

图 4-34. 利用天真方法进行预测

为了避免过于喋喋不休的警报,我们必须减少预测与指标的拟合紧密度(在本例中,0.45 倍数可以让警报在这个时间窗口内保持静默)。当然,这样做也会允许在触发警报之前对“正常”更多的漂移。

单指数平滑

通过在乘以某个因子之前对原始指标进行平滑,我们可以更紧密地拟合阈值到指标上。单指数平滑由 方程 4-1 定义。

方程 4-1. 其中 0 α 1

y ^ T+1|T = α y T + α ( 1 - α ) y T-1 + α (1-α) 2 y T-2 + ... = α n=0 k (1-a) n y T-n

α 是平滑参数。当 α = 1 时,除第一个外的所有项都被清零,这时就得到了天真方法。小于 1 的值表明前一样本的重要性。

与天真方法类似,Atlas 的警戒标准是每当示例 4-38 返回 1 时。

示例 4-38. 单指数平滑的 Atlas 警戒标准
alpha,0.2,:set,
coefficient,(,alpha,:get,1,alpha,:get,:sub,),:set,
name,requestsPerSecond,:eq,
:dup,:dup,:dup,:dup,:dup,:dup,
0,:roll,1m,:offset,coefficient,:fcall,0,:pow,:mul,:mul,
1,:roll,2m,:offset,coefficient,:fcall,1,:pow,:mul,:mul,
2,:roll,3m,:offset,coefficient,:fcall,2,:pow,:mul,:mul,
3,:roll,4m,:offset,coefficient,:fcall,3,:pow,:mul,:mul,
4,:roll,5m,:offset,coefficient,:fcall,4,:pow,:mul,:mul,
5,:roll,6m,:offset,coefficient,:fcall,5,:pow,:mul,:mul,
:add,:add,:add,:add,:add,
0.83,:mul, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
:lt,

1

通过这个因子来设置阈值的紧密度。

求和 α n=0 k (1-a) n 是收敛到 1 的几何级数。例如,对于 α = 0 . 5 ,参见 表 4-3。

表 4-3. 当 α = 0 . 5 时,几何级数收敛到 1。

T (1-α) T α n=0 k (1-a) n
0 0.5 0.5
1 0.25 0.75
2 0.125 0.88
3 0.063 0.938
4 0.031 0.969
5 0.016 0.984

由于我们不包括所有 T 的值,所以平滑函数实际上已经乘以了一个等于我们选择的项数累积和的几何级数的因子。图 4-35 显示了在系列中一个项和两个项的求和相对于真值的情况(从下到上)。

srej 0435

图 4-35. 选择有限求和的缩放效果

图 4-36 显示了不同选择的 αT 如何影响动态阈值,无论是平滑程度还是相对于真实指标的近似缩放因子。

srej 0436

图 4-36. 当选择不同的 αT 时的平滑和缩放效果

Universal Scalability Law

在本节中,我们将完全改变思维方式,不再仅仅平滑过去发生的数据点(我们将其用作动态警报阈值),而是使用一种技术来预测未来性能会如何,如果并发/吞吐量超过当前水平,仅使用一小组已看到的性能样本。通过这种方式,我们可以设置预测警报,当接近服务级别目标边界时,希望能够预防问题,而不是对已经超出边界的问题做出反应。换句话说,这种技术允许我们测试一个预测的服务级别指标值与我们的 SLO 相比,这是我们尚未经历过的吞吐量。

这种技术基于一个称为 Little 法则和通用可扩展性定律(USL)的数学原理。我们将在这里对数学解释保持最少。你可以跳过所讨论的内容。有关更多细节,Baron Schwartz 的免费可用的使用通用可扩展性定律进行实用的可扩展性分析(VividCortex)是一个很好的参考资料。

在交付流水线中使用通用可扩展性定律

除了在生产系统中预测即将发生的 SLA 违规外,我们还可以在交付流水线中使用相同的遥测数据,向一个不需要接近其可能在生产中看到的最大流量的软件发送一些流量,并预测生产级别的流量是否能够达到 SLA。而且我们可以在将软件的新版本部署到生产环境之前就做到这一点!

Little 法则,方程式 4-2,描述了队列的行为,涉及三个变量之间的关系:队列大小( N ),延迟( R ),和吞吐量( X )。如果将排队理论应用于 SLI 预测似乎有点令人费解,不用担心(因为确实如此)。但是对于我们预测 SLI 的目的,N 将表示通过我们系统的请求的并发级别,X 表示吞吐量,而 R 则是诸如平均值或高百分位值的延迟度量。因为这是三个变量之间的关系,只要提供其中两个,我们就能推导出第三个。因为我们关心预测延迟( R ),我们需要在并发( N )和吞吐量( X )的两个维度上进行预测。

方程式 4-2. Little 法则

N = X R X = N / R R = N / X

通用可扩展性定律,方程式 4-3,允许我们只根据单一变量(吞吐量或并发性)来预测延迟。该方程式需要三个系数,这些系数将从 Micrometer 维护的模型中根据系统到目前为止的实际性能观察中得出并更新。USL 定义κ作为串扰成本,ϕ作为争用成本,λ作为系统在未加载条件下操作的速度。这些系数成为固定值,从而使延迟、吞吐量或并发性的预测仅依赖于另外三个中的一个。Micrometer 还将随着时间的推移发布这些系数的值,因此您可以比较系统的主要性能特征随时间的变化。

方程式 4-3. 通用可扩展性定律

X ( N ) = λN 1+ϕ(N-1)+κN(N-1)

通过一系列替换,我们可以将R表示为XN的函数(参见方程式 4-4)。再次,请不要过多思考这些关系,因为 Micrometer 将为您执行这些计算。

方程式 4-4. 预测的延迟作为吞吐量或并发性的函数。

R ( N ) = 1+ϕ(N-1)+κN(N-1) λ R ( X ) = -X 2 (κ 2 +2κ(ϕ-2)+ϕ 2 )+2λX(κ-ϕ)+λ 2 +κX+λ-ϕX 2κX 2

我们将得到一个很好的二维投影,如图 4-37 所示。

srej 0437

图 4-37. 基于不同吞吐量水平的延迟的 USL 预测。

USL 预测是 Micrometer 中的一种“派生”Meter,可以按照示例 4-39 中所示启用。Micrometer 将发布一组Gauge计量器,形成每个发布间隔各种吞吐量/并发性水平的预测系列。吞吐量和并发性是相关的测量,因此从这一点开始可以互换地考虑它们。当您选择发布一个与预测相关的计时器相关组时(这些计时器将始终具有相同的名称),Micrometer 将使用公共指标名称作为前缀发布几个附加指标:

timer.name.forecast

一系列带有标签throughputconcurrencyGauge计量器,基于所选独立变量的类型。在特定时间间隔内,绘制这些计量器将生成类似图 4-37 的可视化效果。

timer.name.crosstalk

系统串扰的直接测量(例如,在 S. Cho 等人论文中描述的分布式系统中的扇出控制,“Moolle: 可扩展分布式数据存储的扇出控制”)。

timer.name.contention

系统争用的直接测量(例如,在关系数据库表上的锁定以及一般的任何其他形式的锁同步)。

timer.name.unloaded.performance

理想情况下未负载性能的改进(例如框架性能改进)也可望在负载条件下产生改进。

示例 4-39. Micrometer 中的通用可扩展性法则预测配置
UniversalScalabilityLawForecast
    .builder(
      registry
        .find("http.server.requests") ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
        .tag("uri", "/myendpoint") ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
        .tag("status", s -> s.startsWith("2")) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
    )
    .independentVariable(UniversalScalabilityLawForecast.Variable.THROUGHPUT) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png)
    // In this case, forecast to up to 1,000 requests/second (throughput)
    .maximumForecast(1000)
    .register(registry);

1

预测将基于 Micrometer 搜索结果中名为 http.server.requests 的一个或多个计时器(请记住,可能有几个具有不同标签值的计时器)。

2

我们可以通过仅匹配具有特定键值标签对的计时器来进一步限制预测的计时器集合。

3

像任何搜索一样,标签值也可以使用 lambda 进行限制。一个很好的例子是将预测限制在任何“2xx”HTTP 状态下。

4

Gauge 直方图的域将是 UniversalScalabilityLawForecast.Variable.CONCURRENCYUniversalScalabilityLawForecast.Variable.THROUGHPUT,默认为 THROUGHPUT

应用程序在当前吞吐量下体验的延迟将紧随预测的延迟。我们可以根据当前吞吐量的某个放大值设置警报,以确定在该放大的吞吐量下预测的延迟是否仍在我们的 SLO 范围内。

除了在增加吞吐量下预测 SLI 外,对串扰、争用和未负载性能的建模值也是性能改进的强有力指标。毕竟,串扰和争用的减少以及未负载性能的增加直接影响系统在各种负载水平下的预测和实际延迟。

总结

本章向您介绍了开始监控每个 Java 微服务可用性所需的工具,这些信号包含在 Java 框架(如 Spring Boot)中。我们还讨论了如何基于类似计数器和计时器的指标类别进行警报和可视化。

虽然您应该努力找到以业务为重点的微服务可用性测量方法,但使用这些基本信号相比仅仅查看盒子指标在理解您的服务表现方面是一个巨大的进步。

在组织上,您已经决定启动一个仪表盘/警报工具。我们在本章中展示了 Grafana。其开源可用性和为多种流行监控系统提供的数据源使其成为一个可靠的选择,可以在不完全锁定到特定供应商的情况下进行构建。

在接下来的章节中,我们将转向交付自动化,看看这些可用性信号如何在决策新微服务发布的适用性方面发挥作用。有效的交付不仅仅是部署的动作;它将监控转化为行动。

第五章:安全的多云持续交付

本书后半部分中本章的安排应表明在实现安全和有效的交付实践中,遥测的重要性。这可能会让人感到意外,因为每个组织都强调测试作为确保安全性的手段,但并非每个组织都以直接关联到最终用户体验的方式积极地进行测量。

本章介绍的概念将以一个名为 Spinnaker 的持续交付工具为例,但与早期章节一样,不同的工具也可以达到类似的目的。我想建立一个你应该从一个值得信赖的 CD 工具中期望的最低基础。

Spinnaker 是一个开源的持续交付解决方案,起源于 2014 年 Netflix 为了帮助管理其 AWS 上的微服务而开发。在 Netflix 之前,有一个称为 Asgard 的工具,它实际上只是一个专为应用程序开发人员设计的替代 AWS 控制台,并且专为 Netflix 在 AWS 上的异常规模构建而成。曾经有一次,我在与 AWS 控制台交互时需要选择安全组。控制台中的 UI 元素是一个普通的 HTML 选择框,显示四个可见元素。由于 Netflix 在此帐户中的广泛规模,可用的安全组在列表框中未排序,且有数千个!像这样的可用性问题导致了 Asgard,进而演变为 Spinnaker。Asgard 实际上只是一个带有一些操作(类似 AWS 控制台)的应用程序清单。Spinnaker 则构想为清单流水线。

2015 年,Spinnaker 开源并添加了其他初始的 IaaS 实现。在不同阶段,Spinnaker 受到了 Google、Pivotal、Amazon 和 Microsoft 等公司以及像 Target 这样的终端用户的重要贡献。许多这些贡献者共同撰写了一本单独的书籍,内容是关于 Spinnaker 的。

本章描述的实践适用于各种平台。

平台类型

不同类型的平台在组成运行应用程序的高级概念方面具有惊人的共性。本章介绍的概念将大部分是平台中立的。平台可分为以下几类:

基础设施即服务(IaaS)

基础设施即服务提供虚拟化计算资源作为服务。传统上,IaaS 提供商负责服务器、存储、网络硬件、虚拟化层以及用于管理这些资源的 API 和其他形式的用户界面。最初,使用 IaaS 是作为不使用物理硬件的替代方法。在 IaaS 上配置资源涉及构建虚拟机(VM)映像。在 IaaS 上部署需要在交付流水线的某个时刻构建 VM 映像。

容器即服务(CaaS)

容器即服务(CaaS)是对基于容器而不是虚拟机的工作负载的 IaaS 的一种专门化。它为应用程序作为容器部署提供了更高级别的抽象。Kubernetes 当然已成为公有云供应商和本地环境提供的事实标准 CaaS。它提供了许多本书范围之外的其他服务。部署到 CaaS 需要在交付管道中的某个地方(通常在构建时)构建容器的额外步骤。

平台即服务(PaaS)

平台即服务(PaaS)进一步将底层基础架构的细节抽象化,通常允许您直接将应用程序二进制文件(如 JAR 或 WAR)上传到 PaaS API,然后由 PaaS 负责构建图像并进行配置。与 PaaS 的即服务含义相反,有时像 Cloud Foundry 这样的 PaaS 提供是在客户数据中心的虚拟化基础设施上的。它们也可以在 IaaS 提供的基础上进行层叠,以进一步抽象出 IaaS 资源模型,这可能达到保留某种程度的公有云提供商供应商中立性或允许在混合私有/公共云环境中提供类似的交付和管理工作流程的目的。

这些抽象可以由另一家公司提供给您,或者您可以自己构建这个云原生基础设施(就像一些大公司所做的那样)。关键要求是一个弹性的、自助服务的、基于 API 的平台,用于构建。

我们在本章中将做出的一个关键假设是,您正在构建不可变基础设施。虽然 IaaS 的任何内容都不会阻止您构建 VM 映像、启动其实例并在启动后将应用程序放置到其中,但我们假设 VM 映像与应用程序和任何支持软件一起“烘焙”,以便在新实例被配置时应用程序应该能够启动和运行。

进一步的假设是以这种方式部署的应用程序大致上是云原生的。云原生的定义因来源而异,但至少可以适用于本章讨论的部署策略的应用程序是无状态的。12 因素应用的其他元素并不是那么关键。

例如,我在 Netflix 管理的一个服务通常需要超过 40 分钟才能启动,这与可处理性标准不符,但在其他方面是不可避免的。同一服务在 AWS 中使用了占用内存极高的实例类型,我们只有一个小型的保留池。这对我的选择施加了限制:我不能同时运行超过四个此服务的实例,所以我不会使用几个禁用的集群进行蓝/绿部署(在“蓝/绿部署”中描述)。

进一步围绕一个共同语言进行讨论,让我们讨论一下所有这些平台共有的资源构建块。

资源类型

为了保持我们对交付概念的讨论与平台无关,我们将采用由 Spinnaker 定义的抽象概念,这些概念在不同类型的平台上都非常易于移植:

实例

实例是某些微服务的运行副本(不是在本地开发者机器上,因为我真诚地希望生产流量不会找到那里)。AWS EC2 和 Cloud Foundry 平台都称之为“实例”,非常方便。在 Kubernetes 中,实例是一个 Pod。

服务器组

服务器组表示一组一起管理的实例。不同平台管理实例集合的方式各不相同,但它们通常负责确保运行一定数量的实例。我们通常假设服务器组的所有实例具有相同的代码和配置,因为它们是不可变的(除非不是这样)。服务器组可以在逻辑上完全没有任何实例,但只需潜在地扩展到非零数量的实例。在 AWS EC2 中,服务器组是一个自动扩展组。在 Kubernetes 中,服务器组大致是部署(Deployment)和副本集(ReplicaSet)的组合(其中部署管理副本集的推出),或者是 StatefulSet。在 Cloud Foundry 中,服务器组是一个应用(不要与本列表中定义的应用混淆,我们在本章中将使用该术语)。

集群

集群是跨多个区域可能存在的服务器组集合。在单个区域内,多个服务器组可能表示微服务的不同版本。集群不跨云服务提供商。您可以在不同的云服务提供商中运行两个非常相似的集群,但对于我们的讨论,它们将被视为不同的。集群是一个逻辑概念,在任何云服务提供商中实际上并没有对应的资源类型。更准确地说,它不涵盖特定平台的多个安装。因此,集群不跨多个 Cloud Foundry 基础设施或 Kubernetes 集群。在 AWS EC2 中没有更高级别的抽象来表示一组自动扩展组,在 Kubernetes 中也没有表示部署集合的抽象。Spinnaker 通过命名约定或者附加到它创建的资源的元数据来管理集群成员资格,具体取决于 Spinnaker 在平台实现中的方式。

应用

应用是一个逻辑业务功能,而不是一个特定的资源。所有运行中的应用实例都包括在内。它们可能跨越多个集群和多个地区。它们可能存在于多个云提供商,要么是因为您正在从一个提供商过渡到另一个提供商,要么是因为您有某些具体的业务情况,不希望锁定在一个提供商上,或者出于任何其他原因。只要存在代表这个业务功能实例的运行进程,它就是所谓的应用的一部分。

负载均衡器

负载均衡器是一个组件,它将单独的请求分配给一个或多个服务器组中的实例。大多数负载均衡器有一组策略或算法,可以用来分配流量。此外,它们通常具有健康检查功能,允许负载均衡器确定候选微服务实例是否健康到足以接收流量。在 AWS EC2 中,负载均衡器是应用负载均衡器(或传统的弹性负载均衡器)。在 Kubernetes 中,Service 资源就是负载均衡器。Cloud Foundry 路由器也是一个负载均衡器。

防火墙

防火墙是一组管理对一组服务器组的入站和出站流量的规则。在 AWS EC2 中,这些称为安全组。

Spinnaker 的 Kubernetes 实现在提供商中有些独特。Spinnaker 实际上可以部署任何 Kubernetes 资源,因为它内部使用kubectl apply并将清单传递给 Kubernetes 集群。此外,Spinnaker 允许您将清单视为模板,并提供变量替换。然后,它将某些 Kubernetes 对象如 ReplicaSets/Deployments/StatefulSets 映射到服务器组,将 Services 映射到负载均衡器。

图 5-1 显示了一个 Spinnaker 视图,展示了一系列 Kubernetes ReplicaSets。请注意,此基础设施视图还包含对所选资源的编辑、缩放、禁用和删除等操作。在此视图中,replicaSet helloworldapp-frontend是“Cluster”资源(在本例中是 Kubernetes 资源类型和名称的结合体),代表一个或多个 Kubernetes 命名空间中的一组 ReplicaSets。HELLOWORLDWEBAPP-STAGING是对应于同名 Kubernetes 命名空间的“Region”。helloworldapp-frontend-v004是一个服务器组(一个 ReplicaSet)。各个块是对应于 Kubernetes pods 的“Instances”。

srej 0501

图 5-1. Spinnaker 视图展示了三个 Kubernetes ReplicaSets,并突出显示了操作

交付管道

Spinnaker 流水线只是市场上商业和开源软件中众多以交付为重点的流水线解决方案之一。它们从低级别且具有强烈观点的 Spring Cloud Pipelines 到包括 JenkinsX 等交付构建块的持续集成流水线,涵盖了各种解决方案。在本章中,我们将专注于 Spinnaker 流水线,但如果您替换其他流水线解决方案,请寻找一些关键能力:

平台中立性

一个交付解决方案不必支持每个可能的供应商才能被视为平台中立的解决方案,但基于 Kubernetes 自定义资源定义的交付解决方案保证会锁定到特定平台。有了这种锁定,您在部署环境中的任何异构性都意味着您将要使用多种工具。在足够规模的企业中,混合平台使用非常普遍(这也是应该的)。

自动化触发

流水线应该能够根据事件自动触发,尤其是根据工件输入的变化。我们将更多地讨论工件触发如何帮助您以安全和可控的方式重新布置基础设施,详见《云端打包》。

可扩展性

一个良好的流水线解决方案考虑到不同流水线阶段的根本不同的计算特性。“部署”阶段通过调用平台 API 端点来提供新资源,其计算需求非常低,即使该阶段可能运行几分钟。一个流水线执行服务的单个实例可以轻松并行运行数千个这样的阶段。“执行脚本”阶段执行类似 Gradle 任务的操作,其资源需求任意复杂,因此最好将其委托给像容器调度器这样的东西,以确保阶段执行的资源利用不会影响流水线执行服务的性能。

当使用持续集成产品执行部署操作时,它们通常会以显着的方式浪费资源。我曾经参观过的一家金融机构使用 CI 系统 Concourse 进行交付操作,每年的成本达到数百万美元。对于这样的组织来说,运行 30 个 m4.large 预留实例在 EC2 上支持一个 Spinnaker 安装每年只需花费超过 15,000 美元。资源的低效性很容易朝另一个方向转变。任意计算复杂度的阶段不应该在主机上或者在 Spinnaker 的 Orca(即流水线)服务中运行。

各种云提供商的感觉非常不同。可部署资源对于每种类型的抽象级别也是不同的。

Spinnaker 流水线由大致与云中性相关的阶段组成。也就是说,每个云提供商实现将有相同的基本构建块可用,但是像 “部署” 这样的阶段的配置会因平台而异。

图 5-2 展示了一个将部署到 Kubernetes 的 Spinnaker 流水线的定义。流水线可以非常复杂,包含并行阶段和在特殊配置阶段上定义的多个触发器。

srej 0502

图 5-2. 展示了一个详细视图的 Spinnaker 流水线,显示了多个阶段

Spinnaker 定义了几种不同的触发器类型。这个流水线是通过发布到 Docker 注册表的新容器镜像来触发的,如 图 5-3 所示。

srej 0503

图 5-3. Spinnaker 期望的构件定义

图 5-4 展示了两个 Spinnaker 流水线的执行历史,包括我们刚刚看到的配置。阶段流水线是通过 Docker 注册触发器(发布到 Docker 注册表的新容器)最后执行的。在其他情况下,流水线是手动触发的。

srej 0504

图 5-4. Spinnaker 查看两个不同交付流水线的视图

任何交付流水线的第一个任务是将应用程序打包成一个不可变的部署单元,可以在服务器组的实例中复制。

云端的打包

不同云平台提供的各种抽象层面上,存在启动时间、资源效率和成本的权衡。但正如我们将看到的,从包装微服务到部署的各自工作量之间应该没有显著差异。

start.spring.io 生成一个新的应用程序包括生成一个 Gradle 或 Maven 构建,可以生成一个可运行的 JAR。对于像 Cloud Foundry 和 Heroku 这样的 PaaS 平台,这个可运行的 JAR 部署的输入单元。由云提供商负责获取这个可运行的 JAR 并将其容器化或以其他方式打包,然后为其运行提供一些基础资源。

对于除了 PaaS 外的云平台,应用团队所需的工作量惊人地并没有太大差异。这里的示例使用 Gradle 实现,因为开源工具同时适用于 IaaS 和 CaaS 的用途。同样,类似的工具也可以为 Maven 生产。

PaaS 的典型价值主张之一是,您只需将应用程序二进制文件作为部署过程的输入,并让 PaaS 代表您管理操作系统和软件包补丁,甚至是透明地。但实际操作并非完全如此。在 Cloud Foundry 的情况下,该平台负责以滚动方式进行一定程度的补丁操作,影响服务器组中的一个实例(在 Cloud Foundry 术语中称为“应用程序”)。但这样的补丁操作会带来一定程度的风险:操作系统的任何部分更新都可能对正在其上运行的应用程序产生不利影响。因此,这里存在着风险与回报的权衡,该权衡会仔细界定平台愿意代表用户自动化的更改类型。所有其他的补丁/更新都将应用于平台将应用程序放置在其上的“类型”镜像。Cloud Foundry 称这些为构建包(buildpacks)。例如,Java 版本升级涉及对构建包的更新。平台不会自动更新每个正在运行使用 Java 构建包的应用程序的构建包版本。这确实取决于组织是否重新部署每个使用 Java 构建包的应用程序来获取更新。

对于非 PaaS 环境来说,通过从构建生成除 JAR 之外的另一种类型的工件(或在部署流水线中增加额外阶段),可以在整个组织中更大程度地控制和灵活性地处理基础设施的补丁。虽然 IaaS 和 CaaS 之间的基础镜像类型不同(分别是虚拟机和容器镜像),但在基础镜像上构建您的应用程序的原则允许您将应用程序二进制文件和其上层叠的基础镜像作为每个微服务交付流水线的独立输入。图 5-5 展示了一个假想的微服务交付流水线,首先部署到测试环境,运行测试,并经过审计检查,最终部署到生产环境。请注意,在此示例中 Spinnaker 支持多种触发类型:一种用于新的应用程序二进制文件,另一种用于新的基础镜像。

srej 0505

图 5-5. 基础镜像的更改会触发流水线

在同一组织中,不同的微服务可能需要更多或更少的阶段来验证应用程序工件和基础镜像的组合是否适合推广到生产环境。使基础镜像的变更触发交付流水线是安全和速度的理想平衡点。交付流水线包含所有完全自动化阶段的微服务可能在几分钟内采用新的基础镜像,而具有更严格手动验证和批准阶段的服务可能需要几天时间。这两种服务都以最符合负责团队独特文化和要求的方式采用变更。

适用于 IaaS 平台的打包

对于 IaaS 平台,部署的不可变单元是虚拟机镜像。在 AWS EC2 中,此镜像称为 Amazon Machine Image。创建镜像只需实例化基础镜像(其中包含所有微服务的公共偏好设置,如 Java 版本、常见系统依赖项以及监控和调试代理),在其上安装包含应用程序二进制文件的系统依赖项,然后对结果镜像进行快照,并在配置新服务器组时将此镜像作为模板使用。

将实例供应、安装系统依赖项并创建快照的过程称为烘焙。甚至不必启动基础镜像的实时副本也是可能的。HashiCorp 的经过考验的开源烘焙解决方案 Packer 适用于各种不同的 IaaS 提供商。

图 5-6 显示了构建工具、烘焙工厂和由云提供商管理的服务器组的责任边界。Spinnaker 流水线阶段负责启动烘焙过程,并使用烘焙阶段产生的镜像创建服务器组。它显示每个微服务构建的额外要求,即生产系统依赖项,意味着在 Ubuntu 或 Debian 基础镜像上生产 Debian 包,在 Red Hat 基础镜像上生产 RPM 等。最终,烘培工厂将以某种方式调用操作系统级别的包安装程序,将应用程序二进制文件叠加到基础镜像上(例如,apt-get install <system-package>)。

srej 0506

图 5-6. IaaS 打包参与者

利用 Netflix 的 Nebula Gradle 插件套件中的 Gradle 插件,如 示例 5-1 所示,生成 Debian 或 RPM 系统依赖项非常简单。这将在构建文件中添加一个名为 buildDeb 的 Gradle 任务,该任务完成所有生成 Spring Boot 应用程序的 Debian 包所需的工作。这只需要对构建文件进行一行更改!

示例 5-1. 使用 Nebula Gradle 插件生成 Debian 包
plugins {
  id("org.springframework.boot") version "LATEST"
  id("io.spring.dependency-management") version "LATEST"
  id("nebula.ospackage-application-spring-boot") version "LATEST" ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
}

...

1

Gradle 插件门户网站 上的最新版本替换 LATEST,因为 LATEST 实际上对 Gradle 插件版本规范无效。

ospackage 插件包含各种选项,用于添加启动脚本,配置配置文件和可运行的文件的输出位置等。不过,无论这些文件发生在何处和发生了什么,组织中的微服务之间应该有足够的共性,以类似于 Netflix 对 nebula.ospackage-application-spring-boot 所做的方式封装这些观点,并将它们作为一个构建工具插件进行分发,以便采用变得微不足道。

为容器调度器打包

准备微服务以部署到像 Kubernetes 这样的容器调度器可能会类似。有可供选择的工具可以为常见框架(如 Spring Boot)打包,如 Example 5-2 所示。这个插件还了解如何发布到 Docker 注册表,只需进行一些配置(可以很容易地封装并作为组织内的常见构建工具插件进行发布)。

示例 5-2. 使用 Nebula Gradle 插件生成和发布 Docker 镜像
plugins {
  id("org.springframework.boot") version "LATEST"
  id("io.spring.dependency-management") version "LATEST"
  id("com.bmuschko.docker-spring-boot-application") version "LATEST"
}

if (hasProperty("dockerUser") && hasProperty("dockerPassword")) {
  docker {
    registryCredentials {
      username = dockerUser
      password = dockerPassword
      email = "bot@myorg.com"
    }

    springBootApplication {
      tag = "$dockerUser/${project.name}:${project.version}"
      baseImage = "openjdk:8"
    }
  }
}

依赖开源构建工具,但小心使用没有经过验证的基础镜像

拥有像本 Muschko 的 Gradle Docker 插件这样的工具很好,可以生成一个包含在某个基础之上构建的应用程序的镜像。但你应该期望你组织中有人正在验证和创建经过批准的镜像,已知性能良好且没有已知缺陷和安全漏洞。这适用于 VM 和容器镜像。

这种方法的缺点是操作系统和其他系统软件包更新是基础 Docker 镜像的一部分,用于生成应用容器镜像。然后需要将基础容器镜像的更改传播到整个组织,这要求我们重新构建应用程序二进制文件,这可能会很不方便。毕竟,要想仅仅改变基础镜像而不改变应用程序代码(可能自上次构建以来已经有一系列的源代码更改),我们必须将应用程序代码签出到生产版本的哈希值,并使用新的镜像重新构建。这个过程,因为涉及再次构建,可能会导致应用程序二进制文件无法复现,而我们只是想要更新基础镜像。

在容器化工作负载中添加一个烘烤阶段可以通过删除完全消除需要发布容器镜像的必要性(只需将 JAR 发布到 Maven 构件库),并允许大规模更新基础镜像,再次采用与 IaaS 基础工作负载触发器相同的流程和安全保证。Spinnaker 支持使用 Kaniko 烘烤容器镜像,从而无需将容器镜像构建/发布作为构建工作流的一部分。这样做的一个优点是,您可以在更新的基础上重新烘烤相同的应用程序二进制文件(例如,在基础中修复安全漏洞时),有效地运行应用程序代码的不可变副本。

令人惊讶的是,跨所有三种云抽象(IaaS、CaaS 和 PaaS)实现安全的基本更新的愿望导致了所有三者都采用了非常类似的工作流程(以及类似的应用程序开发者体验)。实际上,部署的便捷性不再是这些抽象层次之间的决策标准,我们必须考虑其他差异化因素,比如启动时间、厂商锁定、成本和安全性。

现在我们已经讨论了打包问题,让我们转向可以用来在您的平台上部署这些包的部署策略。

删除 + 无部署

如果“删除 + 无部署”听起来很丑陋,那是因为我即将描述的这种小技巧可能仅在某些狭窄情况下有用,但它有助于为随后的其他部署策略奠定框架。

基本思想就是简单地删除现有的部署并部署新的。显然,这样做会导致停机时间,无论多么短暂。停机时间的存在表明,版本间的 API 兼容性并不严格要求,只要您在服务的所有调用方在同一时间进行版本更改的部署协调即可。

后续的每种部署策略都将实现零停机时间。

要将这个概念与您可能熟悉的非不可变部署实践联系起来,当在始终运行的虚拟机上安装并启动新的应用程序版本(替换之前运行的版本)时,就会使用删除 + 无部署部署策略。再次强调,本章仅专注于不可变部署,随后的任何其他部署策略都没有明显的可变对应策略。

这种策略在执行基本的 cf push(Cloud Foundry 的命令)时也会使用,在 AWS EC2 上操作重新配置 Auto Scaling Group 以使用不同的 Amazon Machine Image 时同样适用。关键在于,通常基本的 CLI 或控制台部署选项确实接受停机时间,并且更多或少地按照这种策略操作。

下一个策略类似,但是没有停机时间。

高地人

尽管名称奇特,但 Highlander 策略实际上是当今实践中最常见的零停机策略。名称源自《炫目之剑》电影中的一句口号:“只能有一个。”换句话说,当你部署服务的新版本时,你会替换旧版本。只能有一个。部署结束时,只有新版本在运行。

Highlander 策略是零停机时间的。在实践中,它涉及部署应用程序的新版本并将其添加到负载均衡器,这会导致在销毁旧版本时短时间内同时为两个版本提供服务。因此,这种部署策略的更准确的口号可能是“通常只有一个”。跨版本所需的 API 兼容性源于这种短暂重叠的存在。

Highlander 模型简单,其简单性使其成为许多服务的有吸引力的选择。由于任何给定时间只有一个服务器组,所以无需担心协调以防止来自“其他”不应处于服务状态的运行版本的干扰。

在 Highlander 策略下返回到先前版本的代码涉及重新安装旧版本的微服务(该微服务接收一个新的服务器组版本号)。因此,此伪回滚操作完成所需的时间是安装和初始化应用程序进程所需的时间。

下一个策略提供更快的回滚速度,但需要一些协调和复杂性。

蓝/绿部署

蓝/绿部署策略涉及至少两个微服务副本(无论是启用还是禁用状态),涉及到旧版本和新版本的服务器组。在任何给定时间,生产流量都是从这些版本中的一个版本提供的。回滚只是切换哪个副本被视为活动副本。向前滚动到更新版本具有相同的体验。如何实现这种切换逻辑取决于云平台(但由 Spinnaker 编排),但在高层次上涉及影响云平台的负载平衡器抽象以将流量发送到一个版本或另一个版本。

kubectl apply 默认是一种特定类型的蓝/绿部署。

kubectl apply更新 Kubernetes 部署(从 CLI 而不是使用 Spinnaker)默认是滚动蓝/绿部署,允许您回滚到表示先前版本的 ReplicaSet。因为它是一种容器部署类型,所以回滚操作涉及将镜像拉回来。Kubernetes 部署资源在管理滚动蓝/绿部署和回滚的 ReplicaSet 之上实现为控制器。Spinnaker 为 Kubernetes ReplicaSets 提供了更多控制,可以启用蓝/绿功能,包括禁用版本,金丝雀部署等。因此,将 Kubernetes 部署视为一种有限的,持有的蓝/绿部署策略。

负载均衡器切换可能会对部署资产的结构产生影响。例如,在 Kubernetes 上,蓝绿部署基本上要求您使用 ReplicaSet 抽象。蓝绿策略要求 运行 资源通过某种方式进行编辑以影响流量。对于 Kubernetes,我们可以通过标签操作来实现这一点,这就是 Spinnaker Kubernetes 实现用来实现蓝绿部署的方法。如果我们尝试编辑 Kubernetes 的 Deployment 对象,将触发一次滚动更新。Spinnaker 会自动向 ReplicaSet 添加特殊标签,间接导致它们被视为启用或禁用,并在服务上添加标签选择器以仅将流量路由到启用的 ReplicaSet。如果您不使用 Spinnaker,则需要创建一些类似的过程,在 ReplicaSet 上原地修改标签,并配置服务以识别这些标签。

蓝/绿色暗示有两个服务器组,其中蓝色或绿色服务器组中的一个正在提供流量服务。蓝绿策略并不总是二元的,颜色也不应暗示这些服务器组需要长期存在,随着新服务版本的可用性而变化。

蓝绿部署通常在任何给定集群中是一个 1:N 的关系,其中一个服务器组是活跃的,而 N 个服务器组是非活跃的。这种 1:N 蓝绿集群的可视化表示如图 5-7 所示。

srej 0507

图 5-7. Spinnaker 蓝绿集群

回滚服务器组操作,如图 5-8 所示,允许选择这些禁用的服务器组版本之一(V023–V026)。回滚完成后,当前的活跃版本(V027)仍将存在,但被禁用。

srej 0508

图 5-8. Spinnaker 回滚服务器组操作

根据底层云平台的支持情况,禁用的集群可以保留不接收任何流量的运行实例,或者可以减少到零实例,准备回滚以进行扩展。为了实现最快的回滚形式,禁用的集群应该保留活跃实例。当然,这会增加服务的费用,因为现在你不仅需要支付用于提供实时生产流量的实例集合的成本,还需要支付先前服务版本中剩余实例的成本,这些实例有可能会回滚到。

最终,您需要评估回滚速度与成本之间的权衡,其光谱显示在图 5-9 中。这应该基于每个微服务而不是整个组织来完成。在蓝/绿部署中,对需要运行数百个实例的微服务维护完全缩放禁用的服务器组所产生的额外运营成本,与对只需要少数实例的服务所产生的额外成本并不相等。

srej 0509

图 5-9。按部署策略权衡操作成本与回滚速度

当一个微服务不是纯粹的 RESTful 时,不完全缩放到零禁用集群的蓝/绿部署策略对应用代码本身有影响。

例如考虑一个(至少部分地)事件驱动的微服务,它对 Kafka 主题或 RabbitMQ 队列上的消息做出反应。将负载均衡器从一个服务器组转移到另一个服务器组对这类服务连接到它们的主题/队列没有影响。在某种程度上,应用代码需要响应由外部进程将其置于服务外部的情况,本例中为蓝/绿部署。

同样地,运行在禁用服务器组的实例上的应用程序进程需要响应由外部进程(例如 Spinnaker 中的回滚服务器组操作)将其重新置于服务中,在这种情况下重新连接到队列并开始处理工作。Spinnaker 的 AWS 实现蓝/绿部署策略意识到了这个问题,当也在使用Eureka服务发现时,使用 Eureka 的 API 端点影响服务的可用性,如表 5-1 所示。

注意这是基于每个实例进行的。Spinnaker 通过定期轮询部署环境的状态来意识到存在哪些实例,从而帮助构建这种自动化。

表 5-1。影响服务可用性的 Eureka API 端点

操作 API 注释
将实例置于服务外部 PUT /eureka/v2/apps/appID/instanceID/status?value=OUT_OF_SERVICE
将实例重新置于服务中(移除覆盖) DELETE /eureka/v2/apps/appID/instanceID/status?value=UP value=UP 是可选的;它被用作由于移除覆盖而建议的回退状态

这假定您的应用程序正在使用 Eureka 服务发现客户端注册到 Eureka。但这样做意味着您可以添加一个 Eureka 状态变更事件监听器,如示例 5-3 所示。

示例 5-3。
// For an application with a dependency on
// 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
@Bean
ApplicationInfoManager.StatusChangeListener statusChangeListener() {
  return new ApplicationInfoManager.StatusChangeListener() {
    @Override
    public String getId() {
      return "blue.green.listener";
    }

    @Override
    public void notify(StatusChangeEvent statusChangeEvent) {
      switch(statusChangeEvent.getStatus()) {
        case OUT_OF_SERVICE:
          // Disconnect from queues...
          break;
        case UP:
          // Reconnect to queues...
          break;
      }
    }
  };
}

自然而然,可以使用Consul来实现相同类型的工作流程,它是一个动态配置服务器,允许进行标记(例如,按服务器组名称或集群标记),或者任何其他具有以下两个特征的中心数据源:

  • 应用程序代码可以通过某种事件监听器几乎实时地响应变更事件。

  • 数据可以按服务器组、集群和应用程序至少分组,并且您的应用程序代码能够确定它属于哪个服务器组、集群和应用程序。

相应地响应服务可用性的外部修改的要求也适用于使用持久化 RPC 连接(如RSocket或流/双向GRPC)的微服务,其中禁用的服务器组需要终止任何持久化的 RPC 连接,无论是出站还是入站。

必须监听发现状态事件(或任何其他外部指示器服务可用性)中隐藏且重要的一点是:应用程序意识到其在服务发现中的参与。服务网格(参见“服务网格中的实现”)的目标是将这种责任从应用程序中移出,并将其外部化到旁路进程或容器中,通常是为了快速实现这些模式的多语言支持。稍后我们将讨论该模型的其他问题,但是在消息驱动应用程序的蓝/绿部署中,您希望保留处于禁用状态的服务器组中的活动实例,这是语言特定绑定(在这种情况下是服务发现)必要的一个例子。

名称中有什么?

蓝/绿部署与红/黑部署是相同的事物。它们只是不同的颜色组合,但是这些技术确实具有完全相同的意义。

在考虑更复杂的策略(例如自动金丝雀分析)之前,每个团队都应该在实施蓝/绿部署之前进行练习。

自动金丝雀分析

蓝/绿部署通常在大多数情况下以相对较低的成本实现了很高的可靠性。并非每个服务都需要进一步发展。然而,我们可以追求额外的安全级别。

虽然蓝/绿部署允许您快速回滚导致意外问题的代码或配置更改,但金丝雀发布通过向现有版本旁边运行的新版本服务的小子集暴露,提供了额外的风险降低级别。

并非每个服务都适合金丝雀部署。低吞吐量的服务使得将流量的一小部分发送到金丝雀服务器组变得困难,但并非不可能,而且不会延长金丝雀适应性的决定时间。金丝雀适应性决策需要花费的时间没有一个正确的数量。对于您来说,在相对低吞吐量的服务上运行几天的金丝雀测试以做出决定可能是完全可以接受的。

许多工程团队存在明显的偏差,低估其服务实际接收的流量,因此认为金丝雀分析等技术无法适用于他们。回想一下“学会预期失败”提到的现实团队,他们的业务应用每分钟接收超过 1,000 个请求。这个吞吐量比大多数该团队工程师的猜测要高得多。这也是实际生产遥测数据应该是首要任务的另一个原因。建立起甚至是短期内在生产中发生的情况的历史,可以帮助你更好地决定哪种技术,例如部署策略,在后续情况下是适当的。

我的服务永远不能失败,因为它太重要了

要谨慎对待那种避开自动化金丝雀分析策略的推理,仅仅因为某个微服务对于不失败太重要。相反,应采取一种观念,即失败不仅可能发生,而且无论其对业务的重要性如何,每个服务都会发生故障,据此行事。

金丝雀部署的适应性通过比较旧版本和新版本的服务水平指标来确定。当一个或多个这些 SLI 出现显著恶化时,所有流量都会路由到稳定版本,并且金丝雀测试将被中止。

理想情况下,金丝雀部署应包括三个服务器组,如图 5-10 所示。

srej 0510

图 5-10. 金丝雀发布参与者

这些金丝雀部署可以描述如下:

生产环境

这是金丝雀部署之前的现有服务器组,包含一个或多个实例。

基线

这个服务器组运行与生产服务器组相同版本的代码和配置。虽然一开始运行另一个旧代码副本似乎有些违反直觉,但我们需要一个基线,它大致在金丝雀发布时启动,因为生产服务器组由于运行了一段时间,可能具有不同的特征,比如堆消耗或缓存内容。能够准确比较旧代码和新代码之间的差异非常重要,而最佳方法是大致同时启动每个副本。

金丝雀

这个服务器组包含了新的代码或配置。

金丝雀的适应性完全由将一组指标相对于基准线(而不是生产集群)进行比较来确定。这意味着正在进行金丝雀测试的应用正在发布带有cluster公共标签的度量,以便金丝雀分析系统可以聚合来自属于金丝雀和基准集群的实例的指标,并相互比较这两个聚合指标。

相对比较远比测试一个金丝雀针对一组固定阈值要可取得多,因为固定阈值往往对测试时系统的吞吐量做出某些假设。图 5-11 展示了这个问题。在高峰业务时间,应用展示出更高的响应时间,这时系统流量最大(这是典型情况)。因此,对于固定阈值,也许我们试图设置一个数值,如果响应时间比正常情况差 10%以上,金丝雀测试就会失败。在高峰业务时间,金丝雀测试可能会失败,因为相对于基准线,其性能比差了超过 10%。但是如果我们在非高峰业务时间运行金丝雀测试,它可能会比基准线差得多于 10%,但仍然在设定的固定阈值内,因为这个固定阈值是相对于不同操作条件设定的。相对比较的方法更有可能在测试运行时无论是在或者非业务高峰期,都能捕捉到性能下降。

srej 0511

图 5-11. 对固定阈值的金丝雀测试

在多个场合,我会和组织讨论自动化交付实践,谈论到金丝雀部署,这个想法听起来如此吸引人,以至于激发了对这个主题的兴趣。通常,这些组织没有维度度量仪器,也没有类似蓝/绿部署的自动发布流程。也许是因为金丝雀部署的安全性吸引力,平台有时会包含金丝雀部署功能。通常情况下,它们缺少基线和/或比较测量,因此从这个角度评估平台提供的金丝雀功能,并决定是否放弃其中一个或两个功能,这在许多情况下并不明智。我建议在许多情况下都不应该。

在三集群设置(生产、基准、金丝雀)中,大部分流量将流向生产集群,少量流向基准和金丝雀。金丝雀部署使用负载均衡器配置、服务网格配置或任何其他平台功能来按比例分发流量。

图 5-12 展示了参与 canary 测试的三个集群的 Spinnaker 基础设施视图。在这个案例中,它们在一个名为“PROD-CLUSTER”的单个 Kubernetes 集群上运行(“cluster” 指的是 Kubernetes 集群,不是我们在本章开头定义的交付定义中的含义)。

Spinnaker 与一个开源自动化 canary 分析服务集成,该服务封装了来自 baseline 和 canary 集群的度量评估。

srej 0512

图 5-12. 应用程序 undergoing a canary 的三个集群

Spinnaker 配合 Kayenta 使用

Kayenta 是一个独立的开源自动化 canary 分析服务,也通过管道阶段和配置深度集成到 Spinnaker 中。

Kayenta 确定每个指标的 canary 和 baseline 之间是否存在显著差异,得出 passhighlow 的分类结果。Highlow 都属于失败条件。Kayenta 使用 Mann-Whitney U test 在两个集群之间进行统计上的比较。这个统计测试的实现称为 judge,Kayenta 可以配置使用其他的 judge,但它们通常涉及超出单一查询度量系统所能达到的代码。

图 5-13 展示了 Kayenta 对多个指标进行分类决策的示例。这张截图来自原始的 Netflix 博客 关于 Kayenta 的内容。在这个案例中,延迟未通过测试。

srej 0513

图 5-13. Canary 指标

在 Spinnaker 中,一个应用程序的 canary 指标可以在应用程序基础设施视图的“Canary Configs”选项卡中定义。在配置中,如 图 5-14 所示,您可以定义一个或多个服务水平指标。如果足够多的这些指标失败,canary 将失败。

srej 0514

图 5-14. Spinnaker 中一个应用程序的 canary 配置

图 5-15 展示了单个指标的配置,即处理器利用率。请注意,配置包含一个针对监控系统的特定度量查询,您已经配置 Kayenta 来从中轮询(在本案例中是 Prometheus)。然后,您广泛指示增加或减少(或任何方向的偏差)被认为是不良的。在这种情况下,我们不希望看到处理器利用率显著增加,尽管减少则是受欢迎的。

另一方面,对于应该以某一速率持续处理的应用程序来说,服务吞吐量的减少将是一个不良信号。该指标可以标记为足够严重,仅仅失败就应该导致金丝雀失败。

srej 0515

图 5-15. 处理器利用率金丝雀配置

金丝雀配置一旦建立,就可以在流水线中使用。如图 5-16 所示,一个典型的金丝雀部署流水线。在“配置”阶段定义了触发器,开始评估金丝雀的流程。“将集群名称设置为金丝雀”设置了一个变量,Spinnaker 在随后的“部署金丝雀”阶段中使用该变量来命名金丝雀集群。正是这个变量最终产生了如图 5-12 所示的命名金丝雀集群。

srej 0516

图 5-16. Spinnaker 中的金丝雀部署流水线

与此同时,Spinnaker 正在检索当前生产版本所基于的工件,并使用这些工件创建基线集群。 “金丝雀分析”阶段的运行时间可能长达数小时甚至数天,具体取决于其配置。如果测试通过,我们将部署一个新的生产集群(使用用于创建金丝雀的相同工件,这些工件可能不再是存储库中最新的版本)。同时,可以拆除不再需要的基线和金丝雀集群。整个流水线可以在 Spinnaker 中配置为串行运行,以便每次只评估一个金丝雀。

金丝雀运行的结果可以通过几种不同的方式查看。Spinnaker 提供了一个“金丝雀报告”选项卡,显示了每个服务水平指标的判断结果,以及单独评估每个进入决策的指标。每个指标可以作为时间序列图在金丝雀运行期间查看,就像 图 5-17 中显示的那样。

srej 0517

图 5-17. 基线和金丝雀中 CPU 利用率的时间序列可视化

当前生产版本并不总是最新版本

注意,从中创建基线的当前生产版本并不总是应用程序二进制(例如 JAR 或 WAR)存储库中最新的版本。在某些情况下,它实际上可能是几个版本较旧的版本,这是因为我们曾试图发布新版本,但它们在试验或其他情况下被回退了。像 Spinnaker 这样的有状态持续交付解决方案的一个价值在于其能力,即轮询环境以获取当前状态,并基于此信息采取行动。

或者,可以将指标的比较视为条形图(或直方图),如 图 5-18 中所示。

srej 0518

图 5-18. 99 百分位延迟的直方图可视化

最后,也许最有用的是,可以将金丝雀和基准之间的比较可视化为蜂群图,如图 5-19 所示。金丝雀随时间而判断,Kayenta 定期轮询监控系统以获取金丝雀和基准的值。这里的单个样本显示在蜂群图上,以及显示所有样本的基本四分位数(最小值、25th 百分位数、中位数、75th 百分位数和最大值)的箱形图。中位数肯定增加了,但正如在第二章中讨论的那样,像均值和中位数这样的中心度量并不真正有用于判断服务的适用性。这个图表确实突显了这一事实。最大值甚至 75%的延迟在版本之间几乎没有变化。因此,中位数的变化略有增加,但这可能根本不表示性能退化。

srej 0519

图 5-19. 99 百分位延迟的蜂群图可视化

金丝雀分析的关键指标有时会与我们用于警报的指标不同,因为它们是为了在两个集群之间进行比较分析而设计的,而不是针对绝对测量。即使新的应用程序版本仍然在我们设置为警报测试的服务水平目标边界之下,最好的代码的一般轨迹仍然不要继续向该服务水平目标逐渐退化。

为每个微服务提供通用的金丝雀指标

在考虑起始时有用的金丝雀指标时,请考虑 L-USE 首字母缩略词。事实上,对于大多数微服务应该发出警报的相同的服务级别指标也是很好的金丝雀指标,只是稍有不同。

让我们考虑一些好的金丝雀指标,首先是延迟。实际上,第四章中描述的任何信号都是金丝雀分析的好候选者。

延迟

一些指示性 API 端点的延迟是一个很好的起点。将指标限制在成功的结果上,因为成功的结果往往具有与不成功的结果不同的延迟特性。想象一下,在生产中修复了导致关键 API 端点失败的错误后,由于该错误导致 API 端点快速失败,而金丝雀却认为修复该错误导致了延迟过高而失败!

在 第四章 中,这个想法是测量一个定时操作的衰减 最大 延迟相对于固定的服务水平目标,这个服务水平目标是工程服务级别协议中的保守边界,与业务伙伴确定。但最大延迟往往是波动的。例如,Hypervisor 和垃圾收集暂停或完全连接池大多是暂时的条件(并且超出你的控制),自然会在不同时间影响实例。为了衡量应用程序相对于服务水平目标的适应性,我们希望确保即使在这些条件下,性能仍然是可接受的。但由于它们在不同实例上发生的交错性质,这些效果导致了不良的 比较 措施。

对于金丝雀,最好查看像第 99 百分位延迟这样的分布统计,它剔除了这些临时条件表现出来的顶部 1%。99th 百分位数(或其他高百分位数)通常是代码性能 潜力 的更好度量,减去临时环境因素。

从 “直方图” 中回忆,为了在群集中计算高百分位延迟(并且限制为特定端点的成功结果),我们需要使用像基于直方图数据的百分位近似这样的方法,可以在此群集中和任何其他标记变化之间进行累加,以此关键 API 端点的成功结果。目前只有少数监控系统支持可聚合的百分位近似。如果您的监控系统不能进行百分位近似,请勿尝试从实例中聚合单个百分位数(我们展示了为什么这样的数学不适用于 “百分位/分位数”)。此外,避免使用其他像平均值这样的测量方法。查看 图 5-18 中的蜂群图,了解像中位数和均值这样的中心性度量如何在版本之间(实际上甚至在相同版本的时间内!)有很大的变化,而没有任何真正的性能变化。

Average: 介于最大值和中位数的一半之间的随机数。通常用于忽视现实。

吉尔·特纳

要计算 Atlas 的可聚合百分位近似,使用 :percentiles 函数,如 示例 5-4 所示。

Example 5-4. Atlas 百分位延迟对金丝雀
name,http.server.requests,:eq,
uri,$ENDPOINT,:eq,
:and,
outcome,SUCCESS,:eq,
:and,
(,99,),:percentiles

对于 Prometheus,使用 histogram_quantile 函数,如 示例 5-5 所示。

Example 5-5. 金丝雀的 Prometheus 百分位延迟
histogram_quantile(
  0.99,
  rate(
    http_server_requests_seconds_bucket{
      uri="$ENDPOINT",
      outcome="SUCCESS"
    }[2m]
  )
)

同样地,你应该包括与关键下游资源的交互的延迟指标,如数据库。考虑关系数据库的交互。新代码可能会意外地导致现有数据库索引未被使用(显著增加延迟和数据库负载),或者新索引在投产后表现不如预期。无论我们如何努力在低级环境中复制和测试这些新的交互,实际生产环境永远不会如此。

错误比率

错误比率(在 Atlas 的 示例 5-6 和 Prometheus 的 示例 5-7 上)对于某些基准 API 端点(或全部端点)同样非常有用,因为这将确定您是否引入了语义回归问题,这些问题可能未被测试捕获,但却在生产中造成问题。

示例 5-6. Atlas 中 HTTP 服务器请求的错误比率
name,http.server.requests,:eq,
:dup,
outcome,SERVER_ERROR,:eq,
:div,
uri,$ENDPOINT,:eq,:cq
示例 5-7. Prometheus 中 HTTP 服务器请求的错误比率
sum(
  rate(
    http_server_requests_seconds_count{outcome="SERVER_ERROR", uri="$ENDPOINT"}[2m]
  )
) /
sum(
  rate(
    http_server_requests_seconds_count{uri="$ENDPOINT"}[2m]
  )
)

仔细考虑是否在单个金丝雀信号中包含多个 API 端点。假设您有两个单独的 API 端点,它们接收的吞吐量显著不同,一个接收每秒 1,000 次请求,另一个接收每秒 10 次请求。由于我们的服务并非完美(什么是完美?),旧代码在高吞吐量端点上以固定速率失败,每秒 3 次请求,但所有低吞吐量端点的请求都成功。现在想象我们进行代码更改,导致低吞吐量端点的每 10 次请求中有 3 次失败,但不会改变另一个端点的错误比率。如果这些端点被金丝雀判断一起考虑,判断可能会通过回归,因为错误比率略有上升(从 0.3% 到 0.6%)。然而,如果分开考虑,判断肯定会在低吞吐量端点的错误比率上失败(从 0% 到 33%)。

堆饱和度

堆利用率可以通过两种方式进行比较:对于总消耗相对于最大堆和分配性能。

总消耗由使用量除以最大值确定,如 示例 5-8 和 示例 5-9 所示。

示例 5-8. Atlas 堆消耗的金丝雀指标
name,jvm.memory.used,:eq,
name,jvm.memory.max,:eq,
:div
示例 5-9. Prometheus 堆消耗的金丝雀指标
jvm_memory_used / jvm_memory_max

分配性能可以通过分配量除以提升量来衡量,如示例 5-10 和 5-11 所示。

示例 5-10. Atlas 分配性能的金丝雀指标
name,jvm.gc.memory.allocated,:eq,
name,jvm.gc.memory.promoted,:eq,
:div
示例 5-11. Prometheus 分配性能的金丝雀指标
jvm_gc_memory_allocated / jvm_gc_memory_promoted

CPU 利用率

处理器 CPU 利用率可以相对简单地进行比较,如 示例 5-12 和 示例 5-13 所示。

示例 5-12. Atlas CPU 利用率金丝雀指标
name,process.cpu.usage,:eq
示例 5-13. Prometheus CPU 利用率金丝雀指标
process_cpu_usage

逐步增加金丝雀指标,因为失败的金丝雀测试会阻塞生产路径,可能不必要地减慢功能和错误修复的交付速度。金丝雀失败应调整为阻止危险的回归。

总结

本章介绍了连续交付概念的高层次,以 Spinnaker 作为其示例系统。你不需要急于采用 Spinnaker 以获取一些好处。对于许多企业来说,我认为清除两个障碍将极大地提高发布成功率:

蓝/绿能力

必须有一个支持 N 个活动禁用集群以便快速回滚并考虑到事件驱动应用程序独特需求的蓝/绿部署策略(因为仅切换负载均衡器不足以有效将事件驱动应用程序停止服务)。

已部署资产清单

必须有一些手段来查询部署资产的实时状态。通过定期轮询部署环境的状态,实际上比试图使每个可能的变异动作通过某些像 CI 服务器这样的中央系统并尝试从发生的所有个别变异中重建系统状态更容易(也可能更准确)。

进一步的目标是在交付系统中确保足够的访问和质量控制(再次强调,无论是 Spinnaker 还是其他系统),以允许团队之间的一些部署变化。对于某些部署,特别是静态资产或内部工具,蓝/绿部署可能不会带来显著好处。其他可能会频繁发布,因此需要蓝/绿部署策略中的多个禁用服务器组。有些启动速度快到在禁用的集群中拥有活跃实例会导致成本效率低下。一个以“护栏而非门栓”思维的平台工程团队将更倾向于允许这种管道多样性,而不是组织一致性,从而最大化每个团队独特的安全/成本权衡。

在下一章中,我们将假设已部署资产清单,用于构建一个到每个环境中运行的源代码的工件溯源链。

第六章:源代码可观察性

通过管道或其他可重复的过程实现安全交付是向前迈出的一步。然而,更重要的是展望如何从部署的资产状态开始观察运行系统。过多关注管道本身可能会导致您后来无法对部署的资产进行清单。

源代码与实时进程一样重要。在组织的源代码中,会指定内部组件与第三方库之间的依赖关系。依赖关系的微小变化可能导致应用程序无法使用。在开发者模仿他们在其他地方看到的工作时,会发现某些模式在整个组织中重复出现。即使是揭示攻击向量的模式,在意识到漏洞之前也会被模仿。在足够大的代码库中,即使是最小的 API 更改也可能显得难以克服。

在 Netflix 的代码库中,我们发现 Guava 版本在深层依赖树中的漂移有时几乎是致命的。试图在整个代码库中从一种日志记录库转换到另一种的尝试花费了多年时间,直到开发了一种组织范围的重构解决方案才得以实现。

另一个重要挑战是确定代码实际部署在多少不同环境中。例如,随着组织在持续交付方面取得进展,回滚的可能性意味着给定微服务在构件存储库中的最新发布版本可能与在生产环境中运行的版本不同。或者在金丝雀测试或蓝绿部署中,如果有超过一个活跃集群,你甚至不会只在生产中独占一个特定版本!

将部署资产映射到源代码对于非单体库(大多数情况)的组织尤为重要。限制内部依赖采纳的任何压力都有效地限制了您的持续集成的效果。

本章讨论的是构建工具。这里给出的例子是Gradle构建工具。这里描述的模式可以在 Maven 或任何其他 Java 构建工具中实现,但是 Gradle 生态系统提供了最丰富的可用具体例子,特别是在其最近在二进制依赖管理和 Netflix Nebula 插件生态系统方面的进展方面。我们还假设微服务的二进制构件以及它们包含的任何核心平台依赖项都发布到像JFrog ArtifactorySonatype Nexus这样的 Maven 风格的构件存储库。

虽然在软件交付生命周期中,构建工具出现在持续交付之前可能看起来有些奇怪,但是对于你在版本策略和需要包含的元数据种类的考虑来说,理解有状态持续交付工具为您做的生产资产清单是一个重要的前提条件。在给定正确数据的情况下,交付解决方案呈现的生产资产清单应该是溯源链的第一步。我们将涵盖一些需要在传统软件交付生命周期中注入的组成部分,以便您最终能够将部署的资产映射回运行在其中的源代码,如图 6-1 所示。溯源链至少应该引导到一个不可变的工件版本和提交哈希,可能还包括源代码方法级别的引用。

srej 0601

图 6-1。从代码变更到生产的溯源链

假设我们从像 Spinnaker 这样的持续交付工具(介绍见第五章)开始,告诉我们一个代表微服务的应用程序分布在多个云平台上,不同集群中运行着多个版本的代码。如果部署的资源标记了进入它们的工件信息,我们考虑如何确保这些工件信息能够指向微服务代码历史上的唯一可识别位置。

它始于我们从交付系统中需要的特性。

本章中使用的术语的含义

本章将使用类似实例服务器组集群的术语,这些术语在“资源类型”中已定义,在第五章的开始部分有介绍。

有状态资产清单

从代码变更到部署资产的可追溯性资产清单,使您能够回答有关系统当前状态的问题。

建立此清单的第一个目标是有一些记录系统,我们可以查询以列出我们的已部署资源(在生产和较低级别的非生产测试环境中)。这有多困难取决于您的代码可以部署到多少个以及哪些类型的地方。一些组织即使在数据中心中使用虚拟化硬件,也有一组固定的几乎不会改变的虚拟机名称(编号为几十个或几百个),每个虚拟机专用于特定的应用程序。您可以合理地维护一个静态列表,其中包含应用程序名称和托管它们的虚拟机。在资源更加弹性地提供的 IaaS 或 CaaS 中,我们确实需要查询云提供商以获取当前部署的资产列表。

GitOps 无法实现已部署资产清单

使用 GitOps,您将想要发生的事情的状态存储在 Git 中。在环境中实现的可能是非常不同的事情。这种分歧的最简单示例来自 Kubernetes。当您在 Git 中提交清单并引发部署操作时,Kubernetes 控制器会在目标集群中将该清单变异为可能不同的内容。随着供应商和项目在 Kubernetes 上添加了越来越多的 CRD,变异的范围和数量也在不断增加。通过kubectl get pod -o yaml得到的内容与您在 Git 中提交的清单不同。实际上,它甚至可能与另一个 Kubernetes 集群中的内容不同!关键是,即使您成功地将所有意向操作通过 Git 进行了限制,您在 Git 中仍然没有对已部署环境的真实图像。

Spinnaker 等系统的一个关键优点是其模型会对已部署基础设施的状态进行实时轮询,跨越 Spinnaker 支持的每个云提供商。换句话说,您可以在一个 API 调用中检索到一致的表示(以应用程序/集群/服务器组/实例为单位)跨越许多云平台的已部署基础设施。这是一个单一的窗格体验用于资产清查。这一优点有两个层面:

无需通过一个系统进行协调

通过主动轮询,我们不需要通过一个系统将已部署环境的所有可能变异集中起来,这个系统可以分开维护状态。理论上,您可以要求这样做,例如,通过强制执行“gitops”,即管理更新应用程序需要在 Git 中进行更新。在这种设置中,不能进行回滚、负载均衡器更改、手动启动服务器组或导致 Git 无法完全了解已部署环境状态的任何其他变异。

实时实例级状态

GitOps 或类似系统无法追踪服务器组中各个实例的状态。例如,AWS EC2 并不保证单个虚拟机的生存能力,只是自动扩展组将尽力在任何给定时间保持指定数量的实例。在 Kubernetes 中也是如此,单个 Pod 的生存能力也不受保证。如果度量遥测被标记为实例 ID、实例序数或 Pod ID,则可以方便地在违反某些服务级别目标时接收警报,能够深入到特定失败实例,并导航到实例级别的实时视图以采取一些补救措施。例如,您可以将一个失败的实例从负载均衡器中移出,允许“服务器组”机制启动另一个实例,并在最终终止之前调查失败实例的根本原因,从而解决立即的用户影响问题。

尽管实时实例级状态对操作非常有益,但它并不立即与我们讨论的工件来源的相关性高。我们真正需要的只是某些我们可以询问以列出运行中的服务器组、集群和应用程序的来源(参见示例 6-1)。在本章的其余部分,我们将使用一个 Java 伪代码来描述通过达到一定的来源信息水平应该能够得出的洞察。模型的第一部分封装了资源类型(参见“资源类型”),其定义在之前已经给出。方法的实现,例如getApplications(),取决于您使用的部署自动化。例如,getApplications()是对 Spinnaker 的 Gate 服务到端点/applications的 API 调用。

示例 6-1. 列出正在运行的部署资产
delivery
  .getApplications()
  .flatMap(application -> application.getClusters())
  .flatMap(cluster -> cluster.getServerGroups())

// Where... class Application {
  String name;
  Team owner;
  Stream<Cluster> clusters;
}

class Cluster {
  String cloudProvider;
  String name;
  Stream<ServerGroup> clusters;
}

class ServerGroup {
  String name;
  String region; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  String stack; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
  String version;
  boolean enabled;
  Artifact artifact;
}

1

例如,在 AWS 中的us-east-1或者 Kubernetes 中的命名空间。

2

环境的推广级别,如testproduction

对于 GitOps 系统,getApplications()将询问一个或多个 Git 存储库的状态。对于具有一组命名的几乎不会更改的虚拟机的私有数据中心,这可能是一个手动维护的静态列表。

注意,在各个阶段,您应该能够深入研究状态交付系统中数据的各种特征。例如,参见示例 6-2 以获取在 Kubernetes 中具有一些运行足迹的应用程序的团队列表。

示例 6-2. 发现在 Kubernetes 中运行应用程序的团队
delivery
  .getApplications()
  .filter(application -> application.getClusters()
    .anyMatch(cluster -> cluster.getCloudProvider().equals("kubernetes")))
  .map(application -> application.getTeam())
  .collect(Collectors.toSet())

这种深入了解的能力导致更好的可操作性。建立完整的溯源链后,例如,我们应该能够查找最近揭示的方法级安全漏洞。对于关键漏洞,可能希望首先解决生产堆栈,然后再跟进包含最终进入生产路径的低级环境。

为了能够确定服务器组包含哪个Artifact,我们需要适当的不可变发布版本。

发布版本控制

由于工件存储库是立即在持续交付之前的软件交付生命周期的一部分,唯一的二进制工件版本是工件溯源链中的第一步。对于给定的流水线运行,Spinnaker 跟踪该流水线的工件输入。任何生成的部署资产也将标记有此溯源信息。图 6-2 显示了 Spinnaker 如何跟踪作为流水线输入的 docker 镜像标签和摘要。

srej 0602

图 6-2. Spinnaker 解析的预期工件(注意,工件是通过摘要而不是标签标识的)

要使此图像版本标签能够唯一标识一段代码,该标签必须对于每个源代码和依赖项的唯一组合都是唯一的。也就是说,如果应用程序的源代码或依赖项发生任何更改,版本号都需要更改,并且需要使用此唯一版本号来检索工件。

Docker 镜像标签可变

Docker 注册表不保证标签的不可变性。而摘要并非与标签一一对应,尽管摘要是不可变的。发布新版本的 Docker 容器到容器注册表时的常见约定是使用标签“latest”和固定版本(如“1.2.0”)发布镜像。这样,“latest”标签实际上被覆盖,并且与“1.2.0”共享相同的摘要,直到下一个发布版本。尽管从注册表的角度看,镜像标签是可变的,但可以构建一个发布流程,实际上从不更改固定版本号,以便可以使用这种更易读的标签将图像与其中的代码关联起来。

成为部署输入的工件类型取决于目标云平台。对于像 Cloud Foundry 这样的 PaaS,它是一个 JAR 或 WAR;对于 Kubernetes,它是一个容器镜像;对于像 AWS EC2 这样的 IaaS,是从系统包(如 Debian 或 RPM)中“烘焙”的 Amazon Machine Image(参见“面向 IaaS 平台的打包”)。

无论我们是生成 JAR、容器镜像还是 Debian/RPM,都适用相同的版本控制方案考虑。

我们将此讨论缩小到发布到 Maven 仓库的 JAR 包(该讨论同样适用于发布到 Maven 仓库的 Debian 包),但生产不可变版本的最终目标对于发布容器映像到容器注册表也是一样的。

Maven 仓库

在构件存储库中,通常有两种类型的 Maven 仓库:发布仓库和快照仓库。发布仓库的结构是仓库的基础 URL 加上构件的组、构件和版本。组名中的任何点号在路径中用 / 分隔,以确定构件的位置。在 Gradle 中,对 Micrometer 核心 1.4.1 的依赖可以定义为 implementation io.micrometer:micrometer-core:1.4.1

这些依赖在 Maven 中央仓库的构件看起来像 图 6-3。

srej 0603

图 6-3. micrometer-core 1.4.1 的 Maven 中央仓库目录列表

此目录列表包含二进制 JAR(micrometer-core-1.4.1.jar)、校验和、描述模块的 Maven POM 文件、一组校验和,以及可选的源代码和 javadoc JAR 包。

Maven 发布版本是不可变的

通常,像 Maven 仓库中的版本 1.4.1 这样的构件版本是不可变的。任何其他代码都不应发布在此版本之上。

Maven 快照仓库的结构略有不同。图 6-4 展示了在 Maven 仓库中如何为依赖 implementation io.rsocket:rsocket-core:1.0.0-RC7-SNAPSHOT 结构化 RSocket 的快照。

srej 0604

图 6-4. RSocket 1.0.0-RC7-SNAPSHOT 的 Spring Artifactory 快照仓库

指向此目录列表的路径具有路径段 1.0.0-RC7-SNAPSHOT,但请注意,实际的构件中没有一个包含 SNAPSHOT。相反,它们在发布时将 SNAPSHOT 替换为时间戳。如果我们查看 maven-metadata.xml,我们将看到它维护了发布到此快照仓库的最后时间戳的记录,如 示例 6-3 所示。

示例 6-3. RSocket 1.0.0-RC7-SNAPSHOT 的 Maven 元数据
<?xml version="1.0" encoding="UTF-8"?>
<metadata modelVersion="1.1.0">
  <groupId>io.rsocket</groupId>
  <artifactId>rsocket-core</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <versioning>
    <snapshot>
      <timestamp>20200423.184223</timestamp>
      <buildNumber>24</buildNumber>
    </snapshot>
    <lastUpdated>20200423185021</lastUpdated>
    <snapshotVersions>
      <snapshotVersion>
        <extension>jar</extension>
        <value>1.0.0-RC7-20200423.184223-24</value>
        <updated>20200423184223</updated>
      </snapshotVersion>
      <snapshotVersion>
        <extension>pom</extension>
        <value>1.0.0-RC7-20200423.184223-24</value>
        <updated>20200423184223</updated>
      </snapshotVersion>
      ...
    </snapshotVersions>
  </versioning>
</metadata>

每次发布新的快照,构件存储库都会更新此 maven-metadata.xml。这意味着依赖项 implementation io.rsocket:rsocket-core:1.0.0-RC7-SNAPSHOT 是不可变的。如果我们使用版本 1.0.0-RC7-SNAPSHOT 标记了某个部署资产,我们没有足够的特定信息来将此快照关联到在部署发生时最新的快照时间戳。

换句话说,Maven 快照版本的问题在于构件库中不能唯一标识二进制依赖项的来源。

微服务版本控制与库版本控制也不同,因为随时可能将在低级环境中进行测试的候选版本 提升 到生产环境。如果我们不检查具有类似快照版本的候选二进制文件,并决定其适合提升到生产环境中,重新构建 该二进制文件是不安全的。重新构建二进制文件既浪费时间,也浪费构件库存储空间。更重要的是,这引入了任何构建的一部分(或构建时机条件可能会影响生成的二进制文件)不可重复的可能性。

总结我们讨论的微服务版本控制的两个原则,我们需要以下内容:

独特性

每个唯一的源代码和依赖组合都有一个版本号

不变性

保证在构件库中不会覆盖构件版本

Maven 快照不符合独特性测试。

虽然有各种各样的发布版本控制方案可以工作,但使用开源构建工具的一个相对简单的方法可以显著减少微服务版本控制的苦力,同时满足这两个测试。

发布版本控制的构建工具

Netflix Nebula 套件包含一个发布插件,提供了一个方便的工作流程,用于计算满足这两个测试的版本号。它包含一组 Gradle 任务用于为您的项目进行版本控制:finalcandidatedevSnapshot。在开发库时,通常会生成快照,直到接近发布时,然后可能进行一次或多次发布候选版本,最后生成最终版本。对于微服务版本控制,情况有所不同,因为随时可能将低级环境中运行的特定代码迭代提升到生产环境。图 6-5 展示了一个迭代周期。在这个示例工作流程中,在部署管道的末尾进行标记会提升次要发布号 N 到下一个开发迭代。

srej 0605

图 6-5. 微服务发布版本循环

在代码更改、持续集成构建和生成工件并将其存储在工件存储库中的循环以及交付自动化在较低级别环境中设置新工件的过程中,可能会多次执行,然后才最终促进到生产环境的部署。在只有一个循环的专门情况下,这是理想化的持续部署模型。当在较低级别环境中(可能还会对该较低级别环境进行一些自动化测试)成功部署时,就会进行生产推广。是否立即运送变更并不重要于版本方案。

每次运行构建时,Nebula 发布会查看存储库上的最新标签,例如v0.1.0,并选择(默认情况下)生成下一个次要版本的快照(以及候选版本和库的最终构建)。在本例中,这将是次要版本 0.2.0。

在建议的版本周期中,CI 将为使用 Nebula 发布生成不可变快照的存储库执行 Gradle 构建。次要修订版本保持一致,直到最终将部署提升到生产环境,此时您的交付自动化(如 Spinnaker 管道阶段)会为存储库打标签(例如,Spinnaker 作业阶段专门在存储库上执行./gradlew final,将当前次要发布迭代的标签推送到 Git 远程)。

发布的 SaaS 与打包软件对比

即使打包软件也可以由一系列微服务组成。例如,Spinnaker 本身就是一个旨在一起部署的微服务套件。通常,打包软件还需要额外的步骤:生成某种包含每个单独微服务版本的物料清单。这些版本包含在物料清单中,以表明它们已经一起经过测试并且已知可以作为一个组一起工作。物料清单有助于创建可能有多个运行副本的一组微服务。在运行 SaaS 时,生产环境往往是唯一运行的副本,物料清单则不是必需的。

Nebula 发布插件应用于 Gradle 项目的根项目,如示例 6-4 所示。

示例 6-4. 将 Nebula 发布插件应用于 Gradle 项目的根项目
plugins {
  java
  id("nebula.release") version "LATEST" ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  id("nebula.maven-publish") version "LATEST"
  id("nebula.maven-resolved-dependencies") version "LATEST"
}

project
  .rootProject
  .tasks
  .getByName("devSnapshot")
  .dependsOn(project
    .tasks
    .getByName("publishNebulaPublicationToArtifactory")) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)

project
  .gradle
  .taskGraph
  .whenReady(object: Action<TaskExecutionGraph> { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
    override fun execute(graph: TaskExecutionGraph) {
      if (graph.hasTask(":snapshot") ||
        graph.hasTask(":immutableSnapshot")) {

        throw GradleException("You cannot use the snapshot or" +
          "immutableSnapshot task from the release plugin. " +
          "Please use the devSnapshot task.")
      }
    }
  })

publishing {
  repositories {
    maven {
      name = "Artifactory" ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png)
      url = URI.create("https://repo.myorg.com/libs-services-local")
    }
  }
}

1

LATEST替换为Gradle 插件门户网站上列出的版本。

2

每当 CI 构建执行devSnapshot时,构建并发布一个工件到 Artifactory。因为我们还没有final附加到发布,所以在推广到生产后的阶段运行./gradlew final将用当前次要版本(例如,v0.1.0)标记仓库,并将标记推送到仓库,但不会不必要地上传另一个工件到工件库。此标记的存在完成了开发周期。任何随后的代码推送,因此运行./gradlew devSnapshot,都会为下一个次要版本生成快照(例如,0.2.0-snapshot.<timestamp>+<commit_hash>)。

3

可选地,确保开发人员不会意外使用 Nebula 发布提供的其他类型的快照任务,这些任务会生成具有不同版本号语义的快照。

4

定义在devSnapshotdependsOn子句中引用的仓库。

对于每次提交(通过自动化测试)都会导致生产部署的持续部署模型,只要较低环境中不需要自动化测试套件,每个次要版本发布将恰好有一个快照。如果在工件发布之前运行了所有检查,并且信任结果足以立即推广到生产环境,则可以轻松使用./gradlew final,完全避免不可变的快照。很少有企业会对此感到舒适,并且没有达到这种自动化水平的压力。正如在“分离平台和应用程序度量标准”中提到的,您在某种程度上“发布您的组织图表”。

将每次部署与不可变版本关联是工件溯源链的第一阶段。当您具备了查询交付服务所有当前部署资源的能力 您的交付自动化某种方式将每次部署与不可变发布版本(无论是存储在 EC2 中的 Auto Scaling 组名称、Kubernetes 标签等)进行标记时,您将解锁遍历所有生产部署资源并映射到可以从工件库检索的工件坐标的能力。

到目前为止显示工件溯源链程度的伪代码在示例 6-5 中。

示例 6-5. 映射部署资源到工件版本
delivery
  .getApplications()
  .flatMap(application -> application.getClusters())
  .flatMap(cluster -> cluster.getServerGroups())
  .map(serverGroup -> serverGroup.getArtifact()) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)

// Where... @EqualsAndHashCode(includes = {"group", "artifact", "version"}) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
class Artifact {
  String group;
  String artifact;
  String version;
  Set<Artifact> dependencies; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
}

1

类型是Stream<Artifact>,因为部署和工件之间存在一对一的对应关系。

2

因为devSnapshot生成的不可变的构件版本对于每个源代码和依赖的唯一组合都是独特的,具有相同的组/构件/版本坐标的两个构件也保证具有相同的依赖关系。

3

在这个阶段,我们还不能确定依赖关系。

我们需要更多的配置来提供包含依赖关系的构件来源证明。

在元数据中捕获已解析的依赖关系

将应用程序的依赖关系扩展到更深层次,包括依赖关系,使我们能够快速找到包含可能有问题的版本的已部署资源,例如,由于已识别的安全漏洞。

通常,当我们发布一个 Maven POM 文件以及应用程序时,它包含一个仅列出第一级依赖的<dependencies>块。这些是直接列在 Gradle 构建的dependencies { }部分中的依赖项。例如,对于从start.spring.io生成的示例 Spring Boot 应用程序,第一级依赖可能是类似于示例 6-6 的内容。具体来说,spring-boot-starter-actuatorspring-boot-starter-webflux是两个第一级依赖项。

示例 6-6. 示例 Spring Boot 应用程序的第一级依赖
dependencies {
  implementation("org.springframework.boot:spring-boot-starter-actuator")
  implementation("org.springframework.boot:spring-boot-starter-webflux")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
  testImplementation("io.projectreactor:reactor-test")
}

为了建立起可靠的来源链,测试依赖并不重要。它们仅在本地开发者机器和持续集成构建期间存在于类路径上,并不会打包进最终部署环境中运行的应用程序中。任何测试依赖中的问题或漏洞都安全地限定在持续集成环境中,并不会在运行的部署应用程序中造成问题。

当这个应用程序发布到像 Artifactory 这样的构件库时,会随着二进制构件一起发布一个 Maven POM 文件,其中包含一个dependencies部分,就像在示例 6-7 中一样。

示例 6-7. Maven POM 中显示的第一级依赖
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    <version>2.3.0.M4</version>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <version>2.3.0.M4</version>
    <scope>runtime</scope>
  </dependency>
</dependencies>

当然,第一级依赖会带入其他依赖。从第一级向下递归解析的所有其他依赖的集合被称为依赖的传递闭包。

也许会有人认为,通过递归地从存储库获取每个依赖项的 POM 文件,就可以简单地确定此示例应用程序中的依赖项的传递闭包。遗憾的是,事实并非如此。在构建中通常存在其他约束,这些约束以某种方式影响了已解析并与应用程序一起打包的传递依赖项。例如,某个特定版本可能会被拒绝,因为某个关键错误已由更高版本修复,如示例 6-8 中所示。

示例 6-8. 拒绝版本并进行替换
configurations.all {
  resolutionStrategy.eachDependency {
    if (requested.group == "org.software" &&
      requested.name == "some-library") {

      useVersion("1.2.1")
      because("fixes critical bug in 1.2")
    }
  }
}

仅通过从存储库获取标准元数据(如 POM 的 <dependencies> 块)来确定依赖项的传递闭包的任何过程都是不正确的。

解析策略等构建时特性的后果是,试图仅通过标准构件元数据(如 POM 的 <dependencies> 块)来确定依赖项的传递闭包的任何过程都是不正确的。

试图仅从标准元数据严格构建通用组件分析工具的供应商只能给出对依赖关系使用的近似值。而这种近似值通常与现实并不相符。

由于解析策略、强制版本等原因,在发布应用程序二进制文件时,必须以某种方式持久化传递闭包。一个简单的方法是在 POM 的 <properties> 元素中包含已解析的传递闭包,以供稍后构建的任何工具使用,以检查组织中使用的依赖关系。

星云信息插件专门用于将构建时的元数据附加到 POM 的<properties>部分。星云信息默认添加了诸如 Git 提交哈希和分支、源和目标 Java 版本以及构建主机等属性。

InfoBrokerPlugin允许我们按键值对随意添加新属性。示例 6-9 展示了如何遍历运行时类路径的传递依赖闭包,并将依赖项列表添加为属性。nebula.maven-manifest,由nebula.maven-publish自动包含,读取信息代理管理的所有属性,并将它们作为 POM 属性添加。

示例 6-9. 列出所有传递依赖项并按平面列表排序
plugins {
  id("nebula.maven-publish") version "LATEST" ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  id("nebula.info") version "LATEST"
}

tasks.withType<GenerateMavenPom> {
  doFirst {
    val runtimeClasspath = configurations
      .getByName("runtimeClasspath")
    val gav = { d: ResolvedDependency ->
      "${d.moduleGroup}:${d.moduleName}:${d.moduleVersion}"
    }
    val indented = "\n" + " ".repeat(6)

    project.plugins.withType<InfoBrokerPlugin> {
      add("Resolved-Dependencies", runtimeClasspath
        .resolvedConfiguration
        .lenientConfiguration
        .allModuleDependencies
        .sortedBy(gav)
        .joinToString(
          indented,
          indented,
          "\n" + " ".repeat(4), transform = gav
        )
      )
    }
  }
}

1

用最新版本替换LATEST,可以从Gradle 插件门户上获得最新版本。

这导致 POM 中的属性列表看起来类似于示例 6-10。

示例 6-10. 列出、排序并将扁平化的传递依赖闭包添加为 POM 属性
<project ...>
  <groupId>com.example</groupId>
  <artifactId>demo</artifactId>
  <version>0.1.0</version>
  <name>demo</name>
  ...
  <properties>
    <nebula_Change>1b0f8d9</nebula_Change>
    <nebula_Branch>master</nebula_Branch>
    <nebula_X_Compile_Target_JDK>11</nebula_X_Compile_Target_JDK>
    <nebula_Resolved_Dependencies>
      ch.qos.logback:logback-classic:1.2.3
      ch.qos.logback:logback-core:1.2.3
      com.datastax.oss:java-driver-bom:4.5.1
      com.fasterxml.jackson.core:jackson-annotations:2.11.0.rc1
      com.fasterxml.jackson.core:jackson-core:2.11.0.rc1
      com.fasterxml.jackson.core:jackson-databind:2.11.0.rc1
    </nebula_Resolved_Dependencies>
  </properties>
</project>

现在,要构建工具来查找所有包含logback-core版本 1.2.3 的部署,我们可以使用类似于 Example 6-11 中的伪代码来列出包含特定 logback-core 依赖项的所有服务器组。在Artifact 类型上的dependencies 的填充包括从存储库下载 POM,给定 Artifact 的组/Artifact/版本坐标,并解析<nebula_Resolved_Dependencies> POM 属性的内容。

Example 6-11. 将部署资源映射到其中包含的所有依赖项集合
delivery
  .getApplications()
  .flatMap(application -> application.getClusters())
  .flatMap(cluster -> cluster.getServerGroups())
  .filter(artifact -> serverGroup
    .getArtifact()
    .getDependencies()
    .stream()
    .anyMatch(d -> d.getArtifact().equals("logback-core") &&
      d.getVersion().equals("1.2.3"))
  )

当眼睛一瞥 POM 文件时,可以稍微改进展平的列表表示,而不会真正影响我们如何解析给定 Artifact 的传递闭包。Example 6-12 展示了如何代替创建传递依赖闭包的最小生成树的漂亮打印。

Example 6-12. 作为 POM 属性添加的传递依赖闭包的树视图
tasks.withType<GenerateMavenPom> {
  doFirst {
    val runtimeClasspath = configurations.getByName("runtimeClasspath")

    val gav = { d: ResolvedDependency ->
      "${d.moduleGroup}:${d.moduleName}:${d.moduleVersion}"
    }

    val observedDependencies = TreeSet<ResolvedDependency> { d1, d2 ->
      gav(d1).compareTo(gav(d2))
    }

    fun reduceDependenciesAtIndent(indent: Int):
      (List<String>, ResolvedDependency) -> List<String> =
      { dependenciesAsList: List<String>, dep: ResolvedDependency ->

        dependenciesAsList + listOf(" ".repeat(indent) +
          dep.module.id.toString()) + (
            if (observedDependencies.add(dep)) {
              dep.children
                .sortedBy(gav)
                .fold(emptyList(), reduceDependenciesAtIndent(indent + 2))
            } else {
              // This dependency subtree has already been printed, so skip
              emptyList()
            }
          )
      }

    project.plugins.withType<InfoBrokerPlugin> {
      add("Resolved-Dependencies", runtimeClasspath
        .resolvedConfiguration
        .lenientConfiguration
        .firstLevelModuleDependencies
        .sortedBy(gav)
        .fold(emptyList(), reduceDependenciesAtIndent(6))
        .joinToString("\n", "\n", "\n" + " ".repeat(4)))
    }
  }
}

这会生成一个解析的依赖属性,看起来像 Example 6-13。这在单独查看时更易读,并从工具中使用这样的属性等同于展平表示法——只需去掉每行前面的空格,无论有多少空格。

Example 6-13. 作为 POM 属性显示的传递依赖闭包的树视图
<project ...>
  <groupId>com.example</groupId>
  <artifactId>demo</artifactId>
  <version>0.1.0</version>
  <name>demo</name>
  ...
  <properties>
    <nebula_Change>1b0f8d9</nebula_Change>
    <nebula_Branch>master</nebula_Branch>
    <nebula_X_Compile_Target_JDK>11</nebula_X_Compile_Target_JDK>
    <nebula_Resolved_Dependencies>
      org.springframework.boot:spring-boot-starter-actuator:2.3.0.M4
        io.micrometer:micrometer-core:1.3.7
          org.hdrhistogram:HdrHistogram:2.1.11
          org.latencyutils:LatencyUtils:2.0.3
        org.springframework.boot:spring-boot-actuator-autoconfigure:2.3.0.M4
          com.fasterxml.jackson.core:jackson-databind:2.11.0.rc1
          com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.0.rc1
            com.fasterxml.jackson.core:jackson-annotations:2.11.0.rc1
            com.fasterxml.jackson.core:jackson-core:2.11.0.rc1
            com.fasterxml.jackson.core:jackson-databind:2.11.0.rc1
    </nebula_Resolved_Dependencies>
  </properties>
</project>

您可以在这个星云信息输出中看到一个提示,我们可以进一步找到提交(<nebula_Change>)的方式。

捕获源代码的方法级别利用

更进一步,我们可以捕获给定 Artifact 版本的源代码级别。这是来源链中的最后阶段。完成后,链条从概念上的“应用程序”,包括分布在多个云提供商上的集群,一直到这些应用程序中源代码的方法声明和调用。例如,对于基于 AWS EC2 的 IaaS 部署足迹,现在来源链看起来像 Example 6-14。

Example 6-14. 从应用程序到方法级别源代码的 AWS EC2 完整来源链
Application
  -> Owner (team)
  -> Clusters
    -> Server groups
      -> Instances
      -> Amazon Machine Image (AMI)
        -> Debian
          -> JAR (or WAR)
            -> Git commit
              -> Source abstract syntax tree ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
                -> Classes
                  -> Method declarations
                    -> Method invocations
            -> Dependencies
               -> Git commit
                 -> Source abstract syntax tree
                   -> Classes
                     -> Method declarations
                       -> Method invocations

1

如何构建这个是本节的主题。

由于这条链仅通过足够独特的标识元数据从上到下链接,我们几乎可以回答关于我们正在运行的生产环境的任何问题。现在,每个这些场景都有一个确切的答案,可以随时根据当前部署足迹的状态进行更新:

开源第三方库中的零日漏洞

安全团队发现了开源第三方库中一个方法的漏洞。如果被利用,这一漏洞可能导致公司泄露敏感的个人可识别信息,从而造成重大的法律责任和品牌影响。由于这个漏洞的影响广泛,安全团队希望分两个阶段解决这个问题。首先专注于生产环境中涉及到这一漏洞方法的公共界面,然后在较低的环境中处理内部工具和应用程序代码,最终部署到生产环境。在第一阶段,安全团队需要与哪些团队沟通,这些团队的执行路径可能导致到达这一漏洞方法?

平台团队希望废弃或更改一个 API

负责提供每个微服务团队使用的库,以检查给定请求是否处于运行的 A/B 实验中的集中化工具团队,希望改变其库的使用方式。工具团队在其 Github 企业实例上使用源代码搜索机制来查看现有 API 的使用情况,并注意到某些结果指向了死代码。如果团队决定进行 API 更改,哪些活跃开发和部署的代码将受到影响?哪些团队将受到这一潜在变更的影响?如果影响的团队数量较少,工具团队可能可以与受影响的用户逐个会面或举行小型会议和反馈会议,并继续进行更改。如果影响到了更广泛的组织范围,也许需要更渐进地处理这一变更,或者重新考虑整体变更的策略。

当涉及到识别正在运行的微服务执行路径中使用的方法时,有两种方法:

实时监控执行路径

可以附加代理到运行中的 Java 进程中,以监控实际执行的方法。一段时间内观察到的所有方法签名集合近似于可达的执行路径。例如,Snyk,专注于安全漏洞分析,提供了一个 Java 代理,监控可证明的执行路径以与其方法级漏洞研究匹配,并提醒组织已知的漏洞。这种实时监控自然地低估了实际的执行路径集合,因为有些执行路径可能很少被执行(例如异常处理路径)。

从源代码静态分析潜在的执行路径

存在多个 Java 工具来构建抽象语法树(源代码的中间表示)。可以遍历此树以查找方法调用。对于源代码库中每个类的抽象语法树的所有方法调用集合表示所有潜在执行的集合。这自然会过报告真实的执行路径集合,因为某些方法调用会存在于永远不会评估为真的条件集或永远不会执行的请求映射中。一个开放可用的工具示例是“使用 OpenRewrite 进行结构化代码搜索”。

由于这些方法的欠报告和过报告特性,将它们结合使用可能很有用。任何通过实时监控报告方法使用的应用程序都肯定需要更改。潜在的执行路径可以在第二次通过中进行评估。

静态分析工具需要评估源代码。在示例 6-15 中再次展示的nebula.infonebula.maven-publish的结合给出了一个 Git 提交哈希和分支,这足以连接已知在给定服务器组中运行的工件版本与其中包含的源代码。此外,您可以跟踪应用工件的传递依赖闭包,查看每个依赖项的 POM 文件,并查看每个依赖项用于检查其源代码的提交哈希和分支。

示例 6-15。生成 Git 提交哈希和分支的 Gradle 插件。
plugins {
  id("nebula.maven-publish") version "LATEST"
  id("nebula.info") version "LATEST"
}

此生成的 POM 属性,如示例 6-16 所示,可以很容易地被工具抓取。

示例 6-16。生成 Git 提交哈希和分支的 Maven POM 属性。
<project ...>
  <groupId>com.example</groupId>
  <artifactId>demo</artifactId>
  <version>0.1.0</version>
  <name>demo</name>
  ...
  <properties>
    <nebula_Change>1b0f8d9</nebula_Change>
    <nebula_Branch>master</nebula_Branch>
  </properties>
</project>

要设定关于结构化代码搜索工具应具备的能力类型的预期,可以考虑 OpenRewrite。

使用 OpenRewrite 进行结构化代码搜索。

OpenRewrite项目是 Java 和其他源代码的大规模重构生态系统,旨在消除工程组织中的技术债务。Rewrite 被设计为可插入各种工作流程,包括以下内容:

  • 发现和修复代码作为构建工具任务(例如 Gradle 和 Maven)。

  • 用于任意复杂度模式的亚秒级组织范围内代码搜索。

  • 大规模发出拉取请求以修复安全漏洞,消除弃用 API 的使用,从一种技术迁移到另一种技术(例如从 JUnit 断言到 AssertJ)等。

  • 组织范围内的 Git 提交执行相同操作。

它基于自定义的抽象语法树(AST),用于编码源代码的结构和格式。AST 可以打印以重建源代码,包括其原始格式。重写提供了高级搜索和重构功能,可以转换 AST,以及用于单元测试重构逻辑的实用程序。

重写 AST 的两个关键功能使其适合用于溯源链的目的:

类型属性

每个 AST 元素都蕴含有类型信息。例如,对于字段引用,源代码可能只是引用了myFieldmyField的重写 AST 元素还将包含关于myField类型的信息,即使它并未在同一源文件甚至同一项目中定义。

非循环和可序列化

大多数包含类型信息的 AST 可能是循环的。循环通常来自泛型类型签名,例如类A<T extends A<T>>。这种模式通常出现在 Java 中的抽象构建器类型中。重写会截断这些循环,并为其类型添加序列化注解,以便 AST 可以使用像 Jackson 这样的库进行序列化/反序列化。

准确匹配模式需要类型属性。当查看类似 Example 6-17 的日志记录语句时,我们如何知道logger是 SLF4J 还是 Logback 记录器?方法被调用的实例的类类型称为接收器类型。

示例 6-17. 具有模糊接收器类型的日志记录语句
logger.info("Hi");

为整个组织生成带有类型属性的 AST 在计算上是任意复杂的,因为它需要依赖解析、源代码解析和类型属性(基本上是 Java 编译直到生成字节码的过程)。由于重写 AST 是可序列化的,我们可以将它们作为编译的副产品在持续集成环境中集中存储,然后稍后批量操作。

一旦我们为特定源文件生成了序列化的抽象语法树(AST),由于它还包含类型信息,它可以完全独立于同一源代码包或存储库中的其他源文件进行重构和搜索。这使得大规模搜索和重构成为一种真正线性可扩展的操作。

从 Java 源代码创建重写 AST

要构建 Java 源代码的重写 AST,可以使用以下其中一个构造函数签名构建JavaParser,无论是否在运行时类路径上,如 Example 6-18 所示。

示例 6-18. 构造 JavaParser 实例
JavaParser();
JavaParser(List<Path> classpath);

提供类路径是可选的,因为每个元素的类型属性都是尽力而为的。如果我们将 AST 存储在数据存储中以进行组织范围的搜索,最好将其完全存储为类型属性,因为无法预知将要进行的搜索类型。此类搜索包括以下内容:

完全不需要类型

如果您正在应用类似于 Checkstyle 的 WhitespaceBefore 规则的自动修复规则,我们严格关注源代码格式化,如果 AST 元素中没有任何类型,这并不影响结果。

部分类型需求

如果要搜索已弃用的 Guava 方法的出现次数,则可以使用指向 Guava 二进制文件的路径构造一个 JavaParser。它甚至不必是项目正在使用的 Guava 版本!生成的 AST 将具有有限的类型信息,但足以搜索我们想要的内容。

完整类型需求

当 AST 作为编译的副作用发射到中央数据存储以供后续任意代码搜索时,它们需要具有完整的类型信息,因为我们无法预先知道人们将尝试进行什么样的搜索。

JavaParser 包含一个方便的方法,用于从构造解析器的 Java 进程的运行时类路径构建 JavaParser,如 示例 6-19 所示。

示例 6-19. 给解析器提供进行类型归属所需的编译依赖项
new JavaParser(JavaParser.dependenciesFromClasspath("guava"));

此实用程序获取要查找的依赖项的“artifact name”。artifact name 是 group:artifact:version 坐标的 artifact 部分。例如,对于 Google 的 Guava (com.google.guava:guava:VERSION),artifact name 是 guava

一旦您有了 JavaParser 实例,您可以使用 parse 方法解析项目中的所有源文件,该方法接受一个 List<Path>。 示例 6-20 展示了这一过程。

示例 6-20. 解析 Java 源路径列表
JavaParser parser = ...;
List<J.CompilationUnit> cus = parser.parse(pathsToSourceFiles);

J.CompilationUnit

这是 Java 源文件的顶级 AST 元素,包含有关包、导入以及源文件中包含的任何类/枚举/接口定义的信息。 J.CompilationUnit 是我们将为 Java 源代码构建重构和搜索操作的基本构建块。

JavaParser

这包含了从字符串构建 AST 的 parse 方法重载,对于快速构建不同搜索和重构操作的单元测试非常有用。

对于像 Kotlin 这样支持多行字符串的 JVM 语言,这特别方便,如 示例 6-21 所示。

示例 6-21. 解析 Java 源代码
val cu: J.CompilationUnit = JavaParser().parse("""
    import java.util.Collections;
    public class A {
        Object o = Collections.emptyList();
    }
""")

请注意,这返回一个单独的 J.CompilationUnit,可以立即对其进行操作。最终,JEP-355 也将为 Java 带来多行字符串,因此将能够以纯 Java 代码编写漂亮的 Rewrite 操作单元测试。

在 示例 6-22 中演示的 dependenciesFromClasspath 方法特别适用于构建单元测试,因为您可以将影响测试运行时类路径上的模块放置在解析器中,并将其绑定到解析器。 这样,用于单元测试的 AST 将对该依赖项中的类、方法等的引用进行类型归因。

示例 6-22. 使用类路径对类型进行解析
val cu: J.CompilationUnit = JavaParser(JavaParser.dependenciesFromClasspath("guava"))
    .parse("""
        import com.google.common.io.Files;
        public class A {
            File temp = Files.createTempDir();
        }
    """)

使用 Rewrite 执行搜索

在前面的示例基础上,我们可以搜索 Guava 的 Files#createTempDir() 的用法,如示例 6-23 所示。 findMethodCalls 的参数采用 AspectJ 语法 来匹配方法的切入点。

示例 6-23. 使用 Rewrite 执行搜索
val cu: J.CompilationUnit = JavaParser(JavaParser.dependenciesFromClasspath("guava"))
    .parse("""
        import com.google.common.io.Files;
        public class A {
            File temp = Files.createTempDir();
        }
    """)

val calls: List<J.MethodInvocation> = cu.findMethodCalls(
    "java.io.File com.google.common.io.Files.createTempDir()");

J.CompilationUnit 上还有许多其他搜索方法,其中包括以下内容:

boolean hasImport(String clazz)

寻找导入

boolean hasType(String clazz)

检查源文件是否引用了某一类型

Set<NameTree> findType(String clazz)

返回所有与特定类型相关的 AST 元素

您还可以向下移动到源文件中的单个类(cu.getClasses())并执行其他操作:

List<VariableDecls> findFields(String clazz)

查找在此类中声明的字段,这些字段引用了特定类型。

List<JavaType.Var> findInheritedFields(String clazz)

查找从基类继承的字段。 请注意,由于它们是继承的,所以没有 AST 元素可以匹配,但是您可以确定一个类是否有来自基类的特定类型的字段,并查找此字段的用法。

Set<NameTree> findType(String clazz)

返回此类内部所有引用特定类型的 AST 元素。

List<Annotation> findAnnotations(String signature)

查找所有与 AspectJ 切入点定义中的注释匹配的注释。

boolean hasType(String clazz)

检查类是否引用了某一类型。

hasModifier(String modifier)

检查类定义上的修饰符(例如,public、private、static)。

isClass()/isEnum()/isInterface()/isAnnotation()

检查声明的类型。

AST 更深层次的更多搜索方法可用。

您可以通过扩展 JavaSourceVisitor 并实现所需的任何 visitXXX 方法来构建自定义搜索访问者。 这些不必复杂。 如示例 6-24 所示,FindMethods 仅扩展了 visitMethodInvocation 来检查给定调用是否与我们正在寻找的签名匹配。

示例 6-24. Rewrite 中 FindMethods 操作的实现
public class FindMethods extends JavaSourceVisitor<List<J.MethodInvocation>> {
    private final MethodMatcher matcher;

    public FindMethods(String signature) {
        this.matcher = new MethodMatcher(signature);
    }

    @Override
    public List<J.MethodInvocation> defaultTo(Tree t) {
        return emptyList();
    }

    @Override
    public List<J.MethodInvocation> visitMethodInvocation(J.MethodInvocation method) {
        return matcher.matches(method) ?
          singletonList(method) :
          super.visitMethodInvocation(method);
    }
}

通过实例化访问者并在根 AST 节点上调用visit来调用自定义访问者,如示例 6-25 所示。JavaSourceVisitor可以返回任何类型。您可以使用defaultTo定义默认返回,并且可以通过在访问者上覆盖reduce来提供自定义减少操作。

示例 6-25。调用自定义重写访问者
J.CompilationUnit cu = ...;

// This visitor can return any type you wish, ultimately
// being a reduction of visiting every AST element
new MyCustomVisitor().visit(cu);

重构 Java 源代码

将此工件溯源链建立到源代码方法调用级别的一个好处是,您可以有针对性地执行源代码的某些修复操作:首先迭代部署的资产并将其映射到二进制文件,然后映射到提交,然后映射到从该提交构建的 AST。

重构代码从 AST 的根开始,对于 Java 来说是J.CompilationUnit。调用refactor()开始重构操作。我们将详细介绍可以执行的重构操作类型,但在此过程结束时,您可以调用fix(),生成一个Change实例,允许您生成 git 差异并打印出原始和转换后的源代码。示例 6-26 展示了整个过程的端到端。

示例 6-26。端到端:解析 Java 源代码到打印修复
JavaParser parser = ...;
List<J.CompilationUnit> cus = parser.parse(sourceFiles);

for(J.CompilationUnit cu : cus) {
    Refactor<J.CompilationUnit, J> refactor = cu.refactor();

    // ... Do some refactoring

    Change<J.CompilationUnit> change = refactor.fix();

    change.diff(); // A string representing a git-style patch
    // Relativize the patch's file reference to some other path
    change.diff(relativeToPath);

    // Print out the transformed source, which could be used
    // to overwrite the original source file
    J.CompilationUnit fixed = change.getFixed();
    fixed.print();

    // Useful for unit tests to trim the output of common whitespace
    fixed.printTrimmed();

    // This is null when we synthesize a new compilation unit
    // where one didn't exist before
    @Nullable J.CompilationUnit original = change.getOriginal();
}

rewrite-java打包了一系列重构构建块,可用于执行低级重构操作。例如,要将所有字段从java.util.List更改为java.util.Collection,我们可以使用ChangeFieldType操作,如在测试形式中所示的示例 6-27。

示例 6-27。更改字段类型的单元测试
@Test
fun changeFieldType() {
    val a = parse("""
        import java.util.List;
        public class A {
           List collection;
        }
    """.trimIndent())

    val fixed = a.refactor()
            .visit(ChangeFieldType(
                    a.classes[0].findFields("java.util.List")[0],
                    "java.util.Collection"))
            .fix().fixed

    assertRefactored(fixed, """
        import java.util.Collection;

        public class A {
           Collection collection;
        }
    """)
}

rewrite-java模块带有基本的重构构建块,这些构建块类似于 IDE 中找到的许多单独的重构工具:

  • 向类、方法或变量添加注解。

  • 向类添加字段。

  • 添加/删除导入项,可以配置为展开/折叠星号导入。

  • 更改字段名称(包括引用该字段的其他源文件,而不仅仅是字段定义的位置)。

  • 更改字段类型。

  • 更改文字表达式。

  • 更改方法名称,包括引用该方法的任何位置。

  • 将方法目标更改为从实例方法到静态方法。

  • 将方法目标更改为从静态方法到实例方法。

  • 在树的任何位置更改类型引用。

  • 插入/删除方法参数。

  • 删除任何语句。

  • 使用字段生成构造函数。

  • 重命名变量。

  • 重新排序方法参数。

  • 取消括号。

  • 实现一个接口。

每个操作都定义为JavaRefactorVisitor,它是专为改变 AST 而设计的JavaSourceVisitor的扩展,在重构操作结束时最终生成一个Change对象。

访问者可以是有游标或无游标的。有游标的访问者维护一个已经在树中遍历过的 AST 元素堆栈。尽管需要额外的内存足迹,这种访问者可以基于树中 AST 元素的位置进行操作。许多重构操作不需要此状态。例子 6-28 提供了一个使每个顶层类都变为 final 的重构操作示例。由于类声明可以是嵌套的(例如,内部类),我们使用游标来确定类是否是顶级的或不是。重构操作还应给出一个带有表示操作组的完全限定名称和表示其功能的名称。

示例 6-28. 使每个顶级类变为 final 的重构操作示例
public class MakeClassesFinal extends JavaRefactorVisitor {
    public MakeClassesFinal {
        super("my.MakeClassesFinal");
        setCursoringOn();
    }

    @Override
    public J visitClassDecl(J.ClassDecl classDecl) {
        J.ClassDecl c = refactor(classDecl, super::visitClassDecl);

        // Only make top-level classes final
        if(getCursor().firstEnclosing(J.ClassDecl.class) == null) {
            c = c.withModifiers("final");
        }

        return c;
    }
}

访问者可以通过调用 andThen(anotherVisitor) 连接在一起。这对于构建由较低级别组件组成的重构操作流水线非常有用。例如,当 ChangeFieldType 找到要转换的匹配字段时,它会将 AddImport 访问者链接在一起,如果需要则添加新的导入,以及将 RemoveImport 链接在一起,以移除旧的导入,如果不再有引用。

一个开源平台的现成补救措施继续在开源中增长。

此时,来源链已经完整。将连接到源代码方法级细节的各个点连接起来,让你能够以精细的方式观察你部署的足迹,实时回答各种问题。

现在让我们把注意力转移到建立源可靠性的另一个方面:管理二进制依赖。

依赖管理

二进制依赖(在 Gradle 构建文件或 Maven POM 文件中定义)带来一系列系统性挑战。我们将讨论其中几个问题,并提出解决策略。你会注意到,在每种情况下,补救措施都是在构建工具层面应用的。

一些组织尝试通过策展来限制依赖问题的影响,即禁止使用来自 Maven Central 或 JCenter 等公共存储库源的依赖,而是选择经过批准和策划的一组依赖项。依赖项的策展提出了一系列挑战。特别是考虑到库之间的相互连接,决定将另一个库添加到策划集中涉及添加其整个传递闭包。还存在一种自然倾向,即避免获得添加新工件所需的辛劳,这意味着你的组织会偏向使用略旧的库版本,增加安全漏洞和错误足迹。具有讽刺意味的是,策展的目标通常是为了提高安全性。至少,这种权衡值得根据你的声明目标进行评估。

版本不对齐

由于冲突解析导致的依赖家族版本不一致,使得该家族无法正确运行。策划的构件仓库增加了版本不一致的可能性。

Jackson 就是一个很好的例子。假设我们将 Spring Boot 的新版本及其传递的依赖带入我们的策划仓库。图 6-6 使用 Gradle 的 dependencyInsight 任务来展示 Spring Boot 传递依赖闭包中包含的 Jackson 依赖及其包含方式的路径。

值得注意的是,此列表中显然缺少所有其他不直接由框架需要的 Jackson 模块,例如 jackson-module-kotlinjackson-module-afterburnerjackson-modules-java8。任何使用这些其他模块的微服务在更新到策划仓库中的新版本的 Spring Boot 后,现在存在无法解决的版本不一致(可能会创建运行时问题),直到这些模块的新版本也被添加到策划集中。

srej 0606

图 6-6. Spring Boot 传递的 Jackson 依赖

动态版本带来了一系列不同的问题。

动态版本约束

遗憾的是,Java 构建工具仍然缺乏像NPM 的选择器那样针对语义化版本的高级范围选择器。相反,我们只能使用像latest.release2.10.+(Gradle)、RELEASE(,2.11.0](Maven)这样粗略的选择器。

尽量避免使用+类型的选择器,因为它们会按照字典顺序排序版本号。所以 2.10.9 被认为比 2.10.10 更晚。

Maven 风格的范围选择器不幸地将您固定在一个静态的上限。当进一步的版本发布时,需要在定义它们的所有位置更新上限。

尽管大部分常见的开源库都会谨慎地仅向公共仓库发布正式版本,但偶尔我们仍然会看到即使是被广泛使用的库也发布候选版本。遗憾的是,latest.release(Gradle)和RELEASE(Maven)选择器无法区分版本号和人类明显的正式发布候选版本。例如,在 2020 年 3 月,Jackson 发布了 2.11.0.rc1 版本,这会被latest.release选择。不到一年前的 2019 年 9 月,Jackson 发布了 2.10.0.pr1 版本(非常规的“pr”后缀显然意味着“预发布”)。这两个版本在语义上都不符合“最新发布”的意图。

我们可以通过向 Gradle 构建中添加两个 Maven 仓库,以此来阻止已知模式的发布候选版本,这两个仓库共同形成了一个不可分割的可解析工件的子集,关于示例 6-29,就是所有非发布候选的 Jackson 模块,以及所有非 Jackson 模块的集合。

示例 6-29. 在 Gradle 中阻止使用 Jackson 发布候选版本
repositories {
  mavenCentral {
    content {
      excludeVersionByRegex("com\\.fasterxml\\.jackson\\..*", ".*",
        ".*rc.*")
    }
  }
  mavenCentral {
    content {
      includeVersionByRegex("com\\.fasterxml\\.jackson\\..*", ".*",
        "(\\d+\\.)*\\d+")
    }
  }
}

未使用的依赖项提出了另一种问题。

未使用的依赖项

除了会使打包的微服务体积膨胀(这通常不是一个重大问题),未使用的依赖项还可能导致功能的隐式自动配置,后果严重。

一个 Spring Data REST 漏洞 让许多人措手不及,即使他们根本没有使用该库,但由于它存在于运行时类路径中,导致 Spring 自动配置了一系列 REST 端点,暴露了攻击向量。

基于 Guice 的 Governator 会自动配置类路径中的任何 Guice 模块。Governator 的扫描机制不受包名的限制。类路径中的模块可能依赖于其他模块,但这些依赖项并不一定可靠地在类路径中。经常会发现未使用但已自动配置的 Guice 模块导致应用程序失败,因为之前意外存在于类路径中的依赖项被移除。

未使用的依赖项可以通过 Nebula Lint Gradle 插件自动检测和删除。它可以在 Gradle 项目中进行配置,如示例 6-30 所示。

示例 6-30. Nebula Lint 配置未使用依赖项
plugins {
  id "nebula.lint" version "LATEST"
}

gradleLint.rules = ['unused-dependency']

nebula.lint 被应用时,构建脚本将在任务图中的最后一个任务执行后自动由名为 lintGradle 的任务进行 lint 检查。结果会在控制台中报告,如图 6-7 所示。

srej 0607

图 6-7. Nebula Lint 关于依赖项格式的警告

运行 ./gradlew fixGradleLint 来自动修复你的构建脚本。自动修复过程列出了所有违规项及其修复方式,如图 6-8 所示。

srej 0608

图 6-8. Nebula Lint 自动修复依赖项格式

最后一个问题代表了未使用依赖项的镜像相反。

未声明的显式使用依赖项

一个应用程序类导入了一个从依赖中定义的类,该依赖是通过传递定义的。有效地将传递依赖项引入类路径的一级依赖项要么被移除,要么其树发生变化,使得传递依赖项不再在类路径中。

未声明的依赖项也可以通过 Nebula Lint 自动检测和添加。它可以在 Gradle 项目中进行配置,如示例 6-31 所示。

示例 6-31. 未声明依赖项的 Nebula Lint 配置
plugins {
  id "nebula.lint" version "LATEST"
}

gradleLint.rules = ['undeclared-dependency']

一旦作为一级依赖项添加,其作为依赖项的可见性,特别是其版本对该应用的重要性更为突出。

摘要

本章介绍了一些基本要求,以便设置您的软件交付生命周期,使您可以将部署的资产映射回其中包含的源代码。随着部署资产数量的增加(更小的微服务),确定生产可执行代码中特定代码模式存在的可查询记录系统变得更加重要。

在下一章中,我们将讨论可用于补偿和限制任何微服务架构中存在的失败范围的流量管理和调用弹性模式。

第七章:交通管理

云原生应用程序期望其他服务和资源的故障和低可用性。在本章中,我们介绍了涉及负载均衡(平台、网关和客户端)和调用弹性模式(重试、速率限制器、舱壁和断路器)的重要缓解策略,这些策略共同确保您的微服务继续运行。

这些模式并不适用于每个组织。通常,引入更复杂的交通管理会在操作复杂性和更可预测的用户体验或更低的整体故障率之间进行权衡。换句话说,使用您选择的 HTTP 客户端轻松地向下游服务发出 REST 调用;将该调用包装成重试则稍微复杂些。而提供断路器和回退则更为复杂。但随着复杂性的增加,可靠性也更高。

组织应根据其拥有的应用程序类型(例如,断路器适用的地方)以及微服务主要编写在哪种应用框架中来评估其需求。Java 具有这些模式的一流库支持,并集成到像 Spring 这样的流行框架中,但某些其他语言的支持不足会使得使用边车或服务网格更可取,即使因此会损失一些灵活性。

微服务提供更多潜在故障点。

随着参与用户交互的微服务数量增加,遇到处于低可用状态的服务实例的可能性增加。一个服务可以给下游服务施加负载,而后者可能无法承受并因此失败。调用弹性模式保护服务免受下游服务的失败影响,同时也会对下游服务产生负面影响。它们改变调用顺序的目标是为最终用户提供减少服务,但仍然是服务。例如,如果个性化服务遭遇低可用性,Netflix 电影推荐的个性化列表可以替换为通用的电影推荐。

微服务通常以水平扩展的方式部署在不同的可用区,以增加分布式系统的弹性。微服务并非静态。在任何给定时间,其中几个可以发布(部署新版本或进行金丝雀发布),进行扩展、迁移或故障切换。某些实例可能会经历故障,但不是全部。它们可能会暂时停机或者性能降低。这种动态且频繁变化的系统需要采用一套实践来动态路由流量:从首先发现服务所在的位置到选择哪个实例发送流量。这些都涉及到不同的负载均衡方法。

有两种方法可以实现这些模式:应用程序框架(代码)和支持基础设施(平台或网关负载均衡器、服务网格)。也可以结合使用。一般来说,在应用程序框架中实现这些功能允许更多灵活性和专门用于业务领域的定制。例如,用通用推荐替换个性化电影推荐是可以接受的,但对支付或账单服务的请求却没有明显的回退响应——业务领域的理解至关重要。

系统的并发性

“并发性”指的是一个微服务可以同时处理的请求数量。任何系统中都有一个自然的并发性界限,通常由像 CPU 或内存这样的资源,或者在请求以阻塞方式满足时的下游服务性能驱动。超过此界限的任何尝试请求无法立即满足,必须排队或拒绝。在典型的运行在 Tomcat 上的 Java 微服务中,Tomcat 线程池中的线程数表示其并发性限制的上限(尽管系统资源很可能会被 Tomcat 线程池中较少数量的并发请求耗尽)。操作系统维护的接受队列有效地排队超出该并发性限制的请求。

当请求速率超过响应速率的时间延长时,服务将失败。随着队列增长,延迟也会增加(因为请求直到从队列中移除才开始被处理)。最终,排队的请求将开始超时。

在本章中,我们将涵盖防止由于并发限制已达到而发生级联故障的策略。从这个角度看,负载平衡的讨论实际上是一种积极的方法,以便在第一时间就防止与负载相关的故障。

平台负载平衡

每个现代运行时平台(例如 AWS/GCP/Azure 等的 IaaS 提供、任何 Kubernetes 分发的 CaaS 提供或 Cloud Foundry 等的 PaaS 提供)至少有一些基本的集群负载均衡器。这些负载均衡器用于以某种方式(通常是轮询)在集群的实例之间分发流量,但也具有各种其他责任。例如,AWS Elastic Load Balancers 还服务于 TLS 终止、基于内容的路由、粘性会话等。

在本地环境中,即使是更简单的配置,IIS、Nginx、Apache 等仍然是固定一组命名虚拟机或物理机器前的静态配置负载均衡器。

在讨论更复杂的选项之前,值得注意的是,对于特定规模的设置并没有什么问题。一个区域性的财产/事故保险公司主要为其专有代理人提供 Web 应用程序服务,因此对于此用户群体的容量需求非常稳定。虽然这样的组织可以从主动-主动部署中受益,以增强单个数据中心的故障弹性,但其流量模式并不需要在网关或客户端负载均衡上进行更复杂的处理。

网关负载均衡

开源中有许多软件化的网关可用。Spring Cloud Gateway 是这样一个网关的现代化体现,受到与Zuul的工作经验影响。

运行时平台将流量负载均衡以优化可用性的能力是有限的。对于诸如延迟之类的可用性信号,调用方是最佳信息来源。负载均衡器和调用应用程序同样能观察和响应延迟作为可用性信号。但对于其他信号,尤其是涉及利用率的信号,服务器本身是这些信息的最佳(并且通常是唯一)来源。结合这两个可用性信号源可以产生最有效的负载均衡策略。

从可靠性的角度来看,负载均衡的目标是将流量从错误率高的服务器上移开。目标应该是优化最快的响应时间。优化响应时间往往会导致策略将流量向一个健康的实例或实例组,导致它们过载和不可用。避免具有高错误率的实例仍然允许流量分布到性能不是最佳但足够可用的实例。如果集群中的所有实例都超载,选择一个实例而不是另一个不管负载平衡策略多么聪明都没有好处。然而,在许多情况下,由于临时条件,一些实例的负载过重。

无论何时集群中的某个过程可能在不同实例间交错执行时,都会出现临时超载的子集。例如,并非所有实例都可能同时进行 GC 或 VM 暂停、数据更新或缓存交换。只要这些过程没有集群范围的协调,交错就会存在。如果所有实例根据同步时钟执行某种数据更新,则存在集群协调。例如缺乏协调的示例,考虑导致 GC 暂停发生的原因。满足任何给定请求所产生的分配最终会导致 GC 事件。由于无论负载平衡策略如何,流量几乎肯定会在集群中非均匀分布,因此分配将会交错,从而导致交错的 GC 事件。

低可用性实例的子集另一个例子是冷实例,比如由自动扩展事件或零停机部署引入服务的实例。随着无服务器技术的普及,焦点已经转向了应用启动时间,直到健康检查通过(实际上是应用实例投入服务时)。但重要的是要注意到冷启动第二阶段的性能问题,即从第一个请求开始,如图 7-1 所示,直到运行时优化生效(例如 JVM 的 JIT 优化,或应用特定行为如将工作数据集内存映射)。正是这第二阶段的问题需要得到有效缓解。

在最初的几个请求中,最大值将会更高

图 7-1. 在最初的几个请求中,最大值比 P99 糟糕一个数量级以上

图表绘制了例 7-1 中所示的两个 Prometheus 查询,展示了 REST 端点的最大和 P99 延迟。

例 7-1. Prometheus 查询绘制 REST 端点/人员的最大和 P99 延迟
http_server_requests_seconds_max{uri="/persons"}
histogram_quantile(
  0.99,
  sum(
    rate(
      http_server_requests_seconds_bucket{uri="/persons"}[5m]
    )
  ) by (le)
)

一些实例会因为糟糕的底层硬件或者越来越多的“吵闹邻居”,永久地运行得比其他实例慢。

显然,轮询负载均衡可以得到改进。从架构上看,这个负载均衡器的逻辑驻留在边缘网关中,如图 7-2(part0012_split_004.html#gateway_load_balancer)所示。

srej 0702

图 7-2. 使用 API 网关作为更智能的负载均衡器

用户面向的流量通过平台负载均衡器进入,该负载均衡器将流量以轮询方式分发到一组网关实例。在这种情况下,我们展示了一个网关向边缘后面的多个微服务提供请求服务的情况。网关实例通过从诸如 Netflix Eureka 或 HashiCorp Consul 等发现服务获取的实例列表直接与服务实例通信。不需要在个别微服务前加上平台负载均衡器,因为这些微服务由网关进行负载均衡。

在考虑这种一般性设置的同时,我们可以逐步提出一种负载均衡策略,考虑应用实例对其自身可用性的概念。然后我们将考虑其意外的副作用。目标是让您接触到可以与领域特定知识结合使用的技术,以制定对您有效的负载均衡策略,并学会思考和预见副作用。

加入最短队列

或许最简单的“自适应”负载均衡器,超越简单的轮询法的是“加入最短队列”。

"加入最短队列"的实现是通过比较负载均衡器可见的某些实例可用信号来完成的。一个很好的例子是对每个负载均衡器可见的每个实例的在飞请求。假设负载均衡器正在将流量引导到三个应用实例,其中两个有在飞请求。当负载均衡器收到一个新请求时,它将该请求定向到没有在飞请求的实例,如图 7-3 所示。这是计算上廉价的(只需最小化/最大化某些统计数据)且易于实现。

负载均衡器知道将流量定向到哪个实例

图 7-3. 使用一个负载均衡器节点的加入最短队列

当存在多个负载均衡器实例时,它开始出现问题。到目前为止,所描述的算法基于任何一个负载均衡器实例已知的在飞请求(通过它传递的请求)。换句话说,在负载均衡器池中,每个负载均衡器都在做出自己独立的决策,不知道其他负载均衡器上正在进行的在飞请求。

图 7-4 显示了负载均衡器 1 将基于它所知道的其他负载均衡器节点管理的不完整信息,错误地将一个新请求发送到服务器 3 的情况。箭头表示在飞请求。因此,在新请求到来之前,负载均衡器 1 已经有一个到服务 1 和 2 的在飞请求。负载均衡器 2 有一个到服务 2 和 3 的在飞请求。负载均衡器 3 有三个到服务 3 的在飞请求。当负载均衡器 1 收到一个新请求时,由于它只知道自己的在飞请求,它会决定将新请求发送到服务 3,即使这是最繁忙的服务实例,来自集群中其他负载均衡器的四个在飞请求。

负载均衡器会基于不完整信息做出错误决策

图 7-4. 使用多个负载均衡器节点的加入最短队列

"加入最短队列"是基于负载均衡器对情况的视图进行负载平衡的一个例子。对于低吞吐量应用程序的一个后果是,负载均衡器仅管理少量在飞请求到集群中的一些实例。在集群中最少使用的实例的选择可以导致在没有在飞请求的两个实例之间进行随机选择,因为没有其他可用信息。

避免协调的诱惑!

可能会诱人地考虑与其他负载平衡器共享每个负载平衡器的正在进行中请求的状态,但像这样的分布式协调是困难的,并且应尽可能避免。 在典型的一致性、可用性和分区性权衡中,您最终面临一个工程选择,要么是操纵基于对等的分布式状态系统,要么是选择具有这些权衡的共享数据存储。

下一个模式使用来自负载平衡实例的信息。

实例报告的可用性和利用率

如果我们可以向每个负载平衡器通知实例对其自身可用性和利用率的观点,那么使用相同实例的两个负载平衡器将具有关于其可用性的相同信息。 有两种可用的解决方案:

轮询

轮询每个实例的利用率,从健康检查端点详细数据中采样数据。

被动地跟踪

在来自带有当前利用率数据的服务器响应中 passively track 一个标头。

这两种方法的实现同样简单,并且各自都有权衡。

Micrometer 提供了一个名为HealthMeterRegistryMeterRegistry实现(在io.micrometer:micrometer-registry-health模块中可用),专门用于将度量数据转换为可由负载均衡器监视的健康指标。

HealthMeterRegistry配置了一组服务级别目标,然后将它们映射到框架健康指标,并在负载均衡器查询健康检查端点时进行采样。

Micrometer 提供了一些开箱即用的服务级别目标,已知适用于广泛的 Java 应用程序。 这些可以像 Example 7-2 中那样手动配置。 当micrometer-registry-health存在时,Spring Boot Actuator 也会自动配置这些目标。

示例 7-2. 使用推荐的服务级别目标创建 HealthMeterRegistry
HealthMeterRegistry registry = HealthMeterRegistry
  .builder(HealthConfig.DEFAULT)
  .serviceLevelObjectives(JvmServiceLevelObjectives.MEMORY)
  .serviceLevelObjectives(JvmServiceLevelObjectives.ALLOCATIONS)
  .serviceLevelObjectives(OperatingSystemServiceLevelObjectives.DISK)
  .build();

当这些目标绑定到框架级健康指标时,这些目标被纳入应用程序健康的整体确定中。 Spring Boot Actuator 的健康端点配置显示了这组默认的 SLOs,在 Figure 7-5 中。

srej 0705

图 7-5. Spring Boot Actuator 健康端点与服务级别目标。

您还可以定义自己的服务级别目标,并在 Example 7-3 中,我们定义了一个api.utilization服务级别目标,以支持从服务器健康检查端点详细信息中采样利用率数据。 Spring Boot Actuator 将此目标添加到它将自动创建的 HealthMeterRegistry 中;或者如果您正在进行自己的 HealthMeterRegistry 的连接,您可以在构建时直接添加它。

示例 7-3. 自定义的服务级别目标,用于报告服务器利用率。
@Configuration
class UtilizationServiceLevelObjective {
  @Bean
  ServiceLevelObjective apiUtilization() {
      return ServiceLevelObjective
        .build("api.utilization") ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
        .baseUnit("requests") ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
        .failedMessage("Rate limit to 10,000 requests/second.") ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)
        .count(s -> s.name("http.server.requests") ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png)
          .tag("uri", "/persons")
          .tag("outcome", "SUCCESS")
        )
        .isLessThan(10_000); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00100.png)
  }
}

1

服务水平目标的名称。当将其作为健康指标组件的名称公开时,可以将其命名规范化,就像公制表名称一样。Spring Boot 将其显示为apiUtilization(驼峰命名法),基于其约定。

2

利用率的测量单位,使输出更容易理解一些。

3

对于未达到此目标意味着什么,用通俗的语言说。

4

我们在这里检索吞吐量(count)的度量。也可以使用value检索计量值,total检索定时器总时间,分布摘要总量,长任务定时器活动任务和percentile

5

我们正在对度量值进行测试的阈值。当该服务每秒接收超过 10,000 个请求时,它会将自身报告为服务终端点监控的任何内容的服务中断。

当此健康指标被消费者网关使用,并且可以包含自定义代码以响应不同条件时,最好始终报告此健康指标的状态为UP。我们可以在应用程序中硬编码一些固定的阈值,并在利用率超过阈值时报告不同的状态,例如OVERLOADED。更好的方法是从动态配置服务器中获取阈值,以便可以在运行实例中通过更改配置服务器的值来一次性更改该值。最好是将决定何时利用过多的任务留给负载均衡器,该负载均衡器可以将此决策折叠到更复杂的标准中。

健康检查

但有时我们的“网关”是某种平台负载均衡器(通过平台负载均衡器,我指的是类似于 AWS 应用负载均衡器的东西),它可以响应其决策的较粗略的可用性指标。例如,许多平台负载均衡器提供了配置健康检查路径和端口的手段。这可以很容易地配置为 /actuator/health,但平台负载均衡器只会响应响应的 HTTP 状态是否成功。配置灵活度不足以查看利用率详细信息并根据阈值做出决策。在这种情况下,真正需要应用程序代码来设置阈值并返回 Health.up()Health.outOfService()。虽然将此决策留给应用程序并没有真正的本质问题,但这确实需要在编写应用程序时对性能有一些先验知识,并且在部署环境中灵活性较低。作为查看健康检查的平台负载均衡器的示例,DigitalOcean 为 Kubernetes 负载均衡器提供了“健康检查” 配置,如示例 7-4 所示。AWS Auto Scaling Groups 和 Google Cloud 负载均衡器也提供了健康检查配置。Azure 负载均衡器提供了类似的称为“健康探针”的配置。

示例 7-4. 配置为查看实例报告的利用率的 Kubernetes 负载均衡器
metadata:
 name: instance-reported-utilization
  annotations:
   service.beta.kubernetes.io/do-loadbalancer-healthcheck-port:80
   service.beta.kubernetes.io/do-loadbalancer-healthcheck-protocol:http
   service.beta.kubernetes.io/do-loadbalancer-healthcheck-path:/actuator/health
   service.beta.kubernetes.io/do-loadbalancer-healthcheck-check-interval-seconds:3
   service.beta.kubernetes.io/do-loadbalancer-healthcheck-response-timeout-seconds:5
   service.beta.kubernetes.io/do-loadbalancer-healthcheck-unhealthy-threshold:3
   service.beta.kubernetes.io/do-loadbalancer-healthcheck-healthy-threshold:5

在设置类似这样的健康指标时,任务是找到一些最能概括应用可用性的关键性能指标。这个性能指标应该监控应用程序中的弱点,即过多的流量最终会导致问题。在本例中,我们选择将 /persons API 端点视为此示例中利用率可用性的关键性能指标。我们可以选择多个端点、多个 HTTP 响应结果或任何其他 HTTP 端点吞吐量的组合。此外,还有其他可以使用的利用率测量。如果这是一个事件驱动的应用程序,那么从消息队列或 Kafka 主题中消费消息的速率也是合理的。如果应用程序中的多个执行路径都导致与某些利用率受限资源(如数据源或文件系统)的交互,就像图 7-6 中描述的那样,那么测量该资源上的利用率也似乎是合理的。

srej 0706

图 7-6. 当多个执行路径导致数据源成为瓶颈时,测量数据源的吞吐量

此健康指标可以添加到从start.spring.io生成的新的 Spring Boot 应用程序,该应用程序包括对io.micrometer:micrometer-core的运行时依赖项以及示例 7-5 中找到的配置。

示例 7-5. 必需的 application.yml 配置
management:
  endpoints.web.exposure.include: health
  endpoint.health.show-details: always

http://APP_HOST/actuator/health收到的响应包括实例对其自身利用率的视图。从网关/负载均衡器需要对呈现给“选择两个”算法的实例列表进行预过滤时,是否此利用率表示接近满负载并不重要。算法选择权重更高的两个数字中较低的数字。仅当网关/负载均衡器需要预过滤呈现给“选择两个”算法的实例列表时,它才需要一些特定领域知识的合理截止阈值的某种理解,该阈值是超过某一利用率水平是否表示过多利用率的重要视角。

通过主动轮询每个实例,我们在每个实例上增加了与负载均衡器节点数量成比例的额外负载。但对于吞吐量低的服务,特别是当利用率轮询速率超过特定负载均衡器的请求率时,轮询提供了更准确的利用率图片。

被动策略提供了到达特定实例的最后请求的最新利用率视图。对实例的吞吐量越高,利用率测量就越准确。

我们可以使用实例报告的利用率或健康作为更加随机化的启发式算法的输入,接下来将描述。

选择两个

“Choice of two” 会随机选择两个服务器,并基于某些标准最大化选择其中一个。

使用多因素定义标准限制了可能无意中导致羊群效应的偏见。例如,假设一个服务器在每个请求上都失败,并且(通常情况下)故障模式使得失败响应的响应时间低于成功响应。实例的利用率将显得更低。如果仅使用利用率作为唯一因素,那么负载均衡器将开始向不健康的实例发送更多请求!

计算这三个因素的聚合,并在选择两个选择上进行最大化:

客户端健康

该实例的连接相关错误度量

服务器利用率

由实例提供的最新利用率测量

客户端利用率

从此负载均衡器到实例的正在进行的请求计数

为了使这更加健壮,考虑预先过滤从中选择和比较的服务器列表。确保以某种方式限制过滤,以避免在具有大量不健康实例的集群中搜索相对健康实例时造成高 CPU 成本(例如,仅尝试多次选择相对健康实例)。通过过滤,我们可以在一部分集群持续不可用时,仍然向两个算法的选择提供相对健康实例之间的选择。

我们可以向我们的选择算法中添加最后一个调整,以适应冷启动。

实例试用期

为了避免在新实例仍在进行第二阶段预热时过载,我们可以简单地对允许发送到该新实例的请求数量设置一个静态限制。当负载均衡器从新实例接收到一个或多个利用率响应时,试用期结束。

静态限制新实例的速率限制概念可以扩展到基于实例年龄逐渐增加的速率限制。Micrometer 开箱即用地包含了一个process.uptime度量标准,可用于计算实例的年龄。

现在我们拥有了一系列负载均衡策略的工具箱,让我们思考一下它们可能产生的一些意外副作用。

更智能负载均衡的连锁效应

为了将流量从出现可用性问题的实例转移开来,开发了两个负载均衡器的选择目标。这带来了一些有趣的效果:

  • 在蓝/绿部署(或滚动蓝/绿)中负载均衡两个集群时,如果其中一个集群的性能相对较差,则它将接收到比相等份额更少的流量。

  • 在自动金丝雀分析设置中,由于同样的原因,基准和金丝雀可能会接收到不同比例的流量。

  • 异常检测可能不会像往常那样迅速发现异常值,因为低可靠性的早期信号意味着对该实例的尝试较少。

  • 请求分布不会像轮询负载均衡器那样均匀。

可用性信号始终随时间衰减。在实例报告的利用率示例中(示例 7-3),这就是为什么我们使用了利用率的每个时间间隔的速率测量。一旦时间间隔结束,不稳定期不再反映在利用率数据中。结果是,如果一个实例从低可用性期恢复过来,则它可以再次在选择两个实例时获胜,并从负载均衡器接收流量。

并非每种微服务架构都设计成使得微服务间请求总是通过网关传递(也不应该如此)。

客户端负载均衡

第三种选择是实现客户端负载均衡器。这将负载均衡决策留给调用方。历史上,客户端负载均衡已被用于新颖的负载均衡策略,比如云平台区避免或区亲和性,对最低加权响应时间的偏好等。当这些策略在一般情况下运行良好时,它们往往会重新出现作为平台负载均衡器的特性。

图 7-7 展示了服务 A 和服务 B 之间的交互,其中服务 A 使用客户端负载均衡器将流量分发到服务 B。客户端负载均衡器是服务 A 应用代码的一部分。通常情况下,实例列表将从诸如 Eureka 或 Consul 的发现服务中获取,并且由于客户端负载均衡器将流量引导到从发现服务获取的服务 B 实例,所以在服务 B 前面不需要平台负载均衡器。

srej 0707

图 7-7. 客户端负载均衡

客户端负载均衡可以用于不同的目的。最初的一个目的是从像 Eureka 或 Consul 这样的中央服务发现机制动态获取服务器 IP 或主机名列表。

为什么选择服务发现而不是云负载均衡器?

当 Netflix 首次开发 Eureka 时,AWS VPC 还不存在,Elastic Load Balancers 总是具有公共的、面向互联网的主机名。不希望将内部微服务暴露给公共互联网,Netflix 构建了 Eureka,以实现中心化管理,类似于私有应用负载均衡器(ALBs 现在被认为是 AWS 的旧 ELB 构造的替代品)可以在每个微服务基础上实现的功能。也许如果 VPC 在 Netflix 首次迁移到 AWS 时就存在,Eureka 就永远不会出现。尽管如此,它的使用已经扩展到了集群中可用实例的范围之外。表 5-1 展示了它在事件驱动微服务的蓝/绿部署中的使用,以使禁用集群中的实例失效。并非每家企业都会利用这种工具,如果不使用的话,私有云负载均衡器可能更简单管理。

Spring Cloud Commons 提供了一个客户端负载均衡抽象,使得配置这些典型问题变得相当简单,就像 例子 7-6 中那样。从这样的配置生成的 WebClient 将缓存一段时间的服务列表,优先选择同一区域内的实例,并使用配置的 DiscoveryClient 获取可用名称列表。

例子 7-6. 负载均衡健康检查
@Configuration
@LoadBalancerClient(
  name = "discovery-load-balancer",
  configuration = DiscoveryLoadBalancerConfiguration.class
)
class WebClientConfig {
  @LoadBalanced
  @Bean
  WebClient.Builder webClientBuilder() {
    return WebClient.builder();
  }
}

@Configuration
class DiscoveryLoadBalancerConfiguration {
	@Bean
	public ServiceInstanceListSupplier discoveryClientServiceInstances(
    ConfigurableApplicationContext context) {

    return ServiceInstanceListSuppliers.builder()
      .withDiscoveryClient()
      .withZonePreference()
      .withHealthChecks()
      .withCaching()
      .build(context);
	}
}

然而,并非所有的负载均衡都是关于服务器选择。有一种特定的客户端负载均衡策略用于切断高于第 99 百分位数的尾延迟。

避免请求过载

第二章显示,对于N个请求,至少有一个请求在延迟分布的前 1%的几率是 ( 1 - 0 . 99 N ) 100 % 。此外,我们看到延迟分布几乎总是多模的,前 1%通常比第 99 百分位数差一到两个数量级。即使是 100 个独立资源交互,遇到其中一个前 1%延迟的几率是 ( 1 - 0 . 99 100 ) 100 % = 63 . 3 %

一种经过良好测试的策略,用于减轻调用下游服务或资源时顶部 1%延迟的影响,是简单地向下游发送多个请求,并接受首先返回的响应,丢弃其他响应。

这种方法可能会令人惊讶,因为显然它会根据您在您的对冲中包含的额外请求数量线性增加下游的负载(而且潜在地,这可能会超出直接下游的线性扇出,因为它反过来会向它的下游发出请求,依此类推)。在许多企业中,服务的吞吐量未能接近其总容量,而最具韧性的服务则以某种主动-主动的方式进行水平扩展,以限制在任何一个区域发生故障的影响。这样做的效果是增加容量(通常是未使用的),以提高韧性。在最好的情况下,对冲请求可以简单地利用这些多余的容量,同时通过显著减少最糟糕延迟的频率来改善终端用户响应时间。

显然,决定采用对冲请求需要一些关于所调用下游服务的领域特定知识。将三个请求发送到第三方支付系统以收取客户的信用卡费用是不合适的!因此,对冲请求通常在应用程序代码中执行。

对冲请求无法由服务网格客户端负载均衡器实施(!!)

由于领域特定的知识基本上要求负载平衡决策是由调用应用程序进行的,因此要注意将客户端负载平衡的责任转移到服务网格对于对冲请求来说是行不通的。鉴于对冲请求是补偿长尾延迟超过 99 百分位数的最简单有效的方法之一,因此服务网格无法替代应用程序代码来实现这一目的应该是考虑服务网格模式是否真正适用于更广泛的情况的触发器。

现在我们将讨论补偿下游微服务失败或减少这类服务首次被淹没的模式。

调用弹性模式

无论负载均衡器对哪个实例能够最好地处理流量做出多么准确的预测决策,任何预测都是基于过去性能的投影。过去的表现永远不是未来结果的保证,因此仍然需要另一层面的弹性来处理故障。此外,即使是一个完美地将流量分配给任何给定时间最可用的实例的负载均衡器后面的微服务集群,也有一个可以处理的极限。微服务架构的各层需要防止过载,这可能导致完全失败。

这些机制一起形成了不同的基本“背压”方案。

背压是从服务系统向请求系统发出失败信号以及请求系统如何处理这些失败,以防止过载自身和服务系统。设计背压意味着在过载时和系统故障时限制资源使用。这是创建强大的分布式系统的基本构建块之一。背压的实现通常涉及要么将新消息丢弃,要么在资源受限或发生故障时将错误返回给用户(并在这两种情况下递增度量)。对其他系统的连接和请求进行超时和指数级回退也是必不可少的。如果没有背压机制,级联故障或意外消息丢失变得可能。当系统无法处理另一个系统的故障时,它往往会向依赖于它的另一个系统发出故障。

Jeff Hodges

调用者可以结合四种模式来提高弹性:

  • 重试

  • 速率限制器

  • 防波堤

  • 断路器

重试是克服间歇性故障的明显第一步,但我们需要谨慎创建“重试风暴”,即在原始请求开始失败时,不要用重试淹没已经处于困境中的系统的部分。其他模式将有助于补偿。但是,让我们从重试开始。

重试

预期下游服务的短暂失败,由于临时满线程池、慢网络连接导致超时或其他导致不可用的临时条件。这类故障通常在短时间后会自行纠正。调用者应准备通过在对下游的调用中包装重试逻辑来处理短暂的失败。在添加重试时考虑三个因素:

  • 是否适合重试。这通常需要对被调用服务的领域特定知识。例如,我们是否应该在下游服务返回超时时重试支付尝试?超时操作最终会被处理,重试是否可能导致重复收费?

  • 最大重试次数和重试尝试之间使用的持续时间(包括退避,如 示例 7-7 所示)。

  • 哪些响应(和异常类型)需要重试。例如,如果下游因为某些原因返回 400 错误,表明输入格式错误,我们不能期望通过重试相同的输入获得不同的结果。

示例 7-7. 使用 Resilience4J 设置指数退避重试
RetryConfig config = RetryConfig.custom()
  .intervalFunction(IntervalFunction.ofExponentialBackoff(
    Duration.ofSeconds(10), 3))
  .maxAttempts(3)
  .retryExceptions(RetryableApiException.class)
  .build();

RetryRegistry retryRegistry = RetryRegistry.of(config);

Retry retry = retryRegistry.retry("persons.api");

retry.executeCallable(() -> {
  Response response = ...
  switch(response.code()) {
    case 401:
      // Authentication flow
    case 502:
    case 503:
    case 504:
      throw new RetryableApiException();
  }

  return response;
});

Resilience4J 具有用于重试逻辑的内置指标,通过将重试绑定到 Micrometer 的仪表注册表来启用,如 示例 7-8 所示。

示例 7-8. 通过 Micrometer 发布关于重试的指标
TaggedRetryMetrics
  .ofRetryRegistry(retryRegistry)
  .bindTo(meterRegistry);

这导出了一个单一的仪表 resilience4j.retry.calls,带有一个 kind 标签,区分成功(带重试和无重试)和失败(带重试和无重试)的调用。如果设置了警报,它将基于 kind 等于 failed.with.retry 的调用数阈值。在许多情况下,进行调用的代码本身将会计时。例如,一个 REST 端点在调用时会执行一些工作,包括使用重试逻辑进行下游服务调用,该端点本身将使用 http.server.requests 进行计时,您应该已经在该端点的失败上设置了警报。

然而,如果您的应用程序包含一个常见的组件,通过重试逻辑保护对资源或下游服务的访问,那么对该资源的高失败率报警可能是一个很好的信号,表明应用程序的几个部分将会失败。

速率限制器

微服务的负载根据用户活动模式、定期批处理等而自然变化。突发事件可能导致活动突然激增,给某个微服务提供的特定业务功能造成资源紧张,可能导致可用性低于已建立的 SLO,速率限制(也称为节流)可以保持服务运行并响应请求,尽管在定义的吞吐率下。

Resilience4J 使用多种选项实现速率限制器模式。在 例子 7-9 中,一个微服务需要调用下游的账单历史和支付服务时使用了速率限制器。服务交互的图示如 图 7-8 所示。

srej 0708

图 7-8. 调用恢复性示例服务交互
例子 7-9. 使用 Resilience4J 实现速率限制器
RateLimiterConfig config = RateLimiterConfig.custom()
  .limitRefreshPeriod(Duration.ofMillis(1))
  .limitForPeriod(10) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  .timeoutDuration(Duration.ofMillis(25)) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
  .build();

RateLimiterRegistry rateLimiterRegistry = RateLimiterRegistry.of(config);

RateLimiter billingHistoryRateLimiter = rateLimiterRegistry
  .rateLimiter("billingHistory");
RateLimiter paymentRateLimiter = rateLimiterRegistry
  .rateLimiter("payment", config); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)

// Components that would, as part of their implementations, // execute HTTP requests to downstream services BillingHistory billingHistory = ...
Payments payments = ...

// Spring WebFlux Functional route specification RouterFunction<ServerResponse> route = route()
	.GET("/billing/{id}", accept(APPLICATION_JSON),
    RateLimiter.decorateFunction(
      billingHistoryRateLimiter,
      BillingHistory::getHistory
    )
  )
	.POST("/payment",
    RateLimiter.decorateFunction(
      paymentRateLimiter,
      Payments::sendPayment
    )
  )
	.build();

1

速率限制器允许的并发限制。

2

阻塞线程尝试进入饱和速率限制器的超时时间。

3

可以使用与全局配置不同的配置创建某个服务的速率限制器(例如,因为此服务能够承受比其他服务更高的并发级别)。

Resilience4J 针对速率限制器内置了指标。将您的速率限制器注册到 Micrometer 米表注册中,例如 例子 7-10。

例子 7-10. 通过 Micrometer 发布有关速率限制的指标
TaggedRateLimiterMetrics
  .ofRateLimiterRegistry(rateLimiterRegistry)
  .bindTo(meterRegistry);

然后发布 表格 7-1 中的指标。

这两个指标具有不同的优势。可用权限是一个有趣的 预测性 指标。如果权限接近零(或之前没有),但等待线程数较低,则尚未降低终端用户体验。高等待线程数是一个更 反应性 的度量,表示下游服务可能需要扩展,因为终端用户体验正在降低(如果响应时间很重要且经常有等待线程)。

表格 7-1. Resilience4J 提供的速率限制器指标

指标名称 类型 描述
resilience4j.ratelimiter.available.permissions Gauge 可用权限数或未使用的并发容量
resilience4j.ratelimiter.waiting.threads Gauge 等待线程数

在 Atlas 中,等待线程的警报条件根据固定阈值进行测试,如 例子 7-11 所示。

例子 7-11. Atlas 速率限制器警戒阈值
name,resilience4j.ratelimiter.waiting.threads,:eq,
$THRESHOLD,
:gt

在 Prometheus 中,其思想类似,如 例子 7-12 所示。

例子 7-12. Prometheus 速率限制器警戒阈值
resilience4j_ratelimiter_waiting_threads > $THRESHOLD

防护栅

微服务通常会对多个下游服务执行请求。当某个服务的可用性较低时,可能会导致依赖的服务同样无响应。特别是当依赖的服务是阻塞式并使用线程池进行请求时更为明显。对于一个具有多个下游服务的微服务 A,可能只有一小部分流量(某种类型的请求)导致对微服务 B 的请求。如果 A 使用一个公共线程池来执行请求,不仅针对 B,而是针对所有其他下游服务,那么 B 的不可用性可能会逐渐阻塞请求,使公共线程池中的线程饱和,几乎没有或完全没有工作可以进行。

舱壁模式隔离了各个下游服务,为每个下游服务指定了不同的并发限制。这样,只有需要调用 B 的服务请求会变得无响应,而 A 的其余服务仍将保持响应。

Resilience4J 使用多种选项实现舱壁模式,如 示例 7-13 所示。

示例 7-13. 使用 Resilience4J 实现舱壁模式
BulkheadConfig config = BulkheadConfig.custom()
  	.maxConcurrentCalls(150) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
  	.maxWaitDuration(Duration.ofMillis(500)) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png)
  	.build();

BulkheadRegistry registry = BulkheadRegistry.of(config);

Bulkhead billingHistoryBulkhead = registry.bulkhead("billingHistory");
Bulkhead paymentBulkhead = registry.bulkhead("payment", custom); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png)

// Components that would, as part of their implementations, execute HTTP requests // to downstream services BillingHistory billingHistory = ...
Payments payments = ...

// Spring WebFlux Functional route specification RouterFunction<ServerResponse> route = route()
	.GET("/billing/{id}", accept(APPLICATION_JSON),
    Bulkhead.decorateFunction(
      billingHistoryBulkhead,
      BillingHistory::getHistory
    )
  )
	.POST("/payment",
    Bulkhead.decorateFunction(
      paymentBulkhead,
      Payments::sendPayment
    )
  )
	.build();

1

舱壁允许的并发限制。

2

尝试进入饱和舱壁的阻塞线程的超时时间。

3

可以为某些服务创建与全局配置不同的舱壁(例如,因为此服务能够比其他服务支持更高的并发级别)。

Resilience4J 提供了舱壁的内置指标。将舱壁注册表绑定到 Micrometer 计量注册表,如 示例 7-14 中所示。

示例 7-14. 通过 Micrometer 发布有关舱壁的指标
TaggedBulkheadMetrics
  .ofBulkheadRegistry(bulkheadRegistry)
  .bindTo(meterRegistry);

表 7-2 显示了 Resilience4J 提供的舱壁指标。当可用并发调用频繁达到零或接近零时发出警报。

表 7-2. Resilience4J 提供的舱壁指标

Metric name Type Description
resilience4j.bulkhead.available.concurrent.calls Gauge 可用许可数,或未使用的容量
resilience4j.bulkhead.max.allowed.concurrent.calls Gauge 可用许可的最大数目

在 Atlas 中,等待线程的警报条件针对固定阈值进行测试,如 示例 7-15 中所示。这也可能是使用 :roll-count 限制警报过多的好地方。

示例 7-15. Atlas 舱壁警报条件
name,resilience4j.bulkhead.available.concurrent.calls,:eq,
$THRESHOLD,
:lt

在 Prometheus 中,与 示例 7-16 中所示的类似。

示例 7-16. Prometheus 舱壁警报条件
resilience4j_bulkhead_available_concurrent_calls < $THRESHOLD

断路器

断路器是与一种扭曲的批处理延伸的进一步扩展。断路器维护一个有限状态机,如图 7-9 所示,用于它保护的执行块,其状态为关闭、半开和打开。在关闭和半开状态下,允许执行。在打开状态下,执行应用程序定义的回退。

关闭、半开和打开

图 7-9. 断路器的状态

断路器的一个经典例子是 Netflix 的电影推荐列表。这通常是根据过去的观看历史等个性化订阅者的。一个守卫对个性化服务的调用的断路器可能会在断路器处于打开状态时以一份通用内容列表作为回退而响应。

对于某些类别的业务问题,一个最终不会向用户呈现失败的回退是不可能的。假设无法接受用户的付款(假设不能将其存储在某个地方以供以后处理)。

成功和失败的执行被维护在一个环形缓冲区中。当环形缓冲区初始填满时,失败率被测试是否超过预配置的阈值。当失败率高于可配置阈值时,断路器的状态从关闭变为打开。当断路器被触发并打开时,它将在定义的时间段内停止允许执行,之后断路器半开,允许少量流量通过,并测试该少量流量的失败率是否超过阈值。如果失败率低于阈值,则再次关闭电路。

Netflix Hystrix 是第一个主要的开源断路器库,尽管它仍然很有名,但现在已经被弃用。Resilience4J 通过改进库的卫生性和支持更多线程模型来实现断路器模式。示例见 Example 7-17。

Example 7-17. 使用 Resilience4J 实现断路器模式
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
  .failureRateThreshold(50)
  .waitDurationInOpenState(Duration.ofMillis(1000))
  .ringBufferSizeInHalfOpenState(2)
  .ringBufferSizeInClosedState(2)
  .build();

CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry
  .of(circuitBreakerConfig);

CircuitBreaker billingHistoryCircuitBreaker = circuitBreakerRegistry
  .circuitBreaker("billingHistoryCircuitBreaker");
CircuitBreaker paymentCircuitBreaker = circuitBreakerRegistry
  .circuitBreaker("payment", circuitBreakerConfig); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)

// Components that would, as part of their implementations, execute HTTP requests // to downstream services BillingHistory billingHistory = ...
Payments payments = ...

// Spring WebFlux Functional route specification RouterFunction<ServerResponse> route = route()
	.GET("/billing/{id}", accept(APPLICATION_JSON),
    CircuitBreaker.decorateFunction(
      billingHistoryCircuitBreaker,
      BillingHistory::getHistory
    )
  )
	.POST("/payment",
    CircuitBreaker.decorateFunction(
      paymentCircuitBreaker,
      Payments::sendPayment
    )
  )
	.build();

1

某个服务的断路器可以使用与全局不同的配置创建。

Resilience4J 包含用于断路器的内置指标仪表,您应该监视打开的断路器。通过将断路器注册表绑定到 Micrometer 计量器注册表来启用它,如 Example 7-18 所示。

Example 7-18. 绑定断路器指标
TaggedCircuitBreakerMetrics
  .ofCircuitBreakerRegistry(circuitBreakerRegistry)
  .bindTo(meterRegistry);

Table 7-3 显示了 Resilience4J 为每个断路器公开的两个指标。

表 7-3. Resilience4J 公开的断路器指标

指标名称 类型 描述
resilience4j.circuitbreaker.calls 计时器 成功和失败调用的总数
resilience4j.circuitbreaker.state 计量 根据状态标签描述的状态(开、闭等),设置为 0 或 1。
resilience4j.circuitbreaker.failure.rate 计量 断路器的失败率

由于这些是计量器,您可以通过执行 max 聚合来警告任何断路器当前是否打开。此时,最终用户已经通过接收到回退响应或直接传播失败而经历了降级的体验。

在 Atlas 中,警报条件检查是否处于打开状态,如示例 7-19 中所示。

示例 7-19. Atlas 断路器警报阈值
name,resilience4j.circuitbreaker.state,:eq,
state,open,:eq,
:and,
:max, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png)
1,
:eq

1

如果任何断路器打开,此警报将匹配它。

在 Prometheus 中,这个思路类似,如示例 7-20 中所示。我们可以使用 sum(..) > 0max(..) == 1 来达到相同的效果。

示例 7-20. Prometheus 断路器警报阈值
sum(resilience4j_circuitbreaker_state{state="open"}) > 0

尽管电路可能会暂时打开然后再次关闭,但为了限制警报频繁性,可能最好是在 resilience4j.circuitbreaker.calls 上设置一个错误率指示器,允许在警报之前有一定数量的失败请求转向回退。

接下来我们将讨论如何通过响应代码和环境中的变化来提高警报阈值本身的灵活性。

自适应并发限制

到目前为止,每个呼叫恢复模式(限速器、舱壁和断路器)都有效地用于防范与负载相关的问题,无论是预防性还是响应性的。

在每种情况下,模式都配置有一个在微服务实际运行之前预先确定的阈值。速率限制器被配置为限制在间隔内可以执行的请求数量,舱壁限制瞬时并发,而断路器则将负载从遇到故障的实例(包括与负载相关的故障)中卸载出去。这些阈值可以通过仔细的性能测试确定,但随着代码更改、下游集群的规模及其可用性的变化等,它们的值往往会与真实限制偏离。

本书始终以用自适应判断替代固定阈值或手动判断为主题。我们在第二章中看到了使用预测算法设置的阈值,在第五章中看到了自动金丝雀分析。类似的自适应方法也可以应用于并发限制。

选择正确的调用恢复模式

在代码中,用于舱壁、速率限制器和断路器的模式看起来非常相似。实际上,这三种模式具有重叠的责任,如 图 7-10 所示。

srej 0710

图 7-10. 三种调用恢复模式的重叠责任

所有三种模式都实现了速率限制,但采用了不同的机制,总结在 表 7-4 中。

表 7-4. 不同调用恢复模式的速率限制机制

模式 限制机制 注意事项
速率限制器 限制间隔内的速率 这不限制瞬时并发性(例如,间隔内的流量突增)除非瞬时并发性超过了整个间隔的限制。
舱壁 限制瞬时并发级别 限制在尝试新请求时的并发级别。这不直接限制间隔内的向下游请求数量,除非向下游服务以非零时间响应。
断路器 响应错误(其中一些是由于下游并发的自然限制所导致) 由断路器保护的 RPC 请求要么超时,要么下游服务(或其负载均衡器)会以失败响应,例如 HTTP 502(不可用)。这不直接限制瞬时或每个间隔的速率,除非向下游开始饱和。

因此,很少见到一块代码同时由速率限制器、舱壁或断路器中的多个模式保护。

到目前为止所展示的调用恢复模式的实现都使用了 Resilience4J,使其成为应用开发的关注点。让我们将这作为一个应用关注点与将责任外部化到服务网格中进行比较。

在服务网格中的实现

最终,是否尝试将流量管理从应用关注点中剥离的决定必须根据每个组织的情况做出,基于其对 表 7-5 中所示多个标准的重视程度。这里的“应用责任”意味着通过应用代码实现功能或通过对共享库的二进制依赖自动配置。跨标题行分配给每个标准的权重仅是一个示例,并且会因组织而异。例如,对于使用大量编程语言的组织,可能会更高地分配给语言支持的权重。这应该是您决策的驱动因素。

表 7-5. 交通管理的服务网格与应用责任决策矩阵(得分越高 = 成本越高)

服务网格 应用程序责任
语言支持 = 5 低:只需要一个轻客户端来连接到网格(1 x 5 = 5) 高:每种语言都需要不同的实现(5 x 5 = 25)
运行支持 = 5 高:例如,Istio 是 Kubernetes 的 CRD,因此绑定到特定运行时(5 x 5 = 25) 低:仅在库希望利用运行时的特定功能时才会有影响(1 x 5 = 5)
部署复杂性 = 4 中:需要更改部署实践(3 x 4 = 12) 非常低:根本不改变部署(0 x 4 = 0)
反灵活性 = 3 中:随着模式变得更为熟知,它们在网格中被概括,但不会立即实现(3 x 3 = 9) 中:引入新模式需要跨堆栈进行依赖更新(4 x 3 = 12)
操作成本 = 2 高:通常会消耗更多资源(5 x 2 = 10),并且在独立于应用程序的足迹的操作经验中进行升级 低:每个应用程序不会分配额外的进程或容器(1 x 2 = 2)
总成本 5 + 25 + 12 + 9 + 10 = 61 25 + 5 + 0 + 12 + 2 = 44(在这些权重下的最佳选项)

前文提到的双选负载均衡器是一个复杂的负载均衡器的示例,需要与应用程序代码协调,因此无法完全由服务网格技术封装。

这种与应用程序代码协调不足的另一个复杂性是另一项任务的复杂性,例如 请求超时,由边车代理处理。尽管在配置的超时后,代理可能会挂断调用者,但应用实例仍在工作中处理请求,直到完成。如果应用程序使用像 Tomcat 这样的传统阻塞线程池模型,超时后仍会继续消耗线程。

目前,Istio 只支持一个更接近我们定义的隔离舱的“断路器”,因为它支持通过控制最大连接或请求来限制对服务的瞬时并发。与这成为应用程序关注的情况不同,使用 Istio 时,隔离舱将通过来自 Istio Kubernetes 自定义资源定义的 YAML 配置应用,如 Example 7-21 中所示。

示例 7-21. Istio 断路器
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: billingHistory
spec:
  host: billingHistory
  subsets:
  - name: v1
    labels:
      version: v1
    trafficPolicy:
      connectionPool:
        tcp:
          maxConnections: 150

这也显示了服务网格的反灵活性。您的流量管理策略的复杂性将受到 YAML 可表达性的限制。考虑到这种有限表达能力是不成为应用程序代码的 必要 条件的重要性。

例如,如果 Istio CRD YAML 遵循了标记语言的典型演进,以满足更多不同的期望,我们预计会看到布尔逻辑的引入(如在 multi-match 中已出现)用于组装更复杂的规则等。感觉循环是不可避免的。

再次,软件工程中的趋势往往是循环的。通过静态配置或标记简化应用程序开发的愿望以前曾发生过,并带来了有趣的后果。请记住,远在“配置即代码”时,XSLT 慢慢演变成为图灵完备语言的例子。这一点很重要,因为一旦发生这种情况,使用静态分析验证配置(现在是完整的软件)的各种特性就变得不可能。此时,我们已远离最初的目标,即将功能性保持在代码之外。

在 RSocket 中的实现

响应式流提供了一种标准的异步流处理方式,支持非阻塞回压。RSocket 是一种持久的双向远程过程调用协议,实现了响应式流的语义。回压的目标在 2014 年的响应式宣言中有所描述:

当一个组件难以跟上时,整个系统需要以明智的方式作出响应。组件在压力下不能灾难性地失败或以不受控制的方式丢弃消息是不可接受的。由于它无法应对并且不能失败,它应该向上游组件传达其处于压力之下的事实,以便让它们减少负载。这种回压是一个重要的反馈机制,允许系统在负载增加时优雅地响应而不是崩溃。回压可能一直级联到用户这一层,此时响应性可能会下降,但此机制将确保系统在负载下具有弹性,并提供信息,这些信息可以让系统本身应用其他资源来帮助分配负载...

响应式宣言

网络层上的回压概念很可能消除应用程序代码(或旁路进程)中速率限制器、防护舱和断路器的需要。当应用实例观察到自身可用性下降时,它会对调用者施加回压。有效地,调用者无法对不可用的应用实例发起调用。

预计将看到基础设施的进一步演进,扩展回压到应用程序堆栈的上下游。R2DBC 已将回压扩展至数据库交互。Netifi 围绕此概念构建了整个控制平面,一种没有许多缺点的服务网格替代品。

摘要

在任何生产微服务架构中,都应预期并计划故障和性能下降。在本章中,我们介绍了一些处理这些条件的策略,从负载平衡到调用恢复模式。

你的组织在这些模式中的承诺几乎完全体现在应用代码中。与影响应用代码的其他横切关注点(如度量仪表和分布式跟踪)一样,一个有效的平台工程团队有机会通过在核心库和所有组织微服务都使用的配置中提供一些这些跨组织的良好默认观点来介入。

本书描述了通往更可靠系统的旅程。在这个旅程中,你可以尽可能远地前行,意识到每一步都让你的业务受益。它从简单地测量系统现状开始,增加对终端用户日常体验的更高程度认知。继续添加调试信号,使你能够在意识到故障原因时提出问题。优化软件交付流水线,减少在软件构建过程中引入更多故障的可能性。建立能够观察部署资产状态的能力,以便在需要时开始跨组织地推理如何进行变更。这些针对预期故障的补偿措施是通向构建更可靠分布式系统旅程的最后一步。

每一步都要设置护栏而不是大门!

posted @ 2024-06-15 12:22  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报