OpenTracing初探

2020-09-08  本文已影响0人  MR丿VINCENT

what

OpenTracing分布式链路追踪的一种标准。根据google的论文Dapper,很多厂商根据这篇论文做出了自己的实现,然而每个厂商的实现都不同,因此如果你的分布式应用需要接入某个实现,那必须使用这个厂商提供的API,若哪一天不想用这个厂商的实现了咋办?得去改代码。因此出现了opentracing标准。这好比JSR规范,只提出API定义,至于厂商怎么去实现我不管,大家想用的话只需要使用这个标准API就行。

how

opentracing提供多语言的支持,如Java/Python/Ruby等。这里使用Java语言演示一下如何使用。
首先需要引入依赖:

<dependency>
    <groupId>io.opentracing</groupId>
    <artifactId>opentracing-api</artifactId>
    <version>0.33.0</version>
</dependency>
<!--这里使用的是jaeger的实现-->
<dependency>
    <groupId>io.jaegertracing</groupId>
    <artifactId>jaeger-client</artifactId>
    <version>${jaeger.version}</version>
</dependency>
public class Hello {
    private final Tracer tracer;

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

    private void sayHello(String hello){
        Span span = tracer.buildSpan("say-hello").start();
        System.out.println(hello);
        span.finish();
    }

    public static void main(String[] args) {
        String hello = "hello world";

        new Hello(initTracer("hello-world")).sayHello(hello);
    }

    private static JaegerTracer initTracer(String name){
        Configuration.SamplerConfiguration samplecfg = Configuration.SamplerConfiguration.fromEnv().withType("const").withParam(1);

        Configuration.ReporterConfiguration reporterConfiguration = Configuration.ReporterConfiguration.fromEnv().withLogSpans(true);

        Configuration configuration = new Configuration(name).withSampler(samplecfg).withReporter(reporterConfiguration);

        return configuration.getTracer();
    }
}

有个核心的API Tracer,这个类用于创建Span。
什么是Span呢?简而言之可以理解为一个调用,这里描述可能太过于抽象。可以具体为一次http请求,一次rpc调用。一个Span里可能会出现多个Span,比如你的一次http请求中会调用多个rpc服务,而rpc服务又会去调用别的rpc服务...这样这些Span就形成了类似父子关系的结构,用术语来描述就是DAG(Direct Acyclic Graph)。当然这里描述的仅仅是最常见的一种情况,也就是父子关系的情况。

image
参考这张图可以很轻松的理解Span。
代码中的方法sayHello()通过Tracer创建了一个名为say-hello的Span,方法结束后通过调用finish完成Span的终止。一个Span就这样简单的完成了,看上去是不是非常直观呢!

当然这仅仅是在代码层面的,有小伙伴可能会产生疑问,我写这些代码有啥用?之前说到,Tracer仅仅是一个标准,实现的厂家有很多,因此这里选择一个实现这个标准的厂家即可。initTracer方法初始化一个名叫hello-world的服务,其实现为Jaeger,这样我们的一些trace和span信息就能在Jaeger提供的控制面板中看到了。当然你也可以不选择Jaeger的实现,使用Zipkin也是一样的。
如果选择Jaeger实现,那需要启动一个Jaeger的服务,这里直接省事使用Docker跑一个Jaeger容器:

docker run \
  --rm \
  -p 6831:6831/udp \
  -p 6832:6832/udp \
  -p 16686:16686 \
  jaegertracing/all-in-one:1.7 \
  --log-level=debug

这里的端口配置和initTracer方法中config中配置的默认端口应该是一样的,也就是这些config用于和Jaeger服务进行通信。将这个程序跑起来,就会在Jaeger的UI界面上看到sayHello()方法相关的调用信息了。

opentracing

说了这么多,貌似和想象中的有点差距。不着急,这仅仅才开始。

之前说到,一个Span里会有多个子Span,具体体现在代码中是这样的:

