构建基于 Kong 与 SkyWalking 的 AI 推理服务全链路可观测性及 Argo CD 声明式部署


一套用于金融风控场景的 TensorFlow 推理服务,其 P99 延迟目标是 50ms。当监控系统报告一次请求耗时 150ms 时,问题可能出在哪里?是入口的 Kong 网关插件执行缓慢,是负责特征提取的 Java 中台服务发生了 GC,还是 TensorFlow Serving 模型本身在特定输入下计算量剧增?如果无法将这次请求在各个组件中的耗时精确关联,故障排查就是一场灾难。单纯依赖各个组件独立的日志和指标系统,会形成数据孤岛,无法形成有效的因果链。

我们面临的挑战是,在一个异构的技术栈中实现端到端的请求追踪:从 Nginx/LuaJIT 驱动的 Kong 网关,到 JVM 上的 Java 特征工程服务,再到 C++ 核心的 TensorFlow Serving。本文记录了构建这套全链路可观测性平台的架构决策与核心实现,并阐述了如何通过 Argo CD 对这一复杂系统进行声明式、自动化的部署与管理。

定义问题:异构系统中的可观测性黑洞

我们的核心业务流程如下:

  1. 客户端请求携带交易数据,首先到达 Kong API 网关。
  2. Kong 上的鉴权和路由插件处理后,将请求转发给一个名为 feature-extractor 的 Java 服务。
  3. feature-extractor 服务会查询多个数据源(如 Redis、DB),聚合生成模型所需的特征向量。
  4. 该服务通过 gRPC 调用后端的 tf-serving 实例,执行模型推理。
  5. 推理结果沿原路返回给客户端。

这个链路中的技术栈差异巨大,导致了可观测性上的断层:

  • Kong: 基于 OpenResty,其行为由 Lua 脚本定义。传统的 APM 工具难以深入其内部。
  • Feature-Extractor: Spring Boot 应用,可以使用 SkyWalking Java Agent 进行无侵入埋点,这是最成熟的一环。
  • TensorFlow Serving: 一个高性能的 C++ 程序,它没有内置的 OpenTelemetry 或 SkyWalking 支持。如何将追踪上下文传入并生成有效的 Span,是最大的技术难题。

目标是明确的:在 SkyWalking UI 上,能够看到一个完整的火焰图,清晰展示单次请求在 Kong、feature-extractor 以及 tf-serving 中每一个环节的耗时。

方案权衡:组件各自为政 vs. 统一追踪协议

方案 A:利用各组件原生监控,后期聚合分析

这种方案的思路是,充分利用每个组件自带或社区成熟的监控能力。

  • Kong: 开启 Prometheus 插件,暴露请求延迟、状态码等指标。通过 Log-shipping 方案收集 access log。
  • Feature-Extractor: 同样接入 Prometheus 暴露业务指标和 JVM 指标,并使用 SkyWalking Java Agent 进行内部方法级的追踪。
  • TensorFlow Serving: 暴露原生的 Prometheus 指标端点,监控模型推理延迟、QPS 等。

优势:

  • 实现简单,学习成本低,每个组件都有成熟的方案。
  • 对现有系统侵入性小。

劣势:

  • 无法关联: 这是致命缺陷。当 Kong 的 Prometheus 报告 P99 延迟为 150ms,而 TF Serving 报告 P99 延迟为 40ms 时,我们无法确定这 150ms 的请求是否就是由那 40ms 的推理导致的。数据之间没有相关性。
  • 排障效率低下: 工程师需要在多个监控系统(Prometheus, Grafana, SkyWalking, ELK)之间跳转,凭借经验进行猜测,无法形成确定的证据链。
  • 无法定位偶发问题: 对于长尾请求,这种方案几乎无能为力。

在真实生产环境中,这种“各自为政”的监控体系在系统稳定时看起来很美好,一旦出现复杂问题,就会暴露出其脆弱性。对于我们这种对延迟极其敏感的金融风控业务,该方案直接被否决。

方案 B:实施端到端分布式追踪

