构建基于 eBPF 的零侵入式全栈诊断系统以剖析 APISIX 与 ASP.NET Core 性能瓶颈


一个线上环境的典型性能谜题:某个核心API的P99响应时间偶尔会飙升到数秒,但APISIX网关的访问日志显示其处理延迟(latency)稳定在个位数毫秒,后端的ASP.NET Core应用日志也表明业务逻辑执行耗时正常。传统的APM工具通过在应用层植入探针,也未能捕获到明显的延迟源。问题似乎出在“缝隙”中——网络传输、内核调度,或是某个被监控忽略的底层交互。这种场景下,继续增加应用层监控的粒度,无异于在灯下找钥匙,而钥匙可能掉在了远处的黑暗里。

我们需要一种能照亮整个系统的工具,但前提是不能因为引入新的观察者而干扰系统本身的运行状态。

方案A: 传统深度植入式可观测性

这是最常规的思路。其核心是代码植入(Instrumentation)。

  1. 前端: 在基于MobX和UnoCSS构建的单页应用中,使用OpenTelemetry JS SDK。对关键的用户交互(如按钮点击触发的API请求)进行手动埋点,创建Span来包裹整个异步请求的生命周期。
  2. 后端: 在ASP.NET Core应用中,集成OpenTelemetry.Extensions.Hosting和相关的Instrumentation包(如AspNetCoreHttpClient)。这会自动为所有传入请求和发出的HTTP调用创建Span。对于更细粒度的业务逻辑,仍需手动通过ActivitySource创建Activity(即OTEL中的Span)。
  3. 网关: 在APISIX中启用opentelemetry插件,将网关层面的处理作为一个Span接入整个调用链。

这个方案的优点是生态成熟,遵循行业标准,并且能够提供丰富的业务层上下文。我们可以清晰地看到从用户点击到数据库查询的完整业务流程。

然而,它的弊端在我们的特定问题场景下被放大了:

  • 代码侵入性: 所有需要精细化追踪的地方都需要修改业务代码。这不仅增加了开发负担,也带来了引入新bug的风险。对于一个庞大且复杂的系统,完全的植入式覆盖几乎是不可能完成的任务。
  • 性能开销: 高流量下,大量的Span创建、上下文传播和数据导出本身会带来不可忽视的CPU和内存开销。在某些场景下,观测工具本身就可能成为性能瓶颈。
  • 观测盲区: 这是最致命的。无论应用层Span的粒度有多细,它都无法穿透到操作系统内核。它无法回答诸如“TCP连接建立是否耗时过长?”、“是否发生了大量的TCP重传?”、“内核的socket缓冲区是否已满导致应用写阻塞?”这类问题。我们的问题恰好就怀疑出在这些盲区。

方案B: 基于eBPF的零侵入式内核级观测

eBPF(extended Berkeley Packet Filter)允许我们在内核中运行沙箱化的程序,以一种安全、高效的方式挂载到内核的几乎任何函数上,从而实现对系统行为的深度观测。

  1. 实现方式: 编写eBPF程序,利用kprobe(内核函数探针)和tracepoint(静态跟踪点)来监控与网络相关的系统调用,例如connectsendmsgrecvmsg和TCP协议栈的关键函数。这些eBPF程序由用户空间的控制进程加载到内核中,并通过perf bufferBPF maps与用户空间通信,将采集到的数据导出。
  2. 部署模型: 观测代理(包含eBPF加载器和数据处理逻辑)作为一个独立的进程或Sidecar部署在目标主机或Pod中,完全不与被观测的应用(APISIX, ASP.NET Core)产生直接交互。

它的优势与方案A的劣势形成了完美互补:

  • 零侵入性: 无需修改一行应用代码。无论是.NET应用、Nginx(APISIX核心)还是任何其他程序,只要它在Linux内核上运行,就能被观测。
  • 极低开销: eBPF程序在内核中直接执行,经过JIT编译成高效的本地指令,避免了用户态/内核态的频繁切换。数据聚合通常也在内核中完成,只将必要的结果传回用户空间,开销极小。
  • 全局视野: 它能看到系统调用的全貌,从进程创建、文件IO到网络数据包的收发,提供了无可辩驳的“事实真相”。TCP握手延迟、数据包乱序、内核中排队等待的时间,都一览无余。

