利用 Envoy Proxy 为 Kotlin Multiplatform 与 tRPC 构建统一的零侵入可观测性架构


在采用 Kotlin Multiplatform 构建移动端,并以后端 tRPC 微服务集群提供类型安全接口的架构中,一个核心的非功能性需求浮出水面:如何实现一套从客户端到后端深层服务的、统一且完整的分布式追踪体系。业务代码的快速迭代要求我们不能让可观测性的实现侵入到每个业务逻辑函数中,否则追踪上下文的传递将成为开发人员挥之不去的噩梦,并带来巨大的维护成本。

问题的本质是,我们需要一个机制,能在不修改或极少修改业务代码的前提下,自动完成追踪ID的生成、传递与上报。

方案A:应用层手动埋点与上下文传递

这是最直观的方案。其核心思路是在应用代码中显式地处理追踪逻辑。

  1. KMP 客户端: 在 Ktor HTTP Client 中创建一个 Plugin,在每个发出的请求中检查是否存在追踪上下文。如果不存在,则生成一个新的 traceId,并将其注入到 HTTP Header (例如 X-Trace-ID) 中。
  2. tRPC 服务端: 创建一个 tRPC 中间件 (Middleware)。这个中间件会在每个请求被处理前执行,它负责从请求头中解析出 X-Trace-ID。然后,将这个 traceId 存入一个请求级别的上下文中(例如 Node.js 的 AsyncLocalStorage)。后续的所有日志打印、数据库查询、对下游服务的调用,都必须从这个上下文中手动读取 traceId 并继续向下传递。

我们来看一下这种方案在代码层面的体感。

KMP 客户端 (伪代码):

// TracingPlugin.kt
// 注意:这是“不推荐”的方案示例
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import java.util.UUID

val TracingPlugin = createClientPlugin("TracingPlugin") {
    onRequest { request, _ ->
        // 这是一个极度简化的实现,实际生产中需要处理更复杂的上下文传播
        if (request.headers["X-Trace-ID"] == null) {
            val traceId = UUID.randomUUID().toString()
            println("CLIENT: Generated new Trace ID: $traceId")
            request.header("X-Trace-ID", traceId)
        }
    }
}

// ApiService.kt
val client = HttpClient(CIO) {
    install(TracingPlugin)
    // ... 其他配置
}

suspend fun fetchUserData(userId: String) {
    // 业务代码本身看似干净,但其行为依赖于一个全局安装的、有副作用的插件
    val response = client.get("/users/$userId")
    // ...
}

tRPC 服务端 (伪代码):

// tracingMiddleware.ts
// 同样,这是“不推荐”的方案示例
import { initTRPC } from '@trpc/server';
import { AsyncLocalStorage } from 'async_hooks';

interface RequestContext {
  traceId?: string;
}

export const als = new AsyncLocalStorage<RequestContext>();

const t = initTRPC.context<RequestContext>().create();

export const tracingMiddleware = t.middleware(async ({ ctx, next, req }) => {
  const traceId = req.headers['x-trace-id'] as string | undefined;

  // 这里的核心问题是,我们污染了业务上下文(ctx)
  // 并且需要手动将 traceId 塞进异步上下文中
  return als.run({ traceId }, () => {
    console.log(`SERVER: Received request with Trace ID: ${traceId}`);
    return next({
      ctx: {
        ...ctx,
        traceId, // 污染了业务上下文
      },
    });
  });
});

const protectedProcedure = t.procedure.use(tracingMiddleware);

// logger.ts
// 日志模块必须意识到 AsyncLocalStorage 的存在
import { als } from './tracingMiddleware';

export function log(message: string) {
  const store = als.getStore();
  const traceId = store?.traceId || 'unknown';
  console.log(`[${traceId}] ${message}`);
}

// DownstreamApiClient.ts
// 调用下游服务时,必须手动传递 traceId
import { als } from './tracingMiddleware';
import fetch from 'node-fetch';

export async function callAnotherService() {
    const store = als.getStore();
    const headers = {
        'Content-Type': 'application/json',
        'X-Trace-ID': store?.traceId || '',
    };
    log("Calling downstream service...");
    await fetch('http://downstream-service/api', { headers });
}

方案A的优劣分析:

  • 优点:
    • 逻辑简单直白,不依赖任何外部基础设施组件。
    • 对于极小型的单体应用或许可行。
  • 缺点:
    • 强侵入性: observability 的逻辑与业务逻辑紧密耦合。每个开发者都需要理解并遵守这个约定。
    • 易出错: 忘记在某个下游调用中传递 traceId 会导致追踪链中断。日志库、数据库驱动等都需要进行改造以适配 AsyncLocalStorage
    • 维护噩梦: 随着微服务数量的增加,保证所有服务都遵循完全相同的上下文传递规范,成本极高。一个服务的实现稍有偏差,就会影响整个调用链。在真实项目中,这种方案是不可持续的。

