OpenTracing初探

2020-09-08
笔记

what

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

how

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

1
2
3
4
5
6
7
8
9
10
11
<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>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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)。当然这里描述的仅仅是最常见的一种情况,也就是父子关系的情况。

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

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

1
2
3
4
5
6
7
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,具体体现在代码中是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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的复杂性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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语法巧妙的对资源进行控制。其实现原理是线程上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    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,看看没改动之前的样子:

1
2
3
4
5
6
7
8
9
10
@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;
}
}

看看改动之后的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@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


留言: