Nga Latencia e Lartë e Ceph-it në Arnimin e Kernelit me eBPF/BCC

Nga Latencia e Lartë e Ceph-it në Arnimin e Kernelit me eBPF/BCC

Linux ka një numër të madh mjetesh për korrigjimin e kernelit dhe aplikacioneve. Shumica e tyre kanë një ndikim negativ në performancën e aplikacionit dhe nuk mund të përdoren në prodhim.

Para nja dy vitesh kishte është zhvilluar një mjet tjetër - eBPF. Ai bën të mundur gjurmimin e kernelit dhe aplikacioneve të përdoruesit me kosto të ulët dhe pa nevojën për të rindërtuar programe dhe ngarkuar module të palëve të treta në kernel.

Tashmë ka shumë shërbime aplikacioni që përdorin eBPF, dhe në këtë artikull do të shikojmë se si të shkruani programin tuaj të profilizimit bazuar në bibliotekë PythonBCC. Artikulli është i bazuar në ngjarje reale. Ne do të kalojmë nga një problem në tjetrin për të treguar se si mund të përdoren shërbimet ekzistuese në situata specifike.

Ceph është i ngadalshëm

Një host i ri është shtuar në grupin Ceph. Pas migrimit të disa të dhënave në të, vumë re se shpejtësia e përpunimit të kërkesave për shkrim nga ai ishte shumë më e ulët se në serverët e tjerë.

Nga Latencia e Lartë e Ceph-it në Arnimin e Kernelit me eBPF/BCC
Ndryshe nga platformat e tjera, ky host përdori bcache dhe kernelin e ri linux 4.15. Kjo ishte hera e parë që një host i këtij konfigurimi u përdor këtu. Dhe në atë moment ishte e qartë se rrënja e problemit teorikisht mund të ishte çdo gjë.

Hetimi i hostit

Le të fillojmë duke parë se çfarë ndodh brenda procesit ceph-osd. Për këtë do të përdorim perfekte и flameskopi (më shumë për të cilat mund të lexoni këtu):

Nga Latencia e Lartë e Ceph-it në Arnimin e Kernelit me eBPF/BCC
Fotografia na tregon se funksioni fdatasync () shpenzoi shumë kohë duke dërguar një kërkesë për funksionet generic_make_request(). Kjo do të thotë që ka shumë të ngjarë që shkaku i problemeve të jetë diku jashtë vetë demonit osd. Ky mund të jetë ose kerneli ose disqet. Dalja e iostatit tregoi një vonesë të lartë në përpunimin e kërkesave nga disqet bcache.

Kur kontrolluam hostin, zbuluam se daemon systemd-udevd konsumon një sasi të madhe të kohës së CPU - rreth 20% në disa bërthama. Kjo është sjellje e çuditshme, kështu që ju duhet të zbuloni pse. Meqenëse Systemd-udevd punon me uevents, vendosëm t'i shikojmë ato monitor udevadm. Rezulton se një numër i madh ngjarjesh ndryshimi u krijuan për çdo pajisje bllok në sistem. Kjo është mjaft e pazakontë, kështu që ne do të duhet të shohim se çfarë gjeneron të gjitha këto ngjarje.

Duke përdorur paketën e veglave BCC

Siç e kemi zbuluar tashmë, kerneli (dhe daemon ceph në thirrjen e sistemit) shpenzon shumë kohë në generic_make_request(). Le të përpiqemi të matim shpejtësinë e këtij funksioni. NË BCC Ekziston tashmë një mjet i mrekullueshëm - funksionaliteti. Ne do ta gjurmojmë demonin me PID-in e tij me një interval 1 sekondë midis daljeve dhe do ta nxjerrim rezultatin në milisekonda.

Nga Latencia e Lartë e Ceph-it në Arnimin e Kernelit me eBPF/BCC
Kjo veçori zakonisht funksionon shpejt. Gjithçka që bën është ta kalojë kërkesën në radhën e drejtuesit të pajisjes.

Bcache është një pajisje komplekse që në fakt përbëhet nga tre disqe:

  • pajisje mbështetëse (disk i memorizuar), në këtë rast është një HDD i ngadaltë;
  • pajisja e memories (disku i memories), këtu kjo është një ndarje e pajisjes NVMe;
  • pajisja virtuale bcache me të cilën funksionon aplikacioni.

Ne e dimë që transmetimi i kërkesës është i ngadaltë, por për cilën nga këto pajisje? Do të merremi me këtë pak më vonë.

Tani e dimë se ngjarjet ka të ngjarë të shkaktojnë probleme. Të gjesh se çfarë saktësisht shkakton gjenerimin e tyre nuk është aq e lehtë. Le të supozojmë se ky është një lloj softueri që lëshohet në mënyrë periodike. Le të shohim se çfarë lloj softueri funksionon në sistem duke përdorur një skript execsnoop nga e njëjta Kompleti i shërbimeve BCC. Le ta ekzekutojmë dhe ta dërgojmë daljen në një skedar.

Për shembull si kjo:

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

Ne nuk do të tregojmë prodhimin e plotë të execsnoop këtu, por një linjë me interes për ne dukej kështu:

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

Kolona e tretë është PPID (PID mëmë) i procesit. Procesi me PID 5802 doli të ishte një nga temat e sistemit tonë të monitorimit. Gjatë kontrollimit të konfigurimit të sistemit të monitorimit, u gjetën parametra të gabuar. Temperatura e përshtatësit HBA matej çdo 30 sekonda, gjë që është shumë më shpesh sesa duhet. Pas ndryshimit të intervalit të kontrollit në një më të gjatë, zbuluam se vonesa e përpunimit të kërkesës në këtë host nuk binte më në sy në krahasim me hostet e tjerë.

Por është ende e paqartë pse pajisja bcache ishte kaq e ngadaltë. Ne përgatitëm një platformë testimi me një konfigurim identik dhe u përpoqëm të riprodhonim problemin duke ekzekutuar fio në bcache, duke ekzekutuar në mënyrë periodike këmbëzën udevadm për të gjeneruar uevents.

Shkrimi i mjeteve të bazuara në BCC

Le të përpiqemi të shkruajmë një mjet të thjeshtë për të gjurmuar dhe shfaqur thirrjet më të ngadalta generic_make_request(). Ne jemi gjithashtu të interesuar për emrin e diskut për të cilin u thirr ky funksion.

Plani është i thjeshtë:

  • Regjistrohu ksondë mbi generic_make_request():
    • Ne ruajmë emrin e diskut në memorie, i arritshëm përmes argumentit të funksionit;
    • Ne ruajmë vulën kohore.

  • Regjistrohu kretsondë për kthim nga generic_make_request():
    • Ne marrim vulën kohore aktuale;
    • Ne kërkojmë vulën kohore të ruajtur dhe e krahasojmë atë me atë aktuale;
    • Nëse rezultati është më i madh se ai i specifikuar, atëherë gjejmë emrin e diskut të ruajtur dhe e shfaqim atë në terminal.

Ksonda и kretsonda përdorni një mekanizëm të pikës së ndërprerjes për të ndryshuar kodin e funksionit në fluturim. Ju mund të lexoni dokumentacionin и mirë artikull mbi këtë temë. Nëse shikoni kodin e shërbimeve të ndryshme në BCC, atëherë mund të shihni se ato kanë një strukturë identike. Pra, në këtë artikull ne do të kapërcejmë analizimin e argumenteve të skriptit dhe do të kalojmë te vetë programi BPF.

Teksti eBPF brenda skriptit python duket si ky:

bpf_text = “”” # Here will be the bpf program code “””

Për të shkëmbyer të dhëna ndërmjet funksioneve, programet eBPF përdorin tabela hash. Ne do të bëjmë të njëjtën gjë. Ne do të përdorim PID të procesit si çelës, dhe do ta përcaktojmë strukturën si vlerë:

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

Këtu regjistrojmë një tabelë hash të quajtur p, me tip çelësi u64 dhe një vlerë të llojit struct data_t. Tabela do të jetë e disponueshme në kontekstin e programit tonë BPF. Makroja BPF_PERF_OUTPUT regjistron një tabelë tjetër të quajtur Ngjarjet, e cila përdoret për transmetimin e të dhënave në hapësirën e përdoruesit.

Kur matni vonesat midis thirrjes së një funksioni dhe kthimit prej tij, ose midis thirrjeve në funksione të ndryshme, duhet të keni parasysh që të dhënat e marra duhet t'i përkasin të njëjtit kontekst. Me fjalë të tjera, duhet të mbani mend për nisjen e mundshme paralele të funksioneve. Ne kemi aftësinë për të matur vonesën midis thirrjes së një funksioni në kontekstin e një procesi dhe kthimit nga ai funksion në kontekstin e një procesi tjetër, por kjo ka të ngjarë të jetë e padobishme. Një shembull i mirë këtu do të ishte dobia e biolatencës, ku çelësi i tabelës hash është vendosur në një tregues në kërkesë struct, i cili pasqyron një kërkesë për disk.

Më pas, duhet të shkruajmë kodin që do të ekzekutohet kur të thirret funksioni në studim:

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

Këtu argumenti i parë i funksionit të thirrur do të zëvendësohet si argumenti i dytë generic_make_request(). Pas kësaj, marrim PID-në e procesit në kontekstin e të cilit po punojmë dhe vulën kohore aktuale në nanosekonda. Ne i shkruajmë të gjitha në një të zgjedhur rishtas struct data_t të dhëna. Ne marrim emrin e diskut nga struktura bio, e cila kalohet kur telefononi generic_make_request(), dhe ruajeni në të njëjtën strukturë të dhëna. Hapi i fundit është të shtoni një hyrje në tabelën hash që u përmend më herët.

Funksioni i mëposhtëm do të thirret pas kthimit nga 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);
    }
}

Ky funksion është i ngjashëm me atë të mëparshëm: ne zbulojmë PID-in e procesit dhe vulën kohore, por nuk ndajmë memorie për strukturën e re të të dhënave. Në vend të kësaj, ne kërkojmë në tabelën hash për një strukturë tashmë ekzistuese duke përdorur çelësin == PID aktual. Nëse gjendet struktura, atëherë zbulojmë emrin e procesit të ekzekutimit dhe e shtojmë atë në të.

Zhvendosja binare që përdorim këtu është e nevojshme për të marrë GID-in e fillit. ato. PID-i i procesit kryesor që nisi thread-in në kontekstin e të cilit po punojmë. Funksioni që ne quajmë bpf_get_current_pid_tgid() kthen si GID-in e thread-it ashtu edhe PID-in e tij në një vlerë të vetme 64-bit.

Kur nxjerrim në terminal, aktualisht nuk jemi të interesuar për thread-in, por jemi të interesuar për procesin kryesor. Pasi të krahasojmë vonesën që rezulton me një prag të caktuar, ne e kalojmë strukturën tonë të dhëna në hapësirën e përdoruesit nëpërmjet tabelës Ngjarjet, pas së cilës fshijmë hyrjen nga p.

Në skriptin python që do të ngarkojë këtë kod, duhet të zëvendësojmë MIN_US dhe FACTOR me pragjet e vonesës dhe njësitë kohore, të cilat do t'i kalojmë përmes argumenteve:

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"

Tani duhet të përgatisim programin BPF nëpërmjet makro BPF dhe regjistroni mostrat:

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

Ne gjithashtu do të duhet të përcaktojmë struct data_t në skenarin tonë, përndryshe nuk do të mund të lexojmë asgjë:

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

Hapi i fundit është nxjerrja e të dhënave 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()

Vetë skenari është i disponueshëm në GitHub. Le të përpiqemi ta ekzekutojmë në një platformë testimi ku funksionon fio, duke shkruar në bcache dhe telefononi monitorin udevadm:

Nga Latencia e Lartë e Ceph-it në Arnimin e Kernelit me eBPF/BCC
Më në fund! Tani shohim se ajo që dukej si një pajisje bcache e bllokuar është në të vërtetë një telefonatë e bllokuar generic_make_request() për një disk të memorizuar.

Gërmoni në Kernel

