کاربری تیکتی باز میکند: اپلیکیشن موبایل کُند است. شما داشبوردهای خود را بررسی میکنید. میزان استفاده از CPU ثابت است. نرخ خطا صفر است. چراغهای APM شما با اطمینان سبز هستند. بکاند، از هر نظرِ قابل مشاهده، سالم است. اما کاربر اشتباه نمیکند. این کندی واقعی است و در جایی در طول راهروی طولانی و تاریک میان یک دستگاه اندرویدی و سرور شما در حال رخ دادن است.
مشکل اینجاست که بیشتر ابزارهای مشاهدهپذیری (observability) در مرز اپلیکیشن متوقف میشوند. آنها آنچه را که پس از تجزیه درخواست توسط فریمورک شما اتفاق میافتد، اندازهگیری میکنند. آنها پرسوجوهای پایگاه داده، برخوردهای کش (cache hits) و فراخوانیهای سرویسهای پاییندستی را ردیابی میکنند. آنچه آنها از دست میدهند، مکانیسم خودِ درخواست است: زمانی که یک فراخوانی OkHttp در داخل پشته (stack) HTTP اندروید صرف میکند، عبور از یک شبکه موبایل ناپایدار، مذاکره TLS handshake، و انتظار خاموشی که در صفهای هسته (kernel queues) پیش از اجرای کد شما رخ میدهد. این شکافها میلیثانیه یا حتی ثانیههای کامل را میبلعند، در حالی که APM شما ساکت میماند.
رمزنگاری این نابینایی را کامل میکند. اپلیکیشنهای مدرن اندروید همه چیز را از طریق BoringSSL هدایت میکنند. تا زمانی که یک بسته به پشته شبکه هسته برسد، هدرهای HTTP رمزنگاری شدهاند. یک tcpdump استاندارد یا یک هوک شبکه (network hook) تنها رکوردهای مبهم TLS را میبیند. شما میتوانید مشاهده کنید که ترافیک در حال جریان است، اما نمیتوانید آن را بخوانید. قطعاً نمیتوانید یک قطعه TCP در سطح هسته را به یک فراخوانی API خاص از یک کاربر متصل کنید. بافت ردیابی (trace context) مورد نیاز شما درون متن رمزنگاریشده (ciphertext) گرفتار شده است.
eBPF معادله را تغییر میدهد، زیرا به شما اجازه میدهد سیستم را از درون به بیرون بدون تغییر در کد اپلیکیشن خود، ابزارگذاری (instrument) کنید. به جای اینکه از اپلیکیشن خود بخواهید تأخیر خودش را گزارش دهد، برنامههای کوچکی را مستقیماً به هسته و کتابخانههای حیاتی فضای کاربر (userspace) متصل میکنید. این برنامهها رویدادها را همانطور که اتفاق میافتند مشاهده کرده، آنچه را که نیاز دارید استخراج میکنند و آن را به یک ring buffer میفرستند. هیچ حجم اضافی از SDK در داخل APK اندروید شما فراتر از یک هدر سبک وجود ندارد و هیچ عامل ابزارگذاری (instrumentation agent) کلاسهای بکاند شما را بازنویسی نمیکند.
راهاندازی چهار مرحلهای
ساخت این خط لوله (pipeline) شامل چهار لایه متمایز از مشاهده است.
۱. لنگر traceparent. در دستگاه اندرویدی، شما یک OkHttp interceptor اضافه میکنید که یک هدر W3C traceparent را به هر درخواست خروجی تزریق میکند. این تنها تغییر مورد نیاز در سمت موبایل است و بسیار ناچیز است. این هدر در داخل بار (payload) رمزنگاریشده تا بکاند شما سفر میکند. از آنجایی که این هدر در لایه HTTP قرار دارد، در داخل متن آشکار (plaintext) که موتور TLS در نهایت آشکار میکند، باقی میماند.
۲. زمانبندی ورود TCP. در میزبان بکاند، شما از هوکهای eBPF Traffic Control متصل به رابط شبکه استفاده میکنید. این برنامهها با رسیدن قطعات مجزای TCP اجرا میشوند. آنها شمارههای توالی (sequence numbers) و برچسبهای زمانی (timestamps) را در لبه شبکه ثبت میکنند. اکنون شما دقیقاً میدانید که بیتها چه زمانی از سیم خارج شده و وارد ماشین شما شدهاند، مدتها قبل از اینکه اپلیکیشن شما حتی یک بایت را بخواند.
۳. کاوشگرهای رمزگشایی. بکاند شما TLS را با استفاده از OpenSSL یا BoringSSL خاتمه میدهد. اینجاست که معماری جالب میشود. با استفاده از uprobes (کاوشگرهای پویا در فضای کاربر)، شما به SSL_write و SSL_read در داخل کتابخانه TLS متصل میشوید. این توابع دقیقاً در لحظهای که دادههای متن آشکار از موتور رمزنگاری عبور میکنند، اجرا میشوند. برنامه eBPF شما آن بافر رمزگشاییشده را میخواند، به دنبال هدر traceparent میگردد و آن را استخراج میکند. اکنون هسته دارای یک نگاشت مستقیم بین یک جریان TCP خام و یک درخواست موبایل خاص است، بدون اینکه شما هرگز با گواهینامهها یا کلیدها در یک ابزار سفارشی سروکار داشته باشید.
۴. صفبندی هسته. حتی پس از رمزگشایی دادهها و آماده بودن آنها، ممکن است اپلیکیشن شما بلافاصله آنها را مصرف نکند. شما kprobes را به توابع مرتبط هسته که با بافرهای سوکت (socket buffers) و رویدادهای زمانبندی (scheduling events) سروکار دارند، متصل میکنید. این کار تأخیر صفبندی (queueing latency) را اندازهگیری میکند: زمانی که درخواستها در قلمرو هسته منتظر میمانند زیرا فرآیند شما برای استفاده از CPU در رقابت است یا صرفاً هنوز read() را فراخوانی نکرده است.
هر چهار منبع سیگنال، رویدادها را در یک eBPF ring buffer مینویسند. یک فرآیند sidecar که در فضای کاربر اجرا میشود، این بافر را تخلیه کرده، رویدادها را بر اساس شناسه traceparent مرتبط میکند و یک خط زمانی واحد و منسجم برای هر درخواست بازسازی میکند. آنچه قبلاً پراکندگی از نویزهای بیارتباط هسته بود، به یک ردیابی (trace) ساختاریافته تبدیل میشود.
خواندن مسیر کامل
خروجی تجمیعشده معمولاً به صورت یک نمودار شعلهای (flame graph) یا یک درخت بازه (span tree) ساختاریافته نمایش داده میشود که تأخیر را به چهار بخش ملموس تقسیم میکند:
- زمان انتقال شبکه: مدت زمانی که از ارسال آخرین بایت درخواست توسط رادیوی اندروید تا دریافت آن توسط NIC بکاند طول میکشد. نوسانات شبکه سلولار دقیقاً در همین مرحله رخ میدهد.
- مدت زمان دستدادن (handshake) TLS: زمانی که صرف مذاکره برای ایجاد تونل رمزنگاریشده میشود. در شبکههای ناپایدار، این زمان میتواند بسیار بیشتر از انتقال واقعی داده باشد.
- تأخیر صفبندی هسته (Kernel): زمانی که پس از رسیدن قطعه داده اما قبل از مصرف آن توسط فضای کاربر (userspace)، در بافرهای هسته و صفهای زمانبندی سپری میشود.
- زمان پردازش اپلیکیشن: بخشی که در واقع توسط فریمورک بکاند و منطق تجاری شما مصرف میشود.
ممکن است متوجه شوید که در یک درخواست موبایل ۸۰۰ میلیثانیهای، تنها ۴۰ میلیثانیه در تجزیهگر JSON شما صرف میشود. ۲۰۰ میلیثانیه دیگر در یک دستدادن (handshake) متوقفشدهی TLS در یک اتصال با اتلاف داده (lossy) هدر میرود. ۳۰۰ میلیثانیه دیگر نیز در صف انتظار هسته (kernel backlog) روی یک میزبان با بیشاشتراکگذاری (oversubscribed host) ناپدید میشود. ابزار APM شما فقط آن ۴۰ میلیثانیه را گزارش میکرد. بدون مشاهدهپذیری در سطح هسته، شما کاملاً مورد اشتباهی را بهینهسازی میکردید.
سربار واقعی که مقیاسپذیر است
عاملهای سنتی APM، مشاهدهپذیری خود را از طریق رهگیری فراخوانیها در زمان اجرای زبان (language runtime) به دست میآورند. آنها متدها را میپوشانند (wrap)، اشیاء span را تخصیص میدهند و دادههای تلهمتری را در هیپِ (heap) فرآیند خود سریالسازی میکنند. تحت فشار بار، این سربار به سرعت چندبرابر میشود. هزینههای سریالسازی بالا میرود. فشار جمعآوری زباله (Garbage collection) افزایش مییابد. شما در واقع دارید با استفاده از منابع خودِ اپلیکیشن، هزینه مشاهدهپذیری را پرداخت میکنید.
برنامههای eBPF درون یک ماشین مجازی هسته اجرا میشوند و به دستورالعملهای بومی ماشین (native machine instructions) به صورت JIT کامپایل میشوند. یک تاییدکننده (verifier) پیش از بارگذاری، آنها را از نظر ایمنی بررسی میکند. هر پروب (probe) میکروثانیه به مسیر اضافه میکند، نه میلیثانیه. کار سنگینِ تطبیق رویدادها و رندر کردن نمودارها در سایدکار (sidecar) و خارج از مسیر اصلی (hot path) سرویس شما انجام میشود. شما تخصیصهای هیپ را افزایش نمیدهید و هزینه اضافی سریالسازی را به فرآیند مدیریت درخواست اضافه نمیکنید. برای سرویسهایی که هزاران درخواست در ثانیه ارسال میکنند، این تفاوت بسیار حیاتی است.
آنچه برای اجرا لازم است
این یک راهکار جادویی نیست و یک سرویس SaaS مدیریتشده نیست که بتوانید آن را با یک کلید روشن کنید. شما به یک هسته بکاند با پشتیبانی مدرن از eBPF نیاز دارید، از جمله اطلاعات نوع BTF تا پروبهای شما بتوانند با ایمنی از ساختارهای هسته عبور کنند. کتابخانه TLS شما باید نمادهایی (symbols) را ارائه دهد که uprobes بتوانند آنها را هدف قرار دهند؛ اگر یک باینری لینکشده به صورت استاتیک (statically linked) با نسخهای از OpenSSL که stripped شده یا به شدت سفارشیسازی شده است ارسال میکنید، باید این موضوع را در نظر بگیرید. همچنین باید تأیید کنید که هدر traceparent شما از هرگونه پروکسی یا گیتوی لبه (edge gateway) بین کلاینت موبایل و نقطه پایان TLS عبور میکند.
اما برای تیمهایی که از تیکتهای «موبایل کند است» که تحلیل علت ریشهای (root-cause analysis) را به چالش میکشند، خسته شدهاند، این معماری حدس و گمانهای سنتی را با سیگنالهای دقیق جایگزین میکند. شما از حدس زدن درباره وضعیت شبکه دست میکشید و شروع به اندازهگیری درخواستهای خاص از طریق مسیرهای مشخص میکنید.
نتیجهگیری اصلی
نیازی نیست با ترافیک رمزنگاریشده موبایل مانند یک جریان مبهم برخورد کنید که به محض رسیدن به فریمورک شما به شکلی جادویی قابل مشاهده میشود. با ترکیب یک هدر ساده OkHttp با پروبهای eBPF که به صورت استراتژیک در بکاند قرار گرفتهاند، میتوانید یک درخواست واحد را از یک دستگاه اندروید، از طریق رمزگشایی TLS، صفهای هسته و ورود به منطق اپلیکیشن دنبال کنید—بدون اینکه اپلیکیشن موبایل خود را بازنویسی کنید و بدون اینکه کد بکاند خود را به روشی که APMهای سنتی میطلبند، ابزارگذاری (instrument) کنید. این فقط یک بهبود تدریجی نیست؛ بلکه مشاهدهپذیری سرتاسری (end-to-end observability) است.
