Giới thiệu ngắn gọn về BPF và eBPF

Xin chào, Habr! Chúng tôi muốn thông báo với bạn rằng chúng tôi đang chuẩn bị phát hành một cuốn sách."Khả năng quan sát Linux với BPF".

Giới thiệu ngắn gọn về BPF và eBPF
Do máy ảo BPF tiếp tục phát triển và được sử dụng tích cực trong thực tế nên chúng tôi đã dịch cho bạn một bài viết mô tả các khả năng chính và trạng thái hiện tại của nó.

Trong những năm gần đây, các công cụ và kỹ thuật lập trình ngày càng trở nên phổ biến để bù đắp cho những hạn chế của nhân Linux trong những trường hợp cần xử lý gói hiệu suất cao. Một trong những kỹ thuật phổ biến nhất thuộc loại này được gọi là bỏ qua hạt nhân (bỏ qua hạt nhân) và cho phép, bỏ qua lớp mạng hạt nhân, thực hiện tất cả quá trình xử lý gói từ không gian người dùng. Bỏ qua kernel cũng liên quan đến việc kiểm soát card mạng từ không gian người dùng. Nói cách khác, khi làm việc với card mạng, chúng ta dựa vào driver không gian người dùng.

Bằng cách chuyển toàn quyền kiểm soát card mạng sang chương trình không gian người dùng, chúng tôi giảm chi phí hạt nhân (chuyển ngữ cảnh, xử lý lớp mạng, ngắt, v.v.), điều này khá quan trọng khi chạy ở tốc độ 10Gb/s trở lên. Bỏ qua hạt nhân cộng với sự kết hợp của các tính năng khác (xử lý hàng loạt) và điều chỉnh hiệu suất cẩn thận (kế toán NUMA, cách ly CPU, v.v.) tương ứng với các nguyên tắc cơ bản của xử lý mạng hiệu suất cao trong không gian người dùng. Có lẽ một ví dụ mẫu mực về cách tiếp cận mới này để xử lý gói là CHDCND Triều Tiên từ Intel (Bộ công cụ phát triển mặt phẳng dữ liệu), mặc dù có những công cụ và kỹ thuật nổi tiếng khác, bao gồm VPP (Xử lý gói vectơ) của Cisco, Netmap và, tất nhiên, lém lỉnh.

Việc tổ chức các tương tác mạng trong không gian người dùng có một số nhược điểm:

  • Nhân hệ điều hành là một lớp trừu tượng cho tài nguyên phần cứng. Bởi vì các chương trình không gian người dùng phải quản lý tài nguyên của chúng một cách trực tiếp nên chúng cũng phải quản lý phần cứng của riêng mình. Điều này thường có nghĩa là phải lập trình trình điều khiển của riêng bạn.
  • Bởi vì chúng tôi đang từ bỏ hoàn toàn không gian hạt nhân nên chúng tôi cũng từ bỏ tất cả chức năng mạng do hạt nhân cung cấp. Các chương trình trong không gian người dùng phải triển khai lại các tính năng có thể đã được cung cấp bởi kernel hoặc hệ điều hành.
  • Các chương trình hoạt động ở chế độ hộp cát, điều này hạn chế nghiêm trọng sự tương tác của chúng và ngăn chúng tích hợp với các phần khác của hệ điều hành.

Về bản chất, khi kết nối mạng diễn ra trong không gian người dùng, hiệu suất đạt được bằng cách di chuyển xử lý gói từ kernel sang không gian người dùng. XDP thực hiện hoàn toàn ngược lại: nó di chuyển các chương trình mạng từ không gian người dùng (bộ lọc, bộ phân giải, định tuyến, v.v.) vào không gian kernel. XDP cho phép chúng tôi thực hiện chức năng mạng ngay khi gói chạm vào giao diện mạng và trước khi nó bắt đầu di chuyển vào hệ thống con mạng hạt nhân. Kết quả là tốc độ xử lý gói tăng lên đáng kể. Tuy nhiên, làm thế nào kernel cho phép người dùng thực thi chương trình của họ trong không gian kernel? Trước khi trả lời câu hỏi này, chúng ta hãy xem BPF là gì.

BPF và eBPF

Mặc dù có cái tên khó hiểu nhưng BPF (Berkeley Packet Filtering) trên thực tế là một mô hình máy ảo. Máy ảo này ban đầu được thiết kế để xử lý việc lọc gói, do đó có tên như vậy.