该方案的核心思想是,在请求的生命周期内,始终维系一个唯一的追踪ID(Trace ID),并在跨越服务边界时,通过协议头传递追踪上下文。

  • 协议标准: 选择 W3C Trace Context 或 SkyWalking v3 Header Protocol (sw8) 作为跨服务传递追踪上下文的标准。我们选择 sw8,因为它与我们的 APM 后端 SkyWalking 原生兼容。
  • 入口点改造: 在 Kong 层,需要一个自定义插件来作为追踪的起点。如果请求头中没有 sw8,就生成一个新的 Trace ID;如果存在,则解析它并创建子 Span。然后,将新的 sw8 头注入到向后游服务转发的请求中。
  • 中间服务: Java 服务通过 SkyWalking Agent 自动处理 sw8 头的接收和发送,无需代码改动。
  • 终端改造 (TensorFlow Serving): 这是最棘手的一环。我们需要一个机制来“代表” TF Serving 参与到追踪链路中。

优势:

  • 强因果关联: 所有日志、指标、追踪数据都可以通过 Trace ID 关联起来,形成完整的调用链。
  • 快速定位瓶颈: 火焰图能直观地展示每个环节的耗时,问题定位从小时级缩短到分钟级。
  • 深度洞察: 可以分析特定用户、特定交易类型的请求全链路行为。

劣劣势:

  • 实现复杂度高: 需要为 Kong 开发自定义 Lua 插件,并为 TensorFlow Serving 设计非侵入式的追踪方案。
  • 轻微性能开销: 追踪探针、上下文的序列化和反序列化会带来微小的性能损耗。

最终决策: 尽管方案 B 的实现更复杂,但它提供的高价值可观测性是保障我们 SLA 的基石。在真实项目中,前期投入在可观测性基础设施上的时间,会在后续的运维和故障排查中得到百倍的回报。我们选择方案 B,并决定通过 GitOps 的方式来管理这套复杂系统的部署,以降低维护成本。

核心实现概览

我们通过四个关键步骤来实现这套体系。

graph TD
    subgraph "GitOps Management (Argo CD)"
        A[User] --> |HTTPS Request| B(Kong API Gateway);
        B --> |Injects sw8 header| C(Feature Extractor Service);
        C --> |Forwards sw8 header| D(Observability Sidecar);
        D --> |gRPC| E(TensorFlow Serving);
    end

    subgraph "Observability Backend"
        B -- "Span via HTTP" --> F{SkyWalking OAP};
        C -- "Span via gRPC" --> F;
        D -- "Span via gRPC" --> F;
    end

    G[Git Repository] -.-> |Declarative Config| H(Argo CD Controller);
    H -- "Syncs State" --> I[Kubernetes Cluster];

1. Kong 自定义插件:追踪的起点

我们需要一个 Lua 插件,它在 access 阶段运行,处理追踪头的生成与传递。

-- file: kong/plugins/skywalking-tracer/handler.lua

local http = require "resty.http"
local cjson = require "cjson"
local uuid = require "resty.jit-uuid"
local string_format = require "string".format
local base64 = require "ngx.base64"

local SkywalkingTracerHandler = {}

SkywalkingTracerHandler.PRIORITY = 1001
SkywalkingTracerHandler.VERSION = "0.1.0"

-- base64编码,但移除padding '='
local function base64_encode_nopad(str)
  if not str then return "" end
  local b64 = base64.encode_uri(str)
  return b64
end

