一套用于金融风控场景的 TensorFlow 推理服务,其 P99 延迟目标是 50ms。当监控系统报告一次请求耗时 150ms 时,问题可能出在哪里?是入口的 Kong 网关插件执行缓慢,是负责特征提取的 Java 中台服务发生了 GC,还是 TensorFlow Serving 模型本身在特定输入下计算量剧增?如果无法将这次请求在各个组件中的耗时精确关联,故障排查就是一场灾难。单纯依赖各个组件独立的日志和指标系统,会形成数据孤岛,无法形成有效的因果链。
我们面临的挑战是,在一个异构的技术栈中实现端到端的请求追踪:从 Nginx/LuaJIT 驱动的 Kong 网关,到 JVM 上的 Java 特征工程服务,再到 C++ 核心的 TensorFlow Serving。本文记录了构建这套全链路可观测性平台的架构决策与核心实现,并阐述了如何通过 Argo CD 对这一复杂系统进行声明式、自动化的部署与管理。
定义问题:异构系统中的可观测性黑洞
我们的核心业务流程如下:
- 客户端请求携带交易数据,首先到达 Kong API 网关。
- Kong 上的鉴权和路由插件处理后,将请求转发给一个名为
feature-extractor
的 Java 服务。 -
feature-extractor
服务会查询多个数据源(如 Redis、DB),聚合生成模型所需的特征向量。 - 该服务通过 gRPC 调用后端的
tf-serving
实例,执行模型推理。 - 推理结果沿原路返回给客户端。
这个链路中的技术栈差异巨大,导致了可观测性上的断层:
- 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
这个插件的核心逻辑是:
- 在
access
阶段检查sw8
头,没有则创建。 - 将
sw8
头注入到向上游服务的请求中,实现上下文传递。 - 在
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的职责:
- 监听一个gRPC端口(如
8502
)。 - 接收来自
feature-extractor
的请求,并从gRPC的metadata中读取sw8
头。 - 使用SkyWalking Go Agent
go2sky
,根据sw8
头创建一个”Exit Span”,代表对TF Serving的调用。 - 将请求(不含追踪头)转发给同一Pod内容器网络中的
tf-serving
实例(监听在localhost:8500
)。 - 等待
tf-serving
返回结果。 - 关闭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-extractor
、tf-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请求”,这将为业务优化提供更直接的数据支撑。