Một trong những công cụ nổi tiếng nhất sử dụng BPF là tcpdump. Khi chụp các gói bằng cách sử dụng tcpdump người dùng có thể chỉ định một biểu thức để lọc các gói. Chỉ các gói phù hợp với biểu thức này mới bị bắt. Ví dụ: biểu thức “tcp dst port 80” đề cập đến tất cả các gói TCP đến trên cổng 80. Trình biên dịch có thể rút ngắn biểu thức này bằng cách chuyển đổi nó thành mã byte BPF.

$ sudo tcpdump -d "tcp dst port 80"
(000) ldh [12] (001) jeq #0x86dd jt 2 jf 6
(002) ldb [20] (003) jeq #0x6 jt 4 jf 15
(004) ldh [56] (005) jeq #0x50 jt 14 jf 15
(006) jeq #0x800 jt 7 jf 15
(007) ldb [23] (008) jeq #0x6 jt 9 jf 15
(009) ldh [20] (010) jset #0x1fff jt 15 jf 11
(011) ldxb 4*([14]&0xf)
(012) ldh [x + 16] (013) jeq #0x50 jt 14 jf 15
(014) ret #262144
(015) ret #0

Đây là những gì chương trình trên thực hiện về cơ bản:

  • Lệnh (000): Tải gói ở offset 12, dưới dạng từ 16 bit, vào bộ tích lũy. Offset 12 tương ứng với loại ether của gói.
  • Lệnh (001): so sánh giá trị trong bộ tích lũy với 0x86dd, nghĩa là với giá trị ethertype cho IPv6. Nếu kết quả là đúng thì bộ đếm chương trình sẽ chuyển sang lệnh (002) và nếu không thì chuyển sang lệnh (006).
  • Lệnh (006): so sánh giá trị với 0x800 (giá trị ethertype cho IPv4). Nếu câu trả lời đúng thì chương trình sẽ chuyển đến (007), nếu không thì đến (015).

Và cứ như vậy cho đến khi chương trình lọc gói trả về kết quả. Đây thường là Boolean. Trả về giá trị khác 014 (lệnh (015)) có nghĩa là gói đã được chấp nhận và trả về giá trị XNUMX (lệnh (XNUMX)) có nghĩa là gói không được chấp nhận.

Máy ảo BPF và mã byte của nó được Steve McCann và Van Jacobson đề xuất vào cuối năm 1992 khi bài báo của họ được xuất bản. Bộ lọc gói BSD: Kiến trúc mới để chụp gói cấp độ người dùng, công nghệ này lần đầu tiên được trình bày tại hội nghị Usenix vào mùa đông năm 1993.

Vì BPF là một máy ảo nên nó xác định môi trường chạy các chương trình. Ngoài mã byte, nó còn xác định mô hình bộ nhớ lô (lệnh tải được áp dụng ngầm cho lô), các thanh ghi (A và X; thanh ghi tích lũy và chỉ mục), bộ nhớ lưu trữ đầu và bộ đếm chương trình ẩn. Điều thú vị là mã byte BPF được mô phỏng theo Motorola 6502 ISA. Như Steve McCann đã nhớ lại trong bài viết của mình báo cáo toàn thể tại Sharkfest '11, anh ấy đã quen với bản dựng 6502 từ thời trung học khi lập trình trên Apple II và kiến ​​thức này đã ảnh hưởng đến công việc thiết kế mã byte BPF của anh ấy.

Hỗ trợ BPF được triển khai trong nhân Linux ở các phiên bản v2.5 trở lên, chủ yếu được bổ sung nhờ nỗ lực của Jay Schullist. Mã BPF vẫn không thay đổi cho đến năm 2011, khi Eric Dumaset thiết kế lại trình thông dịch BPF để chạy ở chế độ JIT (Nguồn: JIT cho bộ lọc gói). Sau đó, hạt nhân, thay vì diễn giải mã byte BPF, có thể chuyển đổi trực tiếp các chương trình BPF sang kiến ​​trúc đích: x86, ARM, MIPS, v.v.

Sau đó, vào năm 2014, Alexey Starovoytov đã đề xuất cơ chế JIT mới cho BPF. Trên thực tế, JIT mới này đã trở thành kiến ​​trúc dựa trên BPF mới và được gọi là eBPF. Tôi nghĩ cả hai máy ảo đã cùng tồn tại một thời gian, nhưng hiện tại việc lọc gói được triển khai dựa trên eBPF. Trên thực tế, trong nhiều ví dụ về tài liệu hiện đại, BPF được hiểu là eBPF và BPF cổ điển ngày nay được gọi là cBPF.

eBPF mở rộng máy ảo BPF cổ điển theo nhiều cách:

  • Dựa trên kiến ​​trúc 64-bit hiện đại. eBPF sử dụng các thanh ghi 64 bit và tăng số lượng thanh ghi khả dụng từ 2 (bộ tích lũy và X) lên 10. eBPF cũng cung cấp các mã hoạt động bổ sung (BPF_MOV, BPF_JNE, BPF_CALL...).
  • Tách khỏi hệ thống con lớp mạng. BPF được gắn với mô hình dữ liệu hàng loạt. Vì nó được sử dụng để lọc gói nên mã của nó nằm trong hệ thống con cung cấp liên lạc mạng. Tuy nhiên, máy ảo eBPF không còn bị ràng buộc với mô hình dữ liệu và có thể được sử dụng cho bất kỳ mục đích nào. Vì vậy, bây giờ chương trình eBPF có thể được kết nối với tracepoint hoặc kprobe. Điều này mở đường cho công cụ eBPF, phân tích hiệu suất và nhiều trường hợp sử dụng khác trong bối cảnh của các hệ thống con hạt nhân khác. Bây giờ mã eBPF nằm trong đường dẫn riêng của nó: kernel/bpf.
  • Kho dữ liệu toàn cầu được gọi là Maps. Bản đồ là kho lưu trữ khóa-giá trị cho phép trao đổi dữ liệu giữa không gian người dùng và không gian kernel. eBPF cung cấp một số loại bản đồ.
  • Các chức năng phụ. Cụ thể, để viết lại một gói, tính toán tổng kiểm tra hoặc sao chép một gói. Các hàm này chạy bên trong kernel và không phải là chương trình trong không gian người dùng. Bạn cũng có thể thực hiện cuộc gọi hệ thống từ các chương trình eBPF.
  • Kết thúc cuộc gọi. Kích thước chương trình trong eBPF được giới hạn ở 4096 byte. Tính năng gọi đuôi cho phép chương trình eBPF chuyển quyền kiểm soát sang chương trình eBPF mới và do đó bỏ qua giới hạn này (có thể liên kết tối đa 32 chương trình theo cách này).

eBPF: ví dụ

Có một số ví dụ về eBPF trong nguồn nhân Linux. Chúng có sẵn tại samples/bpf/. Để biên dịch các ví dụ này, chỉ cần nhập:

$ sudo make samples/bpf/

Tôi sẽ không tự mình viết một ví dụ mới cho eBPF mà sẽ sử dụng một trong các mẫu có sẵn trong samples/bpf/. Tôi sẽ xem xét một số phần của mã và giải thích cách hoạt động của nó. Ví dụ tôi chọn chương trình tracex4.

Nói chung, mỗi ví dụ trong samples/bpf/ bao gồm hai tệp. Trong trường hợp này:

  • tracex4_kern.c, chứa mã nguồn sẽ được thực thi trong kernel dưới dạng mã byte eBPF.
  • tracex4_user.c, chứa một chương trình từ không gian người dùng.

Trong trường hợp này, chúng ta cần biên dịch tracex4_kern.c sang mã byte eBPF. Hiện tại ở gcc không có phụ trợ cho eBPF. May mắn thay, clang có thể xuất mã byte eBPF. Makefile sử dụng clang để biên soạn tracex4_kern.c vào tập tin đối tượng.

Tôi đã đề cập ở trên rằng một trong những tính năng thú vị nhất của eBPF là bản đồ. tracex4_kern định nghĩa một bản đồ:

struct pair {
    u64 val;
    u64 ip;
};  

struct bpf_map_def SEC("maps") my_map = {
    .type = BPF_MAP_TYPE_HASH,
    .key_size = sizeof(long),
    .value_size = sizeof(struct pair),
    .max_entries = 1000000,
};

BPF_MAP_TYPE_HASH là một trong nhiều loại thẻ được cung cấp bởi eBPF. Trong trường hợp này, nó chỉ là một hàm băm. Bạn cũng có thể đã nhận thấy một quảng cáo SEC("maps"). SEC là một macro được sử dụng để tạo một phần mới của tệp nhị phân. Thực ra trong ví dụ tracex4_kern hai phần nữa được xác định:

SEC("kprobe/kmem_cache_free")
int bpf_prog1(struct pt_regs *ctx)
{   
    long ptr = PT_REGS_PARM2(ctx);

    bpf_map_delete_elem(&my_map, &ptr); 
    return 0;
}
    
