原文:
zh.annas-archive.org/md5/5840805588a9d2f06db4b8012a31e970
译者:飞龙
第三章:使用可观察性进行调试
如在 第二章 开头提到的,可观察性信号可以根据它们带来的价值大致分为两类:可用性和调试性。聚合应用程序度量提供了最佳的可用性信号。在本章中,我们将讨论另外两个主要信号,即分布式追踪和日志。
我们将展示使用仅开源工具的方法来关联度量和跟踪的一种方法。一些商业供应商也致力于提供这种统一体验。就像在 第二章 中一样,展示特定方法的目的是为了开发对你的可观察性堆栈在完全组装时应具备的最低期望水平。
最后,分布式追踪仪表化,因其需要在微服务层次结构中传播上下文,可以成为系统更深层行为管理的有效场所。我们将讨论一个假设的故障注入测试功能作为其可能性的例子。
可观察性的三大支柱……还是两大支柱?
正如 《分布式系统可观察性》 一书中 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 <https://github.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. <https://github.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. <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png>
*既没有度量也没有日志记录,只有追踪。*Mongo 套接字连接尝试可以很容易地通过度量进行计时,其中标签指示成功/失败,并带有类似exception=ConnectException
的摘要异常标签。这种摘要标签可能足以在查看整个堆栈跟踪之前理解问题。在其他情况下,如果摘要异常标签是类似exception=NullPointerException
的内容,则在监控系统提醒我们一组异常未能达到已建立的服务水平目标时,记录堆栈跟踪有助于识别具体问题。
既有跟踪又有度量,没有日志。 代码中的日志语句可以完全删除。度量和分布式跟踪以一种允许我们综合理解所有支付检索及个别支付的代表性检索的方式捕获所有有趣信息。例如,度量将显示,虽然大多数支付在不到 40 毫秒内检索,但有些支付可能需要一个数量级更长的时间来检索。
度量,没有跟踪或日志。 一个近缓存的频繁检索支付可以严格通过一个度量器来监控。在跟踪仪器中没有等效的度量器,并且记录这一点是多余的。
你应该选择哪个可观察性工具?
如果可能的话,跟踪比日志记录更可取,因为它可以包含相同的信息,但上下文更丰富。在跟踪和度量重叠的地方,应该从度量开始,因为第一个任务应该是知道某个系统不可用。稍后可以添加额外的遥测来帮助解决问题。当你添加跟踪时,从存在定时度量仪器的地方开始,因为很可能也值得使用相同标签的超集进行跟踪。
假设你已经准备好添加分布式跟踪,接下来让我们考虑一下什么构成了一个跟踪,以及它如何被可视化。
分布式跟踪的组成部分
一个完整的分布式跟踪是一组单独的span的集合,这些 span 包含每个端到端用户请求满意度中每个接触点的性能信息。这些 span 可以组装成一个“冰柱”图,显示每个服务中花费的时间相对多少,如图 3-1 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00066.png
图 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/{paymentId} |
详细的链接 | /api/payment/abc123 | |
用户 | user123456 |
到目前为止,应该清楚地知道,追踪旨在让您了解请求的端到端性能。因此,不应感到意外,Zipkin UI 专注于根据一组参数搜索追踪,如图 3-2 所示。这种列表交换了对端到端性能整体分布的理解,以匹配特定参数集的一组追踪。建立整体分布与此视图之间的关联是“遥测相关性”的主题。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00031.png
图 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") <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
}
请注意,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}") <https://github.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"); <https://github.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()); <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png>
return customer;
}
finally {
span.finish(); <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png>
}
}
}
Spring Cloud Sleuth 将自动为此端点添加仪器化,并使用诸如 http.uri
的有用上下文进行标记。
开始一个新的 span 将为跟踪冰柱图添加另一个不同的元素。现在,我们可以在整个端到端用户交互的上下文中推断出仅此方法 findCustomerById
的成本。
添加业务特定的上下文,黑盒仪器化可能缺乏。也许你的公司最近在新的国家推出了一项服务,作为全球扩张的一部分,由于其新近性,该国家的客户缺乏与你的产品的长期活动历史。看到老客户和新客户之间查找时间的惊人差异可能表明在这种情况下如何加载活动历史的变化。
整个数据访问操作都是使用白盒工具手动进行的。
假设数据库访问是跨应用程序堆栈进行追踪的(可能通过将数据库访问的包装与跟踪仪器封装成一个通用库并在整个组织中共享),则可以有效地追踪数据库,而无需向数据库层本身添加任何形式的白盒或黑盒监控。从调用者的角度来仪器化像 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 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00054.png
图 3-3. B3 跟踪头将采样决策传播到下游组件
采样对异常检测的影响
让我们具体考虑概率采样对异常检测的影响。实际上,任何采样策略都会产生类似的影响,但我们将使用概率采样来具体描述这一点。
建立在采样跟踪上的异常检测系统通常是错误的,除非你的组织承担了 100% 采样的成本。为了说明这一点,让我们考虑一种假设的采样策略,根据每个请求开始时的加权随机数做出关于是否保留追踪的决定(就像 Google 的 Dapper 最初所做的那样)。如果我们对请求进行 1% 的采样,那么一个超出第 99 百分位数的异常值,像其他所有请求一样,有 1% 的机会在采样中存活。看到任何这些个别的异常值的机会是 0.01%。即使在每秒 1,000 个请求的情况下,你可能每秒都会发生 10 个异常值,但在追踪数据中只会每 5 分钟看到一个,如图 3-4 所示(这是一个( 1 - 0 . 99 N ) * 100 %的图)。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00026.png
图 3-4. 随时间变化的追踪数据中看到异常值的机会
在第 99 百分位数以上可能存在显著范围的异常值,如图 4-20 所示。你可能每秒都会出现一个巨大的业务关键异常值(高于 P99.9),但在任何给定的小时内追踪数据中只会看到一次!出于调试目的,让一个或一小组异常值在一定时期内存活是可以接受的——我们仍然可以详细检查异常情况发生的性质。
分布式追踪与单体应用
别被分布式追踪的名字所迷惑。在单体架构中使用这种形式的可观察性是完全合理的。在最纯粹的微服务架构中,围绕 RPC 调用进行追踪可以在框架级别(比如 Spring)或者在诸如服务网格技术中找到的边车中以黑匣子的方式实现。考虑到微服务架构的单一责任性质,追踪 RPC 实际上可以为你提供关于正在发生的事情的相当多的信息;即,微服务边界实际上也是业务逻辑功能边界。
在一个接收单个端用户请求并执行许多任务以满足该请求的单体应用程序内部,框架级别的仪器化当然价值降低,但你仍然可以在单体应用程序内部的关键功能边界处编写追踪仪器化,方式与编写日志语句相同。通过这种方式,你将能够选择特定的标签,这些标签允许你搜索具有业务上下文的跨度,而框架或服务网格仪器化肯定会缺少这些标签。
实际上,具有业务特定标记的白盒仪器在纯微服务架构中变得至关重要。在许多情况下,我们的关键业务功能在生产环境中并非完全失效,而是在特定(通常是不寻常的)业务特定故障线路上出现问题。也许一个保险公司的政策管理系统无法对肯塔基州的某个县的经典车辆进行定价。在度量和跟踪遥测中同时拥有车辆类别、县和州的信息,使工程师可以在已知故障维度上进行维度钻取,并找到问题区域,然后跳转到跟踪和日志以查看示例故障。
业务上下文使得白盒跟踪在单体应用中与分布式系统一样重要
在业务功能边界上的白盒分布式跟踪仪器密度在微服务或单体架构中应大致相同,因为黑盒仪器不会使用帮助后续查找的业务特定上下文标记跨度。
因此,微服务和单体应用之间唯一的区别在于您将更多业务功能边界打包到一个进程中。随着每个额外的业务功能的增加,支持其存在的一切都会随之而来。可观察性并不是例外。
此外,即使是负责单一责任的微服务也可以执行一些任务,包括数据访问,以满足用户请求。
遥测数据的关联
由于度量数据是强有力的可用性信号,跟踪和日志数据对调试非常有用,我们可以做的一切都是将它们链接在一起,使得从警报指示可用性缺失到最佳识别潜在问题的调试信息的过渡更加顺畅。在延迟情况下,我们将在仪表板上绘制图表,并在一个衰减的最大值上设置警报。将延迟分布的视图呈现为延迟直方图的热图是一种信息密集的有趣可视化,但我们无法在其上绘制警报阈值。
度量到跟踪的相关性
我们可以在热图上绘制示例跟踪(样本),如图 3-5 所示,并通过将热图单元格转换为链接使热图更加交互化,直接跳转到跟踪界面,从中可以查看与这些标准匹配的一组跟踪。因此,负责系统的工程师收到延迟条件的警报后,查看此应用程序的延迟图表集,并可以立即点击跳转到分布式跟踪系统。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00041.png
图 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
标记跟踪。这些当然在逻辑上是等价的。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00019.png
图 3-6. 独立的 Prometheus 和 Zipkin 查询形成了联合追踪示例热图
然而,应当明确的是,即使标签键(甚至值)不必完全相同,如果您想要将热图过滤到在追踪数据中没有逻辑等价物的指标标签,那么将无法准确地找到与热图上所见内容匹配的样本(会有一些误报)。例如,Spring Cloud Sleuth 最初没有使用 HTTP 状态码或结果标记跟踪,而 Spring 的 Micrometer 工具包则有。通常我们希望将延迟可视化限制在成功或失败的结果之一,因为它们的延迟特性可能会有很大不同(例如,失败由于外部资源不可用而异常快速或由于超时而异常缓慢)。
到目前为止,我们对分布式追踪的探讨严格限于可观察性,但它可以用于影响或管理流量处理的其他目的。
使用追踪上下文进行失败注入和实验
在早期讨论分布式追踪的抽样方法时,我们涵盖了边界抽样(参见 “边界抽样”)。在这种方法中,抽样决策是事先做出的(即在边缘处),并且此决策向下游微服务传播,这些微服务参与满足请求。有一个有趣的机会可以做其他事先决策,并利用跟踪上下文将与抽样决策无关的其他信息传递给下游服务。
这其中一个著名的例子是故障注入测试(FIT),这是混沌工程的一种特定形式。混沌工程的整体学科是广泛的,并且在混沌工程中有详细介绍。
API 网关可以在与由中央 FIT 服务提供的规则协调的前提下,前置添加故障注入决策,并作为跟踪标签向下游传播。稍后,执行路径中的微服务可以使用有关故障测试的信息,以某种方式非自然地使请求失败。图 3-7 显示了整个过程,端到端。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00043.png
图 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 请求没有相同的重要性。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00065.png
图 4-1. 这不是一个好的榜样!
几乎每个指标收集器都会在任何给定时间收集比您发现有用的更多的数据。虽然每个指标在某些情况下可能有用,但绘制每个指标并不有助于。然而,几个指标(例如最大延迟、错误比率、资源利用率)对于几乎每个 Java 微服务都是强大的可靠性信号(通过调整警报阈值)。这些是我们将重点关注的内容。
最后,市场渴望将人工智能方法应用于监控数据,以自动提供对系统的洞察,而无需过多理解警报标准和关键绩效指标。在本章中,我们将在应用监控的背景下调查几种传统统计方法和人工智能方法。您应该对每种方法的优势和劣势有扎实的了解,以便能够洞悉市场宣传,并为您的需求应用最佳方法。
在进一步之前,值得考虑市场上监控系统的广泛变化以及这对于如何仪器化代码并将数据传递给这些系统的决策的影响。
监控系统的差异
在这里讨论监控系统的差异的要点在于,我们将看到如何使用 Prometheus 进行图表绘制和警报的具体内容。像 Datadog 这样的产品与 Prometheus 的查询系统非常不同。两者都很有用。未来将会出现更多具有我们尚未想象到的功能的产品。理想情况下,我们希望我们的监控工具(我们将放入应用程序中的内容)在这些监控系统中是可移植的,不需要更改应用程序代码(除了新的二进制依赖项和一些全局配置)。
分布式跟踪后端系统接收数据的方式比指标系统更具一致性。分布式跟踪仪表化库可能具有不同的传播格式,需要在整个堆栈中选择一致的仪表化库,但数据本身在后端之间基本相似。这在直观上是有道理的,因为数据本质上是分布式跟踪事件的时间信息(通过跟踪 ID 在上下文中粘合在一起)。
指标系统不仅可能表示聚合的计时信息,还可能表示仪表、计数器、直方图数据、百分位数等。它们对于数据聚合的方式并不一致。它们在查询时执行进一步聚合或计算的能力也不同。仪表化库需要发布的时间序列数量与特定指标后端的查询能力之间存在反向关系,如图 4-2 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00044.png
图 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
)的特性整合进去。在一个库深处进行仪表化的时候,很难清楚地知道应用是否将这个操作视为足够关键,值得支付额外费用来传送直方图数据。(因此,这个决定应留给下游应用程序的作者,而不是库的作者。)
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00073.png
图 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 顶部的图表使用默认选项,底部的图表根据这些建议进行了调整。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00018.png
第 4-4 图。Grafana 图表样式的默认与推荐
在图表编辑器的“可视化”选项卡中更改选项,如图 4-5 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00080.png
第 4-5 图。Grafana 线宽选项
错误与成功
对于计时器,绘制成功和错误等结果的堆叠表示非常常见,我们将在“Errors”中看到,并且在其他场景中也会出现。当我们将成功和错误视为颜色时,许多人会立即想到绿色和红色:交通灯颜色。不幸的是,大部分人口中存在色盲,影响他们区分颜色的能力。对于最常见的变色性视觉障碍,绿色和红色之间的差异很难或根本无法区分!那些受单色视觉障碍影响的人根本无法区分颜色,只能区分亮度。由于本书是单色印刷,我们所有人都可以在短暂的时间内体验一下堆叠的错误和成功图表,见图 4-6。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00089.png
图 4-6. 使用不同的线条样式显示辅助功能中的错误
我们需要提供某种错误与成功的视觉指示器,而不仅仅是严格的颜色。在这种情况下,我们选择将“成功”结果绘制为堆叠线,将错误绘制在这些结果上方作为粗点以使其突出显示。
此外,Grafana 没有提供指定时间序列在堆叠表示中出现顺序(即“成功”在堆叠底部或顶部)的选项,即使是针对一组可能值的有限集合也是如此。我们可以通过选择每个值在单独的查询中,并对查询本身进行排序来强制对它们进行排序,如图 4-7 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00088.png
图 4-7. 在 Grafana 堆叠表示中排序结果
最后,我们可以覆盖每个单独查询的样式,如图 4-8 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00092.png
图 4-8. 为每个结果覆盖线条样式
“Top k” 可视化
在许多情况下,我们希望按某个类别显示一些“最差”表现的指示器。许多监控系统提供某种查询功能,以选择某些标准的“top k”时间序列。然而,选择“top 3”最差表现者并不意味着图表上会有最多三条线,因为这场“到底”的竞赛是永无止境的,而且最差表现者在图表可视化的时间间隔内可能会发生变化。在最坏的情况下,您将在特定可视化中显示N个数据点,并且将显示 3**N*个不同的时间序列!如果您在图 4-9 的任何部分绘制垂直线,并计算它所相交的唯一颜色数量,它将始终小于或等于三,因为此图是使用“top 3”查询构建的。但是图例中有六个项目。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00053.png
图 4-9. 具有超过 k 个不同时间序列的 top k 可视化
它可以很容易地变得比这更加繁忙。考虑图 4-10,它显示了一段时间内前五个最长的 Gradle 构建任务时间。由于显示的时间片段内运行的构建任务集合会快速变化,所以图例中填充的值会比简单的五个值多得多。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00029.png
图 4-10. Top k 仍然可以在图例中产生比 k 更多的项目
在这种情况下,图例被标签压倒,以至于无法辨认。使用 Grafana 选项将图例移到右侧的表格,并添加一个“最大值”之类的摘要统计数据,如图 4-11 所示。然后,您可以点击表格中的摘要统计数据,按此统计数据对图例作为表格进行排序。现在,当我们查看图表时,我们可以快速看出在我们查看的时间范围内哪些表现最差。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00040.png
图 4-11. 为每个结果覆盖线条样式
Prometheus 速率间隔选择
在本章中,我们将看到使用范围向量的 Prometheus 查询。我强烈建议使用至少是抓取间隔的两倍长的范围向量(默认为一分钟)。否则,由于抓取时间的轻微变化可能导致相邻数据点间隔略大于抓取间隔,您可能会错过数据点。类似地,如果服务重新启动且数据点丢失,速率函数将无法在间隙或下一个数据点之间进行速率计算,直到间隔包含至少两个点。使用更长的间隔可避免这些问题。由于应用程序的启动可能比抓取间隔长,具体取决于您的应用程序,如果完全避免间隙对您很重要,您可以选择比两倍抓取间隔更长的范围向量(实际上更接近应用程序启动加两个间隔的任何内容)。
范围向量在 Prometheus 中是一个相对独特的概念,但在其他监控系统中的其他上下文中也适用相同的原理。例如,如果您在警报上设置了最小阈值,并且由于应用程序重新启动而可能出现间隙,则需要构建“间隔内的最小值”类型的查询以进行补偿。
计量器
计量器的时间序列表示比即时计量器提供更多关于信息的紧凑表示。当线路穿过警报阈值时,它同样明显,并且有关计量器先前值的历史信息提供了有用的上下文。因此,在图 4-12 中,底部图表更可取。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00046.png
图 4-12. 更倾向于使用线图而不是即时计量器
计量器往往呈现尖峰。线程池可能出现短时间接近枯竭的情况,然后恢复。队列会变满然后清空。在 Java 中,内存利用尤其棘手,因为短期分配可能迅速填满分配的大部分空间,但垃圾收集可能会清除大部分消耗。
限制警报频繁性的最有效方法之一是使用滚动计数功能,其结果显示在图 4-13 中(part0009_split_007.html#rolling_count)。通过这种方式,我们可以定义一个只有在过去五个间隔中超过三次超过阈值时才触发警报的警报,或者某种频率和回顾间隔数量的组合。回顾的时间越长,警报首次触发前的时间就越长,因此在寻找关键指标时,不要回顾得太久。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00055.png
图 4-13. 滚动计数以限制警报频繁性
作为瞬时值,计量器基本上只是在每个监控系统上直接绘制成图形。计数器稍微复杂一些。
计数器
计数器经常针对最大(或较少时是最小)阈值进行测试。对阈值进行测试的需要强化了计数器应被视为速率而不是累积统计数据的想法,无论统计数据如何在监控系统中存储。
图 4-14 显示了一个 HTTP 端点的请求吞吐量作为速率(黄色实线),还显示了自应用进程启动以来对该端点的所有请求的累积计数(绿色点)。图表还显示了在该端点吞吐量上设置的固定最小阈值警报(红色线和区域),阈值设定为每秒 1,000 次请求。这个阈值在相对于速率表示的吞吐量时是有意义的(在此窗口中速率在每秒 1,500 到 2,000 次请求之间变化)。但对累积计数来说意义不大,因为累积计数实际上是吞吐率和进程的长期性的度量。进程的长期性对这个警报来说是不相关的。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00102.png
图 4-14. 具有速率最小阈值警报的计数器,并显示累积计数
有时固定阈值很难事先确定。此外,事件发生的速率可能会周期性地波动,例如根据高峰和低峰的业务时间。这在像每秒请求数这样的吞吐量测量中特别常见,如图 4-15 所示(part0009_split_008.html#counter_dynamic_threshold)。如果我们在此服务上设置一个固定阈值,以便检测到流量突然未达到服务的情况(最小阈值),我们将不得不将其设置在此服务看到的最低吞吐量 40 RPS 以下的某个位置。假设最小阈值设置为 30 RPS。这个警报将在业务低峰期流量低于预期值的 75% 时触发,但只有在业务高峰期流量低于预期值的 10% 时才触发!在所有时期,警报阈值的价值并不相等。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00051.png
图 4-15. 根据一天中的时间增加流量的服务
在这些情况下,考虑以寻找速率的急剧增加或减少的方式设置警报。一个很好的一般方法是在 图 4-16 中看到的,即取计数器速率,应用平滑函数,并将平滑函数乘以某个因子(例如例子中的 85%)。因为平滑函数自然需要一点时间来对速率的突然变化做出响应,所以检测计数器速率是否低于平滑线,可以在完全不知道预期速率的情况下检测到突然变化。关于动态警报使用平滑统计方法的更详细解释,请参见 “使用预测方法构建警报”。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00104.png
图 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 构建任务吞吐量。由于在此窗口中,构建任务由项目和任务名称唯一描述,并且一旦任务完成,它就不会再递增,因此在图表选择的时间域内会产生几个新的时间序列。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00097.png
图 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 Timer
和DistributionSummary
实现的关键统计数据推出。在那里,我遇到了一个来自 Netflix 的前同事,并羞怯地提出了这个新想法,意识到 Netflix 实际上并没有监控最大延迟。他立即笑掉,走了一场演讲,让我有些泄气。不久之后,我收到了他的消息,附上了图表,显示了一项关键内部 Netflix 服务上最大延迟比 P99 延迟差一个数量级(他仅仅为了测试这一假设而将最大延迟添加到了这个服务中)。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00037.png
图 4-18. Netflix 日志服务中的最大与 P99 延迟(单位:纳秒)
更令人惊讶的是,Netflix 最近经历了一次架构转变,使得 P99 稍微好了一点,但最大延迟显著恶化!很容易辩论说实际上这次变动使事情变得更糟。我珍视这段互动的记忆,因为它生动地说明了每个组织都有可以从其他地方学习的东西:在这种情况下,Netflix 的高度复杂的监控文化从 Domo 那里学到了一个技巧,而 Domo 则是从 Azul Systems 那里学到的。
在 图 4-19 中,我们看到了最大和第 99 百分位数之间数量级的差异。响应延迟通常紧密围绕第 99 百分位数,至少有一个独立的分组接近最大值,反映了垃圾收集、虚拟机暂停等。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00114.png
图 4-19. 最大与 P99 延迟
在 图 4-20 中,一个真实的服务展示了一个特征,即平均值浮动在第 99 百分位数之上,因为请求在第 99 百分位数周围非常密集。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00110.png
图 4-20. 平均延迟对比 P99 延迟
尽管这个前 1%看起来微不足道,但实际用户会受到这些延迟的影响,因此重要的是要认识到这个边界,并在需要时进行补偿。识别限制前 1%效果的一种认可方法是一种称为“对冲请求”的客户端负载平衡策略(参见“对冲请求”)。
对最大延迟设置警报至关重要(我们将在“延迟”中更详细地讨论原因)。但是,一旦工程师收到问题的警报,他们用于开始理解问题的仪表板不一定需要有这个指标。看到延迟分布作为热度图会更有用(如图 4-21 所示),其中包括导致警报的最大值所在的非零桶,以了解问题相对于该时间系统中传递的规范请求的重要程度。在热度图可视化中,每个垂直列代表一个特定时间切片上的直方图(参考“直方图”的定义)。彩色方框表示在y轴上定义的时间范围内的延迟频率。因此,终端用户正在经历的规范延迟应该看起来“热”,而异常值则看起来较“冷”。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00094.png
图 4-21. 计时器热度图
大多数请求是否接近最大值而失败,还是只有一个或几个偏离值?这个问题的答案可能会影响警报工程师升级问题并寻求他人帮助的速度。不需要在诊断仪表板上绘制最大值和热度图,如图 4-22 所示。只需包含热度图即可。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00077.png
图 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", <https://github.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(), <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png>
"outcome", "failure"
)
)
);
}
一些监控系统(如 Prometheus)期望在具有相同名称的度量标准上出现一致的标签键集。因此,即使在这里没有异常,我们也应该使用一些占位符值(如“none”),以反映在失败情况下存在的标签。
或许你有更好的方法来更好地分类失败条件,并提供一个更具描述性的标签值,但即使添加异常类名也可以大大增加对失败类型的理解。NullPointerException
和调用下游服务时出现的连接超时处理不当是两种非常不同的异常类型。当错误比例上升时,能够深入了解异常名称对于快速了解错误非常有用。通过异常名称,你可以快速转到调试和观测工具,如日志,并搜索在警报条件发生时异常名称的出现情况。
在使用 Class.getSimpleName()
等作为标签值时要小心
要注意,Class.getSimpleName()
和 Class.getCanonicalName()
可能返回 null 或空值,例如匿名类实例的情况。如果你将它们之一作为标签值使用,至少要对空值进行检查,并回退到使用 Class.getName()
。
对于 HTTP 请求指标,例如,Spring Boot 会自动使用status
标签表示 HTTP 状态码,并且使用outcome
标签表示SUCCESS
、CLIENT_ERROR
或SERVER_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%左右。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00010.png
图 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 显示了三个服务实例调用同一服务的情况。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00091.png
图 4-24. 多个调用方的 HTTP 客户端指标
通过选择 uri
和 serviceName
标签,我们可以确定被调服务特定端点的性能。通过对所有其他标签进行聚合,我们可以查看端点在所有调用者之间的性能。通过 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 { <https://github.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() { <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png>
return new RestTemplateBuilder()
.addAdditionalInterceptors(..)
.build();
}
}
听起来很险恶?
要利用 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 { <https://github.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() { <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png>
return WebClient
.builder();
}
}
听起来很险恶?
确保为 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")) <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
.orElse("basic")
);
}
};
}
}
注意 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 所示。如果你的应用程序经常暂停,并且你想了解随时间变化的典型行为,暂停时间的热图也可能很有趣。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00012.png
图 4-25. 最大垃圾收集暂停时间
垃圾收集中花费的时间比例
由于 jvm.gc.pause
是一个定时器,我们可以单独查看其总和。具体来说,我们可以在一个时间间隔内将其增加的值加起来,然后除以该时间间隔,来确定 CPU 在进行垃圾收集时花费了多少时间。由于在这些时间段内我们的 Java 进程什么都不做,当垃圾收集所花费的时间比例足够大时,应该发出警报。示例 4-22 显示了这个技术的 Prometheus 查询。
示例 4-22. 根据原因查询 Prometheus 中的垃圾收集时间
sum( <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
sum_over_time( <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png>
sum(increase(jvm_gc_pause_seconds_sum[2m])[1m:] <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png>
)
) / 60 <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00016.png>
对所有单独的原因进行求和,比如“小 GC 结束”。
在过去一分钟内在个别原因中所花费的总时间。
这是我们第一次看到 Prometheus 的 子查询。它允许我们将两个指标的操作视为 sum_over_time
的输入的范围向量。
由于 jvm_gc_pause_seconds_sum
的单位是秒(因此总和也是秒),我们已经对一个 1 分钟的时间段进行了求和,将其除以 60 秒,得到在最近一分钟内我们在 GC 中所花费的时间的百分比,其值在 [0, 1] 范围内。
这种技术是灵活的。您可以使用标签选择特定的 GC 原因并评估,例如,仅评估在主要 GC 事件中所占时间的比例。或者,就像我们在这里所做的那样,您可以简单地对所有原因进行求和,并评估给定时间间隔内的总体 GC 时间。很可能,您会发现,如果按原因分别计算这些总和,小 GC 事件对 GC 时间所占比例的贡献并不显著。在 图 4-26 中监控的应用程序每分钟进行一次小集合,毫不奇怪,它在与 GC 相关的活动中仅花费了 0.0182% 的时间。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00002.png
图 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), <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
Duration.ofSeconds(30)
).register(meterRegistry);
控制回顾窗口。
任何庞大分配的存在
除了选择上述一种形式来监视 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 级别周围代表了最大允许内存的固定百分比。这是阈值。它是相对于最大值的固定阈值,但在某种意义上是动态的,因为它是最大值的百分比,而最大值本身可能会波动。
-
较浅的条代表实际堆利用率(堆栈图的顶部)超过阈值的点。这是“警报条件”。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00075.png
图 4-27. 使用固定阈值对内存利用率发出警报
因此,这种简单的固定阈值不起作用。根据目标监控系统的功能,有更好的选择可用。
堆空间填充次数滚动计数
通过在 Atlas 中使用类似滚动计数功能,我们只有在堆超过阈值时才会发出警报——比如,在过去五个间隔中有三个超过阈值——这表明尽管垃圾收集器尽力了,堆消耗仍然是一个问题(见 图 4-28)。
不幸的是,没有多少监控系统具有类似 Atlas 的滚动计数功能。Prometheus 可以通过其 count_over_time
操作做类似的事情,但要实现类似“五个中有三个”的动态是有技巧的。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00072.png
图 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
会在回看窗口的较高值上“粘滞”。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00093.png
图 4-29. Prometheus 的 max_over_time
查看一个一分钟回溯窗口中最大的伊甸空间使用量
这也是我们第一次考虑基于多个条件的警报。警报系统通常允许多个标准的布尔组合。在图 4-30 中,假设 jvm.gc.overhead
指标表示查询 A,使用指标表示查询 B,则可以在 Grafana 中对它们一起配置警报。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00109.png
图 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轴应该是什么样子。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00086.png
图 4-31. 进程 CPU 使用率作为百分比
在 Grafana 中,“percent”是“可视化”选项卡中可选择的单位之一。请确保选择“percent (0.0-1.0)”选项,如图 4-32 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00106.png
图 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 <https://github.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
这表示允许打开文件的数量,而不是当前打开文件的数量。
这个问题不一定常见,但达到文件描述符限制的影响可能是致命的,可能导致应用程序完全停止响应,具体取决于文件描述符的使用方式。与内存不足错误或致命异常不同,通常应用程序可能会简单地阻塞,但仍然显示为在服务中,因此这个问题特别棘手。由于监控文件描述符利用率非常廉价,在每个应用程序上都应发出警报。使用常见技术和 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, <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
:gt
一小时的纳秒
对于 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 所示)。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00049.png
图 4-33. 统计学与机器学习技术的一步预测误差
让我们简要介绍一些统计方法,从最不具预测性的朴素方法开始,该方法可与任何监控系统一起使用。随后的方法由于其数学复杂性需要内置的查询功能,因此在监控系统中的通用支持较少。
朴素方法
朴素方法 是一个简单的启发式方法,根据最后观察到的值来预测下一个值:
y ^ T+1|T = α y T
可以通过将时间序列偏移乘以某个因子来确定朴素方法的动态警报阈值。然后我们可以测试真实线是否曾经低于(如果乘数大于一则超过)预测线。例如,如果真实线是通过系统的吞吐量测量的,则吞吐量突然大幅下降可能表明发生了故障。
Atlas 的警戒标准是每当示例 4-37 返回 1
时。该查询设计针对 Atlas 的测试数据集,因此您可以轻松测试并尝试不同的乘数以观察效果。
示例 4-37. Atlas 天真预测方法的警戒标准
name,requestsPerSecond,:eq,
:dup,
0.5,:mul, <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
1m,:offset, <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png>
:rot,
:lt
通过这个因子来设置阈值的紧密度。
“回溯”到预测的某个前期间隔。
天真方法的影响可以在 图 4-34 中看到。乘法因子(例如查询中的 0.5)控制我们希望将阈值设置多接近真实值,同时也以同样的量减少预测的尖锐度(即阈值越宽松,预测的尖锐度越低)。由于该方法的平滑程度与拟合松紧成正比,所以即使我们允许 50%的“正常”漂移,警戒阈值在这个时间窗口内仍会触发四次(在图表中间垂直条指示)。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00027.png
图 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, <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
:lt,
通过这个因子来设置阈值的紧密度。
求和 α ∑ 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 显示了在系列中一个项和两个项的求和相对于真值的情况(从下到上)。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00009.png
图 4-35. 选择有限求和的缩放效果
图 4-36 显示了不同选择的 α 和 T 如何影响动态阈值,无论是平滑程度还是相对于真实指标的近似缩放因子。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00105.png
图 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表示为X或N的函数(参见方程式 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 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00098.png
图 4-37. 基于不同吞吐量水平的延迟的 USL 预测。
USL 预测是 Micrometer 中的一种“派生”Meter
,可以按照示例 4-39 中所示启用。Micrometer 将发布一组Gauge
计量器,形成每个发布间隔各种吞吐量/并发性水平的预测系列。吞吐量和并发性是相关的测量,因此从这一点开始可以互换地考虑它们。当您选择发布一个与预测相关的计时器相关组时(这些计时器将始终具有相同的名称),Micrometer 将使用公共指标名称作为前缀发布几个附加指标:
timer.name.forecast
一系列带有标签throughput
或concurrency
的Gauge
计量器,基于所选独立变量的类型。在特定时间间隔内,绘制这些计量器将生成类似图 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") <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
.tag("uri", "/myendpoint") <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00059.png>
.tag("status", s -> s.startsWith("2")) <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00067.png>
)
.independentVariable(UniversalScalabilityLawForecast.Variable.THROUGHPUT) <https://github.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);
预测将基于 Micrometer 搜索结果中名为 http.server.requests
的一个或多个计时器(请记住,可能有几个具有不同标签值的计时器)。
我们可以通过仅匹配具有特定键值标签对的计时器来进一步限制预测的计时器集合。
像任何搜索一样,标签值也可以使用 lambda 进行限制。一个很好的例子是将预测限制在任何“2xx”HTTP 状态下。
Gauge
直方图的域将是 UniversalScalabilityLawForecast.Variable.CONCURRENCY
或 UniversalScalabilityLawForecast.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”。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00035.png
图 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 流水线的定义。流水线可以非常复杂,包含并行阶段和在特殊配置阶段上定义的多个触发器。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00001.png
图 5-2. 展示了一个详细视图的 Spinnaker 流水线,显示了多个阶段
Spinnaker 定义了几种不同的触发器类型。这个流水线是通过发布到 Docker 注册表的新容器镜像来触发的,如 图 5-3 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00060.png
图 5-3. Spinnaker 期望的构件定义
图 5-4 展示了两个 Spinnaker 流水线的执行历史,包括我们刚刚看到的配置。阶段流水线是通过 Docker 注册触发器(发布到 Docker 注册表的新容器)最后执行的。在其他情况下,流水线是手动触发的。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00101.png
图 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 支持多种触发类型:一种用于新的应用程序二进制文件,另一种用于新的基础镜像。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00115.png
图 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>
)。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00076.png
图 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" <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00112.png>
}
...
用 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 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00116.png
图 5-7. Spinnaker 蓝绿集群
回滚服务器组操作,如图 5-8 所示,允许选择这些禁用的服务器组版本之一(V023–V026)。回滚完成后,当前的活跃版本(V027)仍将存在,但被禁用。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00032.png
图 5-8. Spinnaker 回滚服务器组操作
根据底层云平台的支持情况,禁用的集群可以保留不接收任何流量的运行实例,或者可以减少到零实例,准备回滚以进行扩展。为了实现最快的回滚形式,禁用的集群应该保留活跃实例。当然,这会增加服务的费用,因为现在你不仅需要支付用于提供实时生产流量的实例集合的成本,还需要支付先前服务版本中剩余实例的成本,这些实例有可能会回滚到。
最终,您需要评估回滚速度与成本之间的权衡,其光谱显示在图 5-9 中。这应该基于每个微服务而不是整个组织来完成。在蓝/绿部署中,对需要运行数百个实例的微服务维护完全缩放禁用的服务器组所产生的额外运营成本,与对只需要少数实例的服务所产生的额外成本并不相等。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00079.png
图 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 所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00006.png
图 5-10. 金丝雀发布参与者
这些金丝雀部署可以描述如下:
生产环境
这是金丝雀部署之前的现有服务器组,包含一个或多个实例。
基线
这个服务器组运行与生产服务器组相同版本的代码和配置。虽然一开始运行另一个旧代码副本似乎有些违反直觉,但我们需要一个基线,它大致在金丝雀发布时启动,因为生产服务器组由于运行了一段时间,可能具有不同的特征,比如堆消耗或缓存内容。能够准确比较旧代码和新代码之间的差异非常重要,而最佳方法是大致同时启动每个副本。
金丝雀
这个服务器组包含了新的代码或配置。
金丝雀的适应性完全由将一组指标相对于基准线(而不是生产集群)进行比较来确定。这意味着正在进行金丝雀测试的应用正在发布带有cluster
公共标签的度量,以便金丝雀分析系统可以聚合来自属于金丝雀和基准集群的实例的指标,并相互比较这两个聚合指标。
相对比较远比测试一个金丝雀针对一组固定阈值要可取得多,因为固定阈值往往对测试时系统的吞吐量做出某些假设。图 5-11 展示了这个问题。在高峰业务时间,应用展示出更高的响应时间,这时系统流量最大(这是典型情况)。因此,对于固定阈值,也许我们试图设置一个数值,如果响应时间比正常情况差 10%以上,金丝雀测试就会失败。在高峰业务时间,金丝雀测试可能会失败,因为相对于基准线,其性能比差了超过 10%。但是如果我们在非高峰业务时间运行金丝雀测试,它可能会比基准线差得多于 10%,但仍然在设定的固定阈值内,因为这个固定阈值是相对于不同操作条件设定的。相对比较的方法更有可能在测试运行时无论是在或者非业务高峰期,都能捕捉到性能下降。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00045.png
图 5-11. 对固定阈值的金丝雀测试
在多个场合,我会和组织讨论自动化交付实践,谈论到金丝雀部署,这个想法听起来如此吸引人,以至于激发了对这个主题的兴趣。通常,这些组织没有维度度量仪器,也没有类似蓝/绿部署的自动发布流程。也许是因为金丝雀部署的安全性吸引力,平台有时会包含金丝雀部署功能。通常情况下,它们缺少基线和/或比较测量,因此从这个角度评估平台提供的金丝雀功能,并决定是否放弃其中一个或两个功能,这在许多情况下并不明智。我建议在许多情况下都不应该。
在三集群设置(生产、基准、金丝雀)中,大部分流量将流向生产集群,少量流向基准和金丝雀。金丝雀部署使用负载均衡器配置、服务网格配置或任何其他平台功能来按比例分发流量。
图 5-12 展示了参与 canary 测试的三个集群的 Spinnaker 基础设施视图。在这个案例中,它们在一个名为“PROD-CLUSTER”的单个 Kubernetes 集群上运行(“cluster” 指的是 Kubernetes 集群,不是我们在本章开头定义的交付定义中的含义)。
Spinnaker 与一个开源自动化 canary 分析服务集成,该服务封装了来自 baseline 和 canary 集群的度量评估。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00042.png
图 5-12. 应用程序 undergoing a canary 的三个集群
Spinnaker 配合 Kayenta 使用
Kayenta 是一个独立的开源自动化 canary 分析服务,也通过管道阶段和配置深度集成到 Spinnaker 中。
Kayenta 确定每个指标的 canary 和 baseline 之间是否存在显著差异,得出 pass、high 或 low 的分类结果。High 和 low 都属于失败条件。Kayenta 使用 Mann-Whitney U test 在两个集群之间进行统计上的比较。这个统计测试的实现称为 judge,Kayenta 可以配置使用其他的 judge,但它们通常涉及超出单一查询度量系统所能达到的代码。
图 5-13 展示了 Kayenta 对多个指标进行分类决策的示例。这张截图来自原始的 Netflix 博客 关于 Kayenta 的内容。在这个案例中,延迟未通过测试。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00028.png
图 5-13. Canary 指标
在 Spinnaker 中,一个应用程序的 canary 指标可以在应用程序基础设施视图的“Canary Configs”选项卡中定义。在配置中,如 图 5-14 所示,您可以定义一个或多个服务水平指标。如果足够多的这些指标失败,canary 将失败。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00118.png
图 5-14. Spinnaker 中一个应用程序的 canary 配置
图 5-15 展示了单个指标的配置,即处理器利用率。请注意,配置包含一个针对监控系统的特定度量查询,您已经配置 Kayenta 来从中轮询(在本案例中是 Prometheus)。然后,您广泛指示增加或减少(或任何方向的偏差)被认为是不良的。在这种情况下,我们不希望看到处理器利用率显著增加,尽管减少则是受欢迎的。
另一方面,对于应该以某一速率持续处理的应用程序来说,服务吞吐量的减少将是一个不良信号。该指标可以标记为足够严重,仅仅失败就应该导致金丝雀失败。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00071.png
图 5-15. 处理器利用率金丝雀配置
金丝雀配置一旦建立,就可以在流水线中使用。如图 5-16 所示,一个典型的金丝雀部署流水线。在“配置”阶段定义了触发器,开始评估金丝雀的流程。“将集群名称设置为金丝雀”设置了一个变量,Spinnaker 在随后的“部署金丝雀”阶段中使用该变量来命名金丝雀集群。正是这个变量最终产生了如图 5-12 所示的命名金丝雀集群。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00022.png
图 5-16. Spinnaker 中的金丝雀部署流水线
与此同时,Spinnaker 正在检索当前生产版本所基于的工件,并使用这些工件创建基线集群。 “金丝雀分析”阶段的运行时间可能长达数小时甚至数天,具体取决于其配置。如果测试通过,我们将部署一个新的生产集群(使用用于创建金丝雀的相同工件,这些工件可能不再是存储库中最新的版本)。同时,可以拆除不再需要的基线和金丝雀集群。整个流水线可以在 Spinnaker 中配置为串行运行,以便每次只评估一个金丝雀。
金丝雀运行的结果可以通过几种不同的方式查看。Spinnaker 提供了一个“金丝雀报告”选项卡,显示了每个服务水平指标的判断结果,以及单独评估每个进入决策的指标。每个指标可以作为时间序列图在金丝雀运行期间查看,就像 图 5-17 中显示的那样。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00108.png
图 5-17. 基线和金丝雀中 CPU 利用率的时间序列可视化
当前生产版本并不总是最新版本
注意,从中创建基线的当前生产版本并不总是应用程序二进制(例如 JAR 或 WAR)存储库中最新的版本。在某些情况下,它实际上可能是几个版本较旧的版本,这是因为我们曾试图发布新版本,但它们在试验或其他情况下被回退了。像 Spinnaker 这样的有状态持续交付解决方案的一个价值在于其能力,即轮询环境以获取当前状态,并基于此信息采取行动。
或者,可以将指标的比较视为条形图(或直方图),如 图 5-18 中所示。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00062.png
图 5-18. 99 百分位延迟的直方图可视化
最后,也许最有用的是,可以将金丝雀和基准之间的比较可视化为蜂群图,如图 5-19 所示。金丝雀随时间而判断,Kayenta 定期轮询监控系统以获取金丝雀和基准的值。这里的单个样本显示在蜂群图上,以及显示所有样本的基本四分位数(最小值、25th 百分位数、中位数、75th 百分位数和最大值)的箱形图。中位数肯定增加了,但正如在第二章中讨论的那样,像均值和中位数这样的中心度量并不真正有用于判断服务的适用性。这个图表确实突显了这一事实。最大值甚至 75%的延迟在版本之间几乎没有变化。因此,中位数的变化略有增加,但这可能根本不表示性能退化。
https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/sre-java-msvc/img/00021.png
图 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 还是其他系统),以允许团队之间的一些部署变化。对于某些部署,特别是静态资产或内部工具,蓝/绿部署可能不会带来显著好处。其他可能会频繁发布,因此需要蓝/绿部署策略中的多个禁用服务器组。有些启动速度快到在禁用的集群中拥有活跃实例会导致成本效率低下。一个以“护栏而非门栓”思维的平台工程团队将更倾向于允许这种管道多样性,而不是组织一致性,从而最大化每个团队独特的安全/成本权衡。
在下一章中,我们将假设已部署资产清单,用于构建一个到每个环境中运行的源代码的工件溯源链。