OpenTelemetry 实战:gRPC 监控的实现原理
前言
最近在给 opentelemetry-java-instrumentation
提交了一个 PR,是关于给 gRPC 新增四个 metrics:
rpc.client.request.size
: 客户端请求包大小rpc.client.response.size
:客户端收到的响应包大小rpc.server.request.size
:服务端收到的请求包大小rpc.server.response.size
:服务端响应的请求包大小
这个 PR 的主要目的就是能够在指标监控中拿到 RPC
请求的包大小,而这里的关键就是如何才能拿到这些包的大小。
首先支持的是 gRPC
(目前在云原生领域使用的最多),其余的 RPC 理论上也是可以支持的:
在实现的过程中我也比较好奇 OpenTelemetry
框架是如何给 gRPC
请求创建 span
调用链的,如下图所示:
这是一个 gRPC 远程调用,java-demo 是 gRPC 的客户端,k8s-combat 是 gRPC 的服务端
在开始之前我们可以根据 OpenTelemetry
的运行原理大概猜测下它的实现过程。
首先我们应用可以创建这些链路信息的前提是:使用了 OpenTelemetry
提供的 javaagent
,这个 agent 的原理是在运行时使用了 byte-buddy 增强了我们应用的字节码,在这些字节码中代理业务逻辑,从而可以在不影响业务的前提下增强我们的代码(只要就是创建 span、metrics 等数据)
Spring 的一些代理逻辑也是这样实现的
gRPC 增强原理
而在工程实现上,我们最好是不能对业务代码进行增强,而是要找到这些框架提供的扩展接口。
拿 gRPC
来说,我们可以使用它所提供的 io.grpc.ClientInterceptor
和 io.grpc.ServerInterceptor
接口来增强代码。
打开 io.opentelemetry.instrumentation.grpc.v1_6.TracingClientInterceptor
类我们可以看到它就是实现了 io.grpc.ClientInterceptor
:
而其中最关键的就是要实现 io.grpc.ClientInterceptor#interceptCall
函数:
@Override
public <REQUEST, RESPONSE> ClientCall<REQUEST, RESPONSE> interceptCall(
MethodDescriptor<REQUEST, RESPONSE> method, CallOptions callOptions, Channel next) {
GrpcRequest request = new GrpcRequest(method, null, null, next.authority());
Context parentContext = Context.current();
if (!instrumenter.shouldStart(parentContext, request)) {
return next.newCall(method, callOptions);
}
Context context = instrumenter.start(parentContext, request);
ClientCall<REQUEST, RESPONSE> result;
try (Scope ignored = context.makeCurrent()) {
try {
// call other interceptors
result = next.newCall(method, callOptions);
} catch (Throwable e) {
instrumenter.end(context, request, Status.UNKNOWN, e);
throw e;
} }
return new TracingClientCall<>(result, parentContext, context, request);
}
这个接口是 gRPC
提供的拦截器接口,对于 gRPC
客户端来说就是在发起真正的网络调用前后会执行的方法。
所以在这个接口中我们就可以实现创建 span 获取包大小等逻辑。
使用 byte-buddy 增强代码
不过有一个问题是我们实现的 io.grpc.ClientInterceptor
类需要加入到拦截器中才可以使用:
var managedChannel = ManagedChannelBuilder.forAddress(host, port) .intercept(new TracingClientInterceptor()) // 加入拦截器
.usePlaintext()
.build();
但在 javaagent
中是没法给业务代码中加上这样的代码的。
此时就需要 byte-buddy 登场了,它可以动态修改字节码从而实现类似于修改源码的效果。
在 io.opentelemetry.javaagent.instrumentation.grpc.v1_6.GrpcClientBuilderBuildInstr umentation
类里可以看到 OpenTelemetry
是如何使用 byte-buddy
的。
@Override
public ElementMatcher<TypeDescription> typeMatcher() {
return extendsClass(named("io.grpc.ManagedChannelBuilder"))
.and(declaresField(named("interceptors")));
}
@Override
public void transform(TypeTransformer transformer) {
transformer.applyAdviceToMethod(
isMethod().and(named("build")),
GrpcClientBuilderBuildInstrumentation.class.getName() + "$AddInterceptorAdvice");
}
@SuppressWarnings("unused")
public static class AddInterceptorAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
public static void addInterceptor(
@Advice.This ManagedChannelBuilder<?> builder,
@Advice.FieldValue("interceptors") List<ClientInterceptor> interceptors) {
VirtualField<ManagedChannelBuilder<?>, Boolean> instrumented =
VirtualField.find(ManagedChannelBuilder.class, Boolean.class);
if (!Boolean.TRUE.equals(instrumented.get(builder))) {
interceptors.add(0, GrpcSingletons.CLIENT_INTERCEPTOR);
instrumented.set(builder, true);
}
}
}
从这里的源码可以看出,使用了 byte-buddy
拦截了 io.grpc.ManagedChannelBuilder#intercept(java.util.List<io.grpc.ClientInterceptor>)
函数。
io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers#extendsClass/ isMethod 等函数都是 byte-buddy 库提供的函数。
而这个函数正好就是我们需要在业务代码里加入拦截器的地方。
interceptors.add(0, GrpcSingletons.CLIENT_INTERCEPTOR);
GrpcSingletons.CLIENT_INTERCEPTOR = new TracingClientInterceptor(clientInstrumenter, propagators);
通过这行代码可以手动将 OpenTelemetry
里的 TracingClientInterceptor
加入到拦截器列表中,并且作为第一个拦截器。
而这里的:
extendsClass(named("io.grpc.ManagedChannelBuilder"))
.and(declaresField(named("interceptors")))
通过函数的名称也可以看出是为了找到 继承了io.grpc.ManagedChannelBuilder
类中存在成员变量 interceptors
的类。
transformer.applyAdviceToMethod(
isMethod().and(named("build")),
GrpcClientBuilderBuildInstrumentation.class.getName() + "$AddInterceptorAdvice");
然后在调用 build
函数后就会进入自定义的 AddInterceptorAdvice
类,从而就可以拦截到添加拦截器的逻辑,然后把自定义的拦截器加入其中。
获取 span 的 attribute
我们在 gRPC 的链路中还可以看到这个请求的具体属性,比如:
- gRPC 服务提供的 IP 端口。
- 请求的响应码
- 请求的 service 和 method
- 线程等信息。
这些信息在问题排查过程中都是至关重要的。
可以看到这里新的 attribute
主要是分为了三类:
net.*
是网络相关的属性rpc.*
是和 grpc 相关的属性thread.*
是线程相关的属性
所以理论上我们在设计 API 时最好可以将这些不同分组的属性解耦开,如果是 MQ 相关的可能还有一些 topic 等数据,所以各个属性之间是互不影响的。
带着这个思路我们来看看 gRPC 这里是如何实现的。
clientInstrumenterBuilder
.setSpanStatusExtractor(GrpcSpanStatusExtractor.CLIENT)
.addAttributesExtractors(additionalExtractors)
.addAttributesExtractor(RpcClientAttributesExtractor.create(rpcAttributesGetter))
.addAttributesExtractor(ServerAttributesExtractor.create(netClientAttributesGetter))
.addAttributesExtractor(NetworkAttributesExtractor.create(netClientAttributesGetter))
OpenTelemetry
会提供一个 io.opentelemetry.instrumentation.api.instrumenter.InstrumenterBuilder#addAttributesExtractor
构建器函数,用于存放自定义的属性解析器。
从这里的源码可以看出分别传入了网络相关、RPC 相关的解析器;正好也就对应了图中的那些属性,也满足了我们刚才提到的解耦特性。
而每一个自定义属性解析器都需要实现接口 io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor
public interface AttributesExtractor<REQUEST, RESPONSE> {
}
这里我们以 GrpcRpcAttributesGetter
为例。
enum GrpcRpcAttributesGetter implements RpcAttributesGetter<GrpcRequest> {
INSTANCE;
@Override
public String getSystem(GrpcRequest request) {
return "grpc";
}
@Override
@Nullable
public String getService(GrpcRequest request) {
String fullMethodName = request.getMethod().getFullMethodName();
int slashIndex = fullMethodName.lastIndexOf('/');
if (slashIndex == -1) {
return null;
}
return fullMethodName.substring(0, slashIndex);
}
可以看到 system 是写死的 grpc
,也就是对于到页面上的 rpc.system
属性。
而这里的 getService
函数则是拿来获取 rpc.service
属性的,可以看到它是通过 gRPC
的method
信息来获取 service
的。
public interface RpcAttributesGetter<REQUEST> {
@Nullable
String getService(REQUEST request);
}
而这里 REQUEST
其实是一个泛型,在 gRPC 里是 GrpcRequest
,在其他 RPC 里这是对应的 RPC 的数据。
这个 GrpcRequest
是在我们自定义的拦截器中创建并传递的。
而我这里需要的请求包大小也是在拦截中获取到数据然后写入进 GrpcRequest。
static <T> Long getBodySize(T message) {
if (message instanceof MessageLite) {
return (long) ((MessageLite) message).getSerializedSize();
} else {
// Message is not a protobuf message
return null;
}}
这样就可以实现不同的 RPC 中获取自己的 attribute
,同时每一组 attribute
也都是隔离的,互相解耦。
自定义 metrics
每个插件自定义 Metrics 的逻辑也是类似的,需要由框架层面提供 API 接口:
public InstrumenterBuilder<REQUEST, RESPONSE> addOperationMetrics(OperationMetrics factory) {
operationMetrics.add(requireNonNull(factory, "operationMetrics"));
return this;
}
// 客户端的 metrics
.addOperationMetrics(RpcClientMetrics.get());
// 服务端的 metrics
.addOperationMetrics(RpcServerMetrics.get());
之后也会在框架层面回调这些自定义的 OperationMetrics
:
if (operationListeners.length != 0) {
// operation listeners run after span start, so that they have access to the current span
// for capturing exemplars
long startNanos = getNanos(startTime);
for (int i = 0; i < operationListeners.length; i++) {
context = operationListeners[i].onStart(context, attributes, startNanos);
}
}
if (operationListeners.length != 0) {
long endNanos = getNanos(endTime);
for (int i = operationListeners.length - 1; i >= 0; i--) {
operationListeners[i].onEnd(context, attributes, endNanos);
}
}
这其中最关键的就是两个函数 onStart 和 onEnd,分别会在当前这个 span 的开始和结束时进行回调。
所以通常的做法是在 onStart
函数中初始化数据,然后在 onEnd
结束时统计结果,最终可以拿到 metrics 所需要的数据。
以这个 rpc.client.duration
客户端的请求耗时指标为例:
@Override
public Context onStart(Context context, Attributes startAttributes, long startNanos) {
return context.with(
RPC_CLIENT_REQUEST_METRICS_STATE,
new AutoValue_RpcClientMetrics_State(startAttributes, startNanos));
}
@Override
public void onEnd(Context context, Attributes endAttributes, long endNanos) {
State state = context.get(RPC_CLIENT_REQUEST_METRICS_STATE);
Attributes attributes = state.startAttributes().toBuilder().putAll(endAttributes).build();
clientDurationHistogram.record(
(endNanos - state.startTimeNanos()) / NANOS_PER_MS, attributes, context);
}
在开始时记录下当前的时间,结束时获取当前时间和结束时间的差值正好就是这个 span 的执行时间,也就是 rpc client 的处理时间。
在 OpenTelemetry
中绝大多数的请求时间都是这么记录的。
Golang 增强
而在 Golang
中因为没有 byte-buddy 这种魔法库的存在,不可以直接修改源码,所以通常的做法还是得硬编码才行。
还是以 gRPC
为例,我们在创建 gRPC server 时就得指定一个 OpenTelemetry
提供的函数。
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
在这个 SDK 中也会实现刚才在 Java 里类似的逻辑,限于篇幅具体逻辑就不细讲了。
总结
以上就是 gRPC
在 OpenTelemetry
中的具体实现,主要就是在找到需要增强框架是否有提供扩展的接口,如果有就直接使用该接口进行埋点。
如果没有那就需要查看源码,找到核心逻辑,再使用 byte-buddy
进行埋点。
比如 Pulsar 并没有在客户端提供一些扩展接口,只能找到它的核心函数进行埋点。
而在具体埋点过程中 OpenTelemetry
提供了许多解耦的 API,方便我们实现埋点所需要的业务逻辑,也会在后续的文章继续分析 OpenTelemetry
的一些设计原理和核心 API 的使用。
这部分 API 的设计我觉得是 OpenTelemetry
中最值得学习的地方。
参考链接:
- https://bytebuddy.net/#/
- https://opentelemetry.io/docs/specs/semconv/rpc/rpc-metrics/#metric-rpcserverrequestsize
OpenTelemetry 实战:gRPC 监控的实现原理的更多相关文章
- [原创].NET 业务框架开发实战之九 Mapping属性原理和验证规则的实现策略
原文:[原创].NET 业务框架开发实战之九 Mapping属性原理和验证规则的实现策略 .NET 业务框架开发实战之九 Mapping属性原理和验证规则的实现策略 前言:之前的讨论一直关注在怎么从D ...
- Spring Boot 揭秘与实战 源码分析 - 工作原理剖析
文章目录 1. EnableAutoConfiguration 帮助我们做了什么 2. 配置参数类 – FreeMarkerProperties 3. 自动配置类 – FreeMarkerAutoCo ...
- 【Go】Golang实现gRPC的Proxy的原理
背景 gRPC是Google开始的一个RPC服务框架, 是英文全名为Google Remote Procedure Call的简称. 广泛的应用在有RPC场景的业务系统中,一些架构中将gRPC请求都经 ...
- Java并发编程原理与实战九:synchronized的原理与使用
一.理论层面 内置锁与互斥锁 修饰普通方法.修饰静态方法.修饰代码块 package com.roocon.thread.t3; public class Sequence { private sta ...
- Linux实战教学笔记15:磁盘原理
第十五节 磁盘原理 标签(空格分隔): Linux实战教学笔记 1,知识扩展 非脚本方式的一条命令搞定批量创建用户并设置随机10位字母数字组合密码. 1.1 sed的高级用法 [root@chensi ...
- Linux实战教学笔记16:磁盘原理
第十五节 磁盘原理 标签(空格分隔): Linux实战教学笔记 1,知识扩展 非脚本方式的一条命令搞定批量创建用户并设置随机10位字母数字组合密码. 1.1 sed的高级用法 [root@chensi ...
- 【Java编程实战】Metasploit_Java后门运行原理分析以及实现源码级免杀与JRE精简化
QQ:3496925334 文章作者:MG1937 CNBLOG博客ID:ALDYS4 未经许可,禁止转载 某日午睡,迷迷糊糊梦到Metasploit里有个Java平台的远控载荷,梦醒后,打开虚拟机, ...
- Java 中的监控与管理原理概述
点赞再看,动力无限.Hello world : ) 微信搜「程序猿阿朗 」. 本文 Github.com/niumoo/JavaNotes 和 程序猿阿朗博客 已经收录,有很多知识点和系列文章. 当前 ...
- Kustomize 生产实战-注入监控 APM Agent
Kustomize 简介 Kubernetes 原生配置管理工具, 它自定义引入了一种无需模板的方式来定制应用程序配置,从而简化了对现成应用程序的使用.目前,在kubectl中内置了,通过 apply ...
- zookeeper配置中心实战--solrcloud zookeeper配置中心原理及源码分析
程序的发展,需要引入集中配置: 随着程序功能的日益复杂,程序的配置日益增多:各种功能的开关.参数的配置.服务器的地址…… 并且对配置的期望也越来越高,配置修改后实时生效,灰度发布,分环境.分集群管理配 ...
随机推荐
- QT自定义右键菜单
利用QMenu和QAction可以实现非常实用的右键菜单功能.具体实现思路如下: 1.在xxx.h文件中添加如下头文件 #include <QMenu> #include <QCon ...
- IIS部署错误HTTP Error 500.36 ASP.NET Core IIS hosting failure(out-of-process)
错误提示 HTTP Error 500.36 - ASP.NET Core IIS hosting failure (out-of-process) The out of process reques ...
- Docker 总体架构图解
Docker 的总体架构 Docker 是一个 C/S 模式的架构,后端是一个松耦合架构,模块各司其职. 下图是它的总体架构图: 1. 用户使用 Docker Client 与 Docker Daem ...
- 树莓派4B-Python-控制超声波模块
树莓派4B-Python-控制超声波模块 超声波模块: 超声波模块为常用的HC-SR04型号,有四个引脚,分别为Vcc.Trig(控制端).Echo(接收端).GND,使用起来也比较简单.在树莓派最新 ...
- Django生成数据库表时报错 __init__() missing 1 required positional argument: 'on_delete'
原因: 在django2.0后,定义外键和一对一关系的时候需要加上on_delete选项,此参数为了避免两个表里的数据不一致问题,不然会报错 例如: owner=models.ForeignKey(U ...
- yb课堂实战之接口协议调整和日期格式 《十八》
调整api接口协议和日期格式 统一输出协议,驼峰转下划线 格式化日期
- Solo开发者社区-H5-Dooring, 开箱即用的零代码搭建平台
Dooring-Saas 是一款功能强大,高可扩展的零代码解决方案,致力于提供一套简单方便.专业可靠.无限可能的页面可视化搭建最佳实践.(Solo社区 投稿) 功能特点 可扩展, Dooring 实现 ...
- 解读GaussDB(for MySQL)灵活多维的二级分区表策略
本文分享自华为云社区<GaussDB(for MySQL)创新特性:灵活多维的二级分区表策略>,作者:GaussDB 数据库. 背景介绍 分区表及二级分区表的功能,可以让数据库更加有效地管 ...
- MySQL 实现 EF Code First TimeStamp/RowVersion 并发控制
在将项目迁移到MySQL 5.6.10数据库上时,遇到和迁移到PostgreSQL数据库相同的一个问题,就是TimeStamp/RowVersion并发控制类型在非Microsoft SQL Serv ...
- mysql中的隐式转换导致全表扫描
mysql中的隐式转换导致全表扫描 在mysql查询中,当查询条件左右两侧类型不匹配的时候会发生隐式转换,可能导致查询无法使用索引.下面分析两种隐式转换的情况 看表结构 phone为 int类型,na ...