Skip to content

第37讲:如何实现追踪服务性能指标

在一个微服务架构的应用中,微服务的数量可能很大,尤其是对于复杂的业务系统来说,微服务的数量可能成百上千。面对如此数量的微服务,相关的运维变得很困难,其中,最典型的两个问题是性能调优和错误调试。因此,本课时我们来一起了解 Istio 提供的服务性能指标和调用追踪功能。

服务性能指标 指的是记录每个服务在运行时的性能指标数据,包括访问次数和响应时间等;调用追踪指的是对于每个请求,记录该请求在不同服务之间的调用关系。一个业务场景通常由多个微服务协作来完成,而即便是在相同的业务场景下,不同的请求所实际调用的服务也可能不同。比如在支付订单时,用户选择用账户余额支付或微信支付,所调用的服务是不同的。

服务性能指标和调用追踪两个功能相辅相成。在系统的运行过程中,通过监控服务性能指标可以发现系统中的异常情况。比如某个服务的响应时间突然出现了很大的延迟,具体的原因可能来自服务自身,也可能是由于来自调用者的请求变多。这些情况有时候是正常的,比如外部用户的访问量变大,造成整个系统的负载变大;有些情况是异常的,比如由于调用者出现了 Bug,造成了调用请求过多。为了定位问题,就需要查看并比对所有关联服务在同一时间段之内的性能指标。

服务追踪的一个典型应用场景是客户支持。当接收到用户报告的异常情况时,需要能够查看该用户请求的实际调用流程,来定位问题所在。比如,用户报告没有收到订单的产品,问题可能出在发货服务本身,也可能是上游的服务并没有调用发货服务的 API。通过服务追踪,查看请求的实际调用追踪记录,可以很清楚地知道应该从哪里查找问题。

性能指标数据

Istio 的服务代理已经提供了一些性能指标数据,其在这基础上添加对 Prometheus 和 Grafana 的集成。Prometheus 用来收集性能指标数据,而 Grafana 用来提供图形化界面进行展示。

预置的 Prometheus 和 Grafana 组件

Istio 默认的安装概要文件中已经包含了 Prometheus,而 Grafana 只在概要文件 demo 中启用。通过下面的命令可以确保 Prometheus 和 Grafana 都启用。

java
istioctl install --set addonComponents.prometheus.enabled=true --set addonComponents.grafana.enabled=true

Istio 提供的 Prometheus 和 Grafana 组件已经预先进行了配置,Prometheus 会自动抓取服务代理的性能指标数据。我们可以使用下面的命令打开 Prometheus 的界面。

java
istioctl dashboard prometheus

在打开的 Prometheus 界面的目标页面中,可以看到 Prometheus 收集的数据来源,如下图所示。这些数据来源通过 Kubernetes 上的自动发现机制来查找。

在这些来源中,Istio 的性能指标数据由 Envoy 来提供,对应与上图中的 envoy-stats 列表,在服务代理的边车容器中,访问 15090 端口上的 /stats/prometheus 路径,可以获取到 Prometheus 格式的数据。

Istio 的 Grafana 组件预置了一些仪表盘来查看服务网格的状态。通过下面的命令可以打开 Grafana 界面。

java
istioctl dashboard grafana

Istio 性能指标

除了 Envoy 自身提供的数据之外,Istio 也提供了一些抽象层次比较高的性能指标数据,这些性能指标的名称都以 istio 作为前缀。

下表给出了与 HTTP、HTTP/2 和 gRPC 相关的性能指标。

名称类型说明
istio_requests_total计数器处理的请求数量
istio_request_duration_milliseconds分布式概要请求的处理时间
istio_request_bytes分布式概要请求内容的大小
istio_response_bytes分布式概要响应内容的大小

下表给出了与 TCP 相关的性能指标。

名称类型说明
istio_tcp_sent_bytes_total计数器发送的字节总数
istio_tcp_received_bytes_total计数器接收的字节总数
istio_tcp_connections_opened_total计数器打开的连接总数
istio_tcp_connections_closed_total计数器关闭的连接总数

每个性能指标都包含了一系列的标签,可以对数据进行过滤。下表给出了一些常用标签的说明。

标签说明
source_canonical_service发送请求的服务的名称
destination_canonical_service接收请求的服务的名称
request_protocol请求的协议
response_code响应的状态码
source_principal发送请求的主体

性能分析

下面通过一个示例场景来说明性能指标的用法。乘客界面的 GraphQL API 在获取乘客信息时,需要同时访问乘客管理服务和地址管理服务的 API。当在入口网关检测到 GraphQL 的查询请求延迟过高时,可以通过性能指标 istio_request_duration_milliseconds 来查找问题。

