De la o latență High Ceph la un patch de kernel folosind eBPF/BCC

De la o latență High Ceph la un patch de kernel folosind eBPF/BCC

În Linux există o mulțime de instrumente pentru depanarea kernel-ului și a aplicațiilor. Majoritatea acestora afectează negativ performanța aplicațiilor și nu pot fi folosite în producție.

Acum câțiva ani a fost dezvoltat un alt instrument — eBPF. Acesta permite trasarea nucleului și a aplicațiilor utilizatorului cu un overhead scăzut și fără a fi necesară recompilarea programelor sau încărcarea de module externe în nucleu.

În prezent, există deja numeroase utilitare aplicative care folosesc eBPF, iar în acest articol vom explora cum să scriem propria utilitară de profilare bazată pe biblioteca PythonBCC. Articolul este bazat pe evenimente reale. Vom parcurge drumul de la apariția problemei până la rezolvarea acesteia, pentru a ilustra cum pot fi utilizate utilitarele existente în situații concrete.

Ceph este lent

Am adăugat un nou gazdă în clusterul Ceph. După migrarea unei părți a datelor pe el, am observat că viteza de procesare a cererilor de scriere era mult mai mică decât pe celelalte servere.

De la o latență High Ceph la un patch de kernel folosind eBPF/BCC
Spre deosebire de alte platforme, pe acest gazdă a fost utilizat bcache și un nou nucleu linux 4.15. Această configurație de gazdă a fost utilizată aici pentru prima dată. Și în acel moment era clar că rădăcina problemei ar fi putut fi orice.

Investigând gazda

Vom începe prin a observa ce se întâmplă în cadrul procesului ceph-osd. Pentru aceasta, vom folosi perf și flamescope (despre care puteți citi mai multe aici):

De la o latență High Ceph la un patch de kernel folosind eBPF/BCC
Imaginea ne spune că funcția fdatasync() a consumat mult timp în trimiterea cererii în funcția generic_make_request(). Asta înseamnă că, cel mai probabil, cauza problemelor se află undeva în afara demonului osd. Aceasta poate fi fie nucleul, fie discurile. Iostat arăta o întârziere mare în procesarea cererilor de către discurile bcache.

În timpul verificării gazdei, am descoperit că demonul systemd-udevd consumă mult timp CPU — aproximativ 20% pe mai multe nuclee. Acest comportament ciudat necesită o elucidare. Deoarece Systemd-udevd lucrează cu uevent-uri, am decis să le examinăm prin intermediul udevadm monitor. Se pare că se generau multe evenimente de schimbare pentru fiecare dispozitiv de bloc din sistem. Acesta este un comportament destul de neobișnuit, prin urmare, va trebui să investigăm ce generează toate aceste evenimente.

Folosind Toolkit-ul BCC

Așa cum am constatat deja, nucleul (și demonul ceph în apelul de sistem) petrece mult timp în generic_make_request(). Să încercăm să măsurăm viteza de execuție a acestei funcții. În BCC există deja o utilitară grozavă — funclatency. Vom urmări demonul după PID-ul său cu un interval de 1 secundă între ieșiri și vom afișa rezultatul în milisecunde.

De la o latență High Ceph la un patch de kernel folosind eBPF/BCC
De obicei, această funcție funcționează rapid. Tot ce face este să transmită cererea în coada driver-ului de dispozitiv.

Bcache — este un dispozitiv complex care constă, de fapt, din trei discuri:

  • dispozitiv de bază (disc care poate fi pus în cache), în acest caz un HDD lent;
  • dispozitiv de cache (disc care face caching), aici este o partiție NVMe a dispozitivului;
  • dispozitiv virtual bcache, cu care lucrează aplicația.

Știm că transmiterea cererii încetinește, dar pentru care dintre aceste dispozitive? Să ne ocupăm de asta puțin mai târziu.

Acum știm că uevent-urile provoacă probabil probleme. A găsi ce anume generează acele uevent-uri nu este atât de simplu. Să presupunem că este un software care se lansează periodic. Să vedem ce software este lansat în sistem cu ajutorul scriptului execsnoop din același set de utilitare BCC. Să-l lansăm și să direcționăm ieșirea într-un fișier.

De exemplu, așa:

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

Nu vom prezenta aici întreaga ieșire execsnoop, dar o linie care ne interesează arăta astfel:

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

A treia coloană este PPID (PID-ul părintelui) procesului. Procesul cu PID 5802 s-a dovedit a fi unul dintre firele sistemului nostru de monitorizare. La verificarea configurației sistemului de monitorizare au fost găsite parametri greșit setați. Temperatura adaptatorului HBA era măsurată la fiecare 30 de secunde, ceea ce era mult mai des decât era necesar. După ce am schimbat intervalul de verificare la unul mai lung, am constatat că întârzierile în procesarea cererilor pe acest host nu mai ieșeau în evidență față de celelalte hosts.