当然,其挑战也很明显:学习曲线陡峭,编写和调试eBPF程序需要深入的系统编程知识。同时,从海量的底层事件中关联出高层次的业务语义(例如,将某个socket上的数据流与特定的HTTP /api/v1/user 请求对应起来)是一个核心难题。

最终选择与理由: 混合模式,以eBPF为基石

我们决定采用一种混合架构,以方案B为核心,辅以方案A中最轻量级的元素来解决语义关联问题。具体而言,我们放弃重量级的全链路Span植入,只在应用边界传递一个唯一的关联ID。eBPF负责捕获所有底层的性能指标,而这个关联ID则像一根线,将这些底层事件串成有意义的业务调用链。

这种方式兼顾了两种方案的优点:

  • 保持了零侵入的核心原则: 对现有应用代码的改动降到最低,仅限于传递和记录一个ID。
  • 获得了内核级的深度洞察力: eBPF为我们提供了解决棘手问题的关键数据。
  • 解决了语义关联的难题: 通过ID,我们能够精确地将内核事件映射回具体的某一次API请求。

核心实现概览

我们的目标是追踪一次完整的用户请求:从前端MobX状态变更触发,经由APISIX网关,到达ASP.NET Core后端,并使用eBPF捕获整个后端链路(APISIX -> a.net core)的内核级网络行为。

sequenceDiagram
    participant FE as Frontend (MobX/UnoCSS)
    participant GW as APISIX Gateway
    participant BE as ASP.NET Core
    participant BPF as eBPF Tracer (Host)

    FE->>+GW: GET /api/data (X-Client-ID: xyz)
    Note over GW, BPF: eBPF traces kernel socket events for APISIX PID
    GW->>GW: Injects X-Request-ID: abc-123
    GW->>+BE: GET /service/data (X-Request-ID: abc-123)
    Note over BE, BPF: eBPF traces kernel socket events for dotnet PID
    BE-->>-GW: 200 OK
    GW-->>-FE: 200 OK
    
    BPF-->>Log/Metric System: Correlated Kernel Events

1. eBPF程序: 捕获TCP套接字生命周期事件

我们将使用C语言和libbpf库来编写eBPF程序。这个程序的目标是追踪TCP连接的建立、数据传输和关闭,并记录关键的时间戳和元数据。这比使用bpftrace等高级工具更具灵活性和性能。

tcp_tracer.bpf.c:

// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

// 用于在内核和用户空间传递事件的数据结构
struct tcp_event {
    u64 ts_ns;         // 事件时间戳 (ns)
    u32 pid;           // 进程ID
    u32 tid;           // 线程ID
    u8 comm[16];       // 进程名
    u8 event_type;     // 事件类型: 1=connect, 2=send, 3=recv, 4=close
    u64 duration_ns;   // 事件持续时间 (ns)
    u32 saddr;         // 源地址
    u32 daddr;         // 目的地址
    u16 sport;         // 源端口
    u16 dport;         // 目的端口
    s64 ret;           // 系统调用返回值
    u32 data_len;      // 发送/接收数据长度
};

// BPF ring buffer for sending events to user space
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024); // 256 KB
} events SEC(".maps");

// Map to store start time of connect syscall
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 8192);
    __type(key, u64); // key: tid
    __type(value, u64); // value: start timestamp
} connect_start SEC(".maps");

// --- Kprobes for connect ---
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(kprobe__tcp_v4_connect, struct sock *sk)
{
    u64 id = bpf_get_current_pid_tgid();
    u64 ts = bpf_ktime_get_ns();
    bpf_map_update_elem(&connect_start, &id, &ts, BPF_ANY);
    return 0;
}

SEC("kretprobe/tcp_v4_connect")
int BPF_KRETPROBE(kretprobe__tcp_v4_connect, int ret)
{
    u64 id = bpf_get_current_pid_tgid();
    u64 *start_ts = bpf_map_lookup_elem(&connect_start, &id);
    if (!start_ts) {
        return 0;
    }

    struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);
    struct tcp_event *e;
    e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (!e) {
        return 0;
    }

    e->ts_ns = bpf_ktime_get_ns();
    e->duration_ns = e->ts_ns - *start_ts;
    e->pid = id >> 32;
    e->tid = (u32)id;
    bpf_get_current_comm(&e->comm, sizeof(e->comm));
    
    e->event_type = 1; // connect
    e->ret = ret;
    
    // Extract connection details from struct sock
    // BPF_CORE_READ is used for CO-RE (Compile Once - Run Everywhere)
    e->saddr = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
    e->daddr = BPF_CORE_READ(sk, __sk_common.skc_daddr);
    e->sport = BPF_CORE_READ(sk, __sk_common.skc_num);
    e->dport = bpf_ntohs(BPF_CORE_READ(sk, __sk_common.skc_dport));

    bpf_ringbuf_submit(e, 0);
    bpf_map_delete_elem(&connect_start, &id);
    return 0;
}

