الوحدة 10 — اختبارات الحمل وتحديد الحجم
الخدمة عملت في التطوير. عملت في المنطقة الاختباريّة بحاوية واحدة. سؤال المدير التقنيّ: هل ستصمد لحمل الإطلاق الفعليّ؟ ثمانمئة موظّف في الخدمة العملاء، تطبيق الجوّال بمئة ألف مستخدم يستدعي /predict/enriched ثلاث مرّات لكلّ عرض شاشة، وتقرير ليليّ يسجّل مليون عميل بين منتصف الليل والفجر. هذه الوحدة تختبر الخدمة تحت حمل حقيقيّ بـLocust، تُفسّر النتائج بلغة العمليّات، وتُقرّر عدد العمّال والنسخ اللازمة، بتكلفة محسوبة.
Locust في ثلاث دقائق
Locust مكتبة Python تُوَلّد حملًا مُحاكيًا عبر مستخدمين افتراضيّين متزامنين. كلّ مستخدم يُنفّذ سيناري و (Python عاديّ) في حلقة، وLocust يُجمّع الإحصائيّات. الميزة على أدوات أخرى (JMeter): السيناريو يُكتب بلغة الفريق نفسها، ويُتحكَّم فيه ببرمجة كاملة.
تثبيت:
pip install locust
سيناريو أساسيّ لخدمة تنبّؤ churn:
# locustfile.py
import random
from locust import HttpUser, task, between
class UtilisateurTypique(HttpUser):
wait_time = between(1, 3) # كلّ مستخدم ينتظر 1 إلى 3 ثوانٍ بين طلبَين
host = "http://localhost:8000"
def on_start(self) -> None:
self.client.headers.update({"X-API-Key": "test-key"})
@task(10) # 10 مرّات أكثر من المهمّة التالية
def predire_unitaire(self) -> None:
payload = {
"tenure_months": random.randint(1, 72),
"monthly_charges": round(random.uniform(20, 120), 2),
"contract": random.choice(["month-to-month", "one-year", "two-year"]),
"payment_method": random.choice(
["electronic-check", "mailed-check", "bank-transfer", "credit-card"]),
"fiber_optic": random.random() > 0.5,
"online_security": random.random() > 0.5,
}
with self.client.post("/predict", json=payload,
catch_response=True) as r:
if r.status_code == 200:
r.success()
else:
r.failure(f"status={r.status_code}")
@task(1) # نادرًا: تنبّؤ بدفعة
def predire_lot(self) -> None:
sujets = [{"tenure_months": 24, "monthly_charges": 79.5,
"contract": "month-to-month",
"payment_method": "electronic-check",
"fiber_optic": True, "online_security": False}] * 100
self.client.post("/predict/batch", json={"subscribers": sujets})
الإطلاق:
locust -f locustfile.py --users 200 --spawn-rate 20 --run-time 5m --host http://localhost:8000
هذا يُشغّل مئتَي مستخدم افتراضيّ، يُضيف عشرين في الثانية، لمدّة خمس دقائق. واجهة الويب على http://localhost:8089 تُظهر النتائج مباشرة.
قراءة النتائج: المتوسّط كذب
بعد التشغيل، Locust يُعرض جدولًا كهذا:
| المسار | الطلبات | فشل | متوسّط | ميديان | p95 | p99 | طلبات/ثانية |
|---|---|---|---|---|---|---|---|
| POST /predict | 42 380 | 0 | 22 ms | 18 ms | 47 ms | 210 ms | 141 |
| POST /predict/batch | 4 210 | 3 | 380 ms | 340 ms | 720 ms | 1 800 ms | 14 |
القاعدة: لا نقرأ المتوسّط أبدًا. المتوسّط 22 ميلّي ثانية للـ/predict يخفي أنّ %5 من الطلبات تجاوزت 47 ميلّي ثانية، و1% تجاوز 210 ميلّي ثانية. لتطبيق ويب بعشرة طلبات لكلّ عرض شاشة، هذه %1 تعني أنّ 10% من الشاشات ستحسّ ببطء ملحوظ. p95 وp99 هما المؤشّران الصادقان.
المرجع العمليّ لخدمة تنبّؤ خلف واجهة تفاعليّة: p95 < 100 ميلّي ثانية، p99 < 300 ميلّي ثانية، معدّل خطأ < 0.1%. أرقام أعلى تعني تجربة مستخدم متردّية.
عنق الزجاجة: أين نُهدر الوقت؟
تحت حمل ثقيل، الخدمة تفشل في نقطة واحدة. اكتشافها يوجّه الإصلاح. الأربع الأكثر شيوعًا:
1. النموذج نفسه. predict_proba يستهلك 80% من وقت الطلب. الحلّ: نموذج أخفّ (LightGBM بمئة شجرة بدل ألف) أو تحويل إلى ONNX (الوحدة 37 من الدورة) يخفّض الكمون ×3.
2. تسلسل JSON/إلغاء تسلسله. لطلب بمئتَي حقل، orjson بدل json القياسيّة يخفّض 30% من الوقت. FastAPI يقبل مبدّل تسلسل:
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
3. عمّال بلا متوازية حقيقيّة. أربعة عمّال Gunicorn على نواة وحيدة يتنافسون عليها بلا فائدة. القياس: htop أثناء الاختبار يُظهر النوى مشبعة أم لا.
4. تبعيّة خارجيّة. استعلام قاعدة بيانات بطيء، أو خدمة HTTP خارجيّة بلا مهلة زمنيّة. p99 مرتفع بشكل متطرّف علامة كلاسيكيّة.
الطريقة العمليّة: حصر عنق الزجاجة قبل التو سيع. مضاعفة عدد النسخ بلا حلّ عنق الزجاجة الحقيقيّ يُضاعف التكلفة دون تحسّن ملموس.
دراسة حالة كاملة
نموذج churn LGBM، حاوية بنواتَين و2 غيغا ذاكرة، عاملان Uvicorn، خدمة خلف Nginx.
اختبار 1: مستخدم واحد، مليون طلب. يقيس الكمون الأدنى الممكن. النتيجة: p50 = 8 ms، p95 = 12 ms. جيّد.
اختبار 2: 100 مستخدم متزامن. يقيس الإنتاجيّة تحت حمل معتدل. النتيجة: 720 طلب/ثانية، p95 = 45 ms. جيّد.
اختبار 3: 500 مستخدم. النتيجة: 850 طلب/ثانية (سقف!)، p95 = 380 ms، أخطاء 503 = 2%. الخدمة وصلت حدّها. النوى مشبعة 100%، والذاكرة على 1.6 غيغا.
التفسير: بحسب رياضيّات النظام، الخدمة تُنتج 850 طلب في الثانية بالحدّ الأقصى. للحمل المتوقّع (2000 طلب/ثانية عند الذروة)، نحتاج ثلاث نسخ خلف موازن حمل، بهامش أمان.
أدوات إضافيّة: wrk وab
Locust ممتاز للسيناريوهات المعقّدة. للاختبار السريع البسيط، wrk أسرع في التنفيذ:
wrk -t4 -c100 -d30s -H "X-API-Key: test-key" \
-s scenario.lua http://localhost:8000/predict
هذا يُشغّل أربعة خيوط، مئة اتّصال متزامن، لثلاثين ثانية. الفارق: wrk سيناريو ثابت (Lua)، Locust سيناريو غنيّ (Python). النصيحة: wrk للتحقّق السريع بعد كلّ تغيير، Locust قبل كلّ إصدار.
حساب التكلفة: العنصر المنسيّ
نُشئ ثلاث نسخ من الحاوية، بنواتَين و2 غيغا لكلّ واحدة. على AWS Fargate (يوليو 2026): 0.048 دولار في الساعة للحاوية. النسخ الثلاث = 0.144 دولار/ساعة = 103 دولار في الشهر. أضف Load Balancer 25 دولار، مراقبة 15 دولار، صور Docker 5 دولار = 148 دولار في الشهر للخدمة الأساسيّة.
مقارنة: خادم VPS واحد بأربع نوى و4 غيغا في Hetzner: 12 دولار في الشهر. الفارق ×12، مقابل: توسّع تلقائيّ، إعادة تشغيل عند العطل، توزيع على مناطق متعدّدة. القرار المهنيّ ليس «الأرخص» بل «ما التوفّر المطلوب فعلًا؟». لخدمة داخليّة تعمل تسع ساعات في اليوم، VPS كافٍ. لخدمة عملاء بتوفّر %99.95، Fargate يبرّر تكلفته.
اختبار الانهيار: كيف تفشل الخدمة؟
اختبار الحمل التقليديّ يُثبت أنّها تصمد لحمل مقبول. اختبار الانهيار (stress test) يقيس كيف تفشل عند تجاوز الحدّ:
- عند 2× السعة، هل تُعيد 503 نظيفة أم تنهار؟
- عند 5×، هل الحاوية تُعاد تشغيلها تلقائيًّا (liveness)؟
- بعد نهاية الحمل، هل تعود إلى وضعها الطبيعيّ في ثوانٍ أم دقائق؟
فشل نظيف بـ503 وretry-after أفضل بكثير من انهيار كامل يُبقي عملاء آخرين في انتظار مقاطع. اختبار الانهيار يكشف المشاكل قبل الإنتاج.
دورة تحسين قصيرة
الأسلوب العمليّ لتحسين الأداء:
- قياس حالة أساس (baseline) بـLocust قبل أيّ تعديل.
- افتراض عنق الزجاجة (مثلًا:
orjsonسيوفّر 20%). - تنفيذ التعديل الوحيد.
- إعادة القياس بنفس السيناريو، مقارنة p95.
- التوثيق: ما التغيير، ما الأثر، هل التعديل يُحفظ أم يُلغى.
الفخّ الكلاسيكيّ: تنفيذ خمسة تعديلات دفعة واحدة. لو تحسّن الأداء، لا نعرف أيّ منها المسؤول. لو ساء، لا نعرف أيّها الذي كسر. تعديل واحد كلّ دورة.
الخلاصة
- p95 وp99 هما المؤشّران الصادقان، ليس المتوسّط الذي يخفي الاستثناءات الطو يلة.
- المرجع العمليّ لخدمة ML تفاعليّة: p95 < 100 ms، p99 < 300 ms، معدّل خطأ < 0.1%.
- عنق الزجاجة قد يكون النموذج، تسلسل JSON، عمّال بلا متوازية، أو تبعيّة خارجيّة؛ لا نُوسّع قبل حصره.
- الاختبار الفعليّ (Locust) يعطي عدد النسخ اللازمة لحمل الذروة، والفارق مع «ضربة الكفّ» يبلغ عشرة أضعاف بسهولة.
- اختبار الانهيار يكشف كيفيّة الفشل: 503 نظيفة أفضل من انهيار كامل؛ الأولى تظهر فقط عند اختبار الحدود الفعليّة.