Istio调用链埋点原理剖析—是否真的“零修改”?(10)

发布于2019-04-21 20:40:10

前言

在Istio的实践中最近经常被问到一个问题,使用Istio做调用链用户的业务代码是不是完全0侵入,到底要不要修改业务代码?

看官方介绍:

Istio makes it easy to create a network of deployed services with load balancing, service-to-service authentication, monitoring, and more, without any changes in service code.

是不用修改任何代码即可做各种治理。实际使用中应用程序不做任何修改,使用Istio的调用链输出总是断开的,这到底是什么原因呢?

对以上问题关注的人比较多,但是貌似说的都不是特别清楚,在最近的K8S技术社区的Meetup上笔者专门做了主题分享,通过解析Istio的架构机制与Istio中调用链的工作原理来回答以上问题。在本文中将节选里面的重点内容,基于Istio官方典型的示例来展开其中的每个细节和原理。

Istio本身的内容在这里不多介绍,作为Google继Kubernetes之后的又一重要项目,Istio提供了Service Mesh方式服务治理的完整的解决方案。正如其首页介绍,通过非侵入的方式提供了服务的连接、控制、保护和观测能力。包括智能控制服务间的流量和API调用;提供授权、认证和通信加密机制自动保护服务安全;通过开放策略来控制调用者对服务的访问;另外提供了可扩展丰富的调用链、监控、日志等手段来对服务与性能进行观测。即用户不用修改代码,就可以实现各种服务治理能力。

较之其他系统和平台,Istio比较明显的一个特点是服务运行的监控数据都可以动态获取和输出,提供了强大的调用链、监控和调用日志收集输出的能力。配合可视化工具,运维人员可以方便的看到系统的运行状况,并发现问题进而解决问题。而我们基本上不用在自己的代码里做任何修改来生成数据并对接各种监控、日志、调用链等后端。非常神奇的是只要我们的程序被部署run起来,其运行数据就自动收集并在一个面板上展现出来。

调用链概述

对于分布式系统的运维管理和故障定位来说,调用链当然是第一利器。

正如Service Mesh的诞生是为了解决大规模分布式服务访问的治理问题,调用链的出现也是为了对应于大规模的复杂的分布式系统运行中碰到的故障定位定界问题。大量的服务调用、跨进程、跨服务器,可能还会跨多个物理机房。无论是服务自身问题还是网络环境的问题导致调用上链路上出现问题都比较复杂,如何定位就比单进程的一个服务打印一个异常栈来找出某个方法要困难的多。需要有一个类似的调用链路的跟踪,经一次请求的逻辑规矩完整的表达出来,可以观察到每个阶段的调用关系,并能看到每个阶段的耗时和调用详细情况。

Dapper, a Large-Scale Distributed Systems Tracing Infrastructure 描述了其中的原理和一般性的机制。模型中包含的术语也很多,理解最主要的两个即可:

上图是Dapper论文中的经典图示,左表示一个分布式调用关系。前端(A),两个中间层(B和C),以及两个后端(D和E)。用户发起一个请求时,先到达前端,再发送两个服务B和C。B直接应答,C服务调用后端D和E交互之后给A应答,A进而返回最终应答。要使用调用链跟踪,就是给每次调用添加TraceId、SpanId这样的跟踪标识和时间戳。

右表示对应Span的管理关系。每个节点是一个Span,表示一个调用。至少包含Span的名、父SpanId和SpanId。节点间的连线下表示Span和父Span的关系。所有的Span属于一个跟踪,共用一个TraceId。从图上可以看到对前端A的调用Span的两个子Span分别是对B和C调用的Span,D和E两个后端服务调用的Span则都是C的子Span。

调用链系统有很多实现,用的比较多的如zipkin,还有已经加入CNCF基金会并且的用的越来越多的Jaeger,满足Opentracing语义标准的就有这么多

一个完整的调用链跟踪系统,包括调用链埋点,调用链数据收集,调用链数据存储和处理,调用链数据检索(除了提供检索的APIServer,一般还要包含一个非常酷炫的调用链前端)等若干重要组件。如图是Jaeger的一个完整实现。

这里我们仅关注与应用相关的内容,即调用链埋点的部分,看下在Istio中是否能做到”无侵入“的调用链埋点。调用链的埋点是一个比起来记录日志,报个metric或者告警要复杂的多。根本原因其数据结构要相对复杂一些,为了能将在多个点上收集的关于一次调用的多个中间请求过程关联起来形成一个链。下面通过详析自带的典型例子来看下这里的细节。

调用链示例

简单起见,我们以Istio最经典的Bookinfo为例来说明。Bookinfo模拟在线书店的一个分类,显示一本书的信息。本身是一个异构应用,几个服务分别由不同的语言编写的。各个服务的模拟作用和调用关系是:

在Istio上运行这个典型例子,不用做任何的代码修改,自带的Zipkin上就能看到如下的调用链输出。

可以看到zipkin上展示给我们的调用链和Boookinfo这个场景设计的调用关系一致:Productpage 服务会调用 Details 和 Reviews 两个服务,Reviews调用了Ratings 微服务。除了显示调用关系外,还显示了每个中间调用的耗时和调用详情。基于这个视图,服务的运维人员比较直观的定界到慢的或者有问题的服务,并钻取当时的调用细节,进而定位到问题。我们就要关注下调用链埋点到底是在哪里做的,怎么做的?

Istio调用链埋点逻辑

在Istio中,所有的治理逻辑的执行体都是和业务容器一起部署的Envoy这个Sidecar,不管是负载均衡、熔断、流量路由还是安全、可观察性的数据生成都是在Envoy上。Sidecar拦截了所有的流入和流出业务程序的流量,根据收到的规则执行执行各种动作。实际使用中一般是基于K8S提供的InitContainer机制,用于在Pod中执行一些初始化任务. InitContainer中执行了一段Iptables的脚本。正是通过这些Iptables规则拦截pod中流量,并发送到Envoy上。Envoy拦截到Inbound和Outbound的流量会分别作不同操作,执行上面配置的操作,另外再把请求往下发,对于Outbound就是根据服务发现找到对应的目标服务后端上;对于Inbound流量则直接发到本地的服务实例上。

所以我们的重点是看下拦截到流量后Sidecar在调用链埋点怎么做的。

Envoy的埋点规则和在其他服务调用方和被调用方的对应埋点逻辑没有太大差别,甚至和一般SDK方式内置的调用链埋点逻辑也类似。

特别是Outbound部分的调用链埋点逻辑,通过一段伪代码描述如下:

parentSpan = tracer.getTracer().extract(headers); 
if(parentSpan == null){ 
  span = tracer.buildSpan(operation).start(); 
} else { 
  span = tracer.buildSpan(operation).asChildOf(parentSpan).start(); 
} 

下面基于具体的例子我们走一遍流程,类剖析下细节,最终得出我们关系的业务代码要做哪些事情?

如图是对前面Zipkin上输出的一个Trace一个透视图,观察下每个调用的细节。可以看到每个阶段四个服务与部署在它旁边上的Sidecar是怎么配合的。在图上只标记了Sidecar生成的Span主要信息。

因为Sidecar 处理 Inbound和Outbound的逻辑有所不同,在图上表也分开两个框图分开表达。如Productpage,接收外部请求是一个处理,给Details发出请求是一个处理,给Reviews发出请求是另外一个处理,因此围绕Productpage这个app有三个黑色的处理块,其实是一个Sidecar在做事。

同时,为了不使的图上箭头太多,最终的Response都没有表达出来,其实图上每个请求的箭头都有一个反方向的Response。在服务发起方的Sidecar会收到Response时,会记录一个CR(client Received)表示收到响应的时间并计算整个Span的持续时间。

下面通过解析下具体数据来找出埋点逻辑:

  1. 首先从调用入口的Gateway开始,Gateway作为一个独立部署在一个pod中的Envoy进程,当有请求过来时,它会将请求转给入口服务Productpage。Gateway这个Envoy在发出请求时里面没有Trace信息,会生成一个根Span:SpanId和TraceId都是f79a31352fe7cae9,因为是第一个调用链上的第一个Span,也就是一般说的根Span,所有ParentId为空,在这个时候会记录CS(Client Send);
  2. 请求从入口Gateway这个Envoy进入Productpage的app业务进程其Inbound流量被Productpage Pod内的Envoy拦截,Envoy处理请求头中带着Trace信息,记录SR(Server Received),并将请求发送给Productpage业务容器处理,Productpage在处理请求的业务方法中在接受调用的参数时,除了接受一般的业务参数外,同时解析请求中的调用链Header信息,并把Header中的Trace信息传递给了调用的Details和Reviews的微服务。
  3. 从Productpage出去的请求到达Reviews服务前,其Oubtbound流量又一次通过同Pod的Envoy,Envoy埋点逻辑检查Header中包含了Trace相关信息,在将请求发出前会做客户端的调用链埋点,即以当前Span为parent Span,生成一个子Span:新的SpanId cb4c86fb667f3114,TraceId保持一致9a31352fe7cae9,ParentId就是上个Span的Id: f79a31352fe7cae9。
  4. 从Productpage到Reviews的请求经过Productpage的Sidecar走LB后,发给一个Reviews的实例。请求在到达Reviews业务容器前,同样也被Reviews的Envoy拦截,Envoy检查从Header中解析出Trace信息存在,则发送Trace信息给Reviews。Reviews处理请求的服务端代码中同样接收和解析出这些包含Trace的Header信息,发送给下一个Ratings服务。