// --- Tracepoints for send/recv (more stable than kprobes on syscalls) ---
SEC("tracepoint/syscalls/sys_enter_sendto")
int tracepoint__sys_enter_sendto(struct trace_event_raw_sys_enter *ctx)
{
    // In a real project, we would store start time and calculate duration in sys_exit
    // For brevity, we just capture the enter event here.
    u64 id = bpf_get_current_pid_tgid();
    struct tcp_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (!e) return 0;

    e->ts_ns = bpf_ktime_get_ns();
    e->pid = id >> 32;
    bpf_get_current_comm(&e->comm, sizeof(e->comm));
    e->event_type = 2; // send
    e->data_len = (u32)ctx->args[1]; // len argument
    
    bpf_ringbuf_submit(e, 0);
    return 0;
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

这个eBPF程序使用kprobe来测量tcp_v4_connect的耗时,并通过ring buffer将结构化的事件数据发送到用户空间。用户空间的加载器(通常用Go或Rust编写)会负责编译、加载此程序到内核,并持续消费ring buffer中的数据。在生产环境中,我们会添加对recvmsgclose等系统调用的追踪,以及对目标PID的过滤,以减少数据量。

2. APISIX: 注入关联ID

APISIX的配置非常直接。我们利用其强大的插件生态,在路由中启用request-id插件。

config.yaml (APISIX route configuration):

routes:
  - id: "backend-service-route"
    uri: "/api/*"
    upstream:
      type: roundrobin
      nodes:
        "aspnet-backend:80": 1
    plugins:
      request-id:
        header_name: "X-Request-ID"
        algorithm: "uuid"
      # We could also use serverless plugins to add more complex logic
      # or even interact with the eBPF agent if needed.

这会为每个进入网关的请求生成一个唯一的UUID,并将其放入X-Request-ID请求头中,然后转发给上游的ASP.NET Core服务。

3. ASP.NET Core: 消费并记录关联ID

为了实现“最低侵入性”,我们不使用完整的OpenTelemetry SDK,而是编写一个极简的中间件来捕获X-Request-ID并将其添加到日志上下文中。

RequestIdLoggingMiddleware.cs:

using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;

public class RequestIdLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestIdLoggingMiddleware> _logger;
    private const string RequestIdHeaderName = "X-Request-ID";

    public RequestIdLoggingMiddleware(RequestDelegate next, ILogger<RequestIdLoggingMiddleware> logger)
    {
        _next = next ?? throw new ArgumentNullException(nameof(next));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Try to get request ID from header, or create a new one if not present.
        var requestId = context.Request.Headers.TryGetValue(RequestIdHeaderName, out var values)
            ? values.ToString()
            : Guid.NewGuid().ToString("N");

        // Add the RequestId to the logging scope. Any logs within this request's
        // execution context will automatically have this property.
        // This is the key for correlation without massive code changes.
        using (_logger.BeginScope(new Dictionary<string, object> { ["RequestId"] = requestId }))
        {
            // Set the ID in the response header as well, so clients can see it.
            context.Response.OnStarting(() =>
            {
                if (!context.Response.Headers.ContainsKey(RequestIdHeaderName))
                {
                    context.Response.Headers.Add(RequestIdHeaderName, requestId);
                }
                return Task.CompletedTask;
            });

            await _next(context);
        }
    }
}

Startup.csProgram.cs中注册这个中间件:

// Program.cs in .NET 6+
var builder = WebApplication.CreateBuilder(args);

// ... other services

var app = builder.Build();

// Use the custom middleware early in the pipeline.
app.UseMiddleware<RequestIdLoggingMiddleware>();

// ... other middlewares and endpoints
app.MapGet("/service/data", () => Results.Ok(new { Message = "Data processed" }));

