iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Cloud Native

30 篇文帶你用 eBPF 與 Golang 打造 Linux Scheduler系列 第 8

實戰:打造 Tiny Load Balancer

  • 分享至 

  • xImage
  •  

如果覺得文章對你有所啟發,可以考慮用 🌟 支持 Gthulhu 專案,短期目標是集齊 300 個 🌟 藉此被 CNCF Landscape 採納 [ref]

前言

在上一篇文章中,我們學習了 XDP 的基本概念和封包過濾技術。今天我們將進行一個稍具挑戰性的實戰專案:使用 XDP 技術打造一個完整的 Tiny Load Balancer。

該專案受 eBPF Summit 2021: An eBPF Load Balancer from scratch 啟發,並且修改自 lb-from-scratch 專案。

這個專案將帶領我們深入學習:

  • 網路封包的解析與修改
  • L2/L3/L4 層的封包處理
  • 實作簡單的負載均衡演算法
  • 完整的測試環境搭建

通過這個實戰專案,您將對 eBPF 在網路程式設計中的強大能力有更深刻的理解。

負載均衡器架構設計

整體架構

┌─────────────┐    ┌─────────────────┐    ┌─────────────┐
│   Client    │───▶│  Load Balancer  │───▶│  Backend A  │
│192.17.0.4   │    │  192.17.0.5     │    │192.17.0.2   │
└─────────────┘    │                 │    └─────────────┘
                   │    XDP Hook     │
                   │                 │    ┌─────────────┐
                   └─────────────────┘───▶│  Backend B  │
                                          │192.17.0.3   │
                                          └─────────────┘

負載均衡原理

我們的 TinyLB 工作原理:

  1. 入站處理:攔截發送到 LB IP 的封包
  2. 後端選擇:使用演算法選擇後端伺服器
  3. 封包修改:修改目標 IP 和 MAC 位址
  4. 轉發封包:將修改後的封包傳送出去
  5. 出站處理:修改返回封包的來源資訊

eBPF 程式實作

主程式檔案

建立 xdp_lb_kern.c

#include "xdp_lb_kern.h"

#define IP_ADDRESS(x) (unsigned int)(192 + (17 << 8) + (0 << 16) + (x << 24))
#define BACKEND_A 2
#define BACKEND_B 3
#define CLIENT 4
#define LB 5
#define HTTP_PORT 80

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__type(key, __u32);
	__type(value, __u32);
	__uint(max_entries, 64);
} lb_map SEC(".maps");

SEC("xdp")
int tiny_lb(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if (data + sizeof(struct ethhdr) > data_end)
        return XDP_ABORTED;

    if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
        return XDP_PASS;
    struct iphdr *iph = data + sizeof(struct ethhdr);
    if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) > data_end)
        return XDP_ABORTED;

    if (iph->protocol != IPPROTO_TCP)
        return XDP_PASS;

    struct tcphdr *tcph = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
    if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct tcphdr) > data_end)
        return XDP_ABORTED;


    __be32 ip_saddr = iph->saddr;
    if (ip_saddr == IP_ADDRESS(CLIENT) && 
        bpf_ntohs(tcph->dest) == HTTP_PORT)
    {
        bpf_printk("Got http request from %x", ip_saddr);
        int dst = BACKEND_A;
        if (bpf_ktime_get_ns() % 2)
            dst = BACKEND_B;

        __u32 *dst_ip = bpf_map_lookup_elem(&lb_map, &dst);
        if (!dst_ip) {
            bpf_printk("Error: Destination IP not found in the map");
            return XDP_PASS;
        }

        iph->daddr = bpf_htonl(*dst_ip);
        eth->h_dest[5] = dst;
    } else if (iph->saddr == IP_ADDRESS(BACKEND_A) || iph->saddr == IP_ADDRESS(BACKEND_B))
    {
        bpf_printk("Got the http response from backend [%x]: forward to client %x", iph->saddr, iph->daddr);

        int c = CLIENT;
        __u32 *client_ip = bpf_map_lookup_elem(&lb_map, &c);
        if (!client_ip) {
            bpf_printk("Error: Client IP not found in the map");
            return XDP_PASS;
        }

        iph->daddr = bpf_htonl(*client_ip);
        eth->h_dest[5] = CLIENT;
    }

    iph->saddr = IP_ADDRESS(LB);
    eth->h_source[5] = LB;

    iph->check = iph_csum(iph);

    bpf_printk("Forward from lb %x to client %x", iph->saddr, iph->daddr);

    return XDP_TX;
}

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

參考上方的程式碼,XDP type program 允許接受 pointer to struct xdp_md 作為 program context。這個 context 本身就是 L2 以上的資料內容,所以我們可以做型別轉換來取得每一層的資料的起始位置:

SEC("xdp")
int tiny_lb(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if (data + sizeof(struct ethhdr) > data_end)
        return XDP_ABORTED;

    if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
        return XDP_PASS;
    struct iphdr *iph = data + sizeof(struct ethhdr);
    if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) > data_end)
        return XDP_ABORTED;

    if (iph->protocol != IPPROTO_TCP)
        return XDP_PASS;

    struct tcphdr *tcph = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
    if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct tcphdr) > data_end)
        return XDP_ABORTED;

藉由這個方式,我們就能將不屬於 TCP 的網路封包過濾掉囉。

讓我們繼續向下追蹤,會發現範例程式使用 eBPF Maps 來管理 Load Balancer 後面 Backend Service 的 entity:

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__type(key, __u32);
	__type(value, __u32);
	__uint(max_entries, 64);
} lb_map SEC(".maps");

稍後會在 Golang 撰寫的管理程式看到操作 MAP 的程式碼。

若 Dst Port 為 80 且 Src IP 來自給定的 Client,那麼就根據當前的時間 %2 決定要將請求轉發到哪一個後端伺服器中:

    if (ip_saddr == IP_ADDRESS(CLIENT) && 
        bpf_ntohs(tcph->dest) == HTTP_PORT)
    {
        bpf_printk("Got http request from %x", ip_saddr);
        int dst = BACKEND_A;
        if (bpf_ktime_get_ns() % 2)
            dst = BACKEND_B;

        __u32 *dst_ip = bpf_map_lookup_elem(&lb_map, &dst);
        if (!dst_ip) {
            bpf_printk("Error: Destination IP not found in the map");
            return XDP_PASS;
        }

        iph->daddr = bpf_htonl(*dst_ip);
        eth->h_dest[5] = dst;
    }

反之,若 Src IP 來自後端伺服器,我們就將請求的 Dst IP 設定為 Client IP:

else if (iph->saddr == IP_ADDRESS(BACKEND_A) || iph->saddr == IP_ADDRESS(BACKEND_B))
    {
        bpf_printk("Got the http response from backend [%x]: forward to client %x", iph->saddr, iph->daddr);

        int c = CLIENT;
        __u32 *client_ip = bpf_map_lookup_elem(&lb_map, &c);
        if (!client_ip) {
            bpf_printk("Error: Client IP not found in the map");
            return XDP_PASS;
        }

        iph->daddr = bpf_htonl(*client_ip);
        eth->h_dest[5] = CLIENT;
    }

Go 語言負載均衡器管理程式

建立 main.go

package main

import (
	"os"
	"time"
	"unsafe"

	bpf "github.com/aquasecurity/libbpfgo"
)

func IP_ADDRESS(d, c, b, a int) uint32 {
	return uint32(a + b<<8 + c<<16 + d<<24)
}

func main() {
	bpfModule, err := bpf.NewModuleFromFile("xdp_lb_kern.o")
	if err != nil {
		panic(err)
	}
	defer bpfModule.Close()

	if err := bpfModule.BPFLoadObject(); err != nil {
		panic(err)
	}

	prog, err := bpfModule.GetProgram("tiny_lb")
	if err != nil {
		panic(err)
	}

	// TODO: support xdpgeneric
	// link, err := prog.AttachXDP("eth0")
	// if err != nil {
	// 	panic(err)
	// }
	// if link.FileDescriptor() == 0 {
	// 	os.Exit(-1)
	// }

	prog_map, err := bpfModule.GetMap("lb_map")
	if err != nil {
		panic(err)
	} else {
		clientIP := IP_ADDRESS(192, 17, 0, 4)
		backendAIP := IP_ADDRESS(192, 17, 0, 2)
		backendBIP := IP_ADDRESS(192, 17, 0, 3)
		lbIP := IP_ADDRESS(192, 17, 0, 5)

		c := uint32(4)
		a := uint32(2)
		b := uint32(3)
		lb := uint32(5)

		prog_map.Update(unsafe.Pointer(&c), unsafe.Pointer(&clientIP))
		prog_map.Update(unsafe.Pointer(&a), unsafe.Pointer(&backendAIP))
		prog_map.Update(unsafe.Pointer(&b), unsafe.Pointer(&backendBIP))
		prog_map.Update(unsafe.Pointer(&lb), unsafe.Pointer(&lbIP))
	}

	prog, err = bpfModule.GetProgram("capture_skb")
	if err != nil {
		panic(err)
	}
	link, err := prog.AttachGeneric()
	if err != nil {
		panic(err)
	}
	if link.FileDescriptor() == 0 {
		os.Exit(-1)
	}

	for {
		time.Sleep(10 * time.Second)
	}
}

Docker Compose 測試環境

由於 Client、Backend A 與 Backend B 的 IP 在上面的範例中都是固定的,為了精簡測試環境的搭建,我們使用 docker-compose 部署測試必要的服務:
建立 docker-compose.yml

version: '3.8'
services:
  client:
    container_name: client
    image: ubuntu:20.04
    command: bash -c "apt-get update && apt-get install -y curl iputils-ping && sleep infinity"
    privileged: true
    cap_add:
      - NET_ADMIN
    networks:
      lbnet:
        ipv4_address: 192.17.0.4
    depends_on:
      - lb

  lb:
    container_name: lb
    image: ianchen0119/lb:latest
    command: bash -c "make run"
    privileged: true
    cap_add:
      - NET_ADMIN
    volumes:
      - ./xdp_lb_user:/ianchen/xdp_lb_user
      - ./Makefile:/ianchen/Makefile
      - ./xdp_lb_kern.h:/ianchen/xdp_lb_kern.h
      - ./xdp_lb_kern.c:/ianchen/xdp_lb_kern.c
      - ./vmlinux:/ianchen/vmlinux
      - ./main:/ianchen/main
    networks:
      lbnet:
        ipv4_address: 192.17.0.5

  backend-a:
    container_name: backend-a
    image: nginxdemos/hello:plain-text
    privileged: true
    cap_add:
      - NET_ADMIN
    networks:
      lbnet:
        ipv4_address: 192.17.0.2
    depends_on:
    - lb

  backend-b:
    container_name: backend-b
    image: nginxdemos/hello:plain-text
    privileged: true
    cap_add:
      - NET_ADMIN
    networks:
      lbnet:
        ipv4_address: 192.17.0.3
    depends_on:
      - lb

networks:
  lbnet:
    ipam:
      driver: default
      config:
        - subnet: 192.17.0.0/24
    driver_opts:
      com.docker.network.bridge.name: br-lb

volumes:
  dbdata:

測試與驗證

請參考 tinyLB README,內含詳細的說明。
這次的範例比起先前筆者發表於 StarBugs 的文章有些許的差異:

  1. 使用 Maps 讓 User Space 管理程式能夠指派 Backend server 的 IP
  2. 使用 libbpf-go 撰寫 User Space 管理程式

總結

在本篇文章中,我們實作了一個完整的 Tiny Load Balancer:

  1. 深入理解了負載均衡原理:從封包攔截到後端選擇的完整流程
  2. 掌握了封包修改技術:IP/MAC 地址修改和 checksum 重新計算
  3. 建立了完整的測試環境:Docker Compose 環境和自動化測試

這個實戰專案展示了 XDP 在實際網路應用中的強大能力,為後續學習更複雜的 eBPF 應用打下堅實基礎。

筆者的話:
Coding Agent 產出文章的品質還是有待加強,所以今日的內容我有手動修改一番。
XDP 雖然能高速、巨量的處理網路包,但完全早於 kernel 的網路堆疊也會造成一些問題:

  • 若有 NAT 功能的需求,必須自行實作,否則因改用 TC type 的 eBPF。當然,這也會損失一些效能。
  • XDP 與 TC 都沒辦法處理 IP Fragment 的問題,如果有這部分的需求得使用其他方法
    總而言之,XDP 非常適合打造 FireWall、LoadBalancer 這類網路功能,但如果有更複雜的封包處理要求,仍需要放棄 XDP 的吞吐量轉而使用更適合的 eBPF program type。

參考資源


上一篇
eBPF 網路程式設計入門
系列文
30 篇文帶你用 eBPF 與 Golang 打造 Linux Scheduler8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言