方案B:基于 Envoy Proxy 的基础设施层透明注入

这个方案将可观测性的责任从应用层下沉到基础设施层。应用本身不需要感知分布式追踪的细节,这一切都由部署在应用旁边的 Envoy Sidecar Proxy 透明地完成。

graph TD
    subgraph Mobile Client
        KMP_App[Kotlin Multiplatform App]
    end

    subgraph K8s Cluster / VM Host 1
        subgraph Service A Pod
            Envoy_A[Envoy Sidecar]
            TRPC_A[tRPC Service A]
        end
    end
    
    subgraph K8s Cluster / VM Host 2
        subgraph Service B Pod
            Envoy_B[Envoy Sidecar]
            TRPC_B[tRPC Service B]
        end
    end

    subgraph Observability Backend
        Jaeger[Jaeger / Zipkin]
    end

    KMP_App -- HTTP Request --> Edge_Gateway[Edge Envoy Gateway]
    Edge_Gateway -- "1. 生成 Trace Headers (x-request-id, x-b3-traceid等)" --> Envoy_A
    Envoy_A -- "2. 附加 Headers 并转发" --> TRPC_A
    TRPC_A -- "3. 读取 Headers 用于日志" --> TRPC_A
    TRPC_A -- "4. 调用下游服务 (HTTP Request)" --> Envoy_A
    Envoy_A -- "5. 自动注入 Trace Headers" --> Envoy_B
    Envoy_B -- "6. 附加 Headers 并转发" --> TRPC_B
    
    Envoy_A -- "异步上报 Span" --> Jaeger
    Envoy_B -- "异步上报 Span" --> Jaeger
    Edge_Gateway -- "异步上报 Span" --> Jaeger

工作流解析:

  1. KMP 客户端发出普通 HTTP 请求,不包含任何追踪信息。
  2. 请求首先到达集群入口的 Edge Envoy Gateway。此 Gateway 配置了追踪功能,它会检查请求头。如果不存在追踪头,它会生成一个,例如 x-request-id 和 B3 Propagation 格式的头 (x-b3-traceid, x-b3-spanid 等)。
  3. Edge Envoy 将带有追踪头的请求转发给上游服务 A 的 Sidecar Envoy (Envoy_A)。
  4. Envoy_A 接收到请求,识别出追踪头,然后将请求原封不动地转发给同一 Pod 内的 tRPC Service A 进程。
  5. tRPC Service A 的代码中,有一个极简的中间件。它唯一的作用就是从请求头中读取 x-b3-traceid 等信息,并将其放入 AsyncLocalStorage注意:它不负责生成或传递,只负责读取。
  6. tRPC Service A 需要调用下游服务 B 时,它会发出一个普通的 HTTP 请求到 http://service-b。这个请求被 Pod 的网络规则拦截,并被重定向到 Envoy_A
  7. Envoy_A 拦截了这个出站请求,它会自动将当前 Span 的追踪信息注入到这个出站请求的 Header 中,然后才将其发往 Envoy_B
  8. 这个过程在整个调用链中不断重复。每个 Envoy Sidecar 在请求出入时都会记录 Span 信息,并异步上报给后端的追踪系统(如 Jaeger 或 Zipkin)。

方案B的优劣分析:

  • 优点:
    • 零侵入/低侵入: 业务开发者几乎不需要关心分布式追踪的实现。他们编写的代码和单体应用没有区别。
    • 语言无关: 无论你的微服务是用 Node.js, Go, Rust 还是 Java 编写,只要它们通过 HTTP 通信,这套机制就完全适用。
    • 集中管理: 所有的追踪策略、采样率、上报地址等都在 Envoy 的配置中集中管理,可以通过 IaC (Infrastructure as Code) 工具进行版本控制和部署。
    • 功能强大: 除了追踪,Envoy 还能透明地提供服务发现、负载均衡、熔断、重试、mTLS 加密等高级功能。
  • 缺点:
    • 运维复杂度: 引入了 Envoy 作为关键组件,需要对服务网格和 Envoy 本身有深入的理解和运维能力。
    • 性能开销: Sidecar 模式会增加额外的网络跳数,带来微秒到毫秒级的延迟。在真实项目中,这个开销通常是可接受的,但需要在性能敏感场景下进行压测评估。

决策与理由

对于一个追求长期可维护性和技术治理的微服务体系而言,方案B是毋庸置疑的选择。方案A的短期便利性会被长期的技术债和沟通成本所吞噬。在拥有几十个甚至上百个微服务的环境中,依赖开发者纪律来保证可观测性的完整性是一种不切实际的幻想。

