赞
踩
最近公司在搭建全链路追踪框架以便对既有接口进行整理、优化。
在这过程中针对全链路追踪技术学习了不少知识,在此归纳整理了一下。如果有不对之处还请各位大大指出~~喜欢的话可以随意转载,记得注明出处就行(o゜▽゜)o☆
全文分别从以下几点介绍全链路追踪技术:
如图所示,在一个微服务架构的请求周期内,一个请求可能会链式调用多个服务。而对于每个服务而言,每个服务可能由一台服务器或者是一个服务器集群构成。
工作中我们可能会碰到以下问题
以上问题对于单体服务而言,通过日志或许就能基本解决(例如,通过对日志记录的时间进行分析,得出单个接口的响应时间)。但是对于微服务而言,由于服务个数的增加,以上的问题的困难程度也跟着大幅度增加。特别是对于统计服务间的调用关系,单个接口的请求数/异常数,单个接口的健康程度等问题,单单通过日志的功能进行判断,就显得越发困难。这时候如果还需要通过UI的形式,将相关的数据整理并展示出来,就变得更为困难。
所以,一种能够清晰明了的解决以上人们所关心的问题的技术就变得越发重要。而全链路追踪技术就是为此而创造的。
因为要解决上述问题,所以全链路追踪技术必须包含以下功能
自从 Google Dapper 的论文发布之后,各大互联网公司和开源社区都了解到了全链路追踪技术的优点,并积极响应,开发出各式各样的分布式链路追踪产品。虽然有大量的产品提供用户选择,但同时也给用户带来了一个问题,各个分布式链路追踪产品的 API 并不兼容,如果用户在各个产品之间进行切换,成本非常高。
因此,是否能有一种规范来规范所有的分布式链路追踪产品的 API ,使每个公司的分布式链路追踪产品能够相互兼容,减少用户的学习成本和使用成本?答案是肯定的,Opentracing应运而生。
开放式分布式追踪规范
如图所示,单个trace中,span之间的关系呈树状结构。Span A是Span B和Span C的父Span,而Span B和Span C是同级Span,Span D是Span B的子Span。
如图所示,在整个Span的耗时中,包含了SpanB、SpanC、SpanD等Span。可以通过时间轴之间的包含关系来判断Span的父子关系和调用顺序。
如图所示,主机或者容器将span数据采用Json序列化后通过http接口上报到链路追踪(Collector)服务器;链路追踪服务器会校验Trace、建立索引、执行转换并最终进行存储。存储是一个可插入的组件,现在支持Cassandra和elasticsearch。最后,用户通过Query服务从存储中检索Trace并通过UI界面进行展现。
下面针对主机上报逻辑进行说明。
单个服务中,通过调用tracer->flush()方法将tracer内所有的span上报到链路追踪服务。所以,flush方法一般在响应发送之前。
/**
* 结束
* @param Opentracing_Span $span
*/
public function finish( Opentracing_Span $span )
{
if( self::enable() )
{
// 单个span结束的方法
$span->finish();
// 如果是最上层的span,则将所有的span上报到链路追踪服务器
if( $span->getKind() == Opentracing_Span::KIND_PARENT )
{
// getTracer方法获得的是一个全局的tracer,tracer包含了sampler、report等全局的功能
Opentracing_Tracing::getInstance()->getTracer()->flush();
}
}
}
再来看看具体的flush方法
/**
* Calling this will flush any pending spans to the transport.
*
* Make sure this method is called after the request is finished.
*
* @see register_shutdown_function()
* @see fastcgi_finish_request()
*/
public function flush(): void
{
// 使用tracer的recorder将所有的span上报
$this->recorder->flushAll();
}
/**
* @return void
*/
public function flushAll(): void
{
// 其中$this->spanMap->removeAll()返回的是所有的span数据
// 调用report将数据上报到链路追踪服务器
$this->reporter->report($this->spanMap->removeAll());
}
interface Reporter
{
/**
* @param MutableSpan[] $spans
* @return void
*/
public function report(array $spans): void;
}
再让我们看看tracer是如何实现Reporter接口的
/**
* 构建trace
*/
private function _buildTracing()
{
// 定义最终上报的服务器地址
$endpoint = Endpoint::create(
$this->_getServerName() ,
$this->_getIp() ,
null ,
null
);
// 根据服务器地址生成一个reporter(此reporter就是上述Reporter接口的实现)
$reporter = new Http(
CurlFactory::create() ,
[ 'endpoint_url' => $this->_getReportUrl() ]
);
$sampler = BinarySampler::createAsAlwaysSample();
// 初始化一个tracing
$this->_tracing = TracingBuilder::create()
->havingLocalEndpoint( $endpoint )
->havingSampler( $sampler )
->havingReporter( $reporter )
->build();
}
tracer中reporter的report方法又是怎么样的?
/**
* @param ReadbackSpan[] $spans
* @return void
*/
public function report(array $spans): void
{
if (\count($spans) === 0) {
return;
}
// 将所有的span数据序列化
$payload = $this->serializer->serialize($spans);
if ($payload === false) {
$this->logger->error(
\sprintf('failed to encode spans with code %d', \json_last_error())
);
return;
}
// 根据传入的endpoint_url数据生成一个client
// 其中client是一个闭包函数,根据传入的payload生成一个curl,并执行这个curl,将数据通过http的方式发送给上报服务器
$client = $this->clientFactory->build($this->options);
try {
$client($payload);
} catch (RuntimeException $e) {
$this->logger->error(\sprintf('failed to report spans: %s', $e->getMessage()));
}
}
以上就是主机上报span数据到Collector的逻辑
对于具体ES中存储的数据结构,感兴趣的话,可以参考这篇文章:https://shared-code.com/article/105
func main() {
// 初始化一个tracer
tracer := jaeger.InitJaeger()
// 设置全局tracer
opentracing.SetGlobalTracer(tracer)
// 开始一个Span,并提供其Span名
span := tracer.StartSpan("span_root")
// 将Span数据放入上下文中
ctx := opentracing.ContextWithSpan(context.Background(), span)
// 将向下文传给下一个span
r1 := foo3("Hello foo3", ctx)
r2 := foo4("Hello foo4", ctx)
fmt.Println(r1, r2)
// 结束当前span并将其上报到上报服务器
span.Finish()
}
通过tracer.StartSpan和span.Finish,将需要采样的函数方法包含在两者之间,用以统计相应的耗时。
再看看foo3中的上下文ctx的作用(foo4与foo3的代码一致)
func foo3(req string, ctx context.Context) (reply string) {
//1.根据传入的上下文ctx创建子span
span, _ := opentracing.StartSpanFromContext(ctx, "span_foo3")
defer func() {
//4.接口调用完,在tag中设置request和reply
span.SetTag("request", req)
span.SetTag("reply", reply)
span.LogFields(
log.String("lalla", "hey"),
)
span.Finish()
}()
println(req)
//2.模拟处理耗时
time.Sleep(time.Second / 2)
//3.返回reply
reply = "foo3Reply"
return
}
这样就能生成下图所示的span时序图了
<--------------span_root------------->
<----span_foo3---->
<----span_foo4---->
因为两者都是基于opentracing规范进行开发的,所以在操作方面基本没有区别——在上文的演示代码可以看出,基本操作如下:
当然,由于opentracing没有强制规范数据格式和上报机制,所以每种框架对这两点的实现还是有所区别的。
在上文我们已经将PHP-Zipkin的上报机制的源码讲解一遍了,总的来说就是——Zipkin会收集所有Span信息,并在程序结束之前通过flush将所有Span信息通过tracer的reporter一同上报到上报服务器。
让我们再来看看Jaeger的上报机制源码。
通看官方文档我们发现,Jaeger是没有flush这个方法对Span的数据进行统一上报的,那Jaeger又是如何对数据进行上报的呢??
// 官方文档中,服务端对Span的操作
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
var serverSpan opentracing.Span
appSpecificOperationName := ...
wireContext, err := opentracing.GlobalTracer().Extract(
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header))
if err != nil {
// Optionally record something about err here
}
// Create the span referring to the RPC client if available.
// If wireContext == nil, a root span will be created.
serverSpan = opentracing.StartSpan(
appSpecificOperationName,
ext.RPCServerOption(wireContext))
// 最后结束只对serverSpan进行Finish操作
defer serverSpan.Finish()
ctx := opentracing.ContextWithSpan(context.Background(), serverSpan)
...
}
同样是查看官方文档,我们发现整个对Span的操作中,只有使用Finish这个方法对Span进行关闭。所以可以猜测,是不是在Finish内对Span进行上报了??接下来我们看看Finish的源码。
func (s *Span) Finish() {
s.FinishWithOptions(opentracing.FinishOptions{})
}
// FinishWithOptions implements opentracing.Span API
func (s *Span) FinishWithOptions(options opentracing.FinishOptions) {
...
// call reportSpan even for non-sampled traces, to return span to the pool
// and update metrics counter
s.tracer.reportSpan(s)
}
func (t *Tracer) reportSpan(sp *Span) {
if !sp.isSamplingFinalized() {
t.metrics.SpansFinishedDelayedSampling.Inc(1)
} else if sp.context.IsSampled() {
t.metrics.SpansFinishedSampled.Inc(1)
} else {
t.metrics.SpansFinishedNotSampled.Inc(1)
}
// Note: if the reporter is processing Span asynchronously then it needs to Retain() the span,
// and then Release() it when no longer needed.
// Otherwise, the span may be reused for another trace and its data may be overwritten.
// 判断是否采样当前Span,如果是则上报
if sp.context.IsSampled() {
t.reporter.Report(sp)
}
sp.Release()
}
如我们所想,Jaeger是通过Span的Finish方法进行上报的。
由于Jaeger中,必须通过Finish结束当前的Span,所以我们可以得出结论——Jaeger与Zipkin不同,Jaeger并不会收集所有的Span信息后进行统一上报,而是每次结束掉当前Span后,对单个Span进行上报。
这样做的好处在于,对于PHP-Zipkin而言,由于Span信息需要先保存到内存中后进行统一上报,如果一次请求响应过程中如果有许多Span就容易把内存撑爆;而对于Go-Jaeger而言,每次结束都对Span进行上报,就不会对内存有太大负担。此外,由于Go拥有goroutine这个概念,可以通过goroutine对Span进行异步上报,对整个接口的性能不会有什么影响。
服务之间传递span数据有两种方式
Opentracing虽然规范了API接口,但是没有规范具体的传输数据的格式,所以在使用不同的链路跟踪框架时要注意每种框架其传递数据的格式。
如图所示,在请求头模式中,client将上下文数据通过Inject API将数据存入请求头中;Server收到请求头后,将数据通过Extract API转换回上下文数据。之后Server使用转换回的上下文生成具体的Span。
func makeSomeRequest(ctx context.Context) ... {
if span := opentracing.SpanFromContext(ctx); span != nil {
// 初始化http客户端
httpClient := &http.Client{}
httpReq, _ := http.NewRequest("GET", "http://myservice/", nil)
// 将上下文使用Inject放入请求头内
opentracing.GlobalTracer().Inject(
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(httpReq.Header))
// 发送这个请求并获取响应
resp, err := httpClient.Do(httpReq)
...
}
...
}
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
var serverSpan opentracing.Span
appSpecificOperationName := ...
// 从http请求头中获取上下文
wireContext, err := opentracing.GlobalTracer().Extract(
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header))
if err != nil {
// Optionally record something about err here
}
// 利用上下文创建serverSpan
// 如果上下文中不包含Span数据,则将当前span作为root Span,也就是父Span
serverSpan = opentracing.StartSpan(
appSpecificOperationName,
ext.RPCServerOption(wireContext))
// 当所有操作完成后,结束这个span并上报
defer serverSpan.Finish()
ctx := opentracing.ContextWithSpan(context.Background(), serverSpan)
...
}
客户端和服务端都使用相同链路框架的时候,基本与单服务的链路追踪操作相同。唯一的区别在于,需要将上下文通过请求头(或者grpc)进行传递。服务端在接收到传来的数据后,将传来的spanId作为父spanId。
客户端和服务端采用不同的语言,不同的框架搭建链路追踪系统的时候,Opentracing就显得尤为重要。以下范例就是使用两种不同的语言和框架进行搭建链路追踪系统。
通过php的gRPC接口调用Go服务器
public static function getRoleUsers( $roleId = -1 , $page = 1, $pageSize = 1 )
{
...
// 在于此处请求Go服务器
$res = $client->getRoleUsers( $roleUsersRequest )->wait();
...
return $replyData;
}
看看getRoleUsers中具体做了什么
public function getRoleUsers( Permission\GetRoleUsersRequest $argument , $metadata = [] , $options = [] )
{
// 将tracer数据存入metadata中
$metadata = \Opentracing_Tracer::injectMetadata( $metadata );
// 构造请求
return $this->_simpleRequest(
'/RoleService/GetRoleUsers' ,
$argument ,
[ '\Icy\Permission\GetRoleUsersResponse' , 'decode' ] ,
$metadata ,
$options
);
}
/**
* 向metadata注入参数
* @param array $metadata
* @return array
*/
public static function injectMetadata( $metadata )
{
if( self::enable() )
{
// 获取span的向下文
$context = self::$rootSpan->getContext();
// 将上下文中储存的数据转换格式后放入metadata中
$meta = \Helper_Tracer_Jaeger::getInstance()
->_setTraceId( $context->getTraceId() )
->_setSpanId( $context->getSpanId() )
->_setParentSpanId( $context->getParentId() )
->_setIsSampled( $context->isSampled() )
->_getMetaData();
$metadata = array_merge( $metadata , $meta );
}
return $metadata;
}
需要传给服务器的上下文已经准备好了,并且通过gRPC的方式将数据传给了服务器。
func NewHandlerWrapper(ot opentracing.Tracer) server.HandlerWrapper {
return func(h server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
if ot == nil {
ot = opentracing.GlobalTracer()
}
name := fmt.Sprintf("%s.%s", req.Service(), req.Endpoint())
// 通过上下文中的数据生成具体的Span
ctx, span, err := StartSpanFromContext(ctx, ot, name)
if err != nil {
return err
}
global.Ctx = ctx
defer span.Finish()
if err = h(ctx, req, rsp); err != nil {
span.LogFields(opentracinglog.String("error", err.Error()))
span.SetTag("error", true)
}
return err
}
}
}
PS:图中的StartSpanFromContext方法是go-micro的方法,具体可以去micro的源码
至此,在两个服务之间建立了联系。服务端成功的利用上下文生成了需要的子Span。
在这个过程中,有一个点需要注意一下——由于客户端和服务端使用的是两种框架,导致其上下文中所需要的数据格式不相同。由于客户端使用的是Zipkin框架,而服务端使用的是Jaeger框架,所以要将X-B3数据格式转换为Jaeger框架所能识别的Uber-Trace-Id。Uber-Trace-Id的数据格式如下:
traceId:spanId:parentSpanId:sampled
下图是在服务器中Debug获取到的客户端传来的链路数据(Uber-Trace-Id)
具体的公司实践效果就不在这里阐述了~~Jaeger和Zipkin的上报服务器在本地可以直接通过docker进行搭建,有兴趣的可以自己在本地试试!
以上です〜(<ゝω·)☆ kira~
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。