美文网首页
OpenTracing初探

OpenTracing初探

作者: MR丿VINCENT | 来源:发表于2020-09-08 17:49 被阅读0次

    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提供了两种方式:

    • inject(spanContext, format, carrier)
    • extract(format, carrier)
      顾名思义,一个是注入,另一个是抽取。其中的format参数也提供了如下可选:
    • TEXT_MAP where span context is encoded as a collection of string key-value pairs,
    • BINARY where span context is encoded as an opaque byte array,
    • HTTP_HEADERS, which is similar to TEXT_MAP except that the keys must be safe to be used as HTTP headers.
      第一个最简单,键值对,可以理解为一个map;第二个是二进制格式;第三个是基于http的头,其实也是键值对格式。而carrier则是根据format来确定的,如果format=TEXT_MAP,那么carrier就提供一个针对键值对的写入入口类似put(key,value).
      接下来对上述代码进行改造。
      首先是注入,简单理解为在发起调用的那一头把自己的rootSpan写到被调用方中去。因此这里使用inject方法:
    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

    相关文章

      网友评论

          本文标题:OpenTracing初探

          本文链接:https://www.haomeiwen.com/subject/bdjjektx.html