Zipkin实现分布式调用链监控实践

tech2023-06-11  113

Zipkin实现分布式调用链监控实践

安装Zipkin Server应用埋点JAVA 应用埋点埋点配置Http客户端埋点服务埋点数据库埋点 GO 应用埋点

本人所在的产品线是公司统一的构建管理平台,该平台采用了微服务的构造,跟微服务自身的通病一样,服务数量多了之后,存在这样一些问题:

接口超时或者调用失败后,无法知道具体是那个下游接口超时接口响应慢后,怎么去了解是那个服务的那个方法调用耗时较长对前端暴露的接口,响应时间对用户的体验比较敏感,怎么监控到系统中存在的慢接口,进而优化

微服务发展到今天,业界对这类问题已经有很多通用的解决方案,最早的比较Google公司发表的Dapper应该算得上是始祖了吧。 我们也不想重复造轮子,比较了一些方案,综合对埋点语言的支持(Golang + Java),Zipkin进行最终的方案。

安装Zipkin Server

Zipkin Server主要包含三个Components:

Zipkin UI

Zipkin Server: 应用的埋点配置需要将Span, trace的数据发到这里

Span Storage: Zipkin Server默认后端使用内存存储, 数据大的话似乎只可以存储几个小时的数据,而且搜索的性能也会有问题,Zipkin支持较多其它的Storage, 比如Mysql, ElasticSearch。

为了简化这些组件的安装,直接采用了官方提供的Docker Image,参数通过环境变量来指定。

启动ElasticSearch6, 官方建议先调高系统的虚拟内存,不然会启动失败

docker run -d -p 9200:9200 zipkin-elasticsearch6:latest

启动Zipkin Server (含UI)

docker run -d -p 9411:9411 -e STORAGE_TYPE=elasticsearch -e ES_HOSTS=192.168.10.10:9200 zipkin:latest

一切就绪后,就可以通过http://< ip-address >:9411/zipkin/访问了。

应用埋点

埋点就是通过SDK将业务的接口数据生成trace信息,再由SDK通过http/消息将trace发送到后端的Zipkin服务。

前面提到选型时主要考虑了语言的兼容性,我们分别看一下Java与Go的埋点,以及其中一些比较tricky的配置。

JAVA 应用埋点

Zipkin官方对Java应用的埋点推荐提供的是Brave,它可以兼容Zipkin后端trace数据的格式,由于项目本身是Springboot的框架搭建的,因些我们采用的是对Brave封装了多一层的Spring Sleuth 。

在工程中添加下面的依赖:

compile('org.springframework.cloud:spring-cloud-starter-zipkin:1.3.3.RELEASE') compile('org.springframework.boot:spring-boot-starter-aop:1.5.6.RELEASE')

埋点配置

埋点的配置主要包含下面常用的配置项 (注意你使用的版本,可能配置项名称会有些差异):

配置项配置项解释application.name应用的名称,在Zipkin UI过滤时对应为 serviceNamesleuth.enabled是否启动sleuth,只有启用后才会收集tracezipkin.baseUrlZipkin Server对应的链接,需要将trace的数据通过http接口发送给它sleuth.sampler.percentage设置Span的采样率,默认似乎是0,初次使用会比较坑,在UI上看不到收集的Span数据sleuth.web.skip-pattern排除掉一些不用监控的服务入口,比如 /health_check

下面是一个具体的配置例子:

spring: profiles: staging application: name: neith-gw zipkin: baseUrl: ${ZIPKIN_SERVER:http://192.168.10.10:9411} sleuth: sampler: percentage: ${SLEUTH_SAMPLE_RATE:1.0} web: client: enabled: true skip-pattern: /prometheus|/_health_check|/ws/.*|/swagger.*|.*\\.ttf|/v2/api-docs|.*\\.png|.*\.css|.*\.js|.*\.html|/favicon.ico enabled: ${SLEUTH_ENABLE:true}

假如配置没问题,你应该可以看到下面的启动日志:

16:24:48.713 [main] INFO o.s.context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker - Bean 'org.springframework.cloud.sleuth.annotation.SleuthAnnotationAutoConfiguration' of type [org.springframework.cloud.sleuth.annotation.SleuthAnnotationAutoConfiguration$$EnhancerBySpringCGLIB$$5766f243] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)

Http客户端埋点

由于Sleuth的埋点是通过AOP代理的,而Spring的AOP默认又是基于Java Bean的proxy对象,即在调用target bean对应的方法前,先调用proxy对象的方法,并在proxy方法实现埋点的功能。

假如你的项目跟我一样没有使用Spring的RestTemplate类(并且必须是Bean,不能通过New生成实例),而是使用了第三方的http client包,为了埋点能工作以及减少埋点对代码的侵入,需要一些小技巧,在我的项目是这样做的:

