前言
最近在給 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