Od vysoké latence Ceph k jádrovému patchi pomocí eBPF/BCC

Od vysoké latence Ceph k jádrovému patchi pomocí eBPF/BCC

V Linuxu existuje mnoho nástrojů pro ladění jádra a aplikací. Většina z nich negativně ovlivňuje výkon aplikací a nemůže být použita v produkci.

Před několika lety byl vyvinut další nástroj — eBPF. Umožňuje sledovat jádro a uživatelské aplikace s nízkou režijní zátěží a bez nutnosti překompilace programů a načítání externích modulů do jádra.

Dnes již existuje mnoho aplikací, které využívají eBPF, a v tomto článku se podíváme na to, jak napsat vlastní profilovací utilitu založenou na knihovně PythonBCC. Článek je založen na skutečných událostech. Projdeme si cestu od vzniku problému po jeho vyřešení, abychom ukázali, jak mohou být využity již existující utility v konkrétních situacích.

Ceph je pomalý

Do clusteru Ceph byl přidán nový hostitel. Po migraci části dat na něj jsme si všimli, že rychlost zpracování zápisových požadavků je mnohem nižší než na jiných serverech.

Od vysoké latence Ceph k jádrovému patchi pomocí eBPF/BCC
Na rozdíl od jiných platforem byl na tomto hostiteli použit bcache a nové jádro linux 4.15. Hostitel této konfigurace byl používán zde poprvé. A v tu chvíli bylo jasné, že příčinou problému mohlo být teoreticky cokoliv.

Zkoumání hostitele

Začneme tím, že se podíváme na to, co se děje uvnitř procesu ceph-osd. K tomu využijeme perf a flamescope (o kterém si můžete přečíst více zde):

Od vysoké latence Ceph k jádrovému patchi pomocí eBPF/BCC
Obrázek nám říká, že funkce fdatasync() strávila hodně času při odeslání žádosti ve funkci generic_make_request(). To znamená, že příčina problémů se pravděpodobně nachází někde mimo samotného démona osd. Může to být buď jádro, nebo disky. Výstup iostat ukazoval vysokou latenci zpracování požadavků bcache-disky.

Při kontrole hostitele jsme zjistili, že démon systemd-udevd spotřebovává velké množství času CPU — přibližně 20% na několika jádrech. Toto podivné chování, je třeba zjistit jeho příčinu. Protože Systemd-udevd pracuje s ueventy, rozhodli jsme se na ně podívat přes udevadm monitor. Ukázalo se, že se generovalo velké množství change událostí pro každé blokové zařízení v systému. To je docela neobvyklé, a tak bude třeba zjistit, co generuje všechny tyto události.

Použití nástroje BCC

Jak jsme již zjistili, jádro (a démon ceph při systémovém volání) tráví spoustu času v generic_make_request(). Pokusíme se změřit rychlost práce této funkce. V BCC již existuje skvělá utilita — funclatency. Budeme sledovat démon podle jeho PID s intervalem mezi výstupy informací 1 sekundu a výstupem výsledků v milisekundách.

Od vysoké latence Ceph k jádrovému patchi pomocí eBPF/BCC
Obvykle tato funkce funguje rychle. Vše, co dělá, je předání požadavku do fronty ovladače zařízení.

Bcache je složité zařízení, které se vlastně skládá ze tří disků:

  • backing device (cacheovaný disk), v tomto případě je to pomalý HDD;
  • caching device (cacheující disk), zde je to jeden oddíl NVMe zařízení;
  • virtuální zařízení bcache, se kterým aplikace pracuje.

Víme, že předání požadavku zpomaluje, ale u kterého z těchto zařízení? Na to se podíváme později.

Teď víme, že ueventy pravděpodobně způsobují problémy. Není tak snadné najít, co přesně je vyvolává. Předpokládejme, že je to nějaký software, který se spouští periodicky. Podíváme se, jaký software se spouští v systému pomocí skriptu execsnoop ze stejné sady utilit BCC. Spustíme ho a nasměrujeme výstup do souboru.

Například takto:

/usr/share/bcc/tools/execsnoop  | tee ./execdump

Nebudeme zde uvádět úplný výstup execsnoop, ale jedna zajímavá řádka vypadala takto:

sh 1764905 5802 0 sudo arcconf getconfig 1 AD | grep Temperature | awk -F '[:\/]' '{print $2}' | sed 's\/^ ([0-9]*) C.*\/1\/'

Třetí sloupec je PPID (parent PID) procesu. Proces s PID 5802 byl jedním z vláken našeho monitorovacího systému. Při kontrole konfigurace monitorovacího systému byly nalezeny chybně nastavené parametry. Teplota HBA adaptéru byla snímána každých 30 sekund, což bylo mnohem častěji, než je potřeba. Po změně intervalu kontroly na delší jsme zjistili, že zpoždění zpracování požadavků na tomto hostiteli přestalo být patrné v porovnání s ostatními hostiteli.

