
هناك الكثير من الأدوات لتصحيح أخطاء برامج kernel و userpace في Linux. معظمها له تأثير في الأداء ولا يمكن تشغيله بسهولة في بيئات الإنتاج. قبل بضع سنوات ،
تم تطوير eBPF ، والذي يوفر القدرة على تتبع kernel ومساحة المستخدمين مع انخفاض الحمل ، دون الحاجة إلى إعادة ترجمة البرامج أو تحميل الوحدات النمطية kernel.
يوجد الآن الكثير من الأدوات التي تستخدم eBPF وفي هذه المقالة ، سنشرح كيفية كتابة أداة
التوصيف الخاصة بك باستخدام
مكتبة PythonBCC . تستند هذه المقالة إلى مشكلة حقيقية من بيئة الإنتاج. سنتعرف على حل المشكلة ونبين كيف يمكن استخدام أدوات النسخة المخفية الموجودة في بعض الحالات.
Ceph بطيء
تمت إضافة نظام أساسي جديد إلى كتلة ceph. بعد ترحيل بعض البيانات إلى النظام الأساسي ، كان زمن الوصول لطلبات الكتابة أعلى من الخوادم الأخرى.
يحتوي هذا النظام الأساسي على جهاز ظاهري جديد للتخزين المؤقت - bcache ، لم نستخدمه في هذه المجموعة من قبل - ونواة جديدة - 4.15 ، والتي لا تزال غير مستخدمة في أي مكان آخر في هذه المجموعة. يمكن أن يكون جذر المشكلة في أي مكان ، لذلك دعونا نلقي نظرة أعمق.
التحقيق في المضيف
دعونا ننظر إلى ما يجري داخل عملية ceph-osd. نستخدم أداة التتبع و
flamescope في بناء flamegraphs:
كما نرى من flamegraph ، قضى
fdatasync () الكثير من الوقت في تقديم bio في
دالة generic_make_request () . وبالتالي ، فإن جذر مشكلتنا هو في مكان ما خارج الخفي ceph. قد تكون مشكلة kernel أو bcache أو قرص. أظهر إخراج iostat زمن انتقال عالٍ لأجهزة bcache.
اكتشاف آخر مشبوه هو أن البرنامج الخفي systemd-udevd يستهلك وحدة المعالجة المركزية ؛ حوالي 20 ٪ على وحدات المعالجة المركزية متعددة. هذا سلوك غريب ، لذلك علينا أن نعرف ما الذي يحدث. نظرًا لأن systemd-udevd يعمل مع uevents ، يتعين علينا استخدام
شاشة udevadm لمعرفة ما إذا كان هناك أي uevents في النظام. بعد التحقق ، رأينا أنه تم إنشاء الكثير من أدوات التغيير "التغيير" لكل جهاز كتلة في النظام.
هذا أمر غير معتاد ، لذلك سنكتشف سبب إرسال كل هذه الإهامات.
باستخدام مجموعة أدوات BCC
كما نعلم بالفعل ، يقضي kernel (و ceph daemon) الكثير من الوقت في أداء وظائف
generic_make_requst () . دعنا نقيس زمن الوصول باستخدام
funclatency من
مجموعة أدوات BCC ، فقط للتأكد من أننا نسير على الطريق الصحيح. سنقوم بتتبع معرف PID الخاص بـ ceph (وسيطة -p) في فواصل زمنية مدتها ثانية واحدة (-i) وطباعة الكمون بالمللي ثانية (-m).
هذه الوظيفة عادة ما تعمل بسرعة كبيرة. كل ما تفعله هو إرسال البنية الحيوية إلى قائمة انتظار برنامج تشغيل الجهاز.
Bcache هو جهاز معقد. في الواقع ، يتكون من 3 أجهزة: جهاز دعم ، وهو محرك أقراص ثابت بطيء في حالتنا ؛ جهاز تخزين مؤقت ، وهو قسم محرك NVMe ؛ وجهاز bcache الظاهري ، والذي يستخدمه التطبيق. نحن نعلم أن التقديم بطيء ، لكن لأي جهاز هذا شيء سننظر إليه لاحقًا.
في الوقت الحالي ، نعلم أن uevents تتسبب في حدوث مشكلات في cememons وعلينا أن نجد البرنامج الذي يحفز uevents. ليس من السهل العثور على الأسباب التي تؤدي إلى إنشاء uevents. نحن نفترض أنه برنامج يعمل بشكل دوري فقط. لمعرفة ما يتم تنفيذه على النظام ، نستخدم
execsnoop من مجموعة أدوات BCC. يمكننا تشغيله وإعادة توجيه
stdout إلى ملف.
على سبيل المثال:
/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 العملية. لقد فحصنا ما كان 5802 ورأينا أنه أحد خيوط المراقبة الخفية لدينا. إذا نظرنا إلى أبعد من ذلك في تكوين نظام المراقبة ، وجدنا معلمة خاطئة. يتم استرداد درجة حرارة HBA كل 30 ثانية ، وهو كثير من الأحيان. بعد تغيير الفاصل الزمني للتحقق إلى قيمة أكثر ملاءمة ، رأينا أن الكمون لدينا ceph يتطابق مع منصات أخرى.
لكننا ما زلنا لا نعرف لماذا كان زمن انتقال bcache مرتفعًا. لقد أنشأنا منصة اختبار بنفس التكوين وحاولنا إعادة إنتاج المشكلة باستخدام fio على جهاز bcache أثناء تشغيل udev في نفس الوقت باستخدام الأمر udevadm trigger.
كتابة الأدوات المستندة إلى BCC
ما سنفعله هنا هو كتابة أداة بسيطة تتتبع أبطأ مكالمات generic_make_request () وتطبع اسم القرص الذي تم استدعاء الوظيفة له.
الخطة بسيطة:
- تسجيل kprobe على generic_make_request () :
- احفظ اسم القرص المتاح من وسيطة الوظيفة
- حفظ الطابع الزمني الحالي
- تسجيل kretprobe في بيان الإرجاع generic_make_request () :
- استرداد الطابع الزمني الحالي
- البحث عن الطوابع الزمنية المحفوظة سابقا ومقارنتها مع الحالية
- إذا كانت النتيجة أعلى من العتبة ، فابحث عن أسماء الأقراص المحفوظة مسبقًا وقم بطباعتها على الجهاز الطرفي بمعلومات إضافية
Kprobes و
kretprobes استخدام نقاط التوقف لتغيير رمز دالة في وقت التشغيل. يمكنك العثور على
الوثائق وكذلك
مقال جيد حول هذا الموضوع. إذا ألقيت نظرة على الكود الخاص
بأدوات BCC المختلفة ، فسترى أن لديهم جميعًا بنية متطابقة. سنقوم بتخطي تحليل الحجج والتركيز على برنامج BPF نفسه.
سيتم تعريف نص برنامجنا في بيثون على النحو التالي:
bpf_text = “””
تستخدم برامج BPF
hashmaps لمشاركة البيانات بين الوظائف المختلفة. سنستخدم 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);
نحن هنا نسجل hashmap يسمى
p بنوع مفتاح
u64 ونوع قيمة
data_t . يمكن الوصول إلى هذه الخريطة من سياق برنامج BPF الخاص بنا.
يسجل الماكرو
BPF_PERF_OUTPUT خريطة أخرى تسمى
الأحداث ، والتي يتم استخدامها
لدفع البيانات إلى مساحة المستخدمين.
عند قياس زمن الوصول بين استدعاء الوظيفة ورجوعها أو بين استدعاء دالة وآخر ، يجب أن تتأكد من أن البيانات التي قمت بحفظها والوصول إليها تتعلق في نفس السياق. بمعنى آخر ، يجب أن تكون على دراية بأي عمليات إعدام موازية أخرى لنفس الوظيفة. من الممكن تتبع زمن الانتقال بين استدعاء دالة إحدى العمليات وإرجاع نفس الوظيفة من عملية أخرى ، لكن هذا لا يساعدنا. مثال جيد على ذلك هو
أداة biolatency حيث يتم استخدام مؤشر
بنية الطلب كمفتاح هاشماب.
بعد ذلك ، يتعين علينا كتابة رمز سيتم تنفيذه على المكالمات الوظيفية عبر آلية kprobe:
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 والطابع الزمني الحالي
بالنانو ثانية ونكتبه على بيانات data_t الهيكلية المخصصة حديثًا. نحصل على اسم القرص من الهيكل الحيوي ، والذي يتم تمريره إلى
generic_make_request () ،
ونحفظه في بياناتنا. الخطوة الأخيرة هي إضافة إدخال إلى hashmap التي وصفناها سابقًا.
سيتم تنفيذ هذه الوظيفة عند إرجاع
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 والطابع الزمني من الإخراج السابق ونبحث عن hashmap للقيمة حيث
key == PID الحالي . إذا تم العثور عليه ، فسنحصل على اسم العملية الجارية ونضيفها إلى بنية
البيانات . ما نقوم به مع
البيانات> معرف المنتج هنا يمنحنا معرف مجموعة سلاسل الرسائل. ترجع
الدالة bpf_get_current_pid_tgid () التي كانت تسمى سابقًا مؤشر ترابط GID و PID للعملية بنفس قيمة 64 بت.
نحن لسنا مهتمين بمعرف كل سلسلة رسائل ، لكننا نريد أن نعرف معرف المنتج الخاص بالموضوع الرئيسي. بعد التحقق من أن زمن الوصول أعلى من العتبة ، نرسل بنية
البيانات الخاصة بنا إلى مساحة المستخدمين عبر خريطة
الأحداث ، ثم نقوم بحذف إدخال hashmap في النهاية.
في برنامجنا النصي الثعبان ، يتعين علينا استبدال
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 في برنامجنا النصي لقراءة البيانات من برنامج BPF:
TASK_COMM_LEN = 16
الخطوة الأخيرة هي طباعة البيانات التي نريدها:
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)
النص الكامل متاح في
جيثب . لنقم بتشغيل البرنامج النصي وتشغيل أحداث udev بينما يكتب fio إلى جهاز bcache:
النجاح! الآن نرى أن ما بدا وكأنه زمن انتقال عالٍ ل bcache هو حقًا زمن انتقال عام لجهاز النسخ
الاحتياطي الخاص به.
حفر في النواة
ما الذي يستمر عند تقديم الطلبات؟ نرى حدوث طفرة استتار قبل بدء طلب الطلب. يمكن التحقق من ذلك بسهولة عن طريق تشغيل iostat أثناء المشكلة أو
البرنامج النصي BCC للغة 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 ():
~
يبدو أننا قريبون. الدالات المستخدمة لتجميد / إلغاء تجميد قائمة الانتظار هي
blk_mq_freeze_queue و
blk_mq_unfreeze_queue . يتم استخدامها لتغيير إعدادات قائمة الانتظار ، والتي قد تؤثر على طلبات io الموجودة حاليًا. عندما يتم استدعاء
blk_mq_freeze_queue () ، يتم زيادة
q-> mq_freeze_depth في
blk_freeze_queue_start () . بعد ذلك ، تنتظر النواة لتكون قائمة الانتظار فارغة في
blk_mq_freeze_queue_wait () .
وقت الانتظار هذا يساوي زمن انتقال القرص ، لأن على kernel انتظار انتهاء جميع عمليات io. عندما تكون قائمة الانتظار فارغة ، يمكن إجراء تغييرات. الخطوة الأخيرة هي استدعاء
blk_mq_unfreeze_queue () ، مما يقلل عداد
freeze_depth .
الآن نحن نعرف ما يكفي لإصلاح المشكلة. يغير الأمر المشغل udevadm إعدادات أجهزة الكتلة. هذه الإعدادات موصوفة في قواعد udev. يمكننا معرفة الإعدادات التي تجمد قائمة الانتظار عن طريق تغييرها عبر sysfs أو من خلال النظر في شفرة مصدر kernel. بدلاً من ذلك ، يمكننا
استدعاء التتبع من مجموعة أدوات BCC لطباعة kernel
وكومة المستخدم لكل مكالمة
blk_freeze_queue :
~
لا تتغير قواعد Udev بشكل متكرر ، لذا حتى تعيين قيم موجودة بالفعل لمعلمات معينة يؤدي إلى ارتفاع في زمن انتقال التطبيق. بالطبع إنشاء أحداث udev عندما لا تكون هناك أي تغييرات في تكوين الجهاز (لا يوجد أي جهاز متصل أو منفصل) لا يعد ممارسة جيدة. ومع ذلك ، يمكننا منع النواة من تجميد قائمة الانتظار إذا لم يكن هناك سبب للقيام بذلك.
ثلاث ارتكاب صغير حل المشكلة.
استنتاج
eBPF هو أداة مرنة وقوية للغاية. في هذه المقالة ، نظرنا إلى حالة واحدة فقط وأظهرنا قليلاً من قدرتها. إذا كنت مهتمًا بتطوير الأدوات المستندة إلى BCC ، فيجب إلقاء نظرة على
البرنامج التعليمي الرسمي الذي يصف مفاهيمه الأساسية.
هناك أيضًا أدوات أخرى مثيرة للاهتمام تستند إلى eBPF متاحة للتوصيف والتصحيح. أحدهما هو
bpftrace ، والذي يتيح لك كتابة عناصر فعالة وبرامج صغيرة بلغة تشبه النكات. والآخر هو
ebpf_exporter ، والذي يمكنه جمع مقاييس عالية الدقة منخفضة المستوى
لخادم prometheus الخاص بك مع إمكاناته الرائعة وتنبيهه.