SEC("kretprobe/kmem_cache_alloc_node") 
int bpf_prog2(struct pt_regs *ctx)
{
    long ptr = PT_REGS_RC(ctx);
    long ip = 0;

    // получаем ip-адрес вызывающей стороны kmem_cache_alloc_node() 
    BPF_KRETPROBE_READ_RET_IP(ip, ctx);

    struct pair v = {
        .val = bpf_ktime_get_ns(),
        .ip = ip,
    };
    
    bpf_map_update_elem(&my_map, &ptr, &v, BPF_ANY);
    return 0;
}   

Hai chức năng này cho phép bạn xóa một mục khỏi bản đồ (kprobe/kmem_cache_free) và thêm mục mới vào bản đồ (kretprobe/kmem_cache_alloc_node). Tất cả các tên hàm được viết bằng chữ in hoa tương ứng với các macro được xác định trong bpf_helpers.h.

Nếu tôi kết xuất các phần của tệp đối tượng, tôi sẽ thấy rằng các phần mới này đã được xác định:

$ objdump -h tracex4_kern.o

tracex4_kern.o: file format elf64-little

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONTENTS, ALLOC, LOAD, DATA
4 license 00000004 0000000000000000 0000000000000000 00000164 2**0
CONTENTS, ALLOC, LOAD, DATA
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
CONTENTS, ALLOC, LOAD, DATA
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

Ngoài ra còn có tracex4_user.c, chương trình chính. Về cơ bản, chương trình này lắng nghe các sự kiện kmem_cache_alloc_node. Khi sự kiện như vậy xảy ra, mã eBPF tương ứng sẽ được thực thi. Mã lưu thuộc tính IP của đối tượng vào bản đồ và đối tượng sau đó được lặp qua chương trình chính. Ví dụ:

$ sudo ./tracex4
obj 0xffff8d6430f60a00 is 2sec old was allocated at ip ffffffff9891ad90
obj 0xffff8d6062ca5e00 is 23sec old was allocated at ip ffffffff98090e8f
obj 0xffff8d5f80161780 is 6sec old was allocated at ip ffffffff98090e8f

Chương trình không gian người dùng và chương trình eBPF có liên quan như thế nào? Khi khởi tạo tracex4_user.c tải một tập tin đối tượng tracex4_kern.o sử dụng chức năng load_bpf_file.

int main(int ac, char **argv)
{
    struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
    char filename[256];
    int i;

    snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);

    if (setrlimit(RLIMIT_MEMLOCK, &r)) {
        perror("setrlimit(RLIMIT_MEMLOCK, RLIM_INFINITY)");
        return 1;
    }

    if (load_bpf_file(filename)) {
        printf("%s", bpf_log_buf);
        return 1;
    }

    for (i = 0; ; i++) {
        print_old_objects(map_fd[1]);
        sleep(1);
    }

    return 0;
}

Bằng cách làm load_bpf_file các đầu dò được xác định trong tệp eBPF được thêm vào /sys/kernel/debug/tracing/kprobe_events. Bây giờ chúng tôi lắng nghe những sự kiện này và chương trình của chúng tôi có thể thực hiện điều gì đó khi chúng xảy ra.

$ sudo cat /sys/kernel/debug/tracing/kprobe_events
p:kprobes/kmem_cache_free kmem_cache_free
r:kprobes/kmem_cache_alloc_node kmem_cache_alloc_node

Tất cả các chương trình khác trong sample/bpf/ đều có cấu trúc tương tự. Chúng luôn chứa hai tệp:

  • XXX_kern.c: chương trình eBPF.
  • XXX_user.c: chương trình chính.

Chương trình eBPF xác định các bản đồ và chức năng liên quan đến một phần. Khi kernel phát hành một sự kiện thuộc một loại nhất định (ví dụ: tracepoint), các hàm liên kết sẽ được thực thi. Các thẻ cung cấp khả năng liên lạc giữa chương trình hạt nhân và chương trình không gian người dùng.

Kết luận

Bài viết này thảo luận về BPF và eBPF nói chung. Tôi biết ngày nay có rất nhiều thông tin và tài nguyên về eBPF, vì vậy tôi sẽ đề xuất thêm một số tài nguyên để nghiên cứu thêm

Tôi khuyên bạn nên đọc:

Nguồn: www.habr.com

Thêm một lời nhận xét