توی توسعه نرمافزار، سریع بودن بزرگترین دغدغه بین افراد دخیل در فرایند هست. از مدیر پروژه که فقط میخواد برنامه سریع باشه تا مدیرفنی که دنبال راههای جدید برای سرعت بخشیدن به زیرساختهاست. «سریع» یه حس مشترکه.
تاحالا به این فکر کردین مرز سریع بودن کجاست؟ مثلا الان بحث میکروسرویسها دوباره داغ شده. ظاهرا؛ وقتی به بقیه قول میدی که قراره برنامه شون رو سریع اجرا کنی خودت به یه چیز خیلی سریعتر نیاز داری تا وضعیت رو بسنجی... چطوری میشه یک وضعیت رو خیلی سریع سنجید؟
ترسیم مرز سریع بودن؛ بدون فرض گرفتن یک context غیر ممکنه. مثلا مرز سریع بودن یه دونده توان جسمانیش هست. ولی مرز سریع بودن توی برنامهنویسی، صرفا توان بالای سخت افزار نیست! «چطوری دستورات به سخت افزار میرسن» خیلی مهمه.
اگه برنامه توی حلقه صفر اجرا بشه یعنی سریع ترین نقطه دسترسی به سخت افزار. و توی لینوکس این کار با نوشتن یک ماژول اختصاصی انجام میشه.
ولی اجرا شدن توی حلقه صفر یعنی کسی نیست که جلوی اشتباهات رو بگیره. اگه توی کدها مشکلی پیش بیاد کرنل کلا کرش میکنه. همه چیز متوقف میشه. تازه این اتفاق اگه بیوفته باید خدا رو شکر کنی؛ چون اگه کرش نکرد احتمال اینکه کدی که نوشتی بدون سروصدا دادهها رو خراب کنه خیلی زیاده.
پس مرز سریع بودن توی توسعه نرمافزار یعنی تا هر جایی که از چهارچوب «صحیح بودن» خارج نشه و BPF در یک کلام؛ تکنولوژیای برای تضمین صحیح بودن و سرعته.
ماشین مجازی تکنیکی برای نوشتن برنامههای سریع با تضمین صحیح بودن هست. (یه مدلش رو توی مطلب قبلیم درباره وب اسمبلی توضیح دادم). این دوتا مثال رو مقایسه کنید:
--------- WebAssembly bytecodes (i32.add (i32.const 1) (i32.const 2) ) --------- BPF bytecodes ldh [12] jeq #0x86dd jt 2 jf 7 jeq #0x6 jt 10 jf 4 jeq #0x2c jt 5 jf 11 ldb [54] jeq #0x6 jt 10 jf 11 jeq #0x800 jt 8 jf 11 ldb [23] jeq #0x6 jt 10 jf 11 ret #65535 ret #0
ویژگی ماشینهای مجازی:
نکته اول: خوبی بایت کد Generic اینه که فقط برای یک ماشین خاص نیست در نتیجه میشه یک قطعه کد توی هر زبانی رو با بالا ترین سرعت ممکن اجرا کرد. فقط کافیه که اون زبان یک کتابخانه داشته باشه که برای دستوراتش معادل بایت کد یک ماشین مجازی رو تولید کنه.
نکته دوم: توی یک ماشین مجازی برنامهها از دنیای بیرون خبر ندارن هر دسترسی که لازم داشته باشن براشون شبیه سازی میشه.
اجازه بدید با این سوال از BPF Design Q&A شروع کنم به توضیح:
Q: Is BPF a generic virtual machine ?
A: NO.
BPF is generic instruction set with C calling convention.
یعنی یک سری دستور Generic هستن ولی بایت کد تولید شده از "قرارداد فراخوانی زبان سی" پیروی میکنه. این یعنی بیپیاف یک bytecode engine هست. به این نقل قول هم نگاه کنید تا فرقش با ماشین مجازی رو بگم:
As a general rule, a function which follows the C calling conventions, and is appropriately declared (see below) in the C headers, can be called as a normal C function. Most of the burden for following the calling rules falls upon the assembly program.
الف) موتور بایتکد و ماشین مجازی در نگاه اجمالی شباهت زیادی دارن به طوری که همیشه از هردو به معنی یک چیز یاد میشه. اما نقل قول اول یک اشاره ریز داره - "قرارداد فراخوانی زبان سی" یعنی؛ موتور بایتکد میدونه که قراره توی چه محیطی اجرا بشه (ولی ماشین مجازی نمیدونه).
ب) موتور بایتکد؛ بخش کوچیک و کنترل شدهای درون یک سیستم بزرگتر هست. برای همین باید از قراردادهای سیستم بزرگتر پیروی کنه (BPF باید از قرارداد فراخوانی زبان سی پیروی کنه چون لینوکس به زبان سی نوشته شده) که باعث میشه در زمان اجرا با سیستم بزرگتر ادغام بشه یعنی هرگونه فراخوانی و ارتباط از برنامه BPF به کرنل و برعکس بدون هزینه هست.
فهمیدیم که BPF موقع اجرا جزئی از کرنل میشه.و باید ابزاری باشه که کد سی رو تبدیل کنه به بایتکد. (که وظیفه LLVM هست) اما اون بایتکد خودش که بال در نمیاره یکدفعه بشینه توی کرنل باید به یک شکلی لود بشه. اولین سوالی که پیش میاد اینه که برنامه رو چطوری داخل کرنل لود کنیم؟
برای جواب دادن به سوال اول بیاید به Front-end و Back-end همین سایت فکر کنیم. برای نظر دادن توی این پست، پایین صفحه یک فرم هست. حرفهای شما رو میفرسته به سرور ویرگول. اینجا نظر شما مصداق ELF Object رو داره و فرم ارسال مصداق یک برنامه داخل User Space.
پس برنامههای BPF در اصل دو قسمتی هستن یک قسمت سمت کاربر اجرا میشه که حداقل وظیفهاش لود کردن قسمت دوم هست. و قسمت دوم میشه برنامه اصلی که داخل کرنل اجرا میشه.
اگه هدف فقط لود کردن برنامه اصلی باشه؛ گاهی وقتا ابزارش هست و نیازمند نوشتن برنامه سمت کاربر نیستیم. در این مثال منظورم tc(8) هست (که نقش Front-end داره). اما CLS_ACT چیه؟
هربرنامه BPF یک نوع داره، هر نوع حداقل یک (یا بیشتر از یک) attach point داره؛ درواقع برنامههای BPF میتونن به رویدادهای مشخصی در کرنل وصل بشن و روی یک context عملیات انجام بدن.
حالا CLS_ACT یک نوع برنامه BPF هست که:
همه برنامهها لزوما قادر به تغییر دادهها نیستن، خیلیهاشون صرفا برای مشاهده کردن و سنجش یک وضعیت کاربرد دارن. مثلا به این قطعه کد نگاه کنید تا یک سری مفهوم جدید رو معرفی کنم.
افراد علاقه مند به BPF یک ابزاری درست کردن تحت عنوان BCC یعنی درواقع یک Front-end هست برای برنامههای BPF. بهتون اجازه میده کدی که سمت کاربر اجرا میشه رو به زبان پایتون بنویسید. البته برنامه اصلی BPF هنوزم باید به زبان C نوشته بشه.
prog = """ int hello(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; } """
خب این برنامه BPF فقط موقع اجرا شدن "Hello World" رو چاپ میکنه. حالا کیاجرا میشه؟
b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")
همونطور که گفتم برنامههای BPF میتونن موقع رخ دادن یک رویداد اجرا بشن. اینجا گفته موقعی که یک فراخوان سیستمی به اسم clone اتفاق افتاد تابع hello (که ورودی برنامه BPF هست) رو اجرا کن.
کلون وقتی اتفاق میوفته که یک پروسه جدید درست میکنید. پس هرموقع یک برنامهای رو باز کنید رشته "Hello World" به همراه یک سری پارامتر دیگه چاپ میشه. این برنامه از نوع kprobe هست که برای سنجش یک فرایند به کار میره. مثل این برنامه.
فعلا زوده و نمیتونم بیشتر از این درباره نوعهای دیگه حرف بزنم؛ بریم نکته بعدی.
برای لود کردن یک برنامه BPF از سمت کاربر (trace_fields) دستور BPF_PROG_LOAD (که یک syscallهست) فراخوانی میشه و توی prog برای چاپ کردن از bpf_trace_printk (که یک helperهست) استفاده میکنه.
از این چی میفهمیم؟ درواقع این BPF System Call موجب ارتباط بین سمت کاربر و کرنل میشه به واسطه این زیربنا هست که میتونیم front-endهای مختلف داشته باشیم.
اما قضیه BPF Helper چیه؟ خب درسته که برنامه داره توی دل کرنل اجرا میشه اما؛ فقط به یک سری توابع مشخص دسترسی داره پس نمیتونه پاشو از گلیمش درازتر کنه.
حالا قضیه دیاگرام سمت راست چیه؟ اون مدل اجرای برنامه سمت کاربر و کرنل رو نشون میده. نکته کلیدی اینه که هرموقع یک رویداد کرنل اتفاق بیوفته (مثل clone) برنامه سمت کرنل اجرا میشه.
پس: برنامههای BPF از سمت کرنل Event Based هستن. برای همین خیلی سریعه.
اما برنامه سمت کاربر (trace_fields) یک task هست. برای اجرا شدن باید زمانبندی بشه.
پس: هر برنامهای که سمت کاربر اجرا بشه پنالتیهایی داره. یکیش همین زمانبندی شدنه که منجر به کاهش سرعته.
برای درک بهتر این مثال رو ببینید. شناسایی و تشخیص ترافیک http نیازمند بررسی عمیق بستههاست. این منطق رو میشه به کمک BPF سمت کرنل پیاده کرد و هرموقع که ترافیک شناسایی شد اون رو به سمت کاربر هدایت کرد. اینطوری خیلی سرعت افزایش پیدا میکنه.
به خاطر اینکه برنامه BPF موقع اجرا جزئی از کرنل به حساب میاد یعنی میتونه به instruction pointer یا return address یا stack pointer دسترسی داشته باشه؟ نه!
یک برنامه BPF در چند مرحله بررسی و محدود میشه مثلا:
یک) خود LLVM به stack pointer نیاز داره ولی مطمین میشه که برنامه کامپایل شده به اون هیچ اشارهای نمیکنه.
دو) درمورد BPF Helpers هم که گفتم. اما Verifier چیه؟
سه) وریفایر میگه من فقط یک الگوی بخصوص از نوشتن و تعریف دستورات رو میپذیرم و اگه خلاف اون باشه برنامه اجرا نمیشه مثلا:
و کلی قانون دیگه که به مرور توی مطالب دیگه بهش اشاره میکنم.
چهار) حالا قضیه JIT یکم مفصل تره ولی اونم از جنبههای مختلف تاثیر گذاره. میتونه دستورات رو ترجمه کنه به یک معماری خاص. فایدش خیلی زیاده مثلا Hardware Offload. یا از یه جنبه دیگه؛ به کمک JIT مفهوم حلقه رو میشه تا یه حدی پیاده کرد و ...
معمولا به صورت پیشفرض JIT فعال نیست باید نسخه کرنل رو بررسی کنید.
این بحث واقعا پایان نداره نمیتونم همش رو توی یک مطلب جمع کنم. مثلا اگه قرار باشه یه مقدار رو از سمت کرنل بفرستیم سمت کاربر میتونیم از BPF Maps استفاده کنیم که نوعهای مختلف داره و میتونه بین چند برنامه به اشتراک گذاشته بشه. صحبت ازش به صورت جدا واقعا مفهوم قابل درکی توی ذهن مخاطب ایجاد نمیکنه و تنها با تمرین و دیدن پروژههای واقعی قابل درک میشه.
یا مثلا شرح ELF Object خودش یه مطلب میخواد نمیتونم با hello world اونو به تصویر بکشم برای همین تصمیم دارم این بحثها رو ادامه بدم. هروقت فرصت شد یک تیکه از BPF رو بیشتر باز کنم و بهتر دربارش بنویسم.
لازم به ذکر هست که من خودم تازه افتادم توی این بحثها بنابر این نوشتنش برای من راه خوبی برای به خاطر سپردنش هست. من بسته به نیازم و تا هرجا که بتونم مسیر پیشرفتم رو مکتوب میکنم.
یه مورد دیگه اینکه عمدا نرفتم سراغ تاریخچه BPF و اونجور چیزها. از نظر من همین یک تیکه رو بفهمید از گذشته کافیه:
توی سال 1992 به همراه tcp dump معرفی شد و اون زمان صرفا برای کارهای شبکه بود. یه عده از سال 2013 دیدن پتانسیل BPF خیلی بالاست و توسعش دادن اسمش شد Extended BPF (یا eBPF) بعدش گفتن شکل قبلی اون رو صدا بزنیم cBPF.
حالا BPF یک تکنولوژی هست؛ یک سبک جدید از برنامه نوشتن، نه یک مخفف.
یه سری منابع که خودتون بیشتر دنبال کنید:
Dive into BPF: a list of reading material
Bpf — a tour of program types
XDP Hands-On Tutorial
BPF Compiler Collection (BCC)
Netflix talks about Extended BPF: A new software type - YouTube
eBPF Superpowers - YouTube
How to Make Linux Microservice-Aware with Cilium and eBPF - YouTube
Netdev 1.2 - Advanced programmability and recent updates with tc's cls_bpf - Daniel Borkmann - YouTube