app.Run();

现在,任何使用ILogger输出的日志都会自动包含RequestId字段,例如:
info: MyWebApp.Controllers.DataController[0] RequestId: abc-123-def-456 Processing data for user X.

4. 数据关联与问题定位

现在,我们把所有部分串联起来。假设用户报告了一次缓慢的请求,并提供了前端捕获到的X-Client-ID

  1. 定位请求: 我们首先在中心化的日志系统中,通过X-Client-ID或相关信息找到对应的后端日志,从而获得X-Request-ID,例如abc-123-def-456。我们看到ASP.NET Core应用处理该请求的日志时间戳范围是 T1T2。我们还从日志中获取到了运行ASP.NET Core应用的dotnet进程的PID,假设是PID_BACKEND=5432
  2. 筛选eBPF数据: 我们转向由eBPF采集器收集的底层事件数据存储(例如一个ClickHouse表或简单的日志文件)。我们执行一个查询,筛选出在时间范围 [T1 - delta, T2 + delta] 内,且 pid5432 (后端服务) 或 PID_APISIX=1234 (APISIX worker进程) 的所有TCP事件。
  3. 分析根本原因:
    • 场景一: 我们发现一个event_type=connect的事件,其duration_ns高达2秒,并且daddr指向后端服务的IP。这清晰地表明,从APISIX到ASP.NET Core的TCP连接建立过程非常缓慢。问题不在于任何一方的应用逻辑,而在于两者之间的网络,可能是SYN包丢失、网络拥塞或后端服务器的TCP accept队列已满。
    • 场景二: 我们看到大量的event_type=send事件,但其data_len与应用日志中记录的响应体大小不符,或者send系统调用的耗时非常长。这可能指向内核的TCP发送缓冲区满了,应用进程被阻塞在send调用上。
    • 场景三: 所有内核事件的耗时都极短,总和远小于用户感受到的延迟。这反过来证明问题可能出在应用逻辑内部,或者是在eBPF未监控的某个地方(比如GC停顿)。此时,我们可以更有信心地回到应用层,启用更详细的dotnet-trace等工具进行剖析。

通过RequestId,我们将模糊的应用层问题报告,与eBPF提供的精确、无可辩驳的内核层证据关联了起来,从而实现了跨越用户空间和内核空间的端到端诊断。

架构的扩展性与局限性

这个方案并非银弹。

扩展性:

  • 数据处理: 原始的eBPF事件量可能很大。在生产环境中,这些数据应该被流式传输到一个专用的可观测性后端(如Vector -> ClickHouse/Elasticsearch),进行实时的聚合、索引和告警。
  • 自动化关联: 手动关联日志和eBPF事件效率低下。可以构建一个自动化系统,当检测到高延迟日志时,自动触发一个脚本去拉取相关时间窗口和PID的eBPF数据,并生成一份诊断报告。
  • 覆盖更多协议: eBPF的能力远不止TCP。我们可以用它来解析HTTP/2、gRPC甚至数据库协议(如MySQL/Postgres),只要流量未被加密。

局限性:

  • 加密流量(TLS): 一旦APISIX和后端服务之间的通信启用了TLS,直接在send/recv系统调用层面解析L7数据(如HTTP头)就变得不可能。虽然可以通过uprobe(用户空间函数探针)挂载到应用的OpenSSL/BoringSSL库的SSL_read/SSL_write函数上来获取解密前后的数据,但这会增加实现的复杂度和脆弱性。我们的关联ID策略部分规避了这个问题,因为我们不依赖L7解析来做关联。
  • 内核依赖性: eBPF的功能与Linux内核版本强相关。一个为5.4内核编写的复杂eBPF程序可能无法在4.18内核上运行。这给跨不同环境的部署带来了挑战,CO-RE(Compile Once - Run Everywhere)技术是缓解此问题的主要手段。
  • 容器环境的复杂性: 在Kubernetes这样的容器化环境中,PID和网络命名空间的隔离增加了追踪的复杂性。eBPF程序需要感知这些命名空间,才能正确地将事件归因于特定的Pod。
  • 上下文深度: eBPF提供了“什么”和“多快”的极致信息,但在“为什么”方面有所欠缺。它不知道一个HTTP请求背后的业务逻辑是什么。因此,它不能完全取代应用层的日志和指标,而是一个极其强大的补充。

  目录