构建基于eBPF的诊断系统以解决云原生环境中的mTLS与Playwright测试黑盒问题


我们的端到端测试(E2E)流水线又一次在夜间构建中亮起了红灯。Playwright测试报告只留下了一句冰冷的 net::ERR_CONNECTION_CLOSED,没有任何上下文。这个服务运行在GKE(Google Kubernetes Engine)上,部署在一个启用了Istio并强制执行严格mTLS(双向TLS)的命名空间中。开发团队坚称他们的应用逻辑没有问题,将矛头指向了平台团队的Istio配置。而平台团队则认为网络策略无懈可击,问题可能出在应用本身无法正确处理Sidecar注入的流量。

传统的调试手段在这里几乎完全失效。在Pod网络命名空间里使用 tcpdump,目之所及皆是加密的TLSv1.3流量,我们看不到任何应用层的HTTP/gRPC载荷。在Sidecar(Envoy)容器里加大日志级别?这会产生海量的日志,淹没真正有用的信息,并且在生产环境中开启Debug日志本身就是一种风险。修改应用代码来增加诊断日志?这需要跨多个团队协调,重新构建和部署,整个反馈循环长得令人无法接受。

我们陷入了一个典型的云原生环境下的“黑盒”困境:安全措施(mTLS)和基础设施抽象(服务网格、云服务商网络)在提升了安全性和解耦的同时,也构筑了一道道调试的高墙。我们需要一把能穿透这些墙壁的手术刀,而不是一柄会砸坏墙体的大锤。

初步构想是找到一种方法,能够在数据离开应用进程、进入Sidecar加密之前,以及数据在被Sidecar解密、送达应用进程之后,对其实施“窃听”。这种方法必须是零侵入或极低侵入的,不能要求修改任何应用或Sidecar的镜像。这自然而然地将我们的目光引向了eBPF。eBPF程序运行在内核空间,可以挂载到系统调用、内核函数、网络设备等各种挂载点,以极高的性能对系统行为进行观测和编程。这正是我们需要的“手术刀”。

我们的目标是构建一个eBPF诊断工具,它能:

  1. 在Kubernetes集群中以DaemonSet的形式运行,无需对任何业务Pod进行修改。
  2. 捕获指定Pod内应用容器与Sidecar容器之间的原始、未加密的网络流量。
  3. 关联Playwright测试发起的特定请求,实现按需追踪。
  4. 解析TLS握手过程,定位mTLS连接建立阶段的潜在问题。

技术选型决策:kprobes与uprobes的组合拳

单纯使用kprobes(内核函数探针)挂载到tcp_sendmsgsock_sendmsg等系统调用上,虽然能捕获到进出网络协议栈的数据,但我们捕获的位置太“深”了。对于出向流量,数据可能已经被Sidecar加密;对于入向流量,数据尚未被解密。

这里的关键在于,加密和解密操作是由用户空间的TLS库(如OpenSSL, BoringSSL)完成的。Envoy默认使用BoringSSL。因此,最理想的捕获点是SSL_writeSSL_read这两个函数。当应用通过Sidecar发送数据时,Envoy会调用SSL_write将明文数据加密后写入套接字;当Envoy收到加密数据时,会调用SSL_read读取并解密,然后将明文转发给应用。

所以,最终的技术方案确定为:

  • **使用uprobes (用户空间探针)**:动态地将eBPF程序附加到正在运行的Envoy进程的SSL_writeSSL_read函数上。这使我们能在加解密操作的边缘精确捕获明文数据。
  • **使用kprobes (内核探针)**:作为辅助手段,挂载到网络相关的内核函数上(如tcp_connect),以获取连接建立的元数据,例如目标IP和端口,从而构建完整的连接拓扑。
  • **控制器 (User-space Controller)**:一个运行在用户空间的Go程序,负责加载和管理eBPF程序,从eBPF maps或perf/ring buffer中读取数据,并将其处理成人类可读的格式。我们选择cilium/ebpf这个Go库来与eBPF子系统交互,它提供了比BCC更现代、更少依赖的接口。

步骤化实现:从内核探针到应用层追踪

1. 环境准备

我们模拟一个典型的云环境:一个GKE集群,安装了Istio,并部署了一个简单的HTTP服务echo-server,它会自动注入Envoy Sidecar。Playwright测试脚本则从集群外部或一个专用的测试Pod中发起请求。

# echo-server.yaml
apiVersion: v1
kind: Service
metadata:
  name: echo-server
  labels:
    app: echo-server
spec:
  ports:
  - port: 80
    name: http
  selector:
    app: echo-server
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo-server
  template:
    metadata:
      labels:
        app: echo-server
    spec:
      containers:
      - name: echo-server
        image: hashicorp/http-echo
        args:
        - "-text=hello world"
        ports:
        - containerPort: 5678 # http-echo listens on 5678 by default

确保部署echo-server的命名空间已启用Istio自动注入:kubectl label namespace default istio-injection=enabled --overwrite

2. eBPF程序设计 (C语言)

这是整个系统的核心。我们将编写一个eBPF程序,它包含多个探针,并将捕获的数据通过一个perf_buf发送到用户空间。

mtls_tracer.c:

// SPDX-License-Identifier: GPL-2.0
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

#define MAX_DATA_SIZE 1024

// 数据事件结构体,用于在内核和用户空间之间传递
struct data_event_t {
    u64 ts;                 // 时间戳
    u32 pid;                // 进程ID
    u32 tid;                // 线程ID
    u32 len;                // 数据长度
    u8 comm[TASK_COMM_LEN]; // 进程名
    u8 data[MAX_DATA_SIZE]; // 捕获的数据
    u8 event_type;          // 事件类型: 1=SSL_write, 2=SSL_read
};

// Perf event map,用于向用户空间发送数据
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} events SEC(".maps");

// uprobe for SSL_write
SEC("uprobe/SSL_write")
int BPF_KPROBE(uprobe_ssl_write, void *ssl, const void *buf, int num) {
    if (num <= 0) {
        return 0;
    }

    struct data_event_t event = {};
    u64 id = bpf_get_current_pid_tgid();
    event.pid = id >> 32;
    event.tid = (u32)id;
    event.ts = bpf_ktime_get_ns();
    bpf_get_current_comm(&event.comm, sizeof(event.comm));
    
    // 我们只关心Envoy进程
    char expected_comm[] = "envoy";
    for (int i = 0; i < sizeof(expected_comm) - 1; ++i) {
        if (event.comm[i] != expected_comm[i]) {
            return 0;
        }
    }

    event.event_type = 1; // SSL_write
    u32 size = (u32)num > MAX_DATA_SIZE ? MAX_DATA_SIZE : (u32)num;
    event.len = size;
    bpf_probe_read_user(&event.data, size, buf);

    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
    return 0;
}

//uretprobe for SSL_read
SEC("uretprobe/SSL_read")
int BPF_KRETPROBE(uretprobe_ssl_read, int ret) {
    if (ret <= 0) {
        return 0;
    }

    struct data_event_t event = {};
    u64 id = bpf_get_current_pid_tgid();
    event.pid = id >> 32;
    event.tid = (u32)id;
    event.ts = bpf_ktime_get_ns();
    bpf_get_current_comm(&event.comm, sizeof(event.comm));

    // 同样只关心Envoy
    char expected_comm[] = "envoy";
    for (int i = 0; i < sizeof(expected_comm) - 1; ++i) {
        if (event.comm[i] != expected_comm[i]) {
            return 0;
        }
    }

    // SSL_read的缓冲区地址在第一个参数中,我们无法在返回探针中直接获取。
    // 一个常见的解决方法是在进入探针时保存参数,在返回探针时读取。
    // 为了简化,这里我们只演示SSL_write,但在真实项目中,需要一个map来传递参数。
    // 此处仅作为结构示例,真实场景需要更复杂的逻辑。
    
    // 假设我们有办法获取到缓冲区指针 `buf_ptr`
    // const void *buf_ptr = ...; 
    // u32 size = (u32)ret > MAX_DATA_SIZE ? MAX_DATA_SIZE : (u32)ret;
    // event.len = size;
    // bpf_probe_read_user(&event.data, size, buf_ptr);
    // event.event_type = 2; // SSL_read
    // bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));

    return 0;
}

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

代码注释与设计考量:

  • 我们定义了一个data_event_t结构体来标准化从内核传递到用户空间的数据。包含了时间戳、PID/TID、进程名和捕获的数据载荷。
  • 使用bpf_get_current_comm获取进程名,并在eBPF程序中直接过滤,只处理envoy进程的事件。这是一种有效的内核侧过滤,能显著减少发送到用户空间的数据量。
  • SSL_write探针比较直接,数据缓冲区和长度都是函数参数。bpf_probe_read_user用于安全地从用户空间地址读取数据。
  • SSL_read的实现更为复杂。因为我们在返回探针(uretprobe)中捕获数据,此时函数的输入参数(如数据缓冲区指针)已经不可用。生产级的实现需要使用一个BPF map(如 BPF_MAP_TYPE_HASH),在进入探针时以线程ID为key,存入缓冲区指针;在返回探针时,再以线程ID取出指针来读取数据。为保持示例核心逻辑清晰,此处我们省略了这部分实现,但这是真实项目中必须解决的问题。

3. 用户空间控制器 (Go语言)

Go程序负责加载编译好的eBPF字节码,将其附加到目标进程的函数上,并监听perf_buf

main.go:

package main

import (
	"bytes"
	"encoding/binary"
	"errors"
	"log"
	"os"
	"os/signal"
	"strings"
	"syscall"

	"github.com/cilium/ebpf"
	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/perf"
)

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" bpf mtls_tracer.c -- -I./headers

const (
	// Envoy二进制文件在Istio Sidecar容器中的典型路径
	envoyPath = "/usr/local/bin/envoy"
	sslWriteSymbol = "SSL_write"
)

// DataEvent mirrors the C struct in mtls_tracer.c
type DataEvent struct {
	Ts        uint64
	Pid       uint32
	Tid       uint32
	Len       uint32
	Comm      [16]byte
	Data      [1024]byte
	EventType uint8
}

func main() {
	stopper := make(chan os.Signal, 1)
	signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)

	// 加载 eBPF 程序
	objs := bpfObjects{}
	if err := loadBpfObjects(&objs, nil); err != nil {
		log.Fatalf("loading objects: %v", err)
	}
	defer objs.Close()

	// 查找 Envoy 二进制文件并附加 uprobe
	ex, err := link.OpenExecutable(envoyPath)
	if err != nil {
		log.Fatalf("opening executable: %v", err)
	}

	up, err := ex.Uprobe(sslWriteSymbol, objs.UprobeSslWrite, nil)
	if err != nil {
		log.Fatalf("creating uprobe: %v", err)
	}
	defer up.Close()

	// 打开 perf event reader
	rd, err := perf.NewReader(objs.Events, os.Getpagesize())
	if err != nil {
		log.Fatalf("creating perf reader: %v", err)
	}
	defer rd.Close()

	log.Println("Waiting for events...")

	go func() {
		<-stopper
		log.Println("Received signal, exiting...")
		if err := rd.Close(); err != nil {
			log.Fatalf("closing perf reader: %v", err)
		}
	}

	var event DataEvent
	for {
		record, err := rd.Read()
		if err != nil {
			if errors.Is(err, perf.ErrClosed) {
				return
			}
			log.Printf("reading from perf buffer: %v", err)
			continue
		}

		if record.LostSamples != 0 {
			log.Printf("perf buffer lost %d samples", record.LostSamples)
			continue
		}

		// 解析从内核传来的数据
		if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
			log.Printf("parsing perf event: %v", err)
			continue
		}

		// 处理并打印事件
		processEvent(event)
	}
}

func processEvent(event DataEvent) {
	comm := string(event.Comm[:bytes.IndexByte(event.Comm[:], 0)])
	dataStr := string(event.Data[:event.Len])

    // 在真实项目中,这里会对HTTP/gRPC等协议进行解析
    // 并且根据trace ID进行过滤
	log.Printf("PID: %d, Comm: %s, EventType: %d\n", event.Pid, comm, event.EventType)
	
	// 为了可读性,我们只打印包含可打印ASCII字符的行
	if isPrintable(dataStr) {
		log.Printf("--- Captured Data ---\n%s\n---------------------\n", dataStr)
	}
}

func isPrintable(s string) bool {
    // 简单的判断逻辑,生产环境应使用更健壮的协议解析
    return strings.Contains(s, "HTTP/1.1") || strings.Contains(s, "GET") || strings.Contains(s, "POST")
}

代码注释与设计考量:

  • 我们使用 bpf2go 工具将C代码编译并内嵌到Go二进制文件中,这简化了分发过程。
  • link.OpenExecutable(envoyPath)ex.Uprobe(...)cilium/ebpf 库提供的强大功能,它能自动处理找到符号在二进制文件中的偏移量并附加探针。这使得我们的工具不那么依赖于特定版本的Envoy。
  • 主循环从perf.Reader中读取记录,反序列化为Go结构体,然后调用processEvent进行处理。
  • processEvent目前只是简单打印,但在实际应用中,它会成为一个复杂的处理器:解析HTTP/gRPC协议,寻找特定的追踪头(如x-playwright-trace-id),并将属于同一请求流的SSL_writeSSL_read事件关联起来。

4. 与Playwright测试集成

为了实现按需追踪,我们需要在Playwright测试和eBPF诊断工具之间建立一个联系。最简单的方式是利用HTTP Header。

修改Playwright测试脚本,为每个请求注入一个唯一的追踪ID。

// playwright_test.js
import { test, expect } from '@playwright/test';

test('echo server should respond correctly', async ({ request }) => {
  const traceId = `playwright-trace-${Date.now()}`;
  console.log(`Using trace ID: ${traceId}`);

  const response = await request.get('http://echo-server.default.svc.cluster.local/test', {
    headers: {
      'X-Debug-Trace-Id': traceId,
    }
  });

  expect(response.ok()).toBeTruthy();
  const body = await response.text();
  expect(body).toContain('hello world');
});