private void sayHello(String helloTo) {
    Span span = tracer.buildSpan("say-hello").start();
    span.setTag("hello-to", helloTo);

    String helloStr = formatString(span, helloTo);
    printHello(span, helloStr);

    span.finish();
}

private  String formatString(Span rootSpan, String helloTo) {
    Span span = tracer.buildSpan("formatString").asChildOf(rootSpan).start();
    try {
        String helloStr = String.format("Hello, %s!", helloTo);
        span.log(ImmutableMap.of("event", "string-format", "value", helloStr));
        return helloStr;
    } finally {
        span.finish();
    }
}

private void printHello(Span rootSpan, String helloStr) {
    Span span = tracer.buildSpan("printHello").asChildOf(rootSpan).start();
    try {
        System.out.println(helloStr);
        span.log(ImmutableMap.of("event", "println"));
    } finally {
        span.finish();
    }
}

首先使用formatString来格式化,接着使用printHello来打印到控制台。这里就很能体现出父子Span到层级关系了。通过asChildOf方法来表示这个层级关系,即:formatStringprintHello的调用Span是sayHello方法的子Span。运行一下程序,在后台UI中看到的就是这样的层级关系了:

span 层级

当然这里还有同感span打印日志操作,语义十分清晰,这里不做过多解释了。
看到这里,似乎觉得这代码写起来是不是有点冗余了?没错,在方法之间非得把rootSpan拿来传递下去,显得格外麻烦。因此opentracing提供一种好用的方式,简化了方法之间传递rootSpan的复杂性。

private void sayHello(String helloTo) {
    Span span = tracer.buildSpan("say-hello").start();
    try (Scope scope = tracer.scopeManager().activate(span)) {
        span.setTag("hello-to", helloTo);

        String helloStr = formatString(helloTo);
        printHello(helloStr);
    } finally{
        span.finish();
    }
}

private  String formatString(String helloTo) {
    Span span = tracer.buildSpan("formatString").start();
    try (Scope scope = tracer.scopeManager().activate(span)) {
        String helloStr = String.format("Hello, %s!", helloTo);
        span.log(ImmutableMap.of("event", "string-format", "value", helloStr));
        return helloStr;
    } finally{
        span.finish();
    }
}

private void printHello(String helloStr) {
    Span span = tracer.buildSpan("printHello").start();
    try (Scope scope = tracer.scopeManager().activate(span)) {
        System.out.println(helloStr);
        span.log(ImmutableMap.of("event", "println"));
    } finally{
        span.finish();
    }
}

通过activate方法来简化rootSpan在方法之间的传递。同时使用try with resource语法巧妙的对资源进行控制。其实现原理是线程上下文。

public class ThreadLocalScopeManager implements ScopeManager {
    final ThreadLocal<ThreadLocalScope> tlsScope = new ThreadLocal<ThreadLocalScope>();

    @Override
    public Scope activate(Span span) {
        return new ThreadLocalScope(this, span);
    }

    @Override
    public Span activeSpan() {
        ThreadLocalScope scope = tlsScope.get();
        return scope == null ? null : scope.span();
    }
}

public class ThreadLocalScope implements Scope {
    private final ThreadLocalScopeManager scopeManager;
    private final Span wrapped;
    private final ThreadLocalScope toRestore;

    // 创建的时候,先拿到之前保存的存到变量中,再将自己放进线程上下文
    ThreadLocalScope(ThreadLocalScopeManager scopeManager, Span wrapped) {
        this.scopeManager = scopeManager;
        this.wrapped = wrapped;
        this.toRestore = scopeManager.tlsScope.get();
        scopeManager.tlsScope.set(this);
    }

    // 关闭的时候,将上次的信息恢复
    @Override
    public void close() {
        if (scopeManager.tlsScope.get() != this) {
            // This shouldn't happen if users call methods in the expected order. Bail out.
            return;
        }

        scopeManager.tlsScope.set(toRestore);
    }