在下图中,通过 Grafana 显示了从 GraphQL 服务中发送到乘客管理服务和地址管理服务的请求的处理时间。图中的绿线表示的是乘客管理服务的处理时间,而黄线表示的是地址管理服务的处理时间。从图中可以看到,同一时间段内,地址管理服务的处理时间远高于乘客管理服务,说明该服务的内部可能有问题。这个时候可以对地址管理服务进行水平扩展,也可以等待熔断器来触发。

Envoy 性能指标

Istio 自身的性能指标比较有限,一些底层的数据由 Envoy 提供,其所提供的性能指标数据非常多。这些性能指标的名称以英文句点的形式分隔成多个部分,对应于 Envoy 中的不同组件。在 Prometheus 中,Envoy 相关的性能指标都以 envoy_ 开头,可以通过 Prometheus 的界面来查看。

虽然性能指标在运维中很有价值,但是收集过多的数据也会带来性能上的开销。Istio 默认对 Envoy 进行了配置,只收集部分数据,性能指标是否收集根据字符串匹配来完成。

对于一个 Pod,可以通过下面的命令来查看性能指标的匹配模式。

java
istioctl proxy-config bootstrap <pod_name> | jq ".bootstrap.statsConfig.statsMatcher.inclusionList"

下面的代码是 Istio 的默认匹配模式,以前缀的形式来匹配。

json
{
  "patterns": [
    {
      "prefix": "reporter="
    },
    {
      "prefix": "cluster_manager"
    },
    {
      "prefix": "listener_manager"
    },
    {
      "prefix": "http_mixer_filter"
    },
    {
      "prefix": "tcp_mixer_filter"
    },
    {
      "prefix": "server"
    },
    {
      "prefix": "cluster.xds-grpc"
    },
    {
      "prefix": "wasm"
    },
    {
      "prefix": "component"
    }
  ]
}

如果默认的配置不能满足需求,可以进行调整,调整的方式是在 Kubernetes 部署中通过注解 sidecar.istio.io/statsInclusionPrefixes 来指定新的前缀列表。

如果需要添加应用自定义的性能指标数据,请参考第 28 课时的相关介绍。

服务追踪

分布式追踪是分布式系统中的一个常见问题,在微服务架构中也是必不可少的一部分。分布式追踪相关的开源和商用产品非常多,虽然在具体的实现存在一些差异,但基本的概念是相似的。

基本概念

分布式追踪中最基本的概念是痕迹(Trace)和跨度(Span)。痕迹是操作的历史轨迹,通常与一个业务行为相对应,也可以是任意感兴趣的动作。痕迹由相互嵌套的跨度组成,跨度表示的是痕迹中的单个操作。

同一个痕迹中的不同跨度之间可能存在引用关系。最典型的引用关系是父子关系,也就是父跨度所对应操作的结果,依赖于子跨度所对应操作的结果。这种父子关系可以与编程语言中的方法调用形成的调用栈进行类比。需要被追踪的方法是整个痕迹的入口。每当这个方法调用其他方法时,会创建新的子跨度。这种方式递归下去,就形成了完整的痕迹。

为了能够记录完整的痕迹,在记录属于同一痕迹的不同跨度时,需要传递与痕迹相关的上下文对象。该上下文对象的作用是把可能产生在不同系统中的跨度串联起来,组织在同一个痕迹中。该上下文对象的具体格式,与使用的分布式追踪系统相关。

为了解决不同追踪系统的互操作问题,W3C 发布了痕迹上下文(Trace Context)推荐规范。根据该规范,该上下文对象应该包含下表中的两个字段:

名称说明
traceparent当前的跨度在整个痕迹中的位置
tracestate供应商特有的附加数据,以名值对的方式组织

traceparent 字段由下表中的 4 个字段组成:

字段格式说明
version1 字节版本号
trace-id16 字节痕迹标识符
parent-id8 字节父跨度标识符
trace-flags8 比特痕迹的标志位

在进行传输时,traceparent 中的 4 个字段会首先以 16 进制来编码,然后再以" - "来连接。

在 REST API 的请求中,上下文对象中的这两个字段会以 HTTP 头的形式来传递。下面的代码给出了这两个头的值的示例。

html
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
tracestate: test=hello

在实际的开发中,可能需要根据分布式追踪实现的不同,来使用不同的上下文对象格式。比如,Zipkin 使用的是以 x-b3 开头的 HTTP 头。这些实现的差异性,可以通过第三方库来屏蔽,大部分时候并不需要了解底层的实现细节。

Istio 服务追踪

服务代理可以拦截对服务的调用请求。如果需要追踪服务之间的调用情况,服务代理可以在接收到请求之后,自动创建出相应的跨度,并发送到追踪服务器。Istio 提供了对一些追踪服务的支持,包括 Zipkin、Jaeger 和 Lightstep 等。

在启用了 tracing 组件之后,Istio 会运行 Jaeger 来作为分布式追踪实现,并配置服务代理来发送追踪数据。通过下面的命令可以打开 Jaeger 的界面。

html
istioctl dashboard jaeger

应用服务追踪

为了启用服务追踪,每个服务本身的代码也需要进行修改。做的修改取决于服务在整个追踪过程中的参与程度。

第一种参与方式是只进行简单的上下文传播,不添加新的跨度,这种情况下,只需要传递上下文对象即可。接收到的请求中已经包含了与跨度上下文相关的 HTTP 头,当该服务调用其他微服务的 API 时,需要把同样的 HTTP 头传递过去。这样才可以保证追踪痕迹不中断。

第二种参与方式是添加新的跨度。当需要追踪一个服务内部的处理流程时,可以使用追踪实现的客户端来添加新的跨度。在调用其他微服务的 API 时,需要传递的是新的跨度上下文,其中痕迹的标识符保持不变,但是父跨度标识符会变成新创建的跨度。

下面以乘客管理的 GraphQL API 为例来分别说明这两种参与方式的用法,使用的 Jaeger 客户端库。

我们需要添加的是对 GraphQL 查询的追踪。通过乘客界面的 GraphQL API 发送下面代码中的查询到 Istio 的入口网关。

java
query allPassengers {
  passengers {
    id
    name
    email
    userAddresses {
      name
      address {
        addressLine
        lat
        lng
      }
    }
  }
}

从这个查询中可以看出,该请求所涉及的服务之间的调用关系路径如下图所示。

当在 Jaeger 的界面搜索痕迹时,会发现每一个查询请求,实际上对应的是 3 条痕迹,每条痕迹中只包含两个跨度,如下图所示。这是因为 Istio 的服务代理在记录跨度时,只知道当前请求的来源和目的地。在没有跨度上下文把这些服务调用串联起来的情况下,每个服务调用都会被当成独立的痕迹。

如果在 GraphQL API 服务内部不记录额外的跨度时,只需要把 HTTP 请求中与跨度上下文相关的一些头记录下来,在调用乘客管理服务和地址管理服务的 API 时,把这些 HTTP 头添加进去即可。

从实现上来说,单纯地转发 HTTP 头并不需要第三方库的支持。我们可以在 Servlet 过滤器中把 HTTP 头的值记录下来,在使用 HTTP 客户端调用其他微服务的 REST API 时,把记录下来的这些 HTTP 头添加到 HTTP 请求中即可。这种做法实现起来简单,不过维护性比较差,更好的做法是使用第三方库。示例应用使用的是 OpenTracing 的 API,以及该 API 的 Jaeger 实现。

完整的实现分成三个部分,分别是跨度上下文的抽取、跨度的创建以及跨度上下文的注入,其中前后两个部分是必须的,中间跨度的创建是可选的。

第一步部分指的是从 HTTP 请求头中抽取跨度上下文的信息。OpenTracing 库 opentracing-web-servlet-filter 提供了一个 Servlet 过滤器实现来抽取跨度上下文信息,只需要注册该过滤器即可。

在下面的代码中,使用 Jaeger 客户端库创建了一个新的 OpenTracing API 中的 Tracer 对象,并注册了一个 TracingFilter 类型的过滤器。

java
@Configuration
public class TracingConfig {
  @Bean
  public Tracer tracer() {
    return io.jaegertracing.Configuration.fromEnv().getTracer();
  }
  @Bean
  public FilterRegistrationBean<TracingFilter> tracingFilter(Tracer tracer) {
    return new FilterRegistrationBean<>(new TracingFilter(tracer));
  }
}

GraphQL API 服务使用的是从 OpenAPI 文档中自动生成的 Java 客户端来访问另外两个微服务的 API,该 Java 客户端使用 OkHttp 来调用 REST API。为了在发送 REST API 请求时添加追踪相关的 HTTP 头,我们使用 OkHttp 中的拦截器。

在下面的代码中,ApiTracingInterceptor 用来拦截 OkHttp 客户端发出的 HTTP 请求,并在请求中添加相关的 HTTP 头。在 intercept 方法中,首先使用 Tracer 对象的 activeSpan 方法来获取到当前活动的跨度对象。如果存在活动的跨度对象,则通过 Tracer 对象的 inject 方法来把跨度上下文以 HTTP 头的形式注入 HTTP 请求中。通过这样的方式,所有对外的 HTTP 请求都会自动传播跨度上下文。

java
public class ApiTracingInterceptor implements Interceptor {
  private final Tracer tracer;
  public ApiTracingInterceptor(final Tracer tracer) {
    this.tracer = tracer;
  }
  @Override
  public Response intercept(final Chain chain) throws IOException {
    final Builder builder = chain.request().newBuilder();
    if (this.tracer.activeSpan() != null) {
      this.tracer.inject(this.tracer.activeSpan().context(),
          Format.Builtin.HTTP_HEADERS,
          new InjectOnlyTextMap(builder));
    }
    return chain.proceed(builder.build());
  }
  private static class InjectOnlyTextMap implements TextMap {
    private final Builder builder;
    public InjectOnlyTextMap(final Builder builder) {
      this.builder = builder;
    }
    @Override
    public Iterator<Entry<String, String>> iterator() {
      throw new UnsupportedOperationException("Only context injection is supported");
    }
    @Override
    public void put(final String key, final String value) {
      this.builder.addHeader(key, value);
    }
  }
}

下一步我们需要修改 API 客户端使用的 OkHttpClient 对象来启用拦截器。在下面的代码中,updateHttpClient 方法用来获取到更新之后的 OkHttpClient 对象,除了添加 ApiTracingInterceptor 拦截器之外,还配置了 OkHttpClient 使用新的 Dispatcher 对象。该 Dispatcher 对象使用的是 TracedExecutorService 作为 ExecutorService 的实现。这样做的目的是确保在异步调用时,不会丢失跨度上下文,因为跨度上下文保存在 ThreadLocal 对象中。TracedExecutorService 实现来自 opentracing-concurrent 库。

java
@Configuration
@EnableConfigurationProperties(ServiceDestinationConfig.class)
public class ApiServiceConfig {
  @Autowired
  ServiceDestinationConfig config;
  @Bean
  public PassengerApi passengerApi(Tracer tracer) {
    PassengerApi passengerApi = new PassengerApi();
    passengerApi.getApiClient().setHttpClient(
        this.updateHttpClient(tracer,
            passengerApi.getApiClient().getHttpClient()));
    passengerApi.getApiClient().setBasePath(this.config.getPassenger());
    return passengerApi;
  }
  @Bean
  public AddressApi addressApi(Tracer tracer) {
    AddressApi addressApi = new AddressApi();
    addressApi.getApiClient().setHttpClient(
        this.updateHttpClient(tracer,
            addressApi.getApiClient().getHttpClient()));
    addressApi.getApiClient().setBasePath(this.config.getAddress());
    return addressApi;
  }
  private OkHttpClient updateHttpClient(Tracer tracer,
      OkHttpClient httpClient) {
    return httpClient.newBuilder()
        .dispatcher(new Dispatcher(
            new TracedExecutorService(httpClient.dispatcher().executorService(),
                tracer)))
        .addInterceptor(new ApiTracingInterceptor(tracer)).build();
  }
}

完成上述两步之后,整个痕迹就被串联起来,如下图所示。每个 GraphQL 查询请求只对应于一条痕迹。

下面介绍如何通过 OpenTracing API 来添加额外的跨度。在下面的代码中,getAddress 方法用来获取到乘客的详细地址信息。在实现中,首先从当前的 GraphQL 执行上下文中获取到 Tracer 对象,然后使用 Tracer 对象的 buildSpan 方法来创建名为 getAddress 的跨度,在创建时可以提供标签来作为附加数据。Tracer 对象的 activateSpan 方法把新创建的 Span 对象作为当前的活动跨度,这就意味着该 Span 对象会作为之后创建的 Span 对象的父跨度。由于获取地址信息是异步操作,我们在返回的 CompletableFuture 对象完成之后,通过 Span 对象的 finish 方法来结束跨度。

java
public class UserAddress {
  private String id;
  private String name;
  private String addressId;
  public CompletableFuture<AddressVO> getAddress(
      DataFetchingEnvironment environment) {
    GraphQLServletContext context = environment.getContext();
    Tracer tracer = (Tracer) context.getHttpServletRequest().getServletContext()
        .getAttribute(Tracer.class.getName());
    Span span = tracer.buildSpan("getAddress")
        .withTag("userAddressId", this.id)
        .withTag("addressId", this.addressId)
        .start();
    try (Scope ignored = tracer.activateSpan(span)) {
      return context.getDataLoaderRegistry()
          .map(
              registry -> registry.
                  <String, AddressVO>getDataLoader(USER_ADDRESS_DATA_LOADER)
                  .load(this.addressId))
          .orElse(CompletableFuture
              .completedFuture(AddressVO.nullObject(this.addressId)))
          .whenComplete((addressVO, throwable) -> span.finish());
    }
  }
}

下图展示了添加自定义的跨度之后的痕迹,从中可以看到对 getAddress 方法的调用。

总结

性能指标数据和服务追踪是微服务架构中两个很重要的功能,在开发和运维中都起着重要的作用。通过本课时的学习,你可以了解到如何使用 Istio 提供的 Prometheus 和 Grafana 组件来查看应用的性能指标,还可以了解到如何为应用添加分布式追踪的功能,包括在服务内部添加自定义的追踪信息。