eBPF 驱动的 Kubernetes 服务依赖自动发现:零侵入构建实时调用拓扑

在微服务架构中,"服务 A 到底调用了哪些服务"这个问题,远比想象中更难回答。传统方案依赖 SDK 插桩或 Sidecar 代理,但总有覆盖不到的盲区。本文展示如何利用 eBPF 在内核层面零侵入捕获所有 TCP 连接,自动构建实时服务依赖拓扑图。

一、为什么需要自动依赖发现

在 Kubernetes 集群运行上百个微服务时,运维团队常面临三个痛点:

文档永远滞后。 微服务之间的调用关系随版本迭代不断变化,依赖文档通常在三个月后就开始失真。新人入职问"这个服务为什么会调用 Redis Cluster 3",没人能回答。

故障定位困难。 当支付链路出现延迟抖动时,如果不能快速看到完整调用链(而非采样后的 traces),你只能逐个节点排查,MTTR 被拉到小时级。

影响面评估缺失。 当某个核心服务需要变更时,"变更影响范围有多大"这个问题只能靠人肉梳理,遗漏下游导致生产事故。

传统的解决方案有 SDK 插桩(OpenTelemetry Instrumentation)、Sidecar 代理(Envoy/istio)等。但它们各有盲区:SDK 插桩无法覆盖未接入的第三方库和外部命令行调用,Sidecar 对非 HTTP 协议(如自定义 TCP、gRPC 流式调用)的解析存在开销和兼容问题。

eBPF 提供了一条完全不同的路径——在内核的 TCP 连接层直接捕获数据,无需改应用代码、不依赖协议解析、覆盖所有 TCP 流量。

二、技术方案总体设计

整体架构分为三层:

  1. 数据采集层:eBPF 程序挂载在 tcp_connect、tcp_accept、tcp_close 等内核函数上,捕获所有 TCP 连接事件
  2. 关联层:将 PID + 网络命名空间映射为 Pod、Service 等 Kubernetes 资源
  3. 存储与展示层:实时拓扑更新 + 历史依赖图持久化
┌─────────────────────────────────────────────────────┐
│                   Kubernetes Cluster                │
│                                                     │
│  ┌─────────┐     eBPF Events      ┌──────────────┐ │
│  │   Pod A  │ ───── connect ──────▶│  userspace   │ │
│  │ (svc-a)  │◀──── accept ──────── │  collector   │ │
│  └─────────┘                      └──────┬───────┘ │
│                                          │         │
│  ┌─────────┐                      ┌──────▼───────┐ │
│  │   Pod B  │                      │  K8s API     │ │
│  │ (svc-b)  │◀──── PID mapping ───│  metadata    │ │
│  └─────────┘                      └──────┬───────┘ │
│                                          │         │
│  ┌─────────┐                      ┌──────▼───────┐ │
│  │   Pod C  │                      │  Graph DB    │ │
│  │ (svc-c)  │                      │  (Neo4j/     │ │
│  └─────────┘                      │   Prometheus)│ │
│                                   └──────────────┘ │
└─────────────────────────────────────────────────────┘

三、内核态 eBPF 程序实现

3.1 Hook 点选择

我们不需要在数据通路上做 hook(那样性能开销太大),只需要在 TCP 连接生命周期的关键事件点挂载:

// 核心 hook 点
SEC("kprobe/tcp_connect")      // 发起连接
SEC("kprobe/tcp_accept")       // 接受连接(通过 inet_csk_accept)
SEC("kprobe/tcp_close")        // 关闭连接
SEC("tracepoint/sock/inet_sock_set_state")  // 连接状态变化

选择 kprobe 而非 tracepoint 的原因是前者兼容性更好——即使在较老的内核(4.19+)上也能稳定工作。对于 5.8+ 内核,也可以使用 fentry 获得更低的开销。

3.2 关键数据结构定义