Ale stále není jasné, proč bcache zařízení tak zpomalovalo. Připravili jsme testovací platformu se stejnou konfigurací a pokusili se problém reprodukovat spuštěním fio na bcache a periodickým spouštěním udevadm trigger pro generování uevents.

Psání nástrojů založených na BCC

Zkusíme napsat jednoduchou utilitu pro sledování a výstup na obrazovku nejpomalejších volání generic_make_request(). Také nás zajímá název disku, pro který byla tato funkce volána.

Plán je jednoduchý:

  • Registrováno kprobe na generic_make_request():
    • Uložíme do paměti název disku dostupný přes argument funkce;
    • Uložíme časové razítko.

  • Registrováno kretprobe při návratu z generic_make_request():
    • Získáme aktuální časové razítko;
    • Hledáme uložené časové razítko a porovnáváme s aktuálním;
    • Pokud je výsledek větší než zadaná hodnota, najdeme uložené jméno disku a zobrazíme ho na terminálu.

Kprobes a kretprobes využívají mechanismus bodů zastavení k dynamické změně kódu funkcí. Můžete si přečíst dokumentaci a dobrý článek na toto téma. Pokud se podíváme na kód různých nástrojů v BCC, můžeme si všimnout, že mají identickou strukturu. Takže v tomto článku vynecháme parsování argumentů skriptu a přejdeme k samotnému programu BPF.

Text eBPF uvnitř python skriptu vypadá následovně:

bpf_text = """ # Zde bude kód bpf programu """

Pro výměnu dat mezi funkcemi používají eBPF programy hashovací tabulky. Uděláme to také. Jako klíč budeme používat PID procesu a jako hodnotu definujeme strukturu:

struct data_t {
	u64 pid;
	u64 ts;
	char comm[TASK_COMM_LEN];
	u64 lat;
	char disk[DISK_NAME_LEN];
};

BPF_HASH(p, u64, struct data_t);
BPF_PERF_OUTPUT(events);

Zde registrujeme hash tabulku, která se nazývá p, s klíčem typu u64 a hodnotou typu struct data_t. Tabulka bude dostupná v kontextu našeho BPF programu. Makro BPF_PERF_OUTPUT registruje další tabulku, nazvanou events, která se používá pro předávání dat do uživatelského prostoru.

Při měření latencí mezi voláním funkce a návratem z ní, nebo mezi voláními různých funkcí, je nutné mít na paměti, že získaná data by měla patřit do jednoho kontextu. Jinými slovy, je třeba mít na paměti možný paralelní běh funkcí. Máme možnost měřit latenci mezi voláním funkce v kontextu jednoho procesu a návratem z této funkce v kontextu jiného procesu, ale to může být v praxi bezcenné. Dobrou ukázkou je nástroj biolatency, kde je jako klíč hash tabulky zadán ukazatel na struct request, který reprezentuje jeden diskový požadavek.

Nyní potřebujeme napsat kód, který bude spuštěn při volání zkoumané funkce:

void start(struct pt_regs *ctx, struct bio *bio) {
	u64 pid = bpf_get_current_pid_tgid();
	struct data_t data = {};
	u64 ts = bpf_ktime_get_ns();
	data.pid = pid;
	data.ts = ts;
	bpf_probe_read_str(&data.disk, sizeof(data.disk), (void*)bio->bi_disk->disk_name);
	p.update(&pid, &data);
}

Jako druhý argument bude použit první argument volané funkce generic_make_request(). Poté získáme PID procesu, v jehož kontextu pracujeme, a aktuální časovou značku v nanosekundách. Všechny tyto informace zapíšeme do nově alokované struct data_t data. Jméno disku získáme ze struktury bio, která se předává při volání generic_make_request(), a uchováváme ji ve stejné struktuře data. Posledním krokem je přidání záznamu do hash tabulky, o které bylo řečeno dříve.

Následující funkce bude volána při návratu z generic_make_request():

void stop(struct pt_regs *ctx) {
    u64 pid = bpf_get_current_pid_tgid();
    u64 ts = bpf_ktime_get_ns();
    struct data_t* data = p.lookup(&pid);
    if (data != 0 && data->ts > 0) {
        bpf_get_current_comm(&data->comm, sizeof(data->comm));
        data->lat = (ts - data->ts)/1000;
        if (data->lat > MIN_US) {
            FACTOR
            data->pid >>= 32;
            events.perf_submit(ctx, data, sizeof(struct data_t));
        }
        p.delete(&pid);
    }
}

