اختبار الأداء والتحميل

الأداء في التكامل المستمر

18 دقيقة الدرس 8 من 28

الأداء في التكامل المستمر

تشغيل اختبار تحميل على جهاز محلي قبل الشحن أفضل من عدم الاختبار أبداً، لكنه ليس ممارسة هندسة أداء — إنه طقس. الانضباط الذي يميز المؤسسات الهندسية الناضجة عن غيرها هو معاملة الأداء كمواطن درجة أولى في خط الأنابيب: ميزانيات مُقنَّنة تُحدد معنى "مقبول"، وفحوصات آلية تحجب الانتكاسات قبل وصولها إلى main، وخط أساس تاريخي يجعل الاتجاهات مرئية على مدى أسابيع وأرباع، وليس لكل إصدار فحسب. في شركات مثل Google وMeta وNetflix، لكل خدمة SLO زمن استجابة متفق عليه، وحد أدنى للإنتاجية، وبوابة CI تُطبّق كليهما. PR يُخفّض p99 بنسبة 20% لن يُدمَج — ليس لأن أحداً تذكّر الفحص، بل لأن خط الأنابيب يرفضه.

ميزانيات الأداء: جعل "جيد بما يكفي" صريحاً

ميزانية الأداء هي مجموعة من العتبات الكمية التي يجب أن تظل الخدمة ضمنها. بدون ميزانيات صريحة، يكون "انتكاس الأداء" ذاتياً — قد تكون زيادة 15% في p99 ضوضاء مقبولة أو دليلاً على إدخال استعلام N+1 جديد، والنتيجة تعتمد على من يصادف النظر. بالميزانيات الصريحة، السؤال ثنائي.

أبعاد الميزانية التي تُحدَّد لكل خدمة:

  • زمن الاستجابة: p50 وp95 وp99 وp999 تحت ملف الحمل الذروة المتوقع. p999 حرج للخدمات الحساسة لزمن الاستجابة الذيلي (معالجة المدفوعات، رموز المصادقة). نقطة بداية شائعة: p99 أقل من 200 ms تحت 500 RPS لبوابة API؛ تُضيَّق بعد وجود بيانات خط أساس.
  • الإنتاجية: الحد الأدنى المقبول من RPS أو TPS عند تزامن محدد. هذا هو حد طاقة خدمتك — إذا لم تستطع تحمّله، فتخطيط الطاقة مكسور.
  • معدل الأخطاء: الحد الأقصى المقبول لمعدل 5xx تحت الحمل. 0.1% شائع للخدمات غير الحرجة؛ 0.01% لتدفقات الدفع أو الهوية.
  • أسقف الموارد: CPU والذاكرة لكل نسخة عند الذروة. يمنع ذلك تغييراً من الزيادة الصامتة في السعة المُخصَّصة بنسبة 40%، وهو انتكاس تكلفة حتى لو ظل زمن الاستجابة دون تغيير.

رمّز الميزانيات كملفات تكوين مُتحكَّم في إصداراتها بجانب كود الخدمة. عند تغيير ميزانية، يُراجَع التغيير وتُحفَظ مسوّغاته في تاريخ git.

# k6/budgets.json — ميزانية أداء خدمة الدفع { "service": "checkout-api", "thresholds": { "p99_latency_ms": 250, "p95_latency_ms": 120, "p50_latency_ms": 40, "error_rate_max": 0.001, "min_rps": 400 }, "resource_ceilings": { "cpu_p99_percent": 70, "memory_mb_p99": 512 } }

في k6، تُترجَم الميزانيات مباشرة إلى thresholds في كتلة خيارات السكريبت، مما يجعل k6 يخرج بكود غير صفري عند اختراق أي عتبة — ويفسّر ذلك CI على أنه فشل في البناء.

