Короткий вступ до BPF та eBPF

Привіт, Хабре! Повідомляємо, що у нас готується до виходу книгаLinux Observability with BPF".

Короткий вступ до BPF та eBPF
Оскільки віртуальна машина BPF продовжує еволюціонувати та активно застосовується на практиці, ми переклали для вас статтю, яка описує її основні можливості та стан на даний час.

В останні роки стали набирати популярності інструментарії для програмування та прийоми, покликані компенсувати обмеження ядра Linux у випадках, коли потрібна високопродуктивна обробка пакетів. Один із найбільш популярних прийомів такого роду називається обхід ядра (kernel bypass) і дозволяє, пропускаючи мережевий рівень ядра, виконувати всю обробку пакетів з простору користувача. Обхід ядра також передбачає управління мережевою картою користувальницького простору. Іншими словами, під час роботи з мережевою картою ми покладаємося на драйвер користувальницького простору.

Передаючи повний контроль над мережевою картою програмі з простору користувача, ми скорочуємо витрати, зумовлені роботою ядра (перемикання контексту, обробка мережного рівня, переривання, тощо), що досить важливо при роботі на швидкостях 10Гб/с або вище. Обхід ядра плюс комбінація інших можливостей (пакетна обробка) та акуратне налаштування продуктивності (облік NUMA, ізоляція CPU, тощо) відповідають основам високопродуктивної мережевої обробки в користувальницькому просторі. Можливо, приклад такого нового підходу до обробки пакетів – це ДПДК від Intel (Data Plane Development Kit), хоча, існують інші широко відомі інструментарії та прийоми, серед яких VPP від ​​Cisco (Vector Packet Processing), Netmap і, звичайно ж, Снабб.

У організації мережевих взаємодій в просторі користувача є ряд недоліків:

  • Ядро ОС – це рівень абстрагування для апаратних ресурсів. Оскільки програмам простору користувача доводиться керувати своїми ресурсами безпосередньо, ним також доводиться керувати і власним апаратним забезпеченням. Найчастіше це означає необхідність програмування власних драйверів.
  • Оскільки ми повністю відмовляємось від простору ядра, ми також відмовляємось і від усього мережевого функціоналу, що надається ядром. Програмам простору користувача доводиться заново реалізувати ті функції, які, можливо, вже надаються ядром або операційною системою.
  • Програми працюють у режимі пісочниці, що серйозно обмежує можливості їхньої взаємодії та заважає їм інтегруватися з іншими частинами операційної системи.

По суті, при організації мережевих взаємодій в просторі користувача підвищення продуктивності досягається шляхом перенесення обробки пакетів з ядра в користувальницький простір. XDP робить рівно навпаки: переміщає мережеві програми з простору користувача (фільтри, перетворювачі, маршрутизація, тощо) в область ядра. XDP дозволяє нам виконати мережеву функцію, як тільки пакет потрапляє на мережевий інтерфейс і до того, як він починає рухатись вгору в мережеву підсистему ядра. В результаті швидкість обробки пакетів суттєво збільшується. Однак, як ядро ​​дозволяє користувачеві виконувати свої програми у просторі ядра? Перш ніж відповісти на це питання, розглянемо, що таке BPF.

BPF та eBPF

Незважаючи на не зовсім зрозумілу назву BPF (Фільтрація пакетів, Берклі) – це фактично модель віртуальної машини. Ця віртуальна машина спочатку проектувалася для обробки фільтрації пакетів, звідси і назва.

Одним із найвідоміших інструментів, що використовують BPF, є tcpdump. При захопленні пакетів за допомогою tcpdump Користувач може задати вираз для фільтрації пакетів. Захоплюватимуться лише пакети, що відповідають цьому виразу. Наприклад, вираз “tcp dst port 80” стосується всіх TCP-пакетів, що надходять на порт 80. Компілятор може скоротити цей вираз, перетворивши його на байт-код 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