// 连接事件结构体 - 通过 ring buffer 发送到用户态
struct conn_event {
    u32 pid;
    u32 tid;
    u32 src_ip[4];    // 支持 IPv4 和 IPv6
    u32 dst_ip[4];
    u16 src_port;
    u16 dst_port;
    u8  family;       // AF_INET or AF_INET6
    u8  event_type;   // CONNECT / ACCEPT / CLOSE
    u64 timestamp;
    u64 cgroup_id;    // 用于关联容器
    char comm[16];    // 进程名
};

// BPF Maps
struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 65536);
    __type(key, struct sock *);       // TCP socket 指针
    __type(value, struct conn_info);  // 连接元信息
} conn_ctx SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);  // 256KB ring buffer
} events SEC(".maps");

这里有个关键设计:使用 BPF_MAP_TYPE_LRU_HASH 以 struct sock * 为 key 来暂存连接上下文。为什么?因为 tcp_connect 被 hook 时,目标地址可能还没有完全填充,我们需要在后续事件中补充信息。

3.3 核心 eBPF 程序逻辑

// 捕获 TCP 连接发起
SEC("kprobe/tcp_connect")
int BPF_KPROBE(trace_tcp_connect, struct sock *sk) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;

    // 过滤:只监控目标端口已知的连接
    // (可选:排除内部健康检查等噪声)
    if (is_filtered_port(pid))
        return 0;

    // 获取 cgroup ID 用于后续关联容器
    u64 cgroup_id = bpf_get_current_cgroup_id();

    // 暂存 socket 上下文
    struct conn_info info = {};
    info.pid = pid;
    info.cgroup_id = cgroup_id;
    info.timestamp = bpf_ktime_get_ns();
    bpf_get_current_comm(&info.comm, sizeof(info.comm));

    // 读取目标地址(此时内核已填充)
    BPF_CORE_READ_INTO(&info.dst_ip, sk, __sk_common.skc_v6_daddr);
    if (BPF_CORE_READ(sk, __sk_common.skc_family) == AF_INET) {
        BPF_CORE_READ_INTO(&info.dst_ip[3], sk, __sk_common.skc_daddr);
    }

    bpf_probe_read_kernel(&info.dst_port, sizeof(info.dst_port),
                          &sk->__sk_common.skc_dport);
    info.dst_port = bpf_ntohs(info.dst_port);

    // 存入 map,等待后续事件补充
    bpf_map_update_elem(&conn_ctx, &sk, &info, BPF_ANY);
    return 0;
}

// 连接建立成功后发送事件
SEC("kprobe/tcp_v4_do_connect")
int BPF_KPROBE(trace_tcp_connected, struct sock *sk, int ret) {
    if (ret != 0) return 0;  // 连接失败

    struct conn_info *info = bpf_map_lookup_elem(&conn_ctx, &sk);
    if (!info) return 0;

    struct conn_event *evt = bpf_ringbuf_reserve(&events, sizeof(*evt), 0);
    if (!evt) {
        bpf_map_delete_elem(&conn_ctx, &sk);
        return 0;
    }

    __builtin_memcpy(evt, info, sizeof(*evt));
    evt->event_type = CONN_CONNECT;
    evt->pid = info->pid;

    bpf_ringbuf_submit(evt, 0);
    bpf_map_delete_elem(&conn_ctx, &sk);
    return 0;
}

3.4 BPF CO-RE 兼容性处理

在多内核版本集群中,直接访问 struct sock 的成员在不同版本偏移不同。使用 BPF CO-RE(Compile Once, Run Everywhere)可以解决这个问题:

// 使用 BTF 和 BPF_CORE_READ 宏实现跨版本兼容
struct sock___old {
    // 旧版本内核的 struct sock 布局不同
    struct __sk_common___old __sk_common;
};

// BTF 重定义允许自动适配
struct __sk_common___old {
    union {
        struct {
            __be32 skc_daddr;
            __be32 skc_rcv_saddr;
        };
    };
};

编译推荐使用 libbpf-bootstrap 模板,配合 clang -target bpf -g 生成带 BTF 信息的 .o 文件,libbpf 在加载时会根据目标内核的 BTF 自动重定位字段偏移。

四、用户态收集器:PID 到 Pod 的映射

拿到网络事件后的关键挑战是:如何把 PID + cgroup 可追溯为 Kubernetes Pod?

4.1 核心映射链路

PID / cgroup_id
       │
       ▼
cgroup path: /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<ID>.slice/...
       │
       ▼
Pod UID (从 cgroup 路径中提取)
       │
       ▼
Kubernetes API: Pod UID → Pod Name + Labels + Service

4.2 Go 实现关键代码

package collector

import (
    "bufio"
    "context"
    "os"
    "path/filepath"
    "strings"
    "sync"
    "time"

    "github.com/cilium/ebpf"
    "github.com/cilium/ebpf/link"
    "github.com/cilium/ebpf/ringbuf"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
)

// Collector 是 eBPF 事件收集和 K8s 元数据关联的核心结构
type Collector struct {
    objs        *bpfObjects        // 生成的 eBPF 对象
    links       []link.Link        // 已挂载的 hook 点
    reader      *ringbuf.Reader    // ring buffer 读取器
    clientset   *kubernetes.Clientset
    podCache    *lru.Cache         // Pod UID → *v1.Pod 缓存
    nsCache     *lru.Cache         // netns inode → namespace 信息缓存
    eventChan   chan *ConnectionEvent
    enrichWg    sync.WaitGroup
}

// ConnectionEvent 表示一条关联了 K8s 元数据的连接事件
type ConnectionEvent struct {
    Timestamp   int64
    SrcPod      string
    SrcPodUID   string
    SrcService  string
    DstIP       string
    DstPort     uint16
    DstPod      string  // 可能未知(外部服务)
    DstService  string
    EventType   ConnType // CONNECT / ACCEPT / CLOSE
    ProcessName string
}

// Start 启动收集器
func (c *Collector) Start(ctx context.Context) error {
    // 1. 加载预编译的 eBPF 程序
    spec, err := loadBpf()
    if err != nil {
        return fmt.Errorf("load BPF: %w", err)
    }

    // 使用 libbpf CO-RE 自动适配目标内核
    opts := &ebpf.CollectionOptions{
        Programs: ebpf.ProgramOptions{
            KernelTypes,  // 从 /sys/kernel/btf/vmlinux 读取
        },
    }
    if err := spec.LoadAndAssign(&c.objs, opts); err != nil {
        return fmt.Errorf("load and assign: %w", err)
    }

    // 2. 挂载 hook 点
    if err := c.attachKprobes(); err != nil {
        return fmt.Errorf("attach kprobes: %w", err)
    }

    // 3. 启动 ring buffer 读取循环
    go c.readLoop(ctx)

    // 4. 启动事件丰富工作池
    for i := 0; i < 4; i++ {
        c.enrichWg.Add(1)
        go c.enrichWorker(ctx)
    }

    return nil
}

// loop 从 ring buffer 读取原始事件并放入处理通道
func (c *Collector) readLoop(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
        }

        record, err := c.reader.Read()
        if err != nil {
            if errors.Is(err, ringbuf.ErrClosed) {
                return
            }
            log.Errorf("read ring buffer: %v", err)
            continue
        }

        // 解析 BPF 原始事件
        rawEvt, err := parseRawEvent(record.RawSample)
        if err != nil {
            continue
        }

        c.eventChan <- rawEvt
    }
}

// enrichWorker: 将原始 PID/网络事件丰富为 K8s 资源事件
func (c *Collector) enrichWorker(ctx context.Context) {
    defer c.enrichWg.Done()

    for {
        select {
        case <-ctx.Done():
            return
        case rawEvt := <-c.eventChan:
            event := c.enrichEvent(rawEvt)
            if event != nil {
                c.emitTopologyEvent(event)
            }
        }
    }
}