// k6/checkout-load-test.js import http from 'k6/http'; import { check } from 'k6'; export const options = { scenarios: { steady_load: { executor: 'constant-arrival-rate', rate: 400, // 400 RPS — حد الإنتاجية الأدنى في الميزانية timeUnit: '1s', duration: '3m', preAllocatedVUs: 50, maxVUs: 100, }, }, thresholds: { // فشل مهمة CI إذا اختُرقت أي عتبة 'http_req_duration': [ { threshold: 'p(99)<250', abortOnFail: true, delayAbortEval: '30s' }, { threshold: 'p(95)<120', abortOnFail: false }, { threshold: 'p(50)<40', abortOnFail: false }, ], http_req_failed: [ { threshold: 'rate<0.001', abortOnFail: true }, ], http_reqs: [ { threshold: 'rate>=400' }, ], }, }; export default function () { const res = http.post( 'http://checkout-api:8080/v1/checkout', JSON.stringify({ cart_id: 'bench-cart-001' }), { headers: { 'Content-Type': 'application/json' } } ); check(res, { 'status 200': (r) => r.status === 200, 'checkout_id present': (r) => r.json('checkout_id') !== undefined, }); }
فكرة أساسية — الميزانيات عقود وليست تطلعات. عتبة ميزانية لا تُطبّقها تُدرّب فريقك على تجاهلها. طبّق كل عتبة في CI. إذا كان خط الأساس ضيقاً جداً وفجّر البوابة في كل إيداع أخضر، فالمشكلة الحقيقية أن خط الأساس كان خاطئاً — أعِد معايرته، رمّز التغيير في git، وتابع. لا تُسكّت البوابة أبداً.

الكشف الآلي عن الانتكاسات

عتبة مطلقة (p99 أقل من 250 ms) تكتشف فقط حالة تجاوز خط مطلق. لن تكتشف انجرافاً بطيئاً — زيادة 5% في p99 لكل sprint لا تتجاوز العتبة منفردة، لكنها تتراكم لتصبح تدهوراً 50% خلال ستة أشهر. يقارن الكشف عن الانتكاسات التشغيل الحالي بخط أساس متجدد، ويُعلّم الانحرافات ذات الدلالة الإحصائية، ويحجب البناء عندما يتجاوز الانحراف نسبة التسامح المُكوَّنة.

النمط القياسي في CI:

  1. شغّل اختبار التحميل على كل PR أو كل دمج في main (الاختيار يعتمد على مدة الاختبار).
  2. صدّر نتائج k6 كـ JSON أو ادفع المقاييس إلى مخزن سلاسل زمنية (Prometheus أو InfluxDB أو k6 Cloud).
  3. تقرأ خطوة الكشف عن الانتكاسات التشغيل الحالي وآخر N من تشغيلات خط الأساس، وتحسب نسبة التغيير لكل مقياس ميزانية، وتُفشل المهمة إذا تجاوز أي مقياس نسبة الانجراف المسموحة.
  4. تنشر ملخصاً منظماً في تعليق PR حتى يرى المهندسون بالضبط أي مقياس انتكس وبكم، دون قراءة المقاييس الخام.
Performance CI Gate Pipeline Flow PR / Merge to main Load Test k6 / JMeter Results Store Prometheus / InfluxDB / S3 Regression Detector delta vs. baseline Baseline last 10 runs avg PASS Merge allowed FAIL PR blocked مقارنة ضمن الميزانية انتكاس مكتشف تخزين النتيجة
تدفق بوابة الأداء في CI: يُخزَّن كل تشغيل اختبار تحميل، ويُقارن بخط أساس متجدد، ويحجب الدمج عند اكتشاف انتكاس.
# .github/workflows/performance.yml # يُشغَّل عند كل دفع إلى main؛ تكيّف لتشغيله على PRs لاختبارات أقصر. name: Performance Gate on: push: branches: [main] jobs: load-test: runs-on: ubuntu-latest services: checkout-api: image: ghcr.io/myorg/checkout-api:${{ github.sha }} ports: ["8080:8080"] options: --health-cmd "curl -sf http://localhost:8080/healthz" --health-interval 5s steps: - uses: actions/checkout@v4 - name: Run k6 load test uses: grafana/k6-action@v0.3.1 with: filename: k6/checkout-load-test.js flags: --out json=k6-results.json - name: Upload results artifact uses: actions/upload-artifact@v4 with: name: k6-results-${{ github.sha }} path: k6-results.json retention-days: 90 - name: Download baseline results run: | aws s3 sync s3://myorg-perf-baselines/checkout-api/latest-10/ ./baselines/ env: AWS_ACCESS_KEY_ID: ${{ secrets.PERF_BASELINE_AWS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.PERF_BASELINE_AWS_SECRET }} - name: Detect regressions id: regression run: | python3 scripts/detect_regression.py \ --current k6-results.json \ --baselines baselines/ \ --budget k6/budgets.json \ --tolerance 0.10 \ --output regression-report.md - name: Post regression report to PR if: always() uses: marocchino/sticky-pull-request-comment@v2 with: path: regression-report.md - name: Store result as new baseline if: success() run: | aws s3 cp k6-results.json \ s3://myorg-perf-baselines/checkout-api/latest-10/$(date +%Y%m%dT%H%M%S)-${{ github.sha }}.json
ممارسة احترافية — نطاق التسامح. تسامح بنسبة 0% في الكشف عن الانتكاسات يُنتج إيجابيات زائفة مستمرة من التباين الطبيعي بين التشغيلات (تذبذب 1-3% في زمن الاستجابة طبيعي في بنية CI المشتركة). تسامح 10% على p99 و5% على p95 نقاط بداية عملية. ضيّق النطاق للخدمات الحرجة (الدفع، المصادقة) ووسّعه للأدوات الداخلية. قِس تباين خط أساسك على 20 تشغيلاً واضبط التسامح عند ضعف معامل التباين المُلاحَظ — بذلك تُفجَّر البوابة على الانتكاسات الحقيقية لا على الضوضاء.

سكريبت الكشف عن الانتكاسات: المنطق الأساسي

سكريبت الكشف بسيط بما يكفي لامتلاكه في مستودعك — لا حاجة لخدمة طرف ثالث. الخطوات الرئيسية: تحليل مخرجات k6 JSON لاستخراج مقاييس الملخص، وحساب متوسط خط الأساس المتجدد من ملفات النتائج المُخزَّنة، وحساب الفرق بالنسبة المئوية، والمقارنة بـ budget * (1 + tolerance).