    Span span() {
        return wrapped;
    }
}

因为try语句在方法中是嵌套的,因此采用这样的方式最终的效果是都能找到上次的span。理解起来可能有点费力,tlsScope实例一直被传递,因为仅此一个实例(并没有显式去new,而是通过this去传递的),而ThreadLocalScope类却会每次创建出来,与此同时每次的span也会不一样。通过toRestore变量来不断地倒转,每次activate调用,创建新的Scope,放进上下文,try执行完,再将上次的Scope放进上下文。一来一回形成闭环有头有尾,类似括号匹配。
这种方式和之前采用方法中传递rootSpan变量是一样的效果。

看到这里,似乎也没觉得有太大的用处,因为这仅仅是在进程内进行trace,进程之间的trace如何实现呢?先看看这个demo:

private void sayHello(String helloTo) {
    Span span = tracer.buildSpan("say-hello").start();
    try (Scope scope = tracer.scopeManager().activate(span)) {
        span.setTag("hello-to", helloTo);

        String helloStr = formatString(helloTo);
        printHello(helloStr);
    } finally {
        span.finish();
    }
}

private String formatString(String helloTo) {
    Span span = tracer.buildSpan("formatString").start();
    try (Scope scope = tracer.scopeManager().activate(span)) {
        String helloStr = getHttp(8081, "format", "helloTo", helloTo);
        span.log(ImmutableMap.of("event", "string-format", "value", helloStr));
        return helloStr;
    } finally {
        span.finish();
    }
}