-- 从请求头中提取或生成一个新的 sw8 上下文
local function get_or_create_sw8_context(request_headers)
  local sw8 = request_headers["sw8"]
  if sw8 then
    return sw8, false -- 返回已存在的上下文,标记为非入口
  end

  -- 如果没有sw8头,则创建一个新的Trace
  -- 格式: 1-{traceId}-{segmentId}-{spanId}-{parentService}-{parentInstance}-{parentEndpoint}-{addressUsedAtClient}
  local trace_id = base64_encode_nopad(uuid())
  local segment_id = base64_encode_nopad(uuid())
  local span_id = 0
  local service_name = "Kong_Gateway"
  local instance_name = ngx.var.hostname or "unknown-kong-instance"
  local endpoint_name = ngx.var.request_uri

  local new_sw8 = string_format("1-%s-%s-%d-%s-%s-%s-%s",
    trace_id,
    segment_id,
    span_id,
    base64_encode_nopad(service_name),
    base64_encode_nopad(instance_name),
    base64_encode_nopad(endpoint_name),
    base64_encode_nopad(ngx.var.remote_addr .. ":" .. ngx.var.remote_port)
  )

  return new_sw8, true -- 返回新创建的上下文,标记为入口
end

function SkywalkingTracerHandler:access(conf)
  local sw8_context, is_entry_span = get_or_create_sw8_context(ngx.req.get_headers())
  
  -- 将上下文保存到ngx.ctx中,以便在log阶段使用
  ngx.ctx.skywalking_context = {
    start_time = ngx.now() * 1000,
    sw8 = sw8_context,
    is_entry = is_entry_span,
    endpoint = ngx.var.request_uri,
    service_name = conf.service_name,
    instance_name = conf.instance_name,
    oap_host = conf.oap_collector_host,
    oap_port = conf.oap_collector_port
  }

  -- 将sw8头注入到上游请求中
  ngx.req.set_header("sw8", sw8_context)
end

function SkywalkingTracerHandler:log(conf)
  local ctx = ngx.ctx.skywalking_context
  if not ctx then return end

  -- 在log阶段,请求已经完成,可以计算耗时并发送span
  local end_time = ngx.now() * 1000
  local duration = end_time - ctx.start_time

  -- 解析 sw8 头
  local parts = {}
  for part in string.gmatch(ctx.sw8, "[^%-]+") do
    table.insert(parts, part)
  end
  local trace_id = parts[2]
  local segment_id = parts[3]
  local parent_span_id = tonumber(parts[4])

  local span = {
    traceId = trace_id,
    segmentId = segment_id,
    spanId = ctx.is_entry and 0 or parent_span_id, -- 入口spanId为0
    parentSpanId = ctx.is_entry and -1 or parent_span_id - 1, -- 伪造一个parent,仅为示例
    operationName = ctx.endpoint,
    startTime = math.floor(ctx.start_time),
    endTime = math.floor(end_time),
    componentId = 49, -- Kong component ID in SkyWalking
    spanLayer = "Http",
    isError = ngx.var.status >= 500,
    tags = {
      { key = "http.method", value = ngx.req.get_method() },
      { key = "http.status_code", value = tostring(ngx.var.status) },
      { key = "url", value = ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.request_uri },
    }
  }
  
  -- 这是一个简化的实现。在生产环境中,应该使用cosocket以非阻塞方式发送
  -- span数据到SkyWalking OAP的HTTP endpoint (e.g., /v3/segments)
  -- 为避免复杂化,这里仅打印JSON
  -- ngx.log(ngx.ERR, "Reporting Span: ", cjson.encode({span}))
  
  -- 实际发送逻辑会是这样:
  -- local httpc = http.new()
  -- local res, err = httpc:request_uri(ctx.oap_host .. ":" .. ctx.oap_port .. "/v3/segments", {
  --   method = "POST",
  --   headers = { ["Content-Type"] = "application/json" },
  --   body = cjson.encode({span})
  -- })
  -- if not res or res.status ~= 200 then
  --   ngx.log(ngx.ERR, "Failed to report skywalking trace: ", err)
  -- end
end

return SkywalkingTracerHandler

这个插件的核心逻辑是:

  1. access阶段检查sw8头,没有则创建。
  2. sw8头注入到向上游服务的请求中,实现上下文传递。
  3. log阶段(请求已完成),收集耗时、状态等信息,构造一个Span,并通过HTTP发送给SkyWalking OAP后端。在真实项目中,这里的发送逻辑必须是异步非阻塞的,以避免影响请求处理性能。

2. 特征提取服务:Java Agent 的威力