将可观测性下沉到基础设施层,是云原生时代的一个核心理念。它将通用问题(如网络、安全、可观测性)与业务问题进行了解耦,让业务开发团队能更专注于业务价值的创造。

核心实现概览

1. Envoy 配置文件 (envoy-config.yaml)

这是一个生产级的 Envoy 配置示例,用于 tRPC 服务的 Sidecar。它包含了监听入站流量、路由到本地应用、配置追踪以及处理出站流量的功能。

# envoy-config.yaml
# 用于微服务 Sidecar 的 Envoy 配置
static_resources:
  listeners:
    # 1. 入站流量监听器 (Inbound Listener)
    # 监听从其他服务或 Gateway 过来的流量
    - name: inbound_listener
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 15006 # 使用 Istio 默认的 Inbound 端口
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: inbound_http
                
                # 关键:配置追踪
                tracing:
                  provider:
                    name: envoy.tracers.zipkin
                    typed_config:
                      "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig
                      collector_cluster: zipkin # 指向后端的 Zipkin/Jaeger 服务
                      collector_endpoint: "/api/v2/spans"
                      collector_endpoint_version: HTTP_JSON
                      shared_span_context: false # 保证每个服务生成自己的 Span
                
                # 生成请求ID,如果上游没有提供的话
                generate_request_id: true
                
                # HTTP 过滤器链
                http_filters:
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                
                # 路由配置:所有入站流量都转发到本地的 tRPC 应用端口
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: local_service
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route:
                            cluster: local_service_cluster
                            # 在这里可以设置超时和重试策略
                            timeout: 5s

    # 2. 出站流量监听器 (Outbound Listener)
    # 拦截从本应用发往其他服务的流量
    - name: outbound_listener
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 15001 # 使用 Istio 默认的 Outbound 端口
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: outbound_http
                # 为出站流量也启用追踪
                tracing:
                  provider:
                    name: envoy.tracers.zipkin
                    typed_config:
                      "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig
                      collector_cluster: zipkin
                      collector_endpoint: "/api/v2/spans"
                      collector_endpoint_version: HTTP_JSON
                
                http_filters:
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                
                # 这里通常由服务发现机制动态生成,静态配置用于演示
                route_config:
                  name: outbound_routes
                  virtual_hosts:
                    # 路由到下游服务 "service-b"
                    - name: service_b_vh
                      domains: ["service-b", "service-b:80"] # 拦截应用对 service-b 的访问
                      routes:
                        - match: { prefix: "/" }
                          route: { cluster: service_b_cluster }

  clusters:
    # 指向本地 tRPC 应用的 Cluster
    - name: local_service_cluster
      connect_timeout: 1s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: local_service_cluster
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: 127.0.0.1
                      port_value: 3000 # tRPC 应用监听的端口

    # 指向 Zipkin/Jaeger Collector 的 Cluster
    - name: zipkin
      connect_timeout: 1s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: zipkin
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      # 在 k8s 中,这里应该是 jaeger-collector.observability.svc.cluster.local
                      address: jaeger-collector
                      port_value: 9411

    # 指向下游服务 "service-b" 的 Cluster
    - name: service_b_cluster
      connect_timeout: 1s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: service_b_cluster
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      # 在 k8s 中,这里应该是 service-b.default.svc.cluster.local
                      address: service-b.default.svc.cluster.local
                      port_value: 80 # service-b 的服务端口

这里的关键在于 tracing 配置块。它告诉 Envoy 拦截的流量需要参与追踪,并指定了上报的后端 (collector_cluster)。generate_request_id: true 确保了即使入口流量没有追踪头,Envoy 也能成为追踪链的发起者。

2. tRPC 服务端的低侵入式改造

tRPC 服务端的代码现在变得极其整洁。唯一需要做的就是创建一个中间件来读取 Envoy 注入的头,以便在日志中打印正确的 traceId。

// server/context.ts
import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
import { AsyncLocalStorage } from 'async_hooks';

// 定义我们的请求级别上下文结构
export interface RequestContext {
  traceId?: string;
  spanId?: string;
}

// 创建 AsyncLocalStorage 实例
export const als = new AsyncLocalStorage<RequestContext>();

// tRPC 上下文创建函数
export const createContext = ({ req, res }: CreateExpressContextOptions) => {
  // 从 Envoy 注入的 Header 中读取追踪信息
  // B3 Propagation Headers
  const traceId = req.headers['x-b3-traceid'] as string | undefined;
  const spanId = req.headers['x-b3-spanid'] as string | undefined;

  // 这里不返回 context,因为我们将使用 AsyncLocalStorage 进行传递
  // 这避免了污染业务函数的 ctx 参数
  return {
    traceId,
    spanId,
    req,
    res,
  };
};

// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { RequestContext, als, createContext } from './context';

const t = initTRPC.context<typeof createContext>().create();

// 这个中间件的核心职责:将上下文信息放入 ALS
const contextInjectorMiddleware = t.middleware(async ({ ctx, next }) => {
  const store: RequestContext = {
    traceId: ctx.traceId,
    spanId: ctx.spanId,
  };
  return als.run(store, () => next());
});

export const publicProcedure = t.procedure.use(contextInjectorMiddleware);
export const router = t.router;

// server/logger.ts
// 日志模块现在可以透明地获取 traceId
import { als } from './context';
import pino from 'pino';

const pinoLogger = pino();

// 生产级的 Logger,自动附加追踪信息
export const logger = {
  info: (message: string, context?: object) => {
    const store = als.getStore();
    pinoLogger.info({ ...store, ...context }, message);
  },
  error: (message: string, error?: Error, context?: object) => {
    const store = als.getStore();
    pinoLogger.error({ ...store, ...context, err: error }, message);
  }
};


// server/routers/user.ts
import { publicProcedure, router } from '../trpc';
import { z } from 'zod';
import { logger } from '../logger';
import fetch from 'node-fetch'; // 或任何 HTTP Client

export const userRouter = router({
  getById: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      logger.info(`Fetching user data for user ID: ${input}`);
      
      // 当我们调用下游服务时,代码非常干净
      // 我们不需要手动添加任何 Header
      // 请求会经由 Envoy Sidecar,自动注入追踪上下文
      try {
        const response = await fetch('http://service-b/profile', {
            method: 'POST',
            body: JSON.stringify({ userId: input }),
            headers: {'Content-Type': 'application/json'}
        });
        
        if (!response.ok) {
            logger.error(`Failed to fetch profile from service-b`, new Error(response.statusText));
            return null;
        }
        
        const profile = await response.json();
        logger.info(`Successfully fetched profile for user ID: ${input}`);
        return { id: input, name: 'Test User', profile };
      } catch (error) {
        logger.error(`Error calling service-b`, error as Error);
        throw new Error('Failed to contact downstream service');
      }
    }),
});

对比方案A,这里的业务代码 (userRouter) 干净得就像在写单体应用。开发者完全不需要关心 traceId 是如何传来和传走的。日志系统也能自动、可靠地关联上正确的追踪ID。

3. Kotlin Multiplatform 客户端

客户端的代码完全不需要任何特殊改动。它就像调用一个普通的 REST API 一样。

// shared/src/commonMain/kotlin/com/example/api/ApiClient.kt
package com.example.api

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class UserProfile(val someData: String)

@Serializable
data class User(
    val id: String,
    val name: String,
    val profile: UserProfile?
)

class ApiClient {
    private val httpClient = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
    }

    private val baseUrl = "http://your-edge-envoy-gateway.com"

    // 业务代码完全不知道追踪的存在
    suspend fun getUser(userId: String): User? {
        // Ktor 将会把 "user.getById" 编码成 /user.getById?input=... 的形式
        // tRPC 的 HTTP 适配器能够正确解析它
        return try {
            val response = httpClient.get("$baseUrl/user.getById") {
                parameter("input", "{\"json\":\"$userId\"}") // 简化演示 tRPC URL 编码
            }
            response.body<User>()
        } catch (e: Exception) {
            // 在真实项目中,这里需要更精细的错误处理
            println("Error fetching user: ${e.message}")
            null
        }
    }
}

客户端代码的纯粹性是这个架构最大的优点之一。移动端开发者可以完全聚焦于 UI 和业务逻辑,而无需分心于分布式系统的复杂性。

架构的扩展性与局限性

当前方案的局限性在于,Envoy 提供的追踪能力主要集中在网络层面,即服务到服务之间的调用。它无法自动感知服务内部的函数调用或数据库查询。例如,在 tRPC 的 getById 方法内部,如果有一个复杂的计算函数 calculateUserScore(),Envoy 无法为这个函数自动创建一个 Span。要实现这种更细粒度的追踪,仍然需要应用内部的 APM SDK(例如 OpenTelemetry SDK)的帮助。

然而,即便引入了内部 APM,基于 Envoy 的架构依然价值巨大。Envoy 已经完美地解决了最棘手的跨服务上下文传播问题。应用内部的 APM SDK 可以轻松地从 Envoy 注入的 Header 中恢复追踪上下文,并以此为父节点创建内部的子 Span,从而形成一个从客户端请求、跨服务网络调用到服务内部函数执行的完整、无缝的调用链。

未来的演进方向可以是,将这套机制与一个完整的服务网格控制平面(如 Istio)结合,从而获得动态配置下发、mTLS 自动加密、基于请求内容的智能路由、故障注入等更高级的能力,而所有这些对业务代码依然保持透明。


  目录