Tato funkce je podobná předchozí: zjistíme PID procesu a časovou značku, ale nevyčleňujeme paměť pro novou strukturu data. Místo toho hledáme v hash tabulce již existující strukturu podle klíče == aktuální PID. Pokud byla struktura nalezena, zjistíme jméno spuštěného procesu a přidáme ho do ní.

Binární posun, který zde používáme, je potřebný k získání GID vlákna, tj. PID hlavního procesu, který spustil vlákno, v kontextu, ve kterém pracujeme. Voláme funkci bpf_get_current_pid_tgid() vrací jak GID vlákna, tak jeho PID v jedné 64bitové hodnotě.

Při výstupu do terminálu nás teď nezajímá vlákno, ale hlavní proces. Po porovnání získané latence se stanoveným prahem předáváme naši strukturu data do uživatelského prostoru prostřednictvím tabulky events, poté vymažeme záznam z p.

V python skriptu, který tento kód načte, bude třeba nahradit MIN_US a FACTOR prahovými hodnotami latence a časovými jednotkami, které předáme jako argumenty:

bpf_text = bpf_text.replace('MIN_US',str(min_usec))
if args.milliseconds:
	bpf_text = bpf_text.replace('FACTOR','data->lat /= 1000;')
	label = "msec"
else:
	bpf_text = bpf_text.replace('FACTOR','')
	label = "usec"

Nyní musíme připravit BPF program prostřednictvím makra BPF a zaregistrovat záznamy:

b = BPF(text=bpf_text)
b.attach_kprobe(event="generic_make_request",fn_name="start")
b.attach_kretprobe(event="generic_make_request",fn_name="stop")

Také budeme muset definovat struct data_t v našem skriptu, jinak nebude možné nic přečíst:

TASK_COMM_LEN = 16	# linux/sched.h
DISK_NAME_LEN = 32	# linux/genhd.h
class Data(ct.Structure):
	_fields_ = [("pid", ct.c_ulonglong),
            	("ts", ct.c_ulonglong),
            	("comm", ct.c_char * TASK_COMM_LEN),
            	("lat", ct.c_ulonglong),
            	("disk",ct.c_char * DISK_NAME_LEN)]

Posledním krokem je výstup dat na terminál:

def print_event(cpu, data, size):
    global start
    event = ct.cast(data, ct.POINTER(Data)).contents
    if start == 0:
        start = event.ts
    time_s = (float(event.ts - start)) / 1000000000
    print("%-18.9f %-16s %-6d   %-1s %s   %s" % (time_s, event.comm, event.pid, event.lat, label, event.disk))

b["events"].open_perf_buffer(print_event)
# formát výstupu
start = 0
while 1:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

Skript je k dispozici na GItHubu. Zkusíme ho spustit na testovací platformě, kde běží fio, zapisující na bcache, a spustíme udevadm monitor:

Od vysoké latence Ceph k jádrovému patchi pomocí eBPF/BCC
Konečně! Nyní vidíme, že to, co vypadalo jako zpomalení zařízení bcache, je ve skutečnosti zpomalený volání generic_make_request() pro disk s mezipamětí.

Zjistěte více o Kernelu

Co přesně zpomaluje při odesílání požadavku? Vidíme, že zpoždění nastává ještě před tím, než začíná účtování požadavku, tj. sledování konkrétního požadavku pro další vykázání statistik ( /proc/diskstats nebo iostat) ještě nezačalo. To lze snadno zkontrolovat spuštěním iostat během reprodukce problému, nebo BCC skriptu biolatency, který se zakládá na začátku a konci účtování požadavků. Žádný z těchto nástrojů neukáže problémy s požadavky na disk s mezipamětí.

Pokud se podíváme na funkci generic_make_request(), uvidíme, že před začátkem účtování požadavku jsou volány ještě dvě funkce. První — generic_make_request_checks(), provádí kontroly platnosti požadavku vzhledem k nastavení disku. Druhá — blk_queue_enter(), ve které je zajímavé volání wait_event_interruptible():

ret = wait_event_interruptible(q->mq_freeze_wq,
	(atomic_read(&q->mq_freeze_depth) == 0 &&
	(preempt || !blk_queue_preempt_only(q))) ||
	blk_queue_dying(q));

V něm jádro čeká na odblokování fronty. Změříme zpoždění blk_queue_enter():