Çfarë saktësisht po ngadalësohet gjatë transmetimit të kërkesës? Shohim që vonesa ndodh edhe para fillimit të kontabilitetit të kërkesës, d.m.th. kontabilizimi i një kërkese specifike për dalje të mëtejshme të statistikave mbi të (/proc/diskstats ose iostat) nuk ka filluar ende. Kjo mund të verifikohet lehtësisht duke ekzekutuar iostat gjatë riprodhimit të problemit, ose Biolatencia e skriptit BCC, e cila bazohet në fillimin dhe përfundimin e kontabilitetit të kërkesës. Asnjë nga këto shërbime nuk do të shfaqë probleme për kërkesat në diskun e memorizuar.

Nëse shikojmë funksionin generic_make_request(), atëherë do të shohim që para se kërkesa të fillojë kontabilizimin thirren edhe dy funksione të tjera. Së pari - generic_make_request_checks(), kryen kontrolle mbi ligjshmërinë e kërkesës në lidhje me cilësimet e diskut. E dyta - blk_queue_enter(), e cila ka një sfidë interesante 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ë të, kerneli pret që radha të shkrihet. Le të matim vonesën 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    	|                                    	|

Duket se jemi afër zgjidhjes. Funksionet e përdorura për të ngrirë / shkrirë një radhë janë blk_mq_freeze_queue и blk_mq_unfrieze_queue. Ato përdoren kur është e nevojshme të ndryshohen cilësimet e radhës së kërkesave, të cilat janë potencialisht të rrezikshme për kërkesat në këtë radhë. Kur telefononi blk_mq_freeze_queue() funksioni blk_freeze_queue_start() numëruesi është rritur q->mq_ngrirja_thellësia. Pas kësaj, kerneli pret që radha të zbrazet brenda blk_mq_freeze_queue_wait().

Koha që duhet për të pastruar këtë radhë është e barabartë me vonesën e diskut pasi kerneli pret që të gjitha operacionet në radhë të përfundojnë. Pasi radha të jetë bosh, ndryshimet e cilësimeve zbatohen. Pas së cilës quhet blk_mq_unfreeze_queue(), duke ulur numëruesin ngrirja_thellësia.

Tani dimë mjaftueshëm për të korrigjuar situatën. Komanda e nxitjes udevadm bën që të aplikohen cilësimet për pajisjen e bllokut. Këto cilësime përshkruhen në rregullat e udev. Mund të gjejmë se cilat cilësime po ngrijnë radhën duke u përpjekur t'i ndryshojmë ato përmes sysfs ose duke parë kodin burimor të kernelit. Mund të provojmë gjithashtu programin BCC gjurmë, i cili do të nxjerrë gjurmët e kernelit dhe hapësirës së përdoruesit për çdo thirrje në terminal blk_freeze_queue, për shembull:

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

Rregullat e Udev ndryshojnë mjaft rrallë dhe zakonisht kjo ndodh në mënyrë të kontrolluar. Pra, ne shohim që edhe aplikimi i vlerave tashmë të vendosura shkakton një rritje të vonesës në transferimin e kërkesës nga aplikacioni në disk. Sigurisht, krijimi i ngjarjeve udev kur nuk ka ndryshime në konfigurimin e diskut (për shembull, pajisja nuk është montuar/shkëputur) nuk është një praktikë e mirë. Megjithatë, ne mund ta ndihmojmë kernelin të mos bëjë punë të panevojshme dhe të ngrijë radhën e kërkesave nëse nuk është e nevojshme. Три i vogël angazhohen korrigjoni situatën.

Përfundim

eBPF është një mjet shumë fleksibël dhe i fuqishëm. Në artikull ne shikuam një rast praktik dhe demonstruam një pjesë të vogël të asaj që mund të bëhet. Nëse jeni të interesuar në zhvillimin e shërbimeve të BCC, ia vlen t'i hidhni një sy tutorial zyrtar, i cili përshkruan mirë bazat.

Ka mjete të tjera interesante për korrigjimin dhe profilizimin e bazuar në eBPF. Një prej tyre - bpftrace, i cili ju lejon të shkruani një linjë të fuqishme dhe programe të vogla në gjuhën e ngjashme me awk. Një tjetër - ebpf_eksportues, ju lejon të grumbulloni metrikë të nivelit të ulët dhe me rezolucion të lartë direkt në serverin tuaj Prometheus, me aftësinë për të marrë më vonë vizualizime të bukura dhe madje sinjalizime.

Burimi: www.habr.com

Shto një koment