Od High Ceph Latency do Kernel Patch pomoću eBPF/BCC

Od High Ceph Latency do Kernel Patch pomoću eBPF/BCC

U Linuxu postoji mnogo alata za debuggiranje jezgre i aplikacija. Većina njih negativno utječe na performanse aplikacija i ne može se koristiti u produkciji.

Prije nekoliko godina razvijen je još jedan alat — eBPF. Omogućava praćenje jezgre i korisničkih aplikacija s niskim prekomjernim troškovima i bez potrebe za ponovnom izradom programa ili učitavanjem vanjskih modula u jezgru.

Sada već postoji mnogo aplikativnih alata koji koriste eBPF, a u ovom članku ćemo razmotriti kako napisati vlastiti alat za profiliranje temeljen na biblioteci PythonBCC. Članak je temeljen na stvarnim događajima. Proći ćemo kroz put od pojave problema do njegovog rješavanja, kako bismo pokazali kako se postojeći alati mogu koristiti u konkretnim situacijama.

Ceph je spor

U Ceph klaster je dodan novi host. Nakon migracije dijela podataka na njega primijetili smo da je brzina obrade zapisničkih zahtjeva na njemu znatno niža nego na drugim poslužiteljima.

Od High Ceph Latency do Kernel Patch pomoću eBPF/BCC
Za razliku od drugih platformi, na ovom hostu korišten je bcache i nova jezgra Linux 4.15. Host takve konfiguracije korišten je ovdje prvi put. I u tom trenutku bilo je jasno da bi problem mogao imati nekoliko korijena.

Istraživanje hosta

Počnimo s time da pogledamo što se događa unutar procesa ceph-osd. Za to ćemo iskoristiti perf i flamescope (više o kojem možete pročitati ovdje):

Od High Ceph Latency do Kernel Patch pomoću eBPF/BCC
Slika nam govori da je funkcija fdatasync() potrošila puno vremena prilikom slanja zahtjeva u funkciji generic_make_request(). To znači da je vjerojatno uzrok problema negdje izvan samog osd demona. To može biti ili jezgra ili diskovi. Izlaz iostat pokazivao je visoke latencije obrade zahtjeva bcache diskovima.

Pri provjeri hosta otkrili smo da demon systemd-udevd troši mnogo CPU vremena — oko 20% na nekoliko jezgri. Ovo je bizarno ponašanje, pa je potrebno istražiti njegov uzrok. Budući da systemd-udevd radi s ueventima, odlučili smo ih pogledati preko udevadm monitor. Čini se da se generira velika količina change-događaja za svaki blok uređaja u sustavu. To je prilično neobično, pa ćemo morati pogledati što generira sve te događaje.

Korištenje BCC alata

Kao što smo već ustanovili, jezgra (i ceph demon u sistemskom pozivu) troši mnogo vremena u generic_make_request(). Pokušat ćemo izmjeriti brzinu rada ove funkcije. U BCC već postoji izvrsna alatka — funclatency. Tracirat ćemo demon po njegovom PID-u s intervalom između ispisivanja informacija od 1 sekunde i ispisivati rezultat u milisekundama.

Od High Ceph Latency do Kernel Patch pomoću eBPF/BCC
Obično ova funkcija radi brzo. Sve što radi jest da proslijedi zahtjev u red vozača uređaja.

Bcache je složen uređaj koji se zapravo sastoji od tri diska:

  • backing device (keširani disk), u ovom slučaju to je spor HDD;
  • caching device (keširajući disk), ovdje je to jedna particija NVMe uređaja;
  • virtualni uređaj bcache s kojim aplikacija radi.

Znamo da prijenos zahtjeva usporava, ali za koji od ovih uređaja? Riješit ćemo to malo kasnije.

Trenutno znamo da ueventi vjerovatno uzrokuju probleme. Nije jednostavno pronaći što točno uzrokuje njihovu generaciju. Pretpostavimo da je to neki softver koji se pokreće periodički. Pogledajmo koji se softver pokreće u sustavu koristeći skriptu execsnoop iz istog skupa alata BCC. Pokrenimo ga i usmjerimo izlaz u datoteku.

Na primjer, ovako:

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

Nećemo ovdje prenositi puni izlaz execsnoop, ali jedan zanimljiv redak izgledao je ovako:

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

Treća kolona je PPID (glavni PID) procesa. Proces s PID-om 5802 bio je jedan od niti našeg sustava nadzora. Provjerom konfiguracije sustava nadzora pronađeni su pogrešno postavljeni parametri. Temperatura HBA-adaptera mjerila se svakih 30 sekundi, što je znatno češće nego što je potrebno. Nakon promjene intervala provjere na duži, otkrili smo da je kašnjenje obrade zahtjeva na ovom hostu prestalo biti uočljivo u usporedbi s ostalim hostovima.

Ali još uvijek nije jasno zašto je bcache uređaj toliko usporavao. Pripremili smo testnu platformu s identičnom konfiguracijom i pokušali reproducirati problem pokretanjem fio na bcache, povremeno pokrećući udevadm trigger za generiranje uevents.

Pisanje alata temeljenih na BCC

Pokušat ćemo napisati jednostavnu alatku za traciranje i ispisivanje na ekranu najsporijih poziva generic_make_request(). Također nas zanima naziv diska za koji je ova funkcija pozvana.

Plan je jednostavan:

  • Registrujemo kprobe na generic_make_request():
    • Spremamo u memoriju ime diska, dostupno kroz argument funkcije;
    • Spremamo vremensku oznaku.

  • Registrujemo kretprobe na povratku iz generic_make_request():
    • Dobivamo trenutni vremenski žig;
    • Tražimo spremljeni vremenski žig i uspoređujemo ga s trenutnim;
    • Ako je rezultat veći od zadanog, tada pronalazimo spremljeno ime diska i ispisujemo ga na terminal.

Kprobes i kretprobes koriste mehanizam točaka prekida za promjenu koda funkcija u hodu. Možete pročitati dokumentaciju i dobar članak o ovoj temi. Ako pogledate kod raznih alata u BCC, možete primijetiti da imaju identičnu strukturu. Stoga ćemo u ovom članku izostaviti parsiranje argumenata skripte i preći na samu BPF program.

Tekst eBPF unutar python skripte izgleda ovako:

bpf_text = """ # Ovdje će biti kod bpf programa """

Za razmjenu podataka između funkcija, eBPF programi koriste haš-tablice. Tako ćemo postupiti i mi. Kao ključ koristi ćemo PID procesa, a kao vrijednost definirati 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);

Ovdje registriramo hash tablicu koja se zove p, s ključem tipa u64 i vrijednošću tipa struct data_t. Tablica će biti dostupna u kontekstu našeg BPF programa. Makro BPF_PERF_OUTPUT registrira drugu tablicu, nazvanu events, koja se koristi za prijenos podataka u korisnički prostor.

Pri mjerenju kašnjenja između poziva funkcije i povratka iz nje, ili između poziva različitih funkcija, potrebno je uzeti u obzir da dobiveni podaci moraju pripadati jednom kontekstu. Drugim riječima, treba se sjetiti mogućeg paralelnog pokretanja funkcija. Imamo mogućnost izmjeriti kašnjenje između poziva funkcije u kontekstu jednog procesa i povratka iz te funkcije u kontekstu drugog procesa, ali to bi vjerojatno bilo beskorisno. Dobar primjer ovdje može biti alat biolatency, gdje se kao ključ hash tablice postavlja pokazivač na struct request, koja odražava jedan zahtjev za disk.

Dalje, trebamo napisati kod koji će se izvršavati prilikom poziva ispitivane funkcije:

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

Ovdje će se kao drugi argument umetnuti prvi argument pozvane funkcije generic_make_request(). Nakon toga dobijemo PID procesa, u kontekstu kojeg radimo, i trenutačnu vremensku oznaku u nanosekundama. Sve to zapisujemo u sveže dodijeljenu struct data_t data. Ime diska dobijamo iz strukture bio, koja se predaje prilikom poziva generic_make_request(), i spremamo ga u istu strukturu data. Posljednji korak je dodavanje unosa u hash-tablicu, o kojoj smo ranije govorili.

Sljedeća funkcija će se pozvati na povratku iz 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);
    }
}

Ova funkcija je slična prethodnoj: saznajemo PID procesa i vremensku oznaku, ali ne alociramo memoriju za novu strukturu data. Umjesto toga, tražimo u hash-tablici postojeću strukturu po ključu == trenutačni PID. Ako je struktura pronađena, saznajemo ime pokrenutog procesa i dodajemo ga u nju.

Binarno pomjeranje, koje ovdje koristimo, potrebno je da dobijemo GID niti, tj. PID glavnog procesa koji je pokrenuo nit, u kontekstu kojeg radimo. Funkcija koju zovemo bpf_get_current_pid_tgid() vraća i GID niti i njen PID u jednoj 64-bitnoj vrijednosti.

Pri ispisu na terminal nas trenutno ne zanima nit, ali nas zanima glavni proces. Nakon usporedbe dobivene latencije s postavljenim praga, predajemo našu strukturu data u korisnički prostor kroz tablicu events, nakon čega brišemo unos iz p.

U Python skripti koja će učitati ovaj kod, potrebno je zamijeniti MIN_US i FACTOR s pragovima latencije i jedinica vremena koje ćemo proslijediti kroz argumente:

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"

Sada trebamo pripremiti BPF program kroz makro BPF i registrirati uzorke:

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")

Također ćemo morati definirati struct data_t u našem skriptu, inače ništa nećemo moći pročitati:

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)]

Zadnji korak je prikaz podataka na terminalu:

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)
# formatiranje izlaza
start = 0
while 1:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

Sam skript je dostupan na GItHub. Pokušajmo pokrenuti ga na testnoj platformi gdje je pokrenut fio koji zapisuje na bcache, i pozvati udevadm monitor:

Od High Ceph Latency do Kernel Patch pomoću eBPF/BCC
Napokon! Sada vidimo da ono što se činilo kao usporavanje bcache uređaja zapravo je usporavajući poziv generic_make_request() za keširani disk.

Zaronite u Kernel

Što točno usporava tijekom prijenosa zahtjeva? Vidimo da se kašnjenje javlja čak i prije nego što započne evidentiranje zahtjeva, tj. evidentiranje konkretnih zahtjeva za daljnju statistiku (proc/diskstats ili iostat) još nije započelo. Ovo se lako može provjeriti pokretanjem iostat-a tijekom reprodukcije problema, ili BCC skript biolatency, koji se temelji na početku i završetku evidentiranja zahtjeva. Nijedna od ovih alatki neće pokazati probleme za zahtjeve prema keširanom disku.

Ako pogledamo funkciju generic_make_request(), vidjet ćemo da se prije početka evidentiranja zahtjeva pozivaju još dvije funkcije. Prva — generic_make_request_checks(), izvršava provjere zakonitosti zahtjeva u odnosu na postavke diska. Druga — blk_queue_enter(), u kojoj postoji zanimljiv poziv 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));

U njemu kernel čeka na odmrzavanje reda. Mjerimo kašnjenje blk_queue_enter():

~# /usr/share/bcc/tools/funclatency blk_queue_enter -i 1 -m               	 
Praćenje 1 funkcije "blk_queue_enter"... Pritisnite Ctrl-C za kraj.

 	msecs           	: broj 	distribucija
     	0 -> 1      	: 341  	|****************************************|

 	msecs           	: broj 	distribucija
     	0 -> 1      	: 316  	|****************************************|

 	msecs           	: broj 	distribucija
     	0 -> 1      	: 255  	|****************************************|
     	2 -> 3      	: 0    	|                                    	|
     	4 -> 7      	: 0    	|                                    	|
     	8 -> 15     	: 1    	|                                    	|

Čini se da smo blizu rješenja. Funkcije korištene za "zamrzavanje/odmrzavanje" reda su blk_mq_freeze_queue i blk_mq_unfreeze_queue. Koriste se kada je potrebno promijeniti postavke reda zahtjeva, potencijalno opasne za zahtjeve koji se nalaze u tom redu. Kada se poziva blk_mq_freeze_queue() funkcijom blk_freeze_queue_start() inkrementira se brojač q->mq_freeze_depth. Nakon toga jezgra čeka da se ispražn java red u blk_mq_freeze_queue_wait().

Vrijeme čekanja za čišćenje ovog reda jednako je kašnjenju diska, jer jezgra čeka završetak svih operacija stavljenih u red. Kada se red isprazni, primjene se promjene postavki. Nakon toga poziva se blk_mq_unfreeze_queue(), smanjujući brojač freeze_depth.

Sada znamo dovoljno da ispravimo situaciju. Komanda udevadm trigger rezultira primjenom postavki za blok uređaj. Ove postavke opisane su u pravilima udev. Možemo saznati koje točno postavke „zamrzavaju“ red tako da pokušamo promijeniti ih preko sysfs ili pogledamo izvorni kod jezgre. Također, možemo isprobati BCC alat trace, koji će na terminal ispisati tragove stoga jezgre i korisničkog prostora za svaki poziv blk_freeze_queue, na primjer:

~# /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]

Udev pravila se rijetko mijenjaju i obično to ide pod kontrolom. Tako vidimo da čak i primjena već zadanih vrijednosti izaziva porast kašnjenja u prijenosu zahtjeva od aplikacije do diska. Naravno, generirati udev događaje kada nema nikakvih promjena u konfiguraciji diskova (na primjer, uređaj nije priključen/isključen) nije najbolja praksa. Ipak, možemo pomoći jezgru da ne obavlja besposlen rad i ne „zamrzava“ red zahtjeva ako za tim nema potrebe. Tri mala komita ispravljaju situaciju.

Zaključak

eBPF je vrlo fleksibilan i moćan alat. U članku smo razmotrili jedan praktični slučaj i demonstrirali mali dio onoga što je moguće učiniti. Ako vas zanima razvoj BCC alata, vrijedi pogledati službeni tutorijal, koja dobro opisuje osnove rada.

Postoje i drugi zanimljivi alati za ispravljanje grešaka i profiliranje, temeljeni na eBPF-u. Jedan od njih je bpftrace, koji omogućuje pisanje snažnih jednostrukih i malih programa na jeziku sličnom awku. Drugi je ebpf_exporter, koji omogućuje prikupljanje niskonaponskih metrika visoke razlučivosti izravno na vaš prometheus poslužitelj, s mogućnošću daljnjeg dobijanja lijepe vizualizacije i čak upozorenja.

Izvor: habr.com

Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere 🔥 Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere | ProHoster