Ось що, в принципі, робить наведена вище програма:

  • Інструкція (000): завантажує пакет зі зміщенням 12 у вигляді 16-розрядного слова в акумулятор. Зміщення 12 відповідає одному типу пакета.
  • Інструкція (001): порівнює значення акумулятора з 0x86dd, тобто, з ethertype-значенням для IPv6. Якщо результат дорівнює true, то лічильник програми переходить до інструкції (002), а якщо ні – то до (006).
  • Інструкція (006): порівнює значення з 0x800 (ethertype-значення для IPv4). Якщо відповідь true, то програма переходить до (007), якщо ні – то до (015).

І так далі, поки програма фільтрації пакетів не поверне результату. Зазвичай, це булеан. Повернення ненульового значення (інструкція (014)) означає, що пакет підійшов, а повернення нульового (інструкція (015)) означає, що пакет не підійшов.

Віртуальна машина BPF та її байт-код були запропоновані Стівом Мак-Канном та Ваном Джейкобсоном наприкінці 1992, коли вийшла їх стаття Фільтр пакетів BSD: Нова архітектура для захоплення пакетів на рівні користувачаУперше ця технологія була представлена ​​на конференції Usenix взимку 1993 року.

Оскільки BPF – це віртуальна машина, вона визначає середовище, в якому виконуються програми. Крім байт-коду вона також визначає пакетну модель пам'яті (інструкції завантаження неявно застосовуються до пакета), регістри (A і X; регістри акумулятора та індексу), сховище скретч-пам'яті та неявний лічильник програм. Цікаво, що байт-код BPF був змодельований на зразок Motorola 6502 ISA. Як згадував Стів Мак-Кан у своєму пленарній доповіді на Sharkfest '11, він був знайомий зі збіркою 6502 ще зі старших класів, коли програмував на Apple II, і ці знання вплинули на його роботу з проектування байт-коду BPF.

Підтримка BPF реалізована в ядрі Linux у версії v2.5 та вище, додана в основному зусиллями Джея Шулліста. Код BPF залишався без серйозних змін аж до 2011 року, коли Ерік Думазет переробив інтерпретатор BPF для роботи в режимі JIT (Джерело: JIT для пакетних фільтрів). Після цього ядро ​​замість інтерпретації байт-коду BPF міг безпосередньо перетворювати програми BPF під цільову архітектуру: x86, ARM, MIPS, тощо.

Пізніше, 2014 року, Олексій Старовойтов запропонував новий JIT-механізм для BPF. Фактично, цей новий JIT став новою архітектурою на основі BPF і отримав назву eBPF. Думаю, протягом деякого часу обидві віртуальні машини співіснували, але зараз фільтрація пакетів реалізується на основі eBPF. Фактично у багатьох зразках сучасної документації під BPF розуміється eBPF, а класична BPF сьогодні відома як cBPF.

eBPF у кількох відносинах розширює класичну віртуальну машину BPF:

  • Спирається на сучасні 64-розрядні архітектури. eBPF використовує 64-розрядні регістри та збільшує кількість доступних регістрів з 2 (акумулятор та X) до 10. У eBPF також надаються додаткові коди операцій (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Відкріплено від підсистеми мережевого рівня. BPF було зав'язано на пакетну модель даних. Оскільки вона використовувалася для фільтрації пакетів, її код знаходився в підсистемі, що забезпечує мережеві взаємодії. Однак, віртуальна машина eBPF більше не прив'язана до моделі даних і може використовуватись у будь-яких цілях. Так, тепер програму eBPF можна підключити до tracepoint або kprobe. Це відкриває шлях до інструментування eBPF, аналізу продуктивності та багатьох інших варіантів використання у контексті інших підсистем ядра. Тепер код eBPF розташовується власним шляхом: kernel/bpf.
  • Світові сховища даних під назвою Карти. Карти – це сховища типу «ключ-значення», що забезпечують обмін даними між простором користувача і простором ядра. У eBPF надаються карти кількох типів.
  • Допоміжні функції. Зокрема для перезапису пакета, обчислення контрольної суми або клонування пакета. Ці функції виконуються всередині ядра і не належать до програм користувача простору. Крім того, із програм eBPF можна здійснювати системні дзвінки.
  • Кінцеві дзвінки. Розмір програми в eBPF обмежений 4096 байт. Можливість кінцевого виклику дозволяє програмі eBPF передати керування новій eBPF-програмі і таким чином обійти це обмеження (в такий спосіб можна зв'язати до 32 програм).

eBPF: приклад

У вихідних джерелах ядра Linux є кілька прикладів для eBPF. Вони доступні за адресою samples/bpf/. Щоб скомпілювати ці приклади, просто введіть:

$ sudo make samples/bpf/

Я не буду писати новий приклад для eBPF, а скористаюся одним із зразків, доступних у samples/bpf/. Розгляну деякі ділянки коду та поясню, як він працює. Як приклад я вибрав програму tracex4.

Взагалі, кожен із прикладів samples/bpf/ складається з двох файлів. В даному випадку:

  • tracex4_kern.cмістить вихідний код, який повинен виконуватися в ядрі як байт-код eBPF.
  • tracex4_user.c, містить програму з простору користувача.

У такому разі нам потрібно скомпілювати tracex4_kern.c байт-код eBPF. Зараз у gcc відсутня серверна частина для eBPF. На щастя, clang може видавати байт-код eBPF. Makefile використовує clang для компіляції tracex4_kern.c файл об'єкта.

Вище я згадував, що однією з найцікавіших фіч eBPF є карти. tracex4_kern визначає одну картку:

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 – один із багатьох типів карт, що пропонуються eBPF. В даному випадку це просто хеш. Також ви могли помітити оголошення SEC("maps"). SEC – це макрос, який використовується для створення нової секції двійкового файлу. Власне, у прикладі tracex4_kern визначається ще дві секції:

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;
}   

Ці дві функції дозволяють видалити запис з карти (kprobe/kmem_cache_free) та додати на карту новий запис (kretprobe/kmem_cache_alloc_node). Усі імена функцій, записані великими літерами, відповідають макросам, визначеним у bpf_helpers.h.

Якщо я виведу дамп секцій об'єктного файлу, то маю побачити, що ці нові секції вже визначені:

$ 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

Ще є tracex4_user.c, основна програма У принципі, ця програма слухає події kmem_cache_alloc_node. Коли відбувається подія, виконується відповідний код eBPF. Код зберігає IP-атрибут об'єкта в карту, а потім цей об'єкт циклічно виводиться в основній програмі. Приклад:

$ 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

Як пов'язані програма простору користувача та програма eBPF? При ініціалізації tracex4_user.c завантажує файл об'єкта tracex4_kern.o за допомогою функції 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;
}

При виконанні load_bpf_file зонди, визначені у файлі eBPF, додаються до /sys/kernel/debug/tracing/kprobe_events. Тепер ми слухаємо ці події, і наша програма може щось робити, коли вони відбуваються.

$ 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

Всі інші програми в sample/bpf/ структуровані таким чином. У них завжди по два файли:

  • XXX_kern.c: програма eBPF.
  • XXX_user.c: основна програма.

Програма eBPF визначає карти та функції, прив'язані до секції. Коли ядро ​​видає подію певного типу (наприклад, tracepoint), прив'язані функції виконуються. Карти забезпечують обмін даними між програмою ядра і програмою простору користувача.

Висновок

У цій статті загалом були розглянуті BPF та eBPF. Я знаю, що сьогодні дуже багато інформації та ресурсів про eBPF, тому порекомендую ще кілька матеріалів для подальшого вивчення

Рекомендую почитати:

  • BPF: універсальний in-kernel virtual machine Джонатана Корбетта. Введення в BPF та розповідь про те, як вона еволюціонувала в eBPF.
  • Якнайбільше введення в eBPF Брендана Греґґа. Стаття із сайту LWN.net. Брендан часто пише твіти про eBPF і веде список ресурсів на цю тему у себе в блозі.
  • Notes on BPF & eBPF Джулії Еванс. Коментарі до презентації Сучакри Шарми “BSD Packet Filter: A New Architecture for User-level Packet Capture”. Коментарі хороші і справді допомагають розібратися у слайдах.
  • eBPF, part1: Past, Present and Future Ферріса Елліса. Лонгрід з продовженнямале читати варто. Одна з найкращих статей про eBPF, які мені траплялися.

Джерело: habr.com

Додати коментар або відгук