Dar încă nu este clar de ce dispozitivul bcache încetinea atât de tare. Am pregătit o platformă de testare cu o configurație identică și am încercat să reproducem problema, lansând fio pe bcache, lansând periodic udevadm trigger pentru a genera uevents.

Scrierea uneltelor bazate pe BCC

Vom încerca să scriem o unealtă simplă pentru a urmări și a afișa cele mai lente apeluri generic_make_request(). De asemenea, ne interesează numele discului pentru care a fost apelată această funcție.

Planul este simplu:

  • Înregistrăm kprobe pe generic_make_request():
    • Salvăm în memorie numele discului, disponibil prin argumentul funcției;
    • Salvăm timestamp-ul.

  • Înregistrăm kretprobe la întoarcerea din generic_make_request():
    • Obținem timestamp-ul curent;
    • Căutăm timestamp-ul salvat și îl comparăm cu cel curent;
    • Dacă rezultatul este mai mare decât cel stabilit, atunci găsim numele salvat al discului și îl afișăm în terminal.

Kprobes și kretprobes utilizează mecanismul punctelor de oprire pentru a modifica codul funcțiilor în timp real. Puteți citi documentație și un articol bun pe această temă. Dacă privim codul diverselor utilitare din , putem observa că ele au o structură identică. Așa că în acest articol vom omite parsarea argumentelor scriptului și vom trece direct la programul BPF. BCCTextul eBPF din scriptul python arată după cum urmează:

bpf_text = “”” # Aici va fi codul programului bpf “””

Pentru schimbul de date între funcții, programele eBPF utilizează

tabele hash . Așa vom proceda și noi. Ca și cheie, vom folosi PID-ul procesului, iar ca valoare vom defini structura: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);

Aici înregistrăm o tabelă hash, care se numește

, cu o cheie de tip pu64 și o valoare de tip struct data_t . Tabela va fi disponibilă în contextul programului nostru BPF. Macro-ul BPF_PERF_OUTPUT înregistrează o altă tabelă, numităevents , care este utilizată pentrutransmiterea datelor în spațiul utilizatorului. Când măsurăm întârzierile între apelul unei funcții și returnarea din aceasta, sau între apelurile diferitelor funcții, trebuie să luăm în considerare că datele obținute trebuie să aparțină aceluiași context. Cu alte cuvinte, trebuie să ne amintim de posibila executare paralelă a funcțiilor. Avem posibilitatea de a măsura întârzierea între apelul unei funcții în contextul unui singur proces și returnarea din această funcție în contextul altui proces, dar acesta este, cel mai probabil, inutil. Un exemplu bun în acest sens ar putea fi

utilitarul biolatency , unde ca și cheie a tabelei hash se definește un pointer cătrestruct request , care reflectă o cerere către disk.Apoi trebuie să scriem codul care se va executa la apelarea funcției investigate:

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

Aici ca al doilea argument va fi înlocuit primul argument al funcției apelate

. După aceasta obținem PID-ul procesului, în contextul căruia lucrăm, și marcajul temporal curent în nanosecunde. Înscriem toate acestea în structura nou alocată generic_make_request()struct data_t data struct data_t dataNumele discului îl obținem din structură bio, care este transmisă la apel generic_make_request(), și îl salvăm în aceeași structură data. Ultimul pas este să adăugăm o înregistrare în tabela de hash despre care s-a menționat anterior.

Funcția următoare va fi apelată la întoarcerea din 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);
    }
}

Această funcție este similară cu precedenta: aflăm PID-ul procesului și timestamp-ul, dar nu alocăm memorie pentru o nouă structură data. În schimb, căutăm în tabela de hash o structură deja existentă după cheia == PID-ul curent. Dacă structura este găsită, atunci aflăm numele procesului care rulează și îl adăugăm în aceasta.

Shift-ul binar pe care îl folosim aici este necesar pentru a obține GID-ul firului de execuție, adică PID-ul procesului principal care a lansat firul, în contextul în care lucrăm. Funcția pe care o apelăm bpf_get_current_pid_tgid() returnează atât GID-ul firului, cât și PID-ul său într-o valoare de 64 de biți.

La ieșirea în terminal, momentan nu ne interesează firul, ci procesul principal. După compararea întârzierii obținute cu pragul stabilit, transmitem structura noastră data în spațiul utilizatorului prin tabelă , care este utilizată pentru, după care eliminăm înregistrarea din p.

În scriptul Python care va încărca acest cod, va trebui să înlocuim MIN_US și FACTOR cu pragurile de întârziere și unitățile de timp pe care le vom transmite prin 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"

Acum trebuie să pregătim programul BPF prin macro BPF și să înregistrăm probele:

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

De asemenea, va trebui să definim . Tabela va fi disponibilă în contextul programului nostru BPF. Macro-ul BPF_PERF_OUTPUT înregistrează o altă tabelă, numită în scriptul nostru, altfel nu vom putea citi nimic:

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

Ultimul pas - ieșirea datelor în terminal:

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

Scriptul este disponibil pe GItHub. Să încercăm să-l rulăm pe platforma de testare, unde se execută fio, scriind pe bcache, și să apelăm udevadm monitor:

De la o latență High Ceph la un patch de kernel folosind eBPF/BCC
În sfârșit! Acum vedem că ceea ce părea a fi un dispozitiv bcache încetinit este de fapt un apel încetinit generic_make_request() pentru discul cache.

Explorați nucleul

Ce exact încetinește în timpul transferului cererii? Vedem că întârzieri apar chiar înainte de începerea contabilizării cererii, adică contabilizarea cererii specifice pentru statistici ulterioare (\/proc\/diskstats sau iostat) nu a început încă. Acest lucru poate fi verificat cu ușurință prin rularea iostat în timpul reproducerii problemei, fie scriptul BCC biolatency, care se bazează pe începutul și sfârșitul contabilizării cererilor. Niciuna dintre aceste utilitare nu va arăta probleme pentru cererile către discul cache.

Dacă ne uităm la funcția generic_make_request(), vom vedea că înainte de a începe contabilizarea cererii sunt apelate încă două funcții. Prima este generic_make_request_checks(), care efectuează verificări de legitimitate a cererii în raport cu setările discului. A doua este blk_queue_enter(), care conține un apel interesant la 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));

În aceasta, nucleul așteaptă dezghețarea cozii. Să măsurăm întârzierile blk_queue_enter():

~# /usr/share/bcc/tools/funclatency  blk_queue_enter -i 1 -m               	 
Tracing 1 functions for "blk_queue_enter"... Hit Ctrl-C to end.

 	msecs           	: count 	distribution
     	0 -> 1      	: 341  	|****************************************|

 	msecs           	: count 	distribution
     	0 -> 1      	: 316  	|****************************************|

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

Se pare că suntem aproape de soluție. Funcțiile folosite pentru „înghețarea/dezghețarea” cozii sunt blk_mq_freeze_queue și blk_mq_unfreeze_queue. Acestea sunt utilizate atunci când trebuie să schimbați setările cozii cererilor, potențial periculoase pentru cererile aflate în această coadă. La apelarea blk_mq_freeze_queue() funcția blk_freeze_queue_start() încrementează contorul q->mq_freeze_depth. După aceasta, nucleul așteaptă golirea cozii în blk_mq_freeze_queue_wait().

Timpul de așteptare pentru golirea acestei cozi este echivalent cu întârzierea discului, deoarece nucleul așteaptă finalizarea tuturor operațiunilor în așteptare. Odată ce coada este goală, se aplică modificările configurației. După care este apelat blk_mq_unfreeze_queue(), care decrementează contorul freeze_depth.

Acum știm suficient pentru a remedia situația. Comanda udevadm trigger va duce la aplicarea configurațiilor pentru dispozitivul bloc. Aceste configurații sunt descrise în regulile udev. Putem afla ce anume ''congelează'' coada, încercând să le modificăm prin sysfs sau consultând codul sursă al nucleului. De asemenea, putem încerca utilitarul BCC trace, care va afișa în terminal traseele stivei nucleului și ale spațiului utilizator pentru fiecare apel blk_freeze_queue, de exemplu:

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

Regulile udev se schimbă destul de rar și de obicei acest lucru se întâmplă sub control. Prin urmare, vedem că chiar și aplicarea valorilor deja definite provoacă un vârf de întârziere a transferului cererii de la aplicație la disc. Desigur, generarea de evenimente udev când nu există modificări în configurația discurilor (de exemplu, dispozitivul nu este conectat/deconectat) nu este o practică foarte bună. Cu toate acestea, putem ajuta nucleul să nu facă muncă inutilă și să nu ''congeleze'' coada cererilor, dacă nu este necesar. Trei micuțe commit repară situația.

Concluzie

eBPF este un instrument foarte flexibil și puternic. În articol, am analizat un caz practic și am demonstrat o mică parte din ceea ce este posibil de realizat. Dacă sunteți interesat de dezvoltarea de utilitare BCC, merită să aruncați o privire pe tutorialul oficial, care descrie bine bazele funcționării.

Există și alte instrumente interesante pentru depanare și profilare, bazate pe eBPF. Unul dintre ele este bpftrace, care permite scrierea de comenzi puternice de o linie și programe mici într-un limbaj asemănător awk. Altul este ebpf_exporter, care permite colectarea de metrici de nivel scăzut cu rezoluție înaltă direct pe serverul vostru Prometheus, cu posibilitatea de a obține ulterior vizualizări frumoase și chiar alerte.

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster