WEBKT

eBPF 核心 Map 结构如何在生产环境中实现无损热升级?

2 0 0 0

在生产环境中,eBPF(Extended Berkeley Packet Filter)已经成为可观测性、网络加速和安全审计的利器。然而,随着业务逻辑的演进,eBPF 程序的升级不可避免。

如果仅仅是修改过滤算法或统计逻辑,直接替换 eBPF 字节码即可。但如果需要修改 eBPF Map 的结构(例如改变 Key/Value 的大小、增加新的字段、或者改变 Map 的最大容量限制),事情就会变得极其棘手。

因为 eBPF Map 的定义在内核中是不可变的(Immutable)。一旦创建,其 Key/Value 字节大小和类型就已经固化。直接卸载旧程序并加载新程序,会导致存储在旧 Map 中的关键运行时状态(如 Connection Tracking 连接跟踪表、会话状态、动态黑白名单等)全部丢失。

如何在不中断流量、不丢失现有状态的前提下,平滑、无损地升级运行中的 eBPF 核心 Map 结构?


核心痛点与技术挑战

在尝试升级 eBPF Map 时,通常会遇到以下三个核心瓶颈:

  1. Map 生命周期的绑定限制:eBPF Map 的生命周期由文件描述符(FD)引用计数以及 bpffs(BPF 文件系统)中的 Pin 路径决定。简单地加载新程序会创建全新的 Map,导致新旧 Map 状态割裂。
  2. 读写并发冲突:在数据迁移过程中,内核数据面(Data Path)仍在以极高的频率(如每秒数十万次数据包处理)对旧 Map 进行读写,用户态的数据迁移工具很难保证读写的原子性。
  3. 内存与性能双重开销:如果采用全量拷贝方案,在迁移瞬间,系统需要同时维持新旧两个大容量 Map 的内存占用。同时,频繁的系统调用拷贝会带来明显的 CPU 抖动。

业界最佳实践:“双 Map 降级读”与渐进式迁移方案

针对高性能网络与安全场景,目前业界(如 Cilium 社区和大型互联网基础设施)最成熟的无损升级方案是**“双 Map 降级读(Fallback Lookup)+ 用户态渐进式迁移(Progressive Migration)”**。

该方案的核心思想是:让新程序同时感知新旧两个 Map,优先读写新 Map,若未命中则降级查询旧 Map,并在内核态或用户态进行按需迁移;同时,由用户态守护进程在后台进行“冷数据”的扫尾迁移,最终实现平滑过渡。

整个升级过程可以拆解为以下五个步骤:

+-------------------------------------------------------------+
| 步骤 1: 加载新程序(过渡态),其同时关联 map_old 与 map_new    |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
| 步骤 2: 将网络/系统事件挂载点(XDP/TC等)原子替换为新程序        |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
| 步骤 3: 触发内核态降级读与在途迁移(Hot Path Migration)       |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
| 步骤 4: 用户态后台协程遍历 map_old,转换格式并写入 map_new     |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
| 步骤 5: 加载终态程序(仅关联 map_new),释放 map_old 资源      |
+-------------------------------------------------------------+

第一步:准备过渡态 eBPF 程序

首先,我们需要编写一个“过渡态”的 eBPF 程序。这个程序在定义中同时引入了旧 Map 的定义(可以通过 Pin 路径复用)和新 Map 的定义。

假设我们要升级一个连接跟踪表(Conntrack Map),旧版的 Value 只有 packets 统计,新版需要增加 bytes 统计和 last_seen 时间戳:

// 旧版数据结构
struct ct_val_old {
    __u64 packets;
};

// 新版数据结构
struct ct_val_new {
    __u64 packets;
    __u64 bytes;
    __u64 last_seen;
};

// 过渡态程序中,同时定义两个 Map
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, struct ct_key);
    __type(value, struct ct_val_old);
    __uint(max_entries, 65536);
} ct_map_old SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, struct ct_key);
    __type(value, struct ct_val_new);
    __uint(max_entries, 131072); // 顺便完成了扩容
} ct_map_new SEC(".maps");

第二步:内核态“降级读”与动态迁移

在过渡态程序的数据面逻辑中,我们实现“双写降级读”逻辑。当一个报文到达时:

  1. 优先去 ct_map_new 中查找连接状态。
  2. 如果找到,直接更新新结构体的统计值,流程结束。
  3. 如果未找到,说明可能是一个新连接,但也可能是一个存在于 ct_map_old 中的存量连接。
  4. 此时,降级去 ct_map_old 中查找:
    • 如果在 ct_map_old 中找到了该连接,则读取旧数据,并将其转换并升级为新格式。
    • 将升级后的数据写入 ct_map_new
    • (可选)从 ct_map_old 中删除该 Key,避免后续重复查找。
    • 继续报文处理逻辑。
  5. 如果两个 Map 都未找到,说明是纯粹的新连接,直接在 ct_map_new 中创建新条目。
SEC("xdp")
int xdp_conntrack_transition(struct xdp_md *ctx) {
    struct ct_key key = {};
    if (extract_key(ctx, &key) < 0)
        return XDP_PASS;

    // 1. 优先查新表
    struct ct_val_new *val_new = bpf_map_lookup_elem(&ct_map_new, &key);
    if (val_new) {
        val_new->packets++;
        val_new->bytes += ctx->data_end - ctx->data;
        val_new->last_seen = bpf_ktime_get_ns();
        return XDP_PASS;
    }

    // 2. 降级查旧表
    struct ct_val_old *val_old = bpf_map_lookup_elem(&ct_map_old, &key);
    if (val_old) {
        // 3. 在内核态进行在途格式转换与动态迁移
        struct ct_val_new migrated_val = {
            .packets = val_old->packets + 1,
            .bytes = ctx->data_end - ctx->data,
            .last_seen = bpf_ktime_get_ns(),
        };
        
        // 写入新表
        bpf_map_update_elem(&ct_map_new, &key, &migrated_val, BPF_ANY);
        // 从旧表中删除,减少后续查旧表的开销
        bpf_map_delete_elem(&ct_map_old, &key);
        
        return XDP_PASS;
    }

    // 4. 纯新连接逻辑
    struct ct_val_new init_val = {
        .packets = 1,
        .bytes = ctx->data_end - ctx->data,
        .last_seen = bpf_ktime_get_ns(),
    };
    bpf_map_update_elem(&ct_map_new, &key, &init_val, BPF_ANY);

    return XDP_PASS;
}

第三步:原子替换与 Pin 关联

在用户态(以 Go 语言结合 cilium/ebpf 库为例),加载该过渡态程序时,我们需要将新定义的 ct_map_old 重新绑定(Reassociate)到系统已经 Pin 在 bpffs 中的旧 Map 文件描述符上。

// 1. 获取已经在系统运行的旧 Map 的 FD
oldMapPath := "/sys/fs/bpf/ct_map"
oldMap, err := ebpf.LoadPinnedMap(oldMapPath, nil)
if err != nil {
    log.Fatalf("无法加载旧 Map: %v", err)
}

// 2. 实例化过渡态程序
spec, err := loadTransitionSpec() // 加载编译好的过渡态 ELF
if err != nil {
    log.Fatalf("加载 Spec 失败: %v", err)
}

// 3. 核心步骤:将过渡态程序中声明的 ct_map_old 替换为实际运行中的旧 Map 的 FD
spec.Maps["ct_map_old"] = oldMap.Spec().Copy() // 保证属性匹配

coll, err := ebpf.NewCollectionWithOptions(spec, ebpf.CollectionOptions{
    MapReplacements: map[string]*ebpf.Map{
        "ct_map_old": oldMap, // 强制关联
    },
})

完成关联并加载后,使用 link 接口(或 tc 命令)将网卡上的旧程序原子替换为新的过渡态程序。此时,高频的数据包在进入系统时,已经开始无缝迁移热点连接数据了。

第四步:用户态冷数据扫尾迁移

通过降级读,高频活跃的连接状态已经在内核态瞬间迁移完毕。但是,对于那些处于非活跃(但仍未老化超时的)“冷数据”条目,数据包可能在短时间内不会触发它们。为了确保数据完整性,我们需要用户态起一个后台协程,对旧 Map 进行一次全量扫描与迁移。

用户态扫尾迁移的逻辑如下:

var (
    key    ctKey
    valOld ctValOld
)

// 使用 Iterator 遍历旧 Map
iter := oldMap.Iterate()
for iter.Next(&key, &valOld) {
    // 检查该 Key 在新 Map 中是否已经存在(可能已经被内核态在途迁移写入了)
    var valNew ctValNew
    err := newMap.Lookup(&key, &valNew)
    if err == nil {
        // 新 Map 中已存在,说明内核态已经处理过了,跳过
        continue
    }

    // 转换格式
    valNew = ctValNew{
        Packets:  valOld.Packets,
        Bytes:    0, // 历史缺省数据填充
        LastSeen: uint64(time.Now().UnixNano()),
    }

    // 写入新 Map (使用 BPF_NOEXIST 避免覆盖内核态刚刚更新的更热的数据)
    _ = newMap.Put(&key, &valNew) // 如果新表已经有了,此操作会返回失败,刚好符合预期
    
    // 从旧表中删除
    _ = oldMap.Delete(&key)
}

第五步:收尾与加载终态程序

当用户态的扫尾迁移完成,且旧 Map 的元素个数趋近于 0 时,说明迁移已经彻底完成。

此时:

  1. 编写并加载“终态” eBPF 程序(该程序只定义并使用 ct_map_new,去除降级读的分支逻辑以保障最优性能)。
  2. 将网卡上的挂载点原子替换为终态程序。
  3. 删除 bpffs 中旧 Map 的 Pin 文件,关闭旧 Map 的相关文件描述符。内核检测到引用计数归零后,会自动释放旧 Map 占用的系统内存。

升级方案对比

除了上述方案,业界在某些特定场景下也会选用其他变种方案。以下是各方案的优缺点对比:

迁移方案 丢包/状态丢失风险 内存开销 CPU/性能开销 适用场景
直接卸载重装 极高(状态完全丢失,连接重置) 极低 无额外消耗 测试环境或无状态的 eBPF 过滤工具
Map-in-Map 原子替换 (新 Map 初始化为空) 高(双倍内存) 极低 适合存储静态路由、ACL 规则等只读/低频更新配置
双 Map 降级读 + 渐进迁移 (完全无损) 高(过渡期双倍内存) 数据面增加一次 Lookup 开销(过渡期) 高并发、强状态网络/安全网关(推荐)

避坑与工程细节指南

  1. 结构体对齐(Struct Padding)问题
    在 C 语言中,扩容 eBPF Map 的 Value 结构体时,必须保证新旧结构体的对齐方式完全一致。建议显式使用 __attribute__((packed)),或者通过增加显式的 padding 字段来填补字节。否则,用户态与内核态在解析相同 struct 时可能会发生数据错位。
  2. 容量极限与 OOM 规避
    如果原 Map 已经接近满载,在迁移到新 Map 时,由于过渡期两个 Map 共同存在,容易触发系统的 locked-memory 限制(可以通过在用户态调用 rlimit.RemoveMemlock() 来解除限制)。
  3. 哈希冲突与 BPF_NOEXIST 的妙用
    在用户态扫尾迁移写入新 Map 时,切记使用 BPF_NOEXIST(在 Go 中通常是 Map.Put 的变体操作,或显式系统调用参数)。这可以有效防止用户态把“历史旧值”覆写回新 Map,从而覆盖了内核态在途更新的、包含最新度量值的数据。

通过这套降级读与双写的设计,可以在不损失任何连接状态的前提下,完成生产级别高并发网络数据面的热升级。对于追求极致性能与稳定性的平台工程师而言,这是一套必须掌握的 eBPF 进阶架构模式。

架构深渊 eBPFLinux内核数据迁移

评论点评