首先需要创建一个新的Bean实现http的发送接口,例如

@Component public class HttpSender { public HttpResponse send(HttpRequest request) { return request.send(); } }

然后将原来的使用三方http client接口的调用替换为新的接口

... @Autowired private HttpSender httpSender; @Override public GitlabCommit findCommitByRef(String gitRepo, String refName) { HttpResponse response = httpSender.send(HttpRequest.get(GITLAB_COMMIT_URL) .query("branch_name", refName) .query("token", token) .timeout(TIMEOUT)); .......

Http client改造完成后,就可以定义AOP的切面了:

@Aspect @Component @ConditionalOnProperty(name = "spring.sleuth.enabled", matchIfMissing = true) public class DemoHttpAspect { @Autowired private Tracer tracer; @Autowired private HttpSpanInjector spanInjector; @Autowired private HttpTraceKeysInjector keysInjector; @Autowired private ErrorParser errorParser; @Around("execution(* com.demo.sleuth.HttpSender.send(..))") public HttpResponse intercept(ProceedingJoinPoint joinPoint) throws Throwable { Span newSpan = null; try { HttpRequest request = (HttpRequest) joinPoint.getArgs()[0];   String spanName = request.url(); newSpan = tracer.createSpan(spanName); // Step-1 this.spanInjector.inject(newSpan, new JoddHttpRequestTextMap(request)); // Step-2 addRequestTags(newSpan, request); // Step-3 newSpan.logEvent(Span.CLIENT_SEND); // Step-4 HttpResponse response = (HttpResponse) joinPoint.proceed(); keysInjector.tagSpan(newSpan, "http.status_code", String.valueOf(response.statusCode())); // Step-5 return response; } catch (Exception e) { errorParser.parseErrorTags(newSpan, e); // Step-6 throw e; } finally { if (newSpan != null && tracer.isTracing()) { tracer.getCurrentSpan().logEvent(Span.CLIENT_RECV); // Step-7 tracer.close(tracer.getCurrentSpan()); } } } step-1: 创建本次方法对应的一个Span,tracer会将当前thread对应的trace context的数据填充到新建的当前Span中,包含trace-id, parent-span-idstep-2:spanInjector将span的context 信息(trace-id, parent-span-id)填充到HTTP请求的Header中,这样下游收到请求后才可以从header中将这次调用的上下文串联起来step-3:添加tag信息到Span中,这些tag信息将帮助你在zipkin UI中更好地了解这次应用,至于要添加那些tag,可以个性化,比如HttpUrl, host, methodstep-4:添加CS annotation,记录Span发出请求的时间step-5:记录Http Response的http code到Span的tag (可选)step-6:假如异常,将异常信息记录在error Tag里面,Zipkin UI对包含有error tag的trace会显示为红色 step-7:添加CR annotation,记录收到http响应的时间

服务埋点

服务的埋点主要包含对@Service, @Component这些接口方法的拦截,与上面AOP的写法并无太多的差异,这里直接上代码就不多哆嗦:

@Around("execution(public * com.demo.*.service.*.*(..))") public Object interceptMethod(ProceedingJoinPoint joinPoint) throws Throwable { Span newSpan = null; try { if (tracer.getCurrentSpan() == null) { return joinPoint.proceed(); } MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method method = methodSignature.getMethod(); Class classz = method.getDeclaringClass(); if (classz.isAnnotationPresent(Service.class)) { newSpan = tracer.createSpan(method.getName()); newSpan.tag("service.class", classz.getSimpleName()); newSpan.tag("service.method", method.getName()); } return joinPoint.proceed(); } catch (Exception e) { errorParser.parseErrorTags(newSpan, e); throw e; } finally { if (tracer.isTracing() && newSpan != null) { tracer.close(newSpan); } } }

数据库埋点

数据库埋点主要想发现现在系统中存中的慢查询,从而导致拖慢接口。本来也想在埋点中可以查看到具体的SQL的,但直接拦截JDBC的话需要依赖aspectj,不能用Spring的AOP,原来项目使用的gradle管理的,gradle没有几个好的插件可以拿来weaver的,而且由于ORM使用的是Mybatis,假如真有问题反推SQL也不是什么难事,因些放弃直接在JDBC埋点,改为监控mybatis mapper的方法埋点。

埋点mapper也有个问题,spring 中定义的mapper只是一个接口,实际跑起来会其实mybatis是会动态地为每个mapper生成一个proxy对象的,名称类似这样:

com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161 com.sun.proxy.$Proxy161

因此直接通过定义AOP切面来拦截mapper也无法实现。调查了比较久没有发现可行的方案,本身打算退而求其次,为mapper的每一个方法人工地通过添加Sleuth的注释@Span来埋点(实在不想一个一个方法地埋点!!),但似乎这个@Span点到了,既然Sleuth可以通过@Span埋点mapper,那肯定还是存在一些路子去埋点这个接口方法的,它又是如何实现的呢?

于是啃了一下Sleuth的源码,发现它有一个Configuration类,该类实现了 BeanFactoryAware, AbstractPointcutAdvisor两个接口,这的思路是这样的:

AbstractPointcutAdvisor定义了两个接口: getPointcut() 和 getAdvice() 。前者返回一个Pointcut表示那些类的那此方法需要拦截,后面定义了需要对这些pointcut怎么拦截。假如要埋点,就需要在pointcut中扫描到我们的mapper类,然后在advice中实现trace埋点BeanFactoryAware的作用是在advice中可以从factory拿到全局的tracer

那要怎么定义Pointcut呢?代码也不长,可以阅读一下

private final class AnnotationClassOrMethodOrArgsPointcut extends DynamicMethodMatcherPointcut { @Override public boolean matches(Method method, Class<?> targetClass, Object... args) { // 对每一个动态方法(proxy)的调用都会调用这个方法判断是否需要对这个method进行拦截 return getClassFilter().matches(targetClass); } @Override public ClassFilter getClassFilter() { return new ClassFilter() { @Override public boolean matches(Class<?> clazz) { boolean matched = new AnnotationClassOrMethodFilter(Mapper.class).matches(clazz); // 直接判断当前类是否包含Mybatis的Mapper注解,有则要拦截 return matched; } }; } } private final class AnnotationClassOrMethodFilter extends AnnotationClassFilter { AnnotationClassOrMethodFilter(Class<? extends Annotation> annotationType) { super(annotationType, true); } @Override public boolean matches(Class<?> clazz) { return super.matches(clazz); } }

又怎么定义Advice呢? 非常熟悉的AOP的味道,埋点的细节在前面介绍Http埋点的时候已经介绍过了,大同小异。

class MapperInterceptor implements IntroductionInterceptor, BeanFactoryAware { private final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final String CLASS_KEY = "class"; private static final String METHOD_KEY = "method"; private static final String OPTTYPE_KEY = "opt.type"; private BeanFactory beanFactory; private SpanCreator spanCreator; private Tracer tracer; private ErrorParser errorParser; private Tracer tracer() { if (this.tracer == null) { this.tracer = this.beanFactory.getBean(Tracer.class); } return this.tracer; } @Override public Object invoke(MethodInvocation invocation) throws Throwable { Method method = invocation.getMethod(); if (method == null || tracer() == null || tracer().getCurrentSpan() == null) { return invocation.proceed(); } Method mostSpecificMethod = AopUtils .getMostSpecificMethod(method, invocation.getThis().getClass()); Span span = null; try { String name = "mapper:" + mostSpecificMethod.getName(); span = tracer().createSpan(name, tracer().getCurrentSpan()); addTags(invocation, span); return invocation.proceed(); } catch (Exception e) { if (logger.isDebugEnabled()) { logger.debug("Exception occurred while trying to continue the pointcut", e); } errorParser().parseErrorTags(tracer().getCurrentSpan(), e); throw e; } finally { if (span != null && tracer().isTracing()) { tracer().close(span); } } } ...

GO 应用埋点

Go使用的是官方推荐的zipkin-go,我的工程是使用godep管理的,添加下面的依赖:

go get github.com/openzipkin/zipkin-go godep save github.com/openzipkin/zipkin-go

http server端的埋点是通过http middleware实现

import zipkinhttp "github.com/openzipkin/zipkin-go/middleware/http" func (srv *Server) Start() error { logging.Infof("Start to listen on %s", srv.Host+":"+strconv.Itoa(srv.Port)) if trace.GetTracer() == nil { return http.ListenAndServe(srv.Host+":"+strconv.Itoa(srv.Port), srv) } // create global zipkin http server middleware serverMiddleware := zipkinhttp.NewServerMiddleware( trace.GetTracer(), zipkinhttp.TagResponseSize(true), zipkinhttp.SkipPattern("/healthz/ping")) return http.ListenAndServe(srv.Host+":"+strconv.Itoa(srv.Port), serverMiddleware(srv)) }

至于其它埋点则与Java基本没有差异,唯一不方便的是Go应该没有AOP这类机制吧?因此埋点的context需要从方法的调用链上一层一层地通过接口参数往下传递,对应用的渗入还是较大的。

func (h *Handler) GetPiplinePhaseRecord(resp http.ResponseWriter, req *http.Request) { span, ctx= tracer.StartSpanFromContext(req.Context(), spanName) .... _, err := h.performer.SayHello(ctx, query) }
最新回复(0)