private void printHello(String helloStr) {
    Span span = tracer.buildSpan("printHello").start();
    try (Scope scope = tracer.scopeManager().activate(span)) {
        getHttp(8082, "publish", "helloStr", helloStr);
        span.log(ImmutableMap.of("event", "println"));
    } finally{
        span.finish();
    }
}
private String getHttp(int port, String path, String param, String value) {
    try {
        HttpUrl url = new HttpUrl.Builder().scheme("http").host("localhost").port(port).addPathSegment(path)
                .addQueryParameter(param, value).build();
        Request.Builder requestBuilder = new Request.Builder().url(url);
        Request request = requestBuilder.build();
        Response response = client.newCall(request).execute();

        Tags.HTTP_STATUS.set(tracer.activeSpan(), response.code());
        if (response.code() != 200) {
            throw new RuntimeException("Bad HTTP result: " + response);
        }
        return response.body().string();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

与之前不同的是,这里的格式化字符串和输出方法都不是在同一个进程执行的,而是跨进程了。这两个操作通过http进行远程方法调用。跑一下在UI界面中依然能看到与之前相同的结果;


跨进程

是不是发现了有什么不对?没错,按道理说跨进程调用,被调用的那一方也应该被trace到,而这里却只有发起方的trace记录,和之前在同一个进程内调用的根本没什么区别。因此这里需要对服务的提供方进行trace一下。

最通俗的解释就是怎么把我的rootSpan传递给别的进程。opentracing api提供了两种方式:

private String getHttp(int port, String path, String param, String value) {
    try {
        HttpUrl url = new HttpUrl.Builder().scheme("http").host("localhost").port(port).addPathSegment(path)
                .addQueryParameter(param, value).build();
        Request.Builder requestBuilder = new Request.Builder().url(url);
        
        Span activeSpan = tracer.activeSpan();
        Tags.SPAN_KIND.set(activeSpan, Tags.SPAN_KIND_CLIENT);
        Tags.HTTP_METHOD.set(activeSpan, "GET");
        Tags.HTTP_URL.set(activeSpan, url.toString());
        tracer.inject(activeSpan.context(), Format.Builtin.HTTP_HEADERS, new RequestBuilderCarrier(requestBuilder));

        Request request = requestBuilder.build();
        Response response = client.newCall(request).execute();

        Tags.HTTP_STATUS.set(activeSpan, response.code());
        if (response.code() != 200) {
            throw new RuntimeException("Bad HTTP result: " + response);
        }
        return response.body().string();
    } catch (Exception e) {
        Tags.ERROR.set(tracer.activeSpan(), true);
        tracer.activeSpan().log(ImmutableMap.of(Fields.EVENT, "error", Fields.ERROR_OBJECT, e));
        throw new RuntimeException(e);
    }
}
public class RequestBuilderCarrier implements io.opentracing.propagation.TextMap {
    private final Request.Builder builder;

    RequestBuilderCarrier(Request.Builder builder) {
        this.builder = builder;
    }

    @Override
    public Iterator<Map.Entry<String, String>> iterator() {
        throw new UnsupportedOperationException("carrier is write-only");
    }

    @Override
    public void put(String key, String value) {
        builder.addHeader(key, value);
    }
}

如果不去深入源码实现,这里也能够猜到inject的操作是将span上下文信息通过键值对的形式写到了http header中了,包含url,method等信息。这样,客户端的trace就完成了,接下来再看看服务端的trace怎么处理。

前面提到inject对应的方法是extract,看看没改动之前的样子:

@Path("/format")
@Produces(MediaType.TEXT_PLAIN)
public class FormatterResource {

    @GET
    public String format(@QueryParam("helloTo") String helloTo) {
        String helloStr = String.format("Hello, %s!", helloTo);
        return helloStr;
    }
}

看看改动之后的:

@GET
public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) {
    Span span =  Tracing.startServerSpan(tracer, httpHeaders, "format");
    try (Scope scope = tracer.scopeManager().activate(span)) {
        String helloStr = String.format("Hello, %s!", helloTo);
        span.log(ImmutableMap.of("event", "string-format", "value", helloStr));
        return helloStr;
    } finally {
        span.finish();
    }
}
public static Span startServerSpan(Tracer tracer, javax.ws.rs.core.HttpHeaders httpHeaders, String operationName) {
    // format the headers for extraction
    MultivaluedMap<String, String> rawHeaders = httpHeaders.getRequestHeaders();
    final HashMap<String, String> headers = new HashMap<String, String>();
    for (String key : rawHeaders.keySet()) {
        headers.put(key, rawHeaders.get(key).get(0));
    }

    Tracer.SpanBuilder spanBuilder;
    try {
        SpanContext parentSpanCtx = tracer.extract(Format.Builtin.HTTP_HEADERS, new TextMapAdapter(headers));
        if (parentSpanCtx == null) {
            spanBuilder = tracer.buildSpan(operationName);
        } else {
            spanBuilder = tracer.buildSpan(operationName).asChildOf(parentSpanCtx);
        }
    } catch (IllegalArgumentException e) {
        spanBuilder = tracer.buildSpan(operationName);
    }
    // TODO could add more tags like http.url
    return spanBuilder.withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_SERVER).start();
}

与之前不同的是增加了一个参数HttpHeaders,然后获取header中的键值对,通过extract方法将span上下文还原,作为当前span的父亲,最后打上tag信息。同理对于print方法也是如此,最终在Jeager UI中看到的会是这样:

RPC CALL
这里多了几个span,因为将服务端的span也trace到了。

Conclusion

本文介绍了opentracing 的一些基础使用和主要概念,理解起来相对比较简单。上述的代码在使用上稍微不是很方便,因为需要开发者手工去针对trace做一下适配。然而opentracing生态提供了相关的库,如上述代码中针对okhttp的定制就可以使用现成的okhttp.
除了使用之外,你肯定对这些span信息如何上报到服务端很感兴趣,等有时间再回头看看书如何实现的。

Ref

分布式全链路监控 -- opentracing小试
opentracing-tutorial
分布式追踪系统 -- Opentracing

上一篇下一篇

猜你喜欢

热点阅读