在这里我们只是理了一遍请求从入口Gateway,访问Productpage服务,再访问Reviews服务的流程。可以看到期间每个访问阶段,对服务的Inbound和Outbound流量都会被Envoy拦截并执行对应的调用链埋点逻辑。图示的Reviews访问Ratings和Productpage访问Details逻辑与以上类似,这里不做复述。

以上过程也印证了前面我们提出的Envoy的埋点逻辑。可以看到过程中除了Envoy处理Inbound和Outbound流量时要执行对应的埋点逻辑外,每一步的调用要串起来,应用程序其实做了些事情。就是在将请求发给下一个服务时,需要将调用链相关的信息同样传下去,尽管这些Trace和Span的标识并不是它生成的。这样在出流量的Proxy向下一跳服务发起请求前才能判断并生成子Span并和原Span进行关联,进而形成一个完整的调用链。否则,如果在应用容器未处理Header中的Trace,则Sidecar在处理请求时会创建根Span,最终会形成若干个割裂的Span,并不能被关联到一个Trace上,就会出现我们开始提到的问题。

不断被问到两个问题来试图说明这个业务代码配合修改来实现调用链逻辑可能不必要:

问题一:既然传入的请求上已经带了这些Header信息了,直接往下一直传不就好了吗?Sidecar请求APP的时候带着这些Header,APP请求Sidecar时也带着这些Header不就完了吗?

问题二:既然TraceId和SpanId是同一个Sidecar生成的,为什么要再费劲让App收到请求的时候解析下,发出请求时候再带着发出来传回给Sidecar呢?

回答问题一,只需理解一点,这里的App业务代码是处理请求不是转发请求,即图上左边的Request to Productpage 到Productpage中请求就截止了,要怎么处理完全是Productpage的服务接口的内容了,可以是调用本地处理逻辑直接返回,也可以是如示例中的场景构造新的请求调用其他的服务。右边的Request from Productpage 完全是Productpage服务构造的发出的另外一个请求。

回答问题二,需要理解当前Envoy是独立的Listener来处理Inbound和Outbound的请求。Inbound只会处理入的流量并将流量转发到本地的服务实例上。而Outbound就是根据服务发现找到对应的目标服务后端上。除了在一个进程里外两个之间可以说没有任何关系。 另外如问题一描述,因为到Outbound已经是一个新构造的请求了,使得想维护一个map来记录这些Trace信息这种方案也变得不可行。

例子中Productpage访问Reviews的Span详细如下,删减掉一些数据只保留主要信息大致是这样:

  {
        "traceId": "f79a31352fe7cae9",
        "id": "cb4c86fb667f3114",
        "name": "reviews-route",
        "parentId": "f79a31352fe7cae9",
        "timestamp": 1536132571847838,
        "duration": 64849,
        "annotations": [
            {
                "timestamp": 1536132571847838,
                "value": "cs",
                "endpoint": {
                    "serviceName": "productpage",
                    "ipv4": "172.16.0.33"
                }
            },
            {
                "timestamp": 1536132571848121,
                "value": "sr",
                "endpoint": {
                    "serviceName": "reviews",
                    "ipv4": "172.16.0.32"
                }
            },
            {
                "timestamp": 1536132571912403,
                "value": "ss",
                "endpoint": {
                    "serviceName": "reviews",
                    "ipv4": "172.16.0.32"
                }
            },
            {
                "timestamp": 1536132571912687,
                "value": "cr",
                "endpoint": {
                    "serviceName": "productpage",
                    "ipv4": "172.16.0.33"
                }
            }
        ],
        "binaryAnnotations": [
            {
                "key": "http.status_code",
                "value": "200",
                "endpoint": {
                    "serviceName": "reviews",
                    "ipv4": "172.16.0.32"
                }
            },
            {
                "key": "http.url",
                "value": "http://reviews:9080/reviews/0",
                "endpoint": {
                    "serviceName": "productpage",
                    "ipv4": "172.16.0.33"
                }
            },
            {
                "key": "response_size",
                "value": "375",
                "endpoint": {
                    "serviceName": "reviews",
                    "ipv4": "172.16.0.32"
                }
            }     
        ]
    }

Productpage的Proxy上报了个CS,CR, Reviews的那个Proxy上报了个SS,SR。分别表示Productpage作为client什么时候发出请求,什么时候最终收到请求,Reviews的Proxy什么时候收到了客户端的请求,什么时候发出了response。另外还包括这次访问的其他信息如Response Code、Response Size等。

业务代码修改

根据前面的分析我们可以得到结论:埋点逻辑是在Sidecar代理中完成,应用程序不用处理复杂的埋点逻辑,但应用程序需要配合在请求头上传递生成的Trace相关信息。下面抽取服务代码来印证下结论,并看下业务代码到底是怎么修改的。

Python写的 Productpage在服务端处理请求时,先从Request中提取接收到的Header。然后再构造请求调用Details获取服务接口,并将Header转发出去。

首先处理Productpage请问的Restful方法中从Request中提取Trace相关的Header.

@app.route('/productpage')
def front():
product_id = 0 # TODO: replace default value
Headers = getForwardHeaders(Request)
…
detailsStatus, details = getProductDetails(product_id, headers)
reviewsStatus, reviews = getProductReviews(product_id, headers)
return …

def getForwardHeaders(request):
Headers = {}
incoming_Headers = [ 'x-request-id',
'x-b3-traceid',
'x-b3-spanId',
'x-b3-parentspanid',
'x-b3-sampled',
'x-b3-flags',
'x-ot-span-context'
   ]

for ihdr in incoming_Headers:
val = request.headers.get(ihdr)
if val is not None:
headers[ihdr] = val
return headers

然后重新构造一个请求发出去,请求Reviews服务接口。可以看到请求中包含收到的Header。

def getProductReviews(product_id, headers):
url = reviews['name'] + "/" + reviews['endpoint'] + "/" + str(product_id)
res = requests.get(url, headers=Headers, timeout=3.0)

Reviews服务中Java的Rest代码类似,在服务端接收请求时,除了接收Request中的业务参数外,还要提取Header信息,调用Ratings服务时再传递下去。其他的Productpage调用Details,Reviews调用ratings逻辑类似。

@GET
@Path("/reviews/{productId}")
public Response bookReviewsById(@PathParam("productId") int productId,
@HeaderParam("end-user") String user,
@HeaderParam("x-request-id") String xreq,
@HeaderParam("x-b3-traceid") String xTraceId,
@HeaderParam("x-b3-spanid") String xSpanId,
@HeaderParam("x-b3-parentspanid") String xparentSpanId,
@HeaderParam("x-b3-sampled") String xsampled,
@HeaderParam("x-b3-flags") String xflags,
@HeaderParam("x-ot-span-context") String xotSpan)

当然这里只是个demo,示意下要在那个环节修改代码。实际项目中我们不会这样在每个业务方法上作这样的修改,这样对代码的侵入,甚至说污染太严重。根据语言的特点会尽力把这段逻辑提取成一段通用逻辑,只要能在接收和发送请求的地方能机械的forward这几个Trace相关的Header即可。如果需要更多的控制,如在在Span上加特定的Tag,或者在应用代码中代码中对某个服务内部方法的调用进详细跟踪需要构造一个Span,可以使用类似opentracing的对应方法来实现。

社区声明

最近一直在和社区沟通,督促在更显著的位置明确的告诉使用者用Istio作治理并不是所有场景下都不需要修改代码,比如调用链,虽然用户不用业务代码埋点,但还是需要修改些代码。尤其是避免首页“without any change”对大家的误导。得到回应是1.1中社区首页what-is-istio已经修改了这部分说明,不再是1.0中说without any changes in service code,而是改为with few or no code changes in service code。提示大家在使用Isito进行调用链埋点时,应用程序需要进行适当的修改。当然了解了其中原理,做起来也不会太麻烦。


改了个程度轻一点的否定词,很少几乎不用修改,还是基本不用改的意思。这也是社区一贯的观点。

结合对Istio调用链的原理的分析和一个典型例子中细节字段、流程包括代码的额解析,再加上和社区沟通的观点。得到以下结论:

文中内容比较多,有点啰嗦,原理性的东西不感兴趣也可以跳过,只要知道结论“Istio调用链埋点业务代码要少量修改”就可以。

作者介绍

张超盟,华为云服务网格架构师,Istio开源项目贡献者,现负责华为云容器服务Istio产品化工作。从事华为云PaaS平台产品设计研发,在Kubernetes容器服务,微服务架构,云服务目录,大数据,APM,DevOps工具等多个领域有深入研究与实践。