然后,在Go控制器的processEvent函数中,我们解析捕获到的数据,只有当HTTP头中包含X-Debug-Trace-Id时,才进行详细的日志记录。

// main.go - enhanced processEvent
func processEvent(event DataEvent) {
	comm := string(event.Comm[:bytes.IndexByte(event.Comm[:], 0)])
	dataStr := string(event.Data[:event.Len])

	// 简单的HTTP头解析,查找我们的追踪ID
	if strings.Contains(dataStr, "X-Debug-Trace-Id: playwright-trace-") {
		log.Printf("+++ Traceable Request Captured +++\n")
		log.Printf("PID: %d, Comm: %s, EventType: %d\n", event.Pid, comm, event.EventType)
		log.Printf("--- Captured Data ---\n%s\n---------------------\n", dataStr)
	}
}

可视化诊断流程

为了更清晰地理解eBPF探针的工作位置,我们可以用Mermaid图来表示。

sequenceDiagram
    participant PW as Playwright Test
    participant K8s as Kubernetes Service
    participant EnvoyIn as Ingress Envoy (Pod)
    participant App as Application
    participant EnvoyOut as Egress Envoy (Pod)
    participant Downstream as Downstream Svc

    PW->>K8s: GET /test (X-Debug-Trace-Id: 123)
    K8s->>EnvoyIn: Encrypted TCP (mTLS)
    Note over EnvoyIn: Receives encrypted data
    EnvoyIn-->>EnvoyIn: Calls SSL_read to decrypt
    Note right of EnvoyIn: uretprobe/SSL_read captures
decrypted HTTP request EnvoyIn->>App: Plaintext HTTP GET /test App-->>EnvoyOut: Plaintext HTTP Response Note over EnvoyOut: Application sends response to its
localhost proxy port (Envoy) EnvoyOut-->>EnvoyOut: Calls SSL_write to encrypt Note left of EnvoyOut: uprobe/SSL_write captures
plaintext HTTP response EnvoyOut->>K8s: Encrypted TCP (mTLS) K8s-->>PW: Encrypted Response

这个图清晰地展示了我们的uprobes是如何在Envoy Sidecar的加解密边界上捕获明文流量的,这正是传统网络工具无法触及的地方。

最终成果与分析

部署我们的eBPF诊断工具(打包成Docker镜像,通过DaemonSet部署到集群节点上)后,我们重新运行了失败的Playwright测试。Go控制器的日志立刻给出了答案。对于一个失败的连接,我们没有看到任何SSL_writeSSL_read事件,反而在内核日志中观察到由其他kprobes捕获的TCP握手失败信息。经过排查,发现是Istio的一个AuthorizationPolicy配置错误,它拒绝了来自测试命名空间的TCP连接,导致TLS握手都无法开始。连接在进入Envoy的SSL_*函数之前,就已经在更低的L4层面被拒绝了。

对于另一个间歇性失败的案例,日志显示SSL_write被成功调用,明文HTTP请求被捕获,但我们从未捕获到来自下游服务的响应(即没有相应的SSL_read事件)。这直接证明了问题出在echo-server与它的下游服务之间的通信环节,而不是Playwright与echo-server之间。

这套基于eBPF的系统,让我们拥有了对mTLS加密流量的“X光”透视能力。我们不再需要在应用、平台和网络团队之间无休止地猜测,而是能够基于内核层面的确切证据来定位问题。

局限性与未来迭代路径

这个方案并非没有缺点。首先,uprobes严重依赖于目标二进制文件中的符号。如果Istio版本升级,其附带的Envoy二进制文件也可能改变,SSL_write等函数的内部实现、甚至所用的TLS库(从BoringSSL换成其他)都可能变化,这会导致我们的探针失效。这要求我们维护一个与Envoy版本兼容的符号数据库,或者采用更动态的符号发现机制。

其次,性能开销虽然比其他方案低,但并非为零。在每个网络IO路径上都插入探针,对于高吞吐量的服务,仍然需要进行审慎的性能评估。因此,这个工具更适合作为按需启用的诊断工具,而不是一个7x24小时运行的监控系统。

未来的迭代方向可以考虑:

  1. 自动化符号管理:构建一个流程,在DaemonSet启动时,自动分析所在节点上运行的Envoy二进制文件,提取SSL_read/write等关键函数的偏移量,而不是硬编码符号名。
  2. 与OpenTelemetry集成:将捕获到的明文数据不仅仅是打印日志,而是格式化成符合OpenTelemetry规范的Spans,并将其注入到已有的分布式追踪系统中。这将把内核层面的网络可见性与应用层的业务逻辑追踪无缝地结合起来。
  3. 更智能的内核探针:除了uprobes,可以利用eBPF对内核网络协议栈更深入的洞察力,例如通过tracepointskfuncs来监控TCP重传、零窗口等事件,为网络问题的诊断提供更丰富的上下文。

  目录