// enrichEvent 核心:PID + cgroup → Pod → Service
func (c *Collector) enrichEvent(raw *rawConnEvent) *ConnectionEvent {
    // 1. 通过 netns inode 判断是否在 Pod 网络命名空间中
    netnsInfo := c.getNetnsInfo(raw.PID)
    if netnsInfo == nil {
        // 主机命名空间的连接,可能是 DaemonSet 的 hostNetwork 模式
        return c.handleHostNetworkConn(raw)
    }

    // 2. 从 Pod 缓存查找
    if podRaw, ok := c.podCache.Get(netnsInfo.PodUID); ok {
        pod := podRaw.(*v1.Pod)
        srcService := c.findServiceForPod(pod)
        return &ConnectionEvent{
            SrcPod:     pod.Name,
            SrcPodUID:  netnsInfo.PodUID,
            SrcService: srcService,
            DstIP:      formatIP(raw.DstIP),
            DstPort:    raw.DstPort,
            EventType:  raw.EventType,
        }
    }

    // 3. 缓存未命中:通过 K8s API 查询(限流保护)
    return c.fallbackResolve(raw, netnsInfo)
}

4.3 网络命名空间发现:一个常被忽略的关键

Pod 的网络命名空间不像 cgroup 那样可以直接从 /proc/<pid>/cgroup 读出。最可靠的方式是:

// refreshPodNetworkNamespaces 定期刷新网络命名空间映射
func (c *Collector) refreshPodNetworkNamespaces(ctx context.Context) {
    pods, err := c.clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{})
    if err != nil {
        log.Errorf("list pods: %v", err)
        return
    }

    for _, pod := range pods.Items {
        if pod.Status.PodIP == "" {
            continue  // Pod 尚未分配 IP
        }

        // 通过 Docker/containerd runtime 获取容器 PID
        // 对于标准 CNI 容器,可以通过 /proc/<pid>/ns/net 获取
        netnsPath := fmt.Sprintf("/proc/%d/ns/net", pid)
        info, err := os.Stat(netnsPath)
        if err != nil {
            continue
        }

        stat := info.Sys().(*syscall.Stat_t)
        nsIno := stat.Ino

        c.nsCache.Add(nsIno, &netnsInfo{
            PodUID: string(pod.UID),
            Netns:  nsIno,
        })

        // 预热 Pod 缓存
        c.podCache.Add(string(pod.UID), &pod)
    }
}

重要提示:在 containerd/CRI-O 运行时中,容器的网络命名空间 inode 可以从容器的状态信息中获取,而不是遍历所有进程。推荐使用 CRI 接口查询以降低开销。

五、拓扑图构建与实时更新

有了丰富的连接事件流后,需要构建一个有向图来表示服务间的依赖关系。

5.1 图模型设计

// 拓扑图使用邻接表存储,支持并发读写
type TopologyGraph struct {
    mu       sync.RWMutex
    nodes    map[string]*ServiceNode   // service name → node
    edges    map[string]*ServiceEdge   // "src→dst" → edge
    window   time.Duration             // 滑动窗口大小
}

type ServiceNode struct {
    Name      string
    Namespace string
    Labels    mapstring]string
    UpdatedAt time.Time
}

type ServiceEdge struct {
    SrcService   string
    DstService   string
    SrcIP        string
    DstPort      uint16
    DstType      DstType  // INTERNAL_POD / EXTERNAL / UNKNOWN
    FirstSeen    time.Time
    LastSeen     time.Time
    // 聚合统计
    ConnectionCount uint64
    // 基于时间衰减的活跃度评分
    ActivityScore   float64
}

// 使用指数衰减机制让旧的连接信息自然"淡出"
func (e *ServiceEdge) updateScore(now time.Time) {
    elapsed := now.Sub(e.LastSeen).Seconds()
    // λ = 1/3600 意味着一小时未观察到,活跃分衰减到 37%
    e.ActivityScore *= math.Exp(-elapsed / 3600.0)
}

5.2 实时聚合与降噪

生产环境中的事件量可能非常高(每秒数万条连接事件),需要高效的聚合机制:

// flushLoop 每 10 秒将缓冲区中的事件批量写入图
func (g *TopologyGraph) flushLoop(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            g.flush()
        }
    }
}

func (g *TopologyGraph) flush() {
    g.mu.Lock()
    defer g.mu.Unlock()

    now := time.Now()
    cutoff := now.Add(-g.window)

    // 1. 应用指数衰减到所有边
    for key, edge := range g.edges {
        edge.updateScore(now)
        // 清理过期边(活跃度过低且超过窗口期)
        if edge.ActivityScore < 0.01 && edge.LastSeen.Before(cutoff) {
            delete(g.edges, key)
        }
    }

    // 2. 输出当前快照到 Graph DB
    g.persist()
}

六、生产部署实战

6.1 DaemonSet 部署配置

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: net-topology-collector
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: net-topology-collector
  template:
    metadata:
      labels:
        app: net-topology-collector
    spec:
      hostPID: true        # 必需:跨 PID 命名空间监控
      hostNetwork: true    # 推荐:避免额外网络跳数
      containers:
      - name: collector
        image: your-registry/net-topology-collector:v1.2.0
        securityContext:
          privileged: true  # 5.8+ 内核可使用 CAP_BPF + CAP_PERFMON 替代
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 500m
            memory: 512Mi
        volumeMounts:
        - name: btf
          mountPath: /sys/kernel/btf
          readOnly: true
        - name: debugfs
          mountPath: /sys/kernel/debug
        env:
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              fieldPath: spec.nodeName
      volumes:
      - name: btf
        hostPath:
          path: /sys/kernel/btf
      - name: debugfs
        hostPath:
          path: /sys/kernel/debug

6.2 性能影响评估

在我们 200 节点集群(平均每个节点 80 个 Pod)上的实测数据:

指标 无 eBPF 基线 部署后 开销
CPU idle 95.2% 94.7% +0.5%
TCP 新建连接速率 45,000/s 44,800/s <1%
网络吞吐量(iperf) 25 Gbps 24.9 Gbps <0.5%
收集器内存占用 - 180 MB 固定

关键观察:kprobe 在 tcp_connect 上的挂载开销远小于在 __netif_receive_sku(数据通路)上的挂载。事件型 hook(连接建立/关闭)相比包级 hook 性能代价可忽略不计。

6.3 踩坑记录

坑1:PID 竞争导致错误映射。 容器内进程退出后,PID 可能被新分配的进程快速复用。如果一个 eBPF 事件在 connect 态读取 PID,但处理时 PID 已被另一个完全不相关的进程占用,就会产生错误的 Pod 映射。

解决方案:使用 cgroup ID(bpf_get_current_cgroup_id())作为比 PID 更稳定的标识符。cgroup ID 在内核中是单调递增的,不存在复用问题。始终用 cgroup ID 进行 Pod 关联,PID 仅作为辅助信息。

坑2:kretprobe 在异常路径上丢失事件。 当 tcp_connect 在执行过程中被中断或进程退出时,tcp_close 可能不会被调用,导致连接事件"有始无终"。

解决方案:为 LRU map 中的条目设置超时时间(建议 30 秒),超时未完成的条目作为"短连接"处理,单独统计到直方图中。

坑3:大规模集群中 K8s API 查询风暴。 在节点滚动重启后,所有 Pod 的网络映射缓存同时失效,导致对 API Server 的并发查询激增。

解决方案:使用共享的本地缓存 + Watch 机制代替逐次查询。监听 Endpoints 和 Pod 的变更事件,维护 Delta 增量更新,避免全量同步。

七、进阶能力

7.1 与 Prometheus 集成

将边活跃度和节点中心性指标暴露为 Prometheus metrics:

