Բարձր Ceph Latency-ից մինչև Kernel Patch՝ օգտագործելով eBPF/BCC

Բարձր Ceph Latency-ից մինչև Kernel Patch՝ օգտագործելով eBPF/BCC

Linux-ն ունի միջուկի և հավելվածների վրիպազերծման մեծ թվով գործիքներ: Դրանց մեծ մասը բացասաբար է անդրադառնում հավելվածի աշխատանքի վրա և չի կարող օգտագործվել արտադրության մեջ:

Մի երկու տարի առաջ կար մշակվել է մեկ այլ գործիք - eBPF. Այն հնարավորություն է տալիս հետևել միջուկին և օգտատերերի հավելվածներին ցածր ծախսերով և առանց ծրագրերի վերակառուցման և միջուկում երրորդ կողմի մոդուլներ բեռնելու անհրաժեշտության:

Արդեն կան բազմաթիվ հավելվածներ, որոնք օգտագործում են eBPF, և այս հոդվածում մենք կանդրադառնանք, թե ինչպես գրել ձեր սեփական պրոֆիլավորման օգտակար ծրագիրը՝ հիմնվելով գրադարանի վրա: PythonBCC. Հոդվածը հիմնված է իրական իրադարձությունների վրա։ Մենք խնդիրից կուղղենք՝ ցույց տալու, թե ինչպես կարելի է օգտագործել գոյություն ունեցող կոմունալ ծառայությունները կոնկրետ իրավիճակներում:

Սեֆը դանդաղ է

Ceph կլաստերին ավելացվել է նոր հաղորդավար: Տվյալների մի մասը դրան տեղափոխելուց հետո մենք նկատեցինք, որ դրա կողմից գրելու հարցումների մշակման արագությունը շատ ավելի ցածր էր, քան մյուս սերվերներում:

Բարձր Ceph Latency-ից մինչև Kernel Patch՝ օգտագործելով eBPF/BCC
Ի տարբերություն այլ հարթակների, այս հոսթն օգտագործում էր bcache և նոր Linux 4.15 միջուկ: Սա առաջին անգամն էր, որ այս կոնֆիգուրացիայի հոսթն օգտագործվում էր այստեղ: Եվ այդ պահին պարզ էր, որ խնդրի արմատը տեսականորեն կարող է լինել ամեն ինչ։

Հյուրընկալողի հետաքննություն

Եկեք սկսենք նայելով, թե ինչ է տեղի ունենում ceph-osd գործընթացի ներսում: Դրա համար մենք կօգտագործենք պերֆ и ֆլեյմսկոպ (ավելին, որի մասին կարող եք կարդալ այստեղ):

Բարձր Ceph Latency-ից մինչև Kernel Patch՝ օգտագործելով eBPF/BCC
Նկարը մեզ ասում է, որ ֆունկցիան fdatasync () շատ ժամանակ է ծախսել գործառույթներին հարցում ուղարկելու համար generic_make_request (). Սա նշանակում է, որ, ամենայն հավանականությամբ, խնդիրների պատճառը գտնվում է բուն osd daemon-ից դուրս: Սա կարող է լինել կամ միջուկը կամ սկավառակը: Իոստատի ելքը ցույց տվեց bcache սկավառակների կողմից հարցումների մշակման բարձր ուշացում:

Հոսթին ստուգելիս մենք պարզեցինք, որ systemd-udevd daemon-ը մեծ քանակությամբ պրոցեսորի ժամանակ է ծախսում՝ մոտ 20% մի քանի միջուկների վրա: Սա տարօրինակ պահվածք է, ուստի պետք է պարզել, թե ինչու: Քանի որ Systemd-udevd-ն աշխատում է uevents-ի հետ, մենք որոշեցինք դրանք դիտարկել udevadm մոնիտոր. Պարզվում է, որ համակարգի յուրաքանչյուր բլոկ սարքի համար մեծ թվով փոփոխության իրադարձություններ են ստեղծվել։ Սա բավականին անսովոր է, ուստի մենք պետք է նայենք, թե ինչն է առաջացնում այս բոլոր իրադարձությունները:

Օգտագործելով BCC Toolkit-ը

Ինչպես արդեն պարզել ենք, միջուկը (և համակարգային զանգի ceph daemon-ը) շատ ժամանակ է ծախսում generic_make_request (). Փորձենք չափել այս ֆունկցիայի արագությունը։ IN BCC Արդեն կա հիանալի օգտակարություն. funclatency. Մենք կհետագծենք դեյմոնին իր PID-ով` ելքերի միջև 1 վայրկյան ընդմիջումով և արդյունքը կարտադրենք միլիվայրկյաններով:

Բարձր Ceph Latency-ից մինչև Kernel Patch՝ օգտագործելով eBPF/BCC
Այս հատկությունը սովորաբար արագ է աշխատում: Այն ամենը, ինչ անում է, այն է, որ հարցումը փոխանցի սարքի վարորդի հերթին:

Bcache բարդ սարք է, որն իրականում բաղկացած է երեք սկավառակից.

  • օժանդակ սարք (քեշավորված սկավառակ), այս դեպքում դա դանդաղ HDD է;
  • քեշավորման սարք (քեշավորման սկավառակ), այստեղ սա NVMe սարքի մեկ բաժանումն է.
  • bcache վիրտուալ սարքը, որով աշխատում է հավելվածը:

Մենք գիտենք, որ հարցումների փոխանցումը դանդաղ է, բայց այս սարքերից ո՞րի համար: Սրանով կզբաղվենք մի փոքր ուշ:

Այժմ մենք գիտենք, որ իրադարձությունները կարող են խնդիրներ առաջացնել: Գտնել, թե կոնկրետ ինչն է առաջացնում նրանց սերունդը, այնքան էլ հեշտ չէ: Ենթադրենք, որ սա ինչ-որ ծրագրաշար է, որը պարբերաբար գործարկվում է: Տեսնենք, թե ինչպիսի ծրագրակազմ է աշխատում համակարգում՝ օգտագործելով սկրիպտը execsnoop նույնից BCC կոմունալ հավաքածու. Եկեք գործարկենք այն և ելքը ուղարկենք ֆայլ:

Օրինակ այսպես.

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

Մենք այստեղ չենք ցուցադրի execsnoop-ի ամբողջական արդյունքը, բայց մեզ հետաքրքրող մի տող այսպիսի տեսք ուներ.

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

Երրորդ սյունակը գործընթացի PPID-ն է (ծնող PID): PID 5802-ով գործընթացը պարզվեց, որ մեր մոնիտորինգի համակարգի թելերից մեկն է։ Մոնիտորինգի համակարգի կոնֆիգուրացիան ստուգելիս հայտնաբերվել են սխալ պարամետրեր: HBA ադապտերի ջերմաստիճանը չափվել է 30 վայրկյանը մեկ, ինչը շատ ավելի հաճախ է, քան անհրաժեշտ է։ Ստուգման միջակայքն ավելի երկարի փոխելուց հետո մենք պարզեցինք, որ հարցումների մշակման հետաձգումը այս հոսթինգում այլևս աչքի չի ընկնում այլ հոսթորդների համեմատ:

Բայց դեռ պարզ չէ, թե ինչու էր bcache սարքն այդքան դանդաղ: Մենք պատրաստեցինք նույնական կոնֆիգուրացիայով փորձնական հարթակ և փորձեցինք վերարտադրել խնդիրը՝ գործարկելով fio-ն bcache-ում, պարբերաբար գործարկելով udevadm գործարկիչը՝ uevents ստեղծելու համար:

BCC-ի վրա հիմնված գործիքներ գրելը

Փորձենք գրել մի պարզ ծրագիր՝ ամենադանդաղ զանգերը հետագծելու և ցուցադրելու համար generic_make_request (). Մեզ հետաքրքրում է նաև այն դրայվի անվանումը, որի համար կանչվել է այս ֆունկցիան։

Պլանը պարզ է.

  • Գրանցվել kprobe մասին generic_make_request ():
    • Մենք պահում ենք սկավառակի անունը հիշողության մեջ, որը հասանելի է ֆունկցիայի փաստարկի միջոցով.
    • Մենք պահպանում ենք ժամանակի կնիքը:

  • Գրանցվել kretprobe -ից վերադարձի համար generic_make_request ():
    • Մենք ստանում ենք ընթացիկ ժամանակացույցը;
    • Մենք փնտրում ենք պահպանված ժամանակի դրոշմակնիքը և համեմատում այն ​​ընթացիկի հետ.
    • Եթե ​​արդյունքը ավելի մեծ է, քան նշվածը, ապա մենք գտնում ենք պահված սկավառակի անունը և ցուցադրում այն ​​տերմինալում:

Kprobes и kretprobes օգտագործեք բեկման կետի մեխանիզմ՝ ֆունկցիոնալ կոդը անմիջապես փոխելու համար: Դուք կարող եք կարդալ փաստաթղթեր и լավ հոդված այս թեմայով: Եթե ​​նայեք տարբեր կոմունալ ծառայությունների ծածկագրին BCC, ապա դուք կարող եք տեսնել, որ նրանք ունեն նույնական կառուցվածք: Այսպիսով, այս հոդվածում մենք բաց կթողնենք սցենարի փաստարկների վերլուծությունը և կանցնենք հենց BPF ծրագրին:

python սցենարի ներսում eBPF տեքստը նման է հետևյալին.

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

Ֆունկցիաների միջև տվյալների փոխանակման համար eBPF ծրագրերն օգտագործում են հեշ աղյուսակներ. Մենք էլ նույնը կանենք։ Մենք կօգտագործենք գործընթացի PID-ը որպես բանալի, իսկ կառուցվածքը կսահմանենք որպես արժեք.

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

Այստեղ մենք գրանցում ենք հեշ աղյուսակ, որը կոչվում է p, բանալի տեսակով u64 և տիպի արժեքը struct data_t. Աղյուսակը հասանելի կլինի մեր BPF ծրագրի համատեքստում: BPF_PERF_OUTPUT մակրոն գրանցում է մեկ այլ աղյուսակ, որը կոչվում է իրադարձությունները, որն օգտագործվում է տվյալների փոխանցում օգտագործողի տարածության մեջ:

Ֆունկցիա կանչելու և դրանից վերադառնալու կամ տարբեր գործառույթների կանչերի միջև ուշացումները չափելիս պետք է հաշվի առնել, որ ստացված տվյալները պետք է պատկանեն նույն համատեքստին: Այլ կերպ ասած, դուք պետք է հիշեք գործառույթների հնարավոր զուգահեռ գործարկման մասին: Մենք հնարավորություն ունենք չափելու ուշացումը մի գործընթացի համատեքստում ֆունկցիա կանչելու և մեկ այլ գործընթացի համատեքստում այդ ֆունկցիայից վերադառնալու միջև, բայց դա, հավանաբար, անօգուտ է: Այստեղ լավ օրինակ կլինի biolatency օգտակար, որտեղ հեշ աղյուսակի ստեղնը դրված է որպես ցուցիչ struct հարցում, որն արտացոլում է մեկ սկավառակի հարցում:

Հաջորդը, մենք պետք է գրենք կոդը, որը կաշխատի, երբ ուսումնասիրվող ֆունկցիան կանչվի.

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

Այստեղ կանչված ֆունկցիայի առաջին արգումենտը կփոխարինվի որպես երկրորդ արգումենտ generic_make_request (). Դրանից հետո մենք ստանում ենք պրոցեսի PID-ը, որի համատեքստում մենք աշխատում ենք, և ընթացիկ ժամանակի դրոշմը նանվայրկյաններով: Մենք գրում ենք այդ ամենը թարմ ընտրված struct data_t տվյալներ. Մենք ստանում ենք սկավառակի անվանումը կառուցվածքից bio, որն անցնում է զանգահարելիս generic_make_request (), և պահպանեք այն նույն կառուցվածքում տվյալներ. Վերջին քայլը հեշ աղյուսակում մուտքագրելն է, որը նշվել է ավելի վաղ:

Հետևյալ ֆունկցիան կկանչվի վերադառնալուց հետո 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);
    }
}

Այս ֆունկցիան նման է նախորդին. մենք պարզում ենք գործընթացի PID-ն ու ժամանակի դրոշմակնիքը, բայց տվյալների նոր կառուցվածքի համար հիշողություն չենք հատկացնում: Փոխարենը, մենք որոնում ենք հեշ աղյուսակում արդեն գոյություն ունեցող կառուցվածք՝ օգտագործելով == ընթացիկ PID բանալին: Եթե ​​կառույցը գտնվի, ապա մենք պարզում ենք ընթացիկ գործընթացի անվանումը և ավելացնում այն:

Երկուական հերթափոխը, որը մենք օգտագործում ենք այստեղ, անհրաժեշտ է թեմայի GID-ը ստանալու համար: դրանք. Հիմնական գործընթացի PID, որը սկսել է շարանը, որի համատեքստում մենք աշխատում ենք: Գործառույթը, որը մենք կոչում ենք bpf_get_current_pid_tgid() վերադարձնում է թելի GID-ը և PID-ը մեկ 64-բիթանոց արժեքով:

Տերմինալ ելք ուղարկելիս մենք ներկայումս հետաքրքրված չենք հոսքով, բայց մեզ հետաքրքրում է հիմնական գործընթացը: Ստացված ուշացումը տվյալ շեմի հետ համեմատելուց հետո մենք անցնում ենք մեր կառուցվածքը տվյալներ աղյուսակի միջոցով օգտագործողի տարածություն իրադարձությունները, որից հետո մենք ջնջում ենք մուտքը p.

Python սկրիպտում, որը կբեռնի այս կոդը, մենք պետք է փոխարինենք MIN_US-ը և FACTOR-ը հետաձգման շեմերով և ժամանակի միավորներով, որոնք մենք կանցնենք արգումենտների միջով.

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"

Այժմ մենք պետք է պատրաստենք BPF ծրագիրը միջոցով BPF մակրո և գրանցել նմուշները.

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

Մենք նույնպես պետք է որոշենք struct data_t մեր սցենարում, հակառակ դեպքում մենք ոչինչ չենք կարողանա կարդալ.

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

Վերջին քայլը տվյալների տերմինալ դուրս բերելն է.

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

Սցենարն ինքնին հասանելի է GITHub. Եկեք փորձենք այն գործարկել փորձնական հարթակի վրա, որտեղ աշխատում է fio-ն, գրում է bcache-ում և զանգում udevadm մոնիտոր.

Բարձր Ceph Latency-ից մինչև Kernel Patch՝ օգտագործելով eBPF/BCC
Վերջապես! Այժմ մենք տեսնում ենք, որ այն, ինչ նման էր փակվող bcache սարքի, իրականում դադարեցված զանգ է generic_make_request () քեշավորված սկավառակի համար:

Փորեք միջուկը

Ի՞նչն է կոնկրետ դանդաղում հարցումների փոխանցման ժամանակ: Մենք տեսնում ենք, որ ուշացումը տեղի է ունենում նույնիսկ պահանջի հաշվառման մեկնարկից առաջ, այսինքն. Դրա վերաբերյալ վիճակագրության հետագա ելքի համար կոնկրետ հարցումի հաշվառումը (/proc/diskstats կամ iostat) դեռ չի սկսվել: Սա հեշտությամբ կարելի է հաստատել՝ խնդիրը վերարտադրելիս գործարկելով iostat, կամ BCC script biolatency, որը հիմնված է հարցումների հաշվառման սկզբի և ավարտի վրա: Այս կոմունալ ծառայություններից ոչ մեկը չի ցուցադրի քեշավորված սկավառակի հարցումների հետ կապված խնդիրներ:

Եթե ​​նայենք ֆունկցիային generic_make_request (), ապա կտեսնենք, որ մինչ հարցումը կսկսվի հաշվառումը, կանչվում է ևս երկու գործառույթ։ Առաջին - generic_make_request_checks(), ստուգում է սկավառակի կարգավորումների վերաբերյալ հարցումի օրինականությունը։ Երկրորդ - blk_queue_enter(), որն ունի հետաքրքիր մարտահրավեր 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));

Դրանում միջուկը սպասում է հերթի ապասառեցմանը: Եկեք չափենք ուշացումը 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    	|                                    	|

Կարծես թե մենք մոտ ենք լուծմանը: Հերթի սառեցման/ապասառեցման համար օգտագործվող գործառույթներն են blk_mq_freeze_queue и blk_mq_unfreeze_queue. Դրանք օգտագործվում են, երբ անհրաժեշտ է փոխել հարցումների հերթի կարգավորումները, որոնք պոտենցիալ վտանգավոր են այս հերթի հարցումների համար: Զանգահարելիս blk_mq_freeze_queue() ֆունկցիան blk_freeze_queue_start() հաշվիչը ավելանում է q->mq_սառեցման_խորություն. Դրանից հետո միջուկը սպասում է, որ հերթը դատարկվի blk_mq_freeze_queue_wait().

Այս հերթը մաքրելու համար պահանջվող ժամանակը համարժեք է սկավառակի հետաձգմանը, քանի որ միջուկը սպասում է հերթագրված բոլոր գործողությունների ավարտին: Երբ հերթը դատարկ է, կարգավորումների փոփոխությունները կիրառվում են: Որից հետո կոչվում է blk_mq_unfreeze_queue(), նվազեցնելով հաշվիչը սառեցման_խորությունը.

Այժմ մենք բավականաչափ գիտենք իրավիճակը շտկելու համար։ Udevadm ձգան հրամանը հանգեցնում է արգելափակման սարքի կարգավորումների կիրառմանը: Այս կարգավորումները նկարագրված են udev կանոններում: Մենք կարող ենք գտնել, թե որ կարգավորումներն են սառեցնում հերթը` փորձելով դրանք փոխել sysfs-ի միջոցով կամ նայելով միջուկի սկզբնական կոդը: Մենք կարող ենք նաև փորձել BCC կոմունալ ծրագիրը շավիղ, որը կարտադրի միջուկի և օգտագործողների տարածքի ստեկերի հետքերը դեպի տերմինալ յուրաքանչյուր զանգի համար blk_freeze_queue, օրինակ `

~# /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-ի իրադարձությունների ստեղծումը, երբ սկավառակի կազմաձևում փոփոխություններ չկան (օրինակ՝ սարքը միացված/անջատված չէ) լավ պրակտիկա չէ: Այնուամենայնիվ, մենք կարող ենք օգնել միջուկին չանել ավելորդ աշխատանք և սառեցնել հարցումների հերթը, եթե դա անհրաժեշտ չէ: Երեքը փոքր պարտավորվել շտկել իրավիճակը.

Եզրափակում

eBPF-ն շատ ճկուն և հզոր գործիք է: Հոդվածում մենք դիտարկեցինք մեկ գործնական դեպք և ցույց տվեցինք, թե ինչ կարելի է անել: Եթե ​​դուք հետաքրքրված եք BCC կոմունալ ծառայությունների մշակմամբ, արժե դիտել պաշտոնական ձեռնարկ, որը լավ է նկարագրում հիմունքները։

EBPF-ի վրա հիմնված վրիպազերծման և պրոֆիլավորման այլ հետաքրքիր գործիքներ կան: Նրանցից մեկը - bpftrace, որը թույլ է տալիս գրել հզոր միակողմանի և փոքր ծրագրեր awk լեզվով: Մեկ այլ - ebpf_exporter, թույլ է տալիս հավաքել ցածր մակարդակի, բարձր լուծաչափի չափումներ անմիջապես ձեր Prometheus սերվերում՝ հետագայում գեղեցիկ վիզուալիզացիաներ և նույնիսկ ծանուցումներ ստանալու հնարավորությամբ:

Source: www.habr.com

Добавить комментарий