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 流量。
二、技术方案总体设计
整体架构分为三层:
- 数据采集层:eBPF 程序挂载在
tcp_connect、tcp_accept、tcp_close等内核函数上,捕获所有 TCP 连接事件 - 关联层:将 PID + 网络命名空间映射为 Pod、Service 等 Kubernetes 资源
- 存储与展示层:实时拓扑更新 + 历史依赖图持久化
┌─────────────────────────────────────────────────────┐
│ 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 可观测性的边界。本文展示的方案在不对业务代码做一行修改的前提下,实现了完整的依赖关系自动发现。核心设计要点包括:
- 事件型 hook 而非数据通路 hook,确保性能开销可控
- cgroup ID 优先于 PID 做容器关联,避免竞争条件
- 指数衰减模型 替代硬过期,让拓扑图自然反映系统变化
- 本地缓存 + K8s Watch 避免 API Server 流量风暴
这项技术在生产运行一年后,已经帮助我们发现了 23 个文档中未记录的"幽灵依赖",将故障 MTTR 从平均 47 分钟降低到 12 分钟。对于任何运行 Kubernetes 的团队来说,这都是一项值得投入的基础能力建设。

发表评论 取消回复