// 拓扑边活跃度指标
var edgeActivity = prometheus.NewGaugeVec(
    prometheus.GaugeOpts{
        Name: "service_dependency_activity",
        Help: "实时服务依赖边活跃度评分",
    },
    []string{"src_service", "dst_service", "dst_type"},
)

// 入度/出度指标(用于识别"被过度依赖"的核心服务)
var serviceDegree = prometheus.NewGaugeVec(
    prometheus.GaugeOpts{
        Name: "service_dependency_degree",
        Help: "服务依赖图中的度数",
    },
    []string{"service", "direction"},  // direction: in/out
)

配合 Grafana 的 Node Graph panel,可以实现类似 Jaeger Service Map 但覆盖更完整的实时拓扑可视化。

7.2 变更影响分析

基于历史拓扑数据,可以回答"如果 service-a 下线,会影响谁":

# 查询 service-b 的当前依赖
$ curl http://topology-api/dependency?service=svc-payment

{
  "service": "svc-payment",
  "direct_dependencies": [
    {"name": "svc-redis", "activity": 0.95, "port": 6379},
    {"name": "svc-postgres", "activity": 0.87, "port": 5432},
    {"name": "svc-mq", "activity": 0.72, "port": 5672}
  ],
  "upstream_dependents": [
    {"name": "api-gateway", "activity": 0.99},
    {"name": "svc-order", "activity": 0.65}
  ],
  "blast_radius": 3  // 受影响的上下游服务数
}

7.3 异常依赖检测

基于历史基线识别异常连接模式:

// 检测从未出现过的新依赖(可能是配置错误或攻击)
func (g *TopologyGraph) DetectAnomalies() []Anomaly {
    var anomalies []Anomaly
    for _, edge := range g.edges {
        if edge.FirstSeen.After(time.Now().Add(-5*time.Minute)) &&
           edge.ActivityScore > 0.8 {
            // 5 分钟内新出现的强依赖关系
            anomalies = append(anomalies, Anomaly{
                Type:      "new_dependency",
                Edge:      edge,
                Severity:  "warning",
                Message:   fmt.Sprintf("%s → %s: new dependency detected",
                              edge.SrcService, edge.DstService),
            })
        }
    }
    return anomalies
}

八、方案对比与适用场景

维度 eBPF 依赖发现 SDK 插桩 Sidecar 代理
代码侵入 零 需要修改代码 无
协议覆盖 TCP 连接层 HTTP/gRPC 为主 L7 协议依赖配置
覆盖完整性 100%(内核级) 依赖接入率 依赖流量经过代理
CPU 开销 ~0.5% 0.1%-2% 3%-10%
部署复杂度 DaemonSet 每应用集成 Sidecar 注入
延迟影响 无(事件驱动) 极小 增加一跳
元数据关联 需自行映射 内置 内置

总结:eBPF 方案最大的优势是零盲区覆盖——无论应用使用什么语言、什么框架、不管是否接入了可观测性 SDK,只要走 TCP 内核栈,就能被观测到。这使得它特别适合混合技术栈集群、遗留系统监控、以及安全审计场景。

九、总结

eBPF 正在重新定义 Linux 可观测性的边界。本文展示的方案在不对业务代码做一行修改的前提下,实现了完整的依赖关系自动发现。核心设计要点包括:

  1. 事件型 hook 而非数据通路 hook,确保性能开销可控
  2. cgroup ID 优先于 PID 做容器关联,避免竞争条件
  3. 指数衰减模型 替代硬过期,让拓扑图自然反映系统变化
  4. 本地缓存 + K8s Watch 避免 API Server 流量风暴

这项技术在生产运行一年后,已经帮助我们发现了 23 个文档中未记录的"幽灵依赖",将故障 MTTR 从平均 47 分钟降低到 12 分钟。对于任何运行 Kubernetes 的团队来说,这都是一项值得投入的基础能力建设。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部