在 Kubernetes 集群环境下,如果跨节点的 Pod 需要组成多个 VPC 网络,使用 eBPF 的时候,该如何在 CNI 层面动态地为每个 Pod 分配 VXLAN VNI 或者 VLAN ID 呢?

一个简单可行的办法是,每次 CNI 为 Pod 挂载 eBPF 程序的时候,都去重新编译 eBPF 程序。这样一来,就可以动态地向 eBPF 程序注入 VXLAN VNI 或者 VLAN ID 了。

可是,这办法会导致每次都需要生成新的 eBPF 源代码、运行 clang 命令行去编译。由此可知,CNI 需要为每个 Pod 都进行文件操作、命令行执行;这些处理都会带来性能损耗、时间损耗、更低的稳定性。

有没有像 CO-RE 那样只需要一次编译、还可以动态注入 VXLAN VNI 或者 VLAN ID 的办法呢?

下面的试验以 ping 为例,动态注入 VXLAN VNI 或者 VLAN ID 也可以采用同样的办法。

全局变量法

全局变量的办法,指的是 eBPF 程序里使用全局变量,然后使用工具去变更 eBPF 程序 ELF 文件里的全局变量。

在 Linux 系统里,clang 编译 eBPF 程序得到的文件格式是 ELF。而在 ELF 文件里,使用工具能够变更其中的全局变量的值。参考 On self-modifying executables in Go

原理如下图:

update global variable

以下为试验代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
__u32 target_addr = 0xFEDCBA98;

SEC("egress")
int filter_out(void *skb) {
  struct iphdr iph;

  if (bpf_skb_load_bytes_relative(skb, 0, &iph, sizeof(iph),
                                  BPF_HDR_START_NET) < 0)
    return TC_ACT_OK;

  if (iph.protocol != IPPROTO_ICMP) return TC_ACT_OK;

  bpf_printk("from ebpf inject-global-var, 0x%08X -> 0x%08X, target 0x%08X\n",
             iph.saddr, iph.daddr, target_addr);

  if (iph.daddr == target_addr) return TC_ACT_SHOT;

  return TC_ACT_OK;
}

可以通过以下方式修改 ELF 文件里的 target_addr 变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func (e *entry) put(data []byte, val uint32, bo binary.ByteOrder) {
	data = data[e.off:]
	switch e.val.(type) {
	case uint8:
		data[0] = byte(val)
	case uint16:
		bo.PutUint16(data[:2], uint16(val))
	case uint32:
		bo.PutUint32(data[:4], val)
	case uint64:
		bo.PutUint64(data[:8], uint64(val))
	}
}

func getEntry(f *elf.File, name string) (*entry, error) {
	syms := must(f.Symbols())
	for _, s := range syms {
		if s.Name == name {
			sect := f.Sections[s.Section]
			bs, _ := sect.Data()
			varOff := s.Value - sect.Addr

			var e entry
			e.off = sect.Offset + varOff
			e.get(bs[varOff:], s.Size, f.ByteOrder)
			return &e, nil
		}
	}
	return nil, fmt.Errorf("can't find symbol '%s'", name)
}

完整代码请参考:ebpf-inject-global-var

效果如下:

update global variable

常量替换法

常量替换的办法,指的是在 eBPF 程序里使用某个特定的常量当作占位符,然后在 Go 程序里解析 eBPF 程序的 ELF 文件后得到 eBPF 汇编之后,将 eBPF 汇编里的那个特定的常量替换为一个新的值。

相比 动态变更全局变量 办法,该办法能够规避因全局变量而带来的内核版本不兼容性(更严格的 eBPF verifier 可能会禁止使用全局变量)。但是对于一个工程而言,该办法在工程上的实践并不友好。

原理如下图:

replace constant number

以下为试验代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#define target_addr 0xFEDCBA98

SEC("egress")
int filter_out(void *skb) {
  struct iphdr iph;

  if (bpf_skb_load_bytes_relative(skb, 0, &iph, sizeof(iph),
                                  BPF_HDR_START_NET) < 0)
    return TC_ACT_OK;

  if (iph.protocol != IPPROTO_ICMP) return TC_ACT_OK;

  bpf_printk(
      "from ebpf inject-replace-const, 0x%08X -> 0x%08X, target 0x%08X\n",
      iph.saddr, iph.daddr, target_addr);

  if (iph.daddr == target_addr) return TC_ACT_SHOT;

  return TC_ACT_OK;
}

然后在解析 eBPF 程序的 ELF 文件后,替换 eBPF 汇编里的特定常量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func attachBpfProg(ifindex int, targetAddr uint32, data []byte) error {
	var bpfObj struct {
		Prog *ebpf.Program `ebpf:"filter_out"`
	}

	bpfSpec, err := ebpf.LoadCollectionSpecFromReader(bytes.NewReader(data))
	if err != nil {
		return err
	}

	for _, prog := range bpfSpec.Programs {
		prog.Type = ebpf.SchedCLS
	}

	// update target address
	for _, prog := range bpfSpec.Programs {
		for i := range prog.Instructions {
			if prog.Instructions[i].Constant == 0xFEDCBA98 {
				prog.Instructions[i].Constant = int64(targetAddr)
			}
		}
	}

	if err := bpfSpec.LoadAndAssign(&bpfObj, nil); err != nil {
		return err
	}

	return attachTcEgress(ifindex, bpfObj.Prog.FD())
}

完整代码请参考:ebpf-inject-replace-const

效果如下:

replace constant number

实验环境

1
2
3
4
5
6
7
8
9
# lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 21.10
Release:        21.10
Codename:       impish

# uname -a
Linux vultr 5.13.0-28-generic #31-Ubuntu SMP Thu Jan 13 17:41:06 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

小结

绞尽脑汁,终于想到这些“黑魔法”来达成目标。

而能够想到这些办法的背后,就是要对使用 cilium/ebpf 加载 eBPF 程序的过程比较了解、对 eBPF 程序的编译、加载、运行有所了解。

总结

大胆设想,小心求证。这是本次研究的最大心得。

研究的思想不囿于开发时的常规思维,需要在了解相关知识的前提下去大胆地设想解决办法。如若没有一定的知识积累,断然很难想到这些“黑魔法”。