这一步最简单。对于我们的feature-extractor Spring Boot应用,我们只需要在启动时挂载SkyWalking Java Agent即可。

Dockerfile中的启动命令:

# ...
ENV JAVA_TOOL_OPTIONS="-javaagent:/opt/skywalking/agent/skywalking-agent.jar"
ENV SW_AGENT_NAME=feature-extractor
ENV SW_AGENT_COLLECTOR_BACKEND_SERVICES=skywalking-oap.o11y.svc:11800
# ...
CMD ["java", "org.springframework.boot.loader.JarLauncher"]

Agent会自动拦截所有进出的HTTP和gRPC请求,解析传入的sw8头,创建子Span,并在调用下游服务时继续传递sw8头。这是最理想的“零代码”侵入方案。

3. TensorFlow Serving: Sidecar 模式破局

直接修改TensorFlow Serving的C++源码来集成SkyWalking C++ SDK是极其复杂的,且会与TF Serving的版本强绑定。一个更务实的方案是使用Sidecar模式。

我们为tf-serving的每个Pod注入一个轻量级的Go语言编写的代理Sidecar。feature-extractor不再直接调用tf-serving,而是调用这个Sidecar。

Sidecar的职责:

  1. 监听一个gRPC端口(如8502)。
  2. 接收来自feature-extractor的请求,并从gRPC的metadata中读取sw8头。
  3. 使用SkyWalking Go Agent go2sky,根据sw8头创建一个”Exit Span”,代表对TF Serving的调用。
  4. 将请求(不含追踪头)转发给同一Pod内容器网络中的tf-serving实例(监听在localhost:8500)。
  5. 等待tf-serving返回结果。
  6. 关闭Span,并将结果返回给feature-extractor

核心Go Sidecar代码片段:

// file: tf-sidecar/main.go
package main

import (
	// ... imports
	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
	"github.com/apache/skywalking-go/plugins/core/operator"
	"github.com/apache/skywalking-go/plugins/go2sky"
	"github.com/apache/skywalking-go/plugins/go2sky/reporter"
	pb "tensorflow_serving/apis"
)

// PredictionProxyService 是我们的gRPC代理服务
type PredictionProxyService struct {
	pb.UnimplementedPredictionServiceServer
	tfServingConn *grpc.ClientConn
	tracer      *go2sky.Tracer
}

// Predict 是代理的核心方法
func (s *PredictionProxyService) Predict(ctx context.Context, req *pb.PredictRequest) (*pb.PredictResponse, error) {
	// 1. 从入站请求的metadata中提取sw8头
	md, ok := metadata.FromIncomingContext(ctx)
	sw8Header := ""
	if ok && len(md.Get("sw8")) > 0 {
		sw8Header = md.Get("sw8")[0]
	}

	// 2. 创建一个代表对TF Serving调用的Exit Span
	// OperationName可以定义为模型的名称,这样在UI上更清晰
	span, err := s.tracer.CreateExitSpan(ctx, "TensorFlowServing/Predict", "localhost:8500", go2sky.WithContext(sw8Header))
	if err != nil {
		// 如果创建span失败,直接调用后端,不影响主流程
		return pb.NewPredictionServiceClient(s.tfServingConn).Predict(ctx, req)
	}
	defer span.End()

	span.SetComponent(34) // TensorFlow component ID
	span.SetSpanLayer(go2sky.SpanLayer_RPCFramework)
	
	// 3. 将请求转发给真正的TF Serving
	resp, err := pb.NewPredictionServiceClient(s.tfServingConn).Predict(ctx, req)
	if err != nil {
		span.Error(time.Now(), err.Error())
	}

	return resp, err
}

func main() {
	// 初始化go2sky tracer
	// Reporter负责将追踪数据发送到OAP
	r, err := reporter.NewGRPCReporter("skywalking-oap.o11y.svc:11800")
	if err != nil {
		log.Fatalf("failed to create skywalking reporter: %v", err)
	}
	defer r.Close()

	tracer, err := go2sky.NewTracer("tf-serving-sidecar", go2sky.WithReporter(r))
	if err != nil {
		log.Fatalf("failed to create tracer: %v", err)
	}
    
    // ... gRPC服务器和客户端连接设置 ...
}

通过这种方式,我们虽然引入了一个额外的网络跳(尽管是在Pod内部的localhost通信),但成功地将TensorFlow Serving这个“黑盒”纳入了我们的追踪体系,且完全无需改动模型或Serving本身。

4. Argo CD: 声明式管理一切

所有组件(Kong、自定义插件、feature-extractortf-serving及其Sidecar)都被打包成Helm Charts。Argo CD通过一个Application CRD来监控我们的Git配置仓库。

Git仓库结构:

├── apps
│   └── financial-fraud-detection.yaml  # Argo CD Application定义
└── charts
    ├── feature-extractor
    │   ├── Chart.yaml
    │   ├── templates
    │   └── values.yaml
    ├── kong
    │   ├── Chart.yaml
    │   ├── templates
    │   │   └── custom-plugin-configmap.yaml # 插件代码通过ConfigMap挂载
    │   └── values.yaml
    └── tf-serving
        ├── Chart.yaml
        ├── templates
        │   └── deployment.yaml             # 定义主容器和sidecar容器
        └── values.yaml

Argo CD Application 定义:

# file: apps/financial-fraud-detection.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: financial-fraud-detection
  namespace: argocd
spec:
  project: default
  source:
    repoURL: 'https://github.com/your-org/infra-configs.git'
    targetRevision: HEAD
    path: charts/ # 这是一个伞形Chart,或者使用App of Apps模式
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: fraud-detection
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

tf-serving的Deployment模板片段,展示了Sidecar注入:

# file: charts/tf-serving/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
# ... metadata
spec:
  # ... selector, replicas
  template:
    # ... metadata
    spec:
      containers:
      - name: tf-serving
        image: tensorflow/serving:{{ .Values.image.tag }}
        args:
          - "--port=8500"
          - "--rest_api_port=0" # 禁用REST API,只用gRPC
          - "--model_name={{ .Values.modelName }}"
          - "--model_base_path=/models/{{ .Values.modelName }}"
        ports:
        - containerPort: 8500
          name: grpc-tf
      - name: tracing-sidecar
        image: your-registry/tf-sidecar:{{ .Values.sidecar.image.tag }}
        ports:
        - containerPort: 8502
          name: grpc-proxy
        env:
        - name: SKYWALKING_OAP_SERVER
          value: "skywalking-oap.o11y.svc.cluster.local:11800"
        - name: UPSTREAM_TF_SERVING_ADDR
          value: "localhost:8500"

当我们需要更新Kong插件的逻辑、调整feature-extractor的资源限制或升级tf-serving的模型时,我们只需修改Git仓库中的代码或YAML文件。Argo CD会自动检测到变更,并以安全、可控的方式将变更应用到Kubernetes集群中。整个复杂系统的状态都由Git仓库这个“唯一事实来源”来定义。

架构的扩展性与局限性

这个架构的优势在于它的模式是可复制的。当引入新的服务时,无论是Go, Python还是其他语言,只要有对应的SkyWalking Agent或SDK,就可以无缝地集成到现有的可观测性体系中。对于无法进行代码埋点的“黑盒”应用,Sidecar代理模式提供了一个通用的解决方案。

当然,当前方案也存在局限性。Sidecar模式为每个请求增加了一次额外的进程内网络跳转,对于延迟极其敏感的场景(如高频交易),这几百微秒的开销也可能需要考量。更理想的未来是,eBPF等技术或许能实现对TF Serving这类应用的零侵入、零开销的内核级追踪,从而彻底替代Sidecar。

此外,当前方案主要解决了技术层面的可观测性。下一步的演进方向是将业务数据,例如用户ID、交易金额等作为标签(Tags)附加到Span上。这使得我们可以从业务维度进行下钻分析,例如,查询“所有VIP用户发起的、金额超过10000元的交易中,推理耗时最长的Top 10请求”,这将为业务优化提供更直接的数据支撑。


  目录