#!/usr/bin/env python3 # scripts/detect_regression.py import json, sys, glob, argparse, statistics from pathlib import Path def load_k6_summary(path): with open(path) as f: data = json.load(f) metrics = data.get('metrics', {}) dur = metrics.get('http_req_duration', {}).get('values', {}) return { 'p50': dur.get('p(50)', 0), 'p95': dur.get('p(95)', 0), 'p99': dur.get('p(99)', 0), 'error_rate': metrics.get('http_req_failed', {}).get('values', {}).get('rate', 0), } def rolling_baseline(baselines_dir): files = sorted(glob.glob(str(Path(baselines_dir) / '*.json')))[-10:] if not files: return None runs = [load_k6_summary(f) for f in files] return {k: statistics.mean(r[k] for r in runs) for k in runs[0]} parser = argparse.ArgumentParser() parser.add_argument('--current'); parser.add_argument('--baselines') parser.add_argument('--budget'); parser.add_argument('--tolerance', type=float) parser.add_argument('--output') args = parser.parse_args() current = load_k6_summary(args.current) baseline = rolling_baseline(args.baselines) budget = json.loads(Path(args.budget).read_text())['thresholds'] tol = args.tolerance checks = [ ('p99', 'p99_latency_ms', 'P99 latency (ms)'), ('p95', 'p95_latency_ms', 'P95 latency (ms)'), ('p50', 'p50_latency_ms', 'P50 latency (ms)'), ('error_rate', 'error_rate_max', 'Error rate'), ] lines = ['## Performance Regression Report\n', '| Metric | Current | Baseline | Budget | Status |', '|--------|---------|----------|--------|--------|'] failed = False for key, bkey, label in checks: cur_val = current[key] base_val = baseline[key] if baseline else None bud_val = budget.get(bkey) over_budget = bud_val and cur_val > bud_val * (1 + tol) over_baseline = base_val and cur_val > base_val * (1 + tol) status = 'FAIL' if (over_budget or over_baseline) else 'PASS' if status == 'FAIL': failed = True base_str = f'{base_val:.1f}' if base_val else 'N/A' lines.append(f'| {label} | {cur_val:.1f} | {base_str} | {bud_val} | {status} |') Path(args.output).write_text('\n'.join(lines)) sys.exit(1 if failed else 0)
خطأ شائع في الإنتاج — اختبار الأداء غير المستقر. اختبار تحميل يعمل على بيئة staging حقيقية تشارك الحوسبة مع مهام CI أخرى يُنتج نتائج شديدة التباين. تذبذب 30% في زمن الاستجابة بين التشغيلات لا علاقة له بتغيير كودك — بل يعكس ضوضاء الجيران. اعزل بيئة الاختبار: شغّل الخدمة المختبَرة على VM مؤقتة مخصصة بـ CPU/ذاكرة محددة، أو شغّل الاختبار داخل Docker Compose منفرد على الـ runner. العزل هو الفرق بين بوابة مفيدة ومولّد أعداد عشوائية.

أين تُشغَّل اختبارات الأداء في خط الأنابيب

ليس كل اختبار يعمل على كل حدث. طابق شدة الاختبار مع التكلفة التي يحرسها:

  • على كل PR (تحميل دخاني سريع): تصاعد 60-90 ثانية إلى الذروة، تحقق من عدم انتهاك العتبات. الهدف: أقل من 3 دقائق إجمالي في CI. الغرض: اكتشاف الانتكاسات الواضحة (حلقة O(n²) جديدة، فهرس مفقود) قبل أن تدمجها مراجعة الكود.
  • على كل دمج في main (تشغيل انتكاس كامل): حمل مستمر 3-5 دقائق عند RPS الميزانية المحددة، مقارنة إحصائية كاملة مع خط الأساس. الغرض: تحديث خط الأساس واكتشاف الانجراف الطفيف.
  • ليلاً أو أسبوعياً (اختبار نقع): 30-60 دقيقة عند حمل معتدل. الغرض: اكتشاف تسرب الذاكرة، واستنفاد مجموعة الاتصالات، وضغط GC التي تظهر فقط مع الوقت.

احجب عمليات الدمج فقط على اختبار الدخان السريع للـ PR وتشغيل الانتكاس على main. اختبارات النقع استعلامية — تُنبّه عند الفشل لكن لا تحجب الشحن، لأن الحجب على اختبار ليلي لمدة 45 دقيقة غير عملي تشغيلياً. أخطر المناوب عوضاً عن ذلك وتتبّعه كبند تحقيق P2.

فكرة أساسية — خطوط الأساس تعيش في git أو مخزن مُعلَّق الإصدار، وليس في ذاكرة أحد. عندما يقول فريق "تدهور الأداء تدريجياً لستة أشهر"، يكون ذلك دائماً تقريباً بسبب غياب خط أساس آلي. الاستثمار الأول هو السجل التاريخي. حتى تخزين ملخصات JSON من k6 كـ GitHub Actions artifacts يمنحك البيانات الخام لرسم الاتجاهات. الإعداد الصحيح يدفع مقاييس الملخص إلى Grafana أو Datadog مع تاغ git.sha، مما يجعل ربط ارتفاع زمن الاستجابة بإيداع محدد أمراً بسيطاً.