Здравей, Хабр! Информираме ви, че подготвяме издаването на книга "".

Тъй като виртуалната машина 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 съответства на ethertype на пакета.
- Инструкция (001): сравнява стойността в акумулатора с 0x86dd, тоест, с ethertype стойността за IPv6. Ако резултатът е вярно, тогава програмният брояч преминава към инструкция (002), а ако не – към (006).
- Инструкция (006): сравнява стойността с 0x800 (ethertype-стойност за IPv4). Ако отговорът е true, програмата преминава към (007), а ако не – към (015).
И така нататък, докато програмата за филтриране на пакети не върне резултат. Обикновено това е булева стойност. Връщането на ненулева стойност (инструкция (014)) означава, че пакетът е подходящ, а връщането на нулева (инструкция (015)) означава, че пакетът не е подходящ.
Виртуалната машина BPF и нейният байт-код бяха предложени от Стив Мак-Канън и Ван Джейкобсън в края на 1992, когато излезе тяхната статия , за първи път тази технология беше представена на конференцията Usenix през зимата на 1993 година.
Тъй като BPF е виртуална машина, тя определя средата, в която се изпълняват програмите. Освен байт-кода, тя също така определя модел на памет за пакети (инструкциите за зареждане се прилагат неявно към пакета), регистрите (A и X; регистри на акумулатора и индекса), хранилището на временно-памет и неявен програмен брояч. Интересно е, че байт-кодът BPF е моделиран по образец на Motorola 6502 ISA. Както си спомня Стив Мак-Канън в своя на Sharkfest ‘11, той беше запознат със сглобяването на 6502 още от гимназията, когато програмирал на Apple II, и тези знания повлияли на работата му по проектирането на байт-кода BPF.
Поддръжката на BPF е реализирана в ядрото на Linux в версия v2.5 и по-високи, добавена основно чрез усилията на Джей Шулист. Кодът на BPF остана без сериозни промени до 2011 година, когато Ерик Думазет преработи интерпретатора на BPF да работи в режим 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. използва 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). Всички имена на функции, записани с главни букви, съответстват на макросите, определени в .
Ако изведа дамп на секциите от обектния файл, ще видя, че тези нови секции вече са дефинирани:
$ 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
Има и , основната програма. В принципе, тази програма следи събития kmem_cache_alloc_node. Когато настъпи такова събитие, изпълнява се съответният код eBPF. Кодът запазва IP-атрибута на обекта в картата и след това този обект циклично се извежда в основната програма. Пример:
$ sudo .\/tracex4
обект 0xffff8d6430f60a00 е на 2 секунди, алокиран е на ip ffffffff9891ad90
обект 0xffff8d6062ca5e00 е на 23 секунди, алокиран е на ip ffffffff98090e8f
обект 0xffff8d5f80161780 е на 6 секунди, алокиран е на ip ffffffff98090e8f
Как са свързани програмата на потребителското пространство и eBPF програмата? При инициализация tracex4_user.c зарежда файла с обект tracex4_kern.o чрез функцията зареди_bpf_файл.
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;
} При изпълнение сонди, определени в файла 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 и как тя е еволюирала в eBPF.
- на Брендан Грегг. Статия от сайта LWN.net. Брендан често пише туитове за eBPF и поддържа списък с ресурси по темата в .
- на Джулия Евънс. Коментари към презентацията на Сучакра Шарма 'The BSD Packet Filter: A New Architecture for User-level Packet Capture'. Коментарите са полезни и наистина помагат да се разберат слайдовете.
- на Ферис Еллис. Лонгрид с , но определено си струва да се прочете. Една от най-добрите статии за eBPF, които съм виждал.
Източник: habr.com