~# /usr/share/bcc/tools/funclatency blk_queue_enter -i 1 -m               	 
Sledování 1 funkcí pro "blk_queue_enter"... Stiskněte Ctrl-C pro ukončení.

 	msecs           	: počet 	distribution
     	0 -> 1      	: 341  	|****************************************|

 	msecs           	: počet 	distribution
     	0 -> 1      	: 316  	|****************************************|

 	msecs           	: počet 	distribution
     	0 -> 1      	: 255  	|****************************************|
     	2 -> 3      	: 0    	|                                    	|
     	4 -> 7      	: 0    	|                                    	|
     	8 -> 15     	: 1    	|                                    	|

Vypadá to, že jsme blízko rozluštění. Funkce používané pro "zmražení/odmražení" fronty jsou blk_mq_freeze_queue a blk_mq_unfreeze_queue. Používají se, když je třeba změnit nastavení fronty požadavků, které mohou být nebezpečné pro požadavky, které se nacházejí v této frontě. Při volání blk_mq_freeze_queue() funkcí blk_freeze_queue_start() se zvyšuje čítač q->mq_freeze_depth. Po tomto jádro čeká na vyprázdnění fronty v blk_mq_freeze_queue_wait().

Doba čekání na vyprázdnění této fronty je ekvivalentní zpoždění disku, protože jádro čeká na dokončení všech operací zařazených do fronty. Jakmile je fronta prázdná, použijí se změny nastavení. Poté se volá blk_mq_unfreeze_queue(), která dekrementuje čítač freeze_depth.

Nyní víme dost na to, abychom situaci napravili. Příkaz udevadm trigger pak vede k aplikaci nastavení pro blokové zařízení. Tato nastavení jsou popsána v pravidlech udev. Můžeme zjistit, která konkrétní nastavení 'zamrazují' frontu, když se pokusíme je změnit přes sysfs nebo se podíváme na zdrojový kód jádra. Můžeme také vyzkoušet nástroj BCC, trace, který zobrazí na terminálu stopy zásobníku jádra a uživatelského prostoru pro každý volání blk_freeze_queue, například:

~# /usr/share/bcc/tools/trace blk_freeze_queue -K -U
PID 	TID 	COMM        	FUNC        	 
3809642 3809642 systemd-udevd   blk_freeze_queue
    	blk_freeze_queue+0x1 [kernel]
    	elevator_switch+0x29 [kernel]
    	elv_iosched_store+0x197 [kernel]
    	queue_attr_store+0x5c [kernel]
    	sysfs_kf_write+0x3c [kernel]
    	kernfs_fop_write+0x125 [kernel]
    	__vfs_write+0x1b [kernel]
    	vfs_write+0xb8 [kernel]
    	sys_write+0x55 [kernel]
    	do_syscall_64+0x73 [kernel]
    	entry_SYSCALL_64_after_hwframe+0x3d [kernel]
    	__write_nocancel+0x7 [libc-2.23.so]
    	[unknown]

3809631 3809631 systemd-udevd   blk_freeze_queue
    	blk_freeze_queue+0x1 [kernel]
    	queue_requests_store+0xb6 [kernel]
    	queue_attr_store+0x5c [kernel]
    	sysfs_kf_write+0x3c [kernel]
    	kernfs_fop_write+0x125 [kernel]
    	__vfs_write+0x1b [kernel]
    	vfs_write+0xb8 [kernel]
    	sys_write+0x55 [kernel]
    	do_syscall_64+0x73 [kernel]
    	entry_SYSCALL_64_after_hwframe+0x3d [kernel]
    	__write_nocancel+0x7 [libc-2.23.so]
    	[unknown]

Pravidla udev se mění poměrně zřídka a obvykle to probíhá pod kontrolou. Takže vidíme, že i použití již zadaných hodnot způsobuje nárůst zpoždění přenosu požadavku od aplikace k disku. Samozřejmě generovat události udev, když nedochází k žádným změnám v konfiguraci disků (například když se zařízení nepřipojuje/odpojuje), není příliš dobrou praxí. Nicméně můžeme pomoci jádru, aby nedělalo zbytečnou práci a nezamrzlo frontu požadavků, pokud to není nutné. Tři malé commitu napravují situaci.

Závěr

eBPF je velmi flexibilní a mocný nástroj. V článku jsme prozkoumali jeden praktický případ a demonstrovali malou část toho, co je možné udělat. Pokud vás zajímá vývoj nástrojů BCC, stojí za to podívat se na oficiální tutoriál, který dobře popisuje základy práce.

Existují také další zajímavé nástroje pro ladění a profilování, založené na eBPF. Jeden z nich je bpftrace, který umožňuje psát mocné jednorázové skripty a malé programy v jazyce podobném awk. Dále je tu ebpf_exporter, který umožňuje shromažďovat nízkoúrovňové metriky s vysokým rozlišením přímo na váš server Prometheus, s možností následně získat krásnou vizualizaci a dokonce i upozornění.

Zdroj: habr.com

Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster