.

.

Istio 最佳实践

介绍用户从 Spring Cloud,Dubbo 等传统微服务框架迁移到 Istio 服务网格时的最佳实践

1 - Sidecar 初始化完成后再启动应用程序

为什么需要配置 Sidecar 和应用程序的启动顺序?

在安装了 Sidecar Proxy 的 Pod 中,应用发出的外部网络请求会被 Iptables 规则重定向到 Proxy 中。如果应用发出请求时 Proxy 还未初始化完成,则 Proxy 无法对请求进行正确路由,导致请求失败。该问题导致的故障现象参见 常见问题-应用程序启动失败/启动时无法访问网络

配置方法 - Istio 1.7 及之后版本

Istio 1.7 及之后的版本中,可以通过下面的方法配置在 Sidecar 初始化完成后再启动应用容器。

全局配置:

在 istio-system/istio ConfigMap 中将 holdApplicationUntilProxyStarts 这个全局配置项设置为 true。

apiVersion: v1
data:
  mesh: |-
    defaultConfig:
      holdApplicationUntilProxyStarts: true    

按 Deployment 配置:

如果不希望该配置全局生效,则可以通过下面的 annotation 在 Deployment 级别进行配置。

  template:
    metadata:
      annotations:
        proxy.istio.io/config: '{ "holdApplicationUntilProxyStarts": true }'

实现原理:在开启 holdApplicationUntilProxyStarts 选项后,Istio Sidecar Injector Webhook 会在 Pod 中插入下面的 yaml 片段。该 yaml 片段在 Sidecar proxy 的 postStart 生命周期时间中执行了 pilot-agent wait 命令。该命令会检测 Proxy 的状态,待 Proxy 初始化完成后再启动 Pod 中的下一个容器。这样,在应用容器启动时,Sidecar proxy 已经完成了配置初始化,可以正确代理应用容器的对外网络请求。

spec:
  containers:
  - name: istio-proxy
    lifecycle:
      postStart:
        exec:
          command:
          - pilot-agent
          - wait

配置方法 - Istio 1.7 之前的版本

Istio 1.7 之前的版本没有直接提供配置 Sidecar 和应用容器启动顺序的能力。由于 Istio 新版本中解决了老版本中的很多故障,建议尽量升级到新版本。如果由于特殊原因还要继续使用 Istio 1.7 之前的版本,可以在应用进程启动时判断 Envoy Sidecar 的初始化状态,待其初始化完成后再启动应用进程。

Envoy 的健康检查接口 localhost:15020/healthz/ready 会在 xDS 配置初始化完成后才返回 200,否则将返回 503,因此可以根据该接口判断 Envoy 的配置初始化状态,待其完成后再启动应用容器。我们可以在应用容器的启动命令中加入调用 Envoy 健康检查的脚本,如下面的配置片段所示。在其他应用中使用时,将 start-awesome-app-cmd 改为容器中的应用启动命令即可。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: awesome-app-deployment
spec:
  selector:
    matchLabels:
      app: awesome-app
  replicas: 1
  template:
    metadata:
      labels:
        app: awesome-app
    spec:
      containers:
      - name: awesome-app
        image: awesome-app
        ports:
        - containerPort: 80
        command: ["/bin/bash", "-c"]
        args: ["while [[ \"$(curl -s -o /dev/null -w ''%{http_code}'' localhost:15020/healthz/ready)\" != '200' ]]; do echo Waiting for Sidecar;sleep 1; done; echo Sidecar available; start-awesome-app-cmd"]

解耦应用服务之间的启动依赖关系

以上配置的思路是控制 Pod 中容器的启动顺序,在 Envoy Sidecar 初始化完成后再启动应用容器,以确保应用容器启动时能够通过网络正常访问其他服务。但即使 Pod 中对外的网络访问没有问题,应用容器依赖的其他服务也可能由于尚未启动,或者某些问题而不能在此时正常提供服务。要彻底解决该问题,建议解耦应用服务之间的启动依赖关系,使应用容器的启动不再强依赖其他服务。

在一个微服务系统中,原单体应用中的各个业务模块被拆分为多个独立进程(服务)。这些服务的启动顺序是随机的,并且服务之间通过不可靠的网络进行通信。微服务多进程部署、跨进程网络通信的特定决定了服务之间的调用出现异常是一个常见的情况。为了应对微服务的该特点,微服务的一个基本的设计原则是 “design for failure”,即需要以优雅的方式应对可能出现的各种异常情况。当在微服务进程中不能访问一个依赖的外部服务时,需要通过重试、降级、超时、断路等策略对异常进行容错处理,以尽可能保证系统的正常运行。

Envoy Sidecar 初始化期间网络暂时不能访问的情况只是放大了微服务系统未能正确处理服务依赖的问题,即使解决了 Envoy Sidecar 的依赖顺序,该问题依然存在。假设应用启动时依赖配置中心,配置中心是一个独立的微服务,当一个依赖配置中心的微服务启动时,配置中心有可能尚未启动,或者尚未初始化完成。在这种情况下,如果在代码中没有对该异常情况进行处理,也会导致依赖配置中心的微服务启动失败。在一个更为复杂的系统中,多个微服务进程之间可能存在网状依赖关系,如果没有按照 “design for failure” 的原则对微服务进行容错处理,那么只是将整个系统启动起来就将是一个巨大的挑战。

2 - 在 Istio 中指定 HTTP Header 大小写

在 Istio 中指定 HTTP Header 大小写

问题背景

Envoy 缺省会把 HTTP Header 的 key 转换为小写,例如有一个 HTTP Header Test-Upper-Case-Header: some-value,经过 Envoy 代理后会变成 test-upper-case-header: some-value。这个在正常情况下没问题,RFC 2616 规范也说明了处理 HTTP Header 应该是大小写不敏感的。

部分场景下,业务请求对某些 Header 字段有大小写要求,此时被 Envoy 转换成为小些会导致请求出现问题。

解决方案

Envoy 支持几种不同的 Header 规则:

  • 全小写(默认规则)
  • 首字母大写

Envoy 1.8 之后新增支持:

  • 保留请求原本样式

基于以上能力,为了解决 Header 默认改为小写的问题在 Istio 1.8 及之前可配置成为首字母大写形式,Istio 1.10 及以后可以配置保留 Header 原有样式。

配置方法

Istio 1.8 之前可添加如下 EnvoyFilter 配置:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: http-header-proper-case-words
  namespace: istio-system
spec:
  configPatches:
    - applyTo: CLUSTER
      match:
        context: SIDECAR_OUTBOUND
        cluster:
          # 集群名称可通过 ConfigDump 查询
          name: "outbound|3000||test2.default.svc.cluster.local"
      patch:
        operation: MERGE
        value:
          http_protocol_options:
            header_key_format:
              proper_case_words: {}

在需要依赖大写 Header 的服务对应的集群中添加规则,将 Header 全部转为首字母大写的形式。

Istio 1.10 及之后可以添加如下 EnvoyFilter 配置:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: http-header-proper-case-words
  namespace: istio-system
spec:
  configPatches:
  # 配置保留发向 upstream 的 request header 大小写
  - applyTo: CLUSTER
    patch:
      operation: MERGE
      value:
        typed_extension_protocol_options:
          envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
            '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
            use_downstream_protocol_config:
              http_protocol_options:
                header_key_format:
                  stateful_formatter:
                    name: preserve_case
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.http.header_formatters.preserve_case.v3.PreserveCaseFormatterConfig
  # 配置保留收到的 response header 大小写
  - applyTo: NETWORK_FILTER
    match:
      listener:
        filterChain:
          filter:
            name: envoy.filters.network.http_connection_manager
    patch:
      operation: MERGE
      value:
        typed_config:
          '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          http_protocol_options:
            header_key_format:
              stateful_formatter:
                name: preserve_case
                typed_config:
                  '@type': type.googleapis.com/envoy.extensions.http.header_formatters.preserve_case.v3.PreserveCaseFormatterConfig

通过此配置可以让 Envoy 保持 Header 原有大小写形式。

Envoy 文档中对此的说明: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/header_casing#config-http-conn-man-header-casing

3 - Sidecar 初始化完成后再启动应用程序

Envoy 内部重定向

Envoy 支持在内部处理 3xx 重定向,捕获可配置的 3xx 重定向响应,合成一个新的请求,将其发送给新路由匹配指定的上游,将重定向的响应作为对原始请求的响应返回。原始请求的 header 和 body 将会发送至新位置。Trailers 尚不支持。

内部重定向可以使用路由配置中的 internal_redirect_policy 字段来配置。 当重定向处理开启,任何来自上游的 3xx 响应,只要匹配到配置的 redirect_response_codes 的响应都将由 Envoy 来处理。

如果 Envoy 内部重定向配置了 303 并且接收到了 303 响应,如果原始请求不是 GET 或者 HEAD,Envoy 将使用没有 body 的 GET 处理重定向。如果原始请求是 GET 或者 HEAD,Envoy 将使用原始的 HTTP Method 处理重定向。更多信息请查看 RFC 7231 Section 6.4.4

要成功地处理重定向,必须通过以下检查:

  1. 响应码匹配到配置的 redirect_response_codes ,默认是 302, 或者其他的 3xx 状态码(301, 302, 303, 307, 308)。
  2. 拥有一个有效的、完全限定的 URL 的 location 头。
  3. 该请求必须已被 Envoy 完全处理。
  4. 请求必须小于 per_request_buffer_limit_bytes 的限制。
  5. allow_cross_scheme_redirect 是 true(默认是 false), 或者下游请求的 scheme 和 location 头一致。
  6. 给定的下游请求之前处理的内部重定向次数不超过请求或重定向请求命中的路由配置的 max_internal_redirects
  7. 所有 predicates 都接受目标路由。

任何失败都将导致重定向传递给下游。

由于重定向请求可能会在不同的路由之间传递,重定向链中的任何满足以下条件的路由都将导致重定向被传递给下游。

  1. 没有启用内部重定向
  2. 或者当重定向链命中的路由的 max_internal_redirects 小于等于重定向链的长度。
  3. 或者路由被 predicates 拒绝。

previous_routesallow_listed_routes 这两个 predicates 可以创建一个有向无环图 (DAG) 来定义一个过滤器链,具体来说,allow_listed_routes 定义的有向无环图(DAG)中各个节点的边,而 previous_routes 定义了边的“访问”状态,因此如果需要就可以避免循环。

第三个 predicate safe_cross_scheme 被用来阻止 HTTP -> HTTPS 的重定向。

一旦重定向通过这些检查,发送到原始上游的请求头将被修改为:

  • 将完全限定的原始请求 URL 放到 x-envoy-original-url 头中。
  • 使用 Location 头中的值替换 Authority/Host、Scheme、Path 头。

修改后的请求头将选择一个新的路由,通过一个新的过滤器链发送,然后把所有正常的 Envoy 请求都发送到上游进行清理。

请注意,HTTP 连接管理器头清理(例如清除不受信任的标头)仅应用一次。即使原始路由和第二个路由相同,每个路由的头修改也将同时应用于原始路由和第二路由,因此请谨慎配置头修改规则, 以避免重复不必要的请求头值。

一个简单的重定向流如下所示:

  1. 客户端发送 GET 请求以获取 http://foo.com/bar
  2. 上游 1 发送 302 响应码并携带 “location: http://baz.com/eep
  3. Envoy 被配置为允许原始路由上重定向,并发送新的 GET 请求到上游 2,携带请求头 “x-envoy-original-url: http://foo.com/bar” 获取 http://baz.com/eep
  4. Envoy 将 http://baz.com/eep 的响应数据代理到客户端,作为对原始请求的响应。

在 Isito 中通过 Envoyfilter 开启内部重定向

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: follow-redirects
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      app: istio-ingressgateway
  configPatches:
  - applyTo: HTTP_ROUTE
    match:
      context: ANY
    patch:
      operation: MERGE
      value:
        route:
          internal_redirect_policy:
            max_internal_redirects: 5
            redirect_response_codes: ["302"]

测试

开启前

curl -i '172.16.0.2/redirect-to?url=http://172.16.0.2/status/200'

HTTP/1.1 302 Found
server: istio-envoy
date: Fri, 11 Mar 2022 07:20:38 GMT
content-type: text/html; charset=utf-8
content-length: 0
location: http://172.16.0.2/status/200
access-control-allow-origin: *
access-control-allow-credentials: true
x-envoy-upstream-service-time: 1

开启后

curl -i '172.16.0.2/redirect-to?url=http://172.16.0.2/status/200'

HTTP/1.1 200 OK
server: istio-envoy
date: Fri, 11 Mar 2022 07:21:03 GMT
content-type: text/html; charset=utf-8
access-control-allow-origin: *
access-control-allow-credentials: true
content-length: 0
x-envoy-upstream-service-time: 0

注意 location 需返回完整 URL,下面这种情况不会触发内部重定向

curl -i '172.16.0.2/status/302'

HTTP/1.1 302 Found
server: istio-envoy
date: Fri, 11 Mar 2022 07:30:38 GMT
location: /redirect/1
access-control-allow-origin: *
access-control-allow-credentials: true
content-length: 0
x-envoy-upstream-service-time: 1

参考资料

4 - 在 Istio 中实现方法级调用跟踪

本文将通过一个网上商店的示例程序介绍如何利用 Spring 和 OpenTracing 简化应用程序的 Tracing 上下文传递,以及如何在 Istio 提供的进程间调用跟踪基础上实现方法级别的细粒度调用跟踪。

分布式调用跟踪和 OpenTracing 规范

什么是分布式调用跟踪?

相比传统的“巨石”应用,微服务的一个主要变化是将应用中的不同模块拆分为了独立的进程。在微服务架构下,原来进程内的方法调用成为了跨进程的 RPC 调用。相对于单一进程的方法调用,跨进程调用的调试和故障分析是非常困难的,很难用传统的调试器或者日志打印来对分布式调用进行查看和分析。  ‘monolith-microserivce.jpg’ 如上图所示,一个来自客户端的请求经过了多个微服务进程。如果要对该请求进行分析,则必须将该请求经过的所有服务的相关信息都收集起来并关联在一起,这就是“分布式调用跟踪”。

什么是 OpenTracing?

CNCF OpenTracing 项目

OpenTracingCNCF(云原生计算基金会)下的一个项目,其中包含了一套分布式调用跟踪的标准规范,各种语言的 API,编程框架和函数库。OpenTracing 的目的是定义一套分布式调用跟踪的标准,以统一各种分布式调用跟踪的实现。目前已有大量支持 OpenTracing 规范的 Tracer 实现,包括 Jager,Skywalking,LightStep 等。在微服务应用中采用 OpenTracing API 实现分布式调用跟踪,可以避免 vendor locking,以最小的代价和任意一个兼容 OpenTracing 的基础设施进行对接。

OpenTracing 概念模型

OpenTracing 的概念模型参见下图:

 ’tracing_mental_model.png’ 图源自 https://opentracing.io/ 如图所示,OpenTracing 中主要包含下述几个概念:

  • Trace: 描述一个分布式系统中的端到端事务,例如来自客户端的一个请求。
  • Span:一个具有名称和时间长度的操作,例如一个 REST 调用或者数据库操作等。Span 是分布式调用跟踪的最小跟踪单位,一个 Trace 由多段 Span 组成。
  • Span context:分布式调用跟踪的上下文信息,包括 Trace id,Span id 以及其它需要传递到下游服务的内容。一个 OpenTracing 的实现需要将 Span context 通过某种序列化机制(Wire Protocol)在进程边界上进行传递,以将不同进程中的 Span 关联到同一个 Trace 上。这些 Wire Protocol 可以是基于文本的,例如 HTTP header,也可以是二进制协议。

OpenTracing 数据模型

一个 Trace 可以看成由多个相互关联的 Span 组成的有向无环图(DAG 图)。下图是一个由 8 个 Span 组成的 Trace:

        [Span A]  ←←←(the root span)
            |
     +------+------+
     |                |
 [Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
     |                  |
 [Span D]      +---+-------+
                   |              |
               [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                                    ↑
                                                    ↑
                                                    ↑
                            (Span G `FollowsFrom` Span F)

上图的 trace 也可以按照时间先后顺序表示如下:

––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time

 [Span A···················································]
   [Span B··············································]
      [Span D··········································]
    [Span C········································]
         [Span E·······]        [Span F··] [Span G··] [Span H··]

Span 的数据结构中包含以下内容:

  • name: Span 所代表的操作名称,例如 REST 接口对应的资源名称。
  • Start timestamp: Span 所代表操作的开始时间
  • Finish timestamp: Span 所代表的操作的的结束时间
  • Tags:一系列标签,每个标签由一个 key value 键值对组成。该标签可以是任何有利于调用分析的信息,例如方法名,URL 等。
  • SpanContext:用于跨进程边界传递 Span 相关信息,在进行传递时需要结合一种序列化协议(Wire Protocol)使用。
  • References:该 Span 引用的其它关联 Span,主要有两种引用关系,Childof 和 FollowsFrom。
    • Childof: 最常用的一种引用关系,表示 Parent Span 和 Child Span 之间存在直接的依赖关系。例 RPC 服务端 Span 和 RPC 客户端 Span,或者数据库 SQL 插入 Span 和 ORM Save 动作 Span 之间的关系。
    • FollowsFrom:如果 Parent Span 并不依赖 Child Span 的执行结果,则可以用 FollowsFrom 表示。例如网上商店购物付款后会向用户发一个邮件通知,但无论邮件通知是否发送成功,都不影响付款成功的状态,这种情况则适用于用 FollowsFrom 表示。

跨进程调用信息传播

SpanContext 是 OpenTracing 中一个让人比较迷惑的概念。在 OpenTracing 的概念模型中提到 SpanContext 用于跨进程边界传递分布式调用的上下文。但实际上 OpenTracing 只定义一个 SpanContext 的抽象接口,该接口封装了分布式调用中一个 Span 的相关上下文内容,包括该 Span 所属的 Trace id,Span id 以及其它需要传递到 downstream 服务的信息。SpanContext 自身并不能实现跨进程的上下文传递,需要由 Tracer(Tracer 是一个遵循 OpenTracing 协议的实现,如 Jaeger,Skywalking 的 Tracer)将 SpanContext 序列化后通过 Wire Protocol 传递到下一个进程中,然后在下一个进程将 SpanContext 反序列化,得到相关的上下文信息,以用于生成 Child Span。

为了为各种具体实现提供最大的灵活性,OpenTracing 只是提出了跨进程传递 SpanContext 的要求,并未规定将 SpanContext 进行序列化并在网络中传递的具体实现方式。各个不同的 Tracer 可以根据自己的情况使用不同的 Wire Protocol 来传递 SpanContext。

在基于 HTTP 协议的分布式调用中,通常会使用 HTTP Header 来传递 SpanContext 的内容。常见的 Wire Protocol 包含 Zipkin 使用的 b3 HTTP header,Jaeger 使用的 uber-trace-id HTTP Header,LightStep 使用的 “x-ot-span-context” HTTP Header 等。Istio/Envoy 支持 b3 header 和 x-ot-span-context header,可以和 Zipkin,Jaeger 及 LightStep 对接。其中 b3 HTTP header 的示例如下:

X-B3-TraceId: 80f198ee56343ba864fe8b2a57d3eff7
X-B3-ParentSpanId: 05e3ac9a4f6e3b90
X-B3-SpanId: e457b5a2e4d86bd1
X-B3-Sampled: 1

Istio 对分布式调用跟踪的支持

Istio/Envoy 为微服务提供了开箱即用的分布式调用跟踪功能。在安装了 Istio 和 Envoy 的微服务系统中,Envoy 会拦截服务的入向和出向请求,为微服务的每个调用请求自动生成调用跟踪数据。通过在服务网格中接入一个分布式跟踪的后端系统,例如 Zipkin 或者 Jaeger,就可以查看一个分布式请求的详细内容,例如该请求经过了哪些服务,调用了哪个 REST 接口,每个 REST 接口所花费的时间等。

需要注意的是,Istio/Envoy 虽然在此过程中完成了大部分工作,但还是要求对应用代码进行少量修改:应用代码中需要将收到的上游 HTTP 请求中的 b3 header 拷贝到其向下游发起的 HTTP 请求的 header 中,以将调用跟踪上下文传递到下游服务。这部分代码不能由 Envoy 代劳,原因是 Envoy 并不清楚其代理的服务中的业务逻辑,无法将入向请求和出向请求按照业务逻辑进行关联。这部分代码量虽然不大,但需要对每一处发起 HTTP 请求的代码都进行修改,非常繁琐而且容易遗漏。当然,可以将发起 HTTP 请求的代码封装为一个代码库来供业务模块使用,来简化该工作。

下面以一个简单的网上商店示例程序来展示 Istio 如何提供分布式调用跟踪。该示例程序由 eshop,inventory,billing,delivery 几个微服务组成,结构如下图所示:  ’eshop-demo.jpg’ eshop 微服务接收来自客户端的请求,然后调用 inventory,billing,delivery 这几个后端微服务的 REST 接口来实现用户购买商品的 checkout 业务逻辑。本例的代码可以从 github 下载:https://github.com/aeraki-framework/method-level-tracing-with-istio

如下面的代码所示,我们需要在 eshop 微服务的应用代码中传递 b3 HTTP Header。

 @RequestMapping(value = "/checkout")
public String checkout(@RequestHeader HttpHeaders headers) {
    String result = "";
    // Use HTTP GET in this demo. In a real world use case,We should use HTTP POST
    // instead.
    // The three services are bundled in one jar for simplicity. To make it work,
    // define three services in Kubernets.
    result += restTemplate.exchange("http://inventory:8080/createOrder", HttpMethod.GET,
            new HttpEntity<>(passTracingHeader(headers)), String.class).getBody();
    result += "<BR>";
    result += restTemplate.exchange("http://billing:8080/payment", HttpMethod.GET,
            new HttpEntity<>(passTracingHeader(headers)), String.class).getBody();
    result += "<BR>";
    result += restTemplate.exchange("http://delivery:8080/arrangeDelivery", HttpMethod.GET,
            new HttpEntity<>(passTracingHeader(headers)), String.class).getBody();
    return result;
}
private HttpHeaders passTracingHeader(HttpHeaders headers) {
    HttpHeaders tracingHeaders = new HttpHeaders();
    extractHeader(headers, tracingHeaders, "x-request-id");
    extractHeader(headers, tracingHeaders, "x-b3-traceid");
    extractHeader(headers, tracingHeaders, "x-b3-spanid");
    extractHeader(headers, tracingHeaders, "x-b3-parentspanid");
    extractHeader(headers, tracingHeaders, "x-b3-sampled");
    extractHeader(headers, tracingHeaders, "x-b3-flags");
    extractHeader(headers, tracingHeaders, "x-ot-span-context");
    return tracingHeaders;
}

下面我们来测试一下 eshop 实例程序。我们可以自己搭建一个 Kubernetes 集群并安装 Istio 以用于测试。这里为了方便,直接使用腾讯云上提供的全托管的服务网格 TCM,并在创建的 Mesh 中加入了一个容器服务 TKE 集群来进行测试。

在 TKE 集群中部署该程序,查看 Istio 分布式调用跟踪的效果。

git clone git@github.com:aeraki-framework/method-level-tracing-with-istio.git
cd method-level-tracing-with-istio
git checkout without-opentracing
kubectl apply -f k8s/eshop.yaml
  • 在浏览器中打开地址:http://${INGRESS_EXTERNAL_IP}/checkout ,以触发调用 eshop 示例程序的 REST 接口。
  • 在浏览器中打开 TCM 的界面,查看生成的分布式调用跟踪信息。

TCM 图形界面直观地展示了这次调用的详细信息,可以看到客户端请求从 Ingressgateway 进入到系统中,然后调用了 eshop 微服务的 checkout 接口,checkout 调用有三个 child span,分别对应到 inventory,billing 和 delivery 三个微服务的 REST 接口。  &lsquo;Screen Shot 2021-04-01 at 10.32.48 AM.png&rsquo;

使用 OpenTracing 来传递分布式跟踪上下文

OpenTracing 提供了基于 Spring 的代码埋点,因此我们可以使用 OpenTracing Spring 框架来提供 HTTP header 的传递,以避免这部分硬编码工作。在 Spring 中采用 OpenTracing 来传递分布式跟踪上下文非常简单,只需要下述两个步骤:

  • 在 Maven POM 文件中声明相关的依赖,一是对 OpenTracing Spring Cloud Starter 的依赖;另外由于 Istio 采用了 Zipkin 的上报接口,我们也需要引入 Zipkin 的相关依赖。
  • 在 Spring Application 中声明一个 Tracer bean。如下所示,注意我们需要把 Istio 中的 Zipkin 上报地址设置到 OKHttpSernder 中。
@Bean
	public io.opentracing.Tracer zipkinTracer() {
		String zipkinEndpoint = System.getenv("ZIPKIN_ENDPOINT");
		if (zipkinEndpoint == null || zipkinEndpoint == ""){
			zipkinEndpoint = "http://zipkin.istio-system:9411/api/v2/spans";
		}

		OkHttpSender sender = OkHttpSender.create(zipkinEndpoint);
		Reporter spanReporter = AsyncReporter.create(sender);

		Tracing braveTracing = Tracing.newBuilder()
				.localServiceName("my-service")
				.propagationFactory(B3Propagation.FACTORY)
				.spanReporter(spanReporter)
				.build();

		Tracing braveTracer = Tracing.newBuilder()
				.localServiceName("spring-boot")
				.spanReporter(spanReporter)
				.propagationFactory(B3Propagation.FACTORY)
				.traceId128Bit(true)
				.sampler(Sampler.ALWAYS_SAMPLE)
				.build();
		return BraveTracer.create(braveTracer);
	}

部署采用 OpenTracing 进行 HTTP header 传递的程序版本,其调用跟踪信息如下所示:  &lsquo;Screen Shot 2021-04-01 at 11.15.53 AM.png&rsquo; 从上图中可以看到,相比在应用代码中直接传递 HTTP header 的方式,采用 OpenTracing 进行代码埋点后,相同的调用增加了 7 个名称前缀为 spring-boot 的 Span,这 7 个 Span 是由 OpenTracing 的 tracer 生成的。虽然我们并没有在代码中显示创建这些 Span,但 OpenTracing 的代码埋点会自动为每一个 REST 请求生成一个 Span,并根据调用关系关联起来。

OpenTracing 生成的这些 Span 为我们提供了更详细的分布式调用跟踪信息,从这些信息中可以分析出一个 HTTP 调用从客户端应用代码发起请求,到经过客户端的 Envoy,再到服务端的 Envoy,最后到服务端接受到请求各个步骤的耗时情况。从图中可以看到,Envoy 转发的耗时在 1 毫秒左右,相对于业务代码的处理时长非常短,对这个应用而言,Envoy 的处理和转发对于业务请求的处理效率基本没有影响。

在 Istio 调用跟踪链中加入方法级的调用跟踪信息

Istio/Envoy 提供了跨服务边界的调用链信息,在大部分情况下,服务粒度的调用链信息对于系统性能和故障分析已经足够。但对于某些服务,需要采用更细粒度的调用信息来进行分析,例如一个 REST 请求内部的业务逻辑和数据库访问分别的耗时情况。在这种情况下,我们需要在服务代码中进行埋点,并将服务代码中上报的调用跟踪数据和 Envoy 生成的调用跟踪数据进行关联,以统一呈现 Envoy 和服务代码中生成的调用数据。

在方法中增加调用跟踪的代码是类似的,因此我们用 AOP + Annotation 的方式实现,以简化代码。 首先定义一个 Traced 注解和对应的 AOP 实现逻辑:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface Traced {
}
@Aspect
@Component
public class TracingAspect {
    @Autowired
    Tracer tracer;

    @Around("@annotation(com.zhaohuabing.demo.instrument.Traced)")
    public Object aroundAdvice(ProceedingJoinPoint jp) throws Throwable {
        String class_name = jp.getTarget().getClass().getName();
        String method_name = jp.getSignature().getName();
        Span span = tracer.buildSpan(class_name + "." + method_name).withTag("class", class_name)
                .withTag("method", method_name).start();
        Object result = jp.proceed();
        span.finish();
        return result;
    }
}

然后在需要进行调用跟踪的方法上加上 Traced 注解:

@Component
public class DBAccess {

    @Traced
    public void save2db() {
        try {
            Thread.sleep((long) (Math.random() * 100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
@Component
public class BankTransaction {
    @Traced
    public void transfer() {
        try {
            Thread.sleep((long) (Math.random() * 100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

demo 程序的 master branch 已经加入了方法级代码跟踪,可以直接部署。

git checkout master
kubectl apply -f k8s/eshop.yaml

效果如下图所示,可以看到 trace 中增加了 transfer 和 save2db 两个方法级的 Span。  &lsquo;Screen Shot 2021-04-01 at 11.04.03 AM.png&rsquo; 可以打开一个方法的 Span,查看详细信息,包括 Java 类名和调用的方法名等,在 AOP 代码中还可以根据需要添加出现异常时的异常堆栈等信息。  &lsquo;Screen Shot 2021-04-01 at 11.07.22 AM.png&rsquo;

总结

Istio/Envoy 为微服务应用提供了分布式调用跟踪功能,提高了服务调用的可见性。我们可以使用 OpenTracing 来代替应用硬编码,以传递分布式跟踪的相关 http header;还可以通过 OpenTracing 将方法级的调用信息加入到 Istio/Envoy 缺省提供的调用链跟踪信息中,以提供更细粒度的调用跟踪信息。

下一步

除了同步调用之外,异步消息也是微服务架构中常见的一种通信方式。在下一篇文章中,我将继续利用 eshop demo 程序来探讨如何通过 OpenTracing 将 Kafka 异步消息也纳入到 Istio 的分布式调用跟踪中。

参考资料

  1. 本文中 eshop 示例程序的源代码
  2. Opentracing docs
  3. Opentracing specification
  4. Opentracing wire protocols
  5. Istio Trace context propagation
  6. Zipkin-b3-propagation
  7. OpenTracing Project Deep Dive

5 - 在 Istio 中实现异步消息调用跟踪

在实际项目中,除了同步调用之外,异步消息也是微服务架构中常见的一种通信方式。在本篇文章中,我将继续利用 eshop demo 程序来探讨如何通过 OpenTracing 将 Kafka 异步消息也纳入到 Istio 的分布式调用跟踪中。

eshop 示例程序结构

如下图所示,demo 程序中增加了发送和接收 Kafka 消息的代码。eshop 微服务在调用 inventory,billing,delivery 服务后,发送了一个 kafka 消息通知,consumer 接收到通知后调用 notification 服务的 REST 接口向用户发送购买成功的邮件通知。  &rsquo;eshop-demo.jpg&rsquo;

将 Kafka 消息处理加入调用链跟踪

植入 Kafka OpenTracing 代码

首先从 github 下载代码。

git clone git@github.com:aeraki-framework/method-level-tracing-with-istio.git

可以直接使用该代码,但建议跟随下面的步骤查看相关的代码,以了解各个步骤背后的原理。

根目录下分为了 rest-service 和 kafka-consumer 两个目录,rest-service 下包含了各个 REST 服务的代码,kafka-consumer 下是 Kafka 消息消费者的代码。

首先需要将 spring kafka 和 OpenTracing kafka 的依赖加入到两个目录下的 pom 文件中。

<dependency>
	<groupId>org.springframework.kafka</groupId>
	<artifactId>spring-kafka</artifactId>
</dependency>
 <dependency>
	<groupId>io.opentracing.contrib</groupId>
	<artifactId>opentracing-kafka-client</artifactId>
	<version>${version.opentracing.kafka-client}</version>
</dependency>

在 rest-service 目录中的 KafkaConfig.java 中配置消息 Producer 端的 OpenTracing Instrument。TracingProducerInterceptor 会在发送 Kafka 消息时生成发送端的 Span。

@Bean
public ProducerFactory<String, String> producerFactory() {
    Map<String, Object> configProps = new HashMap<>();
    configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress);
    configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
    configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
    configProps.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, TracingProducerInterceptor.class.getName());
    return new DefaultKafkaProducerFactory<>(configProps);
}

在 kafka-consumer 目录中的 KafkaConfig.java 中配置消息 Consumer 端的 OpenTracing Instrument。TracingConsumerInterceptor 会在接收到 Kafka 消息是生成接收端的 Span。

@Bean
public ConsumerFactory<String, String> consumerFactory() {
    Map<String, Object> props = new HashMap<>();
    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress);
    props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG, TracingConsumerInterceptor.class.getName());
    return new DefaultKafkaConsumerFactory<>(props);
}

只需要这两步即可完成 Spring 程序的 Kafka OpenTracing 代码植入。下面安装并运行示例程序查看效果。

安装 Kafka 集群

示例程序中使用到了 Kafka 消息,因此我们在 TKE 集群中部署一个简单的 Kafka 实例:

cd method-level-tracing-with-istio
kubectl apply -f k8s/kafka.yaml

部署 demo 应用

修改 Kubernetes yaml 部署文件 k8s/eshop.yaml,设置 Kafka bootstrap server,以用于 demo 程序连接到 Kafka 集群中。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: delivery
  ......
    spec:
      containers:
      - name: eshop
        image: aeraki/istio-opentracing-demo:latest
        ports:
        - containerPort: 8080
        env:
          ....
          //在这里加入 Kafka server 地址
          - name: KAFKA_BOOTSTRAP_SERVERS
            value: "kafka-service:9092"

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kafka-consumer
  ......
    spec:
      containers:
      - name: kafka-consumer
        image: aeraki/istio-opentracing-demo-kafka-consumer:latest
        env:
          ....
          //在这里加入 Kafka server 地址
          - name: KAFKA_BOOTSTRAP_SERVERS
            value: "kafka-service:9092"

然后部署应用程序,相关的镜像可以直接从 dockerhub 下载,也可以通过源码编译生成。

kubectl apply -f k8s/eshop.yaml

在浏览器中打开地址:http://${INGRESS_EXTERNAL_IP}/checkout ,以触发调用 eshop 示例程序的 REST 接口。然后打开 TCM 的界面查看生成的分布式调用跟踪信息。  &lsquo;Screen Shot 2021-04-01 at 2.43.06 PM.png&rsquo;

从图中可以看到,在调用链中增加了两个 Span,分布对应于Kafka消息发送和接收的两个操作。由于Kafka消息的处理是异步的,消息发送端不直接依赖接收端的处理。根据 OpenTracing 对引用关系的定义,From_eshop_topic Span 对 To_eshop_topic Span 的引用关系是 FOLLOWS_FROM 而不是 CHILD_OF 关系。

将调用跟踪上下文从Kafka传递到REST服务

现在 eshop 代码中已经加入了 REST 和 Kafka 的 OpenTracing Instrumentation,可以在进行 REST 调用和发送 Kafka 消息时生成调用跟踪信息。但如果需要从 Kafka 的消息消费者的处理方法中调用一个 REST 接口呢?

我们会发现在 eshop 示例程序中,缺省生成的调用链里面并不会把 Kafka 消费者的 Span 和其发起的调用 notification 服务的 REST 请求的 Span 关联在同一个 Trace 中。

要分析导致该问题的原因,我们首先需要了解“Active Span”的概念。在 OpenTracing 中,一个线程可以有一个 Active Span,该 Active Span 代表了目前该线程正在执行的工作。在调用 Tracer.buildSpan() 方法创建新的 Span 时,如果 Tracer 目前存在一个 Active Span,则会将该 Active Span 缺省作为新创建的 Span 的 Parent Span。

Tracer.buildSpan 方法的说明如下:

Tracer.SpanBuilder buildSpan(String operationName)
Return a new SpanBuilder for a Span with the given `operationName`.
You can override the operationName later via BaseSpan.setOperationName(String).

A contrived example:


   Tracer tracer = ...

   // Note: if there is a `tracer.activeSpan()`, it will be used as the target of an implicit CHILD_OF
   // Reference for "workSpan" when `startActive()` is invoked.
   // 如果存在 active span,则其创建的新 Span 会隐式地创建一个 CHILD_OF 引用到该 active span
   try (ActiveSpan workSpan = tracer.buildSpan("DoWork").startActive()) {
       workSpan.setTag("...", "...");
       // etc, etc
   }

   // 也可以通过 asChildOf 方法指定新创建的 Span 的 Parent Span
   // It's also possible to create Spans manually, bypassing the ActiveSpanSource activation.
   Span http = tracer.buildSpan("HandleHTTPRequest")
                     .asChildOf(rpcSpanContext)  // an explicit parent
                     .withTag("user_agent", req.UserAgent)
                     .withTag("lucky_number", 42)
                     .startManual();

分析 Kafka OpenTracing Instrumentation 的代码,会发现 TracingConsumerInterceptor 在调用 Kafka 消费者的处理方法之前已经把消费者的 Span 结束了,因此发起 REST 调用时 tracer 没有 active span,不会将 Kafka 消费者的 Span 作为后面 REST 调用的 parent span。

public static <K, V> void buildAndFinishChildSpan(ConsumerRecord<K, V> record, Tracer tracer,
      BiFunction<String, ConsumerRecord, String> consumerSpanNameProvider) {
    SpanContext parentContext = TracingKafkaUtils.extractSpanContext(record.headers(), tracer);

    String consumerOper =
        FROM_PREFIX + record.topic(); // <====== It provides better readability in the UI
    Tracer.SpanBuilder spanBuilder = tracer
        .buildSpan(consumerSpanNameProvider.apply(consumerOper, record))
        .withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_CONSUMER);

    if (parentContext != null) {
      spanBuilder.addReference(References.FOLLOWS_FROM, parentContext);
    }

    Span span = spanBuilder.start();
    SpanDecorator.onResponse(record, span);

    //在调用消费者的处理方法之前,该 Span 已经被结束。
    span.finish();

    // Inject created span context into record headers for extraction by client to continue span chain
    //这个 Span 被放到了 Kafka 消息的 header 中
    TracingKafkaUtils.inject(span.context(), record.headers(), tracer);
  }

此时 TracingConsumerInterceptor 已经将 Kafka 消费者的 Span 放到了 Kafka 消息的 header 中,因此从 Kafka 消息头中取出该 Span,显示地将 Kafka 消费者的 Span 作为 REST 调用的 Parent Span 即可。

为MessageConsumer.java使用的RestTemplate设置一个TracingKafka2RestTemplateInterceptor。

@KafkaListener(topics = "eshop-topic")
public void receiveMessage(ConsumerRecord<String, String> record) {
    restTemplate
            .setInterceptors(Collections.singletonList(new TracingKafka2RestTemplateInterceptor(record.headers())));
    restTemplate.getForEntity("http://notification:8080/sendEmail", String.class);
}

TracingKafka2RestTemplateInterceptor 是基于 Spring OpenTracing Instrumentation 的 TracingRestTemplateInterceptor 修改的,将从 Kafka header 中取出的 Span 设置为出向请求的 Span 的 Parent Span。

@Override
public ClientHttpResponse intercept(HttpRequest httpRequest, byte[] body, ClientHttpRequestExecution xecution)
        throws IOException {
    ClientHttpResponse httpResponse;
    SpanContext parentSpanContext = TracingKafkaUtils.extractSpanContext(headers, tracer);
    Span span = tracer.buildSpan(httpRequest.getMethod().toString()).asChildOf(parentSpanContext)
            .withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_CLIENT).start();
    ......
}

在浏览器中打开地址:http://${INGRESS_EXTERNAL_IP}/checkout ,以触发调用 eshop 示例程序的 REST 接口。然后打开 TCM 的界面查看生成的分布式调用跟踪信息。  &lsquo;WeChatWorkScreenshot_487c2202-4960-48be-b6f6-33fbec457cf8 copy.png&rsquo;

从上图可以看到,调用链中出现了 Kafka 消费者调用 notification 服务的 sendEmail REST 接口的 Span。从图中可以看到,由于调用链经过了 Kafka 消息,sendEmail Span 的时间没有包含在 checkout Span 中。

总结

Istio 服务网格通过分布式调用跟踪来提高微服务应用的可见性,这需要在应用程序中通过 HTTP header 传递调用跟踪的上下文。对于 JAVA 应用程序,我们可以使用 OpenTracing Instrumentation 来代替应用编码传递分布式跟踪的相关 http header,以减少对业务代码的影响;我们还可以将方法级的调用跟踪和 Kafka 消息的调用跟踪加入到 Istio 生成的调用跟踪链中,以为应用程序的故障定位提供更为丰富详细的调用跟踪信息。

参考资料

  1. 本